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
4 changes: 3 additions & 1 deletion core/log/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
27 changes: 27 additions & 0 deletions docs/modules/program-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 оценок и права эксперта не меняются.
Expand Down
39 changes: 39 additions & 0 deletions partner_programs/migrations/0023_organizerinvitation_and_more.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
45 changes: 45 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions partner_programs/organizer_views.py
Original file line number Diff line number Diff line change
@@ -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",
"Аккаунт уже создан. Войдите, чтобы принять приглашение.",
)
Loading
Loading