diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29337cc..38037a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11"] services: postgres: diff --git a/backend/api/v1/serializers.py b/backend/api/v1/serializers.py index b781d94..b36681b 100644 --- a/backend/api/v1/serializers.py +++ b/backend/api/v1/serializers.py @@ -12,6 +12,7 @@ from users.models import User as ClassUser from utils.authcode import AuthCode from utils.users import get_user_by_email_or_404 +from utils.amount_skips import counts_missed_days from .constants import FORMAT_DATE, FORMAT_DATETIME, FORMAT_TIME from .fields import Base64ImageField @@ -36,6 +37,13 @@ class UserTimezoneSerializer(serializers.ModelSerializer): class Meta: model = User fields = ("timezone",) + extra_kwargs = {"timezone": {"required": True}} + + def validate_timezone(self, value: str) -> str: + """Валидация timezone пользователя.""" + if value in pytz.all_timezones_set: + return value + raise serializers.ValidationError("Несуществующая timezone.") class MeSerializer(serializers.ModelSerializer): @@ -94,18 +102,17 @@ def validate_amount_of_skips(self, value: int) -> int: return value def validate_date_last_skips(self, value: datetime) -> datetime: - user = self.context["request"].user + user: ClassUser = self.context["request"].user date_last_skips = user.date_last_skips user_timezone = pytz.timezone(user.timezone) localdate = timezone.localdate(timezone=user_timezone) user_timezone_value = value.astimezone(user_timezone).date() - if not date_last_skips: - if localdate != user_timezone_value: - raise serializers.ValidationError("День заморозки должен быть равен текущему дню.") - return value - date_last_skips = date_last_skips.astimezone(user_timezone).date() - if date_last_skips >= user_timezone_value: - raise serializers.ValidationError(f"День заморозки должен быть больше {date_last_skips}.") + if localdate != user_timezone_value: + raise serializers.ValidationError("День заморозки должен быть равен текущему дню.") + if date_last_skips: + date_last_skips = date_last_skips.astimezone(user_timezone).date() + if date_last_skips == user_timezone_value: + raise serializers.ValidationError("Тренировка уже заморожена.") return value def create(self, validated_data: dict) -> ClassUser: @@ -142,7 +149,7 @@ def validate(self, attrs): authcode = AuthCode(user) if authcode.code_is_valid(attrs["code"]): return attrs - raise serializers.ValidationError({"code": ["Неверный или устаревший код"]}) + raise serializers.ValidationError({"code": ["Неверный или устаревший код."]}) def create(self, validated_data): user = User.objects.get(email=validated_data["email"]) @@ -260,7 +267,7 @@ def to_representation(self, instance: History) -> dict: def validate(self, data: OrderedDict) -> OrderedDict: if data["training_start"] >= data["training_end"]: raise serializers.ValidationError( - {"training_start_training_end": ["Время начала тренировки должно быть раньше конца"]} + {"training_start_training_end": ["Время начала тренировки должно быть раньше конца."]} ) return data @@ -276,7 +283,18 @@ def _validate_date(self, value: datetime, name_field: str) -> datetime: raise serializers.ValidationError("Дата должна быть больше прошлой тренировки.") return value + def _check_lock_training(self, value: datetime) -> None: + """Проверка блокировки челленджа.""" + user: ClassUser = self.context["request"].user + if not user.last_completed_training: + return + now = value.astimezone(pytz.timezone(user.timezone)) + days_missed, *_ = counts_missed_days(user, user.timezone, now) + if user.amount_of_skips < days_missed: + raise serializers.ValidationError("Невозможно сохранить тренировку при заблокированном челлендже.") + def validate_training_start(self, value: datetime) -> datetime: + self._check_lock_training(value) return self._validate_date(value, "training_start") def validate_training_end(self, value: datetime) -> datetime: @@ -284,7 +302,7 @@ def validate_training_end(self, value: datetime) -> datetime: def validate_motivation_phrase(self, value: str) -> str: if not MotivationalPhrase.objects.filter(text=value).exists(): - raise serializers.ValidationError("Данной мотивационной фразы не существует") + raise serializers.ValidationError("Данной мотивационной фразы не существует.") return value def validate_training_day(self, value: Day) -> Day: @@ -292,14 +310,14 @@ def validate_training_day(self, value: Day) -> Day: if last_completed_training: day_number_last_training = last_completed_training.training_day.day_number if value.day_number - 1 != day_number_last_training: - raise serializers.ValidationError(f"День тренировки должен быть равен {day_number_last_training+1}") + raise serializers.ValidationError(f"День тренировки должен быть равен {day_number_last_training+1}.") elif value.day_number != 1: - raise serializers.ValidationError("День тренировки должен быть равен 1") + raise serializers.ValidationError("День тренировки должен быть равен 1.") return value def validate_achievements(self, value: list) -> list: if value and len(value) > Achievement.objects.filter(id__in=value).count(): - raise serializers.ValidationError("Некорректные ачивки") + raise serializers.ValidationError("Некорректные ачивки.") return value def get_time(self, obj: History) -> int: @@ -314,7 +332,25 @@ def create(self, validated_data: dict) -> History: return super().create(validated_data) -class BoolSerializer(serializers.Serializer): - """Сериализатор bool значения.""" +class ResponseUserDefaultSerializer(serializers.Serializer): + """Сериализатор возвращаемого значения UserDefaultView.""" + + default = serializers.BooleanField() + + +class ResponseUpdateSerializer(serializers.Serializer): + """Сериализатор возрващаемого значения UpdateView.""" + + enough = serializers.BooleanField() + + +class ResponseResendCodeSerializer(serializers.Serializer): + """Сериализатор возрващаемого значения ResendCodeView.""" + + result = serializers.CharField(default="Код создан и отправлен") + + +class ResponseHealthCheckSerializer(serializers.Serializer): + """Сериализатор возрващаемого значения HealthCheckView.""" - updated = serializers.BooleanField() + Health = serializers.CharField(default="OK") diff --git a/backend/api/v1/views.py b/backend/api/v1/views.py index 9c145c9..9cf6a65 100644 --- a/backend/api/v1/views.py +++ b/backend/api/v1/views.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime import pytz from django.contrib.auth import get_user_model @@ -11,20 +11,24 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView - +from rest_framework_simplejwt.serializers import TokenRefreshSerializer from running.models import Achievement, Day, History, UserAchievement from users.constants import DEFAULT_AMOUNT_OF_SKIPS from users.models import User as ClassUser from utils import authcode, mailsender, motivation_phrase, users from utils.achievements import AchievementUpdater +from utils.amount_skips import counts_missed_days from .serializers import ( AchievementEndTrainingSerializer, AchievementSerializer, - BoolSerializer, CustomTokenObtainSerializer, HistorySerializer, MeSerializer, + ResponseHealthCheckSerializer, + ResponseResendCodeSerializer, + ResponseUpdateSerializer, + ResponseUserDefaultSerializer, TrainingSerializer, UserSerializer, UserTimezoneSerializer, @@ -44,14 +48,23 @@ class HealthCheckView(APIView): permission_classes = (AllowAny,) @extend_schema( + responses={200: ResponseHealthCheckSerializer()}, summary="Проверка работы", - description="Проверка работы АПИ", + description="Проверка работы API", tags=("System",), ) def get(self, request): return Response({"Health": "OK"}) +@extend_schema_view( + post=extend_schema( + responses={201: UserSerializer()}, + summary="Создание пользователя", + description="Создание пользователя", + tags=("User",), + ), +) class RegisterUserView(APIView): serializer_class = UserSerializer permission_classes = (AllowAny,) @@ -67,9 +80,10 @@ def post(self, request, format=None): @extend_schema_view( post=extend_schema( + responses={201: ResponseResendCodeSerializer()}, summary="Повторная отправка кода", description="Повторная отправка кода", - tags=("api",), + tags=("User",), ), ) class ResendCodeView(APIView): @@ -83,6 +97,14 @@ def post(self, request): return Response({"result": "Код создан и отправлен"}, status=status.HTTP_201_CREATED) +@extend_schema_view( + post=extend_schema( + responses={200: TokenRefreshSerializer()}, + summary="Обновление токена", + description="Обновление токена", + tags=("User",), + ), +) class TokenRefreshView(APIView): serializer_class = CustomTokenObtainSerializer throttle_classes = (DurationCooldownRequestThrottle,) @@ -91,31 +113,39 @@ class TokenRefreshView(APIView): def post(self, request, format=None): serializer = self.serializer_class(data=request.data) if serializer.is_valid(): - token_data = serializer.save() + token_data: dict = serializer.save() + token_data.pop("refresh") return Response(token_data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class MyInfoView(APIView): +@extend_schema_view( + get=extend_schema( + responses={200: MeSerializer()}, + summary="Отдаёт данные по пользователю", + description="Отдаёт данные по пользователю", + tags=("User",), + ), + patch=extend_schema( + responses={200: MeSerializer()}, + summary="Обновляет данные пользователя", + description="Обновляет данные пользователя", + tags=("User",), + ), + put=extend_schema(exclude=True), + delete=extend_schema( + responses={204: MeSerializer()}, + summary="Удаляет пользователя", + description="Удаляет пользователя", + tags=("User",), + ), +) +class MyInfoView(generics.RetrieveUpdateDestroyAPIView): serializer_class = MeSerializer - def get(self, request, *args, **kwargs): - user = request.user - serializer = self.serializer_class(user, context={"request": request}) - return Response(serializer.data) - - def patch(self, request, *args, **kwargs): - user = request.user - serializer = self.serializer_class(user, data=request.data, context={"request": request}, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - user = request.user - user.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + def get_object(self): + """Отдаёт объект пользователя.""" + return self.request.user @extend_schema_view( @@ -230,22 +260,15 @@ def create(self, request: Request, *args, **kwargs) -> Response: @extend_schema_view( patch=extend_schema( - responses={200: BoolSerializer()}, + responses={200: ResponseUpdateSerializer()}, summary="Обновляет заморозки у пользователя и сохраняет часовой пояс", description="Обновляет заморозки у пользователя и сохраняет часовой пояс", - tags=("System",), + tags=("User",), ), ) class UpdateView(APIView): serializer_class = UserTimezoneSerializer - def _get_date_activity(self, user: ClassUser, user_timezone: str) -> datetime: - """Отдаёт дату последней активности в виде тренировки или заморозки.""" - date_activity = max( - [date for date in [user.date_last_skips, user.last_completed_training.training_start] if date is not None] - ) - return date_activity.astimezone(pytz.timezone(user_timezone)) - def _updates_skip_data( self, user: ClassUser, amount_of_skips: int, days_missed: int, date_day_ago: datetime ) -> None: @@ -254,12 +277,10 @@ def _updates_skip_data( user.date_last_skips = date_day_ago user.save() - def _clearing_user_training_data(self, user: ClassUser) -> None: - """Очищает данные по тренировка пользователя.""" - user.amount_of_skips = DEFAULT_AMOUNT_OF_SKIPS - user.date_last_skips = None + def _set_null_amount_of_skip(self, user: ClassUser) -> None: + """Устанавливает значение заморозок у пользователя равное нулю.""" + user.amount_of_skips = 0 user.save() - History.objects.filter(user_id=user).delete() def _update_user_timezone_data(self, user: ClassUser, user_timezone: str) -> None: """Обновляет timezone ползователя.""" @@ -271,36 +292,32 @@ def patch(self, request: Request, *args, **kwargs) -> Response: serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) user_timezone = request.data.get("timezone") - response = Response({"updated": True}, status=status.HTTP_200_OK) - user = request.user - last_traning = user.last_completed_training + response = Response({"enough": True}, status=status.HTTP_200_OK) + user: ClassUser = request.user + last_traning: History = user.last_completed_training if not last_traning or last_traning.training_day.day_number == 100: self._update_user_timezone_data(user, user_timezone) return response - - date_activity = self._get_date_activity(user, user_timezone) - amount_of_skips = user.amount_of_skips - date_day_ago = timezone.localtime(timezone=pytz.timezone(user_timezone)) - timedelta(days=1) - days_missed = (date_day_ago.date() - date_activity.date()).days + 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 - user.timezone = user_timezone if amount_of_skips >= days_missed: self._updates_skip_data(user, amount_of_skips, days_missed, date_day_ago) - else: - self._clearing_user_training_data(user) - return response + return response + self._set_null_amount_of_skip(user) + return Response({"enough": False}, status=status.HTTP_200_OK) @extend_schema_view( patch=extend_schema( - responses={200: BoolSerializer()}, + responses={200: ResponseUserDefaultSerializer()}, summary="Очищает данные по тренировкам и ачивки пользователя", description="Очищает данные по тренировкам и ачивки пользователя", - tags=("System",), + tags=("User",), ), ) class UserDefaultView(APIView): @@ -315,4 +332,4 @@ def patch(self, request: Request, *args, **kwargs) -> Response: user_history.delete() user_achievements: QuerySet[UserAchievement] = user.user_achievements.all() user_achievements.delete() - return Response({"updated": True}, status=status.HTTP_200_OK) + return Response({"default": True}, status=status.HTTP_200_OK) diff --git a/backend/utils/amount_skips.py b/backend/utils/amount_skips.py new file mode 100644 index 0000000..9ae1529 --- /dev/null +++ b/backend/utils/amount_skips.py @@ -0,0 +1,25 @@ +from datetime import datetime, timedelta + +import pytz + +from users.models import User + + +def get_date_activity(user: User, user_timezone: str) -> datetime: + """Отдаёт дату последней активности в виде тренировки или заморозки.""" + date_activity: datetime = max( + [date for date in [user.date_last_skips, user.last_completed_training.training_start] if date is not None] + ) + return date_activity.astimezone(pytz.timezone(user_timezone)) + + +def counts_missed_days(user: User, user_timezone: str, now: datetime) -> tuple[int, datetime, int]: + """ + Просчитывает кол-во пропущенных дней и возвращает: + пропущенные дни, дату день назад, кол-во заморозок пользователя + """ + date_activity = get_date_activity(user, user_timezone) + amount_of_skips = user.amount_of_skips + date_day_ago = now - timedelta(days=1) + days_missed = (date_day_ago.date() - date_activity.date()).days + return days_missed, date_day_ago, amount_of_skips diff --git a/docs/schema.yml b/docs/schema.yml index 7ad4201..1123371 100644 --- a/docs/schema.yml +++ b/docs/schema.yml @@ -25,7 +25,7 @@ paths: /api/v1/health/: get: operationId: api_v1_health_retrieve - description: Проверка работы АПИ + description: Проверка работы API summary: Проверка работы tags: - System @@ -34,7 +34,11 @@ paths: - {} responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/ResponseHealthCheck' + description: '' /api/v1/history/: get: operationId: api_v1_history_list @@ -85,8 +89,10 @@ paths: /api/v1/me/: get: operationId: api_v1_me_retrieve + description: Отдаёт данные по пользователю + summary: Отдаёт данные по пользователю tags: - - api + - User security: - jwtAuth: [] responses: @@ -98,8 +104,10 @@ paths: description: '' patch: operationId: api_v1_me_partial_update + description: Обновляет данные пользователя + summary: Обновляет данные пользователя tags: - - api + - User requestBody: content: application/json: @@ -122,20 +130,26 @@ paths: description: '' delete: operationId: api_v1_me_destroy + description: Удаляет пользователя + summary: Удаляет пользователя tags: - - api + - User security: - jwtAuth: [] responses: '204': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/Me' + description: '' /api/v1/resend_code/: post: operationId: api_v1_resend_code_create description: Повторная отправка кода summary: Повторная отправка кода tags: - - api + - User requestBody: content: application/json: @@ -152,17 +166,19 @@ paths: - jwtAuth: [] - {} responses: - '200': + '201': content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/ResponseResendCode' description: '' /api/v1/token/refresh/: post: operationId: api_v1_token_refresh_create + description: Обновление токена + summary: Обновление токена tags: - - api + - User requestBody: content: application/json: @@ -183,7 +199,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CustomTokenObtain' + $ref: '#/components/schemas/TokenRefresh' description: '' /api/v1/training/: get: @@ -209,7 +225,7 @@ paths: description: Обновляет заморозки у пользователя и сохраняет часовой пояс summary: Обновляет заморозки у пользователя и сохраняет часовой пояс tags: - - System + - User requestBody: content: application/json: @@ -228,13 +244,15 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Bool' + $ref: '#/components/schemas/ResponseUpdate' description: '' /api/v1/user/: post: operationId: api_v1_user_create + description: Создание пользователя + summary: Создание пользователя tags: - - api + - User requestBody: content: application/json: @@ -251,7 +269,7 @@ paths: - jwtAuth: [] - {} responses: - '200': + '201': content: application/json: schema: @@ -263,7 +281,7 @@ paths: description: Очищает данные по тренировкам и ачивки пользователя summary: Очищает данные по тренировкам и ачивки пользователя tags: - - System + - User security: - jwtAuth: [] responses: @@ -271,7 +289,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Bool' + $ref: '#/components/schemas/ResponseUserDefault' description: '' components: schemas: @@ -329,14 +347,6 @@ components: required: - icon - title - Bool: - type: object - description: Сериализатор bool значения. - properties: - updated: - type: boolean - required: - - updated CustomTokenObtain: type: object properties: @@ -529,6 +539,48 @@ components: nullable: true title: Часовой пояс пользователя maxLength: 100 + ResponseHealthCheck: + type: object + description: Сериализатор возрващаемого значения HealthCheckView. + properties: + Health: + type: string + default: OK + ResponseResendCode: + type: object + description: Сериализатор возрващаемого значения ResendCodeView. + properties: + result: + type: string + default: Код создан и отправлен + ResponseUpdate: + type: object + description: Сериализатор возрващаемого значения UpdateView. + properties: + enough: + type: boolean + required: + - enough + ResponseUserDefault: + type: object + description: Сериализатор возвращаемого значения UserDefaultView. + properties: + default: + type: boolean + required: + - default + TokenRefresh: + type: object + properties: + access: + type: string + readOnly: true + refresh: + type: string + writeOnly: true + required: + - access + - refresh Training: type: object description: Сериализатор тренировок. diff --git a/test/api_tests/token_generation_tests.py b/test/api_tests/token_generation_tests.py index a043339..4d5e125 100644 --- a/test/api_tests/token_generation_tests.py +++ b/test/api_tests/token_generation_tests.py @@ -46,9 +46,7 @@ def get_users_token(client, user): def test_correct_code_yields_token(get_users_token): response = get_users_token assert response.status_code == status.HTTP_200_OK - assert "refresh" in response.data assert "access" in response.data - assert response.data["refresh"] != "" assert response.data["access"] != ""