diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 05cdd92..02b0920 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -112,7 +112,6 @@ jobs: sudo docker compose -f docker-compose.production.yml pull sudo docker compose -f docker-compose.production.yml down sudo docker compose -f docker-compose.production.yml up -d - sudo docker compose -f docker-compose.production.yml exec backend python manage.py makemigrations sudo docker compose -f docker-compose.production.yml exec backend python manage.py migrate sudo docker compose -f docker-compose.production.yml exec backend python manage.py collectstatic sudo docker compose -f docker-compose.production.yml exec backend cp -r /app/collected_static/. /backend_static/static/ diff --git a/README.md b/README.md index ffee0a4..3917f6d 100644 --- a/README.md +++ b/README.md @@ -1 +1,95 @@ -# praktikum_new_diplom + +# Foodgram +Проект "Продуктовый помошник" (Foodgram) — сайт с рецептами. + +## Технологии: +Python 3.9 +Django 3.2.3 +djangorestframework 3.12.4 +Nginx +gunicorn +Docker + +## Описание работы: +В проекте "Проуктовый помощник" любители вкусно готовить могут публиковать рецепты, добавлять чужие рецепты в избранное и подписываться на публикации других авторов. Пользователям сайта также будет доступен сервис «Список покупок». Он позволит создавать список продуктов, которые нужно купить для приготовления выбранных блюд. + +## Установка проекта +- Сделайте fork репозитория, затем клонируйте его: +``` +git clone https://github.com/ZebraHr/foodgram-project-reactl +``` +- Для адаптации проекта на своем удаленном сервере добавьте секреты в GitHub Actions: +``` +DOCKER_USERNAME # имя пользователя в DockerHub +DOCKER_PASSWORD # пароль пользователя в DockerHub +HOST # ip_address сервера +USER # имя пользователя +SSH_KEY # приватный ssh-ключ (cat ~/.ssh/id_rsa) +SSH_PASSPHRASE # кодовая фраза (пароль) для ssh-ключа + +TELEGRAM_TO # id телеграм-аккаунта (можно узнать у @userinfobot, команда /start) +TELEGRAM_TOKEN # токен бота (получить токен можно у @BotFather, /token, имя бота) +``` +- На удаленном сервере создайте папку foodgram/ +- На удаленном сервере в папке проекта cоздайте файл .env: +``` +POSTGRES_DB=<Желаемое_имя_базы_данных> +POSTGRES_USER=<Желаемое_имя_пользователя_базы_данных> +POSTGRES_PASSWORD=<Желаемый_пароль_пользователя_базы_данных> +DB_HOST=db +DB_PORT=5432 + +SECRET_KEY = 'ваш_secret_key' +ALLOWED_HOSTS = ip_удаленного сервера, 127.0.0.1, localhost +DEBUG = False +``` +- Установка Nginx. Находясь на удалённом сервере, из любой директории выполните команду, затем запустите Nginx: +``` +sudo apt install nginx -y +sudo systemctl start nginx +``` +- Перейдите в файл конфигурации nginx и измените его настройки на следующие: +``` +nano /etc/nginx/sites-enabled/default +``` +``` +server { + server_name server_name <публичный-IP-адрес> <доменное-имя>; + server_tokens_off; + client_max_body_size 30M; + + location / { + proxy_set_header Host $http_host; + proxy_pass http://127.0.0.1:8000; + } + +} +``` +- Перезарузите Nginx: +``` +sudo nginx -t +sudo systemctl reload nginx +``` +- Откройте порты для фаервола и активируйте его: +``` +sudo ufw allow 'Nginx Full' +sudo ufw allow OpenSSH +sudo ufw enable +``` +- (Опционально) Получите SSL-сертификат для вашего доменного имени с помощью Certbot: +``` +sudo apt install snapd +sudo snap install core; sudo snap refresh core +sudo snap install --classic certbot +sudo ln -s /snap/bin/certbot /usr/bin/certbot +sudo certbot --nginx +``` +- Пуш в любую ветку запускает тестирование и деплой Foodgram на ваш удаленный сервер, а после успешного деплоя вам приходит оповещение в телеграм. + +### Автор +Анна Победоносцева + +Студент Яндекс Практикума ["Python-разаботчик плюс"](https://practicum.yandex.ru/python-developer-plus/?from=catalog) + +GitHub: +(https://github.com/ZebraHr) diff --git a/backend/api/filters.py b/backend/api/filters.py index a6ba613..41fad0d 100644 --- a/backend/api/filters.py +++ b/backend/api/filters.py @@ -11,9 +11,9 @@ class Meta: fields = ('name',) -IN_NOT_IN_LIST = ( - (0, 'Not_In_List'), - (1, 'In_List'), +IN_NOT_IN = ( + (0, 'Not_In'), + (1, 'In'), ) @@ -24,10 +24,10 @@ class RecipeFilter(FilterSet): queryset=User.objects.all() ) is_in_shopping_cart = filters.ChoiceFilter( - choices=IN_NOT_IN_LIST, method='get_is_in' + choices=IN_NOT_IN, method='get_is_in' ) is_favorited = filters.ChoiceFilter( - choices=IN_NOT_IN_LIST, + choices=IN_NOT_IN, method='get_is_in' ) tags = filters.AllValuesMultipleFilter( diff --git a/backend/api/serializers.py b/backend/api/serializers.py index b27b7bf..db71959 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -6,8 +6,6 @@ UserSerializer) from rest_framework import serializers from rest_framework.relations import SlugRelatedField -# from rest_framework.fields import IntegerField -# from rest_framework.exceptions import ValidationError from recipes.models import (Ingredient, Tag, @@ -19,7 +17,9 @@ from users.models import User -class Base64ImageField(serializers.ImageField): +class Base64ImageFieldSerializer(serializers.ImageField): + """Сериализатор для декодирования картинки. + Декодирует строку base64.""" def to_internal_value(self, data): if isinstance(data, str) and data.startswith('data:image'): format, imgstr = data.split(';base64,') @@ -92,6 +92,7 @@ class Meta: class IngredientInRecipeWriteSerializer(serializers.ModelSerializer): + """Сериализатор для игредиентов в рецепте.""" id = serializers.PrimaryKeyRelatedField(queryset=Ingredient.objects.all()) class Meta: @@ -101,7 +102,7 @@ class Meta: class RecipeCreateSerializer(serializers.ModelSerializer): """Серилизатор для создания рецепта.""" - image = Base64ImageField( + image = Base64ImageFieldSerializer( required=False, allow_null=True ) @@ -168,6 +169,7 @@ def to_representation(self, instance): class Hex2NameColor(serializers.Field): + """Сериализатор для добавления цветов через HEX.""" def to_representation(self, value): return value @@ -210,7 +212,7 @@ def get_is_favorited(self, obj): def get_is_in_shopping_cart(self, obj): request = self.context.get('request') - if not request or request.user.is_anonymous: + if request.user.is_anonymous: return False return ShoppingCart.objects.filter(recipe=obj, user=request.user).exists() @@ -259,7 +261,7 @@ def get_recipes(self, obj): class RecipeShortSerializer(serializers.ModelSerializer): - image = Base64ImageField() + image = Base64ImageFieldSerializer() class Meta: model = Recipe diff --git a/backend/api/views.py b/backend/api/views.py index 24d2c72..0a58cdf 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.permissions import (IsAuthenticatedOrReadOnly, IsAuthenticated, - AllowAny, + # AllowAny, SAFE_METHODS) from rest_framework.filters import SearchFilter from rest_framework.response import Response @@ -15,6 +15,7 @@ from reportlab.pdfgen import canvas from reportlab.pdfbase import pdfmetrics, ttfonts from django.db.models import Sum +from django.conf import settings from api.serializers import (UserCreateSerializer, UserReadSerializer, @@ -33,7 +34,7 @@ Subscribe, IngredientAmount) from users.models import User -from api.permissions import IsAmdinOrReadOnly +from api.permissions import IsAmdinOrReadOnly, IsOwnerOrReadOnly from api.paginations import RecipePagination from api.filters import RecipeFilter, IngredientFilter @@ -121,7 +122,7 @@ class RecipeViewSet(viewsets.ModelViewSet): """Вьюсет рецепта. Просмотр, создание, редактирование.""" queryset = Recipe.objects.all() - permission_classes = [AllowAny] + permission_classes = [IsOwnerOrReadOnly | IsAmdinOrReadOnly] pagination_class = RecipePagination filter_backends = (DjangoFilterBackend,) filterset_class = RecipeFilter @@ -207,15 +208,15 @@ def download_shopping_cart(self, request): f' - {i["amount"]}' for i in ingredients]) text = canvas.Canvas(buffer) - font = ttfonts.TTFont('Times-Roman', './docs/font.ttf') + font = ttfonts.TTFont('Arial', './docs/arialfont.ttf') pdfmetrics.registerFont(font) - text.setFont('Times-Roman', 20) - text.drawString(200, 750, 'Список покупок') - text.setFont('Times-Roman', 14) - height = 700 + text.setFont('Arial', settings.HEAD_FONT_SIZE) + text.drawString(settings.HEAD_INDENT, settings.HEAD_HEIGHT, + 'Ваш список покупок:') + text.setFont('Arial', settings.TEXT_FONT_SIZE) for line in shopping_list: - text.drawString(50, height, line) - height -= 25 + text.drawString(settings.TEXT_INDENT, settings.TEXT_HEIGHT, line) + settings.TEXT_HEIGHT -= settings.LINE_SPACE text.showPage() text.save() buffer.seek(0) diff --git a/backend/docs/font.ttf b/backend/docs/TNRfont.ttf similarity index 100% rename from backend/docs/font.ttf rename to backend/docs/TNRfont.ttf diff --git a/backend/docs/arialfont.ttf b/backend/docs/arialfont.ttf new file mode 100644 index 0000000..a882d32 Binary files /dev/null and b/backend/docs/arialfont.ttf differ diff --git a/backend/foodgram/settings.py b/backend/foodgram/settings.py index 9832dcc..a9c2f90 100644 --- a/backend/foodgram/settings.py +++ b/backend/foodgram/settings.py @@ -164,8 +164,6 @@ 'rest_framework.authentication.TokenAuthentication', ), 'SEARCH_PARAM': 'name', - # 'DEFAULT_PAGINATION_CLASS': 'api.paginations.RecipePagination', - # 'PAGE_SIZE': 6, } DJOSER = { @@ -174,10 +172,16 @@ 'user': 'api.serializers.UserReadSerializer', 'current_user': 'api.serializers.UserReadSerializer', }, - - # 'PERMISSIONS': { - # 'user': ['djoser.permissions.CurrentUserOrAdminOrReadOnly'], - # 'user_list': ['rest_framework.permissions.IsAuthenticatedOrReadOnly'], - # }, 'HIDE_USERS': False, } + + +# download_shopping_cart + +HEAD_FONT_SIZE = 18 +TEXT_FONT_SIZE = 14 +HEAD_HEIGHT = 800 +HEAD_INDENT = 200 +TEXT_HEIGHT = 750 +TEXT_INDENT = 50 +LINE_SPACE = 20 diff --git a/backend/media/recipes/photo_2023-08-23_14-02-41.jpg b/backend/media/recipes/photo_2023-08-23_14-02-41.jpg deleted file mode 100644 index 1b566d5..0000000 Binary files a/backend/media/recipes/photo_2023-08-23_14-02-41.jpg and /dev/null differ diff --git a/backend/recipes/admin.py b/backend/recipes/admin.py index c9d25d2..b95459d 100644 --- a/backend/recipes/admin.py +++ b/backend/recipes/admin.py @@ -1,5 +1,6 @@ from import_export.admin import ImportExportActionModelAdmin from django.contrib import admin +from django.contrib.admin import display from recipes.models import (Ingredient, Tag, @@ -8,20 +9,28 @@ ShoppingCart, Subscribe, IngredientAmount, + RecipeTag, ) from users.models import User -@admin.register(Ingredient) +@admin.register(Tag) class RecipeIngredientAdmin(ImportExportActionModelAdmin): - list_display = ('name', 'measurement_unit') + list_display = ('name',) list_filter = ('name',) search_fields = ('name',) -@admin.register(Tag) +@admin.register(Ingredient) class RecipeTagAdmin(ImportExportActionModelAdmin): + list_display = ('name', 'measurement_unit') list_filter = ('name',) + search_fields = ('name',) + + +class TagInRecipeAdmin(admin.TabularInline): + model = RecipeTag + autocomplete_fields = ('tag', ) class IngredientAmountAdmin(admin.TabularInline): @@ -30,16 +39,15 @@ class IngredientAmountAdmin(admin.TabularInline): class RecipeAdmin(admin.ModelAdmin): - inlines = (IngredientAmountAdmin,) - list_display = ('id', 'name', 'text', 'author', ) + inlines = (IngredientAmountAdmin, TagInRecipeAdmin) + list_display = ('id', 'name', 'text', 'author', 'is_in_favorites') + readonly_fields = ('is_in_favorites',) list_filter = ('pub_date', 'author', 'name', 'tags',) empty_value_display = '-пусто-' - -# class IngredientAdmin(admin.ModelAdmin): -# list_display = ('name', 'measurement_unit',) -# list_filter = ('name',) -# search_fields = ('name',) + @display(description='Добавлено в избранное') + def is_in_favorites(self, obj): + return obj.favorites_recipes.count() class UserAdmin(admin.ModelAdmin): @@ -48,8 +56,6 @@ class UserAdmin(admin.ModelAdmin): admin.site.register(Recipe, RecipeAdmin) -# admin.site.register(Ingredient, IngredientAdmin) -# admin.site.register(Tag) admin.site.register(Favorite) admin.site.register(ShoppingCart) admin.site.register(User, UserAdmin) diff --git a/backend/recipes/migrations/0001_initial.py b/backend/recipes/migrations/0001_initial.py new file mode 100644 index 0000000..8525517 --- /dev/null +++ b/backend/recipes/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# Generated by Django 4.2.4 on 2023-09-23 11:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Favorite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Избранное', + 'verbose_name_plural': 'Избранное', + 'ordering': ['recipe'], + }, + ), + migrations.CreateModel( + name='Ingredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='Название ингредиента')), + ('measurement_unit', models.CharField(max_length=150, verbose_name='Единица измерения')), + ], + options={ + 'verbose_name': 'Ингредиент', + 'verbose_name_plural': 'Ингредиенты', + 'db_table': 'recipes_ingredient', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='IngredientAmount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.PositiveIntegerField(default=1, verbose_name='Количество')), + ], + ), + migrations.CreateModel( + name='Recipe', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название рецепта')), + ('image', models.ImageField(blank=True, upload_to='recipes/', verbose_name='Картинка')), + ('text', models.TextField(help_text='Введите текст рецепта', verbose_name='Текст рецепта')), + ('cooking_time', models.PositiveSmallIntegerField(default=1, verbose_name='Время приготовления')), + ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации')), + ], + options={ + 'verbose_name': 'Рецепт', + 'verbose_name_plural': 'Рецепты', + 'ordering': ['-pub_date'], + }, + ), + migrations.CreateModel( + name='RecipeIngredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.PositiveSmallIntegerField(verbose_name='Количество')), + ], + options={ + 'ordering': ['recipe'], + }, + ), + migrations.CreateModel( + name='RecipeTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'ordering': ['recipe'], + }, + ), + migrations.CreateModel( + name='ShoppingCart', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Список покупок', + 'verbose_name_plural': 'Списки покупок', + 'ordering': ['recipe'], + }, + ), + migrations.CreateModel( + name='Subscribe', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'ordering': ['author'], + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, unique=True, verbose_name='Название тега')), + ('color', models.CharField(max_length=10, unique=True, verbose_name='Цветовой код')), + ('slug', models.SlugField(unique=True, verbose_name='Слаг')), + ], + options={ + 'verbose_name': 'Тег', + 'verbose_name_plural': 'Теги', + 'db_table': 'recipes_tag', + 'ordering': ['name'], + }, + ), + ] diff --git a/backend/recipes/migrations/0002_initial.py b/backend/recipes/migrations/0002_initial.py new file mode 100644 index 0000000..57f8083 --- /dev/null +++ b/backend/recipes/migrations/0002_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.4 on 2023-09-23 11:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('recipes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='subscribe', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscribing', to=settings.AUTH_USER_MODEL, verbose_name='Автор'), + ), + migrations.AddField( + model_name='subscribe', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriber', to=settings.AUTH_USER_MODEL, verbose_name='Подписчик'), + ), + migrations.AddField( + model_name='shoppingcart', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_cart', to='recipes.recipe', verbose_name='Рецепт'), + ), + migrations.AddField( + model_name='shoppingcart', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_cart', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + migrations.AddField( + model_name='recipetag', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipe_in_tags', to='recipes.recipe'), + ), + migrations.AddField( + model_name='recipetag', + name='tag', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tag_in_recipes', to='recipes.tag'), + ), + migrations.AddField( + model_name='recipeingredient', + name='ingredient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingr_in_recipes', to='recipes.ingredient'), + ), + migrations.AddField( + model_name='recipeingredient', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipe_in_ingr', to='recipes.recipe'), + ), + migrations.AddField( + model_name='recipe', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipes', to=settings.AUTH_USER_MODEL, verbose_name='Автор'), + ), + migrations.AddField( + model_name='recipe', + name='ingredients', + field=models.ManyToManyField(related_name='recipes', to='recipes.ingredient', verbose_name='Название ингредиента'), + ), + migrations.AddField( + model_name='recipe', + name='tags', + field=models.ManyToManyField(related_name='recipes', to='recipes.tag', verbose_name='Теги'), + ), + migrations.AddField( + model_name='ingredientamount', + name='ingredient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredient', to='recipes.ingredient'), + ), + migrations.AddField( + model_name='ingredientamount', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipe', to='recipes.recipe'), + ), + migrations.AddField( + model_name='favorite', + name='recipe', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites_recipes', to='recipes.recipe', verbose_name='Рецепт'), + ), + migrations.AddField( + model_name='favorite', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + ] diff --git a/backend/recipes/migrations/0003_alter_ingredientamount_amount_and_more.py b/backend/recipes/migrations/0003_alter_ingredientamount_amount_and_more.py new file mode 100644 index 0000000..cf70e4f --- /dev/null +++ b/backend/recipes/migrations/0003_alter_ingredientamount_amount_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.4 on 2023-09-23 16:16 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipes', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ingredientamount', + name='amount', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество'), + ), + migrations.AlterField( + model_name='recipe', + name='cooking_time', + field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Время приготовления'), + ), + migrations.AlterField( + model_name='recipeingredient', + name='amount', + field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество'), + ), + ] diff --git a/backend/recipes/models.py b/backend/recipes/models.py index 1a7ffe7..b4472dd 100644 --- a/backend/recipes/models.py +++ b/backend/recipes/models.py @@ -1,5 +1,5 @@ from django.db import models -# from django.core.validators import MinValueValidator +from django.core import validators from users.models import User @@ -10,7 +10,6 @@ class Ingredient(models.Model): verbose_name='Название ингредиента') measurement_unit = models.CharField(max_length=150, verbose_name='Единица измерения') - # добавить выпадающий список class Meta: verbose_name = 'Ингредиент' @@ -72,9 +71,8 @@ class Recipe(models.Model): ) cooking_time = models.PositiveSmallIntegerField( verbose_name='Время приготовления', - default=1, - # validators=(MinValueValidator(1, 'Минимум 1 минута')), - ) + validators=[validators.MinValueValidator(1)] + ) pub_date = models.DateTimeField( verbose_name='Дата публикации', auto_now_add=True @@ -101,13 +99,14 @@ class RecipeIngredient(models.Model): related_name='ingr_in_recipes') amount = models.PositiveSmallIntegerField( 'Количество', + validators=[validators.MinValueValidator(1)] ) class Meta: ordering = ['recipe'] def __str__(self): - return f'{self.recipe} {self.ingredient}' + return f'В {self.recipe} есть {self.ingredient}' class RecipeTag(models.Model): @@ -125,7 +124,7 @@ class Meta: ordering = ['recipe'] def __str__(self): - return f'{self.recipe} {self.tag}' + return f'Рецепт {self.recipe} отмечен тегом {self.tag}' class Favorite(models.Model): @@ -147,6 +146,8 @@ class Meta: ordering = ['recipe'] verbose_name = 'Избранное' verbose_name_plural = 'Избранное' + models.UniqueConstraint(fields=['user', 'recipe'], + name='unique_u_f') def __str__(self): return f'Рецепт {self.recipe} в избранном у {self.user}' @@ -171,6 +172,8 @@ class Meta: ordering = ['recipe'] verbose_name = 'Список покупок' verbose_name_plural = 'Списки покупок' + models.UniqueConstraint(fields=['user', 'recipe'], + name='unique_s_l') def __str__(self): return f'Рецепт {self.recipe} в списке покупок у {self.user}' @@ -194,10 +197,11 @@ class Subscribe(models.Model): class Meta: ordering = ['author'] models.UniqueConstraint(fields=['user', 'author'], - name='unique_ff') + name='unique_ua') class IngredientAmount(models.Model): + """Модель вывода количества ингредиенто в рецепте.""" recipe = models.ForeignKey( Recipe, on_delete=models.CASCADE, @@ -210,6 +214,5 @@ class IngredientAmount(models.Model): ) amount = models.PositiveIntegerField( 'Количество', - default=1, - # validators=(MinValueValidator(1, 'Минимум 1'),), + validators=[validators.MinValueValidator(1)] ) diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 0000000..8348c2e --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.4 on 2023-09-23 11:09 + +import django.contrib.auth.models +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=100, unique=True, verbose_name='email address')), + ('username', models.CharField(max_length=40)), + ('first_name', models.CharField(max_length=40)), + ('last_name', models.CharField(max_length=40)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Пользователь', + 'verbose_name_plural': 'Пользователи', + 'ordering': ['id'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/infra/nginx.conf b/infra/nginx.conf index 67f729f..93e6b14 100644 --- a/infra/nginx.conf +++ b/infra/nginx.conf @@ -3,10 +3,10 @@ server { listen 80; client_max_body_size 30M; - # location /api/docs/ { - # root /usr/share/nginx/html; - # try_files $uri $uri/redoc.html; - # } + location /api/docs/ { + root /usr/share/nginx/html; + try_files $uri $uri/redoc.html; + } location /api/ { proxy_set_header Host $http_host;