diff --git a/backend/api/v1/serializers.py b/backend/api/v1/serializers.py index b36681b..7a4ac27 100644 --- a/backend/api/v1/serializers.py +++ b/backend/api/v1/serializers.py @@ -57,6 +57,7 @@ class MeSerializer(serializers.ModelSerializer): last_completed_training = serializers.IntegerField( source="last_completed_training.training_day.day_number", read_only=True ) + blocked_training = serializers.BooleanField(read_only=True) date_last_skips = serializers.DateTimeField(required=False) amount_of_skips = serializers.IntegerField(required=False) avatar = Base64ImageField(allow_null=True, required=False) @@ -70,6 +71,7 @@ class Meta: "height_cm", "weight_kg", "last_completed_training", + "blocked_training", "date_last_skips", "amount_of_skips", "avatar", @@ -290,7 +292,7 @@ def _check_lock_training(self, value: datetime) -> None: return now = value.astimezone(pytz.timezone(user.timezone)) days_missed, *_ = counts_missed_days(user, user.timezone, now) - if user.amount_of_skips < days_missed: + if user.blocked_training or user.amount_of_skips < days_missed: raise serializers.ValidationError("Невозможно сохранить тренировку при заблокированном челлендже.") def validate_training_start(self, value: datetime) -> datetime: @@ -342,6 +344,7 @@ class ResponseUpdateSerializer(serializers.Serializer): """Сериализатор возрващаемого значения UpdateView.""" enough = serializers.BooleanField() + skip = serializers.BooleanField() class ResponseResendCodeSerializer(serializers.Serializer): diff --git a/backend/api/v1/validators.py b/backend/api/v1/validators.py index 4476a86..b27ecbb 100644 --- a/backend/api/v1/validators.py +++ b/backend/api/v1/validators.py @@ -3,4 +3,6 @@ class CustomUniqueValidator(UniqueValidator): + """Кастомный валидатор уникальности email.""" + message = _("Такой email уже существует.") diff --git a/backend/api/v1/views.py b/backend/api/v1/views.py index 9cf6a65..72114d9 100644 --- a/backend/api/v1/views.py +++ b/backend/api/v1/views.py @@ -38,7 +38,7 @@ User = get_user_model() -def send_auth_code(user) -> None: +def send_auth_code(user: ClassUser) -> None: auth_code = authcode.AuthCode(user) auth_code.set_sender(mailsender.DefaultMailSender()) auth_code.create_code() @@ -53,7 +53,7 @@ class HealthCheckView(APIView): description="Проверка работы API", tags=("System",), ) - def get(self, request): + def get(self, request: Request) -> Response: return Response({"Health": "OK"}) @@ -69,7 +69,7 @@ class RegisterUserView(APIView): serializer_class = UserSerializer permission_classes = (AllowAny,) - def post(self, request, format=None): + def post(self, request: Request) -> Response: serializer = self.serializer_class(data=request.data, context={"request": request}) if serializer.is_valid(): serializer.save() @@ -91,7 +91,7 @@ class ResendCodeView(APIView): throttle_classes = (DurationCooldownRequestThrottle,) serializer_class = UserSerializer - def post(self, request): + def post(self, request: Request) -> Response: user = users.get_user_by_email_or_404(request.data.get("email")) send_auth_code(user) return Response({"result": "Код создан и отправлен"}, status=status.HTTP_201_CREATED) @@ -110,7 +110,7 @@ class TokenRefreshView(APIView): throttle_classes = (DurationCooldownRequestThrottle,) permission_classes = (AllowAny,) - def post(self, request, format=None): + def post(self, request: Request) -> Response: serializer = self.serializer_class(data=request.data) if serializer.is_valid(): token_data: dict = serializer.save() @@ -143,7 +143,7 @@ def post(self, request, format=None): class MyInfoView(generics.RetrieveUpdateDestroyAPIView): serializer_class = MeSerializer - def get_object(self): + def get_object(self) -> ClassUser: """Отдаёт объект пользователя.""" return self.request.user @@ -160,7 +160,7 @@ class TrainingView(generics.ListAPIView): queryset = Day.objects.all() serializer_class = TrainingSerializer - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[History]: """ Формирует список тренировок с динамическими фразами и флагом завершения тренировки. @@ -189,7 +189,7 @@ def get_queryset(self) -> QuerySet: class AchievementView(generics.ListAPIView): serializer_class = AchievementSerializer - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[Achievement]: """Формирует список ачивок c флагом получения и датой.""" user = self.request.user sub_queryset = UserAchievement.objects.filter(user_id=user).values("achievement_id", "achievement_date") @@ -226,7 +226,7 @@ def get_queryset(self) -> QuerySet: class HistoryView(generics.ListCreateAPIView): serializer_class = HistorySerializer - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[History]: """Формирует список историй тренировок пользователя.""" return self.request.user.user_history.order_by("training_day") @@ -278,8 +278,12 @@ def _updates_skip_data( user.save() def _set_null_amount_of_skip(self, user: ClassUser) -> None: - """Устанавливает значение заморозок у пользователя равное нулю.""" + """ + Устанавливает значение заморозок у пользователя равное нулю + и блокирует тренировки. + """ user.amount_of_skips = 0 + user.blocked_training = True user.save() def _update_user_timezone_data(self, user: ClassUser, user_timezone: str) -> None: @@ -289,27 +293,40 @@ def _update_user_timezone_data(self, user: ClassUser, user_timezone: str) -> Non user.save() def patch(self, request: Request, *args, **kwargs) -> Response: + """Обновляет timezone пользователя и просчитывает пропуски тренировок.""" serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) user_timezone = request.data.get("timezone") - response = Response({"enough": True}, status=status.HTTP_200_OK) + response_data = { + "data": { + "enough": True, + "skip": False, + }, + "status": status.HTTP_200_OK, + } user: ClassUser = request.user last_traning: History = user.last_completed_training - + if user.blocked_training: + response_data["data"]["enough"] = False + self._update_user_timezone_data(user, user_timezone) + return Response(**response_data) if not last_traning or last_traning.training_day.day_number == 100: self._update_user_timezone_data(user, user_timezone) - return response + return Response(**response_data) now = timezone.localtime(timezone=pytz.timezone(user_timezone)) days_missed, date_day_ago, amount_of_skips = counts_missed_days(user, user_timezone, now) if days_missed <= 0: self._update_user_timezone_data(user, user_timezone) - return response + return Response(**response_data) user.timezone = user_timezone if amount_of_skips >= days_missed: self._updates_skip_data(user, amount_of_skips, days_missed, date_day_ago) - return response + response_data["data"]["skip"] = True + return Response(**response_data) self._set_null_amount_of_skip(user) - return Response({"enough": False}, status=status.HTTP_200_OK) + response_data["data"]["enough"] = False + response_data["data"]["skip"] = True + return Response(**response_data) @extend_schema_view( @@ -327,6 +344,7 @@ def patch(self, request: Request, *args, **kwargs) -> Response: user.date_last_skips = None user.amount_of_skips = DEFAULT_AMOUNT_OF_SKIPS user.total_m_run = 0 + user.blocked_training = False user.save() user_history: QuerySet[History] = user.user_history.all() user_history.delete() diff --git a/backend/users/admin.py b/backend/users/admin.py index 88129a0..19174a2 100644 --- a/backend/users/admin.py +++ b/backend/users/admin.py @@ -25,6 +25,7 @@ class CustomUserAdmin(admin.ModelAdmin): { "fields": ( "last_completed_training", + "blocked_training", "date_last_skips", "amount_of_skips", "avatar", @@ -53,6 +54,7 @@ class CustomUserAdmin(admin.ModelAdmin): if not DEBUG: readonly_fields = ( "last_completed_training", + "blocked_training", "date_last_skips", "timezone", "email", diff --git a/backend/users/migrations/0011_user_blocked_training.py b/backend/users/migrations/0011_user_blocked_training.py new file mode 100644 index 0000000..40d47b6 --- /dev/null +++ b/backend/users/migrations/0011_user_blocked_training.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-06-13 21:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_alter_user_total_m_run'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='blocked_training', + field=models.BooleanField(default=False, verbose_name='Блокировка тренировок'), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index d79c412..0a6c832 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -35,6 +35,7 @@ class User(AbstractUser): null=True, blank=True, ) + blocked_training = models.BooleanField(_("Блокировка тренировок"), default=False) date_last_skips = models.DateTimeField(_("Дата последнего пропуска"), null=True, blank=True) amount_of_skips = models.PositiveSmallIntegerField( _("Количество доступных пропусков/заморозок"), default=DEFAULT_AMOUNT_OF_SKIPS diff --git a/docs/schema.yml b/docs/schema.yml index 1123371..5080822 100644 --- a/docs/schema.yml +++ b/docs/schema.yml @@ -483,6 +483,9 @@ components: last_completed_training: type: integer readOnly: true + blocked_training: + type: boolean + readOnly: true date_last_skips: type: string format: date-time @@ -493,6 +496,7 @@ components: format: uri nullable: true required: + - blocked_training - email - last_completed_training PatchedMe: @@ -521,6 +525,9 @@ components: last_completed_training: type: integer readOnly: true + blocked_training: + type: boolean + readOnly: true date_last_skips: type: string format: date-time @@ -559,8 +566,11 @@ components: properties: enough: type: boolean + skip: + type: boolean required: - enough + - skip ResponseUserDefault: type: object description: Сериализатор возвращаемого значения UserDefaultView. diff --git a/test/api_tests/achievment_tests.py b/test/api_tests/achievment_tests.py index 7fd2ee9..b638f28 100644 --- a/test/api_tests/achievment_tests.py +++ b/test/api_tests/achievment_tests.py @@ -1,4 +1,5 @@ import pytest +import pytz from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone @@ -20,5 +21,5 @@ def test_all_achievments_returns_correct(user_client, achievements) -> None: assert response.status_code == status.HTTP_200_OK assert len(response.data) == 3 achievement = response.data[0] - assert achievement["achievement_date"] == date.strftime(FORMAT_DATE) + assert achievement["achievement_date"] == date.astimezone(pytz.timezone(user.timezone)).strftime(FORMAT_DATE) assert achievement["received"] is not None