diff --git a/core/log/middleware.py b/core/log/middleware.py index f20e7951..edb37970 100644 --- a/core/log/middleware.py +++ b/core/log/middleware.py @@ -4,7 +4,7 @@ from django.conf import settings from loguru import logger -from core.log.utils import InterceptHandler +from core.log.utils import InterceptHandler, redact_expert_invitation_token def _add_logger_handler(path: str, level: str) -> None: @@ -32,8 +32,14 @@ def __init__(self, get_response): def __call__(self, request): response = self.get_response(request) - logger.info(f"{request.method} {request.get_full_path()}") + logger.info( + redact_expert_invitation_token(f"{request.method} {request.get_full_path()}") + ) return response def process_exception(self, request, exception): - logger.warning(f"{exception} http_path={request.get_full_path()}") + logger.warning( + redact_expert_invitation_token( + f"{exception} http_path={request.get_full_path()}" + ) + ) diff --git a/core/log/utils.py b/core/log/utils.py index 2b9fee47..d1b8dac9 100644 --- a/core/log/utils.py +++ b/core/log/utils.py @@ -1,8 +1,15 @@ import logging import sys +import re from loguru import logger +def redact_expert_invitation_token(message): + return re.sub( + r"(/evaluation/invitations/)[A-Za-z0-9_-]{20,100}", r"\1[redacted]", message + ) + + class InterceptHandler(logging.Handler): def emit(self, record): try: @@ -15,4 +22,6 @@ def emit(self, record): frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + logger.opt(depth=depth, exception=record.exc_info).log( + level, redact_expert_invitation_token(record.getMessage()) + ) diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru index 7f5b4a34..78d7b4a4 100644 --- a/deploy/nginx/host/dev/dev.procollab.ru +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -27,7 +27,7 @@ server { client_max_body_size 100M; - location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|feed|api|anymail|ws)(/|$) { + location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|evaluation|feed|api|anymail|ws)(/|$) { include /etc/nginx/procollab/includes/proxy_app.inc; } diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 78a41449..77de9869 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -13,6 +13,7 @@ services: - .env environment: HOST: 0.0.0.0 + EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru ports: - "127.0.0.1:8000:8000" @@ -31,6 +32,8 @@ services: image: ${DEPLOY_IMAGE:-procollab-dev-api:dev} env_file: - .env + environment: + EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru command: bash ./scripts/celery.sh depends_on: - redis diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md new file mode 100644 index 00000000..39d9033c --- /dev/null +++ b/docs/expert-workspace.md @@ -0,0 +1,107 @@ +# Эксперты программы и личное оценивание + +Организатор приглашает экспертов по email и назначает им сданные проекты. После +явного принятия приглашения эксперт видит программу и свои назначения в кабинете. +Основание доступа — `Expert.programs`, независимо от основной роли пользователя. +Организатор не получает право оценивать автоматически. + +## Контракт + +Все organizer endpoints ниже требуют менеджера **конкретной** программы: + +| Метод и путь относительно `/programs/{id}/management/` | Назначение | +| --- | --- | +| GET `experts/` | Состав, приглашения, нагрузка, N, нехватка и revision | +| POST `experts/` | Пригласить `{email}` | +| POST `expert-invitations/{id}/resend/` | Новая ссылка, прежняя недействительна | +| POST `expert-invitations/{id}/revoke/` | Отозвать непринятое приглашение | +| GET `experts/{expert_id}/assignments/` | Проекты с assigned/protected/own_team | +| PUT `experts/{expert_id}/assignments/` | Заменить набор `{revision, project_ids}` | +| POST `expert-allocation/` | Предпросмотр `{expert_ids, project_ids?}` | +| POST `expert-allocation/apply/` | Применить подписанный `{plan_token}` | +| POST `evaluators/` | Предпросмотр `{required_evaluators}` | +| PUT `evaluators/` | Подтвердить `{required_evaluators, revision}` | + +`GET /evaluation/programs/` возвращает только программы пользователя, его счётчики +assigned/completed/remaining и действующие приглашения на его email. +`POST /evaluation/own-invitations/{id}/accept/` явно принимает своё приглашение. + +`GET /evaluation/invitations/{token}/` возвращает контекст ссылки, фиксированный +email, account_exists, login_email и matches_account. Записей при GET нет. +`POST .../{token}/accept/` требует входа в активный аккаунт с этим email. +`POST .../{token}/register/` принимает first_name, last_name, password, +repeat_password, accept_policy, accept_age и атомарно создаёт аккаунт, членство +и acceptance; возвращает access/refresh/program_id. Email задаётся приглашением. + +Ошибки: 400 — невалидный ввод, 403 — нет прав/другая почта, 404 — нет объекта, +409 — дубликат, устаревший revision/plan или защищённая оценка, 410 — ссылка +истекла/отозвана. Повторное acceptance тем же аккаунтом безопасно. + +## Правила + +- Pending не является экспертом и не участвует в распределении. +- Новые назначения — только принявшим приглашение активным экспертам, на сданные + проекты своей программы; собственная команда исключена. +- Любая сохранённая оценка защищает назначение от удаления, включая старые + неполные наборы. Участие в команде, назначения и оценки входят в revision. +- Автораспределение заполняет дефицит до N, сохраняя существующие назначения, + и балансирует незавершённую нагрузку. Предпросмотр ничего не записывает. + Нехватка кандидатов блокирует применение; план действует 15 минут. +- Все новые записи организатора и сохранение оценок используют одну блокировку + строки программы. Конкурирующая запись отклоняется целиком с 409. +- N — минимум полных оценок, а не максимум. Изменять его можно в любое время; + готовность пересчитывается, оценки и лишние назначения сохраняются. +- Управляемая форма оценки сохраняется целиком: целые баллы и Да/нет обязательны, + комментарий необязателен. 0 и False — допустимые ответы. Дедлайн, назначение, + сданное решение и запрет оценки своей команды проверяются сервером. +- Первая сохранённая оценка продолжает блокировать критерии. + +## Совместимость и rollout + +Добавлены `PartnerProgram.required_evaluators`, `ExpertInvitation` и nullable +`CustomUser.birthday`: migrations partner_programs.0022, project_rates.0004, +users.0065. Обычная регистрация продолжает требовать дату рождения; обычный +профиль не может удалить её. Только новый приглашённый эксперт создаётся без даты, +без фиктивного значения, повторного подтверждения email и обязательного onboarding. + +Legacy rating endpoints/payload и open mode на backend сохраняются. Явное +приглашение, ручное назначение, применение распределения или подтверждение N +включает управляемый режим программы; назначения за историческими оценками +восстанавливаются без изменения самих оценок. `max_project_rates` остаётся +legacy максимумом. Новый Angular запрашивает `assigned_only=1`: он показывает +свои назначения, поэтому организатору нужно распределить проекты существующим +экспертам перед использованием нового кабинета. + +Сначала backend/migrations, затем Angular. DEV использует существующее отдельное +migration connection из Deploy Dev; runtime остаётся под application role. +Откат UI возможен независимо; откат nullable birthday после создания приглашённых +аккаунтов требует отдельной обработки данных, обратную миграцию вслепую не запускать. + +## Письма + +Ссылка имеет случайный bearer token, в БД хранится SHA-256 digest. Срок — 7 дней; +повторная отправка заменяет token. `EXPERT_WORKSPACE_FRONTEND_URL` задаёт разрешённый +frontend origin (DEV: `https://dev.procollab.ru`) для Web и Celery. Нельзя передавать +произвольный return URL. `queued/sent/failed` описывает передачу почтовому backend, +а не подтверждение доставки в inbox. Используется существующий Celery/mail provider. + +В `/auth/reset_password/` можно передать `expert_invitation_token`. Для действующей +ссылки, соответствующей email, reset email ведёт на `/expert-password-reset/` настроенного frontend с контекстом +приглашения. После смены пароля — вход и отдельное acceptance. Общий reset не менялся. +Private ответы не кешируются; invitation token скрывается application HTTP logger. + +На DEV Nginx направляет `/evaluation/` в API. `/expert-invitation/` и +`/expert-password-reset/` остаются SPA routes; отдельный reset route обходит +server-side `/auth/`, который на совмещённом DEV-host принадлежит API. + +## Проверяемые требования + +AC1 invite/resend/revoke, scope, duplicate и доставка; AC2 signup без даты с сохранением +обычной регистрации; AC3 login, wrong account, expiry/replay/reset и гонки; AC4 manual +scope/своя команда/защищённые оценки; AC5 preview/balance/stale apply; AC6 N и полная +оценка; AC7 личные программы и роли; AC8 центрированные адаптивные состояния на UI; +AC9 права при прямом запросе, миграции и регресс. + +Backend coverage: `project_rates.tests.test_workspace` и +`project_rates.tests.test_workspace_concurrency` (гонки требуют PostgreSQL). +UI/Figma и результаты сборки описаны в связанном Angular PR. diff --git a/partner_programs/migrations/0022_partnerprogram_required_evaluators.py b/partner_programs/migrations/0022_partnerprogram_required_evaluators.py new file mode 100644 index 00000000..5b058510 --- /dev/null +++ b/partner_programs/migrations/0022_partnerprogram_required_evaluators.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2026-10-18 00:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_programs', '0021_evaluation_criteria_lock'), + ] + + operations = [ + migrations.AddField( + model_name='partnerprogram', + name='required_evaluators', + field=models.PositiveIntegerField(blank=True, help_text='Минимум полных оценок. NULL сохраняет прежний режим оценивания.', null=True, verbose_name='Обязательное число оценщиков на проект'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 6dca390f..8894043d 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -116,6 +116,12 @@ class PartnerProgram(models.Model): "Если включено, проекты для оценки доступны только назначенным экспертам" ), ) + required_evaluators = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name="Обязательное число оценщиков на проект", + help_text="Минимум полных оценок. NULL сохраняет прежний режим оценивания.", + ) evaluation_criteria_locked_at = models.DateTimeField(null=True, editable=False) evaluation_criteria_version = models.PositiveIntegerField(default=0, editable=False) data_schema = models.JSONField( diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 924f17af..f989ecaa 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -96,12 +96,38 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): materials = serializers.SerializerMethodField() is_user_manager = serializers.SerializerMethodField() + is_user_expert = serializers.SerializerMethodField() + pending_expert_invitation_id = serializers.SerializerMethodField() courses = serializers.SerializerMethodField() def get_materials(self, program: PartnerProgram): materials = program.materials.all() return PartnerProgramMaterialSerializer(materials, many=True).data + def get_is_user_expert(self, program): + user = self.context.get("user") or getattr( + self.context.get("request"), "user", None + ) + return bool( + user and user.is_authenticated and program.experts.filter(user=user).exists() + ) + + def get_pending_expert_invitation_id(self, program): + from django.utils import timezone + + user = self.context.get("user") or getattr( + self.context.get("request"), "user", None + ) + if not user or not user.is_authenticated or not user.is_active: + return None + return ( + program.expert_invitations.filter( + email__iexact=user.email, status="pending", expires_at__gt=timezone.now() + ) + .values_list("pk", flat=True) + .first() + ) + def get_is_user_manager(self, program: PartnerProgram) -> bool: user = self.context.get("user") return bool(user and program.is_manager(user)) @@ -193,6 +219,8 @@ class Meta: "legacy_team_max_size", "is_user_manager", "welcome_acknowledged_at", + "is_user_expert", + "pending_expert_invitation_id", "courses", ) @@ -222,6 +250,8 @@ class Meta: "legacy_team_min_size", "legacy_team_max_size", "is_user_manager", + "is_user_expert", + "pending_expert_invitation_id", "courses", ) diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 487bf0e9..cc49c499 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -267,7 +267,9 @@ def _solution_rows(program, *, rows=None): then=Value("awaiting_evaluation"), ), When( - assignments_completed__lt=F("assignments_total"), + assignments_completed__lt=( + program.required_evaluators or F("assignments_total") + ), then=Value("partially_evaluated"), ), default=Value("evaluated"), diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 2d1f4ec7..e86ccaea 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -43,9 +43,30 @@ ManagementMaterialDetailView, ) +from project_rates.workspace_views import ( + ExpertsView, + InvitationActionView, + ExpertAssignmentsView, + AllocationView, + AllocationApplyView, + EvaluatorsView, +) + app_name = "partner_programs" urlpatterns = [ + path("/management/experts/", ExpertsView.as_view()), + path( + "/management/expert-invitations///", + InvitationActionView.as_view(), + ), + path( + "/management/experts//assignments/", + ExpertAssignmentsView.as_view(), + ), + path("/management/expert-allocation/", AllocationView.as_view()), + path("/management/expert-allocation/apply/", AllocationApplyView.as_view()), + path("/management/evaluators/", EvaluatorsView.as_view()), path( "/management/criteria/", ManagementCriteriaView.as_view(), diff --git a/procollab/settings.py b/procollab/settings.py index 1b1ed91e..e1ee3fd9 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -15,6 +15,9 @@ SECRET_KEY = config("DJANGO_SECRET_KEY", default="django-default-secret-key", cast=str) DEBUG = config("DEBUG", default=False, cast=bool) +EXPERT_WORKSPACE_FRONTEND_URL = config( + "EXPERT_WORKSPACE_FRONTEND_URL", default="https://app.procollab.ru" +) AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool) diff --git a/procollab/urls.py b/procollab/urls.py index 730d8143..c95c9313 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -57,6 +57,7 @@ path("programs/", include("partner_programs.urls", namespace="partner_programs")), path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), + path("evaluation/", include("project_rates.workspace_urls")), path("feed/", include("feed.urls", namespace="feed")), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), diff --git a/project_rates/migrations/0004_expertinvitation_and_more.py b/project_rates/migrations/0004_expertinvitation_and_more.py new file mode 100644 index 00000000..86c3b635 --- /dev/null +++ b/project_rates/migrations/0004_expertinvitation_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.11 on 2026-10-18 00:28 + +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'), + ('project_rates', '0003_projectexpertassignment'), + ] + + operations = [ + migrations.CreateModel( + name='ExpertInvitation', + 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_expert_invitations', to=settings.AUTH_USER_MODEL)), + ('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_expert_invitations', to=settings.AUTH_USER_MODEL)), + ('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_invitations', to='partner_programs.partnerprogram')), + ], + options={ + 'indexes': [models.Index(fields=['email', 'status'], name='project_rat_email_f32dd6_idx')], + }, + ), + migrations.AddConstraint( + model_name='expertinvitation', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('partner_program', 'email'), name='unique_pending_expert_program_email'), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 74dea968..0c583aa2 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -216,7 +216,7 @@ def clean(self): if self.partner_program_id and self.project_id: max_rates = self.partner_program.max_project_rates - if max_rates: + if max_rates and self.partner_program.required_evaluators is None: assignments_qs = ProjectExpertAssignment.objects.filter( partner_program_id=self.partner_program_id, project_id=self.project_id, @@ -242,3 +242,45 @@ def delete(self, *args, **kwargs): "Нельзя удалить назначение: эксперт уже оценил этот проект." ) return super().delete(*args, **kwargs) + + +class ExpertInvitation(models.Model): + """Email invitation; only a digest of the bearer credential is stored.""" + + partner_program = models.ForeignKey( + PartnerProgram, on_delete=models.CASCADE, related_name="expert_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_expert_invitations" + ) + accepted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="accepted_expert_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_expert_program_email", + ) + ] + indexes = [models.Index(fields=["email", "status"])] diff --git a/project_rates/serializers.py b/project_rates/serializers.py index a061c8f3..d1730211 100644 --- a/project_rates/serializers.py +++ b/project_rates/serializers.py @@ -60,6 +60,7 @@ def to_representation(self, instance): class ProjectListForRateSerializer(serializers.ModelSerializer): + managed_evaluation = serializers.SerializerMethodField() criteria_revision = serializers.SerializerMethodField() views_count = serializers.SerializerMethodField() criterias = serializers.SerializerMethodField() @@ -86,11 +87,15 @@ class Meta: "max_rates", "criterias", "criteria_revision", + "managed_evaluation", ] def get_criteria_revision(self, obj): return self.context.get("criteria_revision") + def get_managed_evaluation(self, obj): + return bool(self.context.get("managed_evaluation")) + def get_views_count(self, obj) -> int: return get_views_count(obj) @@ -111,6 +116,18 @@ def _get_user_scores(self, obj): def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: user_scores = self._get_user_scores(obj) + if self.context.get("managed_evaluation"): + program_id = self.context["view"].kwargs.get("program_id") + scores = {score.criteria_id: score for score in user_scores} + return [ + { + **CriteriaSerializer(c).data, + **({"value": scores[c.pk].value} if c.pk in scores else {}), + } + for c in Criteria.objects.filter(partner_program_id=program_id).order_by( + "pk" + ) + ] if user_scores: serializer = ProjectScoreSerializer(user_scores, many=True) return serializer.data @@ -121,13 +138,41 @@ def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: def get_scored(self, obj) -> bool: user_scores = self._get_user_scores(obj) + if self.context.get("managed_evaluation"): + program_id = self.context["view"].kwargs.get("program_id") + required = set( + Criteria.objects.filter(partner_program_id=program_id).values_list( + "pk", flat=True + ) + ) + return bool(required) and required.issubset( + {score.criteria_id for score in user_scores} + ) return bool(user_scores) def get_rated_experts(self, obj) -> list[int]: program_scores = self._get_program_scores(obj) + if self.context.get("managed_evaluation"): + from collections import defaultdict + + required = set( + Criteria.objects.filter( + partner_program_id=self.context["view"].kwargs["program_id"] + ).values_list("pk", flat=True) + ) + scored = defaultdict(set) + for score in program_scores: + scored[score.user_id].add(score.criteria_id) + return [ + user_id + for user_id, ids in scored.items() + if required and required.issubset(ids) + ] return list({score.user_id for score in program_scores}) def get_rated_count(self, obj) -> int: + if self.context.get("managed_evaluation"): + return len(self.get_rated_experts(obj)) rated_attr = getattr(obj, "rated_count", None) if rated_attr is not None: return rated_attr diff --git a/project_rates/services.py b/project_rates/services.py index 20e1ca6c..baf42ef6 100644 --- a/project_rates/services.py +++ b/project_rates/services.py @@ -1,5 +1,6 @@ from django.db import transaction from django.db.models import Count, Prefetch, Q, QuerySet +from django.utils import timezone from rest_framework.exceptions import ValidationError @@ -67,7 +68,7 @@ def get_projects_for_rate_queryset( ) projects_qs = Project.objects.filter(draft=False, id__in=project_ids) - if program.is_distributed_evaluation: + if program.is_distributed_evaluation or getattr(program, "assigned_only", False): projects_qs = projects_qs.filter( expert_assignments__partner_program=program, expert_assignments__expert__user=user, @@ -142,7 +143,7 @@ def submit_project_scores(*, user, project_id: int, data) -> None: ) user_has_scores = scores_qs.filter(user_id=user.id).exists() - if program.max_project_rates: + if program.max_project_rates and program.required_evaluators is None: distinct_raters = scores_qs.values("user_id").distinct().count() if not user_has_scores and distinct_raters >= program.max_project_rates: raise MaxProjectRatesReached(program.max_project_rates) @@ -181,6 +182,35 @@ def _prepare_project_score_data( program = criteria_qs.first().partner_program Expert.objects.get(user__id=user.id, programs=program) + if program.required_evaluators is not None: + from projects.models import Collaborator + + if ( + program.datetime_evaluation_ends + and timezone.now() > program.datetime_evaluation_ends + ): + raise ValidationError("Срок оценивания завершён.") + + required = set(program.criterias.exclude(type="str").values_list("pk", flat=True)) + if not required.issubset(criteria_ids): + raise ValidationError( + "Заполните все обязательные критерии и сохраните оценку целиком." + ) + if not PartnerProgramProject.objects.filter( + partner_program=program, project_id=project_id, submitted=True + ).exists(): + raise ValidationError("Сначала нужно сдать решение.") + if ( + Project.objects.filter(pk=project_id, leader=user).exists() + or Collaborator.objects.filter(project_id=project_id, user=user).exists() + ): + raise ValidationError("Нельзя оценивать проект своей команды.") + # Optional comments are stored too, keeping completion and legacy analytics consistent. + present = set(criteria_ids) + for criterion in program.criterias.filter(type="str").exclude(pk__in=present): + rating_data.append({"criterion_id": criterion.pk, "value": ""}) + criteria_ids.append(criterion.pk) + for criterion in rating_data: criterion["user"] = user.id criterion["project"] = project_id diff --git a/project_rates/tasks.py b/project_rates/tasks.py new file mode 100644 index 00000000..8e5751af --- /dev/null +++ b/project_rates/tasks.py @@ -0,0 +1,47 @@ +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 procollab.celery import app + + +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_expert_invitation(invitation_id, token): + from project_rates.models import ExpertInvitation + from project_rates.workspace import digest + + rows = ExpertInvitation.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("/") + + "/expert-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/expert_invitation.txt", context), + settings.EMAIL_USER, + [invitation.email], + ) + message.attach_alternative( + render_to_string("email/expert_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/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py new file mode 100644 index 00000000..b1d634c3 --- /dev/null +++ b/project_rates/tests/test_workspace.py @@ -0,0 +1,437 @@ +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.services.analytics import _solution_rows +from project_rates.models import ExpertInvitation, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_user, + create_rate_expert, + create_rate_project, + create_rate_criteria, + link_project_to_program, +) +from project_rates.workspace import digest, target +from users.models import CustomUser, Expert +from users.serializers import UserListSerializer + + +@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 WorkspaceTests(TestCase): + def test_regular_profile_cannot_clear_birthday(self): + response = self.client.patch( + f"/auth/users/{self.manager.pk}/", {"birthday": None}, format="json" + ) + self.assertEqual(response.status_code, 400, response.data) + self.manager.refresh_from_db() + self.assertIsNotNone(self.manager.birthday) + + @patch("project_rates.services.send_email.delay") + def test_managed_scoring_respects_submission_own_team_and_deadline(self, send): + self.enable(1) + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=self.expert.expert + ) + self.client.force_authenticate(self.expert) + payload = [{"criterion_id": self.criterion.pk, "value": "0"}] + url = f"/rate-project/rate/{self.project.pk}" + type(self.link).objects.filter(pk=self.link.pk).update(submitted=False) + self.assertEqual(self.post(url, payload).status_code, 400) + type(self.link).objects.filter(pk=self.link.pk).update(submitted=True) + type(self.project).objects.filter(pk=self.project.pk).update(leader=self.expert) + self.assertEqual(self.post(url, payload).status_code, 400) + type(self.project).objects.filter(pk=self.project.pk).update( + leader_id=self.project.leader_id + ) + self.program.datetime_evaluation_ends = timezone.now() - timedelta(seconds=1) + self.program.save() + self.assertEqual(self.post(url, payload).status_code, 400) + self.assertFalse(ProjectScore.objects.exists()) + send.assert_not_called() + + def test_existing_mixed_case_email_keeps_working_in_invitation_login(self): + self.manager.email = "MixedCase@example.com" + self.manager.save() + invitation, token = self.invitation("mixedcase@example.com") + self.client.force_authenticate(None) + preview = self.client.get(f"/evaluation/invitations/{token}/").data + self.assertTrue(preview["account_exists"]) + self.assertEqual(preview["login_email"], self.manager.email) + result = self.post( + "/api/token/", {"email": preview["login_email"], "password": "pass"} + ) + self.assertEqual(result.status_code, 200, result.data) + + def setUp(self): + self.program = create_rate_program(is_competitive=True) + self.manager = create_rate_user() + self.program.managers.add(self.manager) + self.expert = create_rate_expert(program=self.program) + self.project = create_rate_project() + self.program.users.add( + self.project.leader, through_defaults={"partner_program_data": {}} + ) + self.link = link_project_to_program(self.program, self.project) + self.link.submitted = True + self.link.save() + self.criterion = create_rate_criteria(self.program) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.root = f"/programs/{self.program.pk}/management/" + + def post(self, path, data): + return self.client.post(path, data, format="json") + + def enable(self, number=2): + self.program.required_evaluators = number + self.program.is_distributed_evaluation = True + self.program.save() + + def invitation( + self, email="new-expert@example.com", token="test-invitation-token-0123456789" + ): + return ( + ExpertInvitation.objects.create( + partner_program=self.program, + email=email, + invited_by=self.manager, + token_digest=digest(token), + expires_at=timezone.now() + timedelta(days=7), + ), + token, + ) + + def test_manager_invite_delivery_digest_duplicate_and_role_scope(self): + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + "experts/", {"email": "New-Expert@EXAMPLE.COM"} + ) + self.assertEqual(response.status_code, 201, response.data) + invitation = ExpertInvitation.objects.get() + self.assertEqual(invitation.email, "new-expert@example.com") + self.assertEqual(invitation.delivery_status, "sent") + self.assertEqual(len(mail.outbox), 1) + token = re.search(r"/expert-invitation/([\w-]+)", mail.outbox[0].body).group(1) + self.assertEqual(invitation.token_digest, digest(token)) + self.assertNotIn(token, invitation.token_digest) + self.assertIn("Здравствуйте!", mail.outbox[0].body) + self.assertEqual( + self.post(self.root + "experts/", {"email": invitation.email}).status_code, + 409, + ) + self.program.refresh_from_db() + self.assertTrue(self.program.is_distributed_evaluation) + self.assertEqual(target(self.program), 2) + self.client.force_authenticate(create_rate_user()) + self.assertEqual(self.client.get(self.root + "experts/").status_code, 403) + self.assertEqual( + self.post( + self.root + "experts/", {"email": "outsider@example.com"} + ).status_code, + 403, + ) + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.root + "experts/").status_code, 401) + + def test_new_registration_and_normal_signup_birthday_requirement(self): + invitation, token = self.invitation() + url = f"/evaluation/invitations/{token}/" + self.client.force_authenticate(None) + self.assertEqual(self.client.get(url).status_code, 200) + self.assertFalse(CustomUser.objects.filter(email=invitation.email).exists()) + data = dict( + first_name="Анна", + last_name="Волкова", + password="SecureExpert124!", + repeat_password="SecureExpert124!", + accept_policy=True, + accept_age=True, + ) + bad = self.post(url + "register/", {**data, "accept_policy": False}) + self.assertEqual(bad.status_code, 400) + response = self.post(url + "register/", data) + self.assertEqual(response.status_code, 201, response.data) + user = CustomUser.objects.get(email=invitation.email) + self.assertIsNone(user.birthday) + self.assertTrue(user.check_password(data["password"])) + self.assertTrue(user.is_active) + self.assertIsNone(user.onboarding_stage) + self.assertTrue(user.expert.programs.filter(pk=self.program.pk).exists()) + self.assertTrue(response.data["access"]) + self.client.force_authenticate(user) + changed = self.client.patch( + f"/auth/users/{user.pk}/", {"first_name": "Анна"}, format="json" + ) + self.assertEqual(changed.status_code, 200, changed.data) + self.client.force_authenticate(None) + self.assertTrue(UserListSerializer().fields["birthday"].required) + self.assertFalse(UserListSerializer().fields["birthday"].allow_null) + self.assertEqual(self.post(url + "register/", data).status_code, 410) + + def test_existing_member_accepts_without_role_loss_and_wrong_account_denied(self): + invitation, token = self.invitation(self.manager.email) + url = f"/evaluation/invitations/{token}/accept/" + self.client.force_authenticate(self.expert) + self.assertEqual(self.post(url, {}).status_code, 403) + self.client.force_authenticate(self.manager) + self.assertEqual(self.post(url, {}).status_code, 200) + self.assertEqual(self.post(url, {}).status_code, 200) + self.manager.refresh_from_db() + self.assertEqual(self.manager.user_type, CustomUser.MEMBER) + self.assertTrue(self.program.managers.filter(pk=self.manager.pk).exists()) + self.assertTrue( + Expert.objects.filter(user=self.manager, programs=self.program).exists() + ) + # Program membership suffices without mutating the global role or registering as participant. + self.assertEqual( + self.client.get(f"/rate-project/{self.program.pk}").status_code, 200 + ) + + def test_invitation_lifecycle_and_private_hub(self): + invitation, token = self.invitation(self.manager.email) + self.assertEqual( + self.client.get("/evaluation/programs/").data["results"][0]["status"], + "pending", + ) + self.assertFalse( + self.client.get("/evaluation/programs/").data["results"][0].get("assigned") + ) + with self.captureOnCommitCallbacks(execute=True): + response = self.post( + self.root + f"expert-invitations/{invitation.pk}/resend/", {} + ) + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(None) + self.assertEqual( + self.client.get(f"/evaluation/invitations/{token}/").status_code, 404 + ) + current_token = re.search( + r"/expert-invitation/([\w-]+)", mail.outbox[-1].body + ).group(1) + self.client.force_authenticate(self.manager) + self.assertEqual( + self.post( + self.root + f"expert-invitations/{invitation.pk}/revoke/", {} + ).status_code, + 204, + ) + self.client.force_authenticate(None) + self.assertEqual( + self.client.get(f"/evaluation/invitations/{current_token}/").status_code, 410 + ) + invitation, token = self.invitation( + "expired@example.com", "expired-invitation-token-012345" + ) + invitation.expires_at = timezone.now() - timedelta(seconds=1) + invitation.save() + self.assertEqual( + self.post(f"/evaluation/invitations/{token}/register/", {}).status_code, 410 + ) + + def test_resending_old_invitation_does_not_overwrite_new_pending_one(self): + old, _ = self.invitation("pending@example.com") + old.status = "revoked" + old.save() + current, _ = self.invitation( + "pending@example.com", "new-pending-token-0123456789" + ) + response = self.post(self.root + f"expert-invitations/{old.pk}/resend/", {}) + self.assertEqual(response.status_code, 409) + old.refresh_from_db() + current.refresh_from_db() + self.assertEqual(old.status, "revoked") + self.assertEqual(current.status, "pending") + + def test_manual_assignment_scope_own_team_and_stale_write(self): + url = self.root + f"experts/{self.expert.expert.pk}/assignments/" + snapshot = self.client.get(url).data + data = {"revision": snapshot["revision"], "project_ids": [self.project.pk]} + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["projects"][0]["assigned"]) + self.assertEqual(self.client.put(url, data, format="json").status_code, 409) + own = create_rate_project(leader=self.expert) + self.program.users.add(self.expert, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(self.program, own) + link.submitted = True + link.save() + data = { + "revision": self.client.get(url).data["revision"], + "project_ids": [self.project.pk, own.pk], + } + self.assertEqual(self.client.put(url, data, format="json").status_code, 400) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + outsider = create_rate_expert(program=create_rate_program()) + self.assertEqual( + self.client.get( + self.root + f"experts/{outsider.expert.pk}/assignments/" + ).status_code, + 404, + ) + + def test_auto_allocation_shortage_balance_preservation_and_conflict(self): + self.enable() + other = create_rate_expert(program=self.program) + second = create_rate_project() + self.program.users.add( + second.leader, through_defaults={"partner_program_data": {}} + ) + link = link_project_to_program(self.program, second) + link.submitted = True + link.save() + args = {"expert_ids": [self.expert.expert.pk, other.expert.pk]} + plan = self.post(self.root + "expert-allocation/", args) + self.assertEqual(plan.status_code, 200, plan.data) + self.assertEqual(len(plan.data["assignments"]), 4) + self.assertEqual(plan.data["shortages"], []) + apply_url = self.root + "expert-allocation/apply/" + self.assertEqual( + self.post(apply_url, {"plan_token": plan.data["plan_token"]}).status_code, 200 + ) + self.assertEqual(ProjectExpertAssignment.objects.count(), 4) + self.assertEqual( + self.post(apply_url, {"plan_token": plan.data["plan_token"]}).status_code, 409 + ) + next_plan = self.post(self.root + "expert-allocation/", args) + self.assertEqual(next_plan.data["assignments"], []) + self.assertEqual( + self.post(apply_url, {"plan_token": "tampered"}).status_code, 409 + ) + + @patch("project_rates.services.send_email.delay") + def test_complete_form_scores_zero_false_optional_comment_and_protection(self, send): + self.enable(1) + boolean = create_rate_criteria(self.program, type="bool") + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=self.expert.expert + ) + self.client.force_authenticate(self.expert) + url = f"/rate-project/rate/{self.project.pk}" + self.assertEqual( + self.post( + url, [{"criterion_id": self.criterion.pk, "value": "0"}] + ).status_code, + 400, + ) + self.assertFalse(ProjectScore.objects.exists()) + response = self.post( + url, + [ + {"criterion_id": self.criterion.pk, "value": "0"}, + {"criterion_id": boolean.pk, "value": "False"}, + ], + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(ProjectScore.objects.count(), self.program.criterias.count()) + self.program.refresh_from_db() + self.assertIsNotNone(self.program.evaluation_criteria_locked_at) + self.assertEqual( + self.client.get("/evaluation/programs/").data["results"][0]["completed"], 1 + ) + self.client.force_authenticate(self.manager) + assignment_url = self.root + f"experts/{self.expert.expert.pk}/assignments/" + data = { + "revision": self.client.get(assignment_url).data["revision"], + "project_ids": [], + } + self.assertEqual( + self.client.put(assignment_url, data, format="json").status_code, 409 + ) + self.assertTrue(ProjectExpertAssignment.objects.filter(pk=assignment.pk).exists()) + before = ProjectScore.objects.count() + self.assertEqual( + _solution_rows(self.program).get(pk=self.link.pk).status, "evaluated" + ) + quota_url = self.root + "evaluators/" + preview = self.post(quota_url, {"required_evaluators": 2}).data + self.assertEqual(preview["ready_before"], 1) + self.assertEqual(preview["ready_after"], 0) + saved = self.client.put( + quota_url, + {"required_evaluators": 2, "revision": preview["revision"]}, + format="json", + ) + self.assertEqual(saved.status_code, 200, saved.data) + self.program.refresh_from_db() + self.assertEqual( + _solution_rows(self.program).get(pk=self.link.pk).status, + "partially_evaluated", + ) + self.assertEqual(ProjectScore.objects.count(), before) + + def test_own_invitation_accept_and_zero_assignments_keep_menu_access(self): + invitation, _ = self.invitation(self.manager.email) + url = f"/evaluation/own-invitations/{invitation.pk}/accept/" + self.assertEqual(self.post(url, {}).status_code, 200) + hub = self.client.get("/evaluation/programs/").data + self.assertTrue(hub["has_access"]) + self.assertEqual(hub["results"][0]["assigned"], 0) + self.assertEqual(hub["results"][0]["status"], "joined") + self.client.force_authenticate(create_rate_user()) + self.assertEqual(self.post(url, {}).status_code, 404) + self.assertFalse(self.client.get("/evaluation/programs/").data["has_access"]) + + def test_reset_password_preserves_invitation_and_dev_origin(self): + invitation, token = self.invitation(self.expert.email) + self.client.force_authenticate(None) + response = self.post( + "/auth/reset_password/", + {"email": invitation.email, "expert_invitation_token": token}, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertIn( + "https://dev.procollab.ru/expert-password-reset/?", mail.outbox[0].body + ) + self.assertIn("expert_invitation=" + token, mail.outbox[0].body) + self.client.force_authenticate(self.expert) + self.post(f"/evaluation/invitations/{token}/accept/", {}) + self.client.force_authenticate(None) + response = self.post( + "/auth/reset_password/", + {"email": invitation.email, "expert_invitation_token": token}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("expert_invitation=" + token, mail.outbox[-1].body) + + def test_shortage_is_read_only_and_direct_apply_is_rejected(self): + self.enable(3) + plan = self.post( + self.root + "expert-allocation/", {"expert_ids": [self.expert.expert.pk]} + ).data + self.assertEqual(plan["shortages"][0]["missing"], 2) + response = self.post( + self.root + "expert-allocation/apply/", {"plan_token": plan["plan_token"]} + ) + self.assertEqual(response.status_code, 409) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_current_profile_and_program_buttons_use_scoped_roles(self): + invitation, token = self.invitation(self.manager.email) + self.assertTrue( + self.client.get("/auth/users/current/").data["has_evaluation_access"] + ) + detail = self.client.get(f"/programs/{self.program.pk}/").data + self.assertFalse(detail["is_user_expert"]) + self.assertEqual(detail["pending_expert_invitation_id"], invitation.pk) + self.post(f"/evaluation/invitations/{token}/accept/", {}) + detail = self.client.get(f"/programs/{self.program.pk}/").data + self.assertTrue(detail["is_user_expert"]) + another = create_rate_program() + self.assertFalse( + self.client.get(f"/programs/{another.pk}/").data["is_user_expert"] + ) diff --git a/project_rates/tests/test_workspace_concurrency.py b/project_rates/tests/test_workspace_concurrency.py new file mode 100644 index 00000000..0313f43f --- /dev/null +++ b/project_rates/tests/test_workspace_concurrency.py @@ -0,0 +1,112 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +from django.db import connections +from django.test import TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from project_rates.models import ExpertInvitation, ProjectExpertAssignment +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_user, + create_rate_expert, + create_rate_project, + link_project_to_program, +) +from project_rates.workspace import digest, revision +from users.models import CustomUser + + +@skipUnlessDBFeature("has_select_for_update") +class WorkspaceConcurrencyTests(TransactionTestCase): + def setUp(self): + self.program = create_rate_program( + required_evaluators=2, is_distributed_evaluation=True + ) + self.manager = create_rate_user() + self.program.managers.add(self.manager) + + def test_two_registration_submits_create_one_account(self): + token = "concurrent-invitation-token-0123456789" + email = "concurrent-expert@example.com" + ExpertInvitation.objects.create( + partner_program=self.program, + email=email, + token_digest=digest(token), + expires_at=timezone.now() + timedelta(days=7), + ) + gate = Barrier(2) + + def register(): + connections.close_all() + try: + gate.wait(timeout=10) + client = APIClient() + return client.post( + f"/evaluation/invitations/{token}/register/", + dict( + first_name="Анна", + last_name="Волкова", + password="SafeExpert234!", + repeat_password="SafeExpert234!", + accept_policy=True, + accept_age=True, + ), + format="json", + ).status_code + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + statuses = list(executor.map(lambda _: register(), range(2))) + self.assertEqual(sorted(statuses), [201, 410]) + self.assertEqual(CustomUser.objects.filter(email=email).count(), 1) + self.assertEqual(self.program.experts.filter(user__email=email).count(), 1) + + def test_concurrent_manual_save_and_auto_apply_reject_stale_writer(self): + expert = create_rate_expert(program=self.program) + other = create_rate_expert(program=self.program) + project = create_rate_project() + link = link_project_to_program(self.program, project) + type(link).objects.filter(pk=link.pk).update(submitted=True) + root = f"/programs/{self.program.pk}/management/" + client = APIClient() + client.force_authenticate(self.manager) + preview = client.post( + root + "expert-allocation/", + {"expert_ids": [expert.expert.pk, other.expert.pk]}, + format="json", + ) + self.assertEqual(preview.status_code, 200, preview.data) + rev = revision(self.program) + gate = Barrier(2) + + def save(manual): + connections.close_all() + try: + inner = APIClient() + inner.force_authenticate(self.manager) + gate.wait(timeout=10) + if manual: + return inner.put( + root + f"experts/{expert.expert.pk}/assignments/", + {"revision": rev, "project_ids": [project.pk]}, + format="json", + ).status_code + return inner.post( + root + "expert-allocation/apply/", + {"plan_token": preview.data["plan_token"]}, + format="json", + ).status_code + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + statuses = list(executor.map(save, [True, False])) + self.assertEqual(sorted(statuses), [200, 409]) + self.assertIn( + ProjectExpertAssignment.objects.filter(partner_program=self.program).count(), + [1, 2], + ) diff --git a/project_rates/views.py b/project_rates/views.py index 25ecd5e8..d19dc34a 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -68,6 +68,9 @@ def post(self, request, *args, **kwargs): def _get_program(self): if not hasattr(self, "_program"): self._program = get_rate_program(self.kwargs.get("program_id")) + self._program.assigned_only = ( + self.request.query_params.get("assigned_only") == "1" + ) return self._program def get_queryset(self): @@ -86,4 +89,9 @@ def get_serializer_context(self): context = super().get_serializer_context() context["program_max_rates"] = self._get_program().max_project_rates context["criteria_revision"] = revision(self._get_program()) + context["managed_evaluation"] = ( + self._get_program().required_evaluators is not None + ) + if context["managed_evaluation"]: + context["program_max_rates"] = self._get_program().required_evaluators return context diff --git a/project_rates/workspace.py b/project_rates/workspace.py new file mode 100644 index 00000000..5c652a34 --- /dev/null +++ b/project_rates/workspace.py @@ -0,0 +1,608 @@ +"""Program-scoped expert workspace. All organizer writes share the scoring lock.""" + +import hashlib +import json +import secrets +from collections import Counter, defaultdict +from datetime import timedelta + +from django.core import signing +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from partner_programs.services.assignment_analytics import annotated_assignment_queryset +from partner_programs.services.case_analytics import project_case_links +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.management import managed_program, person_name +from project_rates.models import ExpertInvitation, ProjectExpertAssignment, ProjectScore +from projects.models import Collaborator +from users.models import CustomUser, Expert + + +class WorkspaceConflict(APIException): + status_code = 409 + + def __init__(self, code, detail): + super().__init__({"code": code, "detail": detail}) + + +class InvitationGone(APIException): + status_code = 410 + default_detail = ( + "Приглашение истекло или отозвано. Попросите организатора отправить новое." + ) + + +def digest(token): + return hashlib.sha256(token.encode()).hexdigest() + + +def target(program): + return program.required_evaluators or program.max_project_rates or 1 + + +def expert_rows(program): + return list( + program.experts.filter(user__is_active=True).select_related("user").order_by("pk") + ) + + +def assignment_snapshot(program): + return list( + annotated_assignment_queryset(program.pk).select_related( + "expert__user", "project" + ) + ) + + +def revision(program): + """Includes team, membership, submission and score changes invalidating a plan.""" + data = [ + program.pk, + program.required_evaluators, + program.max_project_rates, + program.is_distributed_evaluation, + list(program.experts.order_by("pk").values_list("pk", "user__is_active")), + list( + program.project_expert_assignments.order_by("pk").values_list( + "pk", "project_id", "expert_id" + ) + ), + list( + PartnerProgramProject.objects.filter(partner_program=program) + .order_by("pk") + .values_list( + "project_id", "submitted", "project__leader_id", "project__draft" + ) + ), + list( + Collaborator.objects.filter( + project_id__in=PartnerProgramProject.objects.filter( + partner_program=program + ).values("project_id") + ) + .order_by("pk") + .values_list("project_id", "user_id") + ), + list( + ProjectScore.objects.filter(criteria__partner_program=program) + .order_by("pk") + .values_list("pk", "project_id", "user_id", "criteria_id", "value") + ), + list(program.criterias.order_by("pk").values_list("pk", "type")), + ] + return digest(json.dumps(data, ensure_ascii=False, default=str)) + + +def check_revision(program, expected): + if expected != revision(program): + raise WorkspaceConflict( + "stale_assignments", + "Данные изменились. Обновите список и проверьте изменения.", + ) + + +def enable_workspace(program): + """Explicit organizer action opts in; preserve assignments behind historical scores.""" + if program.required_evaluators is not None: + return + program.required_evaluators = target(program) + program.is_distributed_evaluation = True + program.save( + update_fields=[ + "required_evaluators", + "is_distributed_evaluation", + "datetime_updated", + ] + ) + historical = ( + ProjectScore.objects.filter(criteria__partner_program=program) + .values_list("project_id", "user_id") + .distinct() + ) + members = dict(program.experts.values_list("user_id", "pk")) + linked = set( + PartnerProgramProject.objects.filter(partner_program=program).values_list( + "project_id", flat=True + ) + ) + ProjectExpertAssignment.objects.bulk_create( + [ + ProjectExpertAssignment( + partner_program=program, project_id=p, expert_id=members[u] + ) + for p, u in historical + if u in members and p in linked + ], + ignore_conflicts=True, + ) + + +def invitation_data(invitation): + expired = invitation.status == "pending" and invitation.expires_at <= timezone.now() + return { + "id": invitation.pk, + "email": invitation.email, + "status": "expired" if expired else invitation.status, + "delivery_status": invitation.delivery_status, + "expires_at": invitation.expires_at, + "created_at": invitation.created_at, + } + + +def program_card(program): + return { + "id": program.pk, + "name": program.name, + "image_address": program.image_address, + "image_framing": program.image_framing, + "datetime_evaluation_ends": program.datetime_evaluation_ends, + } + + +def workspace_data(program): + assignments = assignment_snapshot(program) + counts = Counter(a.expert_id for a in assignments) + completed = Counter(a.expert_id for a in assignments if a.is_completed) + invitations = list( + program.expert_invitations.exclude(status="accepted").order_by("pk") + ) + users = { + u.email.lower(): u + for u in CustomUser.objects.filter(email__in=[i.email for i in invitations]) + } + members = expert_rows(program) + rows = [ + { + "id": e.pk, + "user_id": e.user_id, + "name": person_name(e.user), + "email": e.user.email, + "avatar": e.user.avatar, + "status": "joined", + "assigned": counts[e.pk], + "completed": completed[e.pk], + "remaining": counts[e.pk] - completed[e.pk], + } + for e in members + ] + joined_emails = {e.user.email.lower() for e in members} + for invitation in invitations: + if invitation.email in joined_emails: + continue + user = users.get(invitation.email) + rows.append( + { + **invitation_data(invitation), + "id": f"invite-{invitation.pk}", + "invitation_id": invitation.pk, + "name": person_name(user) if user else None, + "avatar": user.avatar if user else None, + "assigned": None, + "completed": None, + "remaining": None, + } + ) + submitted = list( + PartnerProgramProject.objects.filter(partner_program=program, submitted=True) + .values_list("project_id", flat=True) + .distinct() + ) + per_project = Counter(a.project_id for a in assignments) + complete_project = Counter(a.project_id for a in assignments if a.is_completed) + return { + "revision": revision(program), + "required_evaluators": target(program), + "managed_evaluation": program.required_evaluators is not None, + "submitted_projects": len(submitted), + "missing_assignments": sum( + max(0, target(program) - per_project[p]) for p in submitted + ), + "ready_projects": sum(complete_project[p] >= target(program) for p in submitted), + "experts": rows, + } + + +def token_invitation(token, *, lock=False, allow_accepted=False): + if not isinstance(token, str) or not 20 <= len(token) <= 100: + from django.http import Http404 + + raise Http404 + qs = ( + ExpertInvitation.objects.select_for_update() if lock else ExpertInvitation.objects + ) + invitation = get_object_or_404( + qs.select_related("partner_program"), token_digest=digest(token) + ) + if invitation.status == "accepted" and allow_accepted: + return invitation + if invitation.status != "pending" or invitation.expires_at <= timezone.now(): + raise InvitationGone() + return invitation + + +@transaction.atomic +def invite(program_id, user, email, *, invitation_id=None): + program = managed_program(program_id, user, lock=True) + email = email.strip().lower() + if program.experts.filter(user__email__iexact=email).exists(): + raise WorkspaceConflict("already_expert", "Этот эксперт уже в программе.") + if invitation_id is None: + previous = program.expert_invitations.filter( + email=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 = ExpertInvitation( + partner_program=program, email=email, invited_by=user + ) + else: + invitation = get_object_or_404(program.expert_invitations, pk=invitation_id) + if invitation.status == "accepted": + raise WorkspaceConflict("already_accepted", "Приглашение уже принято.") + if ( + program.expert_invitations.filter(email=email, status="pending") + .exclude(pk=invitation.pk) + .exists() + ): + raise WorkspaceConflict( + "already_invited", "Другое приглашение уже отправлено. Обновите список." + ) + token = secrets.token_urlsafe(32) + invitation.token_digest = digest(token) + invitation.status = "pending" + invitation.delivery_status = "queued" + invitation.expires_at = timezone.now() + timedelta(days=7) + invitation.save() + enable_workspace(program) + from project_rates.tasks import send_expert_invitation + + def enqueue(): + try: + send_expert_invitation.delay(invitation.pk, token) + except Exception: + ExpertInvitation.objects.filter( + pk=invitation.pk, token_digest=digest(token) + ).update(delivery_status="failed") + + transaction.on_commit(enqueue) + return invitation_data(invitation) + + +@transaction.atomic +def revoke(program_id, user, invitation_id): + program = managed_program(program_id, user, lock=True) + invitation = get_object_or_404( + program.expert_invitations.select_for_update(), pk=invitation_id + ) + if invitation.status == "accepted": + raise WorkspaceConflict( + "already_accepted", "Принятое приглашение нельзя отозвать." + ) + invitation.status = "revoked" + invitation.save(update_fields=["status", "updated_at"]) + + +def accept_invitation(invitation, user): + if ( + not user.is_authenticated + or not user.is_active + or user.email.lower() != invitation.email + ): + raise PermissionDenied("Войдите в аккаунт с почтой из приглашения.") + if invitation.status == "accepted": + if invitation.accepted_by_id != user.pk: + raise PermissionDenied() + return + if invitation.status != "pending" or invitation.expires_at <= timezone.now(): + raise InvitationGone() + expert, _ = Expert.objects.get_or_create(user=user) + expert.programs.add(invitation.partner_program) + invitation.status = "accepted" + invitation.accepted_by = user + invitation.save(update_fields=["status", "accepted_by", "updated_at"]) + + +def my_programs(user): + programs = list( + PartnerProgram.objects.filter(experts__user=user) + .distinct() + .order_by("name", "pk") + ) + rows = [] + for program in programs: + assignments = list( + annotated_assignment_queryset(program.pk).filter(expert__user=user) + ) + done = sum(a.is_completed for a in assignments) + rows.append( + { + **program_card(program), + "status": "joined", + "assigned": len(assignments), + "completed": done, + "remaining": len(assignments) - done, + } + ) + joined = {p.pk for p in programs} + if user.is_active: + pending = ( + ExpertInvitation.objects.filter( + email__iexact=user.email, status="pending", expires_at__gt=timezone.now() + ) + .select_related("partner_program") + .order_by("pk") + ) + rows.extend( + { + **program_card(i.partner_program), + "status": "pending", + "email": i.email, + "invitation_id": i.pk, + } + for i in pending + if i.partner_program_id not in joined + ) + return {"results": rows, "has_access": bool(rows)} + + +def own_projects(user_id, project_ids): + leaders = PartnerProgramProject.objects.filter( + project_id__in=project_ids, project__leader_id=user_id + ).values_list("project_id", flat=True) + collaborators = Collaborator.objects.filter( + project_id__in=project_ids, user_id=user_id + ).values_list("project_id", flat=True) + return set(leaders) | set(collaborators) + + +def assignment_options(program, expert_id): + expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + links = list( + project_case_links(program, field=get_program_case_field(program)) + .select_related("project") + .order_by("project_id") + ) + assignments = { + a.project_id: a for a in assignment_snapshot(program) if a.expert_id == expert.pk + } + protected = set( + ProjectScore.objects.filter( + criteria__partner_program=program, user=expert.user + ).values_list("project_id", flat=True) + ) + own = own_projects(expert.user_id, [p.project_id for p in links]) + return { + "revision": revision(program), + "projects": [ + { + "id": p.project_id, + "name": p.project.name, + "case_name": p.case_name, + "submitted": p.submitted and not p.project.draft, + "assigned": p.project_id in assignments or p.project_id in protected, + "protected": p.project_id in protected, + "completed": bool( + assignments.get(p.project_id) + and assignments[p.project_id].is_completed + ), + "own_team": p.project_id in own, + } + for p in links + ], + } + + +@transaction.atomic +def save_assignments(program_id, user, expert_id, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + wanted = set(data["project_ids"]) + options = {p["id"]: p for p in assignment_options(program, expert.pk)["projects"]} + existing = set( + program.project_expert_assignments.filter(expert=expert).values_list( + "project_id", flat=True + ) + ) + for project_id in wanted - existing: + option = options.get(project_id) + if not option or not option["submitted"] or option["own_team"]: + raise ValidationError( + "Можно назначить только сданный проект этой программы, кроме своей команды." + ) + protected = set( + ProjectScore.objects.filter( + criteria__partner_program=program, user=expert.user + ).values_list("project_id", flat=True) + ) + if protected - wanted: + raise WorkspaceConflict( + "scores_protected", "Нельзя снять назначение с сохранёнными оценками." + ) + enable_workspace(program) + program.project_expert_assignments.filter( + expert=expert, project_id__in=existing - wanted + ).delete() + ProjectExpertAssignment.objects.bulk_create( + [ + ProjectExpertAssignment(partner_program=program, expert=expert, project_id=p) + for p in sorted(wanted - existing) + ], + ignore_conflicts=True, + ) + return assignment_options(program, expert.pk) + + +def allocation(program, data): + experts = expert_rows(program) + selected = set(data["expert_ids"]) + if not selected or selected - {e.pk for e in experts}: + raise ValidationError("Выберите принявших приглашение экспертов этой программы.") + experts = [e for e in experts if e.pk in selected] + links = list( + PartnerProgramProject.objects.filter( + partner_program=program, submitted=True, project__draft=False + ) + .select_related("project") + .order_by("project_id") + ) + if "project_ids" in data: + ids = set(data["project_ids"]) + if ids - {p.project_id for p in links}: + raise ValidationError("Выберите сданные проекты этой программы.") + links = [p for p in links if p.project_id in ids] + assignments = assignment_snapshot(program) + present = {(a.project_id, a.expert_id) for a in assignments} + counts = Counter(a.project_id for a in assignments) + remaining = Counter(a.expert_id for a in assignments if not a.is_completed) + before = remaining.copy() + teams = defaultdict(set) + for link in links: + teams[link.project.leader_id].add(link.project_id) + for project_id, user_id in Collaborator.objects.filter( + project_id__in=[p.project_id for p in links], + user_id__in=[e.user_id for e in experts], + ).values_list("project_id", "user_id"): + teams[user_id].add(project_id) + own = {e.pk: teams[e.user_id] for e in experts} + additions, shortages = [], [] + for link in links: + needed = max(0, target(program) - counts[link.project_id]) + candidates = [ + e + for e in experts + if (link.project_id, e.pk) not in present and link.project_id not in own[e.pk] + ] + candidates.sort(key=lambda e: (remaining[e.pk], e.pk)) + for expert in candidates[:needed]: + additions.append( + { + "project_id": link.project_id, + "project_name": link.project.name, + "expert_id": expert.pk, + "expert_name": person_name(expert.user), + } + ) + remaining[expert.pk] += 1 + if len(candidates) < needed: + shortages.append( + { + "project_id": link.project_id, + "project_name": link.project.name, + "missing": needed - len(candidates), + } + ) + rev = revision(program) + token = signing.dumps( + { + "program_id": program.pk, + "revision": rev, + "has_shortages": bool(shortages), + "additions": [ + {"project_id": a["project_id"], "expert_id": a["expert_id"]} + for a in additions + ], + }, + salt="expert-allocation", + compress=True, + ) + return { + "revision": rev, + "plan_token": token, + "assignments": additions, + "shortages": shortages, + "load": [ + { + "expert_id": e.pk, + "name": person_name(e.user), + "before": before[e.pk], + "after": remaining[e.pk], + } + for e in experts + ], + } + + +@transaction.atomic +def apply_allocation(program_id, user, token): + program = managed_program(program_id, user, lock=True) + try: + plan = signing.loads(token, salt="expert-allocation", max_age=900) + except signing.BadSignature: + raise WorkspaceConflict( + "stale_plan", "Предпросмотр устарел. Рассчитайте распределение заново." + ) + if plan.get("program_id") != program.pk: + raise ValidationError("План принадлежит другой программе.") + check_revision(program, plan["revision"]) + if plan.get("has_shortages"): + raise WorkspaceConflict( + "expert_shortage", "Не хватает экспертов. Измените параметры распределения." + ) + enable_workspace(program) + ProjectExpertAssignment.objects.bulk_create( + [ + ProjectExpertAssignment(partner_program=program, **row) + for row in plan["additions"] + ], + ignore_conflicts=True, + ) + return workspace_data(program) + + +def quota_preview(program, number): + assignments = assignment_snapshot(program) + completed = Counter(a.project_id for a in assignments if a.is_completed) + counts = Counter(a.project_id for a in assignments) + ids = set( + PartnerProgramProject.objects.filter( + partner_program=program, submitted=True + ).values_list("project_id", flat=True) + ) + return { + "revision": revision(program), + "required_evaluators": number, + "ready_before": sum(completed[p] >= target(program) for p in ids), + "ready_after": sum(completed[p] >= number for p in ids), + "missing_assignments": sum(max(0, number - counts[p]) for p in ids), + "surplus_assignments": sum(max(0, counts[p] - number) for p in ids), + } + + +@transaction.atomic +def save_quota(program_id, user, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + enable_workspace(program) + program.required_evaluators = data["required_evaluators"] + program.save(update_fields=["required_evaluators", "datetime_updated"]) + return workspace_data(program) diff --git a/project_rates/workspace_urls.py b/project_rates/workspace_urls.py new file mode 100644 index 00000000..4d3fdb86 --- /dev/null +++ b/project_rates/workspace_urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from .workspace_views import ( + MyProgramsView, + AcceptOwnInvitationView, + PublicInvitationView, + PublicInvitationActionView, +) + +urlpatterns = [ + path("programs/", MyProgramsView.as_view()), + path( + "own-invitations//accept/", AcceptOwnInvitationView.as_view() + ), + path("invitations//", PublicInvitationView.as_view()), + path("invitations///", PublicInvitationActionView.as_view()), +] diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py new file mode 100644 index 00000000..c7e71c5a --- /dev/null +++ b/project_rates/workspace_views.py @@ -0,0 +1,283 @@ +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken + +from partner_programs.models import PartnerProgram +from partner_programs.services.management import managed_program +from project_rates import workspace as service +from project_rates.models import ExpertInvitation +from users.models import CustomUser + + +class InvitationThrottle(AnonRateThrottle): + rate = "30/min" + + +class InvitationEmail(serializers.Serializer): + email = serializers.EmailField(max_length=254) + + +class RevisionWrite(serializers.Serializer): + revision = serializers.CharField(max_length=64) + + +class AssignmentWrite(RevisionWrite): + project_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=10000 + ) + + +class AllocationWrite(serializers.Serializer): + expert_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), allow_empty=False, max_length=1000 + ) + project_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), required=False, max_length=10000 + ) + + +class QuotaWrite(serializers.Serializer): + required_evaluators = serializers.IntegerField(min_value=1, max_value=1000) + revision = serializers.CharField(max_length=64, required=False) + + +def validated(serializer_type, data): + serializer = serializer_type(data=data) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + +class PrivateView(APIView): + permission_classes = [IsAuthenticated] + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "no-store" + response["Referrer-Policy"] = "no-referrer" + return response + + +class ExpertsView(PrivateView): + def get(self, request, pk): + return Response(service.workspace_data(managed_program(pk, request.user))) + + def post(self, request, pk): + data = validated(InvitationEmail, request.data) + return Response(service.invite(pk, request.user, data["email"]), status=201) + + +class InvitationActionView(PrivateView): + def post(self, request, pk, invitation_id, action): + if action == "revoke": + service.revoke(pk, request.user, invitation_id) + return Response(status=204) + if action == "resend": + program = managed_program(pk, request.user) + invitation = get_object_or_404(program.expert_invitations, pk=invitation_id) + return Response( + service.invite( + pk, request.user, invitation.email, invitation_id=invitation_id + ) + ) + return Response(status=404) + + +class ExpertAssignmentsView(PrivateView): + def get(self, request, pk, expert_id): + return Response( + service.assignment_options(managed_program(pk, request.user), expert_id) + ) + + def put(self, request, pk, expert_id): + data = validated(AssignmentWrite, request.data) + return Response(service.save_assignments(pk, request.user, expert_id, data)) + + +class AllocationView(PrivateView): + @transaction.atomic + def post(self, request, pk): + return Response( + service.allocation( + managed_program(pk, request.user, lock=True), + validated(AllocationWrite, request.data), + ) + ) + + +class AllocationApplyView(PrivateView): + def post(self, request, pk): + token = serializers.CharField(max_length=500000).run_validation( + request.data.get("plan_token") + ) + return Response(service.apply_allocation(pk, request.user, token)) + + +class EvaluatorsView(PrivateView): + @transaction.atomic + def post(self, request, pk): + data = validated(QuotaWrite, request.data) + return Response( + service.quota_preview( + managed_program(pk, request.user, lock=True), data["required_evaluators"] + ) + ) + + def put(self, request, pk): + data = validated(QuotaWrite, request.data) + if "revision" not in data: + raise ValidationError( + {"revision": "Обновите предпросмотр перед сохранением."} + ) + return Response(service.save_quota(pk, request.user, data)) + + +class MyProgramsView(PrivateView): + def get(self, request): + return Response(service.my_programs(request.user)) + + +class AcceptOwnInvitationView(PrivateView): + @transaction.atomic + def post(self, request, invitation_id): + invitation = get_object_or_404( + ExpertInvitation, pk=invitation_id, email__iexact=request.user.email + ) + PartnerProgram.objects.select_for_update().get(pk=invitation.partner_program_id) + invitation = ExpertInvitation.objects.select_for_update().get(pk=invitation.pk) + service.accept_invitation(invitation, request.user) + return Response({"program_id": invitation.partner_program_id, "accepted": True}) + + +class InvitedRegistration(serializers.ModelSerializer): + password = serializers.CharField(min_length=8, max_length=128, write_only=True) + repeat_password = serializers.CharField(write_only=True) + accept_policy = serializers.BooleanField() + accept_age = serializers.BooleanField() + + class Meta: + model = CustomUser + fields = [ + "first_name", + "last_name", + "password", + "repeat_password", + "accept_policy", + "accept_age", + ] + + def validate(self, attrs): + if not attrs["accept_policy"] or not attrs["accept_age"]: + raise ValidationError("Подтвердите согласия платформы.") + if attrs["password"] != attrs["repeat_password"]: + raise ValidationError({"repeat_password": "Пароли не совпадают."}) + candidate = CustomUser( + first_name=attrs["first_name"], + last_name=attrs["last_name"], + email=self.context["email"], + ) + try: + validate_password(attrs["password"], user=candidate) + except DjangoValidationError as error: + raise ValidationError({"password": error.messages}) + return attrs + + +class PublicInvitationView(PrivateView): + permission_classes = [AllowAny] + throttle_classes = [InvitationThrottle] + + def get(self, request, token): + invitation = service.token_invitation(token, allow_accepted=True) + login_email = ( + CustomUser.objects.filter(email__iexact=invitation.email) + .values_list("email", flat=True) + .first() + ) + return Response( + { + **service.invitation_data(invitation), + "program": service.program_card(invitation.partner_program), + "account_exists": login_email is not None, + "login_email": login_email or invitation.email, + "matches_account": bool( + request.user.is_authenticated + and request.user.email.lower() == invitation.email + ), + } + ) + + +class PublicInvitationActionView(PublicInvitationView): + 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" + or invitation.expires_at <= timezone.now() + ): + 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 = InvitedRegistration( + 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=None, + user_type=CustomUser.EXPERT, + 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/templates/email/expert_invitation.html b/templates/email/expert_invitation.html new file mode 100644 index 00000000..59138546 --- /dev/null +++ b/templates/email/expert_invitation.html @@ -0,0 +1,13 @@ + + + +
+
+PROCOLLAB +

Вас пригласили стать экспертом

+

Здравствуйте!

Программа «{{ program_name }}» ждёт вас в качестве эксперта.

+

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

+

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

+

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

+

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

+
diff --git a/templates/email/expert_invitation.txt b/templates/email/expert_invitation.txt new file mode 100644 index 00000000..6cedc868 --- /dev/null +++ b/templates/email/expert_invitation.txt @@ -0,0 +1,10 @@ +Здравствуйте! + +Вас пригласили стать экспертом программы «{{ program_name }}» на PROCOLLAB. +Приглашение для {{ email }}. После принятия организатор сможет назначить вам проекты на оценку. + +Принять приглашение: {{ invitation_url }} + +Если аккаунта ещё нет, вы сможете создать его по этой ссылке. +Ссылка действует до {{ expires_at|date:"d.m.Y H:i" }}. +Если вы не ожидали приглашение, просто проигнорируйте письмо. diff --git a/users/migrations/0065_alter_customuser_birthday.py b/users/migrations/0065_alter_customuser_birthday.py new file mode 100644 index 00000000..0e764138 --- /dev/null +++ b/users/migrations/0065_alter_customuser_birthday.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.11 on 2026-10-18 00:28 + +from django.db import migrations, models +import users.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0064_expand_university_directory'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='birthday', + field=models.DateField(null=True, validators=[users.validators.user_birthday_validator]), + ), + ] diff --git a/users/models.py b/users/models.py index fb4a1a2a..c4035bb5 100644 --- a/users/models.py +++ b/users/models.py @@ -110,6 +110,7 @@ class CustomUser(AbstractUser): ) birthday = models.DateField( validators=[user_birthday_validator], + null=True, ) about_me = models.TextField(null=True, blank=True) status = models.CharField(max_length=255, null=True, blank=True) diff --git a/users/permissions.py b/users/permissions.py index ad6c9023..85c062fe 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -26,8 +26,6 @@ def has_permission(self, request, view): raise PermissionDenied("Authentication credentials were not provided.") program_id = view.kwargs.get("program_id") - if not user.user_type == 3: - raise PermissionDenied("User is not an expert") if not Expert.objects.filter(programs__id=program_id, user=user).exists(): raise PermissionDenied("You don't have permission to rate this program") return True @@ -42,7 +40,7 @@ def has_permission(self, request, view): user = request.user if not getattr(user, "is_authenticated", False): raise PermissionDenied("Authentication credentials were not provided.") - if getattr(user, "user_type", None) != 3: + if not Expert.objects.filter(user=user).exists(): raise PermissionDenied("User is not an expert") return True diff --git a/users/serializers.py b/users/serializers.py index d94347ba..21ae4ab7 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -467,6 +467,39 @@ def get_year(self, program: PartnerProgram) -> int | None: class UserDetailSerializer( serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin ): + birthday = serializers.DateField( + required=True, + allow_null=True, + validators=CustomUser._meta.get_field("birthday").validators, + ) + + def validate_birthday(self, value): + from project_rates.models import ExpertInvitation + + omitted_on_invitation = ( + self.instance is not None + and self.instance.birthday is None + and ExpertInvitation.objects.filter(accepted_by=self.instance).exists() + ) + if value is None and not omitted_on_invitation: + raise ValidationError("Дата рождения обязательна.") + return value + + has_evaluation_access = serializers.SerializerMethodField() + + def get_has_evaluation_access(self, user): + from project_rates.models import ExpertInvitation + + request_user = getattr(self.context.get("request"), "user", None) + if not request_user or request_user.pk != user.pk or not user.is_active: + return False + return ( + Expert.objects.filter(user=user, programs__isnull=False).exists() + or ExpertInvitation.objects.filter( + email__iexact=user.email, status="pending", expires_at__gt=timezone.now() + ).exists() + ) + verification_notice_acknowledged_at = serializers.DateTimeField(read_only=True) profile_fill_prompt_acknowledged_at = serializers.DateTimeField(read_only=True) member = MemberSerializer(required=False) @@ -551,6 +584,7 @@ class Meta: "verification_date", "verification_notice_acknowledged_at", "profile_fill_prompt_acknowledged_at", + "has_evaluation_access", "onboarding_stage", "projects", "programs", @@ -796,6 +830,12 @@ class Meta: class UserListSerializer( serializers.ModelSerializer[CustomUser], SkillsWriteSerializerMixin ): + # Nullable storage supports invited experts only; public signup still requires DOB. + birthday = serializers.DateField( + required=True, + allow_null=False, + validators=CustomUser._meta.get_field("birthday").validators, + ) email = serializers.EmailField( validators=[ UniqueValidator( diff --git a/users/signals.py b/users/signals.py index 73b42547..2b1c0a97 100644 --- a/users/signals.py +++ b/users/signals.py @@ -32,7 +32,9 @@ def update_dataset_migration_applied(sender, instance, **kwargs): """Update the `dataset_migration_applied` attribute based on the presence of `v2_speciality` and `skills`.""" def update_migration(): - dataset_migration_applied = bool(instance.v2_speciality and instance.skills.exists()) + dataset_migration_applied = bool( + instance.v2_speciality and instance.skills.exists() + ) if instance.dataset_migration_applied != dataset_migration_applied: CustomUser.objects.filter(pk=instance.pk).update( dataset_migration_applied=dataset_migration_applied @@ -47,6 +49,31 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, reset_password_url = ( f"https://app.procollab.ru/auth/reset_password/?token={reset_password_token.key}" ) + # Invitation-specific return context; never accept an arbitrary redirect origin. + invitation_token = getattr(getattr(instance, "request", None), "data", {}).get( + "expert_invitation_token" + ) + if invitation_token: + from project_rates.workspace import token_invitation + from rest_framework.exceptions import APIException + from django.http import Http404 + from urllib.parse import urlencode + + try: + invitation = token_invitation(invitation_token, allow_accepted=True) + except (APIException, Http404): + invitation = None + if invitation and invitation.email == reset_password_token.user.email.lower(): + reset_password_url = ( + settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + "/expert-password-reset/?" + + urlencode( + { + "token": reset_password_token.key, + "expert_invitation": invitation_token, + } + ) + ) context = { "user": reset_password_token.user, "email": reset_password_token.user.email,