From 4e92eb5ce9edb89bf475ce1ab8321691f44c029f Mon Sep 17 00:00:00 2001 From: Fedor Date: Tue, 28 Jul 2026 00:04:45 +0300 Subject: [PATCH] Add React dev demo data command --- .env.example | 2 + docs/react-dev-demo-data.md | 98 +++ partner_programs/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/seed_react_dev_demo.py | 87 +++ partner_programs/services/react_dev_demo.py | 591 ++++++++++++++++++ .../tests/test_react_dev_demo_seed.py | 410 ++++++++++++ procollab/settings.py | 6 + 8 files changed, 1194 insertions(+) create mode 100644 docs/react-dev-demo-data.md create mode 100644 partner_programs/management/__init__.py create mode 100644 partner_programs/management/commands/__init__.py create mode 100644 partner_programs/management/commands/seed_react_dev_demo.py create mode 100644 partner_programs/services/react_dev_demo.py create mode 100644 partner_programs/tests/test_react_dev_demo_seed.py diff --git a/.env.example b/.env.example index 800cd674..05f3652b 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,5 @@ TELEGRAM_CHANNEL= CLICKUP_API_TOKEN= CLICKUP_SPACE_ID= +ALLOW_REACT_DEV_DEMO_SEED=False +REACT_DEV_DEMO_PASSWORD= diff --git a/docs/react-dev-demo-data.md b/docs/react-dev-demo-data.md new file mode 100644 index 00000000..cf571c66 --- /dev/null +++ b/docs/react-dev-demo-data.md @@ -0,0 +1,98 @@ + + +# Тестовые данные React-dev + +## Назначение + +Команда `seed_react_dev_demo` создаёт связанный и повторяемый набор данных для +ручного smoke-тестирования кабинета эксперта DEV-053 на изолированном +React-dev backend. + +## Состав данных + +- программа `[DEMO] Экспертная оценка React-dev`; +- менеджер, эксперт и два участника; +- активный Expert-профиль и членство эксперта в программе; +- командная Application, Team и два принятых TeamMember; +- три числовых Criteria: два `int` и один `float`; +- три финальных Submission с описаниями и `links`; +- три назначения одному эксперту; +- решение без Evaluation; +- решение с частично заполненным draft; +- решение с submitted Evaluation и completed assignment. + +## Меры безопасности + +Команда завершается до любой записи, если не выполнено хотя бы одно условие: + +- setting `ALLOW_REACT_DEV_DEMO_SEED=True`; +- передан флаг `--confirm-react-dev`; +- в окружении задана переменная `REACT_DEV_DEMO_PASSWORD`. + +Setting по умолчанию выключен. Значение пароля не хранится в репозитории и не +выводится командой. Все изменения выполняются в одной транзакции. + +Reset находит программу только по одновременному точному совпадению имени и +служебного tag. Аккаунты с фиксированными demo-email сохраняются и обновляются: +это не позволяет каскадному удалению затронуть их возможные внешние связи. +Обычные пользователи и программы с похожими названиями не удаляются. + +## Обычный запуск + +В окружении изолированного React-dev backend включить setting, задать пароль и +выполнить: + +```console +python manage.py seed_react_dev_demo --confirm-react-dev +``` + +Повторный запуск обновляет управляемые записи и не создаёт дубликаты. + +## Dry-run + +```console +python manage.py seed_react_dev_demo --confirm-react-dev --dry-run +``` + +Команда показывает план и итоговые количества, затем откатывает транзакцию. + +## Reset + +```console +python manage.py seed_react_dev_demo --confirm-react-dev --reset +``` + +Удаляется только программа с точными идентификаторами DEV-072 и все связанные +с ней Application, Team, Criteria, Submission, Assignment и Evaluation. +Фиксированные demo-аккаунты переиспользуются, после чего контур создаётся снова. + +## Тестовые email + +- `demo.manager@procollab.test` +- `demo.expert@procollab.test` +- `demo.participant1@procollab.test` +- `demo.participant2@procollab.test` + +## Проверка кабинета эксперта + +1. Войти на React-dev под `demo.expert@procollab.test`. +2. Открыть `/office/expert/submissions`. +3. Проверить три карточки: «Не начато», «Черновик» и «Отправлено». +4. Открыть решение без оценки и создать draft вручную. +5. Открыть черновик, изменить баллы и проверить autosave. +6. Открыть отправленную оценку и проверить полностью read-only состояние. +7. Убедиться, что detail показывает title, description и links, но не данные + Application, Team и участников. + +## Удаление DEMO-данных + +Предпочтительный способ очистки и повторного создания — запуск с `--reset`. +Ручное удаление по одному префиксу `[DEMO]` запрещено: похожие названия не +являются признаком владения набором. + +## Запрет production + +На production setting `ALLOW_REACT_DEV_DEMO_SEED` должен оставаться `False`. +Команду нельзя запускать на production или legacy dev базе. Флаг +`--confirm-react-dev` означает явное подтверждение, что подключена отдельная +React-dev база. diff --git a/partner_programs/management/__init__.py b/partner_programs/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/partner_programs/management/commands/__init__.py b/partner_programs/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/partner_programs/management/commands/seed_react_dev_demo.py b/partner_programs/management/commands/seed_react_dev_demo.py new file mode 100644 index 00000000..f9a19493 --- /dev/null +++ b/partner_programs/management/commands/seed_react_dev_demo.py @@ -0,0 +1,87 @@ +# Roadmap: DEV-072 +# Безопасная точка запуска демонстрационного набора только для React-dev. + +import os + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from partner_programs.services.react_dev_demo import ( + DEMO_PROGRAM_NAME, + DEMO_USER_SPECS, + ReactDevDemoDataError, + build_react_dev_demo_data, +) + + +class Command(BaseCommand): + help = "Создать или обновить DEMO-набор экспертной оценки для React-dev." + requires_system_checks = [] + + def add_arguments(self, parser): + parser.add_argument( + "--confirm-react-dev", + action="store_true", + help="Подтвердить, что используется изолированная база данных React-dev.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Удалить точную DEMO-программу и заново создать ее набор данных.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Построить набор в транзакции и откатить все изменения.", + ) + + def handle(self, *args, **options): + if not settings.ALLOW_REACT_DEV_DEMO_SEED: + raise CommandError("Создание DEMO-данных React-dev отключено в настройках.") + if not options["confirm_react_dev"]: + raise CommandError("Для запуска передайте флаг --confirm-react-dev.") + + password = os.environ.get("REACT_DEV_DEMO_PASSWORD") + if not password: + raise CommandError("Требуется переменная окружения REACT_DEV_DEMO_PASSWORD.") + + mode = "пересоздание" if options["reset"] else "создание/обновление" + if options["dry_run"]: + mode = f"пробный запуск: {mode}" + self.stdout.write(f"План: {mode}") + self.stdout.write(f"Программа: {DEMO_PROGRAM_NAME}") + self.stdout.write( + "Пользователи: " + ", ".join(spec["email"] for spec in DEMO_USER_SPECS) + ) + + try: + summary = build_react_dev_demo_data( + password=password, + reset=options["reset"], + dry_run=options["dry_run"], + ) + except ReactDevDemoDataError as exc: + raise CommandError(str(exc)) from exc + + labels = { + "users": "пользователи", + "programs": "программы", + "program_memberships": "участники программ", + "applications": "заявки", + "teams": "команды", + "team_members": "участники команд", + "criteria": "критерии", + "submissions": "сдачи", + "assignments": "назначения", + "evaluations": "оценки", + "scores": "баллы", + } + self.stdout.write("Результат:") + for label, count in summary.as_dict().items(): + self.stdout.write(f" {labels[label]}: {count}") + if options["dry_run"]: + self.stdout.write( + self.style.WARNING("Пробный запуск завершен; все изменения отменены.") + ) + else: + self.stdout.write(self.style.SUCCESS("DEMO-данные React-dev готовы.")) diff --git a/partner_programs/services/react_dev_demo.py b/partner_programs/services/react_dev_demo.py new file mode 100644 index 00000000..a0f15852 --- /dev/null +++ b/partner_programs/services/react_dev_demo.py @@ -0,0 +1,591 @@ +# Roadmap: DEV-072 +# Повторяемый связанный набор данных для ручной проверки React-dev. + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + PartnerProgram, + PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + TeamMember, +) +from partner_programs.services.application_team import ( + create_or_get_application, + submit_application, +) +from partner_programs.services.evaluations import ( + create_or_get_draft_evaluation, + submit_evaluation, + update_draft_evaluation, +) +from partner_programs.services.submission_assignments import ( + create_submission_assignment, +) +from project_rates.models import Criteria +from users.models import CustomUser, Expert, Member + +User = get_user_model() + +DEMO_PROGRAM_NAME = "[DEMO] Экспертная оценка React-dev" +DEMO_PROGRAM_TAG = "react-dev-expert-evaluation-demo" +DEMO_TEAM_NAME = "[DEMO] Команда React-dev" + +DEMO_USER_SPECS = ( + { + "key": "manager", + "email": "demo.manager@procollab.test", + "first_name": "Демо", + "last_name": "Менеджер", + "user_type": CustomUser.MEMBER, + }, + { + "key": "expert", + "email": "demo.expert@procollab.test", + "first_name": "Демо", + "last_name": "Эксперт", + "user_type": CustomUser.EXPERT, + }, + { + "key": "participant1", + "email": "demo.participant1@procollab.test", + "first_name": "Демо", + "last_name": "Участник Один", + "user_type": CustomUser.MEMBER, + }, + { + "key": "participant2", + "email": "demo.participant2@procollab.test", + "first_name": "Демо", + "last_name": "Участник Два", + "user_type": CustomUser.MEMBER, + }, +) + +DEMO_CRITERION_SPECS = ( + { + "name": "Полнота решения", + "description": "Насколько полно раскрыта предлагаемая идея.", + "type": "int", + "min_value": 1, + "max_value": 10, + }, + { + "name": "Проработанность", + "description": "Качество проработки ключевых частей решения.", + "type": "int", + "min_value": 0, + "max_value": 5, + }, + { + "name": "Реализуемость", + "description": "Реалистичность плана и доступность ресурсов.", + "type": "float", + "min_value": 0, + "max_value": 10, + }, +) + +DEMO_SUBMISSION_SPECS = ( + { + "key": "none", + "stage_key": "demo-without-evaluation", + "title": "[DEMO] Решение без оценки", + "description": ( + "Демонстрационное решение для проверки начала оценивания.\n" + "Описание содержит перенос строки и не содержит персональных данных." + ), + "links": [ + "https://example.com/react-dev-demo/without-evaluation", + "javascript:alert('demo-link')", + ], + }, + { + "key": "draft", + "stage_key": "demo-draft-evaluation", + "title": "[DEMO] Решение с черновиком", + "description": ( + "Демонстрационное решение с частично заполненной оценкой.\n" + "Подходит для проверки восстановления формы и autosave." + ), + "links": [ + "https://example.com/react-dev-demo/draft", + {"invalid": "nested payload"}, + ], + }, + { + "key": "submitted", + "stage_key": "demo-submitted-evaluation", + "title": "[DEMO] Решение с отправленной оценкой", + "description": ( + "Демонстрационное решение с окончательно отправленной оценкой.\n" + "Используется для проверки read-only состояния." + ), + "links": ["https://example.com/react-dev-demo/submitted"], + }, +) + + +class ReactDevDemoDataError(Exception): + """Команда не может безопасно создать или обновить заданный DEMO-контур.""" + + +@dataclass(frozen=True) +class ReactDevDemoSummary: + users: int + programs: int + program_memberships: int + applications: int + teams: int + team_members: int + criteria: int + submissions: int + assignments: int + evaluations: int + scores: int + + def as_dict(self): + return { + "users": self.users, + "programs": self.programs, + "program_memberships": self.program_memberships, + "applications": self.applications, + "teams": self.teams, + "team_members": self.team_members, + "criteria": self.criteria, + "submissions": self.submissions, + "assignments": self.assignments, + "evaluations": self.evaluations, + "scores": self.scores, + } + + +def _find_owned_program(): + candidates = list( + PartnerProgram.objects.filter( + Q(name=DEMO_PROGRAM_NAME) | Q(tag=DEMO_PROGRAM_TAG) + ).order_by("pk") + ) + if not candidates: + return None + if len(candidates) != 1: + raise ReactDevDemoDataError( + "Идентификаторы DEMO-программы соответствуют нескольким программам; " + "требуется ручная проверка." + ) + program = candidates[0] + if program.name != DEMO_PROGRAM_NAME or program.tag != DEMO_PROGRAM_TAG: + raise ReactDevDemoDataError( + "Название или тег DEMO-программы уже используется другой программой." + ) + return program + + +def _delete_owned_program(): + program = _find_owned_program() + if program is not None: + # EvaluationScore защищает Criteria, поэтому сначала удаляем точный + # evaluation-граф программы, а затем запускаем каскад Program. + Evaluation.objects.filter(submission__program=program).delete() + SubmissionExpertAssignment.objects.filter(submission__program=program).delete() + program.delete() + + +def _ensure_user(spec, password): + user, _created = User.objects.get_or_create( + email=spec["email"], + defaults={ + "first_name": spec["first_name"], + "last_name": spec["last_name"], + "birthday": date(1990, 1, 1), + "is_active": True, + "user_type": spec["user_type"], + }, + ) + user.first_name = spec["first_name"] + user.last_name = spec["last_name"] + user.birthday = date(1990, 1, 1) + user.is_active = True + user.is_staff = False + user.is_superuser = False + user.user_type = spec["user_type"] + user.set_password(password) + user.save( + update_fields=[ + "first_name", + "last_name", + "birthday", + "is_active", + "is_staff", + "is_superuser", + "user_type", + "password", + ] + ) + + if spec["user_type"] == CustomUser.EXPERT: + Expert.objects.get_or_create(user=user) + else: + Member.objects.get_or_create(user=user) + return user + + +def _ensure_program(): + program = _find_owned_program() + now = timezone.now() + values = { + "name": DEMO_PROGRAM_NAME, + "tag": DEMO_PROGRAM_TAG, + "description": ( + "Связанный демонстрационный контур для smoke-тестирования " + "кабинета эксперта React-dev." + ), + "city": "Москва", + "data_schema": {}, + "draft": False, + "is_competitive": True, + "projects_availability": "experts_only", + "participation_format": PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + "team_min_size": 2, + "team_max_size": 4, + "datetime_registration_ends": now + timezone.timedelta(days=180), + "datetime_application_ends": now + timezone.timedelta(days=180), + "datetime_project_submission_ends": now + timezone.timedelta(days=180), + "datetime_evaluation_ends": now + timezone.timedelta(days=180), + "datetime_started": now - timezone.timedelta(days=1), + "datetime_finished": now + timezone.timedelta(days=180), + } + if program is None: + return PartnerProgram.objects.create(**values) + + for field, value in values.items(): + setattr(program, field, value) + program.save(update_fields=[*values, "datetime_updated"]) + return program + + +def _ensure_membership(program, user): + membership, _created = PartnerProgramUserProfile.objects.update_or_create( + partner_program=program, + user=user, + defaults={ + "project": None, + "partner_program_data": {"demo_seed": "DEV-072"}, + }, + ) + return membership + + +def _ensure_application(program, users): + result = create_or_get_application( + program=program, + user=users["participant1"], + created_by=users["participant1"], + participation_mode=Application.PARTICIPATION_MODE_TEAM, + form_data={"demo_seed": "DEV-072"}, + team_name=DEMO_TEAM_NAME, + ) + application = result.application + team = application.team + TeamMember.objects.update_or_create( + team=team, + user=users["participant2"], + defaults={ + "role": TeamMember.ROLE_MEMBER, + "status": TeamMember.STATUS_ACCEPTED, + "invited_by": users["participant1"], + }, + ) + return submit_application( + application=application, + actor=users["participant1"], + ) + + +def _ensure_criteria(program): + criteria = {} + for spec in DEMO_CRITERION_SPECS: + criterion, _created = Criteria.objects.update_or_create( + partner_program=program, + name=spec["name"], + defaults={ + "description": spec["description"], + "type": spec["type"], + "min_value": spec["min_value"], + "max_value": spec["max_value"], + }, + ) + criteria[spec["name"]] = criterion + + expected_ids = [criterion.pk for criterion in criteria.values()] + EvaluationScore.objects.filter( + evaluation__submission__program=program, + ).exclude(criterion_id__in=expected_ids).delete() + Criteria.objects.filter(partner_program=program).exclude(pk__in=expected_ids).delete() + return criteria + + +def _ensure_submissions(application): + submissions = {} + for spec in DEMO_SUBMISSION_SPECS: + submission, _created = Submission.objects.update_or_create( + application=application, + stage_key=spec["stage_key"], + version=1, + defaults={ + "program": application.program, + "submitted_by": application.user, + "title": spec["title"], + "description": spec["description"], + "form_data": {"demo_seed": "DEV-072", "private": True}, + "links": spec["links"], + "status": Submission.STATUS_FINAL, + "submitted_at": timezone.now(), + }, + ) + submissions[spec["key"]] = submission + return submissions + + +def _active_assignment(submission, expert): + return ( + SubmissionExpertAssignment.objects.filter( + submission=submission, + expert=expert, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ) + .order_by("-created_at", "-pk") + .first() + ) + + +def _ensure_assignment(submission, expert, manager): + assignment = _active_assignment(submission, expert) + if assignment is None: + assignment = create_submission_assignment( + program=submission.program, + submission_id=submission.pk, + expert_id=expert.pk, + actor=manager, + ).assignment + return assignment + + +def _set_assignment_assigned(assignment): + if assignment.status == SubmissionExpertAssignment.STATUS_ASSIGNED: + return assignment + assignment.status = SubmissionExpertAssignment.STATUS_ASSIGNED + assignment.completed_at = None + assignment.save( + update_fields=[ + "status", + "completed_at", + "updated_at", + ] + ) + return assignment + + +def _sync_evaluation_scores(evaluation, criteria, values): + expected = { + criteria[name].pk: (criteria[name], Decimal(value)) for name, value in values + } + existing = { + score.criterion_id: score + for score in evaluation.scores.select_related("criterion") + } + + for criterion_id, (criterion, value) in expected.items(): + score = existing.get(criterion_id) + if score is None: + EvaluationScore.objects.create( + evaluation=evaluation, + criterion=criterion, + value=value, + ) + elif score.value != value: + score.value = value + score.save(update_fields=["value", "updated_at"]) + + evaluation.scores.exclude(criterion_id__in=expected).delete() + + +def _ensure_draft_evaluation_state(evaluation, expert_user, comment): + if evaluation.status == Evaluation.STATUS_SUBMITTED: + evaluation.status = Evaluation.STATUS_DRAFT + evaluation.submitted_at = None + evaluation.total_score = None + evaluation.save( + update_fields=[ + "status", + "submitted_at", + "total_score", + "updated_at", + ] + ) + if evaluation.comment != comment: + update_draft_evaluation( + evaluation_id=evaluation.pk, + user=expert_user, + comment_supplied=True, + comment=comment, + ) + evaluation.comment = comment + + +def _ensure_evaluation_states(submissions, criteria, expert_user, manager): + expert = expert_user.expert + assignments = { + key: _ensure_assignment(submission, expert, manager) + for key, submission in submissions.items() + } + + Evaluation.objects.filter( + submission=submissions["none"], + expert=expert, + ).delete() + _set_assignment_assigned(assignments["none"]) + + draft_evaluation = Evaluation.objects.filter( + submission=submissions["draft"], + expert=expert, + ).first() + _set_assignment_assigned(assignments["draft"]) + if draft_evaluation is None: + draft_evaluation = create_or_get_draft_evaluation( + submission_id=submissions["draft"].pk, + user=expert_user, + ).evaluation + _ensure_draft_evaluation_state( + draft_evaluation, + expert_user, + "Частично заполненный демонстрационный черновик.", + ) + _sync_evaluation_scores( + draft_evaluation, + criteria, + (("Полнота решения", "7"),), + ) + + submitted_values = ( + ("Полнота решения", "9"), + ("Проработанность", "4"), + ("Реализуемость", "7.5"), + ) + submitted_evaluation = Evaluation.objects.filter( + submission=submissions["submitted"], + expert=expert, + ).first() + if submitted_evaluation is None: + _set_assignment_assigned(assignments["submitted"]) + submitted_evaluation = create_or_get_draft_evaluation( + submission_id=submissions["submitted"].pk, + user=expert_user, + ).evaluation + if submitted_evaluation.status == Evaluation.STATUS_DRAFT: + _set_assignment_assigned(assignments["submitted"]) + _ensure_draft_evaluation_state( + submitted_evaluation, + expert_user, + "Итоговая демонстрационная оценка.", + ) + _sync_evaluation_scores( + submitted_evaluation, + criteria, + submitted_values, + ) + submit_evaluation( + evaluation_id=submitted_evaluation.pk, + user=expert_user, + ) + else: + if submitted_evaluation.comment != "Итоговая демонстрационная оценка.": + submitted_evaluation.comment = "Итоговая демонстрационная оценка." + submitted_evaluation.save(update_fields=["comment", "updated_at"]) + _sync_evaluation_scores( + submitted_evaluation, + criteria, + submitted_values, + ) + if assignments["submitted"].status != SubmissionExpertAssignment.STATUS_COMPLETED: + assignments["submitted"].status = SubmissionExpertAssignment.STATUS_COMPLETED + assignments["submitted"].completed_at = submitted_evaluation.submitted_at + assignments["submitted"].save( + update_fields=[ + "status", + "completed_at", + "updated_at", + ] + ) + + +def _summary(program): + application_qs = Application.objects.filter(program=program) + submission_qs = Submission.objects.filter(program=program) + evaluation_qs = Evaluation.objects.filter(submission__program=program) + return ReactDevDemoSummary( + users=User.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).count(), + programs=PartnerProgram.objects.filter(pk=program.pk).count(), + program_memberships=PartnerProgramUserProfile.objects.filter( + partner_program=program + ).count(), + applications=application_qs.count(), + teams=application_qs.filter(team__isnull=False).count(), + team_members=TeamMember.objects.filter( + team__application__program=program + ).count(), + criteria=Criteria.objects.filter(partner_program=program).count(), + submissions=submission_qs.count(), + assignments=SubmissionExpertAssignment.objects.filter( + submission__program=program, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ).count(), + evaluations=evaluation_qs.count(), + scores=EvaluationScore.objects.filter( + evaluation__submission__program=program + ).count(), + ) + + +def build_react_dev_demo_data(*, password, reset=False, dry_run=False): + """Создать полный набор DEV-072 и при необходимости откатить изменения.""" + + with transaction.atomic(): + if reset: + _delete_owned_program() + + users = {spec["key"]: _ensure_user(spec, password) for spec in DEMO_USER_SPECS} + program = _ensure_program() + program.managers.set([users["manager"]]) + expert = users["expert"].expert + expert.programs.add(program) + + for user in users.values(): + _ensure_membership(program, user) + + application = _ensure_application(program, users) + criteria = _ensure_criteria(program) + submissions = _ensure_submissions(application) + _ensure_evaluation_states( + submissions, + criteria, + users["expert"], + users["manager"], + ) + summary = _summary(program) + + if dry_run: + transaction.set_rollback(True) + return summary diff --git a/partner_programs/tests/test_react_dev_demo_seed.py b/partner_programs/tests/test_react_dev_demo_seed.py new file mode 100644 index 00000000..32285912 --- /dev/null +++ b/partner_programs/tests/test_react_dev_demo_seed.py @@ -0,0 +1,410 @@ +# Roadmap: DEV-072 +# Безопасность, повторяемость и API-контракт демонстрационного React-dev набора. + +import io +import os +import secrets +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase, override_settings +from django.urls import include, path +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + PartnerProgram, + PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + Team, + TeamMember, +) +from partner_programs.services.react_dev_demo import ( + DEMO_CRITERION_SPECS, + DEMO_PROGRAM_NAME, + DEMO_PROGRAM_TAG, + DEMO_SUBMISSION_SPECS, + DEMO_USER_SPECS, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.models import Criteria +from users.models import CustomUser + +urlpatterns = [ + path("expert/", include("partner_programs.expert_urls")), +] + + +@override_settings(ROOT_URLCONF=__name__) +class ReactDevDemoSeedTests(TestCase): + def setUp(self): + self.demo_password = secrets.token_urlsafe(32) + + def run_seed(self, *extra_args, confirm=True, password=True): + args = list(extra_args) + if confirm: + args.append("--confirm-react-dev") + stdout = io.StringIO() + stderr = io.StringIO() + with override_settings(ALLOW_REACT_DEV_DEMO_SEED=True): + with patch.dict(os.environ, {}, clear=False): + if password: + os.environ["REACT_DEV_DEMO_PASSWORD"] = self.demo_password + else: + os.environ.pop("REACT_DEV_DEMO_PASSWORD", None) + call_command( + "seed_react_dev_demo", + *args, + stdout=stdout, + stderr=stderr, + ) + return stdout.getvalue(), stderr.getvalue() + + def assert_demo_absent(self): + self.assertFalse( + PartnerProgram.objects.filter( + name=DEMO_PROGRAM_NAME, + tag=DEMO_PROGRAM_TAG, + ).exists() + ) + self.assertFalse( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).exists() + ) + + def test_seed_is_disabled_by_default_before_any_write(self): + with patch.dict( + os.environ, + {"REACT_DEV_DEMO_PASSWORD": self.demo_password}, + ): + with self.assertRaisesMessage(CommandError, "отключено"): + call_command( + "seed_react_dev_demo", + "--confirm-react-dev", + ) + + self.assert_demo_absent() + + def test_seed_requires_explicit_react_dev_confirmation_before_any_write(self): + with self.assertRaisesMessage(CommandError, "--confirm-react-dev"): + self.run_seed(confirm=False) + + self.assert_demo_absent() + + def test_seed_requires_password_environment_variable_before_any_write(self): + with self.assertRaisesMessage(CommandError, "REACT_DEV_DEMO_PASSWORD"): + self.run_seed(password=False) + + self.assert_demo_absent() + + def test_first_run_creates_the_complete_linked_dataset(self): + self.run_seed() + + program = PartnerProgram.objects.get( + name=DEMO_PROGRAM_NAME, + tag=DEMO_PROGRAM_TAG, + ) + users = { + spec["key"]: CustomUser.objects.get(email=spec["email"]) + for spec in DEMO_USER_SPECS + } + self.assertTrue(all(user.is_active for user in users.values())) + self.assertTrue( + all(user.check_password(self.demo_password) for user in users.values()) + ) + self.assertEqual(users["expert"].user_type, CustomUser.EXPERT) + self.assertTrue(users["expert"].expert.programs.filter(pk=program.pk).exists()) + self.assertTrue(program.managers.filter(pk=users["manager"].pk).exists()) + self.assertEqual( + PartnerProgramUserProfile.objects.filter( + partner_program=program, + ).count(), + 4, + ) + + application = Application.objects.get(program=program) + self.assertEqual(application.status, Application.STATUS_SUBMITTED) + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + team = Team.objects.get(application=application) + self.assertEqual( + TeamMember.objects.filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ).count(), + 2, + ) + + self.assertEqual(Criteria.objects.filter(partner_program=program).count(), 3) + self.assertEqual( + Criteria.objects.filter( + partner_program=program, + type="int", + ).count(), + 2, + ) + self.assertEqual( + Criteria.objects.filter( + partner_program=program, + type="float", + ).count(), + 1, + ) + self.assertEqual( + set( + Criteria.objects.filter(partner_program=program).values_list( + "name", + flat=True, + ) + ), + {spec["name"] for spec in DEMO_CRITERION_SPECS}, + ) + self.assertEqual(Submission.objects.filter(program=program).count(), 3) + self.assertEqual( + SubmissionExpertAssignment.objects.filter( + submission__program=program, + expert=users["expert"].expert, + ).count(), + 3, + ) + + submissions = { + submission.stage_key: submission + for submission in Submission.objects.filter(program=program) + } + without_evaluation = submissions["demo-without-evaluation"] + draft_submission = submissions["demo-draft-evaluation"] + submitted_submission = submissions["demo-submitted-evaluation"] + self.assertFalse( + Evaluation.objects.filter(submission=without_evaluation).exists() + ) + draft = Evaluation.objects.get(submission=draft_submission) + self.assertEqual(draft.status, Evaluation.STATUS_DRAFT) + self.assertEqual(draft.scores.count(), 1) + submitted = Evaluation.objects.get(submission=submitted_submission) + self.assertEqual(submitted.status, Evaluation.STATUS_SUBMITTED) + self.assertIsNotNone(submitted.submitted_at) + self.assertEqual(submitted.scores.count(), 3) + self.assertEqual( + submitted_submission.expert_assignments.get().status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_repeated_run_does_not_create_duplicates(self): + self.run_seed() + first_ids = self.dataset_ids() + + self.run_seed() + + self.assertEqual(self.dataset_ids(), first_ids) + self.assertEqual(len(first_ids["users"]), 4) + self.assertEqual(len(first_ids["criteria"]), len(DEMO_CRITERION_SPECS)) + self.assertEqual(len(first_ids["submissions"]), len(DEMO_SUBMISSION_SPECS)) + self.assertEqual(len(first_ids["assignments"]), 3) + self.assertEqual(len(first_ids["evaluations"]), 2) + self.assertEqual(len(first_ids["scores"]), 4) + + program = PartnerProgram.objects.get(name=DEMO_PROGRAM_NAME) + draft = Evaluation.objects.get( + submission__program=program, + status=Evaluation.STATUS_DRAFT, + ) + extra_criterion = Criteria.objects.create( + partner_program=program, + name="[DEMO] Лишний критерий", + description="Проверка точечной очистки DEV-072.", + type="int", + min_value=0, + max_value=10, + ) + extra_score = EvaluationScore.objects.create( + evaluation=draft, + criterion=extra_criterion, + value=5, + ) + + self.run_seed() + + self.assertFalse(EvaluationScore.objects.filter(pk=extra_score.pk).exists()) + self.assertFalse(Criteria.objects.filter(pk=extra_criterion.pk).exists()) + self.assertEqual(self.dataset_ids(), first_ids) + + def test_dry_run_reports_counts_without_persisting_changes(self): + stdout, _stderr = self.run_seed("--dry-run") + + self.assertIn("Пробный запуск завершен", stdout) + self.assertIn("сдачи: 3", stdout) + self.assert_demo_absent() + + self.run_seed() + draft_score = EvaluationScore.objects.get( + evaluation__submission__program__name=DEMO_PROGRAM_NAME, + evaluation__status=Evaluation.STATUS_DRAFT, + ) + draft_score.value = 3 + draft_score.save(update_fields=["value", "updated_at"]) + + self.run_seed("--dry-run") + + draft_score.refresh_from_db() + self.assertEqual(draft_score.value, 3) + + def test_reset_rebuilds_only_demo_contour(self): + self.run_seed() + original_program = PartnerProgram.objects.get(name=DEMO_PROGRAM_NAME) + original_program_id = original_program.pk + demo_user_ids = set( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).values_list("pk", flat=True) + ) + outsider = create_user(prefix="demo-reset-outsider") + outsider_program = create_partner_program( + name="[DEMO] Похожая, но посторонняя программа", + tag="other-demo-program", + ) + + self.run_seed("--reset") + + rebuilt = PartnerProgram.objects.get( + name=DEMO_PROGRAM_NAME, + tag=DEMO_PROGRAM_TAG, + ) + self.assertNotEqual(rebuilt.pk, original_program_id) + self.assertTrue(CustomUser.objects.filter(pk=outsider.pk).exists()) + self.assertTrue(PartnerProgram.objects.filter(pk=outsider_program.pk).exists()) + self.assertEqual( + set( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).values_list("pk", flat=True) + ), + demo_user_ids, + ) + self.assertEqual(Submission.objects.filter(program=rebuilt).count(), 3) + self.assertEqual(Criteria.objects.filter(partner_program=rebuilt).count(), 3) + self.assertEqual( + EvaluationScore.objects.filter( + evaluation__submission__program=rebuilt, + ).count(), + 4, + ) + + def test_expert_api_returns_three_expected_evaluation_states(self): + self.run_seed() + expert_user = CustomUser.objects.get(email="demo.expert@procollab.test") + client = APIClient() + client.force_authenticate(expert_user) + + response = client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 3) + states = { + ( + item["my_evaluation"]["status"] + if item["my_evaluation"] is not None + else "none" + ) + for item in response.data["results"] + } + self.assertEqual(states, {"none", "draft", "submitted"}) + submitted_item = next( + item + for item in response.data["results"] + if item["my_evaluation"] + and item["my_evaluation"]["status"] == Evaluation.STATUS_SUBMITTED + ) + self.assertEqual( + submitted_item["assignment"]["status"], + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_expert_detail_is_pii_safe_for_all_demo_submissions(self): + self.run_seed() + expert_user = CustomUser.objects.get(email="demo.expert@procollab.test") + participant_emails = { + "demo.participant1@procollab.test", + "demo.participant2@procollab.test", + } + client = APIClient() + client.force_authenticate(expert_user) + forbidden_keys = { + "application", + "form_data", + "submitted_by", + "user", + "created_by", + "email", + "phone", + "team", + "team_members", + } + + for submission in Submission.objects.filter(program__name=DEMO_PROGRAM_NAME): + with self.subTest(submission=submission.pk): + response = client.get(f"/expert/submissions/{submission.pk}/") + self.assertEqual(response.status_code, 200) + self.assertFalse(forbidden_keys.intersection(response.data)) + serialized = str(response.data) + self.assertTrue( + all(email not in serialized for email in participant_emails) + ) + self.assertNotIn("private", serialized) + self.assertEqual(response.data["title"], submission.title) + self.assertEqual(response.data["description"], submission.description) + self.assertEqual(response.data["links"], submission.links) + + def test_command_never_prints_the_demo_password(self): + stdout, stderr = self.run_seed() + + self.assertNotIn(self.demo_password, stdout) + self.assertNotIn(self.demo_password, stderr) + + def dataset_ids(self): + program = PartnerProgram.objects.get( + name=DEMO_PROGRAM_NAME, + tag=DEMO_PROGRAM_TAG, + ) + return { + "users": tuple( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ) + .order_by("pk") + .values_list("pk", flat=True) + ), + "programs": (program.pk,), + "criteria": tuple( + Criteria.objects.filter(partner_program=program) + .order_by("pk") + .values_list("pk", flat=True) + ), + "submissions": tuple( + Submission.objects.filter(program=program) + .order_by("pk") + .values_list("pk", flat=True) + ), + "assignments": tuple( + SubmissionExpertAssignment.objects.filter(submission__program=program) + .order_by("pk") + .values_list("pk", flat=True) + ), + "evaluations": tuple( + Evaluation.objects.filter(submission__program=program) + .order_by("pk") + .values_list("pk", flat=True) + ), + "scores": tuple( + EvaluationScore.objects.filter(evaluation__submission__program=program) + .order_by("pk") + .values_list("pk", flat=True) + ), + } diff --git a/procollab/settings.py b/procollab/settings.py index 546bd2fc..43889f18 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -18,6 +18,12 @@ DEBUG = config("DEBUG", default=False, cast=bool) +ALLOW_REACT_DEV_DEMO_SEED = config( + "ALLOW_REACT_DEV_DEMO_SEED", + default=False, + cast=bool, +) + AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool) TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="", cast=str)