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 сервис для бронирования номеров + +[![BookInn workflow](https://github.com/dmsnback/bookinn/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/bookinn/actions/workflows/main.yml) + + +- [Описание](#Описание) +- [Технологии](#Технологии) +- [Основные ресурсы](#Ресурсы) +- [Шаблон заполнения .env-файла](#Шаблон) +- [Запуск проекта на локальной машине](#Запуск) +- [Автор](#Автор) + + + +### Описание + +Сервис — REST API для бронирования номеров в отеле. +Реализован на Django + Django REST Framework, используется Djoser для аутентификации (JWT), drf-spectacular для документации. + +```python +Проект адаптирован для использования PostgreSQL и развёртывания в контейнерах Docker. +``` + +> [Вернуться в начало](#Начало) + + +### Технологии + +[![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54)](https://www.python.org) +![Django](https://img.shields.io/badge/django-%23092E20.svg?style=for-the-badge&logo=django&logoColor=white) +[![DjangoREST](https://img.shields.io/badge/DJANGO-REST-ff1709?style=for-the-badge&logo=django&logoColor=white&color=ff1709&labelColor=gray)](https://www.django-rest-framework.org) +[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://www.docker.com) +[![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white)](https://www.postgresql.org) +[![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/features/actions) +[![Nginx](https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white)](https://nginx.org/ru/) +[![Gunicorn](https://img.shields.io/badge/gunicorn-%298729.svg?style=for-the-badge&logo=gunicorn&logoColor=white)](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"]