diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..fcc06dd
Binary files /dev/null and b/.DS_Store differ
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..54633e8
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,14 @@
+[flake8]
+max-line-length = 90
+extend-ignore = E501, E203, W503
+per-file-ignores =
+ settings*.py:E402,F403,F405
+exclude =
+ .git,
+ __pycache__,
+ .tox,
+ .eggs,
+ *.egg,
+ .venv,
+ venv,
+ .migrations
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..8d81fbc
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,33 @@
+name: Bookinn workflow
+
+on: [push]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: 3.11
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r ./backend/requirements.txt
+
+ - name: Test with black (check only)
+ run: |
+ python -m black . --check
+
+ - name: Test with flake8-isort (check only)
+ run: |
+ python -m isort . --check-only
+
+ - name: Test with flake8
+ run: |
+ flake8 . --count --statistics --show-source
diff --git a/.gitignore b/.gitignore
index b7faf40..4e5619c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -205,3 +205,8 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
+
+media/
+
+logs/
+*.log
diff --git a/README.md b/README.md
index decf89c..7d408e5 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,143 @@
-# bookinn
-Сервис бронирования номеров в отеле
+
+
+## BookInn
+
+API сервис для бронирования номеров
+
+[](https://github.com/dmsnback/bookinn/actions/workflows/main.yml)
+
+
+- [Описание](#Описание)
+- [Технологии](#Технологии)
+- [Основные ресурсы](#Ресурсы)
+- [Шаблон заполнения .env-файла](#Шаблон)
+- [Запуск проекта на локальной машине](#Запуск)
+- [Автор](#Автор)
+
+
+
+### Описание
+
+Сервис — REST API для бронирования номеров в отеле.
+Реализован на Django + Django REST Framework, используется Djoser для аутентификации (JWT), drf-spectacular для документации.
+
+```python
+Проект адаптирован для использования PostgreSQL и развёртывания в контейнерах Docker.
+```
+
+> [Вернуться в начало](#Начало)
+
+
+### Технологии
+
+[](https://www.python.org)
+
+[](https://www.django-rest-framework.org)
+[](https://www.docker.com)
+[](https://www.postgresql.org)
+[](https://github.com/features/actions)
+[](https://nginx.org/ru/)
+[](https://gunicorn.org)
+
+> [Вернуться в начало](#Начало)
+
+
+
+### Основные ресурсы
+
+- `/api/v1/rooms/` — CRUD и список номеров
+- `/api/v1/room_type/` — типы номеров
+- `/api/v1/bookings/` — бронирования (создание/обновление/просмотр)
+- `/api/v1/room_images/` — загрузка фото номера
+- `/api/v1/auth/` — djoser (регистрация, логин, токены)
+
+> [Вернуться в начало](#Начало)
+
+
+
+### Шаблон заполнения .env-файла, расположен по пути infra/.env
+
+> в settings.py указаны дефолтные значения для переменных из env-файла
+
+```python
+SECRET_KEY = 'Ваш секретный ключ'
+
+DB_ENGINE=django.db.backends.postgresql # указываем, что работаем с postgresql
+DB_NAME=postgres # имя базы данных
+POSTGRES_USER=postgres # логин для подключения к базе данных
+POSTGRES_PASSWORD=postgres # пароль для подключения к БД
+DB_HOST=db # название сервиса (контейнера)
+DB_PORT=5432 # порт для подключения к БД
+```
+
+> [Вернуться в начало](#Начало)
+
+
+
+### Запуск проекта на локальной машине
+
+- Склонируйте репозиторий
+
+```python
+git clone git@github.com:dmsnback/bookinn.git
+```
+
+- Переходим в папку с файлом ```docker-compose.yaml```
+
+```python
+cd infra
+```
+
+- Запускаем Docker контейнеры
+
+```python
+docker-compose up -d --build
+```
+
+- Выполняем миграции
+
+```python
+docker-compose exec backend python manage.py migrate
+```
+
+- Создаём суперюзера
+
+```python
+docker-compose exec backend python manage.py createsuperuser
+```
+
+- Собираем статику
+
+```python
+docker-compose exec backend python manage.py collectstatic --no-input
+```
+
+- Наполняем базу данных содержимым из файла ```rooms_fixture.json```:
+
+> В базу добавится 10 номеров.
+
+```python
+docker-compose exec backend python manage.py loaddata data/rooms_fixture.json
+```
+
+> __Проект станет доступен по адресу:__
+
+[http://localhost/api/v1/](http://localhost/api/v1/)
+
+> __Админка станет доступна по адресу:__
+
+[http://localhost/admin/](http://localhost/admin/)
+
+> __Документация к API будет доступна по адресу:__
+
+[http://localhost/api/docs/](http://localhost/api/docs/)
+
+> [Вернуться в начало](#Начало)
+
+
+
+### Автор
+
+- [Титенков Дмитрий](https://github.com/dmsnback)
+
+> [Вернуться в начало](#Начало)
diff --git a/backend/.DS_Store b/backend/.DS_Store
new file mode 100644
index 0000000..d434508
Binary files /dev/null and b/backend/.DS_Store differ
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..b34901d
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.11-slim
+LABEL maintainer="Dmitry Titenkov "
+LABEL version="1.0"
+LABEL description="API for booking hotel rooms"
+RUN mkdir /app
+COPY requirements.txt /app
+RUN pip3 install -r /app/requirements.txt --no-cache-dir -vvv
+COPY . /app
+WORKDIR /app
+CMD [ "gunicorn", "bookinn.wsgi:application", "--bind", "0.0.0.0:8000" ]
\ No newline at end of file
diff --git a/bookinn/bookinn/__init__.py b/backend/api/__init__.py
similarity index 100%
rename from bookinn/bookinn/__init__.py
rename to backend/api/__init__.py
diff --git a/backend/api/apps.py b/backend/api/apps.py
new file mode 100644
index 0000000..878e7d5
--- /dev/null
+++ b/backend/api/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "api"
diff --git a/bookinn/rooms/__init__.py b/backend/api/migrations/__init__.py
similarity index 100%
rename from bookinn/rooms/__init__.py
rename to backend/api/migrations/__init__.py
diff --git a/backend/api/urls.py b/backend/api/urls.py
new file mode 100644
index 0000000..6d7ebc3
--- /dev/null
+++ b/backend/api/urls.py
@@ -0,0 +1,8 @@
+from django.urls import include, path
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
+
+urlpatterns = [
+ path("v1/", include("api.v1.urls")),
+ path("schema/", SpectacularAPIView.as_view(), name="schema"),
+ path("docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
+]
diff --git a/bookinn/rooms/migrations/__init__.py b/backend/api/v1/__init__.py
similarity index 100%
rename from bookinn/rooms/migrations/__init__.py
rename to backend/api/v1/__init__.py
diff --git a/backend/api/v1/pagination.py b/backend/api/v1/pagination.py
new file mode 100644
index 0000000..2366180
--- /dev/null
+++ b/backend/api/v1/pagination.py
@@ -0,0 +1,21 @@
+from rest_framework.pagination import PageNumberPagination
+from rest_framework.response import Response
+
+
+class CustomPagination(PageNumberPagination):
+ def get_paginated_response(self, data):
+ ordering = self.request.query_params.get("ordering", None)
+ return Response(
+ {
+ "links": {
+ "next": self.get_next_link() or None,
+ "previous": self.get_previous_link() or None,
+ },
+ "count": self.page.paginator.count,
+ "ordering": ordering,
+ "current_page": self.page.number,
+ "total_pages": self.page.paginator.num_pages,
+ "page_sizes": self.get_page_size(self.request),
+ "results": data,
+ }
+ )
diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py
new file mode 100644
index 0000000..68d55ea
--- /dev/null
+++ b/backend/api/v1/permissions.py
@@ -0,0 +1,21 @@
+from rest_framework import permissions
+
+
+class IsOwnerOrAdmin(permissions.BasePermission):
+ """Разрешает доступ админу или владельцу бронирования."""
+
+ def has_object_permission(self, request, view, obj):
+ if request.user.is_staff or request.user.is_superuser:
+ return True
+ return obj.user == request.user
+
+
+class IsAdminOrReadOnly(permissions.BasePermission):
+ """Разрешаеет доступ админу с полными правами или юзеру только чтение."""
+
+ def has_permission(self, request, view):
+ return (
+ request.method in permissions.SAFE_METHODS
+ or request.user.is_staff
+ or request.user.is_superuser
+ )
diff --git a/backend/api/v1/serializers.py b/backend/api/v1/serializers.py
new file mode 100644
index 0000000..0f20d9e
--- /dev/null
+++ b/backend/api/v1/serializers.py
@@ -0,0 +1,229 @@
+from datetime import date
+
+from rest_framework import serializers
+from rest_framework.validators import UniqueTogetherValidator
+
+from rooms.models import Booking, Room, RoomImage, RoomType
+
+
+class RoomTypeSerializer(serializers.ModelSerializer):
+ """Сериализатор для типов номеров.
+ Позволяет Позволяет создавать, редактировать и удалять типы номеров.
+ """
+
+ class Meta:
+ model = RoomType
+ fields = ("id", "name", "description")
+ read_only_fields = ("id",)
+ validators = [
+ UniqueTogetherValidator(
+ queryset=RoomType.objects.all(),
+ fields=("name",),
+ message="Такой тип номера уже сущеествует.",
+ )
+ ]
+
+
+class RoomImageReadSerializer(serializers.ModelSerializer):
+ """Сериализатор для изображений.
+ Позволяет просматривать все загруженные изображения.
+ """
+
+ room = serializers.StringRelatedField(read_only=True)
+
+ class Meta:
+ model = RoomImage
+ fields = ("id", "room", "image")
+ read_only_fields = ("id",)
+
+
+class RoomImageWriteSerializer(serializers.ModelSerializer):
+ """Сериализатор для изображений.
+ Позволяет создавать, редактировать и удалять изображения.
+ """
+
+ class Meta:
+ model = RoomImage
+ fields = ("id", "room", "image", "uploaded_at")
+ read_only_fields = ("id", "uploaded_at")
+
+
+class RoomReadSerializer(serializers.ModelSerializer):
+ """Сериализатор для номеров.
+ Позволяет просматривать номера.
+ """
+
+ room_type = RoomTypeSerializer(read_only=True)
+ images = RoomImageReadSerializer(many=True, read_only=True)
+ is_available = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Room
+ fields = (
+ "id",
+ "title",
+ "description",
+ "room_type",
+ "is_available",
+ "price",
+ "capacity",
+ "number_of_rooms",
+ "images",
+ )
+
+ def get_is_available(self, obj):
+ if obj.is_available:
+ return "Номер доступен"
+ return "Номер недоступен"
+
+
+class RoomWriteSerializer(serializers.ModelSerializer):
+ """Сериализатор для номеров.
+ Позволяет создавать, редактировать и удалять номера.
+ """
+
+ room_type = RoomTypeSerializer(read_only=True)
+ room_type_id = serializers.PrimaryKeyRelatedField(
+ queryset=RoomType.objects.all(),
+ source="room_type",
+ write_only=True,
+ help_text="Выберите тип номера",
+ )
+ image = serializers.ImageField(
+ write_only=True,
+ required=False,
+ help_text="Добавьте фото для номера",
+ )
+ images = RoomImageWriteSerializer(many=True, read_only=True)
+ is_available = serializers.BooleanField(default=True)
+
+ class Meta:
+ model = Room
+ fields = (
+ "id",
+ "title",
+ "description",
+ "room_type",
+ "room_type_id",
+ "is_available",
+ "price",
+ "capacity",
+ "number_of_rooms",
+ "image",
+ "images",
+ )
+ read_only_fields = ("id",)
+ validators = [
+ UniqueTogetherValidator(
+ queryset=Room.objects.all(),
+ fields=("title", "room_type"),
+ message="Номер с таким названием уже существует",
+ )
+ ]
+
+ def validate_price(self, value):
+ """Проверка, что цена за номер больше 0"""
+ if value is not None and value <= 0:
+ raise serializers.ValidationError("Цена должна быть больше 0")
+ return value
+
+ def create(self, validated_data):
+ if "image" not in self.initial_data:
+ room = Room.objects.create(**validated_data)
+ return room
+ image = validated_data.pop("image", None)
+ room = Room.objects.create(**validated_data)
+ if image:
+ RoomImage.objects.create(room=room, image=image)
+ return room
+
+ def update(self, instance, validated_data):
+ image = validated_data.pop("image", None)
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ instance.save()
+ if image:
+ RoomImage.objects.create(room=instance, image=image)
+ return instance
+
+
+class BookingReadSerializer(serializers.ModelSerializer):
+ """Сериализатор для бронирований.
+ Позволяет просматривать брони номеров.
+ """
+
+ user = serializers.StringRelatedField(read_only=True)
+ room = serializers.StringRelatedField(read_only=True)
+ total_days = serializers.StringRelatedField(read_only=True)
+ total_price = serializers.StringRelatedField(read_only=True)
+
+ class Meta:
+ model = Booking
+ fields = (
+ "id",
+ "user",
+ "room",
+ "check_in",
+ "check_out",
+ "total_days",
+ "total_price",
+ "status",
+ "created_at",
+ )
+
+
+class BookingWriteSerializer(serializers.ModelSerializer):
+ """Сериализатор для бронирований.
+ Позволяет создавать, редактировать и удалять брони номеров.
+ """
+
+ user = serializers.HiddenField(default=serializers.CurrentUserDefault())
+ total_days = serializers.StringRelatedField(read_only=True)
+ total_price = serializers.StringRelatedField(read_only=True)
+
+ class Meta:
+ model = Booking
+ fields = (
+ "id",
+ "user",
+ "room",
+ "check_in",
+ "check_out",
+ "total_days",
+ "total_price",
+ "status",
+ "created_at",
+ )
+ read_only_fields = ("id", "created_at", "total_days", "total_price")
+
+ def validate(self, data):
+ check_in = data.get("check_in", getattr(self.instance, "check_in", None))
+ check_out = data.get("check_out", getattr(self.instance, "check_out", None))
+ room = data.get("room", getattr(self.instance, "room", None))
+ if check_in < date.today():
+ raise serializers.ValidationError(
+ "Дата заезда не должна быть раньше текущей даты"
+ )
+ if check_out < check_in:
+ raise serializers.ValidationError(
+ "Дата выселения должна быть позже даты заезда."
+ )
+ if not room.is_available:
+ raise serializers.ValidationError("Номер не доступен")
+ if not room.is_available_for_period(
+ check_in, check_out, exclude_booking=self.instance
+ ):
+ raise serializers.ValidationError("Номер уже забронирован на этот период")
+ return data
+
+ def create(self, validated_data):
+ validated_data["status"] = "booked"
+ return super().create(validated_data)
+
+ def update(self, instance, validated_data):
+ """При PATCH обязательно передать status, проблема пока не решена"""
+
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ instance.save()
+ return instance
diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py
new file mode 100644
index 0000000..b28ba67
--- /dev/null
+++ b/backend/api/v1/urls.py
@@ -0,0 +1,16 @@
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from api.v1.views import BookingViewSet, RoomImageViewSet, RoomTypeViewSet, RoomViewSet
+
+v1_router = DefaultRouter()
+v1_router.register(r"rooms", RoomViewSet, basename="rooms")
+v1_router.register(r"room_type", RoomTypeViewSet)
+v1_router.register(r"bookings", BookingViewSet, basename="bookings")
+v1_router.register(r"room_images", RoomImageViewSet)
+
+urlpatterns = [
+ path("", include(v1_router.urls)),
+ path("auth/", include("djoser.urls")),
+ path("auth/", include("djoser.urls.jwt")),
+]
diff --git a/backend/api/v1/views.py b/backend/api/v1/views.py
new file mode 100644
index 0000000..5022189
--- /dev/null
+++ b/backend/api/v1/views.py
@@ -0,0 +1,281 @@
+import logging
+
+from django_filters.rest_framework import DjangoFilterBackend
+from drf_spectacular.utils import extend_schema
+from rest_framework import filters, permissions, serializers, viewsets
+
+from api.v1.permissions import IsAdminOrReadOnly, IsOwnerOrAdmin
+from api.v1.serializers import (
+ BookingReadSerializer,
+ BookingWriteSerializer,
+ RoomImageReadSerializer,
+ RoomImageWriteSerializer,
+ RoomReadSerializer,
+ RoomTypeSerializer,
+ RoomWriteSerializer,
+)
+from rooms.models import Booking, Room, RoomImage, RoomType
+
+logger = logging.getLogger("rooms")
+
+
+@extend_schema(tags=["RoomType"], summary="Управление типами номеров")
+class RoomTypeViewSet(viewsets.ModelViewSet):
+ """Позволяет администратору создавать, изменять и удалять типы номеров,
+ например "Стандарт", "Люкс" и т.п.
+ Обычные пользователи могут только просматривать список.
+ """
+
+ queryset = RoomType.objects.all()
+ serializer_class = RoomTypeSerializer
+ permission_classes = (permissions.IsAdminUser,)
+ filter_backends = (filters.OrderingFilter,)
+ ordering_fields = ("name",)
+
+ def get_queryset(self):
+ try:
+ queryset = RoomType.objects.all()
+ logger.info(f"Запрошены типы номеров, всего найдено: {queryset.count()}")
+ return queryset
+ except Exception as error:
+ logger.debug(f"Ошибка при получении типов номеров: {error}", exc_info=True)
+ return RoomType.objects.none()
+
+ def perform_create(self, serializer):
+ try:
+ serializer.save()
+ logger.info(f"Создан новый тип номера: {serializer.instance.name}")
+ except Exception as error:
+ logger.error(f"Ошибка при создании типа номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось создать тип номера"})
+
+ def perform_update(self, serializer):
+ try:
+ serializer.save()
+ logger.info(f"Обновлеен тип номера: {serializer.instance.name}")
+ except Exception as error:
+ logger.error(f"Ошибка при обновлении типа номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось обновить тип номера"})
+
+ def perform_destroy(self, instance):
+ try:
+ room_type_name = instance.name
+ instance.delete()
+ logger.info(f"Удален тип номера: {room_type_name}")
+ except Exception as error:
+ logger.error(f"Ошибка при удалении типа номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось удалить тип номера"})
+
+
+@extend_schema(tags=["RoomImage"])
+class RoomImageViewSet(viewsets.ModelViewSet):
+ queryset = RoomImage.objects.all()
+ serializer_class = RoomImageWriteSerializer
+ permission_classes = (permissions.IsAdminUser,)
+
+ def get_serializer_class(self):
+ try:
+ if self.action in ["list", "retrieve"]:
+ logger.debug(
+ f"Используется RoomImageReadSerializer для действия {self.action}"
+ )
+ return RoomImageReadSerializer
+ logger.debug(
+ f"Используется RoomImageWriteSerializer для действия {self.action}"
+ )
+ return RoomImageWriteSerializer
+ except Exception as error:
+ logger.error(
+ f"Ошибка при выборе сериализатора для RoomImage: {error}", exc_info=True
+ )
+ return RoomImageWriteSerializer
+
+ def perform_create(self, serializer):
+ try:
+ serializer.save()
+ logger.info(
+ f"Загружена новая фотография id = {serializer.instance.id} для номера {serializer.instance.room}"
+ )
+ except Exception as error:
+ logger.error(f"Ошибка при загрузке фотогрвфии: {error}", exc_info=True)
+ raise serializers.ValidationError(
+ {"error": "Не удалось загрузить фотографию"}
+ )
+
+ def perform_update(self, serializer):
+ try:
+ serializer.save()
+ logger.info(f"Фотография обновлена id = {serializer.instance.id}")
+ except Exception as error:
+ logger.error(f"Ошибка при обновлении фотографии: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось обновить фотографию"})
+
+ def perform_destroy(self, instance):
+ try:
+ image_name = instance.image
+ instance.delete()
+ logger.info(f"Фотография удалена: {image_name}")
+ except Exception as error:
+ logger.error(f"Ошибка при удалении фотографии: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось удалить фотографию"})
+
+
+@extend_schema(
+ tags=["Room"],
+ summary="Работа с номерами",
+)
+class RoomViewSet(viewsets.ModelViewSet):
+ """Позволяет просматривать список доступных номеров,
+ а администраторам — добавлять и изменять номера.
+ Каждый номер связан с типом номера и может содержать фото.
+ """
+
+ serializer_class = RoomWriteSerializer
+ permission_classes = (permissions.AllowAny, IsAdminOrReadOnly)
+ filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
+ filterset_fields = ("room_type", "is_available", "number_of_rooms", "capacity")
+ search_fields = ("title", "description", "room_type__name")
+ ordering_fields = (
+ "title",
+ "room_type",
+ "is_available",
+ "price",
+ "number_of_rooms",
+ "capacity",
+ )
+
+ def get_serializer_class(self):
+ try:
+ if self.action in ["list", "retrieve"]:
+ logger.debug(
+ f"Используется RoomReadSerializer для действия {self.action}"
+ )
+ return RoomReadSerializer
+ logger.debug(f"Используется RoomWriteSerializer для действия {self.action}")
+ return RoomWriteSerializer
+ except Exception as error:
+ logger.error(
+ f"Ошибка при выборе сериализатора для Room: {error}", exc_info=True
+ )
+ return RoomWriteSerializer
+
+ def get_queryset(self):
+ try:
+ if self.request.user.is_staff or self.request.user.is_superuser:
+ queryset = Room.objects.all()
+ else:
+ queryset = Room.objects.filter(is_available=True)
+ logger.debug(f"Запрошены номера, всего найдено: {queryset.count()}")
+ return queryset
+ except Exception as error:
+ logger.error(f"Ошибка при получении списка номеров: {error}", exc_info=True)
+ return Room.objects.none()
+
+ def perform_create(self, serializer):
+ try:
+ serializer.save()
+ logger.info(f"Добавлен новый номер: {serializer.instance.title}")
+ except Exception as error:
+ logger.error(f"Ошибка при добавлении номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось добавить номер"})
+
+ def perform_update(self, serializer):
+ try:
+ serializer.save()
+ logger.info(f"Номер обновлен {serializer.instance.title}")
+ except Exception as error:
+ logger.error(f"Ошибка при обновлении номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось обновить номер"})
+
+ def perform_destroy(self, instance):
+ try:
+ room_name = instance.title
+ instance.delete()
+ logger.info(f"Номер {room_name} удален")
+ except Exception as error:
+ logger.error(f"Ошибка при удалении номера: {error}", exc_info=True)
+ raise serializers.ValidationError({"error": "Не удалось удалить номер"})
+
+
+@extend_schema(tags=["Booking"], summary="Работа с бронированиями")
+class BookingViewSet(viewsets.ModelViewSet):
+ """Пользователь может создавать, изменять и удалять свои бронирования.
+ Администратор может управлять всеми бронированиями.
+ """
+
+ queryset = Booking.objects.all()
+ serializer_class = BookingWriteSerializer
+
+ permission_classes = (permissions.IsAuthenticated, IsOwnerOrAdmin)
+ filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
+ filterset_fields = ("status", "room", "user")
+ search_fields = ("status",)
+ ordering_fields = ("status", "check_in", "check_out")
+
+ def get_serializer_class(self):
+ try:
+ if self.action in ["list", "retrieve"]:
+ logger.debug(
+ f"Используется BookingReadSerializer для действия {self.action}"
+ )
+ return BookingReadSerializer
+ logger.debug(
+ f"Используется BookingWriteSerializer для действия {self.action}"
+ )
+ return BookingWriteSerializer
+ except Exception as error:
+ logger.error(
+ f"Ошибка при выборе сериализатора для Booking: {error}", exc_info=True
+ )
+ return BookingWriteSerializer
+
+ def get_queryset(self):
+ try:
+ if self.request.user.is_staff or self.request.user.is_superuser:
+ queryset = Booking.objects.all()
+ else:
+ queryset = Booking.objects.filter(user=self.request.user)
+ logger.debug(
+ f"Запрошены бронирования пользователем: {self.request.user}, найдено бронирований: {queryset.count()}"
+ )
+ return queryset
+ except Exception as error:
+ logger.error(
+ f"Ошибка при получении списка бронирований: {error}", exc_info=True
+ )
+ return Booking.objects.none()
+
+ def perform_create(self, serializer):
+ try:
+ serializer.save(user=self.request.user)
+ logger.info(
+ f"Пользователь {self.request.user} создал бронирование для номера: {serializer.instance.room}"
+ )
+ except Exception as error:
+ logger.error(f"Не удалось создать бронирование: {error}", exc_info=True)
+ raise serializers.ValidationError(
+ {"error": "Не Удалось создать бронирование"}
+ )
+
+ def perform_update(self, serializer):
+ try:
+ serializer.save(user=self.request.user)
+ logger.info(
+ f"Пользователь {self.request.user} обновил бронирование для номера: {serializer.instance.room}"
+ )
+ except Exception as error:
+ logger.error(f"Ошибка при обновлении бронирования: {error}", exc_info=True)
+ raise serializers.ValidationError(
+ {"error": "Не удалось обновить бронирование"}
+ )
+
+ def perform_destroy(self, instance):
+ try:
+ booking_for_room = instance.room
+ instance.delete()
+ logger.info(f"Бронирование для номера {booking_for_room} удалено")
+ except Exception as error:
+ logger.error(f"Ошибка при удалении бронирования: {error}", exc_info=True)
+ raise serializers.ValidationError(
+ {"error": "Не удалось удалить бронирование"}
+ )
diff --git a/backend/bookinn/__init__.py b/backend/bookinn/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/bookinn/bookinn/asgi.py b/backend/bookinn/asgi.py
similarity index 82%
rename from bookinn/bookinn/asgi.py
rename to backend/bookinn/asgi.py
index 65dad96..1727a31 100644
--- a/bookinn/bookinn/asgi.py
+++ b/backend/bookinn/asgi.py
@@ -11,6 +11,6 @@
from django.core.asgi import get_asgi_application
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bookinn.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bookinn.settings")
application = get_asgi_application()
diff --git a/backend/bookinn/settings.py b/backend/bookinn/settings.py
new file mode 100644
index 0000000..429a55e
--- /dev/null
+++ b/backend/bookinn/settings.py
@@ -0,0 +1,220 @@
+import os
+from datetime import timedelta
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+LOG_DIR = Path(BASE_DIR / "logs")
+LOG_DIR.mkdir(exist_ok=True)
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "verbose": {
+ "format": "{asctime} [{levelname}] {name}:{filename}:{lineno} - {message}",
+ "style": "{",
+ },
+ },
+ "handlers": {
+ "console": {
+ "level": "DEBUG",
+ "class": "logging.StreamHandler",
+ "formatter": "verbose",
+ },
+ "info_file": {
+ "level": "INFO",
+ "class": "logging.handlers.RotatingFileHandler",
+ "filename": LOG_DIR / "django_info.log",
+ "formatter": "verbose",
+ "maxBytes": 1024 * 1024 * 5,
+ "backupCount": 5,
+ "encoding": "utf-8",
+ },
+ "error_file": {
+ "level": "ERROR",
+ "class": "logging.handlers.RotatingFileHandler",
+ "filename": LOG_DIR / "django_errors.log",
+ "formatter": "verbose",
+ "maxBytes": 1024 * 1024 * 2,
+ "backupCount": 10,
+ "encoding": "utf-8",
+ },
+ },
+ "loggers": {
+ "django": {
+ "handlers": ["console", "error_file"],
+ "level": "ERROR",
+ "propagate": True,
+ },
+ "rooms": {
+ "handlers": ["console", "info_file", "error_file"],
+ "level": "DEBUG",
+ "propagate": False,
+ },
+ },
+}
+
+load_dotenv()
+SECRET_KEY = os.getenv("SECRET_KEY", default="supersecretkey")
+print(SECRET_KEY)
+
+DEBUG = True
+
+ALLOWED_HOSTS = ["*"]
+
+CSRF_TRUSTED_ORIGINS = [
+ "http://localhost",
+ "http://127.0.0.1",
+]
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "rest_framework",
+ "drf_spectacular",
+ "djoser",
+ "rooms.apps.RoomsConfig",
+ "api.apps.ApiConfig",
+ "django_filters",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+
+ROOT_URLCONF = "bookinn.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "bookinn.wsgi.application"
+
+if os.getenv("DATABASE_TYPE") == "postgresql":
+ DATABASES = {
+ "default": {
+ "ENGINE": os.getenv("ENGINE", default="django.db.backends.postgresql"),
+ "NAME": os.getenv("POSTGRES_DB", default="postgres"),
+ "USER": os.getenv("POSTGRES_USER", default="postgres"),
+ "PASSWORD": os.getenv("POSTGRES_PASSWORD", default="postgres"),
+ "HOST": os.getenv("HOST", default="db"),
+ "PORT": os.getenv("PORT", default="5432"),
+ }
+ }
+else:
+ DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+ }
+
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+LANGUAGE_CODE = "ru-RU"
+
+DEFAULT_CHARSET = "utf-8"
+
+TIME_ZONE = "Europe/Moscow"
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+STATIC_URL = "/static/"
+STATIC_ROOT = os.path.join(BASE_DIR, "static")
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+REST_FRAMEWORK = {
+ "DEFAULT_RENDERER_CLASSES": [
+ "rest_framework.renderers.JSONRenderer",
+ "rest_framework.renderers.BrowsableAPIRenderer",
+ ],
+ "DEFAULT_PERMISSION_CLASSES": [
+ "rest_framework.permissions.IsAuthenticated",
+ ],
+ "DEFAULT_AUTHENTICATION_CLASSES": [
+ "rest_framework.authentication.SessionAuthentication",
+ "rest_framework_simplejwt.authentication.JWTAuthentication",
+ ],
+ "DEFAULT_PAGINATION_CLASS": "api.v1.pagination.CustomPagination",
+ "PAGE_SIZE": 5,
+ "DEFAULT_THROTTLE_CLASSES": [
+ "rest_framework.throttling.UserRateThrottle",
+ "rest_framework.throttling.AnonRateThrottle",
+ ],
+ "DEFAULT_THROTTLE_RATES": {"user": "10000/day", "anon": "1000/day"},
+ "DATE_INPUT_FORMATS": ["%d.%m.%Y", "%d-%m-%Y", "%Y.%m.%d", "%Y-%m-%d"],
+ "DATETIME_FORMAT": "%d.%m.%Y %H:%M",
+ "DATE_FORMAT": "%d.%m.%Y",
+ "TIME_FORMAT": "%H:%M",
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+}
+
+SPECTACULAR_SETTINGS = {
+ "TITLE": "BookInn API",
+ "DESCRIPTION": "API для бронирования номеров в отеле",
+ "VERSION": "1.0.0",
+ "CONTACT": {
+ "name": "Dmitry Titenkov",
+ "url": "https://github.com/dmsnback",
+ "Telegram": "@dmsn_dmsn",
+ },
+ "SERVE_INCLUDE_SCHEMA": False,
+ "SWAGGER_UI_SETTINGS": {
+ "filter": True,
+ },
+ "SECURITY": [{"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}}],
+ "COMPONENT_SPLIT_PATCH": True,
+ "COMPONENT_SPLIT_REQUEST": True,
+}
+
+SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": timedelta(days=1),
+ "AUTH_HEADER_TYPES": ("Bearer",),
+}
+
+MEDIA_URL = "/media/"
+MEDIA_ROOT = os.path.join(BASE_DIR, "media")
diff --git a/backend/bookinn/urls.py b/backend/bookinn/urls.py
new file mode 100644
index 0000000..d336485
--- /dev/null
+++ b/backend/bookinn/urls.py
@@ -0,0 +1,12 @@
+from django.conf import settings
+from django.conf.urls.static import static
+from django.contrib import admin
+from django.urls import include, path
+
+urlpatterns = [
+ path("admin/", admin.site.urls),
+ path("api/", include("api.urls")),
+]
+
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/bookinn/bookinn/wsgi.py b/backend/bookinn/wsgi.py
similarity index 82%
rename from bookinn/bookinn/wsgi.py
rename to backend/bookinn/wsgi.py
index e19eb6d..fd8fb9d 100644
--- a/bookinn/bookinn/wsgi.py
+++ b/backend/bookinn/wsgi.py
@@ -11,6 +11,6 @@
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bookinn.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bookinn.settings")
application = get_wsgi_application()
diff --git a/backend/data/rooms_fixture.json b/backend/data/rooms_fixture.json
new file mode 100644
index 0000000..00895bf
--- /dev/null
+++ b/backend/data/rooms_fixture.json
@@ -0,0 +1,175 @@
+[
+ {
+ "model": "rooms.roomtype",
+ "pk": 1,
+ "fields": {
+ "name": "Стандарт",
+ "description": "Базовый тип номера, подходит для короткого проживания."
+ }
+ },
+ {
+ "model": "rooms.roomtype",
+ "pk": 2,
+ "fields": {
+ "name": "Комфорт",
+ "description": "Чуть просторнее стандарта, улучшенная мебель."
+ }
+ },
+ {
+ "model": "rooms.roomtype",
+ "pk": 3,
+ "fields": {
+ "name": "Семейный",
+ "description": "Подходит для 3–4 гостей, включает две комнаты."
+ }
+ },
+ {
+ "model": "rooms.roomtype",
+ "pk": 4,
+ "fields": {
+ "name": "Апартаменты",
+ "description": "Самые просторные номера, включают кухню и гостиную."
+ }
+ },
+
+ {
+ "model": "rooms.room",
+ "pk": 1,
+ "fields": {
+ "title": "Стандарт 101",
+ "description": "Номер на одного гостя, базовое оснащение.",
+ "room_type": 1,
+ "is_available": true,
+ "price": "2200.00",
+ "capacity": 1,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 2,
+ "fields": {
+ "title": "Стандарт 102",
+ "description": "Светлый номер с рабочим столом.",
+ "room_type": 1,
+ "is_available": true,
+ "price": "2300.00",
+ "capacity": 1,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 3,
+ "fields": {
+ "title": "Стандарт 103",
+ "description": "Две раздельные кровати, удобно для сотрудников.",
+ "room_type": 1,
+ "is_available": true,
+ "price": "2600.00",
+ "capacity": 2,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 4,
+ "fields": {
+ "title": "Комфорт 201",
+ "description": "Увеличенная площадь, удобная зона отдыха.",
+ "room_type": 2,
+ "is_available": true,
+ "price": "3100.00",
+ "capacity": 2,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 5,
+ "fields": {
+ "title": "Комфорт 202",
+ "description": "Большое окно и удобное рабочее место.",
+ "room_type": 2,
+ "is_available": true,
+ "price": "3200.00",
+ "capacity": 2,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 6,
+ "fields": {
+ "title": "Комфорт 203",
+ "description": "Улучшенная мебель, большой шкаф.",
+ "room_type": 2,
+ "is_available": true,
+ "price": "3250.00",
+ "capacity": 2,
+ "number_of_rooms": 1,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 7,
+ "fields": {
+ "title": "Семейный 301",
+ "description": "Две комнаты, рассчитан на 3 гостей.",
+ "room_type": 3,
+ "is_available": true,
+ "price": "4200.00",
+ "capacity": 3,
+ "number_of_rooms": 2,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 8,
+ "fields": {
+ "title": "Семейный 302",
+ "description": "Две комнаты, большой диван и рабочая зона.",
+ "room_type": 3,
+ "is_available": true,
+ "price": "4400.00",
+ "capacity": 4,
+ "number_of_rooms": 2,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 9,
+ "fields": {
+ "title": "Апартаменты 401",
+ "description": "Гостиная, кухня, подходит для длительного проживания.",
+ "room_type": 4,
+ "is_available": true,
+ "price": "5500.00",
+ "capacity": 3,
+ "number_of_rooms": 2,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ },
+ {
+ "model": "rooms.room",
+ "pk": 10,
+ "fields": {
+ "title": "Апартаменты 402",
+ "description": "Апартаменты на верхнем этаже, три комнаты.",
+ "room_type": 4,
+ "is_available": true,
+ "price": "6000.00",
+ "capacity": 4,
+ "number_of_rooms": 3,
+ "created_at": "2025-01-01T00:00:00Z"
+ }
+ }
+]
\ No newline at end of file
diff --git a/backend/db.json b/backend/db.json
new file mode 100644
index 0000000..a02b25c
--- /dev/null
+++ b/backend/db.json
@@ -0,0 +1 @@
+[{"model": "admin.logentry", "pk": 1, "fields": {"action_time": "2025-11-05T17:50:15.017Z", "user": 1, "content_type": 9, "object_id": "13", "object_repr": "Booking object (13)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 2, "fields": {"action_time": "2025-11-05T17:53:08.601Z", "user": 1, "content_type": 4, "object_id": "2", "object_repr": "dmsn", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 3, "fields": {"action_time": "2025-11-05T17:53:44.454Z", "user": 1, "content_type": 4, "object_id": "2", "object_repr": "dmsn", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"First name\", \"Email address\"]}}]"}}, {"model": "admin.logentry", "pk": 4, "fields": {"action_time": "2025-11-05T17:54:15.275Z", "user": 1, "content_type": 9, "object_id": "14", "object_repr": "Booking object (14)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 5, "fields": {"action_time": "2025-11-06T10:49:24.935Z", "user": 1, "content_type": 9, "object_id": "15", "object_repr": "Booking object (15)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 6, "fields": {"action_time": "2025-11-06T18:08:30.924Z", "user": 1, "content_type": 9, "object_id": "13", "object_repr": "Booking object (13)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 7, "fields": {"action_time": "2025-11-06T18:08:30.927Z", "user": 1, "content_type": 9, "object_id": "12", "object_repr": "Booking object (12)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 8, "fields": {"action_time": "2025-11-06T18:08:30.929Z", "user": 1, "content_type": 9, "object_id": "11", "object_repr": "Booking object (11)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 9, "fields": {"action_time": "2025-11-06T18:08:30.931Z", "user": 1, "content_type": 9, "object_id": "10", "object_repr": "Booking object (10)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 10, "fields": {"action_time": "2025-11-06T18:08:30.932Z", "user": 1, "content_type": 9, "object_id": "9", "object_repr": "Booking object (9)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 11, "fields": {"action_time": "2025-11-06T18:08:30.934Z", "user": 1, "content_type": 9, "object_id": "8", "object_repr": "Booking object (8)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 12, "fields": {"action_time": "2025-11-06T18:08:30.936Z", "user": 1, "content_type": 9, "object_id": "7", "object_repr": "Booking object (7)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 13, "fields": {"action_time": "2025-11-06T18:08:30.939Z", "user": 1, "content_type": 9, "object_id": "6", "object_repr": "Booking object (6)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 14, "fields": {"action_time": "2025-11-06T18:08:30.940Z", "user": 1, "content_type": 9, "object_id": "1", "object_repr": "Booking object (1)", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 15, "fields": {"action_time": "2025-11-06T18:09:13.840Z", "user": 1, "content_type": 9, "object_id": "16", "object_repr": "Booking object (16)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 16, "fields": {"action_time": "2025-11-08T14:15:11.260Z", "user": 1, "content_type": 8, "object_id": "6", "object_repr": "Люкс номер - Люкс", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 17, "fields": {"action_time": "2025-11-08T14:15:11.262Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 2 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 18, "fields": {"action_time": "2025-11-08T14:15:11.263Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 19, "fields": {"action_time": "2025-11-08T14:15:11.264Z", "user": 1, "content_type": 8, "object_id": "9", "object_repr": "Тестовый Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 20, "fields": {"action_time": "2025-11-08T14:15:46.937Z", "user": 1, "content_type": 8, "object_id": "6", "object_repr": "Люкс номер - Люкс", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 21, "fields": {"action_time": "2025-11-08T14:15:46.938Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 2 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 22, "fields": {"action_time": "2025-11-08T14:15:46.939Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 23, "fields": {"action_time": "2025-11-08T14:15:46.940Z", "user": 1, "content_type": 8, "object_id": "3", "object_repr": "Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 24, "fields": {"action_time": "2025-11-08T14:15:46.942Z", "user": 1, "content_type": 8, "object_id": "9", "object_repr": "Тестовый Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 25, "fields": {"action_time": "2025-11-08T14:22:10.904Z", "user": 1, "content_type": 8, "object_id": "6", "object_repr": "Люкс номер - Люкс", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 26, "fields": {"action_time": "2025-11-08T14:22:10.905Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 2 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 27, "fields": {"action_time": "2025-11-08T14:22:10.907Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 28, "fields": {"action_time": "2025-11-08T14:22:10.908Z", "user": 1, "content_type": 8, "object_id": "3", "object_repr": "Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 29, "fields": {"action_time": "2025-11-08T14:22:10.909Z", "user": 1, "content_type": 8, "object_id": "9", "object_repr": "Тестовый Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 30, "fields": {"action_time": "2025-11-08T14:25:34.489Z", "user": 1, "content_type": 9, "object_id": "17", "object_repr": "Booking object (17)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 31, "fields": {"action_time": "2025-11-08T14:25:57.527Z", "user": 1, "content_type": 8, "object_id": "6", "object_repr": "Люкс номер - Люкс", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 32, "fields": {"action_time": "2025-11-08T14:25:57.529Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 2 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 33, "fields": {"action_time": "2025-11-08T14:25:57.530Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 34, "fields": {"action_time": "2025-11-08T14:25:57.531Z", "user": 1, "content_type": 8, "object_id": "3", "object_repr": "Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 35, "fields": {"action_time": "2025-11-08T14:25:57.532Z", "user": 1, "content_type": 8, "object_id": "9", "object_repr": "Тестовый Обычный номер - Стандарт", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 36, "fields": {"action_time": "2025-11-08T14:26:19.258Z", "user": 1, "content_type": 9, "object_id": "18", "object_repr": "Booking object (18)", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 37, "fields": {"action_time": "2025-11-10T11:19:44.121Z", "user": 1, "content_type": 10, "object_id": "1", "object_repr": "Фотография для: Люкс номер 23", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 38, "fields": {"action_time": "2025-11-10T11:30:49.888Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 23\"}}, {\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 23\"}}, {\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 23\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 39, "fields": {"action_time": "2025-11-10T11:32:45.353Z", "user": 1, "content_type": 8, "object_id": "8", "object_repr": "Люкс номер 23 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 40, "fields": {"action_time": "2025-11-10T11:45:15.626Z", "user": 1, "content_type": 10, "object_id": "2", "object_repr": "Фотография для: Люкс номер 23", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 41, "fields": {"action_time": "2025-11-10T11:45:15.634Z", "user": 1, "content_type": 10, "object_id": "1", "object_repr": "Фотография для: Люкс номер 23", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 42, "fields": {"action_time": "2025-11-12T18:50:53.694Z", "user": 1, "content_type": 10, "object_id": "10", "object_repr": "Фотография для: Обычный номер 1000", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 43, "fields": {"action_time": "2025-11-12T18:50:53.703Z", "user": 1, "content_type": 10, "object_id": "7", "object_repr": "Фотография для: Room witth image 23", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 44, "fields": {"action_time": "2025-11-12T18:50:53.705Z", "user": 1, "content_type": 10, "object_id": "6", "object_repr": "Фотография для: Room witth image 2", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 45, "fields": {"action_time": "2025-11-12T18:50:53.707Z", "user": 1, "content_type": 10, "object_id": "5", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 46, "fields": {"action_time": "2025-11-12T18:50:53.709Z", "user": 1, "content_type": 10, "object_id": "4", "object_repr": "Фотография для: Обычный номер", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 47, "fields": {"action_time": "2025-11-12T18:50:53.711Z", "user": 1, "content_type": 10, "object_id": "3", "object_repr": "Фотография для: Люкс номер 23", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 48, "fields": {"action_time": "2025-11-12T18:51:13.342Z", "user": 1, "content_type": 10, "object_id": "11", "object_repr": "Фотография для: Room witth image", "action_flag": 1, "change_message": "[{\"added\": {}}]"}}, {"model": "admin.logentry", "pk": 49, "fields": {"action_time": "2025-11-12T18:51:57.146Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 50, "fields": {"action_time": "2025-11-12T18:52:14.615Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"\\u0421\\u0442\\u0430\\u0442\\u0443\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\"]}}]"}}, {"model": "admin.logentry", "pk": 51, "fields": {"action_time": "2025-11-12T19:06:16.205Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\"}}]"}}, {"model": "admin.logentry", "pk": 52, "fields": {"action_time": "2025-11-12T19:06:58.160Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 53, "fields": {"action_time": "2025-11-12T19:07:25.900Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 54, "fields": {"action_time": "2025-11-12T19:07:48.835Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 55, "fields": {"action_time": "2025-11-12T19:11:06.910Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 56, "fields": {"action_time": "2025-11-12T19:14:24.397Z", "user": 1, "content_type": 8, "object_id": "7", "object_repr": "Люкс номер 25 - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u041b\\u044e\\u043a\\u0441 \\u043d\\u043e\\u043c\\u0435\\u0440 25\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 57, "fields": {"action_time": "2025-11-12T19:31:58.442Z", "user": 1, "content_type": 8, "object_id": "11", "object_repr": "Тестовый Обычный номер с изображением - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u0422\\u0435\\u0441\\u0442\\u043e\\u0432\\u044b\\u0439 \\u041e\\u0431\\u044b\\u0447\\u043d\\u044b\\u0439 \\u043d\\u043e\\u043c\\u0435\\u0440 \\u0441 \\u0438\\u0437\\u043e\\u0431\\u0440\\u0430\\u0436\\u0435\\u043d\\u0438\\u0435\\u043c\"}}]"}}, {"model": "admin.logentry", "pk": 58, "fields": {"action_time": "2025-11-12T19:34:25.940Z", "user": 1, "content_type": 8, "object_id": "11", "object_repr": "Тестовый Обычный номер с изображением - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: \\u0422\\u0435\\u0441\\u0442\\u043e\\u0432\\u044b\\u0439 \\u041e\\u0431\\u044b\\u0447\\u043d\\u044b\\u0439 \\u043d\\u043e\\u043c\\u0435\\u0440 \\u0441 \\u0438\\u0437\\u043e\\u0431\\u0440\\u0430\\u0436\\u0435\\u043d\\u0438\\u0435\\u043c\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 59, "fields": {"action_time": "2025-11-12T19:35:29.984Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"changed\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\", \"fields\": [\"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f\"]}}]"}}, {"model": "admin.logentry", "pk": 60, "fields": {"action_time": "2025-11-12T19:37:12.487Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}]"}}, {"model": "admin.logentry", "pk": 61, "fields": {"action_time": "2025-11-12T19:38:52.726Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}, {\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}]"}}, {"model": "admin.logentry", "pk": 62, "fields": {"action_time": "2025-11-12T19:39:58.117Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}]"}}, {"model": "admin.logentry", "pk": 63, "fields": {"action_time": "2025-11-12T19:43:22.931Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}, {\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}]"}}, {"model": "admin.logentry", "pk": 64, "fields": {"action_time": "2025-11-12T19:44:40.283Z", "user": 1, "content_type": 8, "object_id": "15", "object_repr": "Room witth image - Standart", "action_flag": 2, "change_message": "[{\"added\": {\"name\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u043d\\u043e\\u043c\\u0435\\u0440\\u0430\", \"object\": \"\\u0424\\u043e\\u0442\\u043e\\u0433\\u0440\\u0430\\u0444\\u0438\\u044f \\u0434\\u043b\\u044f: Room witth image\"}}]"}}, {"model": "admin.logentry", "pk": 65, "fields": {"action_time": "2025-11-12T19:45:08.570Z", "user": 1, "content_type": 10, "object_id": "19", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 66, "fields": {"action_time": "2025-11-12T19:45:08.577Z", "user": 1, "content_type": 10, "object_id": "18", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 67, "fields": {"action_time": "2025-11-12T19:45:08.580Z", "user": 1, "content_type": 10, "object_id": "17", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 68, "fields": {"action_time": "2025-11-12T19:45:08.582Z", "user": 1, "content_type": 10, "object_id": "16", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 69, "fields": {"action_time": "2025-11-12T19:45:08.584Z", "user": 1, "content_type": 10, "object_id": "15", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 70, "fields": {"action_time": "2025-11-12T19:45:08.586Z", "user": 1, "content_type": 10, "object_id": "14", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 71, "fields": {"action_time": "2025-11-12T19:45:08.588Z", "user": 1, "content_type": 10, "object_id": "13", "object_repr": "Фотография для: Тестовый Обычный номер с изображением", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 72, "fields": {"action_time": "2025-11-12T19:45:08.590Z", "user": 1, "content_type": 10, "object_id": "12", "object_repr": "Фотография для: Люкс номер 25", "action_flag": 3, "change_message": ""}}, {"model": "admin.logentry", "pk": 73, "fields": {"action_time": "2025-11-12T19:45:08.592Z", "user": 1, "content_type": 10, "object_id": "11", "object_repr": "Фотография для: Room witth image", "action_flag": 3, "change_message": ""}}, {"model": "auth.permission", "pk": 1, "fields": {"name": "Can add log entry", "content_type": 1, "codename": "add_logentry"}}, {"model": "auth.permission", "pk": 2, "fields": {"name": "Can change log entry", "content_type": 1, "codename": "change_logentry"}}, {"model": "auth.permission", "pk": 3, "fields": {"name": "Can delete log entry", "content_type": 1, "codename": "delete_logentry"}}, {"model": "auth.permission", "pk": 4, "fields": {"name": "Can view log entry", "content_type": 1, "codename": "view_logentry"}}, {"model": "auth.permission", "pk": 5, "fields": {"name": "Can add permission", "content_type": 2, "codename": "add_permission"}}, {"model": "auth.permission", "pk": 6, "fields": {"name": "Can change permission", "content_type": 2, "codename": "change_permission"}}, {"model": "auth.permission", "pk": 7, "fields": {"name": "Can delete permission", "content_type": 2, "codename": "delete_permission"}}, {"model": "auth.permission", "pk": 8, "fields": {"name": "Can view permission", "content_type": 2, "codename": "view_permission"}}, {"model": "auth.permission", "pk": 9, "fields": {"name": "Can add group", "content_type": 3, "codename": "add_group"}}, {"model": "auth.permission", "pk": 10, "fields": {"name": "Can change group", "content_type": 3, "codename": "change_group"}}, {"model": "auth.permission", "pk": 11, "fields": {"name": "Can delete group", "content_type": 3, "codename": "delete_group"}}, {"model": "auth.permission", "pk": 12, "fields": {"name": "Can view group", "content_type": 3, "codename": "view_group"}}, {"model": "auth.permission", "pk": 13, "fields": {"name": "Can add user", "content_type": 4, "codename": "add_user"}}, {"model": "auth.permission", "pk": 14, "fields": {"name": "Can change user", "content_type": 4, "codename": "change_user"}}, {"model": "auth.permission", "pk": 15, "fields": {"name": "Can delete user", "content_type": 4, "codename": "delete_user"}}, {"model": "auth.permission", "pk": 16, "fields": {"name": "Can view user", "content_type": 4, "codename": "view_user"}}, {"model": "auth.permission", "pk": 17, "fields": {"name": "Can add content type", "content_type": 5, "codename": "add_contenttype"}}, {"model": "auth.permission", "pk": 18, "fields": {"name": "Can change content type", "content_type": 5, "codename": "change_contenttype"}}, {"model": "auth.permission", "pk": 19, "fields": {"name": "Can delete content type", "content_type": 5, "codename": "delete_contenttype"}}, {"model": "auth.permission", "pk": 20, "fields": {"name": "Can view content type", "content_type": 5, "codename": "view_contenttype"}}, {"model": "auth.permission", "pk": 21, "fields": {"name": "Can add session", "content_type": 6, "codename": "add_session"}}, {"model": "auth.permission", "pk": 22, "fields": {"name": "Can change session", "content_type": 6, "codename": "change_session"}}, {"model": "auth.permission", "pk": 23, "fields": {"name": "Can delete session", "content_type": 6, "codename": "delete_session"}}, {"model": "auth.permission", "pk": 24, "fields": {"name": "Can view session", "content_type": 6, "codename": "view_session"}}, {"model": "auth.permission", "pk": 25, "fields": {"name": "Can add Тип номера", "content_type": 7, "codename": "add_roomtype"}}, {"model": "auth.permission", "pk": 26, "fields": {"name": "Can change Тип номера", "content_type": 7, "codename": "change_roomtype"}}, {"model": "auth.permission", "pk": 27, "fields": {"name": "Can delete Тип номера", "content_type": 7, "codename": "delete_roomtype"}}, {"model": "auth.permission", "pk": 28, "fields": {"name": "Can view Тип номера", "content_type": 7, "codename": "view_roomtype"}}, {"model": "auth.permission", "pk": 29, "fields": {"name": "Can add Номер", "content_type": 8, "codename": "add_room"}}, {"model": "auth.permission", "pk": 30, "fields": {"name": "Can change Номер", "content_type": 8, "codename": "change_room"}}, {"model": "auth.permission", "pk": 31, "fields": {"name": "Can delete Номер", "content_type": 8, "codename": "delete_room"}}, {"model": "auth.permission", "pk": 32, "fields": {"name": "Can view Номер", "content_type": 8, "codename": "view_room"}}, {"model": "auth.permission", "pk": 33, "fields": {"name": "Can add Бронирование", "content_type": 9, "codename": "add_booking"}}, {"model": "auth.permission", "pk": 34, "fields": {"name": "Can change Бронирование", "content_type": 9, "codename": "change_booking"}}, {"model": "auth.permission", "pk": 35, "fields": {"name": "Can delete Бронирование", "content_type": 9, "codename": "delete_booking"}}, {"model": "auth.permission", "pk": 36, "fields": {"name": "Can view Бронирование", "content_type": 9, "codename": "view_booking"}}, {"model": "auth.permission", "pk": 37, "fields": {"name": "Can add Фотография номера", "content_type": 10, "codename": "add_roomimage"}}, {"model": "auth.permission", "pk": 38, "fields": {"name": "Can change Фотография номера", "content_type": 10, "codename": "change_roomimage"}}, {"model": "auth.permission", "pk": 39, "fields": {"name": "Can delete Фотография номера", "content_type": 10, "codename": "delete_roomimage"}}, {"model": "auth.permission", "pk": 40, "fields": {"name": "Can view Фотография номера", "content_type": 10, "codename": "view_roomimage"}}, {"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$600000$esxqgXHjmxrzUnioisyEXs$xy8L6Q2DfJZ6o67Cg3pSmOPvkST8SFyTthfO+fTMbd4=", "last_login": "2025-11-11T13:02:32.767Z", "is_superuser": true, "username": "admin", "first_name": "", "last_name": "", "email": "admin@mail.com", "is_staff": true, "is_active": true, "date_joined": "2025-10-28T19:38:46.367Z", "groups": [], "user_permissions": []}}, {"model": "auth.user", "pk": 2, "fields": {"password": "pbkdf2_sha256$600000$ehxVMvSB2vvELHmnlkiVZU$UuLRtx+D/S1OLDISacaMACtfu+FzrZzQa0UoZUIFNNM=", "last_login": null, "is_superuser": false, "username": "dmsn", "first_name": "Дмитрий", "last_name": "", "email": "lt200711@yandex.ru", "is_staff": false, "is_active": true, "date_joined": "2025-11-05T17:53:08Z", "groups": [], "user_permissions": []}}, {"model": "auth.user", "pk": 3, "fields": {"password": "pbkdf2_sha256$600000$LKNHrxeiiNMmiWxgqt5JsS$5nfKbJ0JUM9aFS+fDZh3a4jyO7lesiZql3pq8939rfE=", "last_login": null, "is_superuser": false, "username": "Taisia", "first_name": "", "last_name": "", "email": "", "is_staff": false, "is_active": true, "date_joined": "2025-11-08T12:10:20.648Z", "groups": [], "user_permissions": []}}, {"model": "contenttypes.contenttype", "pk": 1, "fields": {"app_label": "admin", "model": "logentry"}}, {"model": "contenttypes.contenttype", "pk": 2, "fields": {"app_label": "auth", "model": "permission"}}, {"model": "contenttypes.contenttype", "pk": 3, "fields": {"app_label": "auth", "model": "group"}}, {"model": "contenttypes.contenttype", "pk": 4, "fields": {"app_label": "auth", "model": "user"}}, {"model": "contenttypes.contenttype", "pk": 5, "fields": {"app_label": "contenttypes", "model": "contenttype"}}, {"model": "contenttypes.contenttype", "pk": 6, "fields": {"app_label": "sessions", "model": "session"}}, {"model": "contenttypes.contenttype", "pk": 7, "fields": {"app_label": "rooms", "model": "roomtype"}}, {"model": "contenttypes.contenttype", "pk": 8, "fields": {"app_label": "rooms", "model": "room"}}, {"model": "contenttypes.contenttype", "pk": 9, "fields": {"app_label": "rooms", "model": "booking"}}, {"model": "contenttypes.contenttype", "pk": 10, "fields": {"app_label": "rooms", "model": "roomimage"}}, {"model": "sessions.session", "pk": "hmdv419n4wa1fxwecyeyidcdwp7exam4", "fields": {"session_data": ".eJxVjDkOwjAUBe_iGlleEi-U9JzB-ouNA8iR4qRC3B0ipYD2zcx7iQTbWtPW85ImFmehxel3Q6BHbjvgO7TbLGlu6zKh3BV50C6vM-fn5XD_Dir0-q2dIXbWYoheIxTWHJmGMcNAOTIb1MQmOtahuAKggvIKRg8Y0BbrtXh_AAhFOLw:1vIo12:MffgUPgiui_NmhsOWPO24fNgODm5_wLQWodrK0ABTWI", "expire_date": "2025-11-25T13:02:32.771Z"}}, {"model": "rooms.roomtype", "pk": 2, "fields": {"name": "Standart", "description": "Обычный номер"}}, {"model": "rooms.roomtype", "pk": 3, "fields": {"name": "Luxury", "description": ""}}, {"model": "rooms.roomtype", "pk": 4, "fields": {"name": "President", "description": ""}}, {"model": "rooms.room", "pk": 3, "fields": {"title": "Обычный номер", "description": "Обычный Номер", "room_type": 2, "is_available": true, "price": "100.00", "capacity": 2, "number_of_rooms": 1, "created_at": "2025-10-30T06:39:20.020Z"}}, {"model": "rooms.room", "pk": 7, "fields": {"title": "Люкс номер 25", "description": "", "room_type": 2, "is_available": true, "price": "100.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-10-30T17:42:59.798Z"}}, {"model": "rooms.room", "pk": 9, "fields": {"title": "Тестовый Обычный номер", "description": "Тестовый Обычный номер", "room_type": 2, "is_available": true, "price": "200.00", "capacity": 2, "number_of_rooms": 1, "created_at": "2025-11-08T12:32:29.755Z"}}, {"model": "rooms.room", "pk": 10, "fields": {"title": "Люкс номер 250", "description": "", "room_type": 2, "is_available": true, "price": "100.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-08T14:58:25.801Z"}}, {"model": "rooms.room", "pk": 11, "fields": {"title": "Тестовый Обычный номер с изображением", "description": "Тестовый Обычный номер с изображением", "room_type": 2, "is_available": true, "price": "200.00", "capacity": 2, "number_of_rooms": 1, "created_at": "2025-11-10T17:55:19.695Z"}}, {"model": "rooms.room", "pk": 12, "fields": {"title": "Тестовый Обычный номер с изображением 2", "description": "Тестовый Обычный номер с изображением", "room_type": 2, "is_available": true, "price": "200.00", "capacity": 2, "number_of_rooms": 1, "created_at": "2025-11-10T17:56:34.080Z"}}, {"model": "rooms.room", "pk": 13, "fields": {"title": "Номер с картинккой", "description": "", "room_type": 2, "is_available": false, "price": "500.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-10T17:59:37.458Z"}}, {"model": "rooms.room", "pk": 14, "fields": {"title": "Номер с картинккой 2", "description": "", "room_type": 2, "is_available": false, "price": "500.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-10T18:01:44.217Z"}}, {"model": "rooms.room", "pk": 15, "fields": {"title": "Room witth image", "description": "", "room_type": 2, "is_available": true, "price": "500.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-10T18:17:32.382Z"}}, {"model": "rooms.room", "pk": 16, "fields": {"title": "Room witth image 2", "description": "", "room_type": 2, "is_available": false, "price": "500.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-10T18:45:35.859Z"}}, {"model": "rooms.room", "pk": 17, "fields": {"title": "Room witth image 2", "description": "", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 3, "created_at": "2025-11-11T20:01:09.294Z"}}, {"model": "rooms.room", "pk": 18, "fields": {"title": "Room witth image 23", "description": "", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 3, "created_at": "2025-11-11T20:04:04.373Z"}}, {"model": "rooms.room", "pk": 20, "fields": {"title": "Обычный номер 1000", "description": "Тестовый Обычный номер", "room_type": 2, "is_available": true, "price": "200.00", "capacity": 2, "number_of_rooms": 1, "created_at": "2025-11-11T20:30:51.723Z"}}, {"model": "rooms.room", "pk": 21, "fields": {"title": "Ещё один обычный номер", "description": "описание Номеера", "room_type": 2, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T09:55:59.215Z"}}, {"model": "rooms.room", "pk": 22, "fields": {"title": "Ещё один обычный номер 2", "description": "описание Номеера", "room_type": 2, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:39:43.838Z"}}, {"model": "rooms.room", "pk": 23, "fields": {"title": "Ещё один обычный номер 3", "description": "описание Номеера", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:40:10.395Z"}}, {"model": "rooms.room", "pk": 24, "fields": {"title": "Ещё один обычный номер 4", "description": "описание Номеера", "room_type": 3, "is_available": true, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:43:47.753Z"}}, {"model": "rooms.room", "pk": 25, "fields": {"title": "Ещё один обычный номер 5", "description": "описание Номеера", "room_type": 3, "is_available": true, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:44:10.991Z"}}, {"model": "rooms.room", "pk": 26, "fields": {"title": "Ещё один обычный номер 6", "description": "описание Номеера", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:44:49.193Z"}}, {"model": "rooms.room", "pk": 27, "fields": {"title": "Ещё один обычный номер 7", "description": "описание Номеера", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T10:45:00.531Z"}}, {"model": "rooms.room", "pk": 28, "fields": {"title": "Ещё один обычный номер 8", "description": "описание Номеера", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T13:18:42.932Z"}}, {"model": "rooms.room", "pk": 29, "fields": {"title": "Ещё один обычный номер 9", "description": "описание Номеера", "room_type": 3, "is_available": false, "price": "1000.00", "capacity": 1, "number_of_rooms": 1, "created_at": "2025-11-13T13:20:14.231Z"}}, {"model": "rooms.roomimage", "pk": 20, "fields": {"room": 15, "image": "room_images/room-witth-image/room-witth-image_2025-11-12_22-44_8.jpg", "uploaded_at": "2025-11-12T19:44:40.281Z"}}, {"model": "rooms.roomimage", "pk": 21, "fields": {"room": 21, "image": "room_images/eshche-odin-obychnyi-nomer/eshche-odin-obychnyi-nomer_2025-11-13_12-55_1.jpeg", "uploaded_at": "2025-11-13T09:55:59.225Z"}}, {"model": "rooms.roomimage", "pk": 22, "fields": {"room": 22, "image": "room_images/eshche-odin-obychnyi-nomer-2/eshche-odin-obychnyi-nomer-2_2025-11-13_13-39_1.jpeg", "uploaded_at": "2025-11-13T10:39:43.847Z"}}, {"model": "rooms.roomimage", "pk": 23, "fields": {"room": 25, "image": "room_images/eshche-odin-obychnyi-nomer-5/eshche-odin-obychnyi-nomer-5_2025-11-13_13-44_1.jpg", "uploaded_at": "2025-11-13T10:44:11.021Z"}}, {"model": "rooms.roomimage", "pk": 24, "fields": {"room": 26, "image": "room_images/eshche-odin-obychnyi-nomer-6/eshche-odin-obychnyi-nomer-6_2025-11-13_13-44_1.jpg", "uploaded_at": "2025-11-13T10:44:49.215Z"}}, {"model": "rooms.booking", "pk": 19, "fields": {"user": 1, "room": 7, "check_in": "2025-11-09", "check_out": "2025-11-10", "status": "booked", "created_at": "2025-11-08T14:33:03.896Z"}}, {"model": "rooms.booking", "pk": 21, "fields": {"user": 1, "room": 10, "check_in": "2025-11-12", "check_out": "2025-11-13", "status": "booked", "created_at": "2025-11-10T18:52:53.869Z"}}, {"model": "rooms.booking", "pk": 27, "fields": {"user": 1, "room": 20, "check_in": "2025-11-15", "check_out": "2025-11-16", "status": "booked", "created_at": "2025-11-13T09:11:54.263Z"}}]
\ No newline at end of file
diff --git a/bookinn/manage.py b/backend/manage.py
similarity index 85%
rename from bookinn/manage.py
rename to backend/manage.py
index fbb351f..ae94224 100755
--- a/bookinn/manage.py
+++ b/backend/manage.py
@@ -6,7 +6,7 @@
def main():
"""Run administrative tasks."""
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bookinn.settings')
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bookinn.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +18,5 @@ def main():
execute_from_command_line(sys.argv)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..958ff67
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,56 @@
+asgiref==3.10.0
+attrs==25.4.0
+black==25.11.0
+certifi==2025.10.5
+cffi==2.0.0
+charset-normalizer==3.4.4
+click==8.1.8
+cryptography==46.0.3
+defusedxml==0.7.1
+Django==4.2.25
+django-filter==25.1
+djangorestframework==3.16.1
+djangorestframework_simplejwt==5.5.1
+djoser==2.3.3
+drf-spectacular==0.29.0
+flake8==7.3.0
+flake8-isort==6.1.2
+gunicorn==23.0.0
+idna==3.11
+importlib_metadata==8.7.0
+inflection==0.5.1
+isort==6.1.0
+jsonschema==4.25.1
+jsonschema-specifications==2025.9.1
+Markdown==3.9
+mccabe==0.7.0
+mypy_extensions==1.1.0
+oauthlib==3.3.1
+packaging==25.0
+pathspec==0.12.1
+pillow==11.3.0
+platformdirs==4.4.0
+psycopg2-binary==2.9.11
+pycodestyle==2.14.0
+pycparser==2.23
+pyflakes==3.4.0
+PyJWT==2.10.1
+python-dotenv==1.2.1
+python-slugify==8.0.4
+python3-openid==3.2.0
+pytokens==0.3.0
+pytz==2025.2
+PyYAML==6.0.3
+referencing==0.36.2
+requests==2.32.5
+requests-oauthlib==2.0.0
+rpds-py==0.27.1
+social-auth-app-django==5.4.3
+social-auth-core==4.7.0
+sqlparse==0.5.3
+text-unidecode==1.3
+tomli==2.3.0
+typing_extensions==4.15.0
+uritemplate==4.2.0
+urllib3==2.5.0
+zipp==3.23.0
diff --git a/backend/rooms/__init__.py b/backend/rooms/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/rooms/admin.py b/backend/rooms/admin.py
new file mode 100644
index 0000000..da3e6c4
--- /dev/null
+++ b/backend/rooms/admin.py
@@ -0,0 +1,98 @@
+from django.contrib import admin
+
+from rooms.models import Booking, Room, RoomImage, RoomType
+
+
+class RoomInline(admin.TabularInline):
+ model = Room
+ extra = 0
+
+
+class RoomImageInline(admin.TabularInline):
+ model = RoomImage
+ extra = 1
+ fields = ("image_tag", "image")
+ readonly_fields = ("image_tag",)
+
+
+class BookingAdmin(admin.ModelAdmin):
+ list_display = (
+ "user",
+ "room",
+ "check_in",
+ "check_out",
+ "total_days_display",
+ "total_price_display",
+ "status",
+ "created_at",
+ )
+ list_editable = ("check_in", "check_out", "status")
+ search_fields = ("status", "room", "user")
+ list_filter = ("status", "room", "user")
+ list_display_links = ("room",)
+ empty_value_display = "Не задано"
+
+ def total_days_display(self, obj):
+ return obj.total_days
+
+ total_days_display.short_description = "Количество дней бронирования"
+
+ def total_price_display(self, obj):
+ return obj.total_price
+
+ total_price_display.short_description = "Полная стоймость бронирования"
+
+
+class RoomAdmin(admin.ModelAdmin):
+ inlines = (RoomImageInline,)
+ list_display = (
+ "title",
+ "room_type",
+ "is_available",
+ "price",
+ "number_of_rooms",
+ "capacity",
+ "created_at",
+ )
+ list_editable = ("room_type", "is_available", "price")
+ search_fields = (
+ "title",
+ "room_type",
+ "is_available",
+ "number_of_rooms",
+ "capacity",
+ )
+ list_filter = (
+ "title",
+ "room_type",
+ "price",
+ "is_available",
+ "number_of_rooms",
+ "capacity",
+ )
+ list_display_links = ("title",)
+ empty_value_display = "Не задано"
+
+
+class RoomTypeAdmin(admin.ModelAdmin):
+ inlines = (RoomInline,)
+ list_display = ("name", "description")
+ search_fields = ("name",)
+ list_filter = ("name",)
+ list_display_links = ("name",)
+ empty_value_display = "Не задано"
+
+
+class RoomImageAdmin(admin.ModelAdmin):
+ list_display = ("room", "image_tag", "uploaded_at")
+ search_fields = ("room", "image_tag")
+ search_fields = ("room", "image_tag")
+ list_filter = ("room",)
+ list_display_links = ("room", "image_tag")
+ empty_value_display = "Не задано"
+
+
+admin.site.register(Booking, BookingAdmin)
+admin.site.register(Room, RoomAdmin)
+admin.site.register(RoomType, RoomTypeAdmin)
+admin.site.register(RoomImage, RoomImageAdmin)
diff --git a/backend/rooms/apps.py b/backend/rooms/apps.py
new file mode 100644
index 0000000..e0350d1
--- /dev/null
+++ b/backend/rooms/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class RoomsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "rooms"
+ verbose_name = "Номера"
diff --git a/bookinn/rooms/migrations/0001_initial.py b/backend/rooms/migrations/0001_initial.py
similarity index 100%
rename from bookinn/rooms/migrations/0001_initial.py
rename to backend/rooms/migrations/0001_initial.py
diff --git a/bookinn/rooms/migrations/0002_alter_roomtype_name.py b/backend/rooms/migrations/0002_alter_roomtype_name.py
similarity index 100%
rename from bookinn/rooms/migrations/0002_alter_roomtype_name.py
rename to backend/rooms/migrations/0002_alter_roomtype_name.py
diff --git a/bookinn/rooms/migrations/0003_alter_booking_status_alter_roomtype_name.py b/backend/rooms/migrations/0003_alter_booking_status_alter_roomtype_name.py
similarity index 100%
rename from bookinn/rooms/migrations/0003_alter_booking_status_alter_roomtype_name.py
rename to backend/rooms/migrations/0003_alter_booking_status_alter_roomtype_name.py
diff --git a/bookinn/rooms/migrations/0004_alter_roomtype_name.py b/backend/rooms/migrations/0004_alter_roomtype_name.py
similarity index 100%
rename from bookinn/rooms/migrations/0004_alter_roomtype_name.py
rename to backend/rooms/migrations/0004_alter_roomtype_name.py
diff --git a/bookinn/rooms/migrations/0005_alter_roomtype_name.py b/backend/rooms/migrations/0005_alter_roomtype_name.py
similarity index 100%
rename from bookinn/rooms/migrations/0005_alter_roomtype_name.py
rename to backend/rooms/migrations/0005_alter_roomtype_name.py
diff --git a/bookinn/rooms/migrations/0006_alter_roomtype_name.py b/backend/rooms/migrations/0006_alter_roomtype_name.py
similarity index 100%
rename from bookinn/rooms/migrations/0006_alter_roomtype_name.py
rename to backend/rooms/migrations/0006_alter_roomtype_name.py
diff --git a/bookinn/rooms/migrations/0007_alter_room_options_and_more.py b/backend/rooms/migrations/0007_alter_room_options_and_more.py
similarity index 100%
rename from bookinn/rooms/migrations/0007_alter_room_options_and_more.py
rename to backend/rooms/migrations/0007_alter_room_options_and_more.py
diff --git a/backend/rooms/migrations/0008_alter_roomtype_unique_together_alter_booking_status_and_more.py b/backend/rooms/migrations/0008_alter_roomtype_unique_together_alter_booking_status_and_more.py
new file mode 100644
index 0000000..0108ac6
--- /dev/null
+++ b/backend/rooms/migrations/0008_alter_roomtype_unique_together_alter_booking_status_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.2.25 on 2025-10-30 17:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0007_alter_room_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name='roomtype',
+ unique_together=set(),
+ ),
+ migrations.AlterField(
+ model_name='booking',
+ name='status',
+ field=models.CharField(choices=[('checked_out', 'Выселен'), ('checked_in', 'Заселен'), ('booked', 'Забронировано'), ('cancelled', 'Отменено')], default='booked', max_length=64, verbose_name='Статус брони'),
+ ),
+ migrations.AlterField(
+ model_name='room',
+ name='price',
+ field=models.DecimalField(decimal_places=2, default=1, max_digits=8, verbose_name='Цена за сутки'),
+ preserve_default=False,
+ ),
+ migrations.AddConstraint(
+ model_name='booking',
+ constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['booked', 'checked_in'])), fields=('room', 'check_in', 'check_out'), name='unique_booking_period'),
+ ),
+ migrations.AddConstraint(
+ model_name='roomtype',
+ constraint=models.UniqueConstraint(fields=('name',), name='unique_name'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0009_room_unique_title.py b/backend/rooms/migrations/0009_room_unique_title.py
new file mode 100644
index 0000000..e07fc13
--- /dev/null
+++ b/backend/rooms/migrations/0009_room_unique_title.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.25 on 2025-10-30 17:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0008_alter_roomtype_unique_together_alter_booking_status_and_more'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='room',
+ constraint=models.UniqueConstraint(fields=('title',), name='unique_title'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0010_remove_room_unique_title_room_unique_title.py b/backend/rooms/migrations/0010_remove_room_unique_title_room_unique_title.py
new file mode 100644
index 0000000..4578990
--- /dev/null
+++ b/backend/rooms/migrations/0010_remove_room_unique_title_room_unique_title.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.25 on 2025-10-30 17:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0009_room_unique_title'),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name='room',
+ name='unique_title',
+ ),
+ migrations.AddConstraint(
+ model_name='room',
+ constraint=models.UniqueConstraint(fields=('title', 'room_type'), name='unique_title'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0011_alter_booking_room_alter_booking_user.py b/backend/rooms/migrations/0011_alter_booking_room_alter_booking_user.py
new file mode 100644
index 0000000..cb90d2c
--- /dev/null
+++ b/backend/rooms/migrations/0011_alter_booking_room_alter_booking_user.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.25 on 2025-11-05 18:25
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('rooms', '0010_remove_room_unique_title_room_unique_title'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='booking',
+ name='room',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to='rooms.room', verbose_name='Номер'),
+ ),
+ migrations.AlterField(
+ model_name='booking',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to=settings.AUTH_USER_MODEL, verbose_name='Гость'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0012_alter_room_is_available.py b/backend/rooms/migrations/0012_alter_room_is_available.py
new file mode 100644
index 0000000..5d6252b
--- /dev/null
+++ b/backend/rooms/migrations/0012_alter_room_is_available.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.25 on 2025-11-08 14:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0011_alter_booking_room_alter_booking_user'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='room',
+ name='is_available',
+ field=models.BooleanField(verbose_name='Статус номера'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0013_alter_room_is_available.py b/backend/rooms/migrations/0013_alter_room_is_available.py
new file mode 100644
index 0000000..213bc17
--- /dev/null
+++ b/backend/rooms/migrations/0013_alter_room_is_available.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.25 on 2025-11-08 14:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0012_alter_room_is_available'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='room',
+ name='is_available',
+ field=models.BooleanField(default=True, verbose_name='Статус номера'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/0014_roomimage.py b/backend/rooms/migrations/0014_roomimage.py
new file mode 100644
index 0000000..d2b73c7
--- /dev/null
+++ b/backend/rooms/migrations/0014_roomimage.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.25 on 2025-11-10 11:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+import rooms.utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0013_alter_room_is_available'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='RoomImage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('image', models.ImageField(help_text='Загрузите фотографию', upload_to=rooms.utils.room_image_upload_path, verbose_name='Фотография')),
+ ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки фото')),
+ ('room', models.ForeignKey(help_text='Номер к которому привязано фото', on_delete=django.db.models.deletion.CASCADE, related_name='images', to='rooms.room', verbose_name='Номер')),
+ ],
+ options={
+ 'verbose_name': 'Фотография номера',
+ 'verbose_name_plural': 'Фотографии номеров',
+ 'ordering': ['-uploaded_at'],
+ },
+ ),
+ ]
diff --git a/backend/rooms/migrations/0015_alter_roomtype_name.py b/backend/rooms/migrations/0015_alter_roomtype_name.py
new file mode 100644
index 0000000..145ecfe
--- /dev/null
+++ b/backend/rooms/migrations/0015_alter_roomtype_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.25 on 2025-11-12 11:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rooms', '0014_roomimage'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='roomtype',
+ name='name',
+ field=models.CharField(max_length=64, unique=True, verbose_name='Название типа номера'),
+ ),
+ ]
diff --git a/backend/rooms/migrations/__init__.py b/backend/rooms/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/rooms/models.py b/backend/rooms/models.py
new file mode 100644
index 0000000..76923cb
--- /dev/null
+++ b/backend/rooms/models.py
@@ -0,0 +1,175 @@
+from datetime import date
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.safestring import mark_safe
+
+from rooms.utils import room_image_upload_path
+
+User = get_user_model()
+
+
+STATUS_ROOM_CHOICES = (
+ ("checked_out", "Выселен"),
+ ("checked_in", "Заселен"),
+ ("booked", "Забронировано"),
+ ("cancelled", "Отменено"),
+)
+
+
+class RoomType(models.Model):
+ """Модель типа номера"""
+
+ name = models.CharField("Название типа номера", max_length=64, unique=True)
+ description = models.TextField("Описание типа номера", blank=True)
+
+ class Meta:
+ verbose_name = "Тип номера"
+ verbose_name_plural = "Типы номеров"
+ constraints = [
+ models.UniqueConstraint(
+ fields=["name"],
+ name="unique_name",
+ )
+ ]
+
+ def __str__(self):
+ return self.name
+
+
+class Room(models.Model):
+ """Информация о номере"""
+
+ title = models.CharField("Название номера", max_length=128)
+ description = models.TextField("Описание номера", blank=True)
+ room_type = models.ForeignKey(
+ RoomType,
+ on_delete=models.SET_NULL,
+ null=True,
+ verbose_name="Тип номера",
+ related_name="rooms",
+ )
+ is_available = models.BooleanField("Статус номера", default=True)
+ price = models.DecimalField("Цена за сутки", max_digits=8, decimal_places=2)
+ capacity = models.PositiveSmallIntegerField("Вместимость", default=1)
+ number_of_rooms = models.PositiveSmallIntegerField("Количество комнат", default=1)
+ created_at = models.DateTimeField("Дата создания номера", auto_now_add=True)
+
+ class Meta:
+ ordering = [
+ "title",
+ ]
+ verbose_name = "Номер"
+ verbose_name_plural = "Номера"
+ constraints = [
+ models.UniqueConstraint(
+ fields=["title", "room_type"],
+ name="unique_title",
+ )
+ ]
+
+ def __str__(self):
+ return f"{self.title} - {self.room_type}"
+
+ def is_available_for_period(self, check_in, check_out, exclude_booking=None):
+ """Вернет True, если номер свободен на указанный период"""
+ bookings = self.bookings.filter(
+ check_in__lt=check_out, check_out__gt=check_in
+ ).exclude(status="cancelled")
+ if exclude_booking:
+ bookings = bookings.exclude(pk=exclude_booking.pk)
+ return not bookings.exists()
+
+
+class RoomImage(models.Model):
+ """Фотографии номеров"""
+
+ room = models.ForeignKey(
+ Room,
+ on_delete=models.CASCADE,
+ related_name="images",
+ verbose_name="Номер",
+ help_text="Номер к которому привязано фото",
+ )
+ image = models.ImageField(
+ "Фотография", upload_to=room_image_upload_path, help_text="Загрузите фотографию"
+ )
+ uploaded_at = models.DateTimeField("Дата загрузки фото", auto_now_add=True)
+
+ class Meta:
+ verbose_name = "Фотография номера"
+ verbose_name_plural = "Фотографии номеров"
+ ordering = ["-uploaded_at"]
+
+ def __str__(self):
+ return f"Фотография для: {self.room.title}"
+
+ def image_tag(self):
+ """Миниатюра фотографии в админке"""
+ if self.image:
+ return mark_safe(
+ f'
'
+ )
+ return "Нет фотографий номера"
+
+ image_tag.allow_tags = True
+ image_tag.short_description = "Превью"
+
+
+class Booking(models.Model):
+ """Модель бронирования номера"""
+
+ user = models.ForeignKey(
+ User, on_delete=models.CASCADE, related_name="bookings", verbose_name="Гость"
+ )
+ room = models.ForeignKey(
+ Room, on_delete=models.CASCADE, related_name="bookings", verbose_name="Номер"
+ )
+ check_in = models.DateField("Дата заезда")
+ check_out = models.DateField("Дата выселения")
+ status = models.CharField(
+ "Статус брони", max_length=64, choices=STATUS_ROOM_CHOICES, default="booked"
+ )
+ created_at = models.DateTimeField("Дата создания бронирования", auto_now_add=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name = "Бронирование"
+ verbose_name_plural = "Бронирования"
+
+ constraints = [
+ models.UniqueConstraint(
+ fields=["room", "check_in", "check_out"],
+ name="unique_booking_period",
+ condition=models.Q(status__in=["booked", "checked_in"]),
+ )
+ ]
+
+ @property
+ def total_price(self):
+ """Расчет стоймости проживания"""
+ if self.check_out <= self.check_in:
+ return 0
+ days = (self.check_out - self.check_in).days
+ return days * self.room.price
+
+ @property
+ def total_days(self):
+ """Расчет количества дней бронирования"""
+ if self.check_out <= self.check_in:
+ return 0
+ days = (self.check_out - self.check_in).days
+ return days
+
+ def clean(self):
+ """Валидация на уровне модели"""
+ if not self.room.is_available:
+ raise ValidationError("Номер недоступен.")
+ if self.check_in < date.today():
+ raise ValidationError("Дата заезда не должна быть раньше текущей даты")
+ if self.check_out <= self.check_in:
+ raise ValidationError("Дата выселения должна быть позже даты заезда.")
+ if not self.room.is_available_for_period(self.check_in, self.check_out):
+ raise ValidationError("Номер уже забронирован на этот период.")
diff --git a/backend/rooms/utils.py b/backend/rooms/utils.py
new file mode 100644
index 0000000..dfa8db7
--- /dev/null
+++ b/backend/rooms/utils.py
@@ -0,0 +1,15 @@
+import os
+from datetime import datetime
+
+from slugify import slugify
+
+
+def room_image_upload_path(instance, filename):
+ """Формирует путь и имя файла для фотографии номера"""
+ file_extension = filename.split(".")[-1]
+ room_name = slugify(instance.room.title, lowercase=True) or "room"
+ upload_date = datetime.now().strftime("%Y-%m-%d_%H-%M")
+ count_image = instance.room.images.count()
+ number_image = count_image + 1
+ new_filename = f"{room_name}_{upload_date}_{number_image}.{file_extension}"
+ return os.path.join("room_images", room_name, new_filename)
diff --git a/backend/wait-for-it.sh b/backend/wait-for-it.sh
new file mode 100755
index 0000000..237610a
--- /dev/null
+++ b/backend/wait-for-it.sh
@@ -0,0 +1,181 @@
+# Скрипт ждёт, пока база на станет доступна, а потом запускает Django.
+
+WAITFORIT_cmdname=${0##*/}
+
+echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
+
+usage()
+{
+ cat << USAGE >&2
+Usage:
+ $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
+ -h HOST | --host=HOST Host or IP under test
+ -p PORT | --port=PORT TCP port under test
+ Alternatively, you specify the host and port as host:port
+ -s | --strict Only execute subcommand if the test succeeds
+ -q | --quiet Don't output any status messages
+ -t TIMEOUT | --timeout=TIMEOUT
+ Timeout in seconds, zero for no timeout
+ -- COMMAND ARGS Execute command with args after the test finishes
+USAGE
+ exit 1
+}
+
+wait_for()
+{
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ else
+ echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
+ fi
+ WAITFORIT_start_ts=$(date +%s)
+ while :
+ do
+ if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
+ nc -z $WAITFORIT_HOST $WAITFORIT_PORT
+ WAITFORIT_result=$?
+ else
+ (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
+ WAITFORIT_result=$?
+ fi
+ if [[ $WAITFORIT_result -eq 0 ]]; then
+ WAITFORIT_end_ts=$(date +%s)
+ echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
+ break
+ fi
+ sleep 1
+ done
+ return $WAITFORIT_result
+}
+
+wait_for_wrapper()
+{
+ # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
+ if [[ $WAITFORIT_QUIET -eq 1 ]]; then
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ else
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ fi
+ WAITFORIT_PID=$!
+ trap "kill -INT -$WAITFORIT_PID" INT
+ wait $WAITFORIT_PID
+ WAITFORIT_RESULT=$?
+ if [[ $WAITFORIT_RESULT -ne 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ fi
+ return $WAITFORIT_RESULT
+}
+
+# process arguments
+while [[ $# -gt 0 ]]
+do
+ case "$1" in
+ *:* )
+ WAITFORIT_hostport=(${1//:/ })
+ WAITFORIT_HOST=${WAITFORIT_hostport[0]}
+ WAITFORIT_PORT=${WAITFORIT_hostport[1]}
+ shift 1
+ ;;
+ --child)
+ WAITFORIT_CHILD=1
+ shift 1
+ ;;
+ -q | --quiet)
+ WAITFORIT_QUIET=1
+ shift 1
+ ;;
+ -s | --strict)
+ WAITFORIT_STRICT=1
+ shift 1
+ ;;
+ -h)
+ WAITFORIT_HOST="$2"
+ if [[ $WAITFORIT_HOST == "" ]]; then break; fi
+ shift 2
+ ;;
+ --host=*)
+ WAITFORIT_HOST="${1#*=}"
+ shift 1
+ ;;
+ -p)
+ WAITFORIT_PORT="$2"
+ if [[ $WAITFORIT_PORT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --port=*)
+ WAITFORIT_PORT="${1#*=}"
+ shift 1
+ ;;
+ -t)
+ WAITFORIT_TIMEOUT="$2"
+ if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --timeout=*)
+ WAITFORIT_TIMEOUT="${1#*=}"
+ shift 1
+ ;;
+ --)
+ shift
+ WAITFORIT_CLI=("$@")
+ break
+ ;;
+ --help)
+ usage
+ ;;
+ *)
+ echoerr "Unknown argument: $1"
+ usage
+ ;;
+ esac
+done
+
+if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
+ echoerr "Error: you need to provide a host and port to test."
+ usage
+fi
+
+WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
+WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
+WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
+WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
+
+# Check to see if timeout is from busybox?
+WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
+WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
+
+WAITFORIT_BUSYTIMEFLAG=""
+if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
+ WAITFORIT_ISBUSY=1
+ # Check if busybox timeout uses -t flag
+ # (recent Alpine versions don't support -t anymore)
+ if timeout &>/dev/stdout | grep -q -e '-t '; then
+ WAITFORIT_BUSYTIMEFLAG="-t"
+ fi
+else
+ WAITFORIT_ISBUSY=0
+fi
+
+if [[ $WAITFORIT_CHILD -gt 0 ]]; then
+ wait_for
+ WAITFORIT_RESULT=$?
+ exit $WAITFORIT_RESULT
+else
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ wait_for_wrapper
+ WAITFORIT_RESULT=$?
+ else
+ wait_for
+ WAITFORIT_RESULT=$?
+ fi
+fi
+
+if [[ $WAITFORIT_CLI != "" ]]; then
+ if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
+ echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
+ exit $WAITFORIT_RESULT
+ fi
+ exec "${WAITFORIT_CLI[@]}"
+else
+ exit $WAITFORIT_RESULT
+fi
diff --git a/bookinn/bookinn/settings.py b/bookinn/bookinn/settings.py
deleted file mode 100644
index c8782e1..0000000
--- a/bookinn/bookinn/settings.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-Django settings for bookinn project.
-
-Generated by 'django-admin startproject' using Django 3.2.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/3.2/topics/settings/
-
-For the full list of settings and their values, see
-https://docs.djangoproject.com/en/3.2/ref/settings/
-"""
-
-from pathlib import Path
-
-# Build paths inside the project like this: BASE_DIR / 'subdir'.
-BASE_DIR = Path(__file__).resolve().parent.parent
-
-
-# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
-
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'django-insecure-jin!h8#xx1wo@4i+3yakz$xvqy@ojglaj!4&69b2tc@xd(@g^g'
-
-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
-
-ALLOWED_HOSTS = ['*']
-
-
-# Application definition
-
-INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'rest_framework',
- 'rooms.apps.RoomsConfig',
-]
-
-MIDDLEWARE = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-]
-
-ROOT_URLCONF = 'bookinn.urls'
-
-TEMPLATES = [
- {
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- ],
- },
- },
-]
-
-WSGI_APPLICATION = 'bookinn.wsgi.application'
-
-
-# Database
-# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
-
-DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': BASE_DIR / 'db.sqlite3',
- }
-}
-
-
-# Password validation
-# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
-
-AUTH_PASSWORD_VALIDATORS = [
- {
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
- },
-]
-
-
-# Internationalization
-# https://docs.djangoproject.com/en/3.2/topics/i18n/
-
-LANGUAGE_CODE = 'ru-RU'
-
-TIME_ZONE = 'UTC'
-
-USE_I18N = True
-
-USE_L10N = True
-
-USE_TZ = True
-
-
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/3.2/howto/static-files/
-
-STATIC_URL = '/static/'
-
-# Default primary key field type
-# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
-
-DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/bookinn/bookinn/urls.py b/bookinn/bookinn/urls.py
deleted file mode 100644
index 2d477c9..0000000
--- a/bookinn/bookinn/urls.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from rest_framework.routers import DefaultRouter
-from django.contrib import admin
-from django.urls import include, path
-
-from rooms.views import BookingViewSet, RoomViewSet, RoomTypeViewSet
-
-
-router = DefaultRouter()
-router.register(r'rooms', RoomViewSet)
-router.register(r'room_type', RoomTypeViewSet)
-router.register(r'bookings', BookingViewSet)
-
-urlpatterns = [
- path('admin/', admin.site.urls),
- path('', include(router.urls)),
-]
diff --git a/bookinn/rooms/admin.py b/bookinn/rooms/admin.py
deleted file mode 100644
index 8c38f3f..0000000
--- a/bookinn/rooms/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/bookinn/rooms/apps.py b/bookinn/rooms/apps.py
deleted file mode 100644
index 4c3c68c..0000000
--- a/bookinn/rooms/apps.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from django.apps import AppConfig
-
-
-class RoomsConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'rooms'
- verbose_name = 'Номера'
diff --git a/bookinn/rooms/models.py b/bookinn/rooms/models.py
deleted file mode 100644
index 56f5b00..0000000
--- a/bookinn/rooms/models.py
+++ /dev/null
@@ -1,114 +0,0 @@
-from django.db import models
-from django.contrib.auth import get_user_model
-
-
-User = get_user_model()
-
-
-ROOM_TYPE_CHOICES = (
- ('Standart', 'Стандарт'),
- ('Luxury', 'Люкс'),
- ('President', 'Президент'),
-)
-
-
-STATUS_ROOM_CHOICES = (
- ('checked_out', 'Выселен'),
- ('checked_in', 'Заселен'),
- ('booked', 'Забронировано'),
- ('cancelled', 'Отменено'),
-)
-
-
-class RoomType(models.Model):
- """Модель типа номера"""
- name = models.CharField(
- 'Название типа номера',
- max_length=64,
- choices=ROOM_TYPE_CHOICES,
- default='Standart'
- )
- description = models.TextField('Описание типа номера', blank=True)
-
- class Meta:
- verbose_name = 'Тип номера'
- verbose_name_plural = 'Типы номеров'
- unique_together = ('name',)
-
- def __str__(self):
- return self.get_name_display()
-
-
-class Room(models.Model):
- """Информация о номере"""
- title = models.CharField('Название номера', max_length=128)
- description = models.TextField('Описание номера', blank=True)
- room_type = models.ForeignKey(
- RoomType,
- on_delete=models.SET_NULL,
- null=True,
- verbose_name='Тип номера',
- related_name='rooms'
- )
- is_available = models.BooleanField('Статус номера', default=True)
- price = models.DecimalField(
- 'Цена за сутки',
- max_digits=8,
- decimal_places=2,
- null=True,
- blank=True
- )
- capacity = models.PositiveSmallIntegerField('Вместимость', default=1)
- number_of_rooms = models.PositiveSmallIntegerField(
- 'Количество комнат',
- default=1
- )
- created_at = models.DateTimeField(
- 'Дата создания номера',
- auto_now_add=True
- )
-
- class Meta:
- ordering = ['title',]
- verbose_name = 'Номер'
- verbose_name_plural = 'Номера'
-
- def __str__(self):
- return f'{self.title} - {self.room_type}'
-
-
-class Booking(models.Model):
- """Модель бронирования номера"""
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name='bookings'
- )
- room = models.ForeignKey(
- Room,
- on_delete=models.CASCADE,
- related_name='bookings'
- )
- check_in = models.DateField('Дата заезда')
- check_out = models.DateField('Дата выселения')
- status = models.CharField(
- 'Статус брони',
- max_length=64,
- choices=STATUS_ROOM_CHOICES,
- default='booked'
- )
- created_at = models.DateTimeField(
- 'Дата создания бронирования',
- auto_now_add=True
- )
-
- class Meta:
- ordering = ['-created_at']
- verbose_name = 'Бронирование'
- verbose_name_plural = 'Бронирования'
-
- @property
- def total_price(self):
- """Расчет стоймости проживантя"""
- days = (self.check_out - self.check_in).days
- return days * self.room.price
diff --git a/bookinn/rooms/serializers.py b/bookinn/rooms/serializers.py
deleted file mode 100644
index 76612d0..0000000
--- a/bookinn/rooms/serializers.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from rest_framework import serializers
-
-from rooms.models import Booking, Room, RoomType
-
-
-class RoomTypeSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = RoomType
- fields = ('id', 'name', 'description')
-
-
-class RoomSerializer(serializers.ModelSerializer):
- room_type = RoomTypeSerializer(read_only=True)
- room_type_id = serializers.PrimaryKeyRelatedField(
- queryset=RoomType.objects.all(),
- source='room_type'
- )
-
- class Meta:
- model = Room
- fields = (
- 'id',
- 'title',
- 'description',
- 'room_type',
- 'room_type_id',
- 'is_available',
- 'price',
- 'capacity',
- 'number_of_rooms'
- )
-
-
-class BookingSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = Booking
- fields = (
- 'id',
- 'user',
- 'room',
- 'check_in',
- 'check_out',
- 'status',
- 'created_at'
- )
diff --git a/bookinn/rooms/tests.py b/bookinn/rooms/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/bookinn/rooms/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/bookinn/rooms/urls.py b/bookinn/rooms/urls.py
deleted file mode 100644
index 8b13789..0000000
--- a/bookinn/rooms/urls.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/bookinn/rooms/views.py b/bookinn/rooms/views.py
deleted file mode 100644
index 586768f..0000000
--- a/bookinn/rooms/views.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from rest_framework import viewsets
-
-from rooms.models import Booking, Room, RoomType
-from rooms.serializers import (
- BookingSerializer,
- RoomSerializer,
- RoomTypeSerializer
-)
-
-
-class RoomTypeViewSet(viewsets.ModelViewSet):
- queryset = RoomType.objects.all()
- serializer_class = RoomTypeSerializer
-
-
-class RoomViewSet(viewsets.ModelViewSet):
- queryset = Room.objects.all()
- serializer_class = RoomSerializer
-
-
-class BookingViewSet(viewsets.ModelViewSet):
- queryset = Booking.objects.all()
- serializer_class = BookingSerializer
diff --git a/infra/.env.example b/infra/.env.example
new file mode 100644
index 0000000..f455fd1
--- /dev/null
+++ b/infra/.env.example
@@ -0,0 +1,8 @@
+SECRET_KEY = 'SuperSecrettKey'
+
+DB_ENGINE=django.db.backends.postgresql
+DB_NAME=postgres
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+DB_HOST=db
+DB_PORT=5432
\ No newline at end of file
diff --git a/infra/docker-compose.yaml b/infra/docker-compose.yaml
new file mode 100644
index 0000000..219f507
--- /dev/null
+++ b/infra/docker-compose.yaml
@@ -0,0 +1,39 @@
+version: '3.8'
+
+services:
+ db:
+ image: postgres:15.0-alpine
+ volumes:
+ - postgres_data:/var/lib/postgresql/data/
+ env_file:
+ - ./.env
+ backend:
+ image: dmsn/bookinn:latest
+ restart: always
+
+ volumes:
+ - static_value:/app/static/
+ - media_value:/app/media/
+ - swagger:/app/api/docs/
+ depends_on:
+ - db
+ env_file:
+ - ./.env
+ command: ./wait-for-it.sh db:5432 -- gunicorn bookinn.wsgi:application --bind 0.0.0.0:8000
+
+ nginx:
+ image: nginx:stable-alpine
+ ports:
+ - "80:80"
+ volumes:
+ - static_value:/app/static/
+ - media_value:/app/media/
+ - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ depends_on:
+ - backend
+
+volumes:
+ postgres_data:
+ static_value:
+ media_value:
+ swagger:
\ No newline at end of file
diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf
new file mode 100644
index 0000000..1288aef
--- /dev/null
+++ b/infra/nginx/default.conf
@@ -0,0 +1,24 @@
+server {
+ listen 80;
+
+ server_name localhost;
+
+ location /static/ {
+ alias /app/static/;
+ }
+
+ location /media/ {
+ alias /app/media/;
+ }
+
+ location / {
+ proxy_pass http://backend:8000;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+ }
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..005f182
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,15 @@
+[tool.black]
+line-length = 90
+target-version = ['py310']
+extend-exclude = '''
+(
+ migrations # Django DB migrations
+)
+'''
+[tool.isort]
+profile = "black"
+line_length = 90
+multi_line_output = 3
+skip_gitignore = true
+skip_glob = ["**/migrations/*", "**/settings/*"]
+src_paths = ["backend"]