diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..45acdd4a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +venv +staticfiles diff --git a/.gitignore b/.gitignore index 0ad052d36..28abf0a39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -bundles/** node_modules/ media/ @@ -142,3 +141,5 @@ dmypy.json cython_debug/ .parcel-cache/ +/data/ +staticfiles/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..38df5dbfa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:16.20 AS parcel +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY bundles-src/ ./bundles-src/ +RUN ./node_modules/.bin/parcel build bundles-src/index.js --dist-dir bundles --public-url="./" + +FROM python:3.10 +WORKDIR /app +COPY --from=parcel /app/bundles/ ./bundles/ +RUN apt update \ + && apt install -y libpq-dev \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY . . +RUN python manage.py collectstatic --noinput +RUN mkdir -p frontend/ \ + && cp -R bundles/* frontend/ \ + && cp -R staticfiles/* frontend/ diff --git a/README.md b/README.md index 54bf1453f..638648b84 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Сайт доставки еды Star Burger +# Сайт доставки еды [Star Burger](http://starburger.mavel.cc/) Это сайт сети ресторанов Star Burger. Здесь можно заказать превосходные бургеры с доставкой на дом. @@ -37,7 +37,7 @@ python --version ``` **Важно!** Версия Python должна быть не ниже 3.6. -Возможно, вместо команды `python` здесь и в остальных инструкциях этого README придётся использовать `python3`. Зависит это от операционной системы и от того, установлен ли у вас Python старой второй версии. +Возможно, вместо команды `python` здесь и в остальных инструкциях этого README придётся использовать `python3`. Зависит это от операционной системы и от того, установлен ли у вас Python старой второй версии. В каталоге проекта создайте виртуальное окружение: ```sh @@ -54,11 +54,15 @@ python -m venv venv pip install -r requirements.txt ``` -Определите переменную окружения `SECRET_KEY`. Создать файл `.env` в каталоге `star_burger/` и положите туда такой код: +Определите переменную окружения `SECRET_KEY`. Создайте файл `.env` в каталоге `star_burger/` и положите туда такой код: ```sh SECRET_KEY=django-insecure-0if40nf4nf93n4 ``` +Получите API-ключ для +[Яндекс-геокодера](https://developer.tech.yandex.ru/services/) (подробнее [здесь](https://dvmn.org/encyclopedia/api-docs/yandex-geocoder-api/)). +Положите ключ в переменную `YANDEX_GEO_KEY` в файле `.env`. + Создайте файл базы данных SQLite и отмигрируйте её следующей командой: ```sh @@ -136,17 +140,56 @@ Parcel будет следить за файлами в каталоге `bundle ## Как запустить prod-версию сайта -Собрать фронтенд: +### Настроить бэкенд: -```sh -./node_modules/.bin/parcel build bundles-src/index.js --dist-dir bundles --public-url="./" -``` - -Настроить бэкенд: создать файл `.env` в каталоге `star_burger/` со следующими настройками: +Создать файл `.env` в корневом каталоге проекта со следующими настройками: - `DEBUG` — дебаг-режим. Поставьте `False`. - `SECRET_KEY` — секретный ключ проекта. Он отвечает за шифрование на сайте. Например, им зашифрованы все пароли на вашем сайте. - `ALLOWED_HOSTS` — [см. документацию Django](https://docs.djangoproject.com/en/3.1/ref/settings/#allowed-hosts) +- `POSTGRES_USER` +- `POSTGRES_PASSWORD` +- `POSTGRES_DB` + +Для SSL-сертификата: +- `EMAIL` +- `CERT_DOMAINS` - список доменов в формате `example.org,www.example.org` + +Убедиться, что в каталоге `star-burger/data` лежат данные, которые нужно загрузить в БД. +Убедиться, что на сервере установлен Docker. + +Заменить домены в `nginx.conf` на ваши собственные. + +Чтобы получать мгновенные уведомления об ошибках, подключите свой аккаунт [Rollbar](https://docs.rollbar.com/docs/setup) +и добавьте следующие переменные в `.env`: +- `ROLLBAR_TOKEN` +- `ROLLBAR_ENV` - `development`/`production`/... +- `ROLLBAR_USERNAME` + +### Поднять контейнеры, получить сертификат SSL и запустить приложение: + +```sh +scripts/first_deploy.sh +``` + +Приложение контролируется таргетом systemd и запускается автоматически при перезагрузке сервера. +В таргет включены следующие юниты: +- starburger_containers.service - запускает и останавливает контейнеры через docker compose +- starburger_cert_renewal.timer - обновляет сертификат SSL и перезагружает nginx +- starburger_clearsessions.timer - удаляет устаревшие сессии в Django + +Команды, которые могут пригодиться: +```sh +systemctl stop starburger.target # остановить +systemctl start starburger.target # запустить +docker compose logs # посмотреть на stdout контейнеров +``` + + +### Подтянуть изменения из репозитория и перезапустить сервисы: +```sh +scripts/deploy.sh +``` ## Цели проекта diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..70d924739 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3' + +services: + db: + container_name: starburger_db + image: postgres:14 + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - postgres:/var/lib/postgresql/data + + django: + container_name: starburger_django + build: . + command: gunicorn -b 0.0.0.0:8081 --workers 3 star_burger.wsgi:application + environment: + - POSTGRES_HOST=db + volumes: + - .:/app + - frontend:/app/frontend + - media:/app/media + ports: + - "8081:8081" + depends_on: + - db + + nginx: + container_name: starburger_nginx + image: nginx:1.25 + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - frontend:/frontend + - media:/media + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + depends_on: + - django + + certbot: + container_name: starburger_certbot + image: certbot/certbot:v2.6.0 + volumes: + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + depends_on: + - nginx + +volumes: + postgres: + frontend: + media: diff --git a/foodcartapp/admin.py b/foodcartapp/admin.py index b31edd3b2..d9278aa58 100644 --- a/foodcartapp/admin.py +++ b/foodcartapp/admin.py @@ -1,12 +1,25 @@ +from django.conf import settings from django.contrib import admin +from django.forms import ModelForm +from django.http import HttpResponseRedirect from django.shortcuts import reverse from django.templatetags.static import static from django.utils.html import format_html +from django.utils.http import url_has_allowed_host_and_scheme from .models import Product from .models import ProductCategory from .models import Restaurant from .models import RestaurantMenuItem +from .models import Order +from .models import ProductOrder +from places.models import Location + + +@admin.register(Location) +class LocationAdmin(admin.ModelAdmin): + list_display = ['address', 'latitude', 'longitude', 'updated_at'] + readonly_fields = ['updated_at'] class RestaurantMenuItemInline(admin.TabularInline): @@ -30,6 +43,10 @@ class RestaurantAdmin(admin.ModelAdmin): RestaurantMenuItemInline ] + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + Location.update_by_address(obj.address) + @admin.register(Product) class ProductAdmin(admin.ModelAdmin): @@ -104,3 +121,43 @@ def get_image_list_preview(self, obj): @admin.register(ProductCategory) class ProductAdmin(admin.ModelAdmin): pass + + +class ProductOrderInlineForm(ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['product_price'].label = 'Цена (оставьте поле пустым для стандартной цены)' + + +class ProductOrderInline(admin.TabularInline): + model = ProductOrder + fields = ('product', 'product_price', 'quantity') + form = ProductOrderInlineForm + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + inlines = [ProductOrderInline] + list_display = ('firstname', 'lastname', 'phonenumber', 'created_at') + readonly_fields = ('created_at',) + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + Location.update_by_address(obj.address) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for obj in formset.deleted_objects: + obj.delete() + for instance in instances: + if instance.product_price is None: + instance.product_price = instance.product.price + instance.save() + formset.save_m2m() + + def response_change(self, request, obj): + response = super().response_change(request, obj) + if url_has_allowed_host_and_scheme(request.GET.get('next'), settings.ALLOWED_HOSTS): + return HttpResponseRedirect(request.GET['next']) + else: + return response diff --git a/foodcartapp/migrations/0038_auto_20230309_1702.py b/foodcartapp/migrations/0038_auto_20230309_1702.py new file mode 100644 index 000000000..310607775 --- /dev/null +++ b/foodcartapp/migrations/0038_auto_20230309_1702.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.15 on 2023-03-09 17:02 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0037_auto_20210125_1833'), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('firstname', models.CharField(max_length=50, verbose_name='имя')), + ('lastname', models.CharField(db_index=True, max_length=50, verbose_name='фамилия')), + ('phonenumber', phonenumber_field.modelfields.PhoneNumberField(db_index=True, max_length=128, region=None, verbose_name='телефон')), + ('address', models.CharField(max_length=200, verbose_name='адрес')), + ('created_at', models.DateTimeField(auto_now=True, verbose_name='время создания')), + ], + options={ + 'verbose_name': 'заказ', + 'verbose_name_plural': 'заказы', + }, + ), + migrations.CreateModel( + name='ProductOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField(verbose_name='количество')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='foodcartapp.order')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='foodcartapp.product')), + ], + ), + migrations.AddField( + model_name='order', + name='products', + field=models.ManyToManyField(related_name='order', through='foodcartapp.ProductOrder', to='foodcartapp.Product', verbose_name='товары'), + ), + ] diff --git a/foodcartapp/migrations/0039_auto_20230310_2031.py b/foodcartapp/migrations/0039_auto_20230310_2031.py new file mode 100644 index 000000000..559f00aa6 --- /dev/null +++ b/foodcartapp/migrations/0039_auto_20230310_2031.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.15 on 2023-03-10 20:31 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0038_auto_20230309_1702'), + ] + + operations = [ + migrations.AddField( + model_name='productorder', + name='product_price', + field=models.DecimalField(decimal_places=2, default=0, max_digits=8, validators=[django.core.validators.MinValueValidator(0)], verbose_name='цена товара'), + preserve_default=False, + ), + migrations.AlterField( + model_name='productorder', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products_ordered', to='foodcartapp.order'), + ), + migrations.AlterField( + model_name='productorder', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='foodcartapp.product'), + ), + migrations.AlterField( + model_name='productorder', + name='quantity', + field=models.PositiveIntegerField(verbose_name='количество'), + ), + ] diff --git a/foodcartapp/migrations/0040_auto_20230310_2031.py b/foodcartapp/migrations/0040_auto_20230310_2031.py new file mode 100644 index 000000000..ce06526a5 --- /dev/null +++ b/foodcartapp/migrations/0040_auto_20230310_2031.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.15 on 2023-03-10 20:16 + +from django.db import migrations + + +def add_prices(apps, schema_editor): + ProductOrder = apps.get_model('foodcartapp', 'ProductOrder') + for product_order in ProductOrder.objects.prefetch_related('product').iterator(): + product_order.product_price = product_order.product.price + product_order.save() + + +def reverse(apps, schema_editor): + ProductOrder = apps.get_model('foodcartapp', 'ProductOrder') + for product_order in ProductOrder.objects.iterator(): + product_order.product_price = 0 + product_order.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0039_auto_20230310_2031'), + ] + + operations = [ + migrations.RunPython(add_prices, reverse), + ] diff --git a/foodcartapp/migrations/0041_auto_20230311_2021.py b/foodcartapp/migrations/0041_auto_20230311_2021.py new file mode 100644 index 000000000..556585188 --- /dev/null +++ b/foodcartapp/migrations/0041_auto_20230311_2021.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.15 on 2023-03-11 20:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0040_auto_20230310_2031'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='status', + field=models.SmallIntegerField(choices=[(0, 'Не обработан'), (1, 'Готовится'), (2, 'В пути'), (3, 'Доставлен')], default=0, verbose_name='статус'), + ), + migrations.AlterField( + model_name='productorder', + name='product_price', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, validators=[django.core.validators.MinValueValidator(0)], verbose_name='цена товара'), + ), + ] diff --git a/foodcartapp/migrations/0042_alter_order_status.py b/foodcartapp/migrations/0042_alter_order_status.py new file mode 100644 index 000000000..6a08c1206 --- /dev/null +++ b/foodcartapp/migrations/0042_alter_order_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-03-11 20:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0041_auto_20230311_2021'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='status', + field=models.SmallIntegerField(choices=[(0, 'Не обработан'), (1, 'Готовится'), (2, 'В пути'), (3, 'Доставлен')], db_index=True, default=0, verbose_name='статус'), + ), + ] diff --git a/foodcartapp/migrations/0043_order_comment.py b/foodcartapp/migrations/0043_order_comment.py new file mode 100644 index 000000000..b2c998341 --- /dev/null +++ b/foodcartapp/migrations/0043_order_comment.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-03-11 20:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0042_alter_order_status'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='comment', + field=models.TextField(blank=True, verbose_name='комментарий'), + ), + ] diff --git a/foodcartapp/migrations/0044_auto_20230311_2052.py b/foodcartapp/migrations/0044_auto_20230311_2052.py new file mode 100644 index 000000000..de7821b36 --- /dev/null +++ b/foodcartapp/migrations/0044_auto_20230311_2052.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.15 on 2023-03-11 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0043_order_comment'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='called_at', + field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='время звонка'), + ), + migrations.AddField( + model_name='order', + name='delivered_at', + field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='когда доставлен'), + ), + migrations.AlterField( + model_name='order', + name='created_at', + field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='время создания'), + ), + ] diff --git a/foodcartapp/migrations/0045_auto_20230311_2125.py b/foodcartapp/migrations/0045_auto_20230311_2125.py new file mode 100644 index 000000000..18b4b8cdc --- /dev/null +++ b/foodcartapp/migrations/0045_auto_20230311_2125.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2023-03-11 21:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0044_auto_20230311_2052'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='payment_method', + field=models.CharField(blank=True, choices=[('CASH', 'Наличные'), ('CARD', 'Карта')], db_index=True, max_length=4, null=True, verbose_name='способ оплаты'), + ), + migrations.AlterField( + model_name='order', + name='created_at', + field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='время создания'), + ), + ] diff --git a/foodcartapp/migrations/0046_auto_20230312_1642.py b/foodcartapp/migrations/0046_auto_20230312_1642.py new file mode 100644 index 000000000..bb6d3c124 --- /dev/null +++ b/foodcartapp/migrations/0046_auto_20230312_1642.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.15 on 2023-03-12 16:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0045_auto_20230311_2125'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='restaurant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='foodcartapp.restaurant', verbose_name='ответственный ресторан'), + ), + migrations.AlterField( + model_name='order', + name='payment_method', + field=models.CharField(blank=True, choices=[(None, ''), ('CASH', 'Наличные'), ('CARD', 'Карта')], db_index=True, max_length=4, null=True, verbose_name='способ оплаты'), + ), + ] diff --git a/foodcartapp/migrations/0047_rename_restaurant_order_assigned_restaurant.py b/foodcartapp/migrations/0047_rename_restaurant_order_assigned_restaurant.py new file mode 100644 index 000000000..9a5ef8b2c --- /dev/null +++ b/foodcartapp/migrations/0047_rename_restaurant_order_assigned_restaurant.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-04-09 11:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0046_auto_20230312_1642'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='restaurant', + new_name='assigned_restaurant', + ), + ] diff --git a/foodcartapp/migrations/0048_alter_productorder_quantity.py b/foodcartapp/migrations/0048_alter_productorder_quantity.py new file mode 100644 index 000000000..2521a6443 --- /dev/null +++ b/foodcartapp/migrations/0048_alter_productorder_quantity.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.15 on 2023-04-09 11:47 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foodcartapp', '0047_rename_restaurant_order_assigned_restaurant'), + ] + + operations = [ + migrations.AlterField( + model_name='productorder', + name='quantity', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1000)], verbose_name='количество'), + ), + ] diff --git a/foodcartapp/models.py b/foodcartapp/models.py index 803492d33..fc14f9b56 100644 --- a/foodcartapp/models.py +++ b/foodcartapp/models.py @@ -1,5 +1,9 @@ +from collections import defaultdict + +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models -from django.core.validators import MinValueValidator +from django.db.models import F, Sum +from phonenumber_field.modelfields import PhoneNumberField class Restaurant(models.Model): @@ -93,6 +97,15 @@ def __str__(self): return self.name +class RestaurantMenuItemQuerySet(models.QuerySet): + def include_products(self, products: list | models.QuerySet): + return ( + self + .filter(product__in=[product.id for product in products], availability=True) + .prefetch_related('restaurant', 'product') + ) + + class RestaurantMenuItem(models.Model): restaurant = models.ForeignKey( Restaurant, @@ -112,6 +125,8 @@ class RestaurantMenuItem(models.Model): db_index=True ) + objects = RestaurantMenuItemQuerySet.as_manager() + class Meta: verbose_name = 'пункт меню ресторана' verbose_name_plural = 'пункты меню ресторана' @@ -121,3 +136,138 @@ class Meta: def __str__(self): return f"{self.restaurant.name} - {self.product.name}" + + +class ProductOrderQuerySet(models.QuerySet): + def in_orders(self, orders: models.QuerySet): + return self.filter(order__in=orders).prefetch_related('product', 'order') + + +class ProductOrder(models.Model): + product = models.ForeignKey( + 'Product', + on_delete=models.CASCADE, + related_name='orders', + ) + order = models.ForeignKey( + 'Order', + on_delete=models.CASCADE, + related_name='products_ordered' + ) + quantity = models.PositiveIntegerField( + 'количество', + validators=[ + MinValueValidator(1), + MaxValueValidator(1000), + ] + ) + product_price = models.DecimalField( + 'цена товара', + max_digits=8, + decimal_places=2, + validators=[MinValueValidator(0)], + blank=True, + ) + + objects = ProductOrderQuerySet.as_manager() + + def __str__(self): + return f'{self.product}, {self.quantity}' + + +class OrderQuerySet(models.QuerySet): + def with_totals(self): + return self.annotate(total=Sum(F('products_ordered__product_price') * F('products_ordered__quantity'))) + + def active(self): + return self.exclude(status=Order.Status.COMPLETE).prefetch_related('assigned_restaurant') + + +class Order(models.Model): + + class Status(models.IntegerChoices): + NEW = 0, 'Не обработан' + PREPARING = 1, 'Готовится' + DELIVERING = 2, 'В пути' + COMPLETE = 3, 'Доставлен' + + class PaymentMethod(models.TextChoices): + CASH = 'CASH', 'Наличные' + CARD = 'CARD', 'Карта' + __empty__ = '' + + firstname = models.CharField( + 'имя', + max_length=50, + ) + lastname = models.CharField( + 'фамилия', + max_length=50, + db_index=True, + ) + phonenumber = PhoneNumberField( + 'телефон', + db_index=True, + ) + address = models.CharField( + 'адрес', + max_length=200, + ) + products = models.ManyToManyField( + 'Product', + verbose_name='товары', + related_name='order', + through='ProductOrder', + ) + status = models.SmallIntegerField( + 'статус', + choices=Status.choices, + default=Status.NEW, + db_index=True, + ) + comment = models.TextField( + 'комментарий', + blank=True, + ) + created_at = models.DateTimeField( + 'время создания', + auto_now_add=True, + db_index=True, + ) + called_at = models.DateTimeField( + 'время звонка', + blank=True, + null=True, + db_index=True, + ) + delivered_at = models.DateTimeField( + 'когда доставлен', + blank=True, + null=True, + db_index=True, + ) + payment_method = models.CharField( + 'способ оплаты', + choices=PaymentMethod.choices, + max_length=4, + blank=True, + null=True, + db_index=True, + ) + assigned_restaurant = models.ForeignKey( + 'Restaurant', + verbose_name='ответственный ресторан', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='orders', + ) + + objects = OrderQuerySet.as_manager() + + class Meta: + verbose_name = 'заказ' + verbose_name_plural = 'заказы' + + def __str__(self): + return f'{self.id}: {self.firstname} {self.lastname[:1]}., {self.created_at:%d.%m.%y %H:%M:%S}' diff --git a/foodcartapp/serializers.py b/foodcartapp/serializers.py new file mode 100644 index 000000000..07285c2b2 --- /dev/null +++ b/foodcartapp/serializers.py @@ -0,0 +1,38 @@ +from rest_framework.serializers import ModelSerializer + +from places.models import Location +from .models import Order, ProductOrder + + +class ProductOrderSerializer(ModelSerializer): + class Meta: + model = ProductOrder + fields = ['product', 'quantity'] + + +class OrderSerializer(ModelSerializer): + products = ProductOrderSerializer(many=True, allow_empty=False, write_only=True) + + class Meta: + model = Order + fields = ['firstname', 'lastname', 'phonenumber', 'address', 'products'] + + def create(self, validated_data): + products_in_order = self.validated_data['products'] + order = Order.objects.create( + firstname=self.validated_data['firstname'], + lastname=self.validated_data['lastname'], + phonenumber=self.validated_data['phonenumber'], + address=self.validated_data['address'], + ) + for product_order in products_in_order: + product = product_order['product'] + order.products.add( + product, + through_defaults={ + 'quantity': product_order['quantity'], + 'product_price': product.price, + } + ) + Location.update_by_address(order.address) + return order diff --git a/foodcartapp/views.py b/foodcartapp/views.py index 66ac42173..be6ae3fd6 100644 --- a/foodcartapp/views.py +++ b/foodcartapp/views.py @@ -1,8 +1,12 @@ +from django.db import transaction from django.http import JsonResponse from django.templatetags.static import static +from rest_framework.decorators import api_view +from rest_framework.response import Response - -from .models import Product +from .models import Product, Order +from .serializers import OrderSerializer +from places.models import Location def banners_list_api(request): @@ -57,6 +61,10 @@ def product_list_api(request): }) +@transaction.atomic +@api_view(['POST']) def register_order(request): - # TODO это лишь заглушка - return JsonResponse({}) + serializer = OrderSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 000000000..bfc8cac2f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + listen [::]:80; + + server_name starburger.mavel.cc www.starburger.mavel.cc; # replace domain here + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + ssl_certificate /etc/letsencrypt/live/starburger.mavel.cc/fullchain.pem; # replace domain here + ssl_certificate_key /etc/letsencrypt/live/starburger.mavel.cc/privkey.pem; # and here + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + server_name starburger.mavel.cc www.starburger.mavel.cc; # replace domain here + + location / { + 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_pass http://django:8081/; + } + + location /static/ { + alias '/frontend/'; + } + + location /media/ { + alias '/media/'; + } + +} + diff --git a/package-lock.json b/package-lock.json index 1eba2b329..82ac26c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,9 @@ "requires": true, "packages": { "": { + "name": "star-burger", "dependencies": { + "16": "0.0.2", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/preset-react": "^7.16.7", "core-js": "^3.21.1", @@ -550,7 +552,7 @@ "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", "hasInstallScript": true }, "node_modules/@babel/template": { @@ -606,26 +608,56 @@ "node": ">=6.9.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz", - "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", + "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", - "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz", - "integrity": "sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ==", + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" } }, "node_modules/@parcel/bundler-default": { @@ -1868,6 +1900,14 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, + "node_modules/16": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/16/-/16-0.0.2.tgz", + "integrity": "sha512-AhG4lpdn+/it+U5Xl1bm5SbaHYTH5NfU/vXZkP7E7CHjtVtITuFVZKa3AZP3gN38RDJHYYtEqWmqzCutlXaR7w==", + "dependencies": { + "numeric": "^1.2.6" + } + }, "node_modules/abortcontroller-polyfill": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz", @@ -2044,6 +2084,7 @@ "version": "3.21.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -2429,9 +2470,9 @@ "integrity": "sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==" }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" }, @@ -2548,6 +2589,11 @@ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" }, + "node_modules/numeric": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/numeric/-/numeric-1.2.6.tgz", + "integrity": "sha512-avBiDAP8siMa7AfJgYyuxw1oyII4z2sswS23+O+ZfV28KrtNzy0wxUFwi4f3RyM4eeeXNs1CThxR7pb5QQcMiw==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2942,7 +2988,8 @@ "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" }, "node_modules/supports-color": { "version": "7.2.0", @@ -2987,13 +3034,13 @@ } }, "node_modules/terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", "dependencies": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "bin": { @@ -3008,14 +3055,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "node_modules/terser/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, "node_modules/timsort": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", @@ -3095,6 +3134,14 @@ } }, "dependencies": { + "16": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/16/-/16-0.0.2.tgz", + "integrity": "sha512-AhG4lpdn+/it+U5Xl1bm5SbaHYTH5NfU/vXZkP7E7CHjtVtITuFVZKa3AZP3gN38RDJHYYtEqWmqzCutlXaR7w==", + "requires": { + "numeric": "^1.2.6" + } + }, "@ampproject/remapping": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz", @@ -3523,23 +3570,47 @@ "to-fast-properties": "^2.0.0" } }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "@jridgewell/resolve-uri": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz", - "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" + }, + "@jridgewell/source-map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", + "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } }, "@jridgewell/sourcemap-codec": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", - "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==" + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "@jridgewell/trace-mapping": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz", - "integrity": "sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ==", + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" } }, "@parcel/bundler-default": { @@ -4640,9 +4711,9 @@ "integrity": "sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==" }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, "keycode": { "version": "2.2.1", @@ -4740,6 +4811,11 @@ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" }, + "numeric": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/numeric/-/numeric-1.2.6.tgz", + "integrity": "sha512-avBiDAP8siMa7AfJgYyuxw1oyII4z2sswS23+O+ZfV28KrtNzy0wxUFwi4f3RyM4eeeXNs1CThxR7pb5QQcMiw==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5078,13 +5154,13 @@ "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==" }, "terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", "requires": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "dependencies": { @@ -5092,11 +5168,6 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" } } }, diff --git a/package.json b/package.json index c20ddfa87..219886538 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "16": "0.0.2", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/preset-react": "^7.16.7", "core-js": "^3.21.1", diff --git a/bundles/.gitkeep b/places/__init__.py similarity index 100% rename from bundles/.gitkeep rename to places/__init__.py diff --git a/places/admin.py b/places/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/places/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/places/apps.py b/places/apps.py new file mode 100644 index 000000000..b3d0c3d3f --- /dev/null +++ b/places/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PlacesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'places' diff --git a/places/migrations/0001_initial.py b/places/migrations/0001_initial.py new file mode 100644 index 000000000..6d3d91566 --- /dev/null +++ b/places/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.15 on 2023-04-02 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Location', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.CharField(max_length=200, unique=True, verbose_name='адрес')), + ('latitude', models.FloatField(verbose_name='широта')), + ('longitude', models.FloatField(verbose_name='долгота')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='обновлено')), + ], + ), + ] diff --git a/places/migrations/0002_auto_20230409_1224.py b/places/migrations/0002_auto_20230409_1224.py new file mode 100644 index 000000000..9a3221b36 --- /dev/null +++ b/places/migrations/0002_auto_20230409_1224.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2023-04-09 12:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('places', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='location', + name='latitude', + field=models.FloatField(blank=True, null=True, verbose_name='широта'), + ), + migrations.AlterField( + model_name='location', + name='longitude', + field=models.FloatField(blank=True, null=True, verbose_name='долгота'), + ), + ] diff --git a/places/migrations/__init__.py b/places/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/places/models.py b/places/models.py new file mode 100644 index 000000000..71beed5d0 --- /dev/null +++ b/places/models.py @@ -0,0 +1,50 @@ +import geopy.exc +from django.conf import settings +from django.db import models +from geopy import geocoders + + +class Location(models.Model): + address = models.CharField( + 'адрес', + max_length=200, + unique=True, + ) + latitude = models.FloatField( + 'широта', + blank=True, + null=True, + ) + longitude = models.FloatField( + 'долгота', + blank=True, + null=True, + ) + updated_at = models.DateTimeField( + 'обновлено', + auto_now=True, + ) + + def __str__(self): + return self.address + + @classmethod + def update_by_address(cls, address): + try: + geocoder = geocoders.Yandex(api_key=settings.YANDEX_GEO_KEY) + geo = geocoder.geocode(address) + cls.objects.update_or_create( + address=address, + defaults={ + 'latitude': geo.latitude, + 'longitude': geo.longitude, + }, + ) + except (geopy.exc.GeocoderServiceError, AttributeError): + cls.objects.get_or_create( + address=address, + defaults={ + 'latitude': None, + 'longitude': None, + }, + ) diff --git a/places/tests.py b/places/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/places/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/places/views.py b/places/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/places/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/requirements.txt b/requirements.txt index 2d1e5b749..49c37a21f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,11 @@ django==3.2.15 django-debug-toolbar==3.2.1 -Pillow==8.2.0 +Pillow==9.4.0 environs[django]==9.3.2 +wheel==0.38.4 +django-phonenumber-field[phonenumbers]==7.0.2 +djangorestframework==3.14.0 +geopy~=2.3.0 +rollbar~=0.16.3 +psycopg2~=2.9.6 +gunicorn~=20.1.0 diff --git a/restaurateur/templates/order_items.html b/restaurateur/templates/order_items.html index 3ba791723..0cbd73931 100644 --- a/restaurateur/templates/order_items.html +++ b/restaurateur/templates/order_items.html @@ -1,4 +1,5 @@ {% extends 'base_restaurateur_page.html' %} +{% load humanize %} {% block title %}Необработанные заказы | Star Burger{% endblock %} @@ -14,17 +15,50 @@

Необработанные заказы

+ + + + + + + - {% for item in order_items %} + {% for order in orders %} - - - - + + + + + + + + + + + {% endfor %}
ID заказаСтатусВремя созданияСтоимостьСпособ оплаты Клиент Телефон Адрес доставкиКомментарииРестораныСсылка на админку
----{{ order.id }}{{ order.get_status_display }}{{ order.created_at|naturaltime }}{{ order.total|floatformat:"2g" }} ₽ {{ order.get_payment_method_display }}{{ order.firstname }} {{ order.lastname }}{{ order.phonenumber }}{{ order.address }}{{ order.comment }} + {% if order.status == order.Status.NEW %} +
+ Могут приготовить: + {% for rest in order.available_restaurants %} +
  • {{ rest }}: + {% if rest.distance %} + {{ rest.distance|floatformat:'2g' }} км
  • + {% else %} + Расстояние недоступно + {% endif %} + {% endfor %} +
    + {% else %} + Передан в ресторан: + {{ order.assigned_restaurant }} + {% endif %} +
    + Редактировать +
    diff --git a/restaurateur/views.py b/restaurateur/views.py index e66d4e32b..2b0e367ea 100644 --- a/restaurateur/views.py +++ b/restaurateur/views.py @@ -1,14 +1,16 @@ +from copy import copy + from django import forms from django.shortcuts import redirect, render from django.views import View from django.urls import reverse_lazy from django.contrib.auth.decorators import user_passes_test - from django.contrib.auth import authenticate, login from django.contrib.auth import views as auth_views +from geopy import distance - -from foodcartapp.models import Product, Restaurant +from foodcartapp.models import Product, Restaurant, Order, ProductOrder, RestaurantMenuItem +from places.models import Location class Login(forms.Form): @@ -92,6 +94,38 @@ def view_restaurants(request): @user_passes_test(is_manager, login_url='restaurateur:login') def view_orders(request): + active_orders = Order.objects.active().with_totals() + active_product_orders = ProductOrder.objects.in_orders(active_orders) + ordered_products = set(product_order.product for product_order in active_product_orders) + ordered_menu_items = RestaurantMenuItem.objects.include_products(ordered_products) + + addresses = [order.address for order in active_orders] + [pr.restaurant.address for pr in ordered_menu_items] + locations = Location.objects.filter(address__in=addresses) + locations_by_address = {location.address: location for location in locations} + + for menu_item in ordered_menu_items: + menu_item.restaurant.location = locations_by_address.get(menu_item.restaurant.address) + + for order in active_orders: + order.location = locations_by_address.get(order.address) + required_product_ids = [po.product.id for po in active_product_orders if po.order == order] + order.available_restaurants = { + copy(menu_item.restaurant) for menu_item in ordered_menu_items + if menu_item.product.id in required_product_ids + } + for restaurant in order.available_restaurants: + try: + restaurant.distance = distance.distance( + (order.location.latitude, order.location.longitude), + (restaurant.location.latitude, restaurant.location.longitude), + ).km + except AttributeError: + restaurant.distance = None + order.available_restaurants = sorted( + order.available_restaurants, + key=lambda rest: rest.distance or 0, + ) + return render(request, template_name='order_items.html', context={ - # TODO заглушка для нереализованного функционала + 'orders': active_orders, }) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 000000000..3adc64642 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -e + +git pull +docker compose build django +docker compose restart django +docker exec starburger_django python manage.py migrate --noinput + +docker exec starburger_django python scripts/report_deploy_rollbar.py diff --git a/scripts/first_deploy.sh b/scripts/first_deploy.sh new file mode 100755 index 000000000..4200d2bf0 --- /dev/null +++ b/scripts/first_deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +if [ ! -f data/db_dump.json ]; then + echo "Error: data/db_dump.json does not exist" + exit 1 +fi + +if [ ! -d media ] || [ ! "$(ls -A media)" ]; then + echo "Error: media directory does not exist or is empty" + exit 1 +fi + +scripts/init_letsencrypt.sh + +cp systemd_units/* /etc/systemd/system/ +systemctl daemon-reload +systemctl enable starburger.target +systemctl start starburger.target + +docker exec starburger_django python manage.py migrate +docker exec starburger_django python manage.py loaddata data/db_dump.json + +docker exec starburger_django python scripts/report_deploy_rollbar.py + +echo "Deploy successful." diff --git a/scripts/init_letsencrypt.sh b/scripts/init_letsencrypt.sh new file mode 100755 index 000000000..f71a9d3ca --- /dev/null +++ b/scripts/init_letsencrypt.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +set -e + +if ! [ -x "$(command -v docker compose)" ]; then + echo 'Error: docker compose is not installed.' >&2 + exit 1 +fi + +source .env + +IFS=',' read -ra domains <<< "$CERT_DOMAINS" # stores `CERT_DOMAINS` from .env in the array `domains` +rsa_key_size=4096 +data_path="./data/certbot" +email="$EMAIL" +staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits + +if [ -d "$data_path" ]; then + read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision + if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then + exit + fi +fi + +rm -r "$data_path" + + +if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then + echo "### Downloading recommended TLS parameters ..." + mkdir -p "$data_path/conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" + echo + fi + +echo "### Creating dummy certificate for $domains ..." +path="/etc/letsencrypt/live/$domains" +mkdir -p "$data_path/conf/live/$domains" +docker compose run --rm --entrypoint "\ + openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ + -keyout '$path/privkey.pem' \ + -out '$path/fullchain.pem' \ + -subj '/CN=localhost'" certbot +echo + + +echo "### Starting nginx ..." +docker compose up --force-recreate -d nginx +echo + +echo "### Deleting dummy certificate for $domains ..." +docker compose run --rm --entrypoint "\ + rm -Rf /etc/letsencrypt/live/$domains && \ + rm -Rf /etc/letsencrypt/archive/$domains && \ + rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot +echo + + +echo "### Requesting Let's Encrypt certificate for $domains ..." +#Join $domains to -d args +domain_args="" +for domain in "${domains[@]}"; do + domain_args="$domain_args -d $domain" +done + +# Select appropriate email arg +case "$email" in + "") email_arg="--register-unsafely-without-email" ;; + *) email_arg="--email $email" ;; +esac + +# Enable staging mode if needed +if [ $staging != "0" ]; then staging_arg="--staging"; fi + +docker compose run --rm --entrypoint "\ + certbot certonly --webroot -w /var/www/certbot \ + $staging_arg \ + $email_arg \ + $domain_args \ + --rsa-key-size $rsa_key_size \ + --agree-tos \ + --force-renewal \ + --no-eff-email" certbot +echo + +echo "### Reloading nginx ..." +docker compose exec nginx nginx -s reload diff --git a/scripts/report_deploy_rollbar.py b/scripts/report_deploy_rollbar.py new file mode 100644 index 000000000..a955c5e46 --- /dev/null +++ b/scripts/report_deploy_rollbar.py @@ -0,0 +1,25 @@ +import subprocess + +import requests +from environs import Env + + +env = Env() +env.read_env() + +commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], encoding='UTF-8').strip() + +url = 'https://api.rollbar.com/api/1/deploy' +headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + 'X-Rollbar-Access-Token': env.str('ROLLBAR_TOKEN'), +} +payload = { + 'environment': env.str('ROLLBAR_ENV'), + 'revision': commit_hash, + 'rollbar_username': env.str('ROLLBAR_USERNAME') +} + +response = requests.post(url, headers=headers, json=payload) +response.raise_for_status() diff --git a/star_burger/settings.py b/star_burger/settings.py index a23d4133c..626c66e23 100644 --- a/star_burger/settings.py +++ b/star_burger/settings.py @@ -13,20 +13,24 @@ SECRET_KEY = env('SECRET_KEY') -DEBUG = env.bool('DEBUG', True) +DEBUG = env.bool('DEBUG', False) ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', ['127.0.0.1', 'localhost']) INSTALLED_APPS = [ 'foodcartapp.apps.FoodcartappConfig', 'restaurateur.apps.RestaurateurConfig', + 'places.apps.PlacesConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.humanize', 'debug_toolbar', + 'phonenumber_field', + 'rest_framework', ] MIDDLEWARE = [ @@ -38,6 +42,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'rollbar.contrib.django.middleware.RollbarNotifierMiddleware', ] ROOT_URLCONF = 'star_burger.urls' @@ -80,11 +85,22 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/' -DATABASES = { - 'default': dj_database_url.config( - default='sqlite:////{0}'.format(os.path.join(BASE_DIR, 'db.sqlite3')) - ) -} +if not env.str('POSTGRES_USER', None): + DEFAULT_DATABASE = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +else: + DEFAULT_DATABASE = { + 'ENGINE': env.str('DB_ENGINE', 'django.db.backends.postgresql'), + 'NAME': env.str('POSTGRES_DB'), + 'USER': env.str('POSTGRES_USER'), + 'PASSWORD': env.str('POSTGRES_PASSWORD'), + 'HOST': env.str('POSTGRES_HOST', '127.0.0.1'), + 'PORT': env.str('POSTGRES_PORT', '5432'), + } + +DATABASES = {'default': DEFAULT_DATABASE} AUTH_PASSWORD_VALIDATORS = [ { @@ -122,3 +138,13 @@ os.path.join(BASE_DIR, "assets"), os.path.join(BASE_DIR, "bundles"), ] + +PHONENUMBER_DEFAULT_REGION = 'RU' + +YANDEX_GEO_KEY = env.str('YANDEX_GEO_KEY') + +ROLLBAR = { + 'access_token': env.str('ROLLBAR_TOKEN', None), + 'environment': env.str('ROLLBAR_ENV', 'development'), + 'root': BASE_DIR, +} diff --git a/star_burger/urls.py b/star_burger/urls.py index e194cafe4..e157b9964 100644 --- a/star_burger/urls.py +++ b/star_burger/urls.py @@ -26,6 +26,7 @@ path('', render, kwargs={'template_name': 'index.html'}, name='start_page'), path('api/', include('foodcartapp.urls')), path('manager/', include('restaurateur.urls')), + path('api-auth/', include('rest_framework.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: diff --git a/systemd_units/starburger.target b/systemd_units/starburger.target new file mode 100644 index 000000000..e5e3a4df3 --- /dev/null +++ b/systemd_units/starburger.target @@ -0,0 +1,7 @@ +[Unit] +Description=Starburger Target +Requires=starburger_containers.service starburger_cert_renewal.timer starburger_clearsessions.timer +After=starburger_containers.service starburger_cert_renewal.timer starburger_clearsessions.timer + +[Install] +WantedBy=multi-user.target diff --git a/systemd_units/starburger_cert_renewal.service b/systemd_units/starburger_cert_renewal.service new file mode 100644 index 000000000..aa2a3b29a --- /dev/null +++ b/systemd_units/starburger_cert_renewal.service @@ -0,0 +1,7 @@ +[Unit] +Description=Starburger Certbot Renewal + +[Service] +WorkingDirectory=/opt/star-burger +ExecStart=/usr/bin/docker exec starburger_certbot certbot renew --force-renewal +ExecStartPost=/usr/bin/docker exec starburger_nginx nginx -s reload diff --git a/systemd_units/starburger_cert_renewal.timer b/systemd_units/starburger_cert_renewal.timer new file mode 100644 index 000000000..7ab97b48c --- /dev/null +++ b/systemd_units/starburger_cert_renewal.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly Starburger Certbot Renewal +PartOf=starburger.target + +[Timer] +OnCalendar=weekly +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/systemd_units/starburger_clearsessions.service b/systemd_units/starburger_clearsessions.service new file mode 100644 index 000000000..e96c63aa0 --- /dev/null +++ b/systemd_units/starburger_clearsessions.service @@ -0,0 +1,6 @@ +[Unit] +Description=Starburger Django Clear Sessions + +[Service] +WorkingDirectory=/opt/star-burger +ExecStart=/usr/bin/docker exec starburger_django python manage.py clearsessions diff --git a/systemd_units/starburger_clearsessions.timer b/systemd_units/starburger_clearsessions.timer new file mode 100644 index 000000000..18d469256 --- /dev/null +++ b/systemd_units/starburger_clearsessions.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly Django Clear Sessions for Starburger +PartOf=starburger.target + +[Timer] +OnCalendar=weekly +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/systemd_units/starburger_containers.service b/systemd_units/starburger_containers.service new file mode 100644 index 000000000..9f5ead134 --- /dev/null +++ b/systemd_units/starburger_containers.service @@ -0,0 +1,16 @@ +[Unit] +Description=Starburger Docker Compose App Service +Requires=docker.service +After=docker.service +PartOf=starburger.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/star-burger/ +ExecStart=docker compose up -d +ExecStop=docker compose down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target