diff --git a/core/log/utils.py b/core/log/utils.py index d1b8dac9..f38ebfaa 100644 --- a/core/log/utils.py +++ b/core/log/utils.py @@ -6,7 +6,9 @@ def redact_expert_invitation_token(message): return re.sub( - r"(/evaluation/invitations/)[A-Za-z0-9_-]{20,100}", r"\1[redacted]", message + r"(/(?:evaluation/invitations|programs/organizer-invitations)/)[A-Za-z0-9_-]{20,100}", + r"\1[redacted]", + message, ) diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index 95ae597a..931d9201 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -37,6 +37,33 @@ PATCH материала передаёт название, один источ ## Angular и внедрение +### Организаторы программы · DEV + +`GET/POST /programs/{id}/management/organizers/` возвращает приватный состав либо +приглашает по email. `POST organizers/{user_id}/remove/` снимает доступ; +`POST organizer-invitations/{id}/resend/` и `revoke/` управляют приглашением. +Все записи требуют актуального `revision` и организатора конкретной программы +либо существующего административного scope. Изменения сериализуются блокировкой +строки программы. Последнего активного организатора снять нельзя; непринятые +приглашения и отключённые аккаунты не заменяют действующего организатора. + +Публичный контекст ссылки — `/programs/organizer-invitations/{token}/`, действия — +`accept/` и `register/`. GET не выдаёт доступ. Принятие требует активного аккаунта +с почтой приглашения; регистрация нового организатора сохраняет обязательную дату +рождения. Доступ ограничен `PartnerProgram.managers`: глобальные административные +права и членство эксперта не выдаются. Снятие доступа отзывает принятые и ожидающие +приглашения этого пользователя, чтобы старая ссылка не восстановила права. + +Нужна только новая таблица `partner_programs.0023_organizerinvitation_and_more`; +существующие организаторы и оценки не преобразуются. Сначала backend/migration, +затем Angular. Страницы `/organizer-invitation/` и `/organizer-password-reset/` +обслуживаются SPA; API использует уже настроенный префикс `/programs/`. +Письмо использует существующие Celery и почтовый backend, разрешённый frontend +origin из `EXPERT_WORKSPACE_FRONTEND_URL`. В БД хранится digest, повторная отправка +меняет токен, срок — 7 дней. `queued/sent/failed` описывает отправку; `sent` не +подтверждает доставку во входящие. Ответы приватные/no-store, токены скрыты в +application logger и Celery argsrepr. Сброс пароля сохраняет контекст приглашения. + Angular получает camelCase через существующий interceptor, передаёт версии, обрабатывает409, сохраняет введённые значения при ошибке и запрашивает подтверждение ухода. После сохранения сбрасывает приватный/публичный кеш программы. Даты редактируются явно в Москве UTC+3; неизменённые поля не округляются. Переход к оценке открывает чтение выбранного проекта. Для пользователя с существующими правами эксперта дополнительная ссылка ведёт в legacy rating с точным `project_id`; фильтр не расширяет queryset доступных эксперту проектов. POST оценок и права эксперта не меняются. diff --git a/partner_programs/migrations/0023_organizerinvitation_and_more.py b/partner_programs/migrations/0023_organizerinvitation_and_more.py new file mode 100644 index 00000000..bf339b5d --- /dev/null +++ b/partner_programs/migrations/0023_organizerinvitation_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2026-10-19 14:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('partner_programs', '0022_partnerprogram_required_evaluators'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizerInvitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('token_digest', models.CharField(max_length=64, unique=True)), + ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('revoked', 'revoked')], default='pending', max_length=12)), + ('delivery_status', models.CharField(choices=[('queued', 'queued'), ('sent', 'sent'), ('failed', 'failed')], default='queued', max_length=12)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('accepted_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_organizer_invitations', to=settings.AUTH_USER_MODEL)), + ('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_organizer_invitations', to=settings.AUTH_USER_MODEL)), + ('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizer_invitations', to='partner_programs.partnerprogram')), + ], + options={ + 'indexes': [models.Index(fields=['email', 'status'], name='partner_pro_email_f9d884_idx')], + }, + ), + migrations.AddConstraint( + model_name='organizerinvitation', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('partner_program', 'email'), name='unique_pending_org_program_email'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 8894043d..7c364c38 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -10,6 +10,51 @@ User = get_user_model() +class OrganizerInvitation(models.Model): + """Program management invitation, independent of expert membership.""" + + partner_program = models.ForeignKey( + "PartnerProgram", on_delete=models.CASCADE, related_name="organizer_invitations" + ) + email = models.EmailField() + token_digest = models.CharField(max_length=64, unique=True) + status = models.CharField( + max_length=12, + default="pending", + choices=[(v, v) for v in ("pending", "accepted", "revoked")], + ) + delivery_status = models.CharField( + max_length=12, + default="queued", + choices=[(v, v) for v in ("queued", "sent", "failed")], + ) + invited_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="sent_organizer_invitations", + ) + accepted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="accepted_organizer_invitations", + ) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["partner_program", "email"], + condition=models.Q(status="pending"), + name="unique_pending_org_program_email", + ) + ] + indexes = [models.Index(fields=["email", "status"])] + + class PartnerProgram(models.Model): """ PartnerProgram model diff --git a/partner_programs/organizer_views.py b/partner_programs/organizer_views.py new file mode 100644 index 00000000..40df1ac4 --- /dev/null +++ b/partner_programs/organizer_views.py @@ -0,0 +1,169 @@ +from django.db import IntegrityError, transaction +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken + +from partner_programs.models import PartnerProgram +from partner_programs.services import organizers as service +from partner_programs.services.management import managed_program +from project_rates.workspace import invited_account, invitation_data, program_card +from project_rates.workspace_views import ( + InvitationEmail, + InvitationThrottle, + InvitedRegistration, + PrivateView, + RevisionWrite, + validated, +) +from users.models import CustomUser +from users.validators import user_birthday_validator + + +class InviteWrite(InvitationEmail, RevisionWrite): + pass + + +class OrganizersView(PrivateView): + @transaction.atomic + def get(self, request, pk): + return Response( + service.organizer_data( + managed_program(pk, request.user, lock=True), request.user + ) + ) + + def post(self, request, pk): + data = validated(InviteWrite, request.data) + return Response( + service.invite(pk, request.user, data["email"], data["revision"]), status=201 + ) + + +class OrganizerInvitationActionView(PrivateView): + def post(self, request, pk, invitation_id, action): + data = validated(RevisionWrite, request.data) + if action == "revoke": + return Response( + service.revoke(pk, request.user, invitation_id, data["revision"]) + ) + if action == "resend": + program = managed_program(pk, request.user) + from django.shortcuts import get_object_or_404 + + invitation = get_object_or_404( + program.organizer_invitations, pk=invitation_id + ) + return Response( + service.invite( + pk, + request.user, + invitation.email, + data["revision"], + invitation_id=invitation_id, + ) + ) + return Response(status=404) + + +class OrganizerAccessView(PrivateView): + def post(self, request, pk, user_id): + data = validated(RevisionWrite, request.data) + return Response(service.remove(pk, request.user, user_id, data["revision"])) + + +class OrganizerRegistration(InvitedRegistration): + birthday = serializers.DateField( + required=True, allow_null=False, validators=[user_birthday_validator] + ) + + class Meta(InvitedRegistration.Meta): + fields = [*InvitedRegistration.Meta.fields, "birthday"] + + +class PublicOrganizerInvitationView(PrivateView): + permission_classes = [AllowAny] + throttle_classes = [InvitationThrottle] + + def get(self, request, token): + invitation = service.token_invitation(token, allow_accepted=True) + accounts = list(CustomUser.objects.filter(email__iexact=invitation.email)) + account = invited_account(accounts) + return Response( + { + **invitation_data(invitation), + "purpose": "organizer", + "program": program_card(invitation.partner_program), + "account_exists": bool(accounts), + "login_email": account.email if account else invitation.email, + "matches_account": bool( + request.user.is_authenticated + and request.user.email.lower() == invitation.email.lower() + ), + } + ) + + +class PublicOrganizerInvitationActionView(PublicOrganizerInvitationView): + def get(self, request, token, action): + return Response(status=405) + + def post(self, request, token, action): + try: + with transaction.atomic(): + invitation = service.token_invitation(token, allow_accepted=True) + PartnerProgram.objects.select_for_update().get( + pk=invitation.partner_program_id + ) + invitation = service.token_invitation( + token, lock=True, allow_accepted=True + ) + if action == "accept": + service.accept_invitation(invitation, request.user) + return Response( + {"program_id": invitation.partner_program_id, "accepted": True} + ) + if action != "register": + return Response(status=404) + if invitation.status != "pending": + raise service.InvitationGone() + if request.user.is_authenticated: + raise PermissionDenied( + "Для существующего аккаунта примите приглашение после входа." + ) + if CustomUser.objects.filter(email__iexact=invitation.email).exists(): + raise service.WorkspaceConflict( + "account_exists", + "Аккаунт с этой почтой уже есть. Войдите, чтобы принять приглашение.", + ) + form = OrganizerRegistration( + data=request.data, context={"email": invitation.email} + ) + form.is_valid(raise_exception=True) + values = form.validated_data + user = CustomUser.objects.create_user( + email=invitation.email, + first_name=values["first_name"], + last_name=values["last_name"], + password=values["password"], + birthday=values["birthday"], + user_type=CustomUser.MEMBER, + is_active=True, + onboarding_stage=None, + ) + service.accept_invitation(invitation, user) + refresh = RefreshToken.for_user(user) + return Response( + { + "access": str(refresh.access_token), + "refresh": str(refresh), + "program_id": invitation.partner_program_id, + }, + status=201, + ) + except IntegrityError: + raise service.WorkspaceConflict( + "account_exists", + "Аккаунт уже создан. Войдите, чтобы принять приглашение.", + ) diff --git a/partner_programs/services/organizers.py b/partner_programs/services/organizers.py new file mode 100644 index 00000000..3f95d9c9 --- /dev/null +++ b/partner_programs/services/organizers.py @@ -0,0 +1,221 @@ +"""Program-scoped access. Membership writes serialize on the program row.""" + +import json +import secrets +from datetime import timedelta + +from django.db import transaction +from django.db.models import Q +from django.db.models.functions import Lower +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied + +from partner_programs.models import OrganizerInvitation +from partner_programs.services.management import managed_program, person_name +from project_rates.workspace import ( + InvitationGone, + WorkspaceConflict, + digest, + invited_account, + invitation_data, +) +from users.models import CustomUser + + +def revision(program): + data = [ + list(program.managers.order_by("pk").values_list("pk", "is_active")), + list( + program.organizer_invitations.order_by("pk").values_list( + "pk", "status", "token_digest", "expires_at" + ) + ), + ] + return digest(json.dumps(data, default=str)) + + +def check_revision(program, expected): + if expected != revision(program): + raise WorkspaceConflict( + "stale_organizers", "Состав организаторов изменился. Обновите список." + ) + + +def organizer_data(program, user): + members = list(program.managers.order_by("pk")) + active_count = sum(member.is_active for member in members) + rows = [ + { + "id": f"member:{member.pk}", + "user_id": member.pk, + "name": person_name(member), + "email": member.email, + "avatar": member.avatar, + "status": "joined", + "active": member.is_active, + "is_self": member.pk == user.pk, + "can_remove": not (member.is_active and active_count <= 1), + } + for member in members + ] + invitations = list( + program.organizer_invitations.filter(status="pending").order_by("pk") + ) + accounts = {} + for account in CustomUser.objects.annotate(invitation_email=Lower("email")).filter( + invitation_email__in=[i.email.lower() for i in invitations] + ): + accounts.setdefault(account.invitation_email, []).append(account) + member_emails = {m.email.lower() for m in members} + for invitation in invitations: + if invitation.email.lower() in member_emails: + continue + account = invited_account(accounts.get(invitation.email.lower(), [])) + rows.append( + { + **invitation_data(invitation), + "id": f"invite:{invitation.pk}", + "invitation_id": invitation.pk, + "name": person_name(account) if account else None, + "avatar": account.avatar if account else None, + "is_self": False, + "can_remove": False, + } + ) + return { + "revision": revision(program), + "organizers": rows, + "organizer_count": len(members), + "active_organizer_count": active_count, + "invitation_count": sum(row["status"] != "joined" for row in rows), + } + + +@transaction.atomic +def invite(program_id, user, email, expected, *, invitation_id=None): + program = managed_program(program_id, user, lock=True) + check_revision(program, expected) + email = email.strip().lower() + if program.managers.filter(email__iexact=email).exists(): + raise WorkspaceConflict( + "already_organizer", "У этого пользователя уже есть доступ." + ) + if invitation_id is None: + previous = program.organizer_invitations.filter( + email__iexact=email, status="pending" + ).first() + if previous and previous.expires_at > timezone.now(): + raise WorkspaceConflict( + "already_invited", "Приглашение уже есть. Можно отправить его повторно." + ) + if previous: + previous.status = "revoked" + previous.save(update_fields=["status", "updated_at"]) + invitation = OrganizerInvitation( + partner_program=program, email=email, invited_by=user + ) + else: + invitation = get_object_or_404(program.organizer_invitations, pk=invitation_id) + if invitation.status != "pending": + raise WorkspaceConflict( + "invitation_resolved", + "Приглашение уже принято или отозвано. Обновите список.", + ) + token = secrets.token_urlsafe(32) + invitation.token_digest = digest(token) + invitation.delivery_status = "queued" + invitation.expires_at = timezone.now() + timedelta(days=7) + invitation.save() + from partner_programs.tasks import send_organizer_invitation + + def enqueue(): + try: + send_organizer_invitation.apply_async( + (invitation.pk, token), argsrepr=f"({invitation.pk}, '[redacted]')" + ) + except Exception: + OrganizerInvitation.objects.filter( + pk=invitation.pk, token_digest=digest(token) + ).update(delivery_status="failed") + + transaction.on_commit(enqueue) + return organizer_data(program, user) + + +@transaction.atomic +def revoke(program_id, user, invitation_id, expected): + program = managed_program(program_id, user, lock=True) + check_revision(program, expected) + invitation = get_object_or_404(program.organizer_invitations, pk=invitation_id) + if invitation.status != "pending": + raise WorkspaceConflict( + "invitation_resolved", + "Приглашение уже принято или отозвано. Обновите список.", + ) + invitation.status = "revoked" + invitation.save(update_fields=["status", "updated_at"]) + return organizer_data(program, user) + + +@transaction.atomic +def remove(program_id, user, member_id, expected): + program = managed_program(program_id, user, lock=True) + check_revision(program, expected) + member = get_object_or_404(program.managers, pk=member_id) + if member.is_active and program.managers.filter(is_active=True).count() <= 1: + raise WorkspaceConflict( + "last_organizer", + "Нельзя убрать последнего организатора. Сначала пригласите коллегу и дождитесь принятия.", + ) + program.managers.remove(member) + program.organizer_invitations.filter( + Q(accepted_by=member) | Q(email__iexact=member.email) + ).exclude(status="revoked").update(status="revoked", updated_at=timezone.now()) + if member.pk == user.pk: + return {"access_removed": True, "program_id": program.pk} + return organizer_data(program, user) + + +def token_invitation(token, *, lock=False, allow_accepted=False): + if not isinstance(token, str) or not 20 <= len(token) <= 100: + raise Http404 + rows = ( + OrganizerInvitation.objects.select_for_update() + if lock + else OrganizerInvitation.objects + ) + invitation = get_object_or_404( + rows.select_related("partner_program"), token_digest=digest(token) + ) + if invitation.status == "accepted" and allow_accepted: + if invitation.partner_program.managers.filter( + pk=invitation.accepted_by_id + ).exists(): + return invitation + raise InvitationGone() + if invitation.status != "pending" or invitation.expires_at <= timezone.now(): + raise InvitationGone() + return invitation + + +def accept_invitation(invitation, user): + if ( + not user.is_authenticated + or not user.is_active + or user.email.lower() != invitation.email.lower() + ): + raise PermissionDenied("Войдите в аккаунт с почтой из приглашения.") + if invitation.status == "accepted": + if invitation.accepted_by_id != user.pk: + raise PermissionDenied() + if not invitation.partner_program.managers.filter(pk=user.pk).exists(): + raise InvitationGone() + return + if invitation.status != "pending" or invitation.expires_at <= timezone.now(): + raise InvitationGone() + invitation.partner_program.managers.add(user) + invitation.status = "accepted" + invitation.accepted_by = user + invitation.save(update_fields=["status", "accepted_by", "updated_at"]) diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 39baf11c..57d85266 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -11,3 +11,48 @@ def publish_finished_program_projects_task() -> int: updated_count = publish_finished_program_projects() logger.info("Published %s program projects after finish", updated_count) return updated_count + + +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_organizer_invitation(invitation_id, token): + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import render_to_string + from django.utils import timezone + from partner_programs.models import OrganizerInvitation + from project_rates.workspace import digest + + rows = OrganizerInvitation.objects.filter( + pk=invitation_id, + token_digest=digest(token), + status="pending", + expires_at__gt=timezone.now(), + ) + invitation = rows.select_related("partner_program").first() + if not invitation: + return + context = { + "program_name": invitation.partner_program.name, + "email": invitation.email, + "invitation_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + "/organizer-invitation/" + + token, + "expires_at": invitation.expires_at, + "logo_url": "https://786505.selcdn.ru/app_files/procollab_logo.png", + } + try: + message = EmailMultiAlternatives( + "Приглашение организатора | PROCOLLAB", + render_to_string("email/organizer_invitation.txt", context), + settings.EMAIL_USER, + [invitation.email], + ) + message.attach_alternative( + render_to_string("email/organizer_invitation.html", context), "text/html" + ) + if not message.send(): + raise RuntimeError("Invitation delivery did not complete") + rows.update(delivery_status="sent") + except Exception: + rows.update(delivery_status="failed") + raise diff --git a/partner_programs/tests/test_organizers_api.py b/partner_programs/tests/test_organizers_api.py new file mode 100644 index 00000000..0e179059 --- /dev/null +++ b/partner_programs/tests/test_organizers_api.py @@ -0,0 +1,338 @@ +import re +from datetime import timedelta +from unittest.mock import patch + +from django.core import mail +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import OrganizerInvitation +from project_rates.tests.helpers import create_rate_program, create_rate_user +from users.models import CustomUser, Expert + + +@override_settings( + EXPERT_WORKSPACE_FRONTEND_URL="https://dev.procollab.ru", + EMAIL_USER="no-reply@example.com", + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, + CELERY_BROKER_URL="memory://", + CELERY_RESULT_BACKEND="cache+memory://", + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], +) +class OrganizersApiTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_rate_user() + cls.colleague = create_rate_user(prefix="colleague") + cls.stranger = create_rate_user(prefix="stranger") + cls.program = create_rate_program() + cls.program.managers.add(cls.manager) + cls.other = create_rate_program() + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.root = f"/programs/{self.program.pk}/management/" + + def roster(self): + return self.client.get(self.root + "organizers/").data + + def post(self, path, data): + return self.client.post(path, data, format="json") + + def invite(self, email="new-organizer@example.com"): + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + "organizers/", + {"email": email, "revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 201, response.data) + token = re.search( + r"/organizer-invitation/([A-Za-z0-9_-]+)", mail.outbox[-1].body + ).group(1) + return ( + OrganizerInvitation.objects.get(email=email.lower(), status="pending"), + token, + ) + + def test_only_program_organizers_can_read_or_write(self): + for user in [self.stranger, None]: + self.client.force_authenticate(user) + self.assertIn( + self.client.get(self.root + "organizers/").status_code, [401, 403] + ) + self.assertIn( + self.post( + self.root + "organizers/", + {"email": "test@example.com", "revision": "x"}, + ).status_code, + [401, 403], + ) + self.client.force_authenticate(self.manager) + self.assertEqual( + self.client.get( + f"/programs/{self.other.pk}/management/organizers/" + ).status_code, + 403, + ) + + def test_roster_is_private_and_last_organizer_is_protected(self): + response = self.client.get(self.root + "organizers/") + self.assertEqual(response["Cache-Control"], "no-store") + self.assertEqual(response.data["organizer_count"], 1) + self.assertFalse(response.data["organizers"][0]["can_remove"]) + self.assertTrue(response.data["organizers"][0]["is_self"]) + self.assertNotIn("token_digest", str(response.data)) + response = self.post( + self.root + f"organizers/{self.manager.pk}/remove/", + {"revision": response.data["revision"]}, + ) + self.assertEqual(response.status_code, 409) + self.assertTrue(self.program.managers.filter(pk=self.manager.pk).exists()) + + def test_invitation_delivers_normalized_email_without_granting_access(self): + invitation, token = self.invite("New-Organizer@Example.com") + invitation.refresh_from_db() + self.assertEqual(invitation.delivery_status, "sent") + self.assertEqual(invitation.email, "new-organizer@example.com") + self.assertNotEqual(token, invitation.token_digest) + self.assertEqual(self.program.managers.count(), 1) + preview = self.client.get(f"/programs/organizer-invitations/{token}/") + self.assertEqual(preview.data["purpose"], "organizer") + self.assertFalse(preview.data["account_exists"]) + self.assertNotIn("token_digest", str(preview.data)) + self.assertEqual(preview["Referrer-Policy"], "no-referrer") + + def test_existing_manager_and_duplicate_pending_are_rejected(self): + response = self.post( + self.root + "organizers/", + {"email": self.manager.email.upper(), "revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 409) + invitation, _ = self.invite() + response = self.post( + self.root + "organizers/", + {"email": invitation.email.upper(), "revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(self.program.organizer_invitations.count(), 1) + + def test_wrong_account_cannot_accept(self): + invitation, token = self.invite(self.colleague.email) + for user in [self.stranger, None]: + self.client.force_authenticate(user) + self.assertEqual( + self.post( + f"/programs/organizer-invitations/{token}/accept/", {} + ).status_code, + 403, + ) + invitation.refresh_from_db() + self.assertEqual(invitation.status, "pending") + + def test_acceptance_is_explicit_idempotent_program_scoped_and_not_expert(self): + invitation, token = self.invite(self.colleague.email) + self.client.force_authenticate(self.colleague) + url = f"/programs/organizer-invitations/{token}/" + self.assertEqual(self.client.get(url).status_code, 200) + self.assertFalse(self.program.is_manager(self.colleague)) + self.assertEqual(self.post(url + "accept/", {}).status_code, 200) + self.assertEqual(self.post(url + "accept/", {}).status_code, 200) + self.assertTrue(self.program.is_manager(self.colleague)) + self.assertFalse(self.other.is_manager(self.colleague)) + self.assertFalse(Expert.objects.filter(user=self.colleague).exists()) + self.assertEqual(self.client.get(self.root + "organizers/").status_code, 200) + self.program.refresh_from_db() + self.assertIsNone(self.program.required_evaluators) + + def test_registration_requires_birthday_and_preserves_normal_role(self): + _, token = self.invite() + self.client.force_authenticate(None) + url = f"/programs/organizer-invitations/{token}/register/" + data = dict( + first_name="Анна", + last_name="Волкова", + password="SafeOrganizer234!", + repeat_password="SafeOrganizer234!", + accept_policy=True, + accept_age=True, + ) + self.assertEqual(self.post(url, data).status_code, 400) + data["birthday"] = "1990-01-01" + response = self.post(url, data) + self.assertEqual(response.status_code, 201, response.data) + user = CustomUser.objects.get(email="new-organizer@example.com") + self.assertTrue(user.check_password(data["password"])) + self.assertEqual(user.birthday.isoformat(), "1990-01-01") + self.assertTrue(self.program.is_manager(user)) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + self.assertEqual(user.user_type, CustomUser.MEMBER) + self.assertFalse(Expert.objects.filter(user=user).exists()) + self.assertIn("access", response.data) + + def test_registration_does_not_replace_existing_account(self): + _, token = self.invite(self.colleague.email) + self.client.force_authenticate(None) + before = CustomUser.objects.count() + self.assertEqual( + self.post( + f"/programs/organizer-invitations/{token}/register/", {} + ).status_code, + 409, + ) + self.assertEqual(CustomUser.objects.count(), before) + + def test_revoke_and_resend_invalidate_previous_links_without_duplicate_rows(self): + invitation, token = self.invite() + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + f"organizer-invitations/{invitation.pk}/resend/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 200) + new_token = re.search( + r"/organizer-invitation/([A-Za-z0-9_-]+)", mail.outbox[-1].body + ).group(1) + self.assertNotEqual(new_token, token) + self.assertEqual( + self.client.get(f"/programs/organizer-invitations/{token}/").status_code, 404 + ) + self.assertEqual(OrganizerInvitation.objects.count(), 1) + response = self.post( + self.root + f"organizer-invitations/{invitation.pk}/revoke/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + self.client.get(f"/programs/organizer-invitations/{new_token}/").status_code, + 410, + ) + self.assertEqual(response.data["invitation_count"], 0) + + def test_pending_or_inactive_colleague_does_not_allow_removing_last_active_manager( + self, + ): + self.invite(self.colleague.email) + self.colleague.is_active = False + self.colleague.save() + self.program.managers.add(self.colleague) + response = self.post( + self.root + f"organizers/{self.manager.pk}/remove/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 409) + + def test_removal_revokes_access_and_previously_accepted_token(self): + invitation, token = self.invite(self.colleague.email) + self.client.force_authenticate(self.colleague) + self.assertEqual( + self.post(f"/programs/organizer-invitations/{token}/accept/", {}).status_code, + 200, + ) + self.client.force_authenticate(self.manager) + response = self.post( + self.root + f"organizers/{self.colleague.pk}/remove/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(self.colleague) + self.assertEqual(self.client.get(self.root + "organizers/").status_code, 403) + self.assertEqual( + self.post(f"/programs/organizer-invitations/{token}/accept/", {}).status_code, + 410, + ) + self.assertTrue(CustomUser.objects.get(pk=self.colleague.pk).is_active) + self.assertFalse(self.program.is_manager(self.colleague)) + + def test_self_exit_returns_no_remaining_private_roster(self): + self.program.managers.add(self.colleague) + response = self.post( + self.root + f"organizers/{self.manager.pk}/remove/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["access_removed"]) + self.assertNotIn("organizers", response.data) + self.assertEqual(self.client.get(self.root + "organizers/").status_code, 403) + + def test_stale_revision_and_foreign_member_are_rejected(self): + old = self.roster()["revision"] + self.program.managers.add(self.colleague) + self.assertEqual( + self.post( + self.root + f"organizers/{self.manager.pk}/remove/", {"revision": old} + ).status_code, + 409, + ) + self.assertEqual( + self.post( + self.root + f"organizers/{self.stranger.pk}/remove/", + {"revision": self.roster()["revision"]}, + ).status_code, + 404, + ) + + def test_expired_invitation_is_gone_and_can_be_renewed(self): + invitation, token = self.invite() + OrganizerInvitation.objects.filter(pk=invitation.pk).update( + expires_at=timezone.now() - timedelta(seconds=1) + ) + self.assertEqual( + self.client.get(f"/programs/organizer-invitations/{token}/").status_code, 410 + ) + self.assertEqual(self.roster()["organizers"][-1]["status"], "expired") + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + f"organizer-invitations/{invitation.pk}/resend/", + {"revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 200) + + def test_broker_failure_is_visible_and_does_not_grant_access(self): + with patch( + "partner_programs.tasks.send_organizer_invitation.apply_async", + side_effect=RuntimeError("broker unavailable"), + ): + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + "organizers/", + {"email": "retry@example.com", "revision": self.roster()["revision"]}, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(self.roster()["organizers"][-1]["delivery_status"], "failed") + self.assertEqual(self.program.managers.count(), 1) + + def test_invitation_name_is_resolved_by_its_email(self): + self.colleague.first_name = "Анна" + self.colleague.last_name = "Волкова" + self.colleague.save() + self.invite(self.colleague.email.upper()) + row = self.roster()["organizers"][-1] + self.assertEqual(row["name"], "Анна Волкова") + self.assertEqual(row["email"], self.colleague.email.lower()) + + def test_reset_password_keeps_organizer_context(self): + _, token = self.invite(self.colleague.email) + self.client.force_authenticate(None) + response = self.post( + "/auth/reset_password/", + {"email": self.colleague.email, "organizer_invitation_token": token}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("/organizer-password-reset/?", mail.outbox[-1].body) + self.assertIn("organizer_invitation=" + token, mail.outbox[-1].body) + + def test_organizer_token_is_redacted_from_application_logs(self): + from core.log.utils import redact_expert_invitation_token + + token = "a" * 43 + self.assertNotIn( + token, + redact_expert_invitation_token( + f"GET /programs/organizer-invitations/{token}/" + ), + ) diff --git a/partner_programs/tests/test_organizers_concurrency.py b/partner_programs/tests/test_organizers_concurrency.py new file mode 100644 index 00000000..9242e4aa --- /dev/null +++ b/partner_programs/tests/test_organizers_concurrency.py @@ -0,0 +1,89 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +from django.db import connections +from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import OrganizerInvitation +from partner_programs.services.organizers import revision +from project_rates.tests.helpers import create_rate_program, create_rate_user +from project_rates.workspace import digest + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +@skipUnlessDBFeature("has_select_for_update") +class OrganizerConcurrencyTests(TransactionTestCase): + def setUp(self): + self.program = create_rate_program() + self.first = create_rate_user() + self.second = create_rate_user() + self.program.managers.add(self.first, self.second) + self.root = f"/programs/{self.program.pk}/management/" + + def parallel(self, calls): + gate = Barrier(2) + + def run(call): + connections.close_all() + try: + gate.wait(timeout=10) + return call() + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + return list(pool.map(run, calls)) + + def test_simultaneous_self_exit_leaves_one_organizer(self): + expected = revision(self.program) + + def leave(user): + client = APIClient() + client.force_authenticate(user) + return client.post( + self.root + f"organizers/{user.pk}/remove/", + {"revision": expected}, + format="json", + ).status_code + + result = self.parallel([lambda: leave(self.first), lambda: leave(self.second)]) + self.assertEqual(sorted(result), [200, 409]) + self.assertEqual(self.program.managers.count(), 1) + + def test_acceptance_racing_with_revocation_has_one_valid_outcome(self): + self.program.managers.remove(self.second) + token = "a" * 43 + invitation = OrganizerInvitation.objects.create( + partner_program=self.program, + email=self.second.email, + invited_by=self.first, + token_digest=digest(token), + expires_at=timezone.now() + timedelta(days=1), + ) + expected = revision(self.program) + + def accept(): + client = APIClient() + client.force_authenticate(self.second) + return client.post( + f"/programs/organizer-invitations/{token}/accept/", {}, format="json" + ).status_code + + def revoke(): + client = APIClient() + client.force_authenticate(self.first) + return client.post( + self.root + f"organizer-invitations/{invitation.pk}/revoke/", + {"revision": expected}, + format="json", + ).status_code + + accepted, revoked = self.parallel([accept, revoke]) + self.assertIn((accepted, revoked), [(200, 409), (410, 200)]) + invitation.refresh_from_db() + self.assertEqual( + self.program.is_manager(self.second), invitation.status == "accepted" + ) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 3ea5239c..d378fe91 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,4 +1,11 @@ from django.urls import path +from partner_programs.organizer_views import ( + OrganizersView, + OrganizerAccessView, + OrganizerInvitationActionView, + PublicOrganizerInvitationView, + PublicOrganizerInvitationActionView, +) from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList from partner_programs.views import ( @@ -62,6 +69,20 @@ app_name = "partner_programs" urlpatterns = [ + path("organizer-invitations//", PublicOrganizerInvitationView.as_view()), + path( + "organizer-invitations///", + PublicOrganizerInvitationActionView.as_view(), + ), + path("/management/organizers/", OrganizersView.as_view()), + path( + "/management/organizers//remove/", + OrganizerAccessView.as_view(), + ), + path( + "/management/organizer-invitations///", + OrganizerInvitationActionView.as_view(), + ), path( "/management/results/", ManagementResultsView.as_view(), diff --git a/templates/email/organizer_invitation.html b/templates/email/organizer_invitation.html new file mode 100644 index 00000000..0ecab906 --- /dev/null +++ b/templates/email/organizer_invitation.html @@ -0,0 +1,13 @@ + + + +
+
+PROCOLLAB +

Вас пригласили стать организатором

+

Программа «{{ program_name }}» на PROCOLLAB.

+

Приглашение для {{ email }}. После принятия вы сможете управлять этой программой и приглашать коллег.

+

Принять приглашение

+

Если аккаунта ещё нет, вы сможете зарегистрироваться по этой ссылке.

+

Ссылка действует до {{ expires_at|date:"d.m.Y H:i" }}. Если вы не ожидали приглашение, просто проигнорируйте письмо.

+
diff --git a/templates/email/organizer_invitation.txt b/templates/email/organizer_invitation.txt new file mode 100644 index 00000000..f603779e --- /dev/null +++ b/templates/email/organizer_invitation.txt @@ -0,0 +1,10 @@ +Здравствуйте! + +Вас пригласили стать организатором программы «{{ program_name }}» на PROCOLLAB. +Приглашение для {{ email }}. После принятия вы сможете управлять этой программой и приглашать коллег. + +Принять приглашение: {{ invitation_url }} + +Если аккаунта ещё нет, вы сможете зарегистрироваться по этой ссылке. +Ссылка действует до {{ expires_at|date:"d.m.Y H:i" }}. +Если вы не ожидали приглашение, просто проигнорируйте письмо. diff --git a/users/signals.py b/users/signals.py index 2b1c0a97..2fe73bd7 100644 --- a/users/signals.py +++ b/users/signals.py @@ -74,6 +74,33 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, } ) ) + organizer_token = getattr(getattr(instance, "request", None), "data", {}).get( + "organizer_invitation_token" + ) + if organizer_token: + from partner_programs.services.organizers import token_invitation + from rest_framework.exceptions import APIException + from django.http import Http404 + from urllib.parse import urlencode + + try: + invitation = token_invitation(organizer_token, allow_accepted=True) + except (APIException, Http404): + invitation = None + if ( + invitation + and invitation.email.lower() == reset_password_token.user.email.lower() + ): + reset_password_url = ( + settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + "/organizer-password-reset/?" + + urlencode( + { + "token": reset_password_token.key, + "organizer_invitation": organizer_token, + } + ) + ) context = { "user": reset_password_token.user, "email": reset_password_token.user.email,