Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/modal-acknowledgements-api.md
Original file line number Diff line number Diff line change
@@ -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/<id>/` возвращает их только владельцу и не разрешает
изменять. Загрузка detail не записывает acknowledgement.

## Idempotent actions

- `POST /auth/users/current/acknowledge-verification-notice/` возвращает актуальный собственный
профиль.
- `POST /auth/users/current/acknowledge-profile-fill-prompt/` возвращает актуальный собственный
профиль.
- `POST /programs/<program_id>/acknowledge-welcome/` возвращает
`{"welcome_acknowledged_at": "<datetime>"}`.

Повторный POST сохраняет исходный timestamp и отвечает `200`. Program action доступен только
участнику соответствующей программы; посторонний пользователь получает `404`.

`GET /programs/<program_id>/` для участника содержит read-only поле
`welcome_acknowledged_at`. Для собственного профиля current/detail serializers содержат оба
пользовательских timestamp; в ответе чужого профиля эти системные поля отсутствуют.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("partner_programs", "0017_alter_partnerprogramproject_options"),
]

operations = [
migrations.AddField(
model_name="partnerprogramuserprofile",
name="welcome_acknowledged_at",
field=models.DateTimeField(
blank=True,
null=True,
verbose_name="Дата подтверждения приветствия программы",
),
),
]
9 changes: 6 additions & 3 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,7 @@ class PartnerProgram(models.Model):
datetime_created = models.DateTimeField(
verbose_name="Дата создания", auto_now_add=True
)
datetime_updated = models.DateTimeField(
verbose_name="Дата изменения", auto_now=True
)
datetime_updated = models.DateTimeField(verbose_name="Дата изменения", auto_now=True)

def is_manager(self, user: User) -> bool:
"""
Expand Down Expand Up @@ -205,6 +203,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)

Expand Down
7 changes: 7 additions & 0 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,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)
Expand All @@ -159,6 +160,11 @@ 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 = (
Expand All @@ -179,6 +185,7 @@ class Meta:
"datetime_evaluation_ends",
"publish_projects_after_finish",
"is_user_manager",
"welcome_acknowledged_at",
"courses",
)

Expand Down
96 changes: 96 additions & 0 deletions partner_programs/tests/test_program_welcome_acknowledgement.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions partner_programs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
PartnerProgramRegister,
PartnerProgramSetLiked,
PartnerProgramSetViewed,
PartnerProgramWelcomeAcknowledgement,
ProgramFiltersAPIView,
ProgramProjectFilterAPIView,
)
Expand All @@ -23,6 +24,11 @@
urlpatterns = [
path("", PartnerProgramList.as_view()),
path("<int:pk>/", PartnerProgramDetail.as_view()),
path(
"<int:pk>/acknowledge-welcome/",
PartnerProgramWelcomeAcknowledgement.as_view(),
name="partner-program-acknowledge-welcome",
),
path(
"partner-program-projects/<int:pk>/submit/",
PartnerProgramProjectSubmitView.as_view(),
Expand Down
58 changes: 48 additions & 10 deletions partner_programs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,10 @@ def get_queryset(self):
qs = PartnerProgram.objects.none()
else:
now = timezone.now()
qs = (
base_qs.filter(
partner_program_profiles__user=self.request.user,
datetime_finished__gte=now,
)
.distinct()
)
qs = base_qs.filter(
partner_program_profiles__user=self.request.user,
datetime_finished__gte=now,
).distinct()

user = self.request.user
if not user.is_authenticated:
Expand All @@ -103,14 +100,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
Expand All @@ -119,6 +131,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):
"""
Создание проекта в рамках программы (подать проект).
Expand All @@ -139,7 +175,9 @@ def get(self, request, pk, *args, **kwargs):
"program_id": program.id,
"can_submit": program.is_project_submission_open(),
"submission_deadline": program.get_project_submission_deadline(),
"program_fields": PartnerProgramFieldSerializer(fields_qs, many=True).data,
"program_fields": PartnerProgramFieldSerializer(
fields_qs, many=True
).data,
},
status=status.HTTP_200_OK,
)
Expand Down
28 changes: 28 additions & 0 deletions users/migrations/0061_customuser_modal_acknowledgements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0060_alter_userachievement_year"),
]

operations = [
migrations.AddField(
model_name="customuser",
name="profile_fill_prompt_acknowledged_at",
field=models.DateTimeField(
blank=True,
null=True,
verbose_name="Дата подтверждения напоминания о заполнении профиля",
),
),
migrations.AddField(
model_name="customuser",
name="verification_notice_acknowledged_at",
field=models.DateTimeField(
blank=True,
null=True,
verbose_name="Дата подтверждения уведомления о верификации",
),
),
]
10 changes: 10 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading