diff --git a/docs/modules/program-role-widget.md b/docs/modules/program-role-widget.md new file mode 100644 index 00000000..f761d88e --- /dev/null +++ b/docs/modules/program-role-widget.md @@ -0,0 +1,96 @@ +# Read-only виджет программы для Angular + +DEV-база backend: `ed5244bd4a098bd0f1cee0f5e380dd67bdd61a96`. +Парный Angular основан на `99c8813a66f89560a946eab7d1de73ab2925d0f5`. +Ветка: `feature/dev-program-role-analytics-widget`; итоговые SHA и ссылки указаны в Draft PR. + +## Контракт + +`GET /programs/{id}/analytics-widget/`, обязательная аутентификация. +Параметры `role`, `user_id` и `project_id` не используются для авторизации или выбора проекта. +GET не изменяет бизнес-данные. Ответ содержит только ветвь разрешённой роли: + +```json +{ + "program_id": 12, + "role": "participant", + "is_competitive": true, + "participant": { + "participant_project": { "id": 5, "name": "StudyFlow", "program_link_id": 34 }, + "case_provided": true, + "case_name": "Цифровой сервис", + "stage": "review", + "submission_open": true + } +} +``` + +Пример иллюстрирует форму, значения не являются данными DEV. Остальные ветви: + +- `organizer`: `participants`, `projects`, `submitted_solutions`, `participants_without_project`. +- `expert`: `mode` (`distributed` / `open`), `assigned`, `remaining`, `evaluation_ends` (ISO datetime либо null). +- `participant_project` и `case_name` могут быть null. `stage`: `none`, `not_submitted`, `submitted`, `review`, `evaluated`, `not_applicable`. +- Неприменимые счётчики — null: open assigned/remaining; неконкурсные submitted_solutions и персональные distributed-счётчики. Реальный срок оценивания передаётся также для open. + +401 — нет входа; 403 — нет роли в этой программе; 404 — программа отсутствует; 409 — противоречивые связи команды, не выбор первого проекта. Angular не отображает raw response body и не подменяет ошибку нулями. + +## Источники и переиспользование + +| Показатель | Источник и правило | Права / изменение | +| --- | --- | --- | +| Роль | `program.is_manager(user)` → `program.experts` → `PartnerProgramUserProfile` | Только текущая программа; global staff/user_type, наличие назначений и клиентские claims не дают роль | +| Проект команды | PartnerProgramProject текущей программы; Project.leader либо Exists Collaborator | Новый локальный selector; максимум две строки для обнаружения несоответствия правилу одной команды. Один проект без выбора | +| Кейс | `get_program_case_field`: точный name="case"; PartnerProgramFieldValue текущей связи | Нет fallback по label, первому option, глобальному Project или другой программе | +| Участники и без проектов | `_get_participant_metrics` основной аналитики | Distinct user; без проекта = нет лидерства и Collaborator на проекте программы; черновик учитывается | +| Проекты и решения | `_get_solution_metrics` основной аналитики | Единица учёта — PartnerProgramProject; отправленные — submitted-связи, не пользователи | +| Этап проекта | `_solution_rows` основной аналитики | Неподанный → not_submitted; submitted без начала → submitted; назначение/начатая оценка → review; общая итоговая классификация → evaluated | +| Остаток эксперта | `annotated_assignment_queryset`, фильтр текущих program/expert, SQL aggregate | Незавершённые назначения, включая not_ready, а не число незаполненных критериев | +| Срок | `PartnerProgram.datetime_evaluation_ends` | Не срок подачи или окончания программы | + +Distributed completion остаётся общим: submitted-связь, ненулевой набор критериев и заполнение всех критериев назначения; для проекта должны завершиться все назначения. Записанная допустимая оценка 0 считается заполненной. Неотправленный проект не завершён даже при существующих оценках. + +В open сохранено существующее правило основной аналитики: ProjectScore по критерию программы даёт проекту evaluated. Это не новая формула виджета. Назначения и персональный остаток в open не выдумываются; frontend сохраняет переход в оценивание и однократно показывает реальный срок. + +Дедлайн не участвует в определении оценённости. Баллы, личности экспертов и закрытые результаты участнику не сериализуются. Неконкурсная программа не получает искусственную обязательную сдачу. + +## Изоляция контрактов + +Используются только Angular-модели Project, Collaborator, PartnerProgramProject, PartnerProgramUserProfile, Expert, ProjectExpertAssignment, Criteria, ProjectScore и существующие поля программы. + +`current_application` остаётся legacy-контрактом лидера с прежней семантикой. Для члена команды null в этом поле не влияет на новый `participant_project`. Просмотр виджета не даёт права редактировать поля или отправлять проект вместо лидера. + +React-модели Application, Team, Submission, SubmissionExpertAssignment, Evaluation, их serializers/services/endpoints не используются и не изменяются. Менеджерские API не открываются другим ролям. + +В существующий program detail добавлен только контекстный boolean `is_user_expert`; он не раскрывает description/links незарегистрированному эксперту. Правила регистрации, выбора/сохранения case, сдачи, назначений, завершения и публикации сохранены. Нет миграций, зависимостей или изменений CI/Docker/workflows. + +Сервис использует общие SQL-агрегации. Не сериализует большую аналитику и не загружает проекты/регистрации в браузер. Тест проверяет постоянное число SQL-запросов при росте числа проектов и отсутствие INSERT/UPDATE/DELETE. + +## Проверки 14.09.2026 + +Python 3.11, существующее Poetry-окружение. Локальные SQLite и PostgreSQL 18; production/DEV базы не использовались. + +```sh +DEBUG=True python manage.py test partner_programs.tests.test_role_widget_api partner_programs.tests.test_program_detail partner_programs.tests.test_program_link_fields_api partner_programs.tests.test_manager_analytics_api partner_programs.tests.test_assignment_analytics_api project_rates.tests --noinput +``` + +Targeted: 120 тестов PASS. Новые 15 тестов проверяют роли и приоритет, эксперта без регистрации/назначений, чужую программу, manager-only API, лидера/члена команды, несколько программ с разными case/status, неизменность current_application и прав сдачи, integrity 409, точное поле case, distributed/open, 0/partial/full/not_ready, черновики и командную метрику, изоляцию назначений, реальный дедлайн, bounded SQL/read-only. + +Уникальность регистраций уже обеспечена схемой: тест проверяет отклонение дубликата и единственный учёт пользователя. Ограничения БД ради теста не менялись; общая аналитика сохраняет Count(distinct user). + +| Команда | Фактический результат | +| --- | --- | +| `DEBUG=True python manage.py test --noinput` (SQLite) | 849 тестов, OK (skipped=4), 570,439 с. После тестов exit 1: WinError 32 при удалении test_db.sqlite3 | +| `python manage.py test --noinput` с временным settings на основе `procollab.settings_ci` (PostgreSQL) | 849 тестов, 1 failure: `feed.tests.test_feed_api.FeedAPITests.test_feed_returns_project_news_as_news_content`, ожидалось news, получено project. Teardown также встретил 21 незакрытое соединение; после завершения процесса временная БД удалена | +| Тот же feed-тест отдельно на исходном `ed5244bd...`, PostgreSQL | PASS. Причина сбоя полного прогона не установлена; полный suite не объявляется успешным, feed-код не менялся | +| `python -m flake8` всех tracked Python и отдельно новых файлов | PASS | +| `python -m black --check` новых файлов и urls | PASS | +| `DEBUG=True python manage.py check` | 0 issues | +| `DEBUG=True python manage.py makemigrations --check --dry-run` | No changes detected | +| `python -m mypy partner_programs/services/role_analytics.py partner_programs/serializers/role_analytics.py partner_programs/widget_views.py` | Не выполнен typecheck: существующий mypy.ini объединяет две строки plugins в один import (`mypy_django_plugin.main\nmypy_drf_plugin.main`), exit 2. Конфигурация не менялась | +| `git diff --check` | PASS | + +Скриншоты трёх ролей/нулевых состояний и сравнение исходной геометрии находятся в парном Angular PR, `docs/program-role-widget/README.md`. Браузерный smoke использует реальные Angular-компоненты и локальные fixtures; живой DEV вход/сдача/оценивание в браузере не проверялись. + +## Зависимость будущего DEV-развёртывания + +Сначала backend-контракт и detail-флаг, затем Angular-виджет. Изменение read-only, миграций нет. Merge, deploy и PROD-операции не выполнены. diff --git a/docs/readme.md b/docs/readme.md index 33f6225b..acb286f7 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -7,6 +7,7 @@ ## API - [Описание API](api.md) +- [Ролевой виджет программы для Angular](modules/program-role-widget.md) - [Swagger](https://api.procollab.ru/swagger) - [Redoc](https://api.procollab.ru/redoc) diff --git a/partner_programs/serializers/role_analytics.py b/partner_programs/serializers/role_analytics.py new file mode 100644 index 00000000..c8497453 --- /dev/null +++ b/partner_programs/serializers/role_analytics.py @@ -0,0 +1,49 @@ +"""Минимальный контракт виджета; отсутствующие метрики не превращаются в нули.""" + +from rest_framework import serializers + + +class WidgetProjectSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_blank=True) + program_link_id = serializers.IntegerField() + + +class ParticipantWidgetSerializer(serializers.Serializer): + participant_project = WidgetProjectSerializer(allow_null=True) + case_provided = serializers.BooleanField() + case_name = serializers.CharField(allow_null=True, allow_blank=True) + stage = serializers.ChoiceField( + choices=( + "none", + "not_submitted", + "submitted", + "review", + "evaluated", + "not_applicable", + ) + ) + submission_open = serializers.BooleanField() + + +class OrganizerWidgetSerializer(serializers.Serializer): + participants = serializers.IntegerField(min_value=0) + projects = serializers.IntegerField(min_value=0) + submitted_solutions = serializers.IntegerField(min_value=0, allow_null=True) + participants_without_project = serializers.IntegerField(min_value=0) + + +class ExpertWidgetSerializer(serializers.Serializer): + mode = serializers.ChoiceField(choices=("open", "distributed")) + assigned = serializers.IntegerField(min_value=0, allow_null=True) + remaining = serializers.IntegerField(min_value=0, allow_null=True) + evaluation_ends = serializers.DateTimeField(allow_null=True) + + +class ProgramRoleWidgetSerializer(serializers.Serializer): + program_id = serializers.IntegerField() + role = serializers.ChoiceField(choices=("organizer", "expert", "participant")) + is_competitive = serializers.BooleanField() + participant = ParticipantWidgetSerializer(required=False) + organizer = OrganizerWidgetSerializer(required=False) + expert = ExpertWidgetSerializer(required=False) diff --git a/partner_programs/services/role_analytics.py b/partner_programs/services/role_analytics.py new file mode 100644 index 00000000..f5f3ccdf --- /dev/null +++ b/partner_programs/services/role_analytics.py @@ -0,0 +1,149 @@ +"""Компактная аналитика текущего пользователя без публикации чужих результатов.""" + +from django.db.models import Count, Exists, OuterRef, Q +from rest_framework.exceptions import APIException, PermissionDenied + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.analytics import _get_participant_metrics +from partner_programs.services.analytics import _get_solution_metrics +from partner_programs.services.analytics import _solution_rows +from partner_programs.services.assignment_analytics import annotated_assignment_queryset +from partner_programs.services.case_fields import get_program_case_field +from project_rates.models import ProjectScore +from projects.models import Collaborator + + +def program_widget_role(program, user): + """Роль только в этой программе; назначения и глобальный user_type не участвуют.""" + if not user or not user.is_authenticated: + return None + if program.is_manager(user): + return "organizer" + if program.experts.filter(user_id=user.pk).exists(): + return "expert" + if PartnerProgramUserProfile.objects.filter( + partner_program_id=program.pk, user_id=user.pk + ).exists(): + return "participant" + return None + + +def organizer_widget_metrics(program): + """Та же единица учёта и предикаты, что у manager-overview, без его детализаций.""" + participants = _get_participant_metrics(program.pk) + solutions = _get_solution_metrics(program) + return { + "participants": participants["unique_participants"], + "projects": solutions["created"], + "submitted_solutions": solutions["submitted"] if program.is_competitive else None, + "participants_without_project": participants["without_team"], + } + + +def expert_widget_metrics(program, user): + """Остаток по назначениям, включая not_ready; нулевая оценка уже учтена общим SQL.""" + mode = "distributed" if program.is_distributed_evaluation else "open" + if mode == "open" or not program.is_competitive: + return {"mode": mode, "assigned": None, "remaining": None} + counts = ( + annotated_assignment_queryset(program.pk) + .filter(expert__user_id=user.pk) + .aggregate( + assigned=Count("pk"), + remaining=Count("pk", filter=Q(is_completed=False)), + ) + ) + return {"mode": mode, **counts} + + +def require_widget_role(program, user): + """Проверка GET-контракта не меняет регистрации, назначения или бизнес-данные.""" + role = program_widget_role(program, user) + if role is None: + raise PermissionDenied("Аналитика доступна только участникам этой программы.") + return role + + +def participant_project_links(program, user): + """Только лидер и действующий Collaborator; чужие программы и приглашения исключены.""" + team = Collaborator.objects.filter(project_id=OuterRef("project_id"), user_id=user.pk) + return ( + PartnerProgramProject.objects.filter(partner_program_id=program.pk) + .annotate(is_team_member=Exists(team)) + .filter(Q(project__leader_id=user.pk) | Q(is_team_member=True)) + ) + + +class ParticipantProjectIntegrityError(APIException): + status_code = 409 + default_detail = "Связи проекта команды требуют проверки организатором." + default_code = "participant_project_integrity_error" + + +def participant_widget_metrics(program, user): + """Единственный проект команды в программе, независимо от legacy current_application. + + Читаем максимум две связи для обнаружения нарушения правила одной команды. + Не выбираем первую запись и не меняем данные. Возвращаем только стадию + общей аналитики, без оценок, экспертов или неопубликованных результатов. + """ + links = participant_project_links(program, user) + link_ids = list(links.order_by("pk").values_list("pk", flat=True)[:2]) + if len(link_ids) > 1: + raise ParticipantProjectIntegrityError() + link_id = link_ids[0] if link_ids else None + + case_field = get_program_case_field(program) + result = { + "participant_project": None, + "case_provided": case_field is not None, + "case_name": None, + "stage": "not_applicable" if not program.is_competitive else "none", + "submission_open": program.is_project_submission_open(), + } + if link_id is None: + return result + link = _solution_rows(program).select_related("project").get(pk=link_id) + result["participant_project"] = { + "id": link.project_id, + "name": link.project.name, + "program_link_id": link.pk, + } + if case_field: + result["case_name"] = ( + link.field_values.filter(field=case_field) + .values_list("value_text", flat=True) + .first() + or None + ) + if program.is_competitive: + if not link.submitted: + result["stage"] = "not_submitted" + elif link.status == "evaluated": + result["stage"] = "evaluated" + elif (link.assignments_total or 0) > 0 or ProjectScore.objects.filter( + project_id=link.project_id, criteria__partner_program_id=program.pk + ).exists(): + result["stage"] = "review" + else: + result["stage"] = "submitted" + return result + + +def build_program_role_widget(program, user): + role = require_widget_role(program, user) + result = { + "program_id": program.pk, + "role": role, + "is_competitive": program.is_competitive, + } + if role == "organizer": + result["organizer"] = organizer_widget_metrics(program) + elif role == "expert": + result["expert"] = { + **expert_widget_metrics(program, user), + "evaluation_ends": program.datetime_evaluation_ends, + } + else: + result["participant"] = participant_widget_metrics(program, user) + return result diff --git a/partner_programs/tests/test_role_widget_api.py b/partner_programs/tests/test_role_widget_api.py new file mode 100644 index 00000000..c9f6384b --- /dev/null +++ b/partner_programs/tests/test_role_widget_api.py @@ -0,0 +1,302 @@ +"""Контракт Angular-виджета: права, общие расчёты и отсутствие побочных записей.""" + +from django.db import connection, IntegrityError, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ProgramRoleWidgetTests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_partner_program( + is_competitive=True, is_distributed_evaluation=True, max_project_rates=3 + ) + self.member = create_user() + create_program_member(self.program, user=self.member) + self.url = reverse( + "partner_programs:analytics-widget", kwargs={"pk": self.program.pk} + ) + self.client.force_authenticate(self.member) + + def widget(self, user=None): + if user is not None: + self.client.force_authenticate(user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_program_roles_and_priority_without_assignments(self): + expert = create_rate_expert(program=self.program) + self.assertEqual(self.widget(expert)["role"], "expert") + self.assertEqual(self.widget()["expert"]["assigned"], 0) + create_program_member(self.program, user=expert) + self.assertEqual(self.widget()["role"], "expert") + self.program.managers.add(expert) + data = self.widget() + self.assertEqual(data["role"], "organizer") + self.assertNotIn("expert", data) + self.assertNotIn("participant", data) + + def test_outsider_other_program_staff_and_client_claims_do_not_grant_role(self): + other = create_partner_program() + for user in ( + create_user(), + create_rate_expert(program=other), + create_user(is_staff=True), + ): + self.client.force_authenticate(user) + response = self.client.get( + self.url, {"role": "organizer", "user_id": self.member.pk} + ) + self.assertEqual(response.status_code, 403) + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.url).status_code, 401) + + def test_member_and_expert_cannot_access_manager_api(self): + url = reverse("partner_programs:manager-overview", kwargs={"pk": self.program.pk}) + for user in (self.member, create_rate_expert(program=self.program)): + self.client.force_authenticate(user) + self.assertEqual(self.client.get(url).status_code, 403) + + def test_no_project_and_case_not_configured(self): + data = self.widget()["participant"] + self.assertIsNone(data["participant_project"]) + self.assertEqual(data["stage"], "none") + self.assertFalse(data["case_provided"]) + + def test_leader_and_collaborator_see_identical_program_scoped_project(self): + teammate = create_user() + create_program_member(self.program, user=teammate) + project = create_project(leader=self.member) + Collaborator.objects.create(project=project, user=teammate, role="Participant") + link = create_program_project(self.program, project=project) + other = create_partner_program() + other_link = create_program_project(other, project=project, submitted=True) + for program, program_link, value in ( + (self.program, link, "Alpha"), + (other, other_link, "Beta"), + ): + field = create_program_field( + program, + name="case", + field_type="select", + is_required=True, + show_filter=True, + options=[value], + ) + PartnerProgramFieldValue.objects.create( + program_project=program_link, field=field, value_text=value + ) + leader_data = self.widget()["participant"] + team_data = self.widget(teammate)["participant"] + self.assertEqual(leader_data, team_data) + self.assertEqual(team_data["participant_project"]["program_link_id"], link.pk) + self.assertEqual(team_data["case_name"], "Alpha") + self.assertEqual(team_data["stage"], "not_submitted") + fields_url = f"/programs/partner-program-projects/{link.pk}/fields/" + self.assertEqual(self.client.get(fields_url).status_code, 200) + self.assertEqual(self.client.put(fields_url, [], format="json").status_code, 403) + submit_url = f"/programs/partner-program-projects/{link.pk}/submit/" + self.assertEqual(self.client.post(submit_url).status_code, 403) + self.assertEqual(self.client.get(f"/projects/{project.pk}/").status_code, 200) + # Existing public legacy contract stays leader-only. + detail = self.client.get(f"/programs/{self.program.pk}/") + self.assertEqual(detail.status_code, 200) + self.assertIsNone(detail.data["current_application"]) + self.client.force_authenticate(self.member) + detail = self.client.get(f"/programs/{self.program.pk}/") + self.assertEqual(detail.data["current_application"]["program_link_id"], link.pk) + + def test_integrity_conflict_does_not_choose_first_project(self): + for _ in range(2): + create_program_project( + self.program, project=create_project(leader=self.member) + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 409) + self.assertNotIn("participant", response.data) + + def test_case_uses_exact_name_and_never_defaults_to_first_option(self): + create_program_project(self.program, project=create_project(leader=self.member)) + create_program_field(self.program, name="topic", label="Кейс") + self.assertFalse(self.widget()["participant"]["case_provided"]) + create_program_field( + self.program, + name="case", + label="Challenge", + field_type="select", + is_required=True, + show_filter=True, + options=["Alpha"], + ) + data = self.widget()["participant"] + self.assertTrue(data["case_provided"]) + self.assertIsNone(data["case_name"]) + + def test_distributed_stages_zero_partial_complete_and_not_ready(self): + project = create_project(leader=self.member) + link = create_program_project(self.program, project=project, submitted=True) + self.assertEqual(self.widget()["participant"]["stage"], "submitted") + # Program creation adds a Comment criterion; isolate two explicit criteria here. + self.program.criterias.all().delete() + criteria = [ + Criteria.objects.create( + name=f"C{i}", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + for i in range(2) + ] + experts = [create_rate_expert(program=self.program) for _ in range(2)] + for expert in experts: + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=project, expert=expert.expert + ) + self.assertEqual(self.widget()["participant"]["stage"], "review") + for criterion in criteria: + ProjectScore.objects.create( + project=project, user=experts[0], criteria=criterion, value="0" + ) + self.assertEqual(self.widget()["participant"]["stage"], "review") + self.assertEqual(self.widget(experts[0])["expert"]["remaining"], 0) + ProjectScore.objects.create( + project=project, user=experts[1], criteria=criteria[0], value="0" + ) + self.assertEqual(self.widget(experts[1])["expert"]["remaining"], 1) + ProjectScore.objects.create( + project=project, user=experts[1], criteria=criteria[1], value="0" + ) + self.assertEqual(self.widget(self.member)["participant"]["stage"], "evaluated") + link.submitted = False + link.save(update_fields=["submitted"]) + self.assertEqual(self.widget(experts[0])["expert"]["remaining"], 1) + self.assertEqual( + self.widget(self.member)["participant"]["stage"], "not_submitted" + ) + + def test_open_mode_uses_existing_project_completion_without_invented_assignments( + self, + ): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + expert = create_rate_expert(program=self.program) + project = create_project(leader=self.member) + create_program_project(self.program, project=project, submitted=True) + criterion = Criteria.objects.create( + name="C", type="int", partner_program=self.program + ) + ProjectScore.objects.create( + project=project, criteria=criterion, user=expert, value="0" + ) + self.assertEqual(self.widget()["participant"]["stage"], "evaluated") + data = self.widget(expert)["expert"] + self.assertEqual(data["mode"], "open") + self.assertIsNone(data["assigned"]) + self.assertIsNone(data["remaining"]) + + def test_manager_metrics_match_overview_including_draft_team_and_other_program(self): + manager = create_user() + self.program.managers.add(manager) + teammate = create_user() + create_program_member(self.program, user=teammate) + project = create_project(leader=self.member, draft=True) + create_program_project(self.program, project=project) + Collaborator.objects.create(project=project, user=teammate, role="Participant") + without_project = create_user() + create_program_member(self.program, user=without_project) + create_program_project( + create_partner_program(), project=create_project(leader=without_project) + ) + data = self.widget(manager)["organizer"] + overview = self.client.get( + reverse("partner_programs:manager-overview", kwargs={"pk": self.program.pk}) + ).data + self.assertEqual(data["participants"], 3) + self.assertEqual(data["projects"], 1) + self.assertEqual(data["participants_without_project"], 1) + self.assertEqual( + data["participants"], overview["summary"]["participants"]["total"] + ) + self.assertEqual(data["projects"], overview["summary"]["projects"]["total"]) + self.assertEqual( + data["submitted_solutions"], overview["solution_funnel"]["submitted"] + ) + self.assertEqual( + data["participants_without_project"], + overview["attention"]["participants_without_team"], + ) + + def test_noncompetitive_metrics_are_inapplicable(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.assertEqual(self.widget()["participant"]["stage"], "not_applicable") + self.program.managers.add(self.member) + self.assertIsNone(self.widget()["organizer"]["submitted_solutions"]) + + def test_get_has_bounded_queries_and_no_business_writes(self): + self.program.managers.add(self.member) + with CaptureQueriesContext(connection) as queries: + self.widget() + self.assertLessEqual(len(queries), 5) + self.assertFalse( + any( + q["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")) + for q in queries + ) + ) + for _ in range(12): + create_program_project(self.program) + with CaptureQueriesContext(connection) as larger_queries: + self.widget() + self.assertEqual(len(queries), len(larger_queries)) + + def test_duplicate_registration_is_rejected_and_users_are_counted_once(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + create_program_member(self.program, user=self.member) + self.program.managers.add(self.member) + self.assertEqual(self.widget()["organizer"]["participants"], 1) + + def test_expert_flag_does_not_expose_member_only_detail(self): + expert = create_rate_expert(program=self.program) + self.client.force_authenticate(expert) + response = self.client.get(f"/programs/{self.program.pk}/") + self.assertTrue(response.data["is_user_expert"]) + self.assertFalse(response.data["is_user_member"]) + self.assertNotIn("description", response.data) + self.assertNotIn("links", response.data) + + def test_other_program_assignment_and_submission_deadline_are_not_used(self): + from django.utils import timezone + + self.program.datetime_evaluation_ends = timezone.now() - timezone.timedelta( + hours=1 + ) + self.program.save(update_fields=["datetime_evaluation_ends"]) + other = create_partner_program() + expert = create_rate_expert(program=self.program) + expert.expert.programs.add(other) + link = create_program_project(other, submitted=True) + ProjectExpertAssignment.objects.create( + partner_program=other, project=link.project, expert=expert.expert + ) + data = self.widget(expert)["expert"] + self.assertEqual(data["assigned"], 0) + self.assertEqual(data["remaining"], 0) + self.assertIsNotNone(data["evaluation_ends"]) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 5bb365de..0b536c8e 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -26,9 +26,16 @@ ProgramProjectFilterAPIView, ) +from partner_programs.widget_views import ProgramRoleWidgetAPIView + app_name = "partner_programs" urlpatterns = [ + path( + "/analytics-widget/", + ProgramRoleWidgetAPIView.as_view(), + name="analytics-widget", + ), path("", PartnerProgramList.as_view()), path("/", PartnerProgramDetail.as_view()), path( diff --git a/partner_programs/views.py b/partner_programs/views.py index f92b6159..3ce43519 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -167,6 +167,10 @@ def get(self, request, *args, **kwargs): data = serializer.data data["is_user_member"] = is_user_member data["current_application"] = current_application + data["is_user_expert"] = bool( + request.user.is_authenticated + and program.experts.filter(user_id=request.user.pk).exists() + ) if request.user.is_authenticated: add_view(program, request.user) return Response(data, status=status.HTTP_200_OK) diff --git a/partner_programs/widget_views.py b/partner_programs/widget_views.py new file mode 100644 index 00000000..acb161c3 --- /dev/null +++ b/partner_programs/widget_views.py @@ -0,0 +1,20 @@ +"""Read-only API Angular-виджета, отдельно от manager-only и React endpoints.""" + +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import PartnerProgram +from partner_programs.serializers.role_analytics import ProgramRoleWidgetSerializer +from partner_programs.services.role_analytics import build_program_role_widget + + +class ProgramRoleWidgetAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + program = get_object_or_404(PartnerProgram, pk=pk) + # role/user_id/project_id клиента не используются для доступа или выбора. + data = build_program_role_widget(program, request.user) + return Response(ProgramRoleWidgetSerializer(data).data)