перепеши сериалайзер и аction subscribe для модели...
创建于:2025年2月14日
创建于:2025年2月14日
перепеши сериалайзер и аction subscribe для модели class Follow(models.Model):
"""Модель для отображения подписок."""
user = models.ForeignKey( # from_user/текущий пользователь
User,
on_delete=models.CASCADE,
related_name='follower',
verbose_name='Подписчик'
)
author = models.ForeignKey( # to_user/на кого подписан
User,
on_delete=models.CASCADE,
related_name='author', # following
verbose_name='Автор'
)
is_subscribed = models.BooleanField(
default=False,
blank=True,
verbose_name='Наличие подписки')
textclass Meta: verbose_name = 'Подписка' verbose_name_plural = 'Подписки' constraints = [ models.UniqueConstraint( fields=['user', 'author'], name='%(app_label)s_%(class)s_unique_follow' ), models.CheckConstraint( name="%(app_label)s_%(class)s_prevent_self_follow", check=~models.Q(user=models.F('author')) ) ] def __str__(self): return (f'{self.user} подписан на {self.author}')
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import TagViewSet, IngredientViewSet, RecipeViewSet
from foodgram_users.views import UserViewSet
router = DefaultRouter()
router.register(r'tags', TagViewSet, basename='tags') # работает
router.register(r'ingredients', IngredientViewSet, basename='ingredients') # работает
router.register(r'recipes', RecipeViewSet, basename='recipes')
router.register(r'users', UserViewSet, basename='users') #
urlpatterns = [
path('', include(router.urls)),
path('', include('djoser.urls')), # стандартная обработка страниц входа
path('auth/', include('djoser.urls.authtoken'))
]
class FollowListSerializer(serializers.ModelSerializer):
"""
Сериализатор отрисовывает сраницу -- СПИСОК "Мои подписки"
Используемые адреса:
список всех подписок -- api/users/subscriptions/
"""
is_subscribed = serializers.SerializerMethodField()
recipes = SerializerMethodField() # возможно через slug related fid правильнее было бы
recipes_count = SerializerMethodField() # найти количество рецептов автора
textclass Meta: # поля наследуемого сериализатора будут выведены без дополнительного указания на них model = Follow fields = ( 'id', 'username', 'first_name', 'last_name', 'email', 'is_subscribed', 'avatar', 'recipes_count', 'recipes' ) def validate_author(self, value): # по аналогии точно также МОЖНО ПОДПРАВИТЬ ПРОВЕРКА ЧТО ПОДПИСКА УЖЕ ЕСТЬ """ На страницы рецепты, собственные рецепты тоже есть. Запрет на подписку. """ current_user = self.context['request'].user if current_user == value: raise serializers.ValidationError( 'Подписаться на себя нельзя.') return value def get_is_subscribed(self, obj): """Возвращает True если пользователь подписан на автора.""" current_user = self.context['request'].user if current_user.is_anonymous: return False return current_user.follower.filter(author=obj).exists() def get_recipes(self, obj): """Получаем все рецептовы автора""" # Нужен сереалайзер для отображения полученных рецептов # добавить ограничение на вывод кличества рецептов через limit (посмотреть что есть во фронтенде) from foodgram_api.serializers import ListRecipeUsersSerializer recipes_author = obj.recipe.all() request = self.context.get('request') limit = request.query_params['recipes_limit'] # request.query_params.get('recipes_limit') if limit: try: recipes_author = recipes_author[:int(limit)] except ValueError: pass serializer = ListRecipeUsersSerializer(recipes_author, many=True) # name, image, coocking_time -- микро сериалайзер return serializer.data def get_recipes_count(self, obj): """Определяет количество рецептов автора на которого подписан""" return obj.recipe.count() # related_name='recipe' .filter()
class FollowUserSerializer(EditUserSerializer):
"""
Сериализатор для отображения сраницы рецептов конкретного пользователя
Профиль пользователя api/users/{id}/ GET
Текущий пользователь api/users/me/ GET ->почти тоже
"""
user = serializers.SlugRelatedField(
queryset=User.objects.all(),
source='follower', many=True, # queryset=User.objects.all(),
slug_field='username',
default=serializers.CurrentUserDefault() # Текущий пользователь
)
author = serializers.SlugRelatedField(
slug_field='username', many=True,
source='following',
queryset=User.objects.all(), # список авторов
)
textclass Meta: # поля наследуемого сериализатора будут выведены без дополнительного указания на них model = Follow fields = ( 'id', 'author', 'user' ) validators = ( UniqueTogetherValidator( queryset=Follow.objects.all(), fields=('author', 'user'), message=('Подписка на этого автора уже добавлена.') ), )
User = get_user_model()
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = EditUserSerializer
pagination_class = CustomPagination
textdef get_serializer_class(self): if self.action in ('list', 'retrieve'): return EditUserSerializer return RegistrationUserSerializer @action(detail=False, methods=['get'], #agination_class=None, permission_classes=(IsAuthenticated,)) def me(self, request): serializer = EditUserSerializer(request.user, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) def set_password(self, request, *args, **kwargs): serializer = SetPasswordSerializer( data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) self.request.user.set_password(serializer.data['new_password']) self.request.user.save() return Response('Пароль изменен', status=status.HTTP_204_NO_CONTENT) @action( detail=True, methods=['post', 'delete'], permission_classes=[IsAuthenticated] ) def subscribe(self, request, id=None): """ Подписка/отписка на автора с id=kwargs['id']. """ user = request.user author = get_object_or_404(User, id=id) if request.method == 'POST': if user == author: return Response( {'errors': 'Нельзя подписаться на самого себя.'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем, нет ли уже такой подписки. # Либо get_or_create, либо проверка существования: if Follow.objects.filter(user=user, author=author).exists(): return Response({ 'errors': 'Вы уже подписаны на данного пользователя' }, status=status.HTTP_400_BAD_REQUEST) follow = Follow.objects.create(user=user, author=author) serializer = FollowUserSerializer( follow, context={'request': request} ) # Если DELETE follow = get_object_or_404(Follow, user=user, author=author) follow.delete() return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) '''def subscribe(self, request, **kwargs): # limit_param = request.query_params.get('recipes_limit') ----- лимит user = request.user author = get_object_or_404(User, id=self.kwargs.get('id')) if request.method == 'POST': serializer = FollowUserSerializer( data=request.data, context={'request': request, 'user': user, 'author': author} ) serializer.is_valid(raise_exception=True) Follow.objects.create(user=user, author=author) return Response(serializer.data, status=status.HTTP_201_CREATED) if request.method == 'DELETE': subscription = get_object_or_404( Follow, user=user, author=author ) subscription.delete() return Response(status=status.HTTP_204_NO_CONTENT)''' @action( detail=False, permission_classes=[IsAuthenticated] ) def subscriptions(self, request): queryset = User.objects.filter(follower__user=request.user) pages = self.paginate_queryset(queryset) serializer = FollowListSerializer(pages, many=True, context={'request': request}) return self.get_paginated_response(serializer.data) @action( detail=True, methods=['put', 'delete'], permission_classes=[IsAuthenticated]) def avatar(self, request, **kwargs): user = get_object_or_404(User, pk=request.user.id) if request.method == 'PUT': serializers = EditUserSerializer( user, data=request.data, partial=True, context={'request': request} ) serializers.is_valid(raise_exception=True) serializers.save() return Response({'avatar': serializers.data.get('avatar')}, status=HTTP_200_OK) user.avatar = None user.save() return Response(status=status.HTTP_204_NO_CONTENT)
сейчас получаю ошибку
FAILED
Статус-код ответа должен быть 201 | AssertionError: Запрос зарегистрированного пользователя на создание подписки должен вернуть ответ со статус-кодом 201: expected 'Internal Server Error' to deeply equal 'Created'
FAILED
Структура ответа должна соответствовать ожидаемой | JSONError: Unexpected token '<' at 1:1 <!DOCTYPE html> ^
|
Ниже приведён один из рабочих вариантов, как можно переписать сериализатор для одной подписки (когда мы хотим получить данные автора, на которого только что подписались) и сам метод subscribe
, чтобы при POST
возвращался статус-код 201
с нужной структурой, а при DELETE
– 204
, без данных в теле ответа.
Чаще всего по требованиям тестов (и фронтенда) при создании подписки нужно вернуть данные пользователя (автора), включая поля id
, username
, first_name
, last_name
, email
, а также признак is_subscribed
и краткий список его рецептов (обычно recipes
, recipes_count
). При этом модель Follow
здесь напрямую не нужна, потому что выводим мы поля из модели пользователя и лишь проверяем через Follow
, подписан ли текущий пользователь.
Пример такого «короткого» сериализатора (условно назовём его SubscriptionSerializer
). Он будет работать с моделью User
, но внутри вычислять, подписан ли текущий пользователь:
pythonfrom rest_framework import serializers from django.shortcuts import get_object_or_404 from django.contrib.auth import get_user_model from recipes.models import Recipe from .models import Follow # ваша модель подписки User = get_user_model() class RecipeShortSerializer(serializers.ModelSerializer): """ Короткий сериализатор для вывода рецептов автора в списке подписок. Обычно включает только нужные поля, например name, image, cooking_time. """ class Meta: model = Recipe fields = ('id', 'name', 'image', 'cooking_time') class SubscriptionSerializer(serializers.ModelSerializer): """ Сериализатор для вывода данных автора (User), на которого подписывается текущий пользователь. """ is_subscribed = serializers.SerializerMethodField() recipes = RecipeShortSerializer(many=True, source='recipe', read_only=True) recipes_count = serializers.SerializerMethodField() class Meta: model = User fields = ( 'id', 'username', 'first_name', 'last_name', 'email', 'is_subscribed', 'recipes', 'recipes_count' ) def get_is_subscribed(self, obj): """Проверяем, подписан ли request.user на данного автора (obj).""" user = self.context['request'].user if user.is_anonymous: return False return Follow.objects.filter(user=user, author=obj).exists() def get_recipes_count(self, obj): """Подсчитываем число рецептов у автора (obj).""" return obj.recipe.count()
Обратите внимание:
model = User
мы указываем модель пользователя.get_is_subscribed
обращаемся к модели Follow
, чтобы проверить наличие связи user=текущий_пользователь, author=obj
.recipes_limit
, если нужно выводить не все рецепты, а обрезать их до лимита.source='recipe'
в поле recipes
ставим тот related_name
, который прописан у вас в модели Recipe
(обычно related_name='recipe'
или related_name='recipes'
). Убедитесь, что совпадает с реальным related_name
в модели.subscribe
во viewset
В UserViewSet
добавьте/исправьте метод-экшен так, чтобы он явно различал POST
и DELETE
и возвращал корректные статусы:
pythonfrom rest_framework.decorators import action from rest_framework.response import Response from rest_framework import status from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated # ... class UserViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = EditUserSerializer pagination_class = CustomPagination # ... @action( detail=True, methods=['post', 'delete'], permission_classes=[IsAuthenticated], url_path='subscribe' ) def subscribe(self, request, pk=None): """ POST /users/{id}/subscribe/ -> подписаться на автора (id) DELETE /users/{id}/subscribe/ -> отписаться от автора (id) """ user = request.user author = get_object_or_404(User, pk=pk) if request.method == 'POST': # Проверки if user == author: return Response( {'errors': 'Нельзя подписаться на самого себя.'}, status=status.HTTP_400_BAD_REQUEST ) if Follow.objects.filter(user=user, author=author).exists(): return Response( {'errors': 'Вы уже подписаны на этого пользователя.'}, status=status.HTTP_400_BAD_REQUEST ) # Создаём запись в модели Follow Follow.objects.create(user=user, author=author) # Возвращаем данные об авторе через наш SubscriptionSerializer serializer = SubscriptionSerializer(author, context={'request': request}) return Response(serializer.data, status=status.HTTP_201_CREATED) # Иначе DELETE subscription = get_object_or_404(Follow, user=user, author=author) subscription.delete() return Response(status=status.HTTP_204_NO_CONTENT)
Ключевые моменты:
if request.method == 'POST': ... return ...
и else: ... (DELETE)
на два разных участка кода, чтобы не смешивать логику.POST
после создания подписки возвращаем SubscriptionSerializer(author)
, а не саму запись Follow
. Тесты (и фронтенд) обычно хотят видеть данные профиля автора.201 CREATED
, а при удалении – 204 NO CONTENT
.400 BAD_REQUEST
.Чтобы вывести список всех подписок, вы уже использовали что-то вроде FollowListSerializer
. Но поскольку фронтенд чаще ожидает те же поля пользователя, что и при одиночном запросе, логика может быть очень похожей на SubscriptionSerializer
. Основное отличие в том, что QuerySet
у нас в таком случае обычно из User
, отфильтрованных по подпискам:
python# views.py @action( detail=False, permission_classes=[IsAuthenticated] ) def subscriptions(self, request): """ GET /users/subscriptions/ Список авторов, на которых подписан текущий пользователь. """ # Ищем всех пользователей, которые являются author # в связке Follow, где user = request.user queryset = User.objects.filter(author__user=request.user) # Применяем пагинацию pages = self.paginate_queryset(queryset) # Используем тот же сериализатор, что и при одиночном запросе serializer = SubscriptionSerializer(pages, many=True, context={'request': request}) return self.get_paginated_response(serializer.data)
related_name
вы используете в модели Follow
:
Тогда у автора естьpythonauthor = models.ForeignKey( User, on_delete=models.CASCADE, related_name='author', # ... )
author_set
, но вы, видимо, назвали его author
. Часто делают что-то типа related_name='following'
, но у вас иначе. В любом случае нужно, чтобы User.objects.filter(author__user=request.user)
корректно находил авторов.FollowListSerializer
полей, которых нет в модели Follow
, правильнее взять модель User
, а через методы get_is_subscribed
и т.д. проверять связь.subscribe
нужно явно разделять POST
и DELETE
, возвращать нужные статус-коды и в случае успеха для POST
сериализовать именно автора, а не объект подписки.Таким образом, тест, который ожидает 201 Created
с полями пользователя, перестанет ломаться на 500 Internal Server Error
, а при повторном запросе на подписку (если уже есть подписка) или попытке подписаться на себя вы получите `400 Bad
Warning: The response is not complete, would not consume credits.
FAILED
Статус-код ответа должен быть 400 | AssertionError: При попытке пользователя удалить несуществующую подписку должен вернуться ответ со статусом 400: expected 'Not Found' to deeply equal 'Bad Request'
FAILED
Статус-код ответа должен быть 400 | AssertionError: Запрос пользователя на удаление из избранного рецепта, который не был туда добавлен, должен вернуть ответ со статусом 400: expected 'Not Found' to deeply equal 'Bad Request'
•
Необходимо добавить проверку в action favorite и subscribe
@action(
detail=True,
methods=['post', 'delete'],
permission_classes=[IsAuthenticated],
url_path='favorite')
def favorite(self, request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
if request.method == 'POST':
if Favorite.objects.filter(user=request.user, recipe=recipe).exists():
return Response(
{'errors': 'Рецепт уже в избранном.'},
status=status.HTTP_400_BAD_REQUEST
)
Favorite.objects.create(user=request.user, recipe=recipe)
serializer = ListRecipeUsersSerializer(
recipe,
context={'request': request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if request.method == 'DELETE':
favorite_obj = get_object_or_404(
Favorite,
user=request.user,
recipe=recipe
)
deleted_count, _ = favorite_obj.delete()
if deleted_count:
return Response(
{'detail': 'Рецепт успешно удален из избранного.'},
status=status.HTTP_204_NO_CONTENT)
return Response(
{'detail': 'Рецепт не найден в избранном.'},
status=status.HTTP_400_BAD_REQUEST
)
def subscribe(self, request, pk=None):
"""
Подписка/отписка на автора с id=kwargs['id'].
"""
user = request.user
author = get_object_or_404(User, pk=pk)
limit_param = request.query_params.get('recipes_limit')
if request.method == 'POST':
if user == author:
return Response(
{'errors': 'Нельзя подписаться на самого себя.'},
status=status.HTTP_400_BAD_REQUEST
)
# Проверяем, нет ли уже подписки
follow, created = Follow.objects.get_or_create(user=user, author=author)
if not created:
return Response(
{'errors': 'Вы уже подписаны на этого автора.'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = FollowListSerializer(
author, context={'request': request,
'limit_param': limit_param}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if request.method == 'DELETE':
follow = get_object_or_404(Follow, user=user, author=author)
follow.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Нужно понимать, что get_object_or_404(...)
при отсутствии записи в БД автоматически выбрасывает 404 Not Found, а по вашим тестам при попытке удалить несуществующую подписку (или рецепт из избранного) должен возвращаться статус-код 400 Bad Request.
Поэтому, когда тесты хотят вместо «404 Not Found» ответить «400 Bad Request», нельзя использовать get_object_or_404()
– нужно вручную проверять наличие записи в базе. Ниже приведён пример, как изменить методы favorite
и subscribe
, чтобы при удалении не существующей подписки/избранного возвращался код 400.
favorite
Чтобы при DELETE /favorite/
вернуть 400 для несуществующего избранного, вместо get_object_or_404
используем фильтрацию и проверку:
python@action( detail=True, methods=['post', 'delete'], permission_classes=[IsAuthenticated], url_path='favorite' ) def favorite(self, request, pk): recipe = get_object_or_404(Recipe, pk=pk) if request.method == 'POST': if Favorite.objects.filter(user=request.user, recipe=recipe).exists(): return Response( {'errors': 'Рецепт уже в избранном.'}, status=status.HTTP_400_BAD_REQUEST ) Favorite.objects.create(user=request.user, recipe=recipe) serializer = ListRecipeUsersSerializer( recipe, context={'request': request} ) return Response(serializer.data, status=status.HTTP_201_CREATED) # Если DELETE favorite_obj = Favorite.objects.filter( user=request.user, recipe=recipe ).first() if not favorite_obj: # Здесь возвращаем 400, а не 404 return Response( {'errors': 'Рецепт не найден в избранном.'}, status=status.HTTP_400_BAD_REQUEST ) favorite_obj.delete() return Response( {'detail': 'Рецепт успешно удален из избранного.'}, status=status.HTTP_204_NO_CONTENT )
get_object_or_404(Favorite, ...)
мы используем Favorite.objects.filter(...).first()
.favorite_obj
равен None
, то возвращаем 400.subscribe
По аналогии, в методе подписки при DELETE
нужно вернуть 400, если подписки нет:
pythondef subscribe(self, request, pk=None): """ Подписка/отписка на автора с id=pk. """ user = request.user author = get_object_or_404(User, pk=pk) limit_param = request.query_params.get('recipes_limit') if request.method == 'POST': if user == author: return Response( {'errors': 'Нельзя подписаться на самого себя.'}, status=status.HTTP_400_BAD_REQUEST ) follow, created = Follow.objects.get_or_create(user=user, author=author) if not created: return Response( {'errors': 'Вы уже подписаны на этого автора.'}, status=status.HTTP_400_BAD_REQUEST ) serializer = FollowListSerializer( author, context={'request': request, 'limit_param': limit_param} ) return Response(serializer.data, status=status.HTTP_201_CREATED) # Если DELETE follow = Follow.objects.filter(user=user, author=author).first() if not follow: # Возвращаем 400, а не 404 return Response( {'errors': 'Подписка не найдена.'}, status=status.HTTP_400_BAD_REQUEST ) follow.delete() return Response(status=status.HTTP_204_NO_CONTENT)
DELETE
вместо get_object_or_404
используем .filter(...).first()
.None
, возвращаем 400 с сообщением "Подписка не найдена."
.get_object_or_404(...)
даёт 404 Not Found.get_object_or_404
вручную проверять наличие и, если запись не найдена, возвращать 400.Таким образом, тесты вроде:
При попытке пользователя удалить несуществующую подписку должен вернуться ответ со статусом 400
При удалении из избранного рецепта, который не был туда добавлен, должен вернуться ответ со статусом 400
начнут проходить без ошибок.