diff --git a/docs/modal-acknowledgements-api.md b/docs/modal-acknowledgements-api.md new file mode 100644 index 00000000..6fcdbe57 --- /dev/null +++ b/docs/modal-acknowledgements-api.md @@ -0,0 +1,33 @@ +# Подтверждения одноразовых пользовательских окон + +Продуктовые окна хранят состояние на backend, поэтому подтверждение действует для аккаунта во +всех браузерах и на всех устройствах. Cookie consent в этот контракт не входит и остаётся локальным +состоянием браузера. + +## Поля состояния + +- `CustomUser.verification_notice_acknowledged_at` — пользователь ознакомился с ожиданием + верификации. +- `CustomUser.profile_fill_prompt_acknowledged_at` — пользователь явно закрыл напоминание о + заполнении профиля. +- `PartnerProgramUserProfile.welcome_acknowledged_at` — участник ознакомился с приветствием + конкретной программы. + +Поля nullable. Обычный `PATCH /auth/users//` возвращает их только владельцу и не разрешает +изменять. Загрузка detail не записывает acknowledgement. + +## Idempotent actions + +- `POST /auth/users/current/acknowledge-verification-notice/` возвращает актуальный собственный + профиль. +- `POST /auth/users/current/acknowledge-profile-fill-prompt/` возвращает актуальный собственный + профиль. +- `POST /programs//acknowledge-welcome/` возвращает + `{"welcome_acknowledged_at": ""}`. + +Повторный POST сохраняет исходный timestamp и отвечает `200`. Program action доступен только +участнику соответствующей программы; посторонний пользователь получает `404`. + +`GET /programs//` для участника содержит read-only поле +`welcome_acknowledged_at`. Для собственного профиля current/detail serializers содержат оба +пользовательских timestamp; в ответе чужого профиля эти системные поля отсутствуют. diff --git a/partner_programs/migrations/0025_partnerprogramuserprofile_welcome_acknowledged_at.py b/partner_programs/migrations/0025_partnerprogramuserprofile_welcome_acknowledged_at.py new file mode 100644 index 00000000..8d62642b --- /dev/null +++ b/partner_programs/migrations/0025_partnerprogramuserprofile_welcome_acknowledged_at.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("partner_programs", "0024_evaluation_amended_at_evaluationamendment"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogramuserprofile", + name="welcome_acknowledged_at", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата подтверждения приветствия программы", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 8b67ce37..6fb16e32 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -1184,6 +1184,11 @@ class PartnerProgramUserProfile(models.Model): related_name="partner_program_profiles", ) partner_program_data = models.JSONField() + welcome_acknowledged_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата подтверждения приветствия программы", + ) datetime_created = models.DateTimeField(auto_now_add=True) datetime_updated = models.DateTimeField(auto_now=True) diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 7fc084e1..95929889 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -183,6 +183,7 @@ class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin): views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField(method_name="get_links") is_user_manager = serializers.SerializerMethodField(method_name="get_is_user_manager") + welcome_acknowledged_at = serializers.SerializerMethodField() def count_views(self, program): return get_views_count(program) @@ -199,6 +200,10 @@ def get_is_user_liked(self, obj): return is_fan(obj, user) return False + def get_welcome_acknowledged_at(self, _program): + program_user_profile = self.context.get("program_user_profile") + return getattr(program_user_profile, "welcome_acknowledged_at", None) + class Meta: model = PartnerProgram fields = ( @@ -219,6 +224,7 @@ class Meta: "datetime_evaluation_ends", "publish_projects_after_finish", "is_user_manager", + "welcome_acknowledged_at", "courses", "application_policy", ) diff --git a/partner_programs/tests/test_program_welcome_acknowledgement.py b/partner_programs/tests/test_program_welcome_acknowledgement.py new file mode 100644 index 00000000..12b44209 --- /dev/null +++ b/partner_programs/tests/test_program_welcome_acknowledgement.py @@ -0,0 +1,96 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class PartnerProgramWelcomeAcknowledgementTests(TestCase): + """Проверяет одно приветствие для каждой пары пользователь–программа.""" + + def setUp(self): + self.client = APIClient() + self.user_a = create_user(prefix="welcome-user-a") + self.user_b = create_user(prefix="welcome-user-b") + self.program_1 = create_partner_program(name="Welcome program 1") + self.program_2 = create_partner_program(name="Welcome program 2") + self.profile_a_1 = create_program_member(self.program_1, user=self.user_a) + self.profile_a_2 = create_program_member(self.program_2, user=self.user_a) + self.profile_b_1 = create_program_member(self.program_1, user=self.user_b) + + def test_detail_does_not_acknowledge_welcome_on_load(self): + self.client.force_authenticate(self.user_a) + + response = self.client.get(f"/programs/{self.program_1.id}/") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["is_user_member"]) + self.assertIsNone(response.data["welcome_acknowledged_at"]) + self.profile_a_1.refresh_from_db() + self.assertIsNone(self.profile_a_1.welcome_acknowledged_at) + + def test_welcome_acknowledgement_is_idempotent(self): + self.client.force_authenticate(self.user_a) + url = f"/programs/{self.program_1.id}/acknowledge-welcome/" + + first_response = self.client.post(url, format="json") + second_response = self.client.post(url, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertIsNotNone(first_response.data["welcome_acknowledged_at"]) + self.assertEqual( + second_response.data["welcome_acknowledged_at"], + first_response.data["welcome_acknowledged_at"], + ) + + def test_acknowledgement_is_independent_for_another_program(self): + self.client.force_authenticate(self.user_a) + self.client.post( + f"/programs/{self.program_1.id}/acknowledge-welcome/", + format="json", + ) + + response = self.client.get(f"/programs/{self.program_2.id}/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["welcome_acknowledged_at"]) + self.profile_a_2.refresh_from_db() + self.assertIsNone(self.profile_a_2.welcome_acknowledged_at) + + def test_acknowledgement_is_independent_for_another_user(self): + self.client.force_authenticate(self.user_a) + self.client.post( + f"/programs/{self.program_1.id}/acknowledge-welcome/", + format="json", + ) + + self.client.force_authenticate(self.user_b) + response = self.client.get(f"/programs/{self.program_1.id}/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["welcome_acknowledged_at"]) + self.profile_b_1.refresh_from_db() + self.assertIsNone(self.profile_b_1.welcome_acknowledged_at) + + def test_non_member_cannot_acknowledge_program_welcome(self): + outsider = create_user(prefix="welcome-outsider") + self.client.force_authenticate(outsider) + + response = self.client.post( + f"/programs/{self.program_1.id}/acknowledge-welcome/", + format="json", + ) + + self.assertEqual(response.status_code, 404) + + def test_anonymous_user_cannot_acknowledge_program_welcome(self): + response = self.client.post( + f"/programs/{self.program_1.id}/acknowledge-welcome/", + format="json", + ) + + self.assertEqual(response.status_code, 401) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 47a304ab..2fba577d 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -31,6 +31,7 @@ PartnerProgramRegister, PartnerProgramSetLiked, PartnerProgramSetViewed, + PartnerProgramWelcomeAcknowledgement, ProgramFiltersAPIView, ProgramProjectFilterAPIView, ) @@ -75,6 +76,11 @@ name="application-create", ), path("/", PartnerProgramDetail.as_view()), + path( + "/acknowledge-welcome/", + PartnerProgramWelcomeAcknowledgement.as_view(), + name="partner-program-acknowledge-welcome", + ), path( "partner-program-projects//submit/", PartnerProgramProjectSubmitView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 9f48b313..8961ddb1 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -104,14 +104,29 @@ class PartnerProgramDetail(generics.RetrieveAPIView): def get(self, request, *args, **kwargs): program = self.get_object() - is_user_member = program.users.filter(pk=request.user.pk).exists() + program_user_profile = None + if request.user.is_authenticated: + program_user_profile = ( + PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=request.user, + ) + .only("id", "welcome_acknowledged_at") + .first() + ) + is_user_member = program_user_profile is not None serializer_class = ( PartnerProgramForMemberSerializer if is_user_member else PartnerProgramForUnregisteredUserSerializer ) serializer = serializer_class( - program, context={"request": request, "user": request.user} + program, + context={ + "request": request, + "user": request.user, + "program_user_profile": program_user_profile, + }, ) data = serializer.data data["is_user_member"] = is_user_member @@ -120,6 +135,30 @@ def get(self, request, *args, **kwargs): return Response(data, status=status.HTTP_200_OK) +class PartnerProgramWelcomeAcknowledgement(APIView): + """Идемпотентно фиксирует приветствие для текущего участника программы.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + program_user_profile = get_object_or_404( + PartnerProgramUserProfile, + partner_program_id=pk, + user=request.user, + ) + if program_user_profile.welcome_acknowledged_at is None: + PartnerProgramUserProfile.objects.filter( + pk=program_user_profile.pk, + welcome_acknowledged_at__isnull=True, + ).update(welcome_acknowledged_at=timezone.now()) + program_user_profile.refresh_from_db(fields=["welcome_acknowledged_at"]) + + return Response( + {"welcome_acknowledged_at": program_user_profile.welcome_acknowledged_at}, + status=status.HTTP_200_OK, + ) + + class PartnerProgramProjectApplyView(GenericAPIView): """ Создание проекта в рамках программы (подать проект). diff --git a/users/migrations/0062_customuser_modal_acknowledgements.py b/users/migrations/0062_customuser_modal_acknowledgements.py new file mode 100644 index 00000000..01e4d4fb --- /dev/null +++ b/users/migrations/0062_customuser_modal_acknowledgements.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0061_userlink_kind"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="verification_notice_acknowledged_at", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата подтверждения уведомления о верификации", + ), + ), + migrations.AddField( + model_name="customuser", + name="profile_fill_prompt_acknowledged_at", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата подтверждения напоминания о заполнении профиля", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 66990ee9..3c602e27 100644 --- a/users/models.py +++ b/users/models.py @@ -149,6 +149,16 @@ class CustomUser(AbstractUser): blank=True, verbose_name="Дата верификации", ) + verification_notice_acknowledged_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата подтверждения уведомления о верификации", + ) + profile_fill_prompt_acknowledged_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата подтверждения напоминания о заполнении профиля", + ) last_activity = models.DateTimeField( null=True, blank=True, diff --git a/users/serializers.py b/users/serializers.py index fa0900c0..2d9da22f 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -451,6 +451,8 @@ def get_year(self, program: PartnerProgram) -> int | None: class UserDetailSerializer( serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin ): + verification_notice_acknowledged_at = serializers.DateTimeField(read_only=True) + profile_fill_prompt_acknowledged_at = serializers.DateTimeField(read_only=True) member = MemberSerializer(required=False) investor = InvestorSerializer(required=False) expert = ExpertSerializer(required=False) @@ -555,6 +557,8 @@ class Meta: "mentor", "achievements", "verification_date", + "verification_notice_acknowledged_at", + "profile_fill_prompt_acknowledged_at", "onboarding_stage", "projects", "programs", @@ -763,6 +767,8 @@ def to_representation(self, instance) -> dict[str, Any]: request = self.context.get("request") if request and request.user != instance: representation.pop("phone_number", None) + representation.pop("verification_notice_acknowledged_at", None) + representation.pop("profile_fill_prompt_acknowledged_at", None) return representation def validate_phone_number(self, data): diff --git a/users/tests/test_modal_acknowledgements_api.py b/users/tests/test_modal_acknowledgements_api.py new file mode 100644 index 00000000..59c61417 --- /dev/null +++ b/users/tests/test_modal_acknowledgements_api.py @@ -0,0 +1,123 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import build_user + + +class UserModalAcknowledgementAPITests(TestCase): + """Проверяет account-level подтверждения пользовательских системных окон.""" + + def setUp(self): + self.client = APIClient() + self.user = build_user(email="modal-acknowledgements@example.com") + self.client.force_authenticate(self.user) + + def test_current_user_exposes_unacknowledged_notice_state(self): + response = self.client.get("/auth/users/current/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["verification_notice_acknowledged_at"]) + self.assertIsNone(response.data["profile_fill_prompt_acknowledged_at"]) + + def test_verification_notice_acknowledgement_is_idempotent(self): + url = "/auth/users/current/acknowledge-verification-notice/" + + first_response = self.client.post(url, format="json") + second_response = self.client.post(url, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertIsNotNone(first_response.data["verification_notice_acknowledged_at"]) + self.assertEqual( + second_response.data["verification_notice_acknowledged_at"], + first_response.data["verification_notice_acknowledged_at"], + ) + + def test_profile_fill_prompt_acknowledgement_is_idempotent(self): + url = "/auth/users/current/acknowledge-profile-fill-prompt/" + + first_response = self.client.post(url, format="json") + second_response = self.client.post(url, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertIsNotNone(first_response.data["profile_fill_prompt_acknowledged_at"]) + self.assertEqual( + second_response.data["profile_fill_prompt_acknowledged_at"], + first_response.data["profile_fill_prompt_acknowledged_at"], + ) + + def test_profile_update_cannot_reset_system_acknowledgements(self): + self.client.post( + "/auth/users/current/acknowledge-verification-notice/", + format="json", + ) + self.client.post( + "/auth/users/current/acknowledge-profile-fill-prompt/", + format="json", + ) + self.user.refresh_from_db() + verification_acknowledged_at = self.user.verification_notice_acknowledged_at + profile_fill_acknowledged_at = self.user.profile_fill_prompt_acknowledged_at + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "verification_notice_acknowledged_at": None, + "profile_fill_prompt_acknowledged_at": None, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual( + self.user.verification_notice_acknowledged_at, + verification_acknowledged_at, + ) + self.assertEqual( + self.user.profile_fill_prompt_acknowledged_at, + profile_fill_acknowledged_at, + ) + + def test_other_user_does_not_receive_private_acknowledgement_fields(self): + other_user = build_user(email="other-modal-user@example.com") + + response = self.client.get(f"/auth/users/{other_user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("verification_notice_acknowledged_at", response.data) + self.assertNotIn("profile_fill_prompt_acknowledged_at", response.data) + + def test_acknowledgements_are_independent_for_another_account(self): + self.client.post( + "/auth/users/current/acknowledge-verification-notice/", + format="json", + ) + self.client.post( + "/auth/users/current/acknowledge-profile-fill-prompt/", + format="json", + ) + other_user = build_user(email="independent-modal-user@example.com") + self.client.force_authenticate(other_user) + + response = self.client.get("/auth/users/current/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["verification_notice_acknowledged_at"]) + self.assertIsNone(response.data["profile_fill_prompt_acknowledged_at"]) + + def test_anonymous_user_cannot_acknowledge_notices(self): + self.client.force_authenticate(user=None) + + verification_response = self.client.post( + "/auth/users/current/acknowledge-verification-notice/", + format="json", + ) + profile_fill_response = self.client.post( + "/auth/users/current/acknowledge-profile-fill-prompt/", + format="json", + ) + + self.assertEqual(verification_response.status_code, 401) + self.assertEqual(profile_fill_response.status_code, 401) diff --git a/users/urls.py b/users/urls.py index 998adf36..b6fec4bc 100644 --- a/users/urls.py +++ b/users/urls.py @@ -4,6 +4,8 @@ from users.views import ( AchievementDetail, AchievementList, + AcknowledgeProfileFillPrompt, + AcknowledgeVerificationNotice, CurrentUser, PublicUserListView, SpecialistsList, @@ -64,6 +66,16 @@ path("users//news//set_liked/", NewsDetailSetLiked.as_view()), path("users//approve_skill//", UserSkillsApproveDeclineView.as_view()), path("users/current/", CurrentUser.as_view()), + path( + "users/current/acknowledge-verification-notice/", + AcknowledgeVerificationNotice.as_view(), + name="acknowledge-verification-notice", + ), + path( + "users/current/acknowledge-profile-fill-prompt/", + AcknowledgeProfileFillPrompt.as_view(), + name="acknowledge-profile-fill-prompt", + ), # todo: change password view path("users/current/programs/", CurrentUserPrograms.as_view()), path("users/current/programs/tags/", CurrentUserProgramsTags.as_view()), diff --git a/users/views.py b/users/views.py index abe49592..e0e850d8 100644 --- a/users/views.py +++ b/users/views.py @@ -259,6 +259,41 @@ def get(self, request): return Response(serializer.data, status=status.HTTP_200_OK) +def _acknowledge_current_user_notice(request, field_name: str) -> Response: + """Идемпотентно фиксирует подтверждение системного окна текущим пользователем.""" + User.objects.filter( + pk=request.user.pk, + **{f"{field_name}__isnull": True}, + ).update(**{field_name: timezone.now()}) + request.user.refresh_from_db(fields=[field_name]) + serializer = UserDetailSerializer(request.user, context={"request": request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class AcknowledgeVerificationNotice(APIView): + """Фиксирует ознакомление текущего пользователя с ожиданием верификации.""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + return _acknowledge_current_user_notice( + request, + "verification_notice_acknowledged_at", + ) + + +class AcknowledgeProfileFillPrompt(APIView): + """Фиксирует явное закрытие напоминания о заполнении профиля.""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + return _acknowledge_current_user_notice( + request, + "profile_fill_prompt_acknowledged_at", + ) + + class UserTypesView(APIView): permission_classes = [AllowAny]