diff --git a/.env.example b/.env.example index 13e8e71d..813d4d12 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,29 @@ DEBUG= DJANGO_SECRET_KEY= +# Дополнительные домены для изолированных dev-контуров; в боевом окружении переменные необязательны. +EXTRA_ALLOWED_HOSTS= +EXTRA_CSRF_TRUSTED_ORIGINS= +# Пример для react-dev: +# EXTRA_ALLOWED_HOSTS=api-react-dev.procollab.ru,react-dev.procollab.ru +# EXTRA_CSRF_TRUSTED_ORIGINS=https://api-react-dev.procollab.ru,https://react-dev.procollab.ru + EMAIL_USER= +EMAIL_BACKEND=anymail.backends.unisender_go.EmailBackend +VERIFY_EMAIL_REDIRECT_URL=https://app.procollab.ru/auth/verification/ EMAIL_PASSWORD= EMAIL_HOST= EMAIL_PORT= +DRF_THROTTLE_AUTH_REGISTER=5/min +DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min +DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min +DRF_THROTTLE_TOKEN_OBTAIN=10/min +DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min +DRF_THROTTLE_APPLICATION_CREATE=10/min +DRF_THROTTLE_SUBMISSION_CREATE=10/min +DRF_THROTTLE_EVALUATION_AMEND=30/min + SENTRY_DSN= DATABASE_NAME= @@ -27,3 +45,5 @@ TELEGRAM_CHANNEL= CLICKUP_API_TOKEN= CLICKUP_SPACE_ID= +ALLOW_REACT_DEV_DEMO_SEED=False +REACT_DEV_DEMO_PASSWORD= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5d6bcb53..73e43cf5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,20 @@ Кратко опишите изменения и при необходимости приложите ссылку на задачу. +## Roadmap + +Укажите связанные доработки из Google-таблицы. + +```text +Roadmap-IDs: DEV-000 +Roadmap-Complete: DEV-000 +Roadmap-Partial: +``` + +- `Roadmap-IDs` — все связанные доработки. +- `Roadmap-Complete` — доработки, полностью закрываемые этим PR. +- `Roadmap-Partial` — доработки, реализованные только частично. + ## Проверка Укажите, как проверялись изменения. diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml new file mode 100644 index 00000000..eced28d2 --- /dev/null +++ b/.github/workflows/backend-postgres-ci.yml @@ -0,0 +1,161 @@ +# Roadmap: DEV-074 +name: Backend PostgreSQL CI + +on: + pull_request: + branches: + - master + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: backend-postgres-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres-tests: + name: Backend PostgreSQL CI + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: procollab_ci + POSTGRES_USER: procollab_ci + POSTGRES_PASSWORD: procollab_ci_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U procollab_ci -d procollab_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + DJANGO_SETTINGS_MODULE: procollab.settings_ci + DJANGO_SECRET_KEY: ci-only-not-production-secret + DEBUG: "False" + DATABASE_NAME: procollab_ci + DATABASE_USER: procollab_ci + DATABASE_PASSWORD: procollab_ci_password + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: "5432" + ALLOW_REACT_DEV_DEMO_SEED: "False" + AUTOPOSTING_ON: "False" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install "poetry==1.2.2" + poetry config virtualenvs.create true + poetry install --no-interaction --no-ansi --no-root + + - name: Verify PostgreSQL connection and locking support + run: | + poetry run python - <<'PY' + import django + + django.setup() + + from django.db import connection + + connection.ensure_connection() + assert connection.vendor == "postgresql", connection.vendor + assert connection.features.has_select_for_update + print(f"Database vendor: {connection.vendor}") + print( + "select_for_update support: " + f"{connection.features.has_select_for_update}" + ) + PY + + - name: Check Django configuration + run: poetry run python manage.py check + + - name: Check Django models + run: poetry run python manage.py check --tag models + + - name: Check migration consistency + run: poetry run python manage.py makemigrations --check --dry-run + + - name: Apply migrations + run: poetry run python manage.py migrate --noinput + + - name: Check CI settings formatting + run: poetry run black --check procollab/settings_ci.py + + - name: Lint CI settings + run: poetry run flake8 procollab/settings_ci.py + + - name: Run PostgreSQL locking and constraint tests + run: | + poetry run python manage.py test \ + partner_programs.tests.test_expert_evaluation_api.ConcurrentEvaluationSubmitTests \ + partner_programs.tests.test_application_team_models.ApplicationTeamDatabaseConstraintTests \ + partner_programs.tests.test_application_team_service.ApplicationTeamServiceSequentialConflictTests \ + partner_programs.tests.test_program_participation_policy.PartnerProgramParticipationPolicyConstraintTests \ + partner_programs.tests.test_team_api \ + partner_programs.tests.test_team_invite_api \ + partner_programs.tests.test_team_invite_service \ + partner_programs.tests.test_team_management_service \ + --verbosity 2 + + - name: Run full backend test suite + run: poetry run python manage.py test --verbosity 1 --keepdb + + - name: Remove retained PostgreSQL test database + if: always() + run: | + poetry run python - <<'PY' + import os + + import psycopg2 + from psycopg2 import sql + + test_database = "test_procollab_ci" + + connection = psycopg2.connect( + dbname=os.environ["DATABASE_NAME"], + user=os.environ["DATABASE_USER"], + password=os.environ["DATABASE_PASSWORD"], + host=os.environ["DATABASE_HOST"], + port=os.environ["DATABASE_PORT"], + ) + connection.autocommit = True + + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = %s + AND pid <> pg_backend_pid() + """, + [test_database], + ) + cursor.execute( + sql.SQL("DROP DATABASE IF EXISTS {}").format( + sql.Identifier(test_database) + ) + ) + finally: + connection.close() + PY diff --git a/.github/workflows/backend-react-dev-deploy.yml b/.github/workflows/backend-react-dev-deploy.yml new file mode 100644 index 00000000..279056f2 --- /dev/null +++ b/.github/workflows/backend-react-dev-deploy.yml @@ -0,0 +1,200 @@ +# Roadmap: DEV-075 +name: Backend React-dev deploy + +on: + workflow_run: + workflows: + - Backend PostgreSQL CI + types: + - completed + branches: + - master + +permissions: + contents: read + packages: write + +concurrency: + group: backend-react-dev-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Deploy backend to React-dev + if: >- + ${{ + github.event.workflow_run.conclusion == 'success' && + ( + github.event.workflow_run.event == 'push' || + github.event.workflow_run.event == 'workflow_dispatch' + ) && + github.event.workflow_run.head_branch == 'master' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_sha != '' + }} + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + DEPLOY_RUN_ID: ${{ github.run_id }} + DEPLOY_IMAGE_TAG: >- + ghcr.io/procollab-github/api:react-dev-${{ github.event.workflow_run.head_sha }} + + steps: + - name: Validate deploy source + shell: bash + run: | + set -Eeuo pipefail + + if [[ ! "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Successful CI run did not provide a valid commit SHA." >&2 + exit 1 + fi + + if [[ ! "$DEPLOY_RUN_ID" =~ ^[0-9]+$ ]]; then + echo "GitHub Actions run ID is invalid." >&2 + exit 1 + fi + + if [[ ! "$DEPLOY_IMAGE_TAG" =~ ^ghcr\.io/procollab-github/api:react-dev-[0-9a-f]{40}$ ]]; then + echo "React-dev image tag is invalid." >&2 + exit 1 + fi + + - name: Checkout tested commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + + - name: Verify checked out commit + shell: bash + run: | + set -Eeuo pipefail + + actual_sha="$(git rev-parse HEAD)" + if [[ "$actual_sha" != "$DEPLOY_SHA" ]]; then + echo "Checked out commit does not match the successful CI run." >&2 + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish tested image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + cache-from: type=gha,scope=backend-react-dev + cache-to: type=gha,mode=max,scope=backend-react-dev + tags: ${{ env.DEPLOY_IMAGE_TAG }} + labels: | + org.opencontainers.image.revision=${{ env.DEPLOY_SHA }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + + - name: Pin published image by digest + shell: bash + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -Eeuo pipefail + + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Published image digest is invalid." >&2 + exit 1 + fi + + deploy_image="ghcr.io/procollab-github/api@${IMAGE_DIGEST}" + echo "DEPLOY_IMAGE=${deploy_image}" >> "$GITHUB_ENV" + + - name: Prepare pinned SSH configuration + shell: bash + env: + REACT_DEV_SSH_HOST: ${{ secrets.REACT_DEV_SSH_HOST }} + REACT_DEV_SSH_PORT: ${{ secrets.REACT_DEV_SSH_PORT }} + REACT_DEV_SSH_USER: ${{ secrets.REACT_DEV_SSH_USER }} + REACT_DEV_SSH_PRIVATE_KEY: ${{ secrets.REACT_DEV_SSH_PRIVATE_KEY }} + REACT_DEV_SSH_KNOWN_HOSTS: ${{ secrets.REACT_DEV_SSH_KNOWN_HOSTS }} + run: | + set -Eeuo pipefail + + required_secrets=( + REACT_DEV_SSH_HOST + REACT_DEV_SSH_PORT + REACT_DEV_SSH_USER + REACT_DEV_SSH_PRIVATE_KEY + REACT_DEV_SSH_KNOWN_HOSTS + ) + for secret_name in "${required_secrets[@]}"; do + if [[ -z "${!secret_name:-}" ]]; then + echo "Required GitHub Secret is empty: ${secret_name}" >&2 + exit 1 + fi + done + + if [[ ! "$REACT_DEV_SSH_PORT" =~ ^[0-9]+$ ]] || + ((REACT_DEV_SSH_PORT < 1 || REACT_DEV_SSH_PORT > 65535)); then + echo "React-dev SSH port is invalid." >&2 + exit 1 + fi + + if [[ ! "$REACT_DEV_SSH_USER" =~ ^[a-z_][a-z0-9_.-]*$ ]]; then + echo "React-dev SSH user is invalid." >&2 + exit 1 + fi + + if [[ "$REACT_DEV_SSH_HOST" =~ [[:space:]] ]]; then + echo "React-dev SSH host is invalid." >&2 + exit 1 + fi + + install -d -m 700 "$HOME/.ssh" + printf '%s\n' "$REACT_DEV_SSH_PRIVATE_KEY" | + tr -d '\r' > "$HOME/.ssh/react_dev_deploy_key" + printf '%s\n' "$REACT_DEV_SSH_KNOWN_HOSTS" | + tr -d '\r' > "$HOME/.ssh/react_dev_known_hosts" + chmod 600 \ + "$HOME/.ssh/react_dev_deploy_key" \ + "$HOME/.ssh/react_dev_known_hosts" + + test -s "$HOME/.ssh/react_dev_deploy_key" + test -s "$HOME/.ssh/react_dev_known_hosts" + ssh-keygen -y -f "$HOME/.ssh/react_dev_deploy_key" >/dev/null + + - name: Deploy tested commit to React-dev + shell: bash + env: + REACT_DEV_SSH_HOST: ${{ secrets.REACT_DEV_SSH_HOST }} + REACT_DEV_SSH_PORT: ${{ secrets.REACT_DEV_SSH_PORT }} + REACT_DEV_SSH_USER: ${{ secrets.REACT_DEV_SSH_USER }} + run: | + set -Eeuo pipefail + + ssh_options=( + -i "$HOME/.ssh/react_dev_deploy_key" + -p "$REACT_DEV_SSH_PORT" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile="$HOME/.ssh/react_dev_known_hosts" + -o ConnectTimeout=10 + -o ServerAliveInterval=15 + -o ServerAliveCountMax=3 + ) + + ssh "${ssh_options[@]}" \ + -- "$REACT_DEV_SSH_USER@$REACT_DEV_SSH_HOST" \ + "bash -s -- '$DEPLOY_SHA' '$DEPLOY_RUN_ID' '$DEPLOY_IMAGE'" \ + < scripts/deploy_react_dev.sh diff --git a/.github/workflows/sync-roadmap.yml b/.github/workflows/sync-roadmap.yml new file mode 100644 index 00000000..b0591d36 --- /dev/null +++ b/.github/workflows/sync-roadmap.yml @@ -0,0 +1,73 @@ +name: Sync roadmap + +on: + push: + pull_request: + types: [opened, edited, synchronize, reopened, closed] + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Build payload + id: payload + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + REPOSITORY: ${{ github.repository }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + ACTOR: ${{ github.actor }} + SERVER_URL: ${{ github.server_url }} + TOKEN: ${{ secrets.ROADMAP_WEBHOOK_TOKEN }} + run: | + set -euo pipefail + + test -f "$GITHUB_EVENT_PATH" + + if [ "$EVENT_NAME" = "push" ]; then + jq -n \ + --arg token "$TOKEN" \ + --arg event_type "push" \ + --arg repository "$REPOSITORY" \ + --arg branch "$REF_NAME" \ + --arg sha "$SHA" \ + --arg commit_message "$(jq -r '.head_commit.message // ""' "$GITHUB_EVENT_PATH")" \ + --arg author "$(jq -r '.head_commit.author.name // env.ACTOR' "$GITHUB_EVENT_PATH")" \ + --arg timestamp "$(jq -r '.head_commit.timestamp // (now | todateiso8601)' "$GITHUB_EVENT_PATH")" \ + --arg commit_url "$SERVER_URL/$REPOSITORY/commit/$SHA" \ + '{token:$token,event_type:$event_type,repository:$repository,branch:$branch,sha:$sha,commit_message:$commit_message,author:$author,timestamp:$timestamp,commit_url:$commit_url}' \ + > payload.json + else + jq -n \ + --arg token "$TOKEN" \ + --arg event_type "pull_request" \ + --arg action "$(jq -r '.action // ""' "$GITHUB_EVENT_PATH")" \ + --arg repository "$REPOSITORY" \ + --arg pr_number "$(jq -r '.number // ""' "$GITHUB_EVENT_PATH")" \ + --arg pr_url "$(jq -r '.pull_request.html_url // ""' "$GITHUB_EVENT_PATH")" \ + --arg pr_title "$(jq -r '.pull_request.title // ""' "$GITHUB_EVENT_PATH")" \ + --arg pr_body "$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH")" \ + --arg branch "$(jq -r '.pull_request.head.ref // ""' "$GITHUB_EVENT_PATH")" \ + --arg timestamp "$(jq -r '.pull_request.updated_at // (now | todateiso8601)' "$GITHUB_EVENT_PATH")" \ + --argjson merged "$(jq -r '.pull_request.merged // false' "$GITHUB_EVENT_PATH")" \ + '{token:$token,event_type:$event_type,action:$action,repository:$repository,pr_number:$pr_number,pr_url:$pr_url,pr_title:$pr_title,pr_body:$pr_body,branch:$branch,timestamp:$timestamp,merged:$merged}' \ + > payload.json + fi + + jq 'del(.token)' payload.json + + - name: Send to roadmap + env: + ROADMAP_WEBHOOK_URL: ${{ secrets.ROADMAP_WEBHOOK_URL }} + run: | + set -euo pipefail + test -n "$ROADMAP_WEBHOOK_URL" + curl --fail-with-body --silent --show-error \ + --request POST \ + --header 'Content-Type: application/json' \ + --data @payload.json \ + "$ROADMAP_WEBHOOK_URL" diff --git a/core/throttling.py b/core/throttling.py new file mode 100644 index 00000000..fc20abb1 --- /dev/null +++ b/core/throttling.py @@ -0,0 +1,14 @@ +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.settings import api_settings + + +class PostOnlyScopedRateThrottle(ScopedRateThrottle): + def get_rate(self): + if not getattr(self, "scope", None): + return None + return api_settings.DEFAULT_THROTTLE_RATES.get(self.scope) + + def allow_request(self, request, view): + if request.method != "POST": + return True + return super().allow_request(request, view) diff --git a/docs/api-safety-fix-plan.md b/docs/api-safety-fix-plan.md new file mode 100644 index 00000000..7e56d529 --- /dev/null +++ b/docs/api-safety-fix-plan.md @@ -0,0 +1,27 @@ +# API Safety Fix Plan: Scoped Throttling + +Дата: 2026-07-19. + +Implemented in this PR: + +- Added `PostOnlyScopedRateThrottle`, a small DRF throttle helper that applies scoped throttling only to `POST` requests. +- Added scoped throttling to public/user-facing high-risk endpoints: + - `POST /auth/users/` + - `POST /auth/resend_email/` + - `POST /auth/reset_password/` + - `POST /api/token/` + - `POST /programs//register_new/` +- Added env-configurable rates in `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` without enabling global `DEFAULT_THROTTLE_CLASSES`. +- Scoped rate keys are `auth_register`, `auth_resend_email`, `auth_reset_password`, `token_obtain`, and `program_register_new`. +- Added `.env.example` entries for the new rates. +- Added targeted tests that override each selected scope to `1/min` and assert the second request is throttled. + +Not changed in this PR: + +- No serializers, payload schemas, success response bodies, or existing business validation branches were changed. +- No production/dev GitHub Actions, deploy files, release process, nginx, or proxy configuration were changed. +- No global throttling was enabled for the rest of the API. + +Proxy/IP note: + +DRF throttles anonymous requests by client ident. The current backend has `SECURE_PROXY_SSL_HEADER`, but broader trusted proxy/IP handling must be coordinated with infrastructure before relying on IP throttles as an abuse boundary. Reverse proxy config should ensure client IP headers are overwritten by trusted infrastructure, not accepted from arbitrary clients. diff --git a/docs/application-project-submission-rfc.md b/docs/application-project-submission-rfc.md new file mode 100644 index 00000000..3a82867d --- /dev/null +++ b/docs/application-project-submission-rfc.md @@ -0,0 +1,674 @@ +# Application, Project and Submission RFC + +Статус: proposal. + +Этот документ описывает целевую доменную модель для React-архитектуры PROCOLLAB. PR является только документационным: он не меняет модели, миграции, serializers, views, endpoints, deploy, nginx, docker-compose или GitHub Actions. + +## Цель + +PROCOLLAB работает с кейс-чемпионатами, хакатонами, акселераторами и проектными активностями. В текущем backend уже есть `PartnerProgram`, `PartnerProgramUserProfile`, `PartnerProgramProject`, `Project` и `ProjectScore`, но несколько разных бизнес-понятий частично смешаны вокруг `Project`. + +Целевая модель должна развести: + +- `Application` - организационная сущность участия пользователя или команды в активности. +- `Project` - содержательная карточка проекта, портфолио или рабочая проектная сущность. +- `Submission` - конкретная сдача решения на активность или этап активности. +- `Team` - группа участников внутри активности. +- `Evaluation` / `Score` - оценка эксперта по конкретной сдаче. + +Главное правило: `Application` не равна `Project`, а `Submission` не равна ни `Application`, ни `Project`. + +## Проблема Текущей Модели + +Сейчас `Project` используется слишком широко: + +- как обычная карточка проекта в каталоге; +- как портфолио или рабочий проект пользователя; +- как содержательная часть участия в партнерской программе; +- как объект, который подается в программу через `PartnerProgramProject`; +- как объект оценки через `ProjectScore`; +- как часть регистрационного сценария через `PartnerProgramUserProfile.project`. + +Такой подход был удобен для раннего продукта, но он плохо масштабируется под React-архитектуру, где нужны отдельные пользовательские экраны: "Моя заявка", "Команда", "Сдачи", "Оценки", "Проект". + +Почему нельзя делать `Project = Application`: + +- заявка описывает факт участия в конкретной активности, а проект описывает содержание работы; +- заявка может быть без проекта, например регистрация слушателя, участника курса или индивидуального трека; +- один проект может быть использован в разных заявках и конкурсах; +- команда может подать заявку до создания полноценной проектной карточки; +- проект может появиться после сдачи решения, а не до регистрации; +- статусы заявки и статусы проекта живут в разных жизненных циклах; +- заявка содержит ответы формы регистрации, согласия, трек, роль, командный режим, а проект содержит описание идеи, цели, ресурсы, ссылки и достижения. + +Если заявка и проект останутся одной сущностью, появятся риски: + +- дубли проектов при повторных регистрациях; +- невозможность корректно показать "мою заявку" без создания лишнего проекта; +- смешение прав доступа: участник заявки не всегда должен редактировать проект; +- сложная миграция старого Angular flow; +- неоднозначная оценка: эксперт оценивает сдачу, а не всю проектную карточку навсегда; +- невозможность хранить несколько версий решения; +- сложные ограничения уникальности вокруг `user + program`, `team + program`, `application + stage`; +- дальнейший рост legacy-логики в `projects` и `partner_programs`. + +## Целевая Модель Сущностей + +### Program / PartnerProgram + +Назначение: активность, конкурс, акселератор, хакатон или программа, в которую пользователь может вступить, подать заявку, собрать команду, сдать решение и получить оценку. + +Когда создается: менеджером или администратором до старта активности. + +Владелец: менеджеры программы и администраторы платформы. + +Основные поля: + +- `id` +- `name` +- `tag` +- `description` +- `data_schema` или будущая схема формы +- `datetime_registration_ends` +- `datetime_project_submission_ends` +- `datetime_evaluation_ends` +- `datetime_started` +- `datetime_finished` +- `draft` +- настройки конкурсности, публикации проектов и доступности проектов + +Связи: + +- имеет много `Application`; +- имеет много `Team`; +- имеет много `Submission` через заявки; +- имеет много критериев оценки; +- legacy-связи сохраняются через `PartnerProgramUserProfile` и `PartnerProgramProject`. + +Статусы: сейчас ключевой флаг `draft`; в будущем можно добавить явные статусы `draft`, `published`, `registration_open`, `in_progress`, `evaluation`, `finished`, `archived`. + +Ограничения от дублей: + +- `tag` или slug должны быть уникальными, если используются как публичный идентификатор; +- legacy `PartnerProgramUserProfile` уже имеет `unique_together = (user, partner_program)`; +- legacy `PartnerProgramProject` уже имеет `unique_together = (partner_program, project)`. + +Будущие endpoints: + +- `GET /programs/` +- `GET /programs//` +- `GET /programs//applications/my/` +- `POST /programs//applications/` + +### Application + +Назначение: организационная сущность участия пользователя или команды в конкретной активности. Она отвечает на вопрос "кто и на каких условиях участвует в программе". + +Когда создается: при сохранении черновика заявки или при отправке заявки на участие. + +Владелец: `created_by`; для индивидуальной заявки также `user`; для командной заявки капитан команды или назначенный владелец заявки. + +Минимальный MVP-набор полей: + +- `id` +- `program` +- `user` +- `team`, nullable +- `created_by` +- `status` +- `form_data`, JSON +- `project`, nullable +- `submitted_at` +- `approved_at`, nullable +- `rejected_at`, nullable +- `withdrawn_at`, nullable +- `created_at` +- `updated_at` + +MVP implementation note: `team` is intentionally not added in the first +`Application` model PR because the `Team` model does not exist yet. Until the +Team PR lands, backend validation treats `Application` as an individual +application and requires `user`. + +Связи: + +- принадлежит одному `Program`; +- принадлежит одному `User` для индивидуального режима; +- может принадлежать одному `Team` для командного режима; +- может ссылаться на `Project`, но не обязана; +- имеет много `Submission`; +- может иметь итоговые решения менеджера или модератора. + +Статусы: + +- `draft` - черновик, еще не отправлен; +- `submitted` - отправлена на рассмотрение или зафиксирована как участие; +- `approved` - одобрена; +- `rejected` - отклонена; +- `withdrawn` - отозвана пользователем; +- `cancelled` - отменена системой или менеджером. + +Ограничения от дублей: + +- одна активная индивидуальная заявка на `user + program`; +- одна активная командная заявка на `team + program`; +- активными считать, например, `draft`, `submitted`, `approved`; +- `withdrawn`, `rejected`, `cancelled` могут разрешать повторную подачу только по явному правилу продукта; +- желательно иметь idempotency для создания заявки, чтобы повтор POST не создавал дубль. + +Будущие endpoints: + +- `GET /programs//applications/my/` +- `POST /programs//applications/` +- `GET /applications//` +- `PATCH /applications//` +- `POST /applications//withdraw/` + +### Application Подробно + +`Application` нужна для разделения регистрационного процесса и проектной сущности. + +Она хранит: + +- факт участия в активности; +- ответы регистрационной формы; +- индивидуальный или командный режим; +- статус прохождения заявки; +- привязку к проекту, если проект нужен для конкретной активности; +- историю ключевых timestamp-событий. + +Почему это не `Project`: + +- заявка может существовать без проекта; +- проект может быть создан раньше заявки как портфолио; +- проект может быть создан позже, после отбора или после сдачи; +- заявку можно отклонить, не удаляя проект; +- проект можно редактировать и переиспользовать вне конкретной активности; +- одна карточка проекта может участвовать в нескольких процессах, если это разрешено правилами. + +Индивидуальная заявка: + +- `user` заполнен; +- `team` пустой; +- `created_by` обычно равен `user`; +- uniqueness строится вокруг `user + program + active_status`. + +Командная заявка: + +- `team` заполнен; +- `user` может хранить капитана или основного участника, если это нужно для совместимости MVP; +- `created_by` - пользователь, который подал заявку; +- uniqueness строится вокруг `team + program + active_status`. + +Заявка с привязанным проектом: + +- `project` указывает на существующий или созданный в процессе заявки `Project`; +- заявка остается организационной оболочкой; +- проект остается содержательной карточкой. + +Заявка без проекта: + +- подходит для программ без проектной части, образовательных активностей, предварительной регистрации, индивидуальных треков; +- `project = null` не является ошибкой, если правила программы это разрешают. + +### Team + +Назначение: команда участников внутри конкретной активности. + +Когда создается: пользователем при командной подаче или менеджером программы. + +Владелец: капитан команды; административный доступ имеют менеджеры программы и staff. + +Основные поля: + +- `id` +- `program` +- `name` +- `captain` +- `status` +- `created_at` +- `updated_at` + +Связи: + +- принадлежит одному `Program`; +- имеет много `TeamMember`; +- может иметь одну активную `Application`; +- может быть связана с одним или несколькими `Project` только если это разрешено правилами активности. + +Статусы: + +- `draft` +- `active` +- `locked` +- `archived` + +Ограничения от дублей: + +- в рамках программы не должно быть нескольких активных команд с одним и тем же пользователем, если правила активности запрещают участие в нескольких командах; +- название команды можно ограничивать уникальностью в рамках программы, если оно используется публично. + +Будущие endpoints: + +- `POST /teams/` +- `GET /teams//` +- `PATCH /teams//` +- `POST /teams//invite/` + +### TeamMember + +Назначение: связь `Team` и `User` с ролью и статусом приглашения. + +Когда создается: при добавлении капитана, приглашении участника или принятии приглашения. + +Владелец: команда; действия обычно выполняют капитан, приглашенный пользователь или менеджер программы. + +Основные поля: + +- `id` +- `team` +- `user` +- `role` +- `status` +- `invited_by` +- `accepted_at` +- `created_at` +- `updated_at` + +Связи: + +- принадлежит одной `Team`; +- ссылается на одного `User`; +- через `Team` связан с `Program`. + +Роли: + +- `captain` +- `member` + +Статусы: + +- `invited` +- `accepted` +- `declined` +- `removed` + +Ограничения от дублей: + +- один пользователь не должен иметь два активных `TeamMember` в одной команде; +- один пользователь не должен быть в нескольких активных командах одной активности, если правила активности это запрещают; +- капитан должен быть участником своей команды со статусом `accepted`. + +Будущие endpoints: + +- `POST /teams//invite/` +- `POST /team-invites//accept/` +- `POST /team-invites//decline/` +- `DELETE /teams//members//` + +### Project + +Назначение: содержательная карточка проекта, портфолио или рабочая проектная сущность. + +Когда создается: пользователем отдельно в каталоге проектов, при подготовке заявки, при переносе результата активности в портфолио или в legacy flow подачи проекта. + +Владелец: лидер проекта; дополнительно могут быть collaborators. + +Основные поля в текущей модели уже включают: + +- `id` +- `name` +- `description` +- `region` +- `actuality` +- `target_audience` +- `problem` +- `trl` +- `industry` +- `presentation_address` +- `image_address` +- `cover_image_address` +- `leader` +- `draft` +- `is_public` +- `is_company` +- `datetime_created` +- `datetime_updated` + +Связи: + +- имеет `Collaborator`; +- имеет `ProjectLink`; +- имеет `Achievement`; +- имеет `ProjectGoal`; +- может быть связан с компаниями и ресурсами; +- legacy: связан с программами через `PartnerProgramProject`; +- target: может быть привязан к `Application` или создан из `Submission`. + +Статусы: + +- в текущей модели используются `draft` и `is_public`; +- в будущем можно добавить более явный жизненный цикл, но не в первом MVP PR. + +Ограничения от дублей: + +- `Project` не должен создаваться автоматически при каждой попытке регистрации; +- связь проекта с программой должна оставаться уникальной в рамках `program + project`; +- участник проекта уже защищен `UniqueConstraint(project, user)` в `Collaborator`. + +Будущие endpoints: + +- текущие `/projects/` endpoints сохранить; +- новые application/submission endpoints должны ссылаться на `Project`, а не заменять его; +- возможный будущий endpoint: `POST /submissions//create-project/`, если продукт решит создавать проект из финальной сдачи. + +Project не является заявкой. Он может быть: + +- портфолио-объектом пользователя; +- содержанием заявки; +- результатом активности; +- объектом, который можно подать на конкурс проектов; +- объектом, который появляется после `Submission`. + +### Submission + +Назначение: факт сдачи решения на конкретную активность или этап активности. + +Когда создается: участником, капитаном команды или уполномоченным пользователем при сохранении черновика или отправке решения. + +Владелец: `submitted_by`; доступ также имеют владелец заявки, команда, менеджеры программы и назначенные эксперты. + +Минимальные поля: + +- `id` +- `application` +- `program` +- `stage_key`, строковый ключ этапа +- `submitted_by` +- `title` +- `description` +- `form_data`, JSON +- `links`, JSON/list +- `status` +- `version` +- `submitted_at` +- `created_at` +- `updated_at` + +Связи: + +- принадлежит одной `Application`; +- денормализованно хранит `program` для простых выборок и прав доступа; +- может быть связана с файлами и ссылками; +- имеет много `Evaluation`. + +Статусы: + +- `draft` - черновик решения; +- `submitted` - отправлена; +- `returned` - возвращена на доработку; +- `final` - финальная версия зафиксирована. +- `cancelled` - сдача отменена. + +Ограничения от дублей: + +- уникальность `application + stage_key + version`; +- повторный POST должен быть идемпотентным или защищенным constraint-ами; +- нельзя создавать сдачу для отозванной, отклоненной или отмененной заявки, если правила программы это запрещают. + +Будущие endpoints: + +- `POST /applications//submissions/` +- `GET /applications//submissions/` +- `GET /submissions//` +- `PATCH /submissions//` +- `POST /submissions//submit/` + +Submission не является заявкой и не является проектом. Это событие или версия сдачи решения. Эксперт должен оценивать конкретную сдачу, а не абстрактный проект, который может измениться после дедлайна. + +MVP-заметка: отдельная модель `Stage` пока не создается, вместо нее используется +`stage_key="main"`. Модели `SubmissionFile` и `SubmissionLink` также отложены: +ссылки временно хранятся в `links` как JSON-массив, а полноценные этапы и файлы +будут добавлены отдельными PR. + +### SubmissionFile / SubmissionLink + +Назначение: вложения и ссылки, прикрепленные к конкретной сдаче. + +Когда создается: во время редактирования черновика или отправки `Submission`. + +Владелец: владелец `Submission`. + +Основные поля: + +- `id` +- `submission` +- `file` или `url` +- `title` +- `kind` +- `created_at` +- `updated_at` + +Связи: + +- принадлежит одной `Submission`; +- файл может ссылаться на существующий `UserFile`. + +Статусы: + +- обычно наследуются от `Submission`; +- отдельные статусы нужны только если появится асинхронная проверка файлов. + +Ограничения от дублей: + +- уникальность `submission + file`; +- уникальность `submission + url`, если ссылки хранятся отдельной таблицей; +- лимиты количества файлов и размера должны задаваться правилами программы. + +Будущие endpoints: + +- можно включить в `POST/PATCH /applications//submissions/`; +- отдельные endpoints понадобятся, если нужен независимый lifecycle файлов. + +### Evaluation / Score + +Назначение: оценка эксперта по конкретной сдаче. + +Когда создается: экспертом после назначения на проверку или менеджером при ручной модерации. + +Владелец: эксперт; менеджер программы может иметь административный доступ. + +Основные поля: + +- `id` +- `submission` +- `expert` +- `criteria` +- `score` или `value` +- `comment` +- `status` +- `created_at` +- `updated_at` + +Связи: + +- принадлежит одной `Submission`; +- ссылается на эксперта; +- ссылается на критерий программы; +- может агрегироваться в итоговый результат заявки или команды. + +Статусы: + +- `draft` +- `submitted` +- `revised` +- `cancelled` + +Ограничения от дублей: + +- уникальность `submission + expert + criteria`; +- если оценка хранится одним объектом на эксперта, then uniqueness `submission + expert`; +- текущий legacy `ProjectScore` уже защищает `criteria + user + project`, но target-модель должна оценивать `Submission`. + +Будущие endpoints: + +- `GET /expert/submissions/` +- `POST /submissions//evaluations/` +- `PATCH /evaluations//` + +### Certificate / Achievement + +Назначение: будущий слой результата участия: сертификат, достижение, бейдж, место или итоговый статус. + +Когда создается: после завершения активности, финальной оценки или ручного решения менеджера. + +Владелец: пользователь или команда; источник выдачи - программа. + +Основные поля: + +- `id` +- `program` +- `application` +- `user`, nullable +- `team`, nullable +- `project`, nullable +- `title` +- `kind` +- `status` +- `issued_at` +- `created_at` + +Связи: + +- может быть связан с `Application`; +- может быть связан с `Project`; +- может создавать или дополнять текущий `Achievement`. + +Статусы: + +- `draft` +- `issued` +- `revoked` + +Ограничения от дублей: + +- уникальность зависит от правил программы: например `application + kind` или `user + program + kind`. + +Будущие endpoints: + +- не входит в MVP; +- возможны `GET /applications//achievements/` и `GET /users/me/certificates/`. + +## Рекомендуемые DB Constraints + +Для будущих моделей стоит заложить ограничения на уровне БД, а не только во view/serializer validation: + +- одна активная индивидуальная заявка на `user + program`; +- одна активная командная заявка на `team + program`; +- один accepted `TeamMember` на `user + program`, если правила активности запрещают несколько команд; +- уникальность `TeamMember(team, user)` для активных/приглашенных связей; +- защита от дублей `Submission` по `application + stage + version` или другой выбранной версии бизнес-логики; +- защита от дублей `Evaluation` по `submission + expert + criteria`; +- уникальность `SubmissionFile(submission, file)` и `SubmissionLink(submission, url)`, если они вынесены в отдельные таблицы; +- legacy constraints `PartnerProgramUserProfile(user, partner_program)` и `PartnerProgramProject(partner_program, project)` не удалять до отдельной миграции. + +Для PostgreSQL желательно использовать partial unique constraints по активным статусам там, где soft lifecycle допускает повторную подачу после `withdrawn`, `rejected` или `cancelled`. + +## Endpoint Proposal + +MVP endpoints для React-архитектуры: + +- `GET /programs//applications/my/` - получить мою заявку в программе. +- `POST /programs//applications/` - создать или отправить заявку. +- `GET /applications//` - получить заявку. +- `PATCH /applications//` - обновить черновик или разрешенные поля. +- `POST /applications//withdraw/` - отозвать заявку. +- `POST /applications//submissions/` - создать или отправить сдачу. +- `GET /applications//submissions/` - список сдач по заявке. +- `POST /teams/` - создать команду. +- `POST /teams//invite/` - пригласить участника. +- `POST /team-invites//accept/` - принять приглашение. +- `GET /expert/submissions/` - список назначенных сдач для эксперта. +- `POST /submissions//evaluations/` - создать оценку по сдаче. + +Эти endpoints должны жить рядом с текущими `/programs/` и `/projects/`, а не заменять их в первом релизе. + +## Migration Strategy + +Миграция должна быть совместимой со старым Angular flow и текущими backend endpoints. + +Порядок: + +- не менять существующие `/programs//register/`, `/programs//register_new/`, `/programs//projects/apply/`, `/programs/partner-program-projects//submit/` сразу; +- сначала добавить новые модели рядом со старыми; +- оставить `PartnerProgramUserProfile` источником совместимости для старого участия; +- оставить `PartnerProgramProject` источником совместимости для старой связи программы и проекта; +- для React использовать новые application/submission endpoints; +- сделать чтение новых сущностей без изменения старых response shapes; +- добавить backfill/mapping только отдельным PR после согласования правил; +- старый `Project` не удалять и не переименовывать; +- старую связь `PartnerProgramProject` не ломать до отдельной миграции данных; +- production/dev deploy и GitHub Actions не менять в рамках доменной миграции. + +Возможная стратегия совместимости: + +- на первом этапе `Application` может ссылаться на legacy `PartnerProgramUserProfile`, если это нужно для сопоставления; +- новая заявка может опционально создавать или связывать `PartnerProgramUserProfile`, но только после отдельного design review; +- новая `Submission` может опционально ссылаться на legacy `PartnerProgramProject`, если это нужно для переходного периода; +- после стабилизации React flow можно подготовить отдельный migration RFC для удаления дублирующей логики. + +## MVP Implementation Plan + +PR 1: только этот RFC-документ. + +PR 2: `Application` model + migrations + admin + tests. Не менять старые endpoints. + +PR 3: API endpoints для `my application`, create/update draft, submit и withdraw. Добавить scoped throttling/idempotency для create/submit, если endpoint публичный или рискованный. + +PR 4: React frontend "Моя заявка" на новых endpoints. + +PR 5: `Submission` model + API. Описать versioning и constraints до реализации. + +PR 6: React submission page. + +PR 7: `Team` и `TeamMember` model + API. Сначала минимальный командный сценарий, затем invitations. + +PR 8: expert cabinet/evaluation на `Submission`, без расширения legacy `ProjectScore` сверх совместимости. + +## What Not To Change + +В ближайших PR нельзя: + +- делать `Project = Application`; +- переименовывать текущие `/programs/` endpoints без миграции; +- ломать `PartnerProgramProject`; +- ломать `PartnerProgramUserProfile`; +- менять старый Angular flow в одном PR; +- менять production deploy; +- менять dev deploy; +- менять GitHub Actions; +- делать глобальный рефакторинг всех проектов; +- удалять или переименовывать legacy `Project`; +- менять serializers/views/models без отдельного согласования в документационном PR. + +## Open Questions + +- Должна ли `Application` создаваться сразу как `draft`, или первый MVP создает только `submitted`? +- Может ли пользователь иметь повторную заявку после `rejected` или `withdrawn`, и нужна ли история предыдущих заявок? +- Нужен ли `Application.user` для командных заявок, или достаточно `team + created_by`? +- Должен ли `Project` создаваться внутри заявки, выбираться из существующих проектов или оба сценария нужны сразу? +- Нужны ли этапы программы как отдельная модель `Stage` до реализации `Submission`? +- Оценивает ли эксперт один критерий за раз или отправляет полный набор критериев одной оценочной формой? +- Какой объект считается источником итогового результата: `Application`, последняя `Submission`, агрегированная `Evaluation` или отдельный `Result`? + +## Recommended Next PR + +Следующий PR: `Application` model + migrations + admin + model tests. + +Приоритеты: + +- P0: зафиксировать модель `Application`, статусы, ownership и uniqueness для активной заявки. +- P0: добавить тесты на запрет дублей `user + program` и `team + program`. +- P1: добавить admin для чтения и ручной диагностики заявок. +- P1: подготовить mapping к legacy `PartnerProgramUserProfile`, но не включать destructive migration. +- P2: описать будущую модель `Stage` перед реализацией `Submission`, если продукту нужны этапы. diff --git a/docs/application-team-model.md b/docs/application-team-model.md new file mode 100644 index 00000000..44f48bd8 --- /dev/null +++ b/docs/application-team-model.md @@ -0,0 +1,136 @@ +# Application Team Model + +## Назначение + +Базовый слой командных заявок разделяет три понятия: + +- `Application` — заявка на одну партнерскую программу; +- `Team` — команда, собранная только для этой заявки; +- `TeamMember` — членство пользователя в команде заявки. + +Транзакционный Application/Team service создает командный draft вместе с Team и +accepted-капитаном и проверяет полный invariant перед submit. Публичный Team API +дает ролевой read-доступ, rename/leave/remove/transfer. Отдельный TeamInvite API +добавляет нового участника только после его явного accept. + +## Participation mode + +В `Application` добавлено поле `participation_mode`: + +| Значение | Смысл | +|---|---| +| `undecided` | Формат участия еще не выбран | +| `individual` | Индивидуальная заявка | +| `team` | Командная заявка | + +Runtime default временно равен `individual`: API принимает поле, но старые +клиенты его не передают, поэтому текущие и исторические Application продолжают +работать как индивидуальные. `undecided` должен стать default одновременно с +wizard и запретом submit заявки без выбранного формата. + +Миграция `0020_team_application_participation_mode_teammember_and_more` +добавляет поле с default `individual`; Django применяет это значение ко всем +существующим строкам Application. + +## Team + +`Team` содержит: + +- one-to-one `application` с `related_name="team"`; +- необязательное на стадии черновика `name` длиной до 255 символов; +- `captain`; +- `created_at` и `updated_at`. + +Team относится к Application, потому что состав команды может различаться в +разных активностях даже при использовании одного Project. Существующий +`projects.Collaborator` описывает постоянного участника Project и намеренно не +переиспользуется как TeamMember. + +В первом MVP `Team.captain` обязан совпадать с `Application.user`. +`Team.status`, invite code и token не добавлены. Размеры хранятся в Program +policy, а редактируемость Team выводится из `Application.status`. + +## TeamMember + +`TeamMember` содержит: + +- `team` и `user`; +- роль `captain` или `member`; +- статус `invited`, `accepted`, `declined`, `removed` или `left`; +- nullable `invited_by`; +- nullable `joined_at`; +- `created_at` и `updated_at`. + +Для первоначального captain member `invited_by` остается `null`: капитан не +принимает собственное приглашение, а создается как владелец команды. Статус +`invited` в TeamMember остается исторической модельной заготовкой и не является +источником истины приглашения. Pending/accepted/declined/revoked lifecycle +хранится отдельно в `TeamInvite`; только accept создает accepted TeamMember. + +При первом сохранении статуса `accepted` модель автоматически заполняет +`joined_at`, если дата не передана. При переходе в `removed` или `left` дата не +очищается и сохраняет момент фактического присоединения. + +## Database constraints + +На уровне БД обеспечены: + +- одна Team на Application через `OneToOneField`; +- уникальность `TeamMember(team, user)`; +- не более одного `accepted` TeamMember с ролью `captain` в Team; +- check: роль `captain` допустима только со статусом `accepted`. + +Простым constraint одной таблицы нельзя надежно обеспечить: + +- участие пользователя в нескольких Team разных Application одной Program; +- конфликт TeamMember с индивидуальной Application той же Program; +- Registration всех членов команды; +- размер команды; +- блокировку состава после submit. + +Эти правила проходят через Application, Team и TeamMember, поэтому требуют +отдельного транзакционного domain service с блокировкой строк. + +## Model validation + +`Team.clean()` проверяет: + +- `Application.participation_mode == team`; +- `Team.captain == Application.user`. + +Это запрещает Team для `individual` и `undecided` Application. + +`TeamMember.clean()` проверяет: + +- captain member имеет статус `accepted`; +- его `user` совпадает с `Team.captain`; +- для первого принятия заполнен `joined_at`. + +Наличие captain TeamMember намеренно не проверяется при первом `Team.save()`. +Такая проверка создала бы цикл: Team должна быть сохранена до создания +TeamMember, а TeamMember уже требует сохраненную Team. + +## Текущий технический порядок создания + +Domain service выполняет последовательность атомарно: + +1. создать или обновить Application с `participation_mode=team`; +2. создать Team с captain, совпадающим с `Application.user`; +3. создать accepted TeamMember с `role=captain` и тем же пользователем. + +Клиенты используют существующий Application create endpoint с +`participation_mode=team` и необязательным `team_name`. При ошибке service +откатывает все три шага. После создания детали доступны через +`GET/PATCH /applications//team/`; остальные операции описаны в +`docs/team-permissions-api.md`. + +## Вне текущего MVP + +Следующие PR должны добавить: + +- email/token/link приглашения и expiry; +- email и внутренние уведомления для TeamInvite; +- frontend wizard и вкладку команды. + +Legacy Application withdraw, Project/`projects.Collaborator`, +`invites.Invite` и Submission flow этим service не изменяются. diff --git a/docs/application-team-service.md b/docs/application-team-service.md new file mode 100644 index 00000000..67b9eaab --- /dev/null +++ b/docs/application-team-service.md @@ -0,0 +1,143 @@ +# Application Team Service + +## Назначение + +`partner_programs.services.application_team` — транзакционный domain service +для индивидуальных и командных `Application`. Он отделяет бизнес-правила от +DRF views и не зависит от HTTP response. + +Service предоставляет четыре публичные операции: + +- `create_or_get_application()`; +- `change_application_participation_mode()`; +- `validate_team_invariants()`; +- `submit_application()`. + +Управление уже созданной Team вынесено в соседний +`partner_programs.services.team_management`: `rename_team()`, `leave_team()`, +`remove_team_member()` и `transfer_team_captain()`. Оба service используют одну +проверку cross-table конфликта под блокировкой Program. + +## Доменные ошибки + +Все ожидаемые отказы наследуются от `ApplicationTeamServiceError` и содержат +стабильные `code`, `detail` и `field`. Views преобразуют их в DRF +`ValidationError`, но сам service не импортирует DRF. + +Основные коды: `registration_required`, `application_deadline_passed`, +`participation_mode_not_allowed`, `participation_mode_undecided`, +`active_application_conflict`, `team_required`, `team_not_allowed`, +`team_has_other_members`, `captain_member_missing`, `captain_mismatch`, +`team_size_invalid`, `team_member_registration_missing` и +`application_not_editable`. + +## Создание Application + +`create_or_get_application()` выполняет в одной транзакции: + +1. блокирует строку `PartnerProgram`; +2. проверяет `PartnerProgramUserProfile` владельца; +3. проверяет `datetime_application_ends` без fallback на legacy-дедлайны; +4. валидирует формат по Program policy; +5. ищет активную собственную Application и accepted-членство в другой Team; +6. создает individual/undecided draft либо team draft; +7. для team атомарно создает `Team` и accepted captain `TeamMember`. + +Повторный совместимый запрос возвращает существующую Application с +`created=False`. Отличающийся формат, form data, Project или Team name не +перезаписывает существующий draft скрытым образом и дает конфликт. + +`undecided` допустим для draft и не создает Team. Старый API-запрос без +`participation_mode` преобразуется view в `individual`, поэтому существующий +frontend сохраняет прежний формат. + +## Смена формата + +`change_application_participation_mode()` доступен владельцу или staff для +draft до application deadline: + +- `undecided → individual` меняет только поле; +- `undecided/individual → team` создает Team и капитана; +- `individual → undecided` разрешен, пока Team отсутствует; +- `team → individual` удаляет Team только при наличии единственной записи + accepted-капитана; +- `team → undecided` запрещен, чтобы не удалять состав неявно; +- повторный `team → team` может изменить `team_name`. + +PATCH Application вызывает эту операцию до сохранения остальных полей в общей +транзакции. Serializer намеренно не записывает `participation_mode` и +`team_name` напрямую. + +## Конфликт активного участия + +Активными остаются `draft`, `submitted` и `approved`. Для пользователя service +учитывает обе роли: + +- `Application.user`; +- accepted `TeamMember` связанной активной Application той же Program. + +Текущая Application исключается при change/submit. Терминальные Application и +членство в другой Program не блокируют новую заявку. + +Строка Program блокируется через `select_for_update()` как общая точка +сериализации, после чего блокируются найденные Application/TeamMember. Это +закрывает гонки между операциями, которые проходят через service. Существующая +partial unique constraint дополнительно защищает две собственные активные +Application. + +Cross-table invariant нельзя выразить обычным UniqueConstraint. Team transfer +использует ту же проверку под общей блокировкой Program; прямые записи через +admin/model и TeamInvite accept также обязаны проходить domain service. + +## Полный Team invariant + +`validate_team_invariants()` ничего не изменяет и проверяет: + +- Team существует только для `participation_mode=team`; +- `Team.application` и капитан согласованы с Application; +- есть ровно один accepted captain member; +- все accepted-участники имеют Registration этой Program; +- accepted-состав находится между `team_min_size` и `team_max_size`. + +Капитан входит в размер команды. `invited`, `declined`, `removed` и `left` +сохраняют историю, но не считаются участниками при submit. + +## Submit + +`submit_application()` сохраняет текущий owner/staff access contract. Staff не +обходит Registration, Program policy, deadline или Team invariant. + +Для draft операция проверяет Registration владельца, application deadline, +финальный формат, cross-table конфликты и Team invariant, затем атомарно +устанавливает `submitted` и `submitted_at`. + +Повторный submit уже отправленной Application идемпотентен: он возвращает +текущую запись, не меняет `submitted_at` и не падает из-за дедлайна, который +истек после первой отправки. + +## API contract + +Application routes сохраняют прежние поля и действия: + +- `POST /programs//applications/` принимает необязательные + `participation_mode` и write-only `team_name`; +- отсутствие mode означает `individual`; +- response содержит `participation_mode` и компактный read-only `team` summary, + но не раскрывает полный список TeamMember; +- `PATCH /applications//` проводит mode/team name через service; +- `POST /applications//submit/` вызывает транзакционный submit service. + +Публичный Team API отдельно описан в `docs/team-permissions-api.md`, а lifecycle +приглашений — в `docs/team-invites-api.md`. Прямого добавления accepted-member +по `user_id` нет: членство появляется только через accept TeamInvite. + +## Ограничения concurrency и MVP + +Production-база с row-level locking сериализует service-операции одной Program. +SQLite не реализует полноценный `select_for_update`, поэтому автоматический +тест проверяет устойчивую последовательную конфликтную операцию без threading. + +Отдельный TeamInvite service управляет приглашениями и принятием участников, +используя ту же Program-lock и проверку cross-table конфликтов. Прямое +редактирование моделей через admin по-прежнему может обходить services; +returned Application и organizer review отсутствуют. diff --git a/docs/backend-postgres-ci.md b/docs/backend-postgres-ci.md new file mode 100644 index 00000000..26ce36bf --- /dev/null +++ b/docs/backend-postgres-ci.md @@ -0,0 +1,122 @@ +# Backend PostgreSQL CI + + + +Workflow `Backend PostgreSQL CI` проверяет backend на настоящем PostgreSQL, а не +на SQLite. Это важно для транзакционных блокировок, partial constraints и +поведения `select_for_update`, которые SQLite не воспроизводит. + +## Когда запускается + +Workflow запускается: + +- для pull request в `master`; +- для push в `master`; +- вручную через `workflow_dispatch`. + +Имя обязательной проверки: `Backend PostgreSQL CI`. + +## Что проверяется + +Job поднимает одноразовый PostgreSQL 15 и последовательно выполняет: + +1. установку зависимостей из существующего `poetry.lock`; +2. preflight-проверку подключения, backend `postgresql` и поддержки + `select_for_update`; +3. `manage.py check`; +4. `manage.py check --tag models`; +5. `makemigrations --check --dry-run`; +6. применение всех миграций; +7. Black и flake8 для `procollab/settings_ci.py`; +8. targeted-тесты блокировок и ограничений Evaluation, Application и Team; +9. полный набор backend-тестов. + +Любая ошибка останавливает job. Ослабляющие флаги, пропуск ошибок и fake-миграции +не используются. + +Полный suite запускается с `--keepdb`, чтобы завершившиеся WebSocket- и +асинхронные тесты не мешали Django вернуть результат из-за удаления базы внутри +того же Python-процесса. Это не переиспользование базы между job: каждый job +получает новый PostgreSQL container, а тестовая база создается заново. После +остановки тестового процесса обязательный шаг с `if: always()` завершает +оставшиеся подключения и явно удаляет `test_procollab_ci`. Ошибки тестов при +этом не скрываются и сохраняют job красным. + +Первый полный прогон на PostgreSQL выявил попытку блокировать nullable-сторону +outer join в team/invite-сервисах. Lock-query сохраняют `select_related` для +загрузки графа, но используют `select_for_update(of=("self",))`, чтобы +блокировать только строку основной модели. Порядок ключей JSONB не считается +контрактом: тесты проверяют состав и значения JSON-полей независимо от порядка. + +## Изоляция + +`procollab.settings_ci` подключается только через переменную +`DJANGO_SETTINGS_MODULE`. Он не меняет обычный `procollab.settings` и не +используется deployment-контурами. + +В CI применяются: + +- одноразовая база `procollab_ci` и тестовая база `test_procollab_ci`; +- `LocMemCache` вместо Redis; +- `InMemoryChannelLayer` вместо Redis Channels; +- локальный email backend; +- синхронное выполнение Celery-задач с пробросом исключений; +- быстрый MD5 password hasher; +- отключенный React dev demo seed. + +Логин и пароль PostgreSQL находятся прямо в workflow, потому что относятся +только к одноразовому service container. Это не production/dev credentials и не +секреты инфраструктуры. + +## Переменные окружения + +Workflow задает: + +- `DJANGO_SETTINGS_MODULE=procollab.settings_ci`; +- `DJANGO_SECRET_KEY` с тестовым значением; +- `DEBUG=False`; +- `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_HOST`, + `DATABASE_PORT`; +- `ALLOW_REACT_DEV_DEMO_SEED=False`; +- `AUTOPOSTING_ON=False`. + +## Локальный запуск + +Локальные проверки следует запускать только с отдельным одноразовым PostgreSQL. +Нельзя направлять `settings_ci` на существующую dev- или production-базу. + +Пример для PowerShell после запуска отдельного PostgreSQL: + +```powershell +$env:DJANGO_SETTINGS_MODULE = "procollab.settings_ci" +$env:DJANGO_SECRET_KEY = "local-ci-only-secret" +$env:DEBUG = "False" +$env:DATABASE_NAME = "procollab_ci" +$env:DATABASE_USER = "procollab_ci" +$env:DATABASE_PASSWORD = "procollab_ci_password" +$env:DATABASE_HOST = "127.0.0.1" +$env:DATABASE_PORT = "5432" +$env:ALLOW_REACT_DEV_DEMO_SEED = "False" +$env:AUTOPOSTING_ON = "False" + +poetry run python manage.py check +poetry run python manage.py migrate --noinput +poetry run python manage.py test --verbosity 1 +``` + +После проверки одноразовую базу или контейнер нужно удалить. + +## Типовые ошибки + +- `connection refused`: PostgreSQL еще не готов или указан неверный порт. +- `password authentication failed`: переменные пользователя и пароля не + совпадают с настройками одноразового PostgreSQL. +- `database does not exist`: не создана исходная база `procollab_ci`. +- `makemigrations --check` предлагает миграцию: модели и миграции расходятся. +- preflight сообщает не `postgresql`: выбран неверный settings module или + переопределены параметры базы. +- тест блокировок зависает или завершается по timeout: следует проверить порядок + транзакционных блокировок и конкурентные изменения, а не отключать тест. + +Workflow не выполняет deployment, не подключается к dev/production-сервисам и не +запускает команды на существующих базах. diff --git a/docs/backend-react-dev-autodeploy.md b/docs/backend-react-dev-autodeploy.md new file mode 100644 index 00000000..34cf9c13 --- /dev/null +++ b/docs/backend-react-dev-autodeploy.md @@ -0,0 +1,191 @@ +# Backend React-dev autodeploy + + + +## Назначение + +DEV-075 автоматически доставляет backend в отдельный React-dev контур после +успешного PostgreSQL CI: + +```text +push master → Backend PostgreSQL CI → Backend React-dev deploy +``` + +Deploy запускается только для успешного `push` в `master`. Проверка pull request +не запускает deploy, поэтому непроверенный или еще не объединенный commit не +попадает на сервер. + +## Exact SHA + +Workflow получает SHA из завершившегося `Backend PostgreSQL CI`, проверяет формат +из 40 lowercase hexadecimal символов и выполняет checkout именно этого commit. +После checkout GitHub Actions собирает Docker image, публикует его в GHCR с +revision label, равным tested SHA, и фиксирует образ по immutable `sha256` digest. +Перед SSH фактический `git rev-parse HEAD` сравнивается с SHA успешного CI. На +сервер передаются проверенные SHA, GitHub Actions run ID и digest image. + +Серверный script выполняет `git fetch origin master --prune`, проверяет наличие +commit и его принадлежность текущему `origin/master`. `git pull` не используется. +Если `origin/master` уже указывает на более новый commit, deploy признается +устаревшим и завершается без изменения code или containers: следующий workflow +доставит актуальную версию. + +## GitHub Secrets и SSH + +Workflow использует только следующие GitHub Secrets: + +- `REACT_DEV_SSH_HOST`; +- `REACT_DEV_SSH_PORT`; +- `REACT_DEV_SSH_USER`; +- `REACT_DEV_SSH_PRIVATE_KEY`; +- `REACT_DEV_SSH_KNOWN_HOSTS`. + +Значения не хранятся в репозитории и не передаются в `.env`. Private key и +`known_hosts` записываются во временный `~/.ssh` runner с правами `600`; +Windows CRLF удаляются. SSH использует `BatchMode`, `IdentitiesOnly`, +`StrictHostKeyChecking=yes` и отдельный `UserKnownHostsFile`. + +`REACT_DEV_SSH_KNOWN_HOSTS` должен содержать заранее проверенный pinned host key. +Workflow намеренно не использует `ssh-keyscan` и не принимает новый ключ +автоматически. + +Для публикации image используется стандартный `GITHUB_TOKEN` с минимальными +permissions `contents: read` и `packages: write`. Дополнительный registry secret +на React-dev не передается: сервер скачивает тот же GHCR package +`ghcr.io/procollab-github/api`, который уже используется release pipeline. + +## Изоляция + +Единственный разрешенный repository: + +```text +/root/api-react-dev +``` + +Script проверяет точный `realpath`, наличие `.git` и origin +`PROCOLLAB-github/api`. Старый backend в `/root/api`, production containers, +production database, nginx и production URL не используются. + +Tracked staged/unstaged изменения блокируют deploy. Untracked и ignored файлы не +удаляются, `git clean` не выполняется, серверный `.env` сохраняется. + +## Docker Compose + +Repository `docker-compose.yml` является legacy и запрещен для автодеплоя. +Script находит уже работающий `web` container по labels: + +- `com.docker.compose.project=api-react`; +- `com.docker.compose.service=web`. + +Фактические Compose services `web`, `celerys` и `redis` подтверждены по labels +React-dev сервера, включая `com.docker.compose.service=celerys`. + +Из labels читаются Compose project, working directory и config files. Все пути +проверяются через `realpath`: working directory должен совпадать с +`/root/api-react-dev`, каждый config file должен находиться внутри этого каталога, +а legacy `docker-compose.yml` отклоняется. + +Compose-команда собирается как Bash array без `eval`. До любых изменений +containers проверяется наличие точных сервисов `web`, `celerys` и `redis`. Иное +имя Celery service не угадывается: deploy завершается с явной ошибкой. + +## Порядок deploy + +1. Получение deployment lock через `flock`. +2. Проверка repository, origin, git state и stale deploy. +3. Сохранение предыдущих SHA, container IDs, image IDs и image references. +4. Сборка backend image в GitHub Actions и публикация в GHCR по SHA-тегу. +5. Загрузка image на React-dev по immutable digest и проверка revision label. +6. Переназначение существующих Compose image references без server-side build. +7. `python manage.py check` во временном container нового `web` image без TTY + и без доступа к stdin deploy-скрипта. +8. `python manage.py migrate --noinput` с существующим React-dev `.env`, также без TTY + и с stdin, подключенным к `/dev/null`. +9. Пересоздание только `web` и `celerys` с явным запретом server-side build. +10. Проверка image ID, running state и публичный HTTPS health-check. + +React-dev сервер больше не устанавливает Python-зависимости и не обращается к +PyPI во время deploy. Доступ к PyPI требуется только GitHub-hosted runner на +стадии сборки image. + +Redis, database и nginx не пересоздаются. `docker compose down`, prune-команды и +удаление старых images не выполняются. + +## Health-check + +Проверяется только: + +```text +https://api-react-dev.procollab.ru/programs/?limit=1 +``` + +Успех требует одновременно: + +- HTTP `200` без следования redirect; +- `Content-Type` с `application/json`; +- непустой body; +- отсутствие HTML/SPA fallback; +- корректный JSON; +- running state `web` и `celerys`. + +Используются ограниченные connect/total timeout и восемь попыток с паузой. +Полный API response в лог не выводится. + +## Rollback + +При ошибке pull, проверки image, Django check или migration работающие containers не +пересоздаются; repository и image references возвращаются к предыдущему +состоянию. + +Если ошибка возникла после начала пересоздания containers или на health-check, +script: + +1. возвращает предыдущий code SHA; +2. возвращает предыдущие image IDs на сохраненные image references; +3. пересоздает только `web` и `celerys`; +4. повторяет ограниченный React-dev health-check; +5. сообщает результат rollback; +6. завершает deploy с ошибкой даже при успешном rollback. + +Redis не затрагивается. Ошибка rollback выводится отдельно и не маскирует +исходную ошибку. + +Rollback возвращает code и containers, но **не откатывает уже примененные +миграции автоматически**. Новые миграции обязаны быть backward-compatible с +предыдущим image, чтобы предыдущая версия могла работать после container +rollback. + +## Concurrency + +GitHub Actions использует одну общую concurrency group с +`cancel-in-progress: false`: новый deploy ждет завершения текущего. На сервере +дополнительно действует `flock` с ограниченным временем ожидания. + +## Типовые ошибки + +- отсутствует один из GitHub Secrets; +- pinned host key не совпадает с ключом сервера; +- SSH недоступен или запрещает key authentication; +- repository имеет tracked изменения или неверный origin; +- workflow устарел относительно текущего `origin/master`; +- Compose labels отсутствуют или указывают вне React-dev; +- сервисы называются не `web`, `celerys`, `redis`; +- GitHub Actions не смог собрать или опубликовать image; +- React-dev не смог скачать digest из GHCR; +- revision label image не совпал с tested commit; +- Django check или migration завершились ошибкой; +- containers не перешли в running state; +- HTTPS endpoint вернул redirect, HTML, не-JSON или статус не `200`; +- rollback не смог восстановить containers или health-check. + +Workflow не подключается к production, не изменяет старый backend и не управляет +nginx. После первого успешного deploy нужно вручную проверить React-dev API и +основной React-dev пользовательский сценарий в браузере. + +После merge и первого подтвержденного deploy PR можно дополнить маркером +`Roadmap-Complete: DEV-075`. До этого используются: + +```text +Roadmap-IDs: DEV-075 +Roadmap-Partial: DEV-075 +``` diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md new file mode 100644 index 00000000..af3de5f0 --- /dev/null +++ b/docs/case-championship-domain.md @@ -0,0 +1,830 @@ +# Домен кейс-чемпионатов PROCOLLAB + +Статус: целевая доменная модель и аудит текущей реализации backend. + +Актуальность аудита: после добавления базовых моделей Team и TeamMember, +21 июля 2026 года. + +Документ развивает `docs/application-project-submission-rfc.md` с учетом уже +реализованных `Application`, `Team`, `TeamMember` и `Submission`. Он не является +описанием готового контракта для еще не существующих приглашений, оценки и +результата. + +## 1. Назначение документа + +Документ фиксирует целевую модель полного сценария кейс-чемпионата и отделяет +ее от legacy-сценария подачи проекта в партнерскую программу: + +```text +Program + → Registration + → Application + → Team / Individual participation + → Submission + → Evaluation + → Result + → Project reuse +``` + +Ключевое разделение понятий: + +- `Registration` означает присоединение пользователя к активности; +- `Application` описывает участие пользователя или команды в одной активности; +- `Project` является переиспользуемой карточкой проекта; +- `Submission` фиксирует конкретную версию решения по конкретной заявке; +- `Evaluation` относится к зафиксированной сдаче, а не к изменяемому проекту. + +В текущем коде `Program` называется `PartnerProgram`, а роль регистрации +выполняет `PartnerProgramUserProfile`. Имена `Application`, `Project` и +`Submission` уже соответствуют целевой терминологии. + +## 2. Основные сущности + +### Program / Activity + +**Назначение.** Активность, программа, хакатон или кейс-чемпионат, внутри +которого действуют регистрация, заявки, команды, сдачи и оценки. + +**Связи.** Имеет регистрации, заявки и сдачи; связывается с менеджерами, +экспертами, курсами, материалами и legacy-проектами. + +**Что хранит.** Описание и изображения, даты начала и завершения, дедлайн +регистрации, отдельный дедлайн Application, legacy-дедлайн подачи проектов и +дедлайн оценки, схему регистрационных данных, настройки конкурсности, +доступности проектов, формата участия и размера команды. + +**Что не должна хранить.** Ответы конкретного участника, состав команды, +решение или оценку конкретной работы. + +**Текущее состояние.** Реализована как `partner_programs.PartnerProgram`. +Формат участия, минимальный/максимальный размер команды и отдельный дедлайн +Application хранятся в модели, защищены validation/constraints и применяются +Application/team service. Отдельный дедлайн решения пока отсутствует. + +### Registration + +**Назначение.** Факт присоединения пользователя к активности. Дает доступ к +материалам и включает активность в раздел «Мои активности». + +**Связи.** Соединяет `User` и `PartnerProgram`; сейчас также может ссылаться на +legacy `Project`. + +**Что хранит.** Пользователя, программу, JSON регистрационной формы +`partner_program_data`, необязательный legacy-проект и даты создания/изменения. + +**Что не должна хранить.** Статус заявки, формат участия, состав команды, +сдачу решения и оценку. Поле `project` в текущей модели является legacy-связью, +а не частью целевой Registration. + +**Текущее состояние.** Отдельной модели `Registration` нет. Ее фактический +аналог — `partner_programs.PartnerProgramUserProfile`. Ограничение +`unique_together(user, partner_program)` не позволяет обычному пользователю +зарегистрироваться в одной программе дважды. + +### Application + +**Назначение.** Заявка одного владельца или команды на участие в одной +активности. + +**Связи.** Принадлежит одному `PartnerProgram`, имеет владельца и автора, +может ссылаться на один `Project`, а также имеет много `Submission`. + +**Что хранит.** Ответы формы конкретной программы (`form_data`), статус, +связь с проектом и timestamps ключевых решений. + +**Что не должна хранить.** Изменяемое содержимое проекта вместо snapshot, +историю версий решения, экспертные оценки или постоянный состав команды +проекта. + +**Текущее состояние.** Реализована как `partner_programs.Application`. +`user` и `created_by` обязательны для текущего MVP. Поле `participation_mode` +поддерживает `undecided/individual/team`, но runtime default временно остается +`individual`. Application create/PATCH принимают поле и проводят изменение +через транзакционный domain service. + +### Team + +**Назначение.** Команда, собранная для одной конкретной заявки. + +**Связи.** Принадлежит ровно одной `Application`, имеет капитана и участников. + +**Что хранит.** Заявку, название, капитана, при необходимости собственный +статус формирования, даты создания и изменения. + +**Что не должна хранить.** Ответы заявки, данные решения, постоянных +collaborators проекта и состояние email-приглашений. `invite_code` допустим +только как временный MVP-механизм; предпочтительнее отдельная `TeamInvite`. + +**Текущее состояние.** Реализована как `partner_programs.Team` с one-to-one +Application, названием, капитаном и timestamps. Транзакционный service создает +Team вместе с accepted-капитаном и проверяет invariant перед submit. Публичный +API дает ролевое чтение, rename/leave/remove/transfer без прямого добавления +участника. `projects.Collaborator` не является заменой: он связан с +долгоживущим `Project`, а не с заявкой на одну программу. + +### TeamMember + +**Назначение.** Членство пользователя в команде заявки с ролью и состоянием. + +**Связи.** Принадлежит одной `Team`, ссылается на `User` и на пригласившего +пользователя. + +**Что хранит.** `team`, `user`, `role`, `status`, `invited_by`, `joined_at`, +`created_at`, `updated_at`. + +**Что не должна хранить.** Профиль проекта, ответы заявки или независимый +статус самой заявки. + +**Текущее состояние.** Реализована как `partner_programs.TeamMember` с ролями, +пятью статусами, `invited_by` и `joined_at`. `projects.Collaborator` продолжает +хранить только участников проекта. Cross-Application конфликт проверяется при +create/submit/transfer и accept TeamInvite. + +### TeamInvite + +**Назначение.** Приглашение в команду заявки для пользователя платформы, +email-адресата или владельца ссылки. + +**Связи.** Принадлежит `Team`, ссылается на пригласившего пользователя и, +когда он известен, на приглашенного пользователя. + +**Что хранит.** Токен, срок действия, статус, `email` nullable, `user` +nullable, `invited_by` и timestamps. + +**Что не должна хранить.** Членство как факт: только принятое приглашение +делает пользователя участником команды. + +**Текущее состояние.** Реализован platform-user MVP: `TeamInvite` связан с +Team, обязательными `user`/`invited_by`, имеет статусы pending/accepted/ +declined/revoked, историю, admin и create/my/accept/decline/revoke API. Email, +token, link, expiry и notifications отсутствуют. Имеющаяся `invites.Invite` +по-прежнему предназначена исключительно для приглашения в `Project`. + +### Project + +**Назначение.** Долгоживущая и переиспользуемая карточка проектной идеи или +результата, которая может существовать до, во время и после активности. + +**Связи.** Имеет лидера, `Collaborator`, ссылки, достижения, цели, компании и +ресурсы. Может быть связан с несколькими заявками через +`Application.project`; legacy-связь с программами хранится в +`PartnerProgramProject`. + +**Что хранит.** Название и описание проекта, проблему, целевую аудиторию, +отрасль, TRL, презентацию, публичность, изображения и долгосрочные данные +проекта. + +**Что не должен хранить.** Статус заявки, ответы формы отдельной программы, +команду заявки, версию сдачи или оценку неизменяемого решения. + +**Текущее состояние.** Реализован как `projects.Project`. Участники проекта +хранятся в `projects.Collaborator`; `Application.project` уже допускает +переиспользование одного проекта в нескольких заявках. + +### Submission + +**Назначение.** Конкретная сдача решения по конкретной заявке и этапу. + +**Связи.** Принадлежит `Application`, денормализованно ссылается на ее +`PartnerProgram`, имеет автора сдачи и в будущем — оценки и файлы. + +**Что хранит.** Название, описание, `form_data`, JSON-список `links`, статус, +`stage_key`, номер версии, дату отправки и audit timestamps. + +**Что не должна хранить.** Саму заявку, постоянную карточку проекта, команду +проекта или итоговую экспертную оценку как одно изменяемое поле. + +**Текущее состояние.** Реализована как `partner_programs.Submission`. +Несколько версий уже поддерживаются отдельными строками с уникальностью +`application + stage_key + version`; отдельные `Stage`, файлы и оценки +отсутствуют. + +### Evaluation + +**Назначение.** Оценка конкретной `Submission` конкретным экспертом. + +**Связи.** Принадлежит сдаче и эксперту, содержит оценки по критериям и может +агрегироваться в результат заявки. + +**Что хранит.** Баллы по критериям, комментарий, итоговый балл, статус и +timestamps. + +**Что не должна хранить.** Изменяемую копию проекта или общий рейтинг проекта +вне контекста сдачи. + +**Текущее состояние.** Целевой сущности нет. Legacy-модуль `project_rates` +содержит `Criteria`, `ProjectScore` и `ProjectExpertAssignment`, но оценивает +`Project`, а не `Submission`. У `ProjectScore` нет собственного lifecycle и +комментария; уникальность задана по `criteria + user + project`. + +### Notification + +**Назначение.** Доставляемое и сохраняемое уведомление о приглашении, решении +организатора, возврате заявки или результате. + +**Связи.** Ссылается на получателя и доменный объект или событие. + +**Что хранит.** Тип, получателя, payload/ссылку, дату создания, состояние +прочтения и, при необходимости, статус доставки. + +**Что не должна хранить.** Источник истины о членстве, статусе заявки или +приглашения. + +**Текущее состояние.** Доменной модели уведомлений нет. В проекте есть +email-инфраструктура и WebSocket consumer `NotificationConsumer` в модуле +чатов, но они не заменяют persistent-уведомления о TeamInvite. + +## 3. Ключевые продуктовые правила + +1. **Регистрация не создает заявку автоматически.** Текущий + `register_user_to_program()` соблюдает это правило. +2. **Заявка относится только к одной активности.** Это обеспечено + `Application.program`. +3. **Заявка не переиспользуется между программами.** Перенос Application между + программами запрещен serializer-ом; `program` read-only. +4. **Проект можно использовать в нескольких заявках.** Текущая связь + `Application.project` не имеет запрета на повторное использование. +5. **Проект является источником данных, но не становится заявкой.** Модели уже + разделены; автоматического создания Project из Application нет. +6. **Отправленная заявка не меняется вслед за Project.** `form_data` хранится + отдельно, но полноценный `project_snapshot` отсутствует. Потребители не + должны считать актуальные поля связанного Project частью отправленной + заявки. +7. **Команда относится к конкретной заявке.** Это обеспечено one-to-one связью + `Team.application`. +8. **Команда проекта и команда заявки могут различаться.** Team реализована + отдельно от `projects.Collaborator`. +9. **Пользователь участвует только в одной активной заявке одной программы.** + Сейчас правило обеспечено только для `Application.user`; будущие члены + команд этим constraint не покрыты. +10. **В MVP заявку и решение отправляет только капитан.** Captain уже хранится + в Team и совпадает с `Application.user`, но существующие endpoints еще не + используют Team-aware permissions. +11. **Членами команды считаются только принятые участники.** Это правило должно + опираться на `TeamMember.status = accepted`, а не на наличие TeamInvite. +12. **Submission относится к конкретной Application.** Уже обеспечено FK и + проверкой совпадения `Submission.program` с `Application.program`. +13. **Результат Submission можно сохранить как Project или новую версию + Project.** Это будущий явный пользовательский сценарий; автоматического + преобразования быть не должно. + +## 4. Жизненный цикл регистрации + +Целевой flow: + +1. Пользователь вызывает `POST /programs//register/`. +2. Backend проверяет `datetime_registration_ends` и отсутствие дублирующей + регистрации. +3. Создается `PartnerProgramUserProfile` — фактическая Registration. +4. Программа появляется в `GET /programs/?participating=1`, пока ее + `datetime_finished` не наступил. +5. Application не создается автоматически. +6. Пользователь отдельно выбирает «Создать заявку» и вызывает + `POST /programs//applications/` при первом сохранении. + +Текущая реализация выполняет шаги 1–6. Application create требует существующий +`PartnerProgramUserProfile`, проверяет отдельный application-дедлайн и не +создает Registration автоматически. Поэтому источником `participating=1` +по-прежнему остается отдельный registration flow. + +Регистрационные ответы сейчас находятся в +`PartnerProgramUserProfile.partner_program_data`, а ответы новой заявки — в +`Application.form_data`. До изменения форм необходимо явно определить, какие +поля относятся к присоединению к активности, а какие — к заявке, чтобы не +появилось два неявно конкурирующих источника истины. + +## 5. Жизненный цикл Application + +### Целевые статусы + +| Статус | Значение | +|---|---| +| `draft` | Черновик, доступный для заполнения | +| `submitted` | Заявка отправлена и зафиксирована | +| `returned` | Заявка возвращена организатором на доработку | +| `approved` | Заявка одобрена | +| `rejected` | Заявка отклонена | +| `withdrawn` | Заявка отозвана владельцем/капитаном | +| `cancelled` | Заявка отменена организатором или системой | + +### Целевая таблица переходов + +| Исходный статус | Действие | Новый статус | Кто | Основные проверки | +|---|---|---|---|---| +| — | Создать черновик | `draft` | Зарегистрированный пользователь | Регистрация открыта; нет другой активной заявки пользователя в Program | +| `draft` | Сохранить | `draft` | Владелец/капитан | Дедлайн не истек; изменяются только разрешенные поля | +| `draft` | Отправить | `submitted` | Владелец/капитан | Registration существует; форма валидна; `participation_mode != undecided`; команда допустимого размера; дедлайн не истек | +| `submitted` | Повторить submit | `submitted` | Владелец/капитан | Идемпотентный ответ без изменения `submitted_at` | +| `submitted` | Вернуть на доработку | `returned` | Организатор | Причина возврата сохранена; программа допускает доработку | +| `returned` | Возобновить редактирование | `draft` | Владелец/капитан | Дедлайн/специальный срок доработки не истек | +| `submitted` | Одобрить | `approved` | Организатор | Проверены заявка и состав команды; заполняется `approved_at` | +| `submitted` | Отклонить | `rejected` | Организатор | Сохраняется причина; заполняется `rejected_at` | +| `draft`, `submitted`, `returned` | Отозвать | `withdrawn` | Владелец/капитан | Отзыв разрешен правилами и сроком; заполняется `withdrawn_at` | +| `approved` | Отозвать | `withdrawn` | Владелец/капитан | Только если политика программы явно разрешает отзыв после допуска | +| Любой нетерминальный | Отменить | `cancelled` | Организатор/система | Сохраняются причина и actor; участник не может подменить отмену отзывом | + +`rejected`, `withdrawn` и `cancelled` считаются терминальными для конкретной +Application. Возможность создать после них новую заявку должна быть явной +политикой программы; текущий backend такую повторную заявку разрешает. + +### Текущая реализация и отличия + +- В `Application.STATUS_CHOICES` есть `draft`, `submitted`, `approved`, + `rejected`, `withdrawn`, `cancelled`; `returned` отсутствует. +- Активными считаются `draft`, `submitted`, `approved`. +- `PATCH /applications//` разрешен владельцу только для `draft`. +- `POST /applications//submit/` реализует идемпотентный переход + `draft → submitted` и заполняет `submitted_at`. +- `POST /applications//withdraw/` разрешает отзыв из `draft`, + `submitted` и `approved`, повторный отзыв идемпотентен. +- Endpoint-ов return, approve, reject и cancel нет. Поля `approved_at` и + `rejected_at` существуют, но новый API их не заполняет. +- Staff может читать, submit и withdraw любую Application. Обычный менеджер + программы без `is_staff` в новую permission-модель не включен. +- Создание и submit проверяют Registration, application-дедлайн, Program policy + и cross-table конфликты; submit командной заявки также проверяет полный Team + invariant. Withdraw пока сохраняет прежний contract без этих проверок. + +## 6. Формат участия + +В `Application` реализовано поле `participation_mode`: + +| Значение | Смысл | +|---|---| +| `undecided` | Формат еще не выбран; черновик можно сохранять, отправлять нельзя | +| `individual` | Индивидуальная заявка; Team отсутствует | +| `team` | Командная заявка; перед submit обязательны Team и капитан | + +Правила: + +- формат выбирается при создании Application или позже в черновике; +- `undecided` допустим только до submit; +- `individual` не должна иметь Team; +- `team` перед submit должна иметь Team и accepted-капитана; +- менять формат можно только в `draft` и `returned`; +- после `submitted` формат фиксируется; +- переход `team → individual` должен быть запрещен, пока существует команда с + участниками или необработанными приглашениями, либо выполняться отдельной + подтверждаемой операцией; +- переход `individual → team` должен создавать Team явным действием, а не + побочным эффектом чтения Application. + +Сейчас runtime default равен `individual` для обратной совместимости. Serializer +и endpoints специально не изменены; выбор `undecided/team` станет частью +отдельного API/UI PR вместе с submit validation. + +## 7. Команда заявки + +### Целевая Team + +Минимальные поля: + +- `application` — one-to-one связь с Application; +- `name`; +- `captain` — пользователь, совпадающий с владельцем командной заявки; +- `status` — только если у команды появится самостоятельный lifecycle; +- `created_at`; +- `updated_at`. + +Для первого MVP состояние редактируемости безопаснее выводить из +`Application.status`, а не дублировать его в `Team.status`. Если отдельный +статус все же нужен, сначала следует зафиксировать его переходы и правила +синхронизации. `invite_code` не следует добавлять в Team вместе с будущей +`TeamInvite`, чтобы не получить два источника истины. + +### Целевая TeamMember + +Минимальные поля: + +- `team`; +- `user`; +- `role`: `captain` или `member`; +- `status`: `invited`, `accepted`, `declined`, `removed`, `left`; +- `invited_by`; +- `joined_at`; +- `created_at`; +- `updated_at`. + +### Ограничения + +- одна Team на одну Application; +- капитан обязательно представлен accepted-записью TeamMember с ролью + `captain`; +- только один accepted-капитан допустим в одной Team; +- один пользователь не может иметь дублирующиеся членства в одной Team; +- пользователь не может одновременно участвовать в двух активных Application + одной Program, включая конфликт индивидуальной заявки и TeamMember; +- в минимальном/максимальном размере учитываются только accepted-участники; +- капитан не может уйти, пока не передал роль или не расформировал команду; +- перед submit все accepted-участники должны иметь Registration этой Program; +- после `submitted` состав команды блокируется в MVP; после `returned` он может + редактироваться только по явно разрешенному правилу. + +Часть ограничений нельзя надежно выразить одним Django `UniqueConstraint`, +потому что активность и статус Application находятся через связи +`TeamMember → Team → Application`. Проверки должны выполняться в транзакционном +domain service с блокировкой затрагиваемых строк. На уровне БД все равно нужны +простые ограничения `OneToOne(Team.application)`, uniqueness `team + user`, +условная уникальность капитана и check `captain → accepted`. + +Базовые Team и TeamMember реализуют простые DB constraints и model validation. +Полный invariant наличия captain member намеренно не проверяется при первом +`Team.save()` из-за циклического порядка создания. Domain service атомарно +создает капитана и повторно проверяет полный invariant перед submit; будущие +операции управления составом обязаны использовать те же проверки. + +## 8. Приглашения + +Будущая `TeamInvite` должна поддерживать три канала: + +- приглашение существующего пользователя платформы (`user` заполнен); +- приглашение по email (`email` заполнен, `user` может появиться позже); +- приглашение по ссылке с непредсказуемым token. + +Минимальные поля: + +- `team`; +- `token` с уникальностью и безопасной генерацией; +- `expires_at`; +- `invited_by`; +- `email`, nullable; +- `user`, nullable; +- `status`; +- `created_at`, `updated_at`, а для решения — `responded_at`. + +Статусы: `pending`, `accepted`, `declined`, `expired`, `revoked`. + +Принятие должно быть идемпотентным и атомарно: + +1. блокировать TeamInvite; +2. проверять срок, статус, Registration и отсутствие участия в другой активной + заявке той же Program; +3. создавать или переводить TeamMember в `accepted`; +4. менять TeamInvite на `accepted`. + +Pending TeamInvite не учитывается в размере команды. Если состояния +`invited/declined` одновременно хранятся в TeamMember, service обязан +синхронизировать обе записи; перед реализацией предпочтительно выбрать один +источник истины для приглашения. + +Email и внутренние уведомления намеренно не входят в первый PR моделей команды. +Текущую project-specific `invites.Invite` нельзя расширять неявно: ее contract +и permission-логика обслуживают `Project` и старый Angular flow. + +## 9. Связь Application и Project + +`Application.project` остается nullable. Project можно использовать для: + +- выбора существующей проектной идеи; +- явного предварительного заполнения form_data; +- повторного использования одной идеи в разных программах; +- дальнейшего сохранения результата активности. + +Application должна независимо хранить: + +- `form_data` и program-specific ответы; +- `participation_mode`; +- Team; +- статус и timestamps; +- выбранный трек/кейс; +- согласия. + +Текущая связь уже реализована. `ApplicationSerializer` разрешает выбрать +только Project, лидером которого является текущий пользователь, и запрещает +менять связь после выхода заявки из `draft`. Один Project может быть связан с +несколькими Application. + +Связь сама по себе не фиксирует данные Project. Будущий `project_snapshot` +нужен, чтобы организатор видел состояние источника на момент submit, даже если +Project позже изменен, скрыт или удален. Snapshot должен создаваться при +первом успешном submit и не обновляться автоматически. До submit обновление из +Project допускается только как явное действие с понятным перечнем +перезаписываемых полей. + +`project_snapshot` не обязателен для первого PR моделей Team. Но до запуска +рассмотрения реальных заявок нужно определить его схему, версионирование и +поведение при `Project.on_delete=SET_NULL`. + +Legacy-связи сохраняются отдельно: + +- `PartnerProgramUserProfile.project` связывает регистрацию со старым flow; +- `PartnerProgramProject` связывает Project с Program и хранит + `submitted/datetime_submitted`; +- `PartnerProgramFieldValue` хранит ответы старой формы подачи проекта. + +Они не должны автоматически создаваться или изменяться новым Application API +без отдельной миграционной стратегии. + +## 10. Жизненный цикл Submission + +Текущие и целевые статусы совпадают: + +| Статус | Значение | Редактирование сейчас | +|---|---|---| +| `draft` | Черновик решения | Разрешено владельцу Application/staff | +| `submitted` | Решение отправлено | Запрещено | +| `returned` | Возвращено на доработку | Разрешено | +| `final` | Финальная версия зафиксирована | Запрещено | +| `cancelled` | Решение отменено | Запрещено | + +Правила: + +- Submission всегда относится к Application; +- создавать Submission можно только при допустимом статусе Application; +- доступ наследуется от участия в Application, но в Team MVP отправляет только + капитан; +- редактировать можно только `draft/returned` и до дедлайна; +- отправленные решения остаются читаемыми после дедлайна; +- повторный submit уже отправленной версии должен быть идемпотентным; +- return и final выполняются организатором/экспертом по отдельным действиям; +- отмена владельцем допустима только для `draft/returned`, если продукт не + зафиксирует другое правило. + +Текущий API: + +- `GET/POST /applications//submissions/`; +- `GET/PATCH /submissions//`; +- `POST /submissions//submit/`; +- `POST /submissions//cancel/`. + +Создание разрешено только для Application в `submitted` или `approved`, +заполняет `program` из заявки и `submitted_by` из request user. Версия по +умолчанию вычисляется как следующая для `application + stage_key`; список +сортируется от новых к старым. Дубликат версии возвращает validation error. + +Текущие ограничения: + +- нет проверки дедлайна; +- accepted TeamMember и manager имеют read-only доступ; expert permissions нет; +- нет действий `return` и `finalize`; +- Application status повторно проверяется при создании, но не при PATCH, + submit или cancel существующей Submission; +- несколько версий уже технически реализованы, но нет правила выбора текущей + версии, фиксации оцениваемой версии и создания новой версии после return; +- `stage_key` есть, отдельной модели этапа нет; +- файлы отсутствуют, ссылки временно хранятся JSON-массивом. + +## 11. Evaluation + +Целевая Evaluation описывается концептуально: + +- `submission`; +- `expert`; +- баллы по критериям; +- комментарий; +- итоговый балл; +- статус (`draft`, `submitted`, при необходимости `revised/cancelled`); +- audit timestamps. + +До проектирования модели нужно решить, является ли одна Evaluation полной +формой одного эксперта или отдельной записью на каждый критерий. В первом +варианте требуется уникальность `submission + expert`, во втором — +`submission + expert + criterion`. + +Существующий `ProjectScore` нельзя считать Evaluation: он связан с изменяемым +Project. `ProjectExpertAssignment` распределяет между экспертами legacy-проекты, +а не Submission. Этот механизм должен продолжать работать для старого flow до +отдельной миграции. + +Evaluation, экспертный кабинет, агрегирование баллов и публикация Result не +входят в ближайший этап Team. + +## 12. Права ролей + +Таблица описывает целевые права. «Редактировать» относится к Application или +Submission в редактируемом статусе; чтение закрытых материалов требует +Registration. + +| Роль | Просматривать | Редактировать | Приглашать | Отправлять | Отзывать | Оценивать | +|---|---|---|---|---|---|---| +| Незарегистрированный в Program пользователь | Публичную карточку Program | Нет | Нет | Нет | Нет | Нет | +| Зарегистрированный пользователь без Application | Карточку и материалы Program | Может создать собственный draft | Нет | Нет | Нет | Нет | +| Владелец индивидуальной заявки | Свою Application и Submission | Свои `draft/returned` | Нет | Свою заявку и решение | Свою заявку по правилам | Нет | +| Капитан команды | Заявку, Team и Submission | Заявку, Team и свои `draft/returned` Submission | Да, до блокировки состава | Заявку и решение | Заявку по правилам | Нет | +| Accepted-участник команды | Заявку, Team и Submission | Нет в MVP | Нет | Нет | Может покинуть Team до submit, но не отзывает Application | Нет | +| Организатор/менеджер Program | Все заявки, команды, сдачи и результаты своей Program | Review-поля и разрешенные административные действия | Может помогать по отдельному permission | Возвращает/одобряет/отклоняет/финализирует, но не подменяет капитана | Отменяет, а не выполняет пользовательский withdraw | Не обязан; может администрировать оценки | +| Эксперт | Только назначенные отправленные/финальные Submission | Только свой черновик Evaluation | Нет | Отправляет Evaluation | Нет | Да | + +Текущие различия: + +- капитан совпадает с `Application.user` и сохраняет owner mutation-права; +- accepted TeamMember и `PartnerProgram.managers` читают Application, Team и + Submission, но не выполняют participant mutations; +- приглашенные и исторические TeamMember не получают read-доступ; +- эксперт работает только с legacy Project через `project_rates`; +- staff может выполнять submit/withdraw Application и создавать/изменять + Submission, что является административной возможностью текущего MVP, а не + окончательной ролевой моделью. + +## 13. Текущее состояние реализации + +### Сводный audit + +| Область | Уже реализовано | Частично | Отсутствует | Комментарий | +|---|---|---|---|---| +| Registration | `PartnerProgramUserProfile`, register endpoints, `participating=1`, deadline и uniqueness | Статус Registration и связь с новым flow | Отдельная модель/явный contract Registration | Application create/submit требуют существующий профиль, но не создают его | +| Program participation policy | Форматы `individual_only/team_only/individual_or_team`, размеры Team, application deadline, model/DB validation, admin и service enforcement | Program API намеренно не расширен | Policy UI | Default `individual_only`, размеры и deadline существующих программ остаются `null` | +| Application model | Program, user, created_by, participation_mode, form_data, nullable Project, timestamps, partial unique constraint | Ownership по-прежнему опирается на Application.user | returned, snapshot, review reason | Default participation_mode временно individual | +| Application API | create/my/detail/patch/submit/withdraw, mode/team service, Registration/deadline/policy/conflict checks, idempotency и scoped throttle | Accepted member/manager read-only | review endpoints | Старый create без mode остается individual; my application учитывает accepted TeamMember | +| Application statuses | Шесть статусов и timestamps submit/approve/reject/withdraw | Переходы draft/submit/withdraw | `returned`, return/approve/reject/cancel actions | approved/rejected могут появиться только вне нового API, например через admin | +| Submission model | Поля MVP, пять целевых статусов, version/stage constraints, model validation | JSON links вместо отдельной модели | Files, Evaluation, Stage | Program согласуется с Application | +| Submission API | list/create/detail/patch/submit/cancel, owner/staff mutations, accepted member/manager read-only, version allocation, throttling | Нет expert access | return/finalize, deadlines | Создание только для submitted/approved Application | +| Project model | Полноценная карточка, лидер, collaborators, links, цели, компании, ресурсы | Lifecycle через `draft/is_public` | Project version/snapshot | Project остается независимым от Application | +| Application → Project | Nullable FK, reuse, owner validation, immutable после draft | Ручная связь | Prefill mapping и `project_snapshot` | Автоматически Project не создается | +| Team | One-to-one Application, name, captain, timestamps, validation/admin, creation/invariant/management services и public API | Add member только через TeamInvite accept | Email/link invite integration | Mutation только draft до application deadline | +| TeamMember | Roles/statuses, invited_by, joined_at, constraints, validation, admin, read/leave/remove/transfer | invited — только историческая заготовка | Organizer actions | Не переиспользует Project Collaborator | +| TeamInvite | Platform-user model/admin, scoped candidate search, history, create/my/accept/decline/revoke API, conflicts/capacity/locking/throttle | Только существующий `user_id` | Token/email/link/expiry/notifications | Search не резервирует место; pending не дает membership или read-доступ | +| Notification | Email и chat WebSocket infrastructure | Mailing logs не являются inbox | Доменная Notification и пользовательский центр | Не входит в первый Team PR | +| Evaluation | `Criteria`, `ProjectScore`, `ProjectExpertAssignment` для legacy Project | Эксперты и распределенное оценивание проекта | Evaluation по Submission | Нельзя смешивать с ProjectScore без миграции | +| Result | Legacy scores и пользовательские достижения существуют отдельно | Нет единого результата заявки | Result/ranking/publication contract | Требует решения об источнике итогов | +| Deadlines | Registration, отдельный Application deadline с create/mode/submit enforcement и legacy project submission/evaluation dates | Withdraw/form-only PATCH не используют application deadline | Submission checks и отдельный solution deadline | Application deadline не имеет fallback на legacy-поля | +| Permissions | Единые owner/captain/accepted member/manager/staff helpers для Application/Team/Submission | Staff имеет расширенный доступ | Expert/review permissions | Историческое membership не дает read-доступ | +| Constraints | Registration uniqueness; active owner Application; Team/Submission DB constraints; service проверяет cross-table участие, Registration и team size | Прямые model/admin записи обходят service | DB constraint для cross-table участия невозможен | Program row lock сериализует service-операции; SQLite test проверяет последовательный конфликт | +| Admin | PartnerProgram, Registration, Application, Team, TeamMember, TeamInvite, Submission, Project, Invite и legacy evaluation зарегистрированы | Admin позволяет ручную диагностику | Evaluation admin | Admin не заменяет transition services | +| Tests | Model/service/permission/API tests Application/Team/TeamMember/TeamInvite/Submission и regression legacy flow | Нет notification flow | Evaluation/result tests нового flow | TeamInvite accept проверяется вместе с rollback и access boundary | + +### Актуальные domain endpoints + +| Область | Endpoint | Текущее назначение | +|---|---|---| +| Program | `GET /programs/`, `GET /programs//` | Список и детали активности | +| Registration | `POST /programs//register/` | Создать PartnerProgramUserProfile текущего пользователя | +| Registration legacy | `POST /programs//register_new/` | Создать/найти пользователя и зарегистрировать из внешней формы | +| Application | `GET /programs//applications/my/` | Вернуть собственную либо accepted-командную Application; 404, если ее нет | +| Application | `POST /programs//applications/` | Создать draft или вернуть существующую активную Application | +| Application | `GET/PATCH /applications//` | Прочитать или изменить draft | +| Application | `POST /applications//submit/` | Отправить draft | +| Application | `POST /applications//withdraw/` | Отозвать допустимую Application | +| Team | `GET/PATCH /applications//team/` | Прочитать Team или изменить ее название | +| Team | `POST /applications//team/leave/` | Покинуть draft Team обычному accepted member | +| Team | `POST /applications//team/members//remove/` | Исключить обычного member капитаном/staff | +| Team | `POST /applications//team/transfer-captain/` | Атомарно передать капитанство и ownership Application | +| TeamInvite | `GET /applications//team/invite-candidates/?q=...` | Найти до 20 зарегистрированных и потенциально доступных кандидатов без раскрытия email | +| TeamInvite | `GET/POST /applications//team/invites/` | История и создание platform-user приглашения капитаном/staff | +| TeamInvite | `GET /team-invites/my/` | Приглашения текущего пользователя, pending первыми | +| TeamInvite | `POST /team-invites//accept/` | Принять приглашение и создать/восстановить accepted TeamMember | +| TeamInvite | `POST /team-invites//decline/` | Отклонить свое pending-приглашение | +| TeamInvite | `POST /team-invites//revoke/` | Отозвать pending-приглашение капитаном/staff | +| Submission | `GET/POST /applications//submissions/` | Список версий или новый draft Submission | +| Submission | `GET/PATCH /submissions//` | Прочитать или изменить draft/returned | +| Submission | `POST /submissions//submit/` | Отправить Submission | +| Submission | `POST /submissions//cancel/` | Отменить draft/returned | +| Legacy Project flow | `POST /programs//projects/apply/` | Создать и подать отдельный Project в программу | +| Legacy Project flow | `POST /programs/partner-program-projects//submit/` | Зафиксировать сдачу PartnerProgramProject | +| Legacy evaluation | `GET/POST /rate-project/...` | Список/оценка Project, не Submission | + +Новые serializers закрепляют immutable-поля. `ApplicationSerializer` принимает +`form_data` и `project/project_id`, а `SubmissionSerializer` на create принимает +`title`, `description`, `form_data`, `links`, `stage_key`, `version`; на update +— только первые четыре содержательных поля. + +### Admin и тесты + +- `ApplicationAdmin` показывает Program, owner/creator, status, Project и даты; +- `TeamAdmin` и `TeamMemberAdmin` показывают Program, капитана, роли, статусы и + даты командного слоя; +- `TeamInviteAdmin` показывает Team, Program, адресата, отправителя, статус и + даты приглашения; +- `SubmissionAdmin` показывает Application, Program, submitter, status, + `stage_key`, version и даты; +- legacy admin продолжает обслуживать Registration, PartnerProgramProject, + Project, Collaborator, Invite, Criteria, ProjectScore и назначения экспертов; +- Application tests покрывают uniqueness, ownership, immutable fields, + idempotency, project ownership и throttle; +- Submission tests покрывают доступ, допустимые статусы Application, + автозаполнение Program/submitter, версии, immutable fields, переходы и + throttle; +- TeamInvite candidate tests покрывают scoped search, eligibility, безопасный + response, permission/state boundaries, limit и отдельный throttle; +- registration и legacy project tests отдельно покрывают их дедлайны и права. + +## 14. Gap analysis + +| Требование | Текущее состояние | Требуемое изменение | Приоритет | Рекомендуемый PR | +|---|---|---|---|---| +| Явный формат участия | Поле/API и безопасный default `individual` реализованы | Подключить UI и позднее default `undecided` | P0 | Application participation wizard | +| Team и TeamMember | Модели, admin, constraints, services, permissions и public API реализованы | Добавить organizer policy | P1 | Application review API | +| Только зарегистрированный создает Application | Проверяется create/submit service | Поддержать те же правила в будущих organizer actions | P1 | Application review API | +| Одна активная заявка на пользователя с учетом Team | Service проверяет owner и accepted membership под Program lock | Все будущие Team member actions обязаны использовать проверку | P0 | Team permissions/API | +| Валидация команды перед submit | Проверяются captain, Registration, accepted-состав и Program size | Добавить form-schema validation | P1 | Application form validation | +| Captain-only actions | Team-aware permissions, transfer и platform-user invites реализованы | Подключить review actions к тем же helpers | P1 | Application review API | +| Team API | Read/rename/leave/remove/transfer и invite lifecycle реализованы | Добавить notification delivery | P2 | Invite notifications | +| Полный lifecycle Application | Нет returned/review actions | Добавить return/approve/reject/cancel с reason/audit | P1 | Application review API | +| TeamInvite | Platform-user lifecycle и идемпотентный accept реализованы | Добавить token/email/link/expiry без смешения с Project Invite | P2 | Extended TeamInvite delivery | +| Application deadlines | Create/mode/submit и Team mutations проверяют отдельный deadline | Решить policy для form-only PATCH/withdraw | P1 | Application lifecycle hardening | +| Submission deadlines и роли | Team/manager read-only реализован, deadline/expert отсутствуют | Добавить deadline и expert policy | P1 | Submission lifecycle hardening | +| Return/finalize Submission | Статусы есть, действий нет | Организаторские endpoints и audit fields/reasons | P1 | Submission review API | +| Semantics версий Submission | Несколько записей и auto-next уже есть | Определить current/final/evaluated version и поведение after return | P1 | Submission version workflow | +| Предзаполнение из Project | Только nullable FK | Явный mapping/copy в Application.form_data | P1 | Application project prefill | +| Неизменяемый Project snapshot | Отсутствует | Создавать snapshot на submit | P1 | Project snapshot | +| Evaluation по Submission | Legacy ProjectScore | Новая модель и API без поломки project_rates | P1 | Submission evaluation | +| Email/in-app приглашения | Нет Team notification flow | Подключить после стабильного TeamInvite | P2 | Team invite notifications | +| Result и сохранение в Project | Нет единого flow | Result contract, ручное create/update/version Project | P2 | Results and project reuse | +| Актуальная модульная документация | RFC есть, module doc не перечисляет новый API | После стабилизации синхронизировать domain/API docs | P2 | Documentation sync | + +## 15. Рекомендуемый порядок реализации + +1. **`feature/application-team-model` — реализовано.** Добавлены + `participation_mode`, Team и TeamMember с миграцией, admin, модельными + ограничениями и тестами; без API. +2. **`feature/program-participation-policy` — реализовано.** Добавлены + разрешенные форматы, минимальный/максимальный размер команды и отдельный + дедлайн Application; enforcement в API намеренно не включен. +3. **`feature/application-team-service` — реализовано.** Транзакционный service + проверяет Registration, deadline, Program policy, конфликты активного + участия, капитана и accepted-состав при create/mode change/submit. +4. **`feature/team-permissions-api` — реализовано.** Добавлены + owner/captain/member/manager/staff permissions, чтение Team, rename, + leave/remove/transfer и read-only доступ к Application/Submission. +5. **Application review API.** Добавить `returned`, return, approve, reject и + cancel с reason и timestamps; не смешивать с admin-редактированием. +6. **`feature/team-invites-api` — реализовано.** Добавлены platform-user + TeamInvite, accept/decline/revoke, блокировки, конфликты и capacity policy. +7. **Invite notifications.** Отдельно добавить email/token/link/expiry и + подключить email и persistent in-app + notifications с retry/idempotency. +8. **Frontend participation wizard.** Выбор individual/team/later и создание + draft только по явному сохранению. +9. **Frontend Team tab.** Состав, accepted/pending состояния, приглашения, + передача капитанства и блокировка после submit. +10. **Application Project prefill.** Сохранить уже существующую FK, добавить + явное копирование выбранных полей Project в form_data. +11. **Project snapshot.** Фиксировать согласованный snapshot при submit и + отображать его организатору. +12. **Submission lifecycle hardening.** Добавить captain/manager/expert rights, + дедлайн, return/finalize и четкое правило выбора версии. Само поле version + и DB constraint уже существуют, поэтому повторно «добавлять версии» не + требуется. +13. **Evaluation.** Добавить assignment/evaluation для Submission и постепенно + отделить новый flow от legacy `ProjectScore`. +14. **Result and Project reuse.** Зафиксировать публикацию результата и только + по подтверждению пользователя создавать Project или новую версию Project. + +Каждый PR должен содержать собственные model/API tests и не менять legacy +endpoints без отдельной миграционной задачи. + +## 16. Реализованный базовый coding PR + +Ветка: `feature/application-team-model`. + +Статус исторического PR: базовый слой данных реализован без service. В +последующих PR транзакционный creation/invariant service подключен к +Application create/PATCH/submit, затем добавлен отдельный Team permissions/API +без TeamInvite. + +### Точный scope + +1. Добавить в `Application` поле `participation_mode` с choices + `undecided/individual/team`. +2. Сохранить `Application.user` обязательным и трактовать его как + владельца/капитана в первом Team MVP. Это сохраняет текущие query и + permissions до отдельного Team API PR. +3. Для существующих Application выполнить data migration в `individual`. + Пока current API не принимает и не валидирует новый выбор, безопасный + runtime default также должен оставаться `individual`; переход default на + `undecided` следует делать вместе с API/UI и запретом submit undecided. +4. Добавить `Team` с one-to-one `application`, `name`, `captain`, + `created_at`, `updated_at`. В первом PR не добавлять `invite_code`. +5. Не добавлять `Team.status`, пока не определен самостоятельный lifecycle: + блокировку состава в MVP выводить из `Application.status`. +6. Добавить `TeamMember` с `team`, `user`, `role`, `status`, `invited_by`, + `joined_at`, `created_at`, `updated_at` и указанными выше choices. +7. Добавить DB constraints: + - одна Team на Application через OneToOne; + - уникальность `team + user`; + - не более одного accepted TeamMember с ролью captain на Team; + - captain-role допустима только со статусом accepted. +8. Добавить model validation: + - Team допустима только для `participation_mode = team`; + - `Team.captain` совпадает с `Application.user` в первом MVP; + - captain TeamMember совпадает с `Team.captain`; + - `individual` Application не может иметь Team. +9. Не требовать наличия captain TeamMember непосредственно при первом + `Team.save()`: это создает циклическую последовательность создания. Полный + invariant теперь проверяется после атомарного создания Team + captain member + в domain service и обязательно перед submit Application. +10. Зарегистрировать Team и TeamMember в Django admin с фильтрами по Program, + статусу и роли. +11. Добавлены model tests для choices, one-to-one, uniqueness и validation. + Существующие строки получают `individual` непосредственно через default + операции `AddField`; отдельного migration-test convention в проекте нет. + +### Намеренно вне scope + +- serializers, views, URLs и новые endpoints; +- TeamInvite, token, email и уведомления; +- поиск пользователей; +- frontend; +- изменение Application submit/withdraw contract; +- `returned` и organizer review actions; +- Program policy fields и дедлайны; +- cross-table enforcement «один пользователь — одна активная заявка Program»; +- Project prefill/snapshot; +- Submission, Evaluation и Result; +- изменение `projects.Collaborator`, `invites.Invite`, legacy registration и + legacy project submission; +- deploy, settings, Docker, nginx и workflows. + +Такой scope дал самостоятельный мигрируемый слой данных. Последующие PR +добавили атомарное создание/submit-invariant и публичный Team permissions/API, +не меняя историческую миграцию моделей. diff --git a/docs/expert-evaluation-api.md b/docs/expert-evaluation-api.md new file mode 100644 index 00000000..f1d2b88d --- /dev/null +++ b/docs/expert-evaluation-api.md @@ -0,0 +1,266 @@ +# Expert Evaluation API + +## Roadmap + +- DEV-050 — Expert Submission API +- DEV-051 — Evaluation mutation API +- DEV-052 — Manager evaluation read API +- DEV-073 — изменение submitted Evaluation с неизменяемой историей + +Roadmap-IDs: DEV-050, DEV-051, DEV-052, DEV-073 + +## Назначение + +Контур позволяет назначенному эксперту читать зафиксированную версию +`Submission`, вести черновик оценки и финально отправлять его. Менеджер +программы получает отдельный read-only доступ к оценкам экспертов. + +Эксперт оценивает `Submission`, а не изменяемый legacy `Project`. Права на +объект определяются `SubmissionExpertAssignment`, а не одной ролью Expert или +membership в программе. + +## Сущности + +- `SubmissionExpertAssignment` связывает конкретную Submission и Expert. +- `Evaluation` хранит форму оценки, комментарий и lifecycle. +- `EvaluationScore` хранит одно числовое значение и snapshot критерия. +- `EvaluationAmendment` хранит неизменяемые снимки до и после изменения + отправленной оценки. +- `project_rates.Criteria` временно используется как каталог критериев + программы. + +`ProjectScore`, `ProjectExpertAssignment` и `/rate-project/` в новый контур не +включены и не синхронизируются с Evaluation. + +## Статусы и переходы + +Assignment: + +- `assigned` — эксперт может создавать и изменять draft; +- `completed` — Evaluation отправлена, эксперт сохраняет read-only доступ; +- `revoked` — доступ эксперта закрыт, но draft и scores не удаляются. + +Evaluation: + +- `draft` — доступно атомарное обновление `comment` и полного набора `scores`; +- `submitted` — отправленное состояние; обычный PATCH запрещен, изменение + возможно только через DEV-073 amend с audit-записью. + +Финальный submit в одной транзакции переводит Evaluation в `submitted`, а +assignment в `completed`. Amend не возвращает Evaluation в draft, не меняет +`submitted_at` и сохраняет историю. Reopen отсутствует. + +## Права доступа + +- Expert list/detail доступны только эксперту с текущим membership программы + и assignment `assigned` или `completed`. +- Создание и изменение draft требуют assignment `assigned`. +- Amend submitted Evaluation доступен только владельцу при assignment + `assigned` или `completed` и текущем membership программы. +- Историю amend читают владелец, manager программы и staff/superuser. +- Staff может открыть PII-safe expert detail в административном режиме. +- Владелец-эксперт, manager программы и staff могут читать Evaluation detail. +- Manager list/detail ограничены конкретной программой. +- Неназначенному или чужому эксперту объект возвращается как `404`. +- Manager API не имеет PATCH, DELETE, submit или reopen. + +Одна роль Expert, membership в программе или доступ к legacy ProjectScore не +создают object-level право. + +## Expert API + +### GET /expert/submissions/ + +Возвращает пагинированный список назначенных решений. Фильтры: + +- `program_id`; +- `submission_status`; +- `evaluation_status`: `draft`, `submitted`, `none`; +- стандартные `limit` и `offset`. + +Элемент содержит PII-safe Submission summary, Program, assignment и только +Evaluation текущего эксперта. + +### GET /expert/submissions/\/ + +Возвращает `title`, `description`, отдельное поле `links`, status, stage, +version, Program, assignment, собственную Evaluation и числовые Criteria. + +`Submission.form_data` не возвращается. В текущем домене нет надёжного +solution-only allowlist, поэтому частичная выдача JSON была бы небезопасной. +Также не возвращаются Application, participant, Team, registration data, +email, phone и Evaluation других экспертов. + +### GET /submissions/\/evaluations/my/ + +Возвращает существующую собственную Evaluation и scores. GET ничего не +создаёт. Отсутствующий draft или assignment возвращает `404`. + +### POST /submissions/\/evaluations/ + +Создаёт draft. `comment` и `scores` необязательны. Первый запрос возвращает +`201`; повторный POST возвращает существующий draft с `200` и не изменяет его. +Для submitted Evaluation возвращается `409`. + +### GET /evaluations/\/ + +Read-only detail для владельца-эксперта, manager соответствующей программы и +staff. + +### PATCH /evaluations/\/ + +Autosave владельца draft. Можно передать `comment`, `scores` или оба поля. Если +передан `scores`, старый набор заменяется целиком внутри транзакции. Пустой +массив удаляет все scores. Системные и lifecycle-поля read-only. + +### POST /evaluations/\/submit/ + +Проверяет полноту формы и атомарно завершает Evaluation и assignment. +Повторный submit идемпотентен: возвращает `200` и сохраняет первоначальный +`submitted_at`. + +### PATCH /evaluations/\/amend/ + +Изменяет `comment` и/или полный набор `scores` уже отправленной Evaluation. +Endpoint доступен только эксперту-владельцу. Evaluation остается `submitted`, +исходный `submitted_at` сохраняется, `amended_at` обновляется, а `total_score` +сбрасывается в `null` до появления формулы пересчета. + +Если передан `scores`, payload обязан содержать все текущие числовые Criteria +программы. Валидация и замена scores, обновление Evaluation и создание +`EvaluationAmendment` выполняются в одной транзакции. Полностью совпадающий +запрос возвращает `200`, но не обновляет `amended_at` и не создает историю. + +### GET /evaluations/\/amendments/ + +Возвращает упорядоченную неизменяемую историю со снимками comment, scores и +total_score до и после каждой правки. Доступ имеют эксперт-владелец, manager +программы и staff/superuser. Для остальных существование Evaluation скрывается +ответом `404`. + +## Manager API + +### GET /programs/\/submission-assignments/ + +Существующий контракт дополнен nullable-полем `evaluation` с полями `id`, +`status`, `updated_at`, `submitted_at`, `amended_at`, `total_score`. Старое поле +`evaluation_status` сохранено. + +### GET /programs/\/evaluations/ + +Пагинированный read-only список. Фильтры: + +- `submission_id`; +- `expert_id`; +- `evaluation_status`; +- `assignment_status`; +- `stage_key`; +- `limit`, `offset`. + +Ответ содержит безопасные Submission/Expert/Assignment summaries, scores, +comment, total_score, `submitted_at`, `amended_at` и остальные timestamps. + +### GET /programs/\/evaluations/\/ + +Read-only detail в пределах программы manager. Mutation-методы отсутствуют. + +## Валидация критериев + +- Используются только Criteria той же Program типов `int` и `float`. +- Criterion ID не может повторяться в одном payload. +- Для `int` запрещена дробная часть. +- Значения проверяются по текущим `min_value` и `max_value` без преобразования + Decimal через float. +- PATCH сначала валидирует весь новый набор и только затем удаляет старый. +- Submit требует все текущие числовые Criteria программы и повторно проверяет + типы и диапазоны. +- Amend с `scores` также требует полный набор текущих числовых Criteria до + начала любых изменений. +- Нечисловые Criteria не включаются в форму; свободный текст хранится в + `Evaluation.comment`. + +## Идемпотентность + +- Повторный create существующего draft возвращает его без изменения. +- Повторный PATCH с теми же данными не создаёт дублей. +- Повторный submit submitted Evaluation не меняет `submitted_at`. +- Повторный amend с теми же comment и scores не создает новую историю и не + меняет `amended_at`. +- Уникальность `submission + expert` и `evaluation + criterion` дополнительно + защищена существующими constraints. + +## Транзакции и блокировки + +- Create блокирует assignment и обрабатывает race через вложенный savepoint + вокруг INSERT Evaluation. +- PATCH блокирует assignment и Evaluation; набор scores заменяется атомарно. +- Submit блокирует assignment и Evaluation в стабильном порядке, валидирует + полную форму и записывает одинаковый timestamp в Evaluation и assignment. +- Amend блокирует assignment и Evaluation в том же порядке, затем атомарно заменяет scores, + обновляет Evaluation и записывает снимок `EvaluationAmendment`. +- Два конкурентных submit не создают противоречивое терминальное состояние. + +## Защита персональных данных + +Expert serializers построены отдельно от participant serializers. В ответах +нет Application user/creator, submitted_by, Team, TeamMember, registration +form_data, email или phone. `Submission.form_data` полностью исключён до +появления явной схемы разрешённых solution-полей. + +Manager responses содержат только минимальные имя/фамилию назначенного +эксперта и не содержат данные участника. Полные request payload не логируются +новым кодом. + +## Throttling + +- `evaluation_create`: `10/min`; +- `evaluation_update`: `120/min`; +- `evaluation_submit`: `20/min`; +- `evaluation_amend`: `30/min`. + +Scopes применяются только к соответствующим mutation-методам и не включают +глобальный DRF throttle. + +## Ошибки API + +- `200` — успешное чтение, autosave, повторный create или submit; +- `201` — первый draft create; +- `400` — payload, Criteria, тип, диапазон или неполная форма; +- `401` — отсутствует авторизация; +- `403` — нет доступа к list/manager endpoint; +- `404` — объект отсутствует или скрыт object-level политикой; +- `409` — lifecycle conflict Evaluation, assignment или Submission; +- `429` — превышен scoped throttle. + +## Проверка + +Основной regression-модуль: + +`partner_programs.tests.test_expert_evaluation_api` + +Он покрывает expert list/detail, PII regression, draft create, полную замену +scores, rollback, submit lifecycle, amend submitted Evaluation, audit history, +concurrent submit, manager read-only API, filters, pagination и throttling. + +Также сохраняются model tests и Assignment API regression. + +## Известные ограничения + +- `total_score` остаётся `null`, пока у Program нет формулы. +- Criteria не имеют отдельного Evaluation order/weight/required policy: + обязательны все текущие числовые Criteria. +- Staff expert detail выбирает последнее активное или завершённое назначение + Submission для административного просмотра. +- Deadline `datetime_evaluation_ends` пока не блокирует новый контур. + +## Что не входит в контур + +- frontend и экспертный кабинет; +- Result, ranking и публикация участнику; +- экспорт Evaluation; +- уведомления; +- reopen и revision history; +- веса и формула total_score; +- изменение legacy Criteria; +- синхронизация с ProjectScore; +- изменение `/rate-project/`, Angular flow или старой выгрузки. diff --git a/docs/manager-program-overview-api.md b/docs/manager-program-overview-api.md new file mode 100644 index 00000000..c1f1d760 --- /dev/null +++ b/docs/manager-program-overview-api.md @@ -0,0 +1,130 @@ + + +# Manager program overview API + +DEV-076 является первым read-only этапом DEV-056 «Кабинет менеджера». Endpoint +возвращает компактные обезличенные счетчики по пути участника от регистрации до +экспертной оценки. + +## Endpoint и права + +```http +GET /programs//manager-overview/ +``` + +Требуется авторизация. Доступ разрешен manager указанной `PartnerProgram`, staff +и superuser. Обычный участник и manager другой программы получают `403`. +Несуществующая программа возвращает `404`. + +## Контракт + +```json +{ + "program": {"id": 1, "name": "Program"}, + "registrations": {"total": 0}, + "participants": {"total": 0}, + "applications": { + "total": 0, + "by_status": { + "draft": 0, + "submitted": 0, + "approved": 0, + "rejected": 0, + "withdrawn": 0, + "cancelled": 0 + }, + "by_participation_mode": { + "undecided": 0, + "individual": 0, + "team": 0 + } + }, + "teams": {"total": 0, "accepted_members": 0}, + "submissions": { + "total": 0, + "by_status": { + "draft": 0, + "submitted": 0, + "returned": 0, + "final": 0, + "cancelled": 0 + }, + "applications_with_submitted_solution": 0 + }, + "expert_assignments": { + "total": 0, + "by_status": {"assigned": 0, "completed": 0, "revoked": 0} + }, + "evaluations": { + "total": 0, + "by_status": {"draft": 0, "submitted": 0} + } +} +``` + +Отсутствующие статусы всегда присутствуют в ответе с нулевым значением. + +## Семантика счетчиков + +- `registrations.total` — количество всех записей `PartnerProgramUserProfile` + программы, включая исторические записи с `user=null`. +- `participants.total` — количество уникальных ненулевых `user_id` в + `PartnerProgramUserProfile`. Записи удаленного пользователя с `user=null` не + учитываются, один пользователь учитывается только один раз. +- `applications.total` и `by_status` — заявки программы целиком и по статусам. +- `applications.by_participation_mode` — заявки по режимам `undecided`, + `individual` и `team`. +- `teams.total` — команды, связанные с заявками программы. +- `teams.accepted_members` — только `TeamMember` со статусом `accepted`, включая + капитана. Исторические и ожидающие статусы не учитываются. +- `submissions.total` и `by_status` — все версии `Submission` программы. +- `applications_with_submitted_solution` — число уникальных заявок, имеющих хотя + бы одну версию `Submission` в статусе `submitted` или `final`. +- `expert_assignments` — назначения через `Submission` программы целиком и по + статусам `assigned`, `completed`, `revoked`. +- `evaluations` — оценки через `Submission` программы целиком и по статусам + `draft`, `submitted`. + +Подсчеты выполняются фиксированным набором ORM aggregate-запросов и не зависят +от количества участников. + +## Программы, доступные организатору + +```http +GET /programs/managed/ +``` + +Endpoint требует авторизацию и возвращает компактный непагинированный список: + +```json +[ + { + "id": 123, + "name": "Кейс-чемпионат", + "draft": true + } +] +``` + +Обычный пользователь получает только программы, где он указан в +`PartnerProgram.managers`. Опубликованные и draft-программы включаются в список. +Staff и superuser получают все программы. Пользователь без управляемых программ +получает `200` и пустой массив, неавторизованный пользователь — `401`. + +Сортировка стабильна: сначала по `name`, затем по `id`. Ответ строится одним +запросом и не загружает связанные сущности для каждой программы. Контракт +существующего публичного `GET /programs/` не изменяется. + +## Ограничения данных + +Ответ не содержит email, ФИО, `form_data`, `partner_program_data`, ссылки на +пользовательские файлы, содержимое решений, комментарии экспертов и scores +отдельных пользователей. + +Списки и фильтры сущностей, просмотр анкет, выгрузки Excel/CSV, результаты, +рейтинг, уведомления, продуктовая аналитика и frontend в DEV-076 не входят. +Endpoint не изменяет заявки, решения, назначения или оценки. + +Roadmap-Complete: DEV-076 + +Roadmap-Partial: DEV-056 diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 06e2ffbe..f738e363 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -86,12 +86,19 @@ View выбирает подходящие `news.News`, сериализует - `GET /feed/?type=project|vacancy|news` - комбинированная выдача по нескольким типам. +Legacy `/feed/` сохраняет этот контракт без изменений. Для React добавлен +отдельный `/feed/news/`, который возвращает только полноценные публикации и +никогда не смешивает их со служебными записями проектов и вакансий. Подробный +контракт находится в `docs/react-news-feed-api.md`. + ## Ограничения и правила - Feed читает данные из `news.News`, но не отвечает за создание обычных project/user news. - Служебная feed-запись определяется через пустой `text`. - Новости партнерских программ не отображаются в `/feed/`. +- Новая React-лента программ не меняет это правило legacy endpoint: она читает + `News` через отдельные selectors и serializers. - Signals `feed` создают или удаляют служебные feed-записи для проектов и вакансий. Более широкие сценарии публикации проекта остаются в модуле `projects`. diff --git a/docs/modules/invites.md b/docs/modules/invites.md index 48efddfa..e13f4dfe 100644 --- a/docs/modules/invites.md +++ b/docs/modules/invites.md @@ -11,6 +11,11 @@ Invites отвечает за приглашения пользователей Модуль рабочий и подключен в публичный API через `/invites/`. +Для React workspace DEV-079.1 добавлен отдельный project-scoped контракт поверх +той же модели `Invite`. Он описан в +[`docs/project-invitations-workspace-api.md`](../project-invitations-workspace-api.md) +и не меняет URL старого Angular-клиента. + Приглашения доступны приглашенному пользователю, лидеру проекта и staff/superuser. Изменять или удалять приглашение может лидер проекта. Принять или отклонить приглашение может приглашенный пользователь. diff --git a/docs/modules/news.md b/docs/modules/news.md index 3ca99eab..a7e6c5b4 100644 --- a/docs/modules/news.md +++ b/docs/modules/news.md @@ -36,6 +36,8 @@ generic relation: - лайк и снятие лайка с новости; - участие новостей в общей ленте `/feed/`; - закрепление новости программы через `pin`. +- явная аудитория новости программы: вся платформа или участники программы; +- плоские комментарии к публикациям нового React-контура. ## Архитектура @@ -64,6 +66,8 @@ generic relation: - `likes` - generic likes через `core.Like`. - `views` - generic views через `core.View`. - `pin` - закрепление новости, сейчас используется для новостей программ. +- `audience` - `platform` или `program_participants`. +- `NewsComment` - комментарий с автором, текстом и датами создания/изменения. Feed-запись определяется через helper `is_feed_record(news)`, а обычная новость через `is_content_news(news)`. Сейчас оба helper'а используют текущий признак @@ -117,6 +121,11 @@ Feed-запись определяется через helper `is_feed_record(new Новости программ сортируются с учетом `pin`: закрепленные новости идут выше обычных. +Поле `audience` можно передать при создании и изменении. Для совместимости со +старым Angular отсутствие поля при создании означает `program_participants`. +Внутренние публикации видят участники программы, менеджеры и staff/superuser; +остальным list их не возвращает, а detail отвечает `404`. + ### 4. Просмотры и лайки Просмотры и лайки работают через generic-модели `core.View` и `core.Like`. @@ -143,6 +152,15 @@ Feed-запись определяется через helper `is_feed_record(new - Вложения новости должны ссылаться только на `UserFile` текущего пользователя. - Несуществующий project/user/program context возвращает `404`. - Проектные новости реализованы через `news.News`. +- Новости пользователей и проектов всегда имеют аудиторию `platform`. +- Старые новости программ мигрируются в `program_participants`, чтобы не + публиковать внутренние записи автоматически. + +## React API + +Новый стабильный контракт общей ленты и комментариев описан в +`docs/react-news-feed-api.md`. Он использует существующую модель `News`, +`core.Like` и `core.View`; отдельный модуль публикаций не создается. ## Тесты diff --git a/docs/modules/vacancy.md b/docs/modules/vacancy.md index a9b2b416..cdc0e743 100644 --- a/docs/modules/vacancy.md +++ b/docs/modules/vacancy.md @@ -1,5 +1,9 @@ # Vacancy +> Актуальный безопасный контракт для React, права и транзакционные правила +> описаны в [vacancies-react-api.md](../vacancies-react-api.md). Ниже сохранено +> описание legacy-контура и исторических ограничений модуля. + ## Назначение Vacancy отвечает за вакансии внутри проектов Procollab: публикацию ролей в diff --git a/docs/notifications-api.md b/docs/notifications-api.md new file mode 100644 index 00000000..56e70bc9 --- /dev/null +++ b/docs/notifications-api.md @@ -0,0 +1,148 @@ +# API внутренних уведомлений + +## Назначение + +Модуль `notifications` хранит адресные внутриприложенные уведомления о значимых +бизнес-событиях. Он не заменяет email, чат или WebSocket и не создаёт рассылки о +лайках и технических изменениях. + +Уведомление создаётся из существующего доменного service/view внутри той же +транзакции, что и бизнес-изменение. Django signals намеренно не используются: +получатели и смысл события определяются в явной точке перехода состояния. + +## Модель + +`Notification` содержит получателя, nullable-инициатора, тип, категорию, +исторический снимок заголовка и текста, внутренний `action_url`, `event_key`, +`read_at` и время создания. + +- удаление получателя каскадно удаляет его уведомления; +- удаление инициатора сохраняет уведомление с `actor=null`; +- `UniqueConstraint(recipient, event_key)` защищает от повторов и гонок; +- для переходов Application и Submission ключ включает сохранённый момент + перехода: `submitted_at` для отправки и `updated_at` для смены статуса; +- повторный вызов для одного сохранённого перехода остаётся идемпотентным, а + новый цикл в тот же статус получает отдельный `event_key`; +- индексы `(recipient, -created_at)` и + `(recipient, read_at, -created_at)` обслуживают историю и непрочитанные; +- `action_url` может быть только относительным маршрутом `/office/`, созданным + backend-кодом; +- actor в API содержит только `id`, `first_name`, `last_name`, `avatar`. + +Email, телефон, дата рождения, права администратора и другие служебные данные в +модели и публичном serializer отсутствуют. + +## REST API + +Все endpoints требуют авторизации и работают только с уведомлениями текущего +пользователя. + +### Список + +```http +GET /notifications/?limit=20&offset=0 +GET /notifications/?limit=20&offset=0&unread=true +``` + +`limit` принимает значения `1..100`, `offset` — неотрицательное число. Сначала +возвращаются новые записи, при одинаковом времени — запись с большим `id`. +`unread_count` всегда считает все непрочитанные уведомления пользователя, даже +когда текущая страница отфильтрована или ограничена. + +```json +{ + "count": 1, + "unread_count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 125, + "type": "vacancy_response_created", + "category": "vacancy", + "title": "Новый отклик на вакансию", + "message": "Получен отклик на вакансию «Frontend-разработчик».", + "action_url": "/office/projects/7/vacancies/21/responses", + "read_at": null, + "created_at": "2026-08-15T12:00:00Z", + "actor": { + "id": 15, + "first_name": "Иван", + "last_name": "Петров", + "avatar": null + } + } + ] +} +``` + +### Счётчик + +```http +GET /notifications/unread-count/ +``` + +```json +{"unread_count": 4} +``` + +### Отметка о прочтении + +```http +POST /notifications//read/ +POST /notifications/read-all/ +``` + +Первый endpoint возвращает обновлённое уведомление. Повторный вызов сохраняет +первоначальный `read_at`; чужой или неизвестный `id` скрывается через `404`. +`read-all` обновляет только непрочитанные записи текущего пользователя: + +```json +{"updated": 4, "unread_count": 0} +``` + +## Типы и переходы + +| Тип | Получатель | `action_url` | +| --- | --- | --- | +| `project_invite_created` | приглашённый | `/office/projects/invites` | +| `project_invite_accepted` | руководитель проекта | `/office/projects//edit?section=team` | +| `project_invite_declined` | руководитель проекта | `/office/projects//edit?section=team` | +| `project_invite_revoked` | приглашённый | `/office/projects/invites` | +| `vacancy_response_created` | руководитель проекта | `/office/projects//vacancies//responses` | +| `vacancy_response_accepted` | кандидат | `/office/vacancies/my` | +| `vacancy_response_declined` | кандидат | `/office/vacancies/my` | +| `team_invite_created` | приглашённый | `/office/team-invites` | +| `team_invite_accepted` | капитан | `/office/applications//team` | +| `team_invite_declined` | капитан | `/office/applications//team` | +| `team_invite_revoked` | приглашённый | `/office/team-invites` | +| `application_submitted` | менеджеры программы | `/office/program/` | +| `application_status_changed` | владелец заявки | `/office/program/` | +| `submission_submitted` | менеджеры программы | `/office/program/` | +| `submission_status_changed` | владелец и принятые участники | `/office/program//submission` | +| `expert_assignment_created` | эксперт | `/office/expert/submissions` | +| `expert_assignment_revoked` | эксперт | `/office/expert/submissions` | +| `evaluation_submitted` | менеджеры программы | `/office/analytics?programId=` | +| `news_comment_created` | владелец источника публикации | `/office/news/` | + +При принятии одного отклика остальные ожидающие отклики отклоняются в той же +транзакции, и каждый кандидат получает отдельное уведомление. Для нескольких +менеджеров и участников применяется `bulk_create`. Инициатор исключается из +получателей, а повторный retry использует тот же `event_key`. + +## Текущее покрытие lifecycle + +В текущем API существуют переходы submit/withdraw для Application, +submit/cancel для Submission, а также приглашения, отклики, назначения, +отправка Evaluation и комментарии. Отдельных публичных операций approve/reject +Application и returned/final Submission на момент DEV-094 нет. Типы +`application_status_changed` и `submission_status_changed` уже закреплены в +контракте и подключаются к фактически существующим переходам; новые lifecycle +endpoints ради уведомлений не добавлялись. + +## Границы этапа + +Не реализованы WebSocket, push, email/Telegram-рассылки, уведомления о лайках и +чатах, пользовательские настройки, удаление уведомлений и ручные массовые +рассылки. Frontend-центр уведомлений реализуется отдельным этапом после фиксации +и развёртывания backend-контракта. diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md new file mode 100644 index 00000000..cd89d028 --- /dev/null +++ b/docs/program-participation-policy.md @@ -0,0 +1,117 @@ +# Program Participation Policy + +## Назначение + +Policy хранится в `PartnerProgram` и задает допустимый формат участия, +границы размера команды и отдельный дедлайн подачи `Application`. Эти данные +служат источником истины для транзакционного сервиса создания и отправки +заявок. + +Policy валидируется на уровне модели и базы данных и применяется +Application/Team service при create, смене формата и submit. + +## Публичный detail contract + +`GET /programs//` возвращает read-only объект +`application_policy`: + +```json +{ + "application_policy": { + "participation_format": "individual_or_team", + "allowed_participation_modes": ["individual", "team"], + "team_min_size": 2, + "team_max_size": 5, + "datetime_application_ends": "2026-08-31T20:59:59Z", + "is_application_deadline_passed": false + } +} +``` + +`allowed_participation_modes` содержит только финальные режимы, разрешенные +методом `allows_participation_mode`: `individual`, `team` или оба значения. +Черновой режим `undecided` клиенту для выбора формата не возвращается. + +`team_min_size` и `team_max_size` равны `null` для `individual_only` и содержат +настроенные границы для форматов с командами. `datetime_application_ends` +сериализуется стандартным DRF DateTimeField; `null` означает отсутствие +отдельного дедлайна. `is_application_deadline_passed` вычисляется существующим +model method без fallback на legacy-дедлайны. + +Policy добавлена только в detail serializers. Контракт списка `GET /programs/` +не изменен. Объект не содержит user-specific состояний регистрации или заявки. + +## Формат участия + +Поле `participation_format` поддерживает три значения: + +| Значение | Разрешенные финальные `Application.participation_mode` | +|---|---| +| `individual_only` | Только `individual` | +| `team_only` | Только `team` | +| `individual_or_team` | `individual` и `team` | + +`undecided` остается допустимым состоянием черновика Application, но не +считается разрешенным финальным форматом. Метод +`allows_participation_mode(mode)` возвращает `False` и для `undecided`, и для +неизвестного значения. + +Runtime default — `individual_only`. Он сохраняет поведение существующих +программ и не включает командные заявки без явной настройки организатора. +После миграции все существующие строки получают это значение. + +## Размер команды + +`team_min_size` и `team_max_size` применяются только к `team_only` и +`individual_or_team`. Для этих форматов оба поля обязательны, минимальный +размер не может быть меньше двух, а максимальный — меньше минимального. + +Для `individual_only` оба значения должны быть `null`. Существующим программам +произвольные границы не назначаются. + +Размер команды в будущем рассчитывается только по `TeamMember` со статусом +`accepted`. Капитан имеет accepted-запись `TeamMember` и входит в размер +команды. Фактическое число участников нельзя надежно проверить constraint-ом +одной таблицы: оно зависит от связанных строк и конкурентных изменений. +Поэтому эту проверку выполняет транзакционный domain service непосредственно +перед submit Application. + +## Дедлайн Application + +`datetime_application_ends` — nullable datetime отдельного процесса заявки. +Он не переиспользует и не меняет semantics следующих legacy-полей: + +- `datetime_registration_ends`; +- `datetime_project_submission_ends`; +- `datetime_evaluation_ends`. + +Значение `null` означает, что отдельный дедлайн Application не настроен. Метод +`is_application_deadline_passed(at=None)` в таком случае возвращает `False`. +Fallback на регистрацию или сдачу проекта намеренно отсутствует. Domain +service трактует `null` как отсутствие блокирующего дедлайна. + +## Validation и database constraints + +`PartnerProgram.clean()` привязывает русскоязычные ошибки к +`team_min_size`/`team_max_size` и проверяет соответствие размеров выбранному +формату. + +Database constraints дополнительно гарантируют: + +- каждый заданный размер не меньше двух; +- `team_max_size >= team_min_size`, когда заданы оба значения; +- у `individual_only` оба размера равны `null`; +- у форматов с командами оба размера заданы. + +Constraints защищают данные при обходе model validation, но не проверяют +фактический состав `Team`. + +## Текущие ограничения и следующий шаг + +Application create, смена participation mode и submit теперь применяют policy, +проверяют Registration, deadline и конфликты участия. Legacy +registration/project flow не изменен. + +Публичный Team API дает ролевое чтение и операции rename/leave/remove/transfer. +TeamInvite/accept service использует те же cross-table +проверки без изменения legacy-дедлайнов. diff --git a/docs/project-goals-achievements-workspace-api.md b/docs/project-goals-achievements-workspace-api.md new file mode 100644 index 00000000..d8aa5ca5 --- /dev/null +++ b/docs/project-goals-achievements-workspace-api.md @@ -0,0 +1,94 @@ +# Project Goals and Achievements Workspace API + +## Назначение + +API дополняет безопасный React workspace отдельным CRUD целей и достижений проекта. +Он не использует `ProjectDetailSerializer` и `check_related_fields_update()`, поэтому +изменение вложенного объекта не запускает массовую замену legacy-связей проекта. + +Модели `ProjectGoal` и `Achievement` остаются прежними. Миграция не требуется. +Legacy endpoints также сохраняют существующий контракт. + +## Endpoints + +| Метод | URL | Назначение | +| --- | --- | --- | +| `GET` | `/projects//workspace/goals/` | Список целей проекта | +| `POST` | `/projects//workspace/goals/` | Создание цели | +| `PATCH` | `/projects//workspace/goals//` | Частичное изменение цели | +| `DELETE` | `/projects//workspace/goals//` | Удаление цели | +| `GET` | `/projects//workspace/achievements/` | Список достижений проекта | +| `POST` | `/projects//workspace/achievements/` | Создание достижения | +| `PATCH` | `/projects//workspace/achievements//` | Частичное изменение достижения | +| `DELETE` | `/projects//workspace/achievements//` | Удаление достижения | + +Все endpoints требуют аутентификацию. + +## Контракты + +Цель: + +```json +{ + "id": 12, + "title": "Подготовить прототип", + "completion_date": "2026-12-15", + "responsible": 42 +} +``` + +`title` обязателен и после удаления крайних пробелов не может быть пустым. +`completion_date` может быть `null`. `responsible` должен быть руководителем или +участником именно проекта из URL. Поля `project` и `is_done` этот контракт не +принимает. + +Достижение: + +```json +{ + "id": 7, + "title": "Победа в конкурсе", + "year": 2025 +} +``` + +`title` обязателен и не может состоять из пробелов. `year` — целое число от 2000 +до текущего года включительно. Модель исторически хранит это значение в строковом +поле `Achievement.status`; workspace serializer выполняет явное преобразование, +не меняя legacy-модель и API. + +## Доступ и изоляция + +| Пользователь | Публичный опубликованный проект | Свой private/draft | Чужой private/draft | Изменение | +| --- | --- | --- | --- | --- | +| Руководитель | чтение | чтение | нет | свой проект | +| Collaborator | чтение | чтение своего проекта | нет | нет | +| Авторизованный посторонний | чтение | нет | нет | нет | +| Staff/superuser | чтение | чтение | чтение | административное | + +Видимость совпадает с `GET /projects//workspace/`: публичным считается только +проект с `draft=false` и `is_public=true`. Недоступный private/draft проект скрыт +ответом `404`; видимый пользователю проект без права изменения возвращает `403` +для `POST`, `PATCH` и `DELETE`. + +Идентификатор проекта не принимается из request body. Queryset вложенного объекта +всегда одновременно фильтруется по `project_id` из URL и по собственному ID, +поэтому цель или достижение нельзя прочитать, изменить либо удалить через URL +другого проекта. + +## Транзакции и производительность + +Создание, изменение и удаление выполняются внутри `transaction.atomic`. Сначала +валидируется весь payload, затем меняется одна строка; ошибка в одном поле не +оставляет частично сохраненные значения других полей. + +Список целей загружает ответственных через `select_related`. Контракты списков не +выполняют запрос на каждый элемент и покрыты regression-тестом на постоянный +query budget. + +## Что не входит в этот этап + +API не меняет основные поля, правила публикации и черновиков Project, загрузку +файлов, вакансии, партнеров, ресурсы, приглашения, подписки, конкурсные связи, +Application или Submission lifecycle. Управление выполнением цели (`is_done`) +остается в legacy-контуре до отдельного продуктового решения. diff --git a/docs/project-invitations-workspace-api.md b/docs/project-invitations-workspace-api.md new file mode 100644 index 00000000..55274bd2 --- /dev/null +++ b/docs/project-invitations-workspace-api.md @@ -0,0 +1,216 @@ +# Project Invitations Workspace API + +## Назначение + +DEV-079.1 добавляет безопасный backend-сценарий приглашения зарегистрированного +пользователя в рабочее пространство `Project`. После принятия пользователь +становится `Collaborator` этого проекта. `TeamMember`, участник Application, +подписчик проекта и `Collaborator` остаются разными сущностями. + +React-интерфейс будет добавлен отдельным этапом DEV-079.2. В этом PR не +добавляются email-приглашения, токены, сроки действия и приглашения +незарегистрированных пользователей. + +## Аудит legacy-сценария + +Существующая модель `invites.Invite` уже хранит связь `Project` и +зарегистрированного `CustomUser`, роль, специализацию, сообщение и tri-state +`is_accepted`. Angular передает числовой user id, извлеченный из ссылки на +профиль, в `POST /invites/`, получает активные приглашения через +`GET /invites/`, принимает и отклоняет их отдельными action endpoints, а +`DELETE /invites//` использует как отзыв. + +Legacy-контракт `/invites/` сохранен. Новый workspace API использует ту же +сущность, но не требует от React знания legacy-полей `project`, `user` и +`is_accepted`. + +## Lifecycle и миграция + +В `Invite` добавлены: + +- `invited_by` — зарегистрированный отправитель; +- `is_revoked` — отзыв без физического удаления; +- `resolved_at` — время принятия, отклонения или отзыва. + +Публичный `status` вычисляется без удаления legacy `is_accepted`: + +- `pending` — `is_accepted=null`, `is_revoked=false`; +- `accepted` — `is_accepted=true`; +- `declined` — `is_accepted=false`; +- `revoked` — `is_revoked=true`. + +Допустимые переходы: + +```text +pending -> accepted +pending -> declined +pending -> revoked +``` + +Повторный переход завершенного приглашения возвращает `409`. Миграция заполняет +`invited_by` текущим лидером проекта, переносит дату обработки legacy-записей и, +если в старых данных есть несколько pending-записей одной пары Project/User, +оставляет активной только новейшую. Остальные сохраняются как `revoked`. + +На уровне БД действуют: + +- partial unique constraint для одной pending-записи на `project + user`; +- check constraint, запрещающий одновременно `is_revoked=true` и принятое или + отклоненное значение `is_accepted`; +- индексы списков по пользователю/проекту и lifecycle-полям. + +## API + +Все endpoints требуют аутентификацию. + +### Список и создание + +```http +GET /projects//workspace/invitations/ +POST /projects//workspace/invitations/ +``` + +Список содержит историю только указанного проекта. Доступ имеют лидер проекта, +staff и superuser. + +Payload создания: + +```json +{ + "recipient_id": 42, + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту" +} +``` + +Обязателен только `recipient_id`. Project берется из URL, отправитель — из +аутентифицированного пользователя. Неизвестные и read-only поля отклоняются. + +### Входящие приглашения + +```http +GET /projects/workspace/invitations/incoming/ +``` + +Возвращает только историю текущего получателя; pending-приглашения идут первыми. + +### Поиск кандидатов + +```http +GET /projects//workspace/invitations/candidates/?q= +``` + +Поиск доступен лидеру Project, staff и superuser. `q` обязателен, после удаления +пробелов должен содержать от 3 до 100 символов. Поиск регистронезависимо +проверяет имя, фамилию, полное имя в обоих порядках и начало email, но email в +ответ не включает. Результат ограничен 20 записями и стабильно сортируется по +фамилии, имени и id. + +Пример ответа: + +```json +[ + { + "id": 42, + "display_name": "Иван Петров", + "avatar": null + } +] +``` + +Из результата исключаются неактивные пользователи, лидер и текущие +`Collaborator`, а также получатели действующих pending-приглашений. Отклоненные +и отозванные записи остаются историей и повторный поиск не блокируют. Если +legacy Project напрямую связан с `PartnerProgramProject`, кандидат должен быть +участником этой программы через `PartnerProgramUserProfile`. + +Endpoint не создает и не резервирует приглашение. `POST` повторно проверяет все +условия в транзакционном service, поэтому устаревший результат поиска не +позволяет обойти права или eligibility. Для поиска действует отдельный throttle +scope `project_invitation_candidate_search` со скоростью `20/min`. + +### Решение и отзыв + +```http +POST /projects/workspace/invitations//accept/ +POST /projects/workspace/invitations//decline/ +POST /projects//workspace/invitations//revoke/ +``` + +Action payload должен быть пустым. Принять или отклонить приглашение может +только получатель. Отозвать pending-приглашение может лидер соответствующего +Project, staff или superuser. + +Коды ответа: `201` для создания, `200` для списков и успешных переходов, `400` +для невалидных полей или получателя, `403` для видимого Project без права +управления, безопасный `404` для скрытого Project/чужого приглашения и `409` для +активного дубля либо повторного перехода статуса. + +Пример ответа: + +```json +{ + "id": 7, + "project": { + "id": 10, + "name": "Проект", + "draft": true, + "is_public": false + }, + "sender": { + "id": 1, + "first_name": "Анна", + "last_name": "Иванова", + "avatar": null + }, + "recipient": { + "id": 42, + "first_name": "Иван", + "last_name": "Петров", + "avatar": null + }, + "status": "pending", + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту", + "created_at": "2026-08-06T12:00:00Z", + "processed_at": null, + "updated_at": "2026-08-06T12:00:00Z" +} +``` + +## Права и ограничения + +| Действие | Лидер | Collaborator | Получатель | Посторонний | Staff | +|---|---:|---:|---:|---:|---:| +| Список проекта | Да | Нет | Нет | Нет | Да | +| Поиск кандидатов | Да | Нет | Нет | Нет | Да | +| Создание | Да | Нет | Нет | Нет | Да | +| Входящие | Только свои | Только свои | Только свои | Только свои | Только свои | +| Accept/decline | Только если получатель | Только если получатель | Да | Нет | Только если получатель | +| Revoke | Да | Нет | Нет | Нет | Да | + +Нельзя пригласить лидера, существующего `Collaborator`, неактивного или +несуществующего пользователя. Для legacy Project, напрямую связанного с +`PartnerProgramProject`, получатель должен быть участником этой программы — это +повторяет действующий invariant `Collaborator.clean()`. + +Приватный Project скрывается от постороннего через `404`; пользователь, который +видит Project, но не управляет им, получает `403`. Ответы не содержат email, +телефон, анкету Application или данные Submission. + +Создание, принятие, отклонение и отзыв выполняются в `transaction.atomic` с +блокировками Project и Invite. Принятие и создание `Collaborator` — одна +транзакция. Partial unique constraint закрывает гонку двух создающих запросов. + +## Ограничения DEV-079.1 + +- приглашаются только существующие активные пользователи по `recipient_id`; +- поиск кандидатов возвращает только `id`, `display_name` и `avatar` и не + заменяет серверную проверку при создании приглашения; +- email, уведомления, invite links и expiration отсутствуют; +- React UI относится к DEV-079.2; +- Angular продолжает использовать legacy `/invites/`; +- `TeamInvite`, `TeamMember`, Application, Submission, подписки, цели и + достижения не изменяются. diff --git a/docs/project-subscriptions-workspace-api.md b/docs/project-subscriptions-workspace-api.md new file mode 100644 index 00000000..73440fce --- /dev/null +++ b/docs/project-subscriptions-workspace-api.md @@ -0,0 +1,69 @@ +# Project Subscriptions Workspace API + +## Назначение + +DEV-084.1 добавляет стабильный React workspace-контракт подписки на Project. +Новая модель не создаётся: источником данных остаётся существующая связь +`Project.subscribers`. Legacy API старого Angular-клиента сохраняется без +изменений. + +## Контракт + +Для авторизованного пользователя доступны методы: + +```http +GET /projects//workspace/subscription/ +POST /projects//workspace/subscription/ +DELETE /projects//workspace/subscription/ +``` + +`POST` и `DELETE` принимают только пустой JSON-объект `{}`. Ответ всех методов: + +```json +{ + "is_subscribed": true, + "subscribers_count": 7 +} +``` + +Повторный `POST` сохраняет подписку, а повторный `DELETE` сохраняет отсутствие +подписки. Оба действия идемпотентны и возвращают фактическое текущее количество. + +## Видимость и права + +- опубликованный публичный Project доступен любому авторизованному пользователю; +- private или draft Project доступен руководителю, collaborator, staff и + superuser; +- отсутствующий или недоступный Project скрывается одинаковым ответом `404`; +- клиент не может передать пользователя, Project или состояние через payload. + +Список подписчиков, email, телефон и другие данные профилей не возвращаются. +Подписка не создаёт Collaborator, Invite, News или другие доменные сущности. + +## Workspace detail + +`GET /projects//workspace/` дополнительно возвращает: + +```json +{ + "is_subscribed": false, + "subscribers_count": 6 +} +``` + +Selector получает оба значения ORM-аннотациями `Exists` и `Count`. Serializer +не обращается к базе, не загружает пользователей и не создаёт N+1. + +## Совместимость и ограничения + +Без изменений продолжают работать: + +```http +POST /projects//subscribe/ +POST /projects//unsubscribe/ +GET /projects//subscribers/ +``` + +На этом этапе не добавляются React UI, email и внутренние уведомления, настройки +частоты, автоматическая подписка команды, список подписчиков и интеграция с +новостной лентой. diff --git a/docs/projects-create-flow.md b/docs/projects-create-flow.md new file mode 100644 index 00000000..576ea3e7 --- /dev/null +++ b/docs/projects-create-flow.md @@ -0,0 +1,55 @@ +# Standalone Project Creation Flow + +## Назначение + +DEV-078 добавляет самостоятельное создание `Project` в React-контуре. Такой проект является постоянным рабочим результатом пользователя и не создает автоматически `Application` или `Submission`. + +Создание проекта из отправленного решения остается отдельным явным сценарием `POST /submissions//project/`. Он не изменен: `Submission` хранит зафиксированную версию решения, а новый самостоятельный Project начинается как пустой черновик. + +Старый Angular-клиент продолжает создавать проект пустым `POST /projects/` и переходить на `/office/projects//edit`. Новый endpoint отделён именно потому, что legacy model default `is_public=true` нельзя менять без риска для действующего контракта. + +## API + +### `POST /projects/workspace/` + +Endpoint требует авторизацию и принимает только пустой JSON-объект. Сервер: + +- назначает `request.user` руководителем; +- создает `draft=true`; +- создает `is_public=false`; +- не принимает `leader`, статусы публикации или другие поля от клиента; +- не создает Application, Submission, Team или связь с программой; +- возвращает полный workspace detail contract, включая ID и права текущего пользователя. + +Legacy `POST /projects/`, используемый Angular-приложением, не изменен. + +### `PATCH /projects//workspace/` + +Руководитель или staff может обновлять основные поля проекта. В DEV-078 контракт дополнен полями: + +- `industry` — ID существующей отрасли или `null`; +- `links` — список валидных URL. + +Ссылки заменяются атомарно вместе с обновлением проекта. Дубликаты удаляются с сохранением исходного порядка. Поля владельца, Application, Submission и программы по-прежнему недоступны для изменения. + +## Draft и публикация + +React-клиент использует два явных действия: + +- сохранение черновика отправляет `draft=true`, `is_public=false`; +- публикация отправляет `draft=false`, `is_public=true`. + +Workspace serializer рассчитывает итоговое состояние из текущего Project и присланных полей. Явное `draft=false`, явное `is_public=true` и изменение уже опубликованного проекта требуют название, регион, отрасль, описание, проблему, целевую аудиторию и обложку. Поэтому частичный PATCH только с одним флагом не позволяет обойти серверную проверку, а незаполненный приватный черновик по-прежнему можно сохранять с `draft=true` и `is_public=false`. + +Каталог `GET /projects/catalog/` возвращает только опубликованные публичные проекты. `GET /projects/my/` возвращает руководителю и черновики, и приватные проекты. + +## Маршруты интерфейса + +- `/office/projects/my` — список и создание проекта; +- `/office/projects/all` — публичный каталог; +- `/office/projects//edit` — заполнение и публикация; +- `/office/projects/` — карточка после сохранения. + +## Ограничения этапа + +DEV-078 не добавляет приглашения, подписки, уведомления, публикации проекта, вакансии или расширенное управление ресурсами. Удаление пустого черновика не добавлено: legacy DELETE не содержит отдельного безопасного правила «незаполненного» workspace-черновика. Создание из Submission также не меняется. diff --git a/docs/projects-lifecycle-api.md b/docs/projects-lifecycle-api.md new file mode 100644 index 00000000..edc98e66 --- /dev/null +++ b/docs/projects-lifecycle-api.md @@ -0,0 +1,180 @@ +# Projects Lifecycle API + +## Назначение + +DEV-077 добавляет отдельный API рабочего пространства проектов для React-контура. `Submission` и `Project` остаются разными сущностями: + +- `Submission` — неизменяемая после отправки версия решения в одной `Application` и одной активности; именно ее оценивает эксперт; +- `Project` — постоянный рабочий результат пользователя или команды, который можно развивать и повторно использовать; +- `Application.project` связывает выбранный Project с участием в конкретной `PartnerProgram`. + +Новый lifecycle не переносит Evaluation на `ProjectScore` и не использует `ProjectExpertAssignment`. Источником связанных активностей служит `Project ← Application → PartnerProgram`, а не legacy `PartnerProgramProject`. + +## Аудит старого Angular-раздела + +В `frontend-angular` изучены domain- и API-слои проекта, каталог и маршруты в `projects/social_platform/src/app`. Старый интерфейс поддерживает: + +- каталог и «Мои проекты»; +- карточку, создание, полное редактирование и удаление Project; +- команду, цели, партнеров, ресурсы и вакансии; +- подписки, приглашения, новости, рабочую область и чат; +- привязку проекта к программе и legacy-оценку проекта. + +Angular использует legacy endpoints `GET/POST /projects/`, `GET/PUT/PATCH/DELETE /projects//`, `GET /projects/count/`, `GET /auth/users/projects/`, а также вложенные endpoints коллабораторов, целей, ресурсов, компаний, вакансий, подписок и приглашений. Эти контракты не удаляются и не переименовываются. + +В DEV-077 перенесены каталог, список пользователя, базовая карточка, редактирование лидером, связанные активности, выбор существующего Project в Application и создание Project из Submission. Расширенные Angular-сценарии перечислены в разделе DEV-066 и не реализуются частично. + +## API + +Все новые endpoints требуют аутентификацию. + +### `GET /projects/catalog/` + +Возвращает только `draft=false` и `is_public=true`. Поддерживает limit/offset pagination, `search` по названию и `industry` по идентификатору. Порядок стабилен: сначала последние обновленные, затем больший id. + +### `GET /projects/my/` + +Возвращает Project, где текущий пользователь является `leader` либо имеет `Collaborator`. Включает приватные проекты и черновики. + +Минимальный list contract: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Название", + "short_description": "Краткое описание", + "image_address": null, + "cover_image_address": null, + "draft": true, + "is_public": false, + "current_user_role": "leader", + "can_edit": true, + "can_use_in_application": true, + "activities": [ + { + "id": 10, + "name": "Активность", + "application_id": 25, + "application_status": "submitted" + } + ], + "datetime_updated": "2026-08-01T10:00:00Z" + } + ] +} +``` + +`can_use_in_application=true` только у руководителя. Административный доступ staff не превращает чужой Project в собственный вариант для Application. + +### `GET /projects//workspace/` + +Дополняет list contract описанием, отраслью, регионом, TRL, сроком реализации, руководителем, коллабораторами и ссылками. Публичный опубликованный Project доступен любому авторизованному пользователю. Private/draft видят руководитель, Collaborator и staff. Для постороннего private/draft скрывается через 404. + +Ответ не содержит email, телефон, Application.form_data, Submission или закрытые профильные данные. + +DEV-087A добавляет read-only поле `vacancies` на основе существующего +`ProjectVacancyListSerializer`. Оно содержит все вакансии текущего проекта, включая +неактивные и созданные более 90 дней назад; ограничения публичного +`GET /vacancies/` к workspace detail не применяются. Пример элемента: + +```json +{ + "id": 15, + "role": "Backend-разработчик", + "specialization": null, + "required_skills": [ + {"id": 3, "name": "Python", "category": {"id": 1, "name": "Backend"}} + ], + "description": "Описание вакансии", + "project": 7, + "is_active": false, + "datetime_closed": "2026-08-01T10:00:00Z", + "response_count": 2, + "date_create_time": "2026-01-01T10:00:00+03:00" +} +``` + +### `PATCH /projects//workspace/` + +Руководитель и staff могут изменять только: + +- `name`, `description`, `region`; +- `actuality`, `problem`, `target_audience`; +- `implementation_deadline`, `trl`; +- `presentation_address`, `image_address`, `cover_image_address`; +- `draft`, `is_public`. + +Нельзя менять leader, collaborators, Application, Program, Submission, Evaluation и подписчиков. Неизвестное или запрещенное поле возвращает 400. + +### `POST /submissions//project/` + +Создает Project только из `submitted` или `final` Submission. Разрешен владельцу Application (капитану по текущему invariant), staff и superuser. Обычный accepted TeamMember и посторонний получают безопасный 404. + +При первом вызове в одной `transaction.atomic`: + +1. блокируются Submission и Application; +2. повторно проверяются права и статус; +3. создается private draft Project с leader=`Application.user`; +4. title/description и валидные уникальные HTTP(S)-ссылки переносятся из Submission; +5. accepted TeamMember, кроме капитана, добавляются в `Collaborator`; +6. Project сохраняется в `Application.project`. + +Ответ при создании — HTTP 201: + +```json +{ + "created": true, + "project": { "id": 1 } +} +``` + +Повторный запрос и запрос по другой версии Submission той же Application возвращают существующий Project с HTTP 200 и `created=false`. Если `Application.project` был выбран заранее, endpoint ничего в нем не переписывает: не меняет название, описание, ссылки или команду. + +## Application contract и переиспользование + +Существующее поле `project` сохраняет тип `number | null`. Ответ обратно совместимо дополнен: + +```json +{ + "project": 12, + "project_summary": { + "id": 12, + "name": "Проект", + "draft": true, + "is_public": false + } +} +``` + +Один Project можно выбрать в нескольких draft Application разных программ. Сервер проверяет, что пользователь является leader (staff имеет административное исключение). После submit serializer запрещает изменение Application, поэтому Project нельзя подменить. Новая Application не копирует Project, а новые Submission не изменяют его автоматически. + +## Матрица прав + +| Операция | Leader / владелец Application | Collaborator | Accepted TeamMember | Посторонний | Staff / superuser | +| --- | --- | --- | --- | --- | --- | +| Каталог public | Да | Да | Да | Да | Да | +| Свой private/draft Project | Да | Read-only | Только если также Collaborator | 404 | Да | +| Редактирование Project | Да | Нет | Нет | 404/нет | Да | +| Выбор Project в Application | Да | Нет | Нет | Нет | Административно | +| Создание Project из Submission | Да | Нет | Нет | 404 | Да | + +## Производительность и совместимость + +List/detail selectors используют `select_related` и `Prefetch` для ролей, Application/Program, команды Project и ссылок. Workspace detail отдельно предзагружает вакансии и их навыки, а число необработанных откликов считает в SQL; количество запросов не растёт с количеством вакансий. Тест списка задает query budget, который не растет с количеством Project. + +Legacy модели `Project`, `Collaborator`, `PartnerProgramProject`, `ProjectScore`, serializers и `/projects/` сохранены. Подтвержденная ошибка legacy PATCH, который вызывал полный PUT, исправлена на partial update и покрыта regression-тестом. Остальной legacy contract не расширяется новым workspace-ответом. + +Изменений моделей и миграций в DEV-077 нет. + +## Что остается DEV-066 + +Следующим этапом остаются React-интерфейс управления вакансиями, чат, рабочая область, новости, подписки, legacy-приглашения, расширенное управление командой Project, передача лидерства и удаление. Также не входят legacy `ProjectScore`, Evaluation lifecycle и автоматическое обновление Project из новых Submission. + +## Проверка + +Backend PostgreSQL CI должен выполнить новые suites `projects.tests.test_project_workspace_api` и `partner_programs.tests.test_submission_project_api`, затем regression `projects` и `partner_programs`. Локально нельзя заменять PostgreSQL на SQLite: транзакционные блокировки и PostgreSQL constraints должны проверяться в целевой СУБД. diff --git a/docs/public-profiles-api.md b/docs/public-profiles-api.md new file mode 100644 index 00000000..b9af13a2 --- /dev/null +++ b/docs/public-profiles-api.md @@ -0,0 +1,102 @@ +# Public Profiles API + +## Назначение + +API предоставляет авторизованным пользователям каталог участников и безопасный публичный профиль. Он не переиспользует `UserDetailSerializer`: собственный профиль содержит контактные данные, которые нельзя раскрывать другим пользователям. + +Заполнение профиля добровольное. Onboarding и ограничения доступа по заполненности профиля не добавлялись. + +## Endpoints + +| Метод | Endpoint | Назначение | +| --- | --- | --- | +| `GET` | `/auth/profiles/` | Каталог активных пользователей | +| `GET` | `/auth/profiles//` | Публичный профиль активного пользователя | +| `GET` | `/auth/users/current/` | Приватные данные текущего пользователя | +| `PATCH` | `/auth/users//` | Редактирование собственного профиля | + +Оба публичных endpoint требуют активную авторизованную сессию. Неизвестный или неактивный пользователь возвращает `404`. + +## Каталог + +Ответ использует limit/offset-пагинацию: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 42, + "first_name": "Ирина", + "last_name": "Орлова", + "avatar": null, + "city": "Казань", + "user_type": 1, + "user_type_label": "Участник", + "specialization": { + "id": 7, + "name": "Аналитик", + "category": {"id": 2, "name": "Аналитика"} + }, + "skills": [ + { + "id": 5, + "name": "Python", + "category": {"id": 3, "name": "Разработка"} + } + ] + } + ] +} +``` + +Поддерживаются параметры: + +* `limit`, `offset`; +* `search` — имя, фамилия и полное имя в обоих порядках; +* `user_type` — идентификатор типа пользователя; +* `specialization` — идентификатор специализации; +* `skill` — идентификатор навыка. + +Порядок стабилен: имя, фамилия, идентификатор. Связанные навыки не создают дубли пользователей. + +## Публичный detail + +Detail дополняет карточку следующими полями: + +* `patronymic`; +* `about_me`; +* `social_links` — словарь типизированных публичных ссылок; +* `education`; +* `work_experience`; +* `user_languages`; +* `achievements` и безопасные метаданные файлов достижений. + +Публичный JSON построен через явный allow-list. Он не содержит на любом уровне: + +* email и телефон; +* дату рождения; +* пароль и токены; +* `is_staff`, `is_superuser`, `is_active`; +* onboarding, verification и другие служебные статусы; +* online-статус и технические даты; +* идентификатор владельца загруженного файла. + +Legacy-массив `links` в публичный detail не входит. Формат чтения и частичного +обновления типизированных ссылок описан в `docs/typed-social-links-api.md`. + +## Редактирование собственного профиля + +Существующий `PATCH /auth/users//` сохраняется. Object permission разрешает изменение только владельцу; административное расширение прав не добавлялось. Email, состояние аккаунта, onboarding и административные признаки не изменяются через serializer профиля. + +Вложенные `education`, `work_experience`, `user_languages`, `achievements` и `links` передаются полным актуальным набором. Обновление выполняется атомарно: ошибка вложенной коллекции откатывает остальные изменения. `avatar` остаётся URL, полученным после загрузки через `/files/`. + +## Производительность + +Список заранее загружает специализацию, категории и навыки. Detail дополнительно загружает ссылки, образование, опыт, языки, достижения и их файлы. Размер списка не увеличивает число SQL-запросов. + +## Границы + +В API не добавлялись рейтинги, статистика, CV, сообщения, экспорт, изменения регистрации или onboarding. Модели и миграции не изменялись. diff --git a/docs/react-dev-backend.md b/docs/react-dev-backend.md new file mode 100644 index 00000000..7564ad79 --- /dev/null +++ b/docs/react-dev-backend.md @@ -0,0 +1,31 @@ +# React Dev Backend + +## Назначение + +Дополнительные домены отдельных dev-контуров настраиваются через окружение. Это позволяет обновлять backend из репозитория без локальных изменений `procollab/settings.py`. + +Базовые значения `ALLOWED_HOSTS` и `CSRF_TRUSTED_ORIGINS` остаются прежними. Если новые переменные не заданы или пусты, поведение dev и production не меняется. + +## Настройка react-dev + +В окружении процесса или контейнера `api-react-dev` задаются: + +```dotenv +EXTRA_ALLOWED_HOSTS=api-react-dev.procollab.ru,react-dev.procollab.ru +EXTRA_CSRF_TRUSTED_ORIGINS=https://api-react-dev.procollab.ru,https://react-dev.procollab.ru +``` + +После изменения окружения настройки применятся при следующем запуске backend-процесса. Этот документ не описывает и не инициирует deploy. + +## Правила разбора + +- значения разделяются запятыми; +- пробелы по краям удаляются; +- пустые элементы игнорируются; +- дополнительные значения добавляются после базовых; +- для `EXTRA_ALLOWED_HOSTS` указываются домены без схемы; +- для `EXTRA_CSRF_TRUSTED_ORIGINS` указываются полные origins со схемой `http://` или `https://`. + +## Ограничения + +Настройка не меняет API endpoints, сущности, статусы или бизнес-логику. Она не выполняет проверку доступности доменов и не управляет nginx, Docker или инфраструктурой. Корректность формата trusted origins дополнительно проверяется стандартными проверками Django при запуске. diff --git a/docs/react-dev-demo-data.md b/docs/react-dev-demo-data.md new file mode 100644 index 00000000..deb50a7e --- /dev/null +++ b/docs/react-dev-demo-data.md @@ -0,0 +1,135 @@ + + +# Тестовые данные 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. + +### Новостная лента DEV-083 + +Тот же запуск создаёт отдельный публичный проект +`[DEMO] Новостная лента React-dev` под руководством первого DEMO-участника и +связанный набор для проверки React-ленты: + +- 11 публичных новостей программы; +- 11 публичных новостей DEMO-проекта; +- 11 публичных новостей, распределённых между DEMO-пользователями; +- одну новость программы с аудиторией `program_participants`; +- публикации с лайками, просмотрами и комментариями, а также публикации без реакций; +- 21 хронологический комментарий у одной новости для проверки второй страницы. + +Во всех трёх публичных вкладках `/feed/news/` есть вторая страница при стандартном +лимите 10. Внутренняя новость отсутствует в списке, открывается по возвращённому +командой ID у участника DEMO-программы и отвечает `404` постороннему пользователю. + +## Меры безопасности + +Команда завершается до любой записи, если не выполнено хотя бы одно условие: + +- setting `ALLOW_REACT_DEV_DEMO_SEED=True`; +- передан флаг `--confirm-react-dev`; +- в окружении задана переменная `REACT_DEV_DEMO_PASSWORD`. + +Setting по умолчанию выключен. Значение пароля не хранится в репозитории и не +выводится командой. Все изменения выполняются в одной транзакции. + +Reset находит программу только по одновременному точному совпадению имени и +служебного tag. Аккаунты с фиксированными demo-email сохраняются и обновляются: +это не позволяет каскадному удалению затронуть их возможные внешние связи. +Обычные пользователи и программы с похожими названиями не удаляются. + +Проект DEV-083 определяется точным служебным именем и ожидаемым DEMO-руководителем. +Если это имя уже занято проектом другого руководителя, транзакция завершается без +изменений. Новости удаляются только по сочетанию ожидаемого источника и полного +точного текста; общий префикс `[DEMO]` не является признаком владения. Поэтому +`--reset` не удаляет другие новости DEMO-пользователей и проекты с похожими именами. + +## Обычный запуск + +В окружении изолированного 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 +``` + +Команда показывает план и итоговые количества, затем откатывает транзакцию. +В итог также входят количества проектов, публичных и внутренних новостей, лайков, +просмотров и комментариев, а также ID внутренней новости. Пароль не выводится. + +Пример части вывода DEV-083: + +```text + проекты: 1 + публичные новости: 33 + внутренние новости: 1 + лайки: 6 + просмотры: 9 + комментарии: 23 + ID внутренней новости: +``` + +## 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/docs/react-dev-industry-reference-data.md b/docs/react-dev-industry-reference-data.md new file mode 100644 index 00000000..f1084fd9 --- /dev/null +++ b/docs/react-dev-industry-reference-data.md @@ -0,0 +1,41 @@ +# Справочник отраслей React-dev + +React-форма проекта загружает отрасли через `GET /industries/`. Endpoint возвращает +непагинированный массив объектов `id`, `name` и `datetime_created` непосредственно из +таблицы `industries_industry`. + +Изолированная React-dev база создаётся отдельно от legacy dev и production. Schema +migration приложения `industries` создаёт таблицу, но исторически не добавляет в неё +справочные строки. Поэтому новая база может корректно отвечать `200 []`, а обязательное +поле отрасли в форме публикации останется без вариантов. + +## Безопасное заполнение React-dev + +Команда создаёт только отсутствующие названия из действующего legacy-справочника. Она +не удаляет, не переименовывает и не обновляет существующие строки, не фиксирует primary +key и безопасна для повторного запуска. + +Перед любым изменением проверить план: + +```console +python manage.py seed_react_dev_industries --confirm-react-dev --dry-run +``` + +После проверки выполнить в контейнере, подключённом именно к изолированной React-dev +базе: + +```console +python manage.py seed_react_dev_industries --confirm-react-dev +``` + +Команда дополнительно требует `ALLOW_REACT_DEV_DEMO_SEED=True`. По умолчанию setting +равен `False`, поэтому запуск в production или legacy dev с обычной конфигурацией +завершается до записи данных. На production эту команду запускать нельзя. + +После заполнения `GET /industries/` должен вернуть 27 элементов. Smoke-проверка проекта: + +1. создать private draft через `POST /projects/workspace/`; +2. сохранить выбранный `industry` через `PATCH /projects//workspace/`; +3. повторно открыть workspace detail и проверить сохранённую отрасль; +4. заполнить остальные обязательные поля и отправить `draft=false`, `is_public=true`; +5. убедиться, что опубликованный Project сохранил тот же `industry`. diff --git a/docs/react-news-feed-api.md b/docs/react-news-feed-api.md new file mode 100644 index 00000000..c29f9491 --- /dev/null +++ b/docs/react-news-feed-api.md @@ -0,0 +1,203 @@ +# React News Feed API + +## Назначение + +DEV-083.1 добавляет стабильный backend-контракт для будущего раздела +`/office/news`. Он использует общую модель `news.News`, существующие +`core.Like`/`core.View` и новую модель `NewsComment`. Legacy `/feed/` и +контекстные endpoints Angular сохраняют прежние URL и формат ответа. + +В `News` по-прежнему хранятся как публикации, так и служебные feed-записи с +пустым текстом. Новый API возвращает только полноценные публикации. + +## Источники и список + +```text +GET /feed/news/?source=program&search=&limit=10&offset=0 +``` + +Endpoint требует авторизацию. `source` принимает: + +- `program` — значение по умолчанию, только новости программ с + `audience=platform`; +- `project` — новости опубликованных публичных проектов; +- `user` — новости пользователей. + +Неизвестный source возвращает `400`. Служебные записи с пустым текстом, +новости private/draft-проектов и participant-only новости программ в список не +попадают. Сортировка: `datetime_created DESC, id DESC`. + +`search` обрезается по краям, ограничен 200 символами и выполняет +регистронезависимый поиск внутри выбранной вкладки: по тексту публикации, имени +программы/проекта либо имени и фамилии пользователя. + +Ответ использует limit/offset pagination: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 123, + "source_type": "program", + "source": { + "id": 45, + "name": "Название программы", + "image_address": "https://example.com/program.png" + }, + "text": "Текст публикации", + "files": [], + "audience": "platform", + "datetime_created": "2026-08-06T12:00:00Z", + "datetime_updated": "2026-08-06T12:00:00Z", + "likes_count": 5, + "comments_count": 3, + "views_count": 18, + "is_user_liked": true + } + ] +} +``` + +Counts и состояние лайка аннотируются в queryset; источники и файлы +prefetch-ятся, поэтому размер страницы не создает N+1. + +## Audience программ + +`News.audience` принимает: + +- `platform` — публикация доступна всем авторизованным пользователям и может + быть показана во вкладке программ; +- `program_participants` — публикация доступна участникам программы, её + менеджерам и staff/superuser. + +`program_participants` разрешен только для `PartnerProgram`. Новости +пользователей и проектов всегда имеют `platform`. + +База ограничивает поле двумя допустимыми значениями. Проверка того, что +`program_participants` относится именно к `PartnerProgram`, выполняется в +model validation и на API-boundary: generic foreign key нельзя надежно +сопоставить с content type в статическом check constraint. + +Контекстное создание программы совместимо с Angular: + +```text +POST /programs//news/ +``` + +```json +{ + "text": "Текст новости", + "files": [], + "audience": "platform" +} +``` + +Если `audience` отсутствует, создается `program_participants`. Менеджер может +изменить поле через существующий PATCH. Недоступная +внутренняя публикация исключается из контекстного списка и возвращает `404` в +detail. + +Data migration переводит все существующие program news в +`program_participants`, а остальные новости — в `platform`. + +## Detail, лайки и просмотры + +```text +GET /feed/news// +POST /feed/news//set-liked/ +POST /feed/news//set-viewed/ +``` + +Detail возвращает тот же объект, что элемент списка. Он предназначен для +будущего маршрута `/office/news/`. Публичная новость программы +доступна авторизованным пользователям, внутренняя — только своей аудитории, +новость проекта — только если проект опубликован и публичен. Недоступный, +несуществующий или служебный объект возвращает `404`. + +Лайк: + +```json +{ "is_liked": true } +``` + +```json +{ "is_user_liked": true, "likes_count": 6 } +``` + +Просмотр не требует payload и возвращает: + +```json +{ "views_count": 18 } +``` + +Обе операции идемпотентны благодаря существующим уникальным ограничениям +`core.Like` и `core.View`. Перед изменением проверяется доступ к публикации. + +## Комментарии + +```text +GET /feed/news//comments/ +POST /feed/news//comments/ +PATCH /feed/news//comments// +DELETE /feed/news//comments// +``` + +Список использует limit/offset pagination и сортировку от старых комментариев к +новым. Создание и изменение принимают: + +```json +{ "text": "Комментарий" } +``` + +Пробелы по краям удаляются; пустой текст и текст длиннее 2000 символов +возвращают `400`. + +Ответ: + +```json +{ + "id": 17, + "author": { + "id": 8, + "name": "Имя Фамилия", + "image_address": "https://example.com/avatar.png" + }, + "text": "Комментарий", + "datetime_created": "2026-08-06T12:00:00Z", + "datetime_updated": "2026-08-06T12:00:00Z", + "is_edited": false, + "can_edit": true, + "can_delete": true +} +``` + +Читать и создавать комментарии может любой авторизованный пользователь с +доступом к новости. Редактирует только автор; удаляет автор либо +staff/superuser. `news_id` входит в lookup комментария, поэтому подмена пары +`news_id/comment_id` дает `404`. Удаление новости каскадно удаляет комментарии. + +## Обратная совместимость и ограничения + +- `/feed/` сохраняет служебные project/vacancy records, старый serializer и + намеренное исключение новостей программ; +- context API пользователей, проектов и программ сохраняет URL и основные + поля; +- репостов нет: в продукте это копирование detail-ссылки; +- комментарии плоские, без ответов, лайков, упоминаний и файлов; +- создание публикаций из общей ленты не добавлено: программы продолжают + публиковать через context endpoint; +- UI ленты, popup, deep-link recovery и копирование ссылки входят в DEV-083.2; +- DEMO-набор DEV-083.3 создаётся существующей защищённой командой + `python manage.py seed_react_dev_demo --confirm-react-dev`. Он содержит по 11 + публичных новостей программы, проекта и пользователей, реакции и 21 комментарий + у одной публикации. Отдельная новость `program_participants` предназначена для + проверки detail-доступа участника и безопасного `404` для постороннего пользователя. + Точные правила повторного запуска, `--dry-run` и `--reset` описаны в + `docs/react-dev-demo-data.md`. + +Angular-аудит подтвердил: карточка копирует отдельную ссылку, project/profile +detail открывает новость в модальном маршруте, а блок комментариев в карточке +закомментирован и отдельного Angular-flow комментариев нет. diff --git a/docs/submission-evaluation-domain.md b/docs/submission-evaluation-domain.md new file mode 100644 index 00000000..c15a639f --- /dev/null +++ b/docs/submission-evaluation-domain.md @@ -0,0 +1,683 @@ + +# Submission Evaluation Domain RFC + +Статус: proposal. + +Фактический статус MVP-моделей на 25 июля 2026 года: + +- `SubmissionExpertAssignment`, `Evaluation` и `EvaluationScore` реализованы в + `partner_programs`; +- Assignment service и manager API реализованы: + `GET/POST /programs//submission-assignments/` и + `POST /submission-assignments//revoke/`; +- Expert Submission read API, Evaluation mutation API и manager Evaluation + read API реализованы в рамках DEV-050, DEV-051 и DEV-052; +- изменение submitted Evaluation и неизменяемая история реализованы в рамках + DEV-073; +- frontend, `Result`, ranking и публикация итогов еще не реализованы; +- временно используется существующий `project_rates.Criteria`; +- дедлайном MVP остается существующий `datetime_evaluation_ends`; + +Документ описывает следующий этап React-контура PROCOLLAB: назначение +экспертов на конкретные `Submission`, кабинет эксперта и управляемую отправку +оценки. RFC является только анализом. Он не меняет модели, миграции, +serializers, views, permissions, API, admin, settings или deploy. + +Главное правило: + +> Эксперт оценивает зафиксированную `Submission`, а не изменяемый legacy +> `Project`. + +## 1. Текущее состояние + +### Реализованный React-контур + +В `partner_programs` уже существуют: + +- `Application` для индивидуального или командного участия; +- `Team`, `TeamMember` и `TeamInvite`; +- `Submission` с версиями, `stage_key` и статусами `draft`, `submitted`, + `returned`, `final`, `cancelled`; +- Submission API: + - `GET/POST /applications//submissions/`; + - `GET/PATCH /submissions//`; + - `POST /submissions//submit/`; + - `POST /submissions//cancel/`; +- owner/captain, accepted-team-member, manager и staff permissions. + +`Submission` хранит решение независимо от `Project`. Уникальность версии уже +защищена по `application + stage_key + version`. Экспертного доступа к +Submission и нового Evaluation API пока нет. + +### Аудит legacy evaluation + +| Область | Текущая реализация | Вывод для нового контура | +|---|---|---| +| Критерии | `project_rates.Criteria`: Program, имя, описание, тип `str/int/float/bool`, числовые границы | Можно временно использовать как каталог критериев Program, но не как готовую схему Evaluation | +| Оценка | `ProjectScore`: FK на Criteria, User и Project, строковое `value` | Не переиспользовать: оценивает Project, не имеет формы, статуса, комментария и времени финальной отправки | +| Уникальность | `criteria + user + project` | Не защищает `submission + expert` и не моделирует атомарную отправку формы | +| Валидация | `ProjectScoreValidator` проверяет тип и числовые границы | Логику проверки диапазонов можно адаптировать в новом service, не привязывая Evaluation к ProjectScore | +| Эксперт | `users.Expert` связан OneToOne с User и ManyToMany с PartnerProgram | Профиль и membership Program переиспользуются | +| Назначение | `ProjectExpertAssignment`: Program, Project, Expert | Не переиспользовать: target — Project; нет назначения на конкретную версию Submission | +| Распределение | `PartnerProgram.is_distributed_evaluation` включает фильтрацию назначенных Project | Новый контур всегда требует явного назначения на Submission; legacy-флаг не определяет новые права | +| Лимит | `PartnerProgram.max_project_rates` ограничивает число экспертов Project | Не применять автоматически к Submission без отдельного продуктового решения | +| Дедлайн | `PartnerProgram.datetime_evaluation_ends` | Кандидат на общий дедлайн, но его совместное использование legacy/React flow нужно подтвердить | +| Permissions | `IsExpert` проверяет роль и membership Program; `IsExpertPost` проверяет только `user_type` | Недостаточно: новый доступ обязан проверять активное назначение на Submission | +| API | `GET/POST /rate-project/`, `POST /rate-project/rate/` | Остается неизменным только для Angular/legacy Project flow | +| Serializer | Возвращает Project и плоские criteria/score; повторный POST делает upsert | Нельзя использовать как Evaluation response: нет lifecycle и изоляции персональных данных | +| Admin | Criteria, ProjectScore и bulk ProjectExpertAssignment | Паттерны фильтров и bulk-назначения полезны, но новые сущности требуют отдельного admin | +| Экспорт | `/programs//export-rates/` читает ProjectScore | Новый Result/export не подключать к legacy-выгрузке автоматически | + +Аудит опирается на: + +- `project_rates/models.py`, `serializers.py`, `views.py`, `services.py`, + `validators.py`, `permissions` в `users/permissions.py`; +- `project_rates/admin.py`, `signals.py`, `urls.py`; +- `users.Expert`, `PartnerProgram.experts`, + `PartnerProgram.datetime_evaluation_ends`, `max_project_rates` и + `is_distributed_evaluation`; +- `partner_programs.Submission`, Submission serializers, views, permissions, + admin и API tests; +- tests модулей `project_rates`, `users` и export tests `partner_programs`; +- `docs/application-project-submission-rfc.md`, + `docs/case-championship-domain.md` и `docs/modules/project-rates.md`. + +Текущие tests подтверждают upsert ProjectScore, проверку membership эксперта, +Program/Project/Criteria consistency, диапазоны, лимит экспертов, +распределенные назначения, запрет удаления назначения после оценки и legacy +Excel export. Эти правила полезны как reference, но не являются тестами +Evaluation по Submission. + +## 2. Проблема legacy ProjectScore + +`ProjectScore` нельзя расширить до новой Evaluation без смешения двух контуров: + +- `Project` может измениться после оценки, а `Submission` фиксирует конкретную + версию решения; +- одна строка ProjectScore представляет один критерий, поэтому у полной формы + эксперта нет атомарного статуса `draft/submitted`; +- отдельного комментария нет: signal создает специальный строковый критерий + «Комментарий»; +- нет `submitted_at`, блокировки после отправки и управляемого возврата к + редактированию; +- назначение связано с `Project`, а не с `Submission.stage_key/version`; +- legacy API раскрывает Project-oriented response и не подходит для + минимизированного экспертного serializer; +- изменение ProjectScore или старых endpoints создает риск для Angular, + export и production flow. + +Поэтому новый контур добавляется рядом с `project_rates`. Автоматический +backfill ProjectScore в Evaluation и обратная синхронизация не входят в MVP. + +## 3. Целевая модель + +### Выбор хранения критериев + +Для MVP выбран **вариант B**: + +- одна `Evaluation` хранит владельца, статус, комментарий, итог и lifecycle + полной формы; +- отдельная `EvaluationScore` хранит значение каждого критерия. + +Вариант A с JSON внутри Evaluation проще по количеству таблиц, но не дает +надежной ссылочной целостности, uniqueness по критерию, удобной проверки +полноты формы и безопасной аналитики. Изменение одного элемента JSON также +сложнее аудитировать. + +Нормализованный вариант позволяет атомарно отправлять всю форму в service, +сохраняя DB uniqueness для каждого критерия. + +### SubmissionExpertAssignment + +Отдельная сущность назначения эксперта на конкретную Submission. + +Предлагаемые поля: + +- `id`; +- `submission`, FK на `partner_programs.Submission`; +- `expert`, FK на `users.Expert`; +- `status`; +- `assigned_by`, FK на User; +- `assigned_at`; +- `revoked_by`, nullable FK на User; +- `revoked_at`, nullable; +- `revoke_reason`, blank; +- `completed_at`, nullable; +- `created_at`, `updated_at`. + +Назначения не удаляются через публичный API. Повторное назначение после +отзыва создает новый исторический episode. Текущее активное назначение +определяется статусом, а не последней строкой без проверки. + +### Evaluation + +Одна форма одного эксперта для одной Submission. + +Предлагаемые поля: + +- `id`; +- `submission`, FK на Submission; +- `expert`, FK на `users.Expert`; +- `status`; +- `comment`, blank; +- `total_score`, nullable Decimal; +- `submitted_at`, nullable; +- `created_at`, `updated_at`. + +`total_score` не принимается как доверенное клиентское значение. Оно +вычисляется backend service только при наличии согласованной настройки +формулы Program. Пока формулы нет, поле остается `null`. + +### EvaluationScore + +Предлагаемые поля: + +- `id`; +- `evaluation`, FK на Evaluation, related name `scores`; +- `criterion`, FK на `project_rates.Criteria` с `PROTECT`; +- `value`, Decimal; +- snapshot-поля `criterion_name`, `criterion_type`, `min_value`, `max_value`; +- `created_at`, `updated_at`. + +Для первого MVP `Criteria` можно переиспользовать как Program-scoped каталог, +потому что он уже имеет admin, тип и числовые границы. При этом: + +- допускаются только числовые `int/float` Criteria; +- legacy-критерий «Комментарий» не становится EvaluationScore: комментарий + хранится в `Evaluation.comment`; +- принадлежность Criteria той же Program проверяет новый service; +- snapshot фиксирует смысл критерия на момент оценки; +- `ProjectScore` и `ProjectScoreValidator` не становятся частью target-модели. + +Если продукту нужны weight, order, required, active и versioned schema, нужен +отдельный `EvaluationCriterion` в самостоятельном PR. Не следует молча +добавлять эти значения в legacy Criteria. + +### EvaluationAmendment + +`EvaluationAmendment` — неизменяемая audit-запись изменения submitted +Evaluation: + +- `evaluation`; +- `changed_by`; +- `previous_comment`, `comment`; +- `previous_scores`, `scores`; +- `previous_total_score`, `total_score`; +- `created_at`. + +`Evaluation.amended_at` хранит время последнего фактического изменения. +Снимки scores включают идентификатор и snapshot критерия вместе со значением. + +## 4. Сущности и связи + +```text +PartnerProgram + ├── experts ── users.Expert + ├── criterias ── project_rates.Criteria (переходный каталог) + └── applications + └── submissions + ├── SubmissionExpertAssignment ── Expert + └── Evaluation ── Expert + └── EvaluationScore ── Criteria +``` + +Инварианты: + +- `Evaluation.submission.program` совпадает с Program каждого Criteria; +- эксперт Evaluation совпадает с экспертом активного назначения; +- эксперт должен состоять в `submission.program.experts`; +- Evaluation создается только для `submitted` или `final` Submission; +- участник, Team и Project не являются владельцами Evaluation; +- одна Submission может иметь несколько экспертов и по одной Evaluation + каждого эксперта; +- Evaluation не изменяет Submission, Project или Application. + +Cross-table и ManyToMany invariants проверяются транзакционным domain service. +Их нельзя надежно выразить обычным `CheckConstraint`. + +## 5. Статусы и переходы + +### Статусы назначения + +| Статус | Назначение | Допустимый переход | +|---|---|---| +| `assigned` | Эксперт может читать работу и вести draft Evaluation | `completed` или `revoked` | +| `completed` | Эксперт отправил Evaluation | Терминальный в MVP | +| `revoked` | Менеджер снял эксперта до финальной оценки | Терминальный; новое назначение — новая строка | + +Правила: + +- назначение создается сразу как `assigned`; +- `assigned → completed` выполняется атомарно вместе с submit Evaluation; +- `assigned → revoked` разрешен менеджеру до submit Evaluation; +- `completed` нельзя отозвать: требуется отдельная процедура аннулирования + финальной оценки, которой нет в MVP; +- draft Evaluation при revoke сохраняется, но становится недоступной эксперту; + повторное назначение того же эксперта может продолжить этот draft. + +### Статусы Evaluation + +| Статус | Редактирование | Допустимый переход | +|---|---|---| +| `draft` | Назначенным экспертом | `submitted` | +| `submitted` | Только отдельный amend владельца с audit | Остается `submitted` | + +MVP намеренно не добавляет `cancelled`, `revised` или `reopened` без готового +audit contract. Возврат submitted Evaluation в draft запрещен; DEV-073 меняет +ее только отдельной атомарной операцией с неизменяемой историей. + +Будущее явное правило может добавить manager-only action `reopen` с +обязательной причиной. До перехода прежняя финальная форма и scores должны +копироваться в неизменяемую revision/audit запись. Нельзя просто перевести +submitted Evaluation обратно в draft и перезаписать исторический результат. + +### Submission во время оценки + +Новый статус `in_review` в Submission не нужен. Состояние оценки вычисляется +из назначения и Evaluation: + +- `submitted` — версия зафиксирована и может быть назначена эксперту; +- `final` — зафиксированная версия также может оцениваться; +- `returned` — редактируется участником, новые/финальные Evaluation запрещены; +- `draft` и `cancelled` — не назначаются и не оцениваются. + +Derived UI-состояния: `not_assigned`, `assigned`, `evaluation_draft`, +`evaluated`. Они не сохраняются как новые Submission statuses. + +## 6. Права доступа + +| Роль | Submission | Evaluation | Назначения | +|---|---|---|---| +| Участник/капитан | Свои работы по существующим правилам | Только опубликованный итог/комментарий по отдельной Program policy | Нет | +| Accepted TeamMember | Read-only своей Application/Submission | Только разрешенный опубликованный результат | Нет | +| Эксперт | Только активно назначенные или завершенные им Submission | Создает/редактирует только свой draft, читает свою submitted Evaluation | Нет | +| Менеджер Program | Submission, назначения и Evaluation только своей Program | Read-only всех форм; future reopen только отдельным action | Назначает и отзывает до submit | +| Staff/superuser | Административный доступ | Административный доступ с audit | Административный доступ | +| Посторонний эксперт | Нет | Нет | Нет | + +Обязательные правила: + +- membership `Expert.programs` сам по себе не дает доступ ко всем Submission; +- активное/завершенное назначение проверяется для каждого объекта; +- чужой или неназначенный Submission возвращается как 404, чтобы не + подтверждать его существование; +- участник никогда не видит draft Evaluation или внутренние manager notes; +- submitted комментарий показывается участнику только после явного правила + публикации Program; до появления policy participant Evaluation endpoint не + добавляется; +- manager не подменяет эксперта и не редактирует его scores напрямую; +- staff-действия должны логировать actor и причину. + +Новые permissions должны жить рядом с Submission/Evaluation domain helpers. +Legacy `IsExpert` и особенно `IsExpertPost` недостаточны: последний проверяет +только `user_type`, но не назначение. + +## 7. API-контракт + +Все endpoints требуют authentication. JSON использует обычный DRF contract. +Ошибки: + +- `401` — пользователь не авторизован; +- `403` — роль не позволяет выполнить действие в известном Program context; +- `404` — Submission/Evaluation/assignment отсутствует или скрыта object-level + permission; +- `400` — payload, Criteria или диапазон невалидны; +- `409` — конфликт статуса, повторное активное назначение или закрытая форма; +- `429` — scoped throttle. + +### Expert cabinet + +#### `GET /expert/submissions/` + +Права: Expert видит только свои `assigned` и `completed` назначения; staff +может использовать административный режим отдельно. + +Query: опционально `program_id`, `submission_status`, `evaluation_status`, +pagination. + +Response item: + +```json +{ + "id": 42, + "program": {"id": 7, "name": "Case Championship"}, + "title": "Решение", + "status": "submitted", + "stage_key": "main", + "version": 2, + "submitted_at": "2026-08-31T18:00:00Z", + "assignment": { + "id": 15, + "status": "assigned", + "assigned_at": "2026-09-01T09:00:00Z" + }, + "my_evaluation": { + "id": 9, + "status": "draft", + "updated_at": "2026-09-02T10:00:00Z" + } +} +``` + +Endpoint безопасен для повторов. Дополнительный mutation throttle не нужен. + +#### `GET /expert/submissions//` + +Права: назначенный эксперт или staff. Для неназначенного эксперта — 404. + +Response содержит только solution-поля Submission, разрешенную часть +`form_data`, criteria snapshot для формы, assignment и собственную Evaluation. +Application form, User, TeamMember, email, phone и `submitted_by` не +возвращаются. + +Endpoint безопасен для повторов. Дополнительный mutation throttle не нужен. + +### Evaluation эксперта + +#### `GET /submissions//evaluations/my/` + +Права: назначенный эксперт. Слово `my` означает Evaluation текущего эксперта, +а не Evaluation участника Submission. + +Response: `200` с собственной Evaluation и scores; `404`, если Evaluation еще +не создана или Submission не назначена. GET ничего не создает. + +#### `POST /submissions//evaluations/` + +Права: эксперт с текущим `assigned` assignment; Submission имеет статус +`submitted/final`. + +Request: + +```json +{ + "comment": "Сильная аргументация", + "scores": [ + {"criterion_id": 3, "value": "8.5"}, + {"criterion_id": 4, "value": "7"} + ] +} +``` + +Создает draft и атомарно валидирует Criteria, Program и диапазоны. Пустой +draft допустим, если UI создает его перед первым autosave. + +Идемпотентность: uniqueness `submission + expert` и `get_or_create` под +transaction. Первый запрос возвращает `201`; повтор при существующем draft +возвращает `200` без неявного изменения данных. Для изменения используется +PATCH. При существующей submitted Evaluation возвращается `409`. + +Рекомендуемый scope: `evaluation_create`, default `10/min`. + +#### `GET/PATCH /evaluations//` + +GET: владелец-эксперт, manager соответствующей Program или staff. Для +участника endpoint закрыт. + +PATCH: только владелец-эксперт, активное назначение и статус `draft`. + +Request может содержать `comment` и `scores`. Если передан `scores`, service +атомарно заменяет набор scores формы, а не оставляет неоднозначный частичный +набор. `submission`, `expert`, `status`, `total_score` и timestamps read-only. + +Повторная установка тех же значений идемпотентна. Ошибка диапазона — 400; +чужой объект — 404; revoked assignment или submitted Evaluation — 409. + +Для PATCH/autosave нужен отдельный разумный rate, например +`evaluation_update=120/min`; POST-only throttle его не покрывает. + +#### `POST /evaluations//submit/` + +Права: владелец-эксперт с активным назначением. + +Request: пустой объект. + +Service под transaction и row locks: + +1. повторно проверяет assignment и статус Submission; +2. проверяет наличие всех обязательных Criteria; +3. валидирует scores; +4. вычисляет `total_score`, только если Program имеет формулу; +5. переводит Evaluation в `submitted`; +6. фиксирует `submitted_at`; +7. переводит assignment в `completed`. + +Повторный submit уже submitted Evaluation возвращает тот же объект с `200` и +не изменяет `submitted_at`. Другие конфликты состояния — 409. + +Рекомендуемый scope: `evaluation_submit`, default `20/min`. + +#### `PATCH /evaluations//amend/` + +Права: только владелец-эксперт. Evaluation должна оставаться в статусе +`submitted`, Submission — в `submitted/final`, назначение — в +`assigned/completed`, а эксперт должен по-прежнему состоять в Program. + +Можно изменить `comment` и полностью заменить `scores`. Переданный набор +scores обязан содержать все числовые Criteria Program; типы, диапазоны и +принадлежность Program проверяются теми же правилами, что для draft. +Изменение выполняется атомарно, не создает новую Evaluation, не меняет +`submitted_at` и статус assignment. `amended_at` обновляется только при +фактическом изменении, а `total_score` сбрасывается до отдельного пересчета. + +Полностью совпадающий запрос является no-op и не создает запись истории. +Рекомендуемый scope: `evaluation_amend`, default `30/min`. + +#### `GET /evaluations//amendments/` + +Неизменяемые снимки до и после изменения доступны владельцу Evaluation, +manager соответствующей Program и staff/superuser. Остальные получают `404`, +чтобы endpoint не раскрывал существование оценки. + +### Назначение экспертов менеджером + +#### `GET /programs//submission-assignments/` + +Права: manager Program или staff. + +Query: `submission_id`, `expert_id`, `status`. Response содержит историю +назначений и краткий статус Evaluation, но не participant form data. + +#### `POST /programs//submission-assignments/` + +Права: manager Program или staff. + +Request: + +```json +{ + "submission_id": 42, + "expert_id": 11 +} +``` + +Проверяет Program, статус Submission, Expert membership и отсутствие другого +активного/завершенного назначения этой пары. + +Первое назначение — `201`. Повтор того же запроса при уже `assigned` возвращает +существующую строку с `200`. После `revoked` создается новый episode. +`completed` или конфликт Program возвращает 409/400 соответственно. + +Рекомендуемый scope: `submission_assignment_create`, default `60/min`. + +#### `POST /submission-assignments//revoke/` + +Права: manager соответствующей Program или staff. + +Request: + +```json +{ + "reason": "Перераспределение нагрузки" +} +``` + +Допустим только переход `assigned → revoked`, пока Evaluation не submitted. +Повторный revoke возвращает ту же строку с `200` и не меняет `revoked_at`. +Completed assignment возвращает 409. + +Delete endpoint не используется, чтобы сохранять историю. + +### Не входящий в MVP reopen + +DEV-073 разрешает владельцу точечно изменить submitted Evaluation без возврата +в draft. Отдельный manager action `POST /evaluations//reopen/` по-прежнему +не входит в MVP: он потребует reason, отдельной модели переходов статуса и +самостоятельного продуктового решения. + +## 8. DB constraints + +Обязательные ограничения: + +1. Partial `UniqueConstraint(submission, expert)` для assignment в статусах + `assigned/completed`. Он исключает два активных назначения и новое + назначение после завершенной оценки; несколько `revoked` строк сохраняют + историю. +2. `UniqueConstraint(submission, expert)` для Evaluation. +3. `UniqueConstraint(evaluation, criterion)` для EvaluationScore. +4. `EvaluationAmendment` хранит неизменяемые JSON-снимки comment, scores и + total_score до и после каждой фактической правки. +5. Общий `value >= 0` не добавляется без продуктового правила: существующий + Criteria может допускать другой диапазон. Индивидуальные min/max являются + cross-row правилом и проверяются service/model validation; для Criteria + типа `int` дополнительно запрещается дробное значение. +6. `submitted_at IS NOT NULL` для submitted Evaluation и `IS NULL` для draft, + если синтаксис текущей версии Django/PostgreSQL позволяет выразить это без + неоднозначности. +7. Assignment timestamps согласуются со статусом: revoked требует + `revoked_at`, completed требует `completed_at`. + +Транзакционный service дополнительно проверяет: + +- `submission.program` соответствует Program manager endpoint; +- Expert состоит в `submission.program.experts`; +- Criteria относится к `submission.program`; +- Submission имеет `submitted/final`; +- значения находятся в диапазонах Criteria; +- набор обязательных Criteria полон; +- submit и assignment transitions выполняются с `select_for_update`; +- IntegrityError преобразуется в контролируемый 409, а не 500. + +Связь Submission с другой Program и Expert membership нельзя проверить +простым DB constraint: это FK/M2M cross-table invariants. + +Поведение при снятии эксперта: + +- assignment остается в истории как `revoked`; +- draft Evaluation и scores не удаляются, но блокируются для эксперта; +- submitted Evaluation запрещает revoke; +- hard delete Evaluation из публичного API отсутствует. + +История финальной оценки сохраняется неизменностью submitted Evaluation. +Будущий reopen требует отдельной revision/audit модели; перезапись submitted +scores недопустима. + +## 9. Безопасность персональных данных + +Экспертный serializer строится отдельно от participant/manager serializers. +Он не должен возвращать: + +- `Application.user`, `created_by` и registration `form_data`; +- Team, TeamMember, приглашения и contact data; +- email, phone, город, образование и другие поля профиля; +- внутренние manager notes; +- Evaluation других экспертов. + +Эксперту достаточно: + +- идентификатора/анонимного кода Submission; +- Program, stage и version; +- title, description, links; +- только разрешенных полей solution `form_data`; +- criteria; +- собственного assignment и собственной Evaluation. + +Если `Submission.form_data` допускает произвольные ключи, нельзя отдавать его +эксперту целиком без schema allowlist. До появления field-level policy +безопаснее возвращать только явно известные solution-поля. + +Логи не должны содержать полный payload Evaluation или персональные данные. +Manager/staff actions записывают actor, object id, transition и reason. + +## 10. Совместимость + +RFC не меняет: + +- `Project`, `ProjectScore`, `ProjectExpertAssignment` и Criteria API; +- `PartnerProgramProject`, `PartnerProgramUserProfile`; +- Application, Team, TeamInvite и Submission model/API; +- `/rate-project/` и `/programs//export-rates/`; +- старый Angular flow; +- production и React-dev; +- settings, Docker, nginx и GitHub Actions. + +Новые таблицы и endpoints должны жить рядом с legacy-контуром. Нельзя: + +- автоматически создавать ProjectScore из Evaluation; +- включать Evaluation в legacy export без отдельного contract; +- считать `max_project_rates` лимитом новых назначений; +- давать Expert доступ через общий Submission detail только на основании роли; +- менять Submission status при сохранении draft Evaluation; +- реализовывать Result в Evaluation PR. + +## 11. План следующих PR + +1. **Evaluation models.** Добавить `SubmissionExpertAssignment`, `Evaluation`, + `EvaluationScore`, migrations, admin, constraints и model tests. API не + добавлять. +2. **Assignment service/API (реализовано).** Транзакционные manager permissions, + list/create/revoke, history и tests. +3. **Expert Submission read API (реализовано).** Изолированный queryset, + PII-safe serializer, criteria contract и object-level permission tests. +4. **Evaluation mutation API (реализовано).** Draft create/update/submit, + idempotency, locking, ranges, completeness и throttling. +5. **Manager evaluation read API (реализовано).** Просмотр форм/статусов своей + Program без редактирования экспертных scores. +6. **React expert cabinet.** Список назначений, detail, autosave draft и + финальная отправка. +7. **Publication and Result RFC.** Настройка видимости участнику, формула + агрегации, ranking и ручное решение менеджера. Только после этого — + participant result UI/export. + +Каждый coding PR должен сохранять legacy tests и добавлять собственные +permission, transition, concurrency и PII regression tests. + +## 12. Открытые продуктовые вопросы + +1. Считать ли `datetime_evaluation_ends` общим дедлайном ProjectScore и + Submission Evaluation или нужен отдельный deadline? +2. Остается ли legacy Criteria временным каталогом или до первого coding PR + нужны `EvaluationCriterion`, order, weight, required и schema version? +3. Все ли criteria обязательны при submit и допустимы ли текстовые/boolean + ответы кроме общего комментария? +4. Как ограничивается число экспертов одной Submission? Нельзя автоматически + переиспользовать `max_project_rates`. +5. Должен ли эксперт видеть автора/команду или оценивание всегда анонимное? +6. Какие `Submission.form_data` поля безопасно показывать эксперту? +7. Когда участник видит submitted score и comment: сразу, после дедлайна, + после решения менеджера или только после публикации Result? +8. Нужен ли manager-only reopen? Если да, какая revision/audit модель хранит + предыдущую финальную форму? +9. Какая Submission version оценивается при нескольких stage/version и можно + ли назначать одновременно несколько версий одной Application? +10. Какая формула вычисляет `total_score`: сумма, среднее, weights или ручное + значение? До настройки Program поле остается `null`. +11. Может ли manager одновременно быть назначенным экспертом? Если да, права + evaluator и manager должны проверяться как разные действия. +12. Нужно ли уведомление при назначении, отзыве и submit Evaluation? Delivery + не входит в первые model/API PR. + +## Итоговый результат в будущем + +`Result` в этом RFC не реализуется. После появления финальных Evaluation +отдельный слой может рассчитывать: + +- средний балл; +- сумму; +- взвешенный балл; +- ручное решение менеджера; +- комбинацию автоматического результата и ручной модерации. + +Формулу нельзя зашивать в Evaluation service без настройки Program. +Evaluation сохраняет исходные финальные оценки; будущий Result хранит +агрегат, правило расчета, момент фиксации и публикации. diff --git a/docs/subscribed-projects-api.md b/docs/subscribed-projects-api.md new file mode 100644 index 00000000..e6cd3490 --- /dev/null +++ b/docs/subscribed-projects-api.md @@ -0,0 +1,95 @@ +# Subscribed Projects API + +## Назначение + +`GET /projects/subscribed/` возвращает карточки проектов, на обновления которых +подписан текущий пользователь. Endpoint предназначен для будущей React-вкладки +«Мои подписки» и требует авторизации. + +## Источник данных и видимость + +Список использует существующую M2M-связь `Project.subscribers`; отдельная модель +подписок не создаётся. Queryset формируется в базе данных как пересечение: + +1. проектов, где текущий пользователь присутствует в `subscribers`; +2. проектов, доступных ему по общим workspace-правилам. + +Опубликованный публичный проект доступен любому авторизованному пользователю. +Private или draft видны руководителю, collaborator, staff и superuser. Если +пользователь потерял доступ к private/draft проекту, карточка исчезает из выдачи, +но историческая M2M-связь подписки не удаляется. + +## Запрос + +```http +GET /projects/subscribed/ +GET /projects/subscribed/?page=2 +GET /projects/subscribed/?search=лаборатория +GET /projects/subscribed/?page=2&search=лаборатория +``` + +`search` применяется только к подпискам текущего пользователя, ищет по названию +без учёта регистра и игнорирует пробелы по краям. Пустое значение эквивалентно +отсутствию поиска. Персональные данные пользователей в поиск не входят. + +## Пагинация и сортировка + +Размер страницы равен 10, а ответ сохраняет общий формат project lists: + +```json +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 42, + "name": "Лаборатория городской среды", + "short_description": "Краткое описание", + "image_address": null, + "cover_image_address": null, + "draft": false, + "is_public": true, + "current_user_role": null, + "can_edit": false, + "can_use_in_application": false, + "activities": [], + "datetime_updated": "2026-08-11T09:30:00Z" + } + ] +} +``` + +Несуществующая или некорректная страница возвращает безопасный `404`. Проекты +сортируются по `-datetime_updated`, затем по `-id`, поэтому порядок остаётся +стабильным при одинаковом времени обновления. + +## Контракт карточки и запросы + +Endpoint переиспользует `ProjectWorkspaceListSerializer`, применяемый в +`/projects/my/` и `/projects/catalog/`. Он не возвращает подписчиков, email, +телефоны, даты рождения, приглашения или другие закрытые данные. + +Роли текущего пользователя и связанные активности загружаются заранее. Serializer +не обращается к базе, а фильтрация видимости не выполняется отдельно для каждой +карточки. GET не изменяет Project, подписки, collaborators, приглашения и новости. + +## Совместимость + +Workspace endpoints состояния и изменения подписки сохраняются без изменений: + +```http +GET /projects//workspace/subscription/ +POST /projects//workspace/subscription/ +DELETE /projects//workspace/subscription/ +``` + +Legacy Angular endpoints `subscribe`, `unsubscribe` и `subscribers` также не +изменяются. После успешной workspace-подписки доступный проект появляется в новом +списке, после отписки — исчезает. + +## Ограничения этапа + +DEV‑084.3 не добавляет React-интерфейс, список пользователей-подписчиков, +уведомления, автоматическую подписку, очистку потерявших доступ связей или новую +модель данных. Новостная лента, зависимости и deploy-конфигурация не меняются. diff --git a/docs/team-invites-api.md b/docs/team-invites-api.md new file mode 100644 index 00000000..15ce1053 --- /dev/null +++ b/docs/team-invites-api.md @@ -0,0 +1,137 @@ +# Team Invites API + +## Назначение + +`TeamInvite` оформляет согласие существующего пользователя платформы войти в +команду конкретной `Application`. Приглашение не является членством и само по +себе не дает доступа к Application, Team или Submission. Только атомарный +`accept` создает либо восстанавливает `TeamMember` со статусом `accepted`. + +Новый flow не использует `invites.Invite`: legacy-модель по-прежнему относится +только к `Project` и не менялась. + +## Модель + +`TeamInvite` хранит: + +- `team`, `user` и `invited_by`; +- статус `pending`, `accepted`, `declined` или `revoked`; +- `resolved_at`, `created_at`, `updated_at`. + +Условный `UniqueConstraint(team, user)` действует только для `pending`. +Завершенные строки остаются историей, после них пользователя можно пригласить +повторно. Индексы поддерживают список приглашений команды и пользователя. +Создание `TeamInvite` намеренно не создает предварительный `TeamMember`. + +Миграция: `0022_teaminvite_teaminvite_uniq_pending_invite_team_user`. + +## Endpoints + +| Method | Endpoint | Доступ | Назначение | +|---|---|---|---| +| GET | `/applications//team/invite-candidates/?q=...` | captain, staff | Найти потенциальных кандидатов | +| GET | `/applications//team/invites/` | captain, staff | История приглашений Team | +| POST | `/applications//team/invites/` | captain, staff | Создать pending по `{"user_id": id}` | +| GET | `/team-invites/my/` | invitee | Собственные приглашения, pending первыми | +| POST | `/team-invites//accept/` | invitee | Принять приглашение | +| POST | `/team-invites//decline/` | invitee | Отклонить приглашение | +| POST | `/team-invites//revoke/` | captain, staff | Отозвать приглашение | + +Manager и accepted member видят Team read-only, но не список приглашений и не +могут управлять им. Вложенные профили содержат только `id`, `display_name` и +`avatar`; email и закрытые поля не выдаются. + +Скрытый объект возвращает 404, известный объект без права на действие — 403, +нарушение статуса или domain invariant — 400. + +## Создание + +Создание разрешено только для командной draft-Application до +`datetime_application_ends`. Target должен: + +- существовать и не быть капитаном либо accepted member этой Team; +- иметь `PartnerProgramUserProfile` в Program; +- не владеть другой активной Application этой Program; +- не быть accepted member другой активной Team этой Program. + +Accepted-состав вместе с pending-приглашениями не должен превышать +`team_max_size`. Повторный запрос для уже существующего pending идемпотентно +возвращает его, не создавая новую строку. + +## Поиск кандидатов + +Поиск scoped к конкретной Team и не является глобальным каталогом пользователей. +Параметр `q` обязателен, очищается от пробелов и должен содержать от 3 до 100 +символов. Поиск выполняется по имени, фамилии, обоим порядкам полного имени и +email prefix без учета обычных вариантов регистра. Email используется только +в ORM-фильтре и никогда не включается в response. + +Selector возвращает не более 20 активных пользователей со стабильной +сортировкой. Каждый кандидат должен иметь Registration этой Program и не иметь: + +- accepted membership или капитанства в текущей Team; +- pending TeamInvite в текущую Team; +- другой активной собственной Application в Program; +- accepted membership другой активной Team в Program. + +Исторические declined/revoked invites, removed/left memberships и терминальные +Application не исключают кандидата. ORM использует `Exists`-подзапросы без +загрузки регистраций в Python, N+1 и размножающих строки join-ов. + +Search не создает TeamInvite, TeamMember и не резервирует место. Перед поиском +проверяются draft, application deadline и свободное место с учетом accepted + +pending. После выбора `POST .../team/invites/` заново выполняет все проверки +под блокировкой Program, поэтому устаревший результат поиска нельзя использовать +для обхода domain invariants. + +## Accept, decline и revoke + +Все переходы блокируют строки в порядке Program → Application → Team → +TeamInvite. `accept` повторно проверяет Registration, конфликт активного +участия, вместимость, draft и deadline, затем: + +1. создает или восстанавливает единственный `TeamMember(team, user)`; +2. устанавливает роль `member` и статус `accepted`; +3. сохраняет прежний `joined_at`, если пользователь уже состоял в Team; +4. переводит приглашение в `accepted` и заполняет `resolved_at`; +5. отзывает другие pending-приглашения пользователя в этой Program. + +`accept` уже принятого, `decline` уже отклоненного и `revoke` уже отозванного +приглашения идемпотентны. Другие терминальные переходы возвращают 400. После +submit Application новые create/accept/decline/revoke запрещены. + +Pending-приглашение блокирует смену team → individual, но не считается членом +команды при submit. Если accepted-состав соответствует policy Program, наличие +pending не мешает отправить Application. + +## Domain errors + +- `team_invite_permission_denied`; +- `team_invite_not_pending`; +- `team_invite_target_invalid`; +- `team_invite_registration_missing`; +- `team_invite_duplicate`; +- `team_invite_capacity_reached`; +- `team_invite_active_application_conflict`; +- `team_invite_not_owned`; +- общие `application_not_editable` и `application_deadline_passed`. + +Mutation endpoints имеют независимые локальные scopes +`team_invite_create/accept/decline/revoke` с rate `20/min`. Scoped GET-поиск +использует отдельный `team_invite_candidate_search` с тем же rate. Остальные +GET endpoints этим throttle не ограничиваются; глобальные DRF settings не +менялись. + +## Ограничения MVP + +Приглашение работает только для существующего `user_id`. В MVP отсутствуют: + +- приглашение по email, token или ссылке; +- expiry; +- email и in-app notifications; +- глобальный каталог и поиск пользователей вне контекста Team; +- organizer/manager mutation; +- frontend flow. + +`Team.captain`, `Application.user/created_by/project`, Project и +`projects.Collaborator` при принятии приглашения не меняются. diff --git a/docs/team-permissions-api.md b/docs/team-permissions-api.md new file mode 100644 index 00000000..ee593ead --- /dev/null +++ b/docs/team-permissions-api.md @@ -0,0 +1,133 @@ +# Team Permissions and API + +## Назначение + +Публичный Team API дает безопасный read-доступ к командной заявке и базовые +операции управления составом. `Team` относится к `Application`, а не к +`Project`; `projects.Collaborator` и legacy `invites.Invite` не участвуют в +этом flow. + +Новых участников нельзя добавить напрямую в TeamMember. Согласие пользователя +оформляется отдельной сущностью `TeamInvite`; ее endpoints описаны в +`docs/team-invites-api.md`. Этот API читает состав, меняет название, фиксирует +выход/удаление и передает капитанство. + +## Роли и права + +| Роль | Application | Team | Submission | Mutation Team | +|---|---|---|---|---| +| Владелец individual Application | read/write/submit/withdraw | — | read/write | — | +| Капитан Team | read/write/submit/withdraw | read | read/write | rename/remove/transfer | +| Accepted member | read-only | read | read-only | leave | +| Manager PartnerProgram | read-only | read | read-only | нет | +| Staff/superuser | текущий административный доступ | read | текущий административный доступ | rename/remove/transfer | + +Только `TeamMember.status=accepted` дает участнику read-доступ. `invited`, +`declined`, `removed` и `left` являются историей состава и не открывают +Application, Team или Submission. + +## Endpoints + +- `GET /applications//team/` — детали Team и состав; +- `PATCH /applications//team/` — изменить только `name`; +- `POST /applications//team/leave/` — выйти обычному member; +- `POST /applications//team/members//remove/` — + перевести member в `removed`; +- `POST /applications//team/transfer-captain/` — передать роль, + body: `{"member_id": }`. + +Капитан и staff также могут использовать scoped-поиск кандидатов +`GET /applications//team/invite-candidates/?q=...`. Accepted +member и manager сохраняют read-only доступ к Team, но получают 403 на поиск и +список приглашений; постороннему существование Team скрывается ответом 404. +Контракт поиска и правила eligibility описаны в `docs/team-invites-api.md`. + +Team response содержит безопасный профиль пользователя (`id`, +`display_name`, `avatar`) без email, телефона и закрытых полей. Состав +сортируется: капитан, accepted members, исторические статусы, затем дата/id. +Также возвращаются размерные ограничения Program, роль текущего пользователя и +флаги `can_edit`, `can_manage_members`, `can_leave`. + +Application response дополнен компактным read-only `team` summary: `id`, +`name`, `captain_id`, `accepted_members_count`, `current_user_role`. Для +individual/undecided значение равно `null`; полный состав остается только в +Team detail. + +## Leave, remove и transfer + +Все mutation разрешены только для `Application.status=draft` и до +`datetime_application_ends`; отдельный Application deadline не имеет fallback +на legacy-даты. После submit состав, капитан и название заблокированы. + +`leave` доступен accepted member с ролью `member`. Статус становится `left`, +строка и `joined_at` сохраняются. Повторный запрос возвращает стабильную +ошибку `team_membership_not_active`. Капитан получает +`captain_transfer_required`. + +`remove` доступен капитану/staff для accepted или invited member. Статус +становится `removed`, `joined_at` не очищается. Капитана удалить нельзя; +исторический статус возвращает `team_member_not_removable`. + +`transfer-captain` требует accepted member той же Team, Registration этой +Program и отсутствие другой активной собственной/командной Application. +Операция блокирует Program, Application, Team и обе TeamMember, затем атомарно: + +1. переводит старого капитана в accepted member; +2. переводит target member в accepted captain; +3. меняет `Team.captain`; +4. меняет `Application.user`. + +`Application.created_by` и `Application.project` не меняются. Старый капитан +сохраняет read-доступ как accepted member, но теряет write/submit права. + +## Application и Submission read access + +`GET /programs//applications/my/` ищет в порядке: + +1. собственную активную Application; +2. активную Application accepted-команды; +3. последнюю собственную/командную терминальную Application. + +Accepted member и manager могут читать Application detail, список Submission и +Submission detail. PATCH/create/submit/cancel/withdraw остаются owner/captain +или staff operations. Постороннему пользователю объекты не раскрываются. + +## Доменные ошибки + +Team management service использует стабильные коды: + +- `team_permission_denied`; +- `captain_transfer_required`; +- `team_membership_not_active`; +- `team_member_not_found`; +- `team_member_not_removable`; +- `captain_transfer_target_invalid`; +- общие `application_not_editable`, `application_deadline_passed`, + `team_member_registration_missing`, `active_application_conflict`, + `captain_member_missing`, `captain_mismatch`. + +Permission boundary возвращает 403, скрытый объект — 404, нарушение состояния +или invariant — 400. + +## Throttling + +Mutation endpoints имеют независимые scoped buckets: + +- `team_rename`; +- `team_leave`; +- `team_remove_member`; +- `team_transfer_captain`. + +Rate равен `20/min` на scope и пользователя/IP. Throttle локален для Team API: +глобальные DRF settings и существующие application/submission scopes не +изменяются; GET Team отдельным throttle не ограничивается. + +## Ограничения MVP + +В API намеренно отсутствуют прямой add member, token/link/email invites, +expiry, уведомления и глобальный каталог пользователей. Базовый TeamInvite по +существующему `user_id` и scoped-поиск кандидатов реализованы отдельно. Manager +получает только read-access; +organizer review, approve/reject/return, изменение Submission lifecycle и +expert permissions остаются отдельными PR. Frontend и legacy Project flow не +затрагиваются. diff --git a/docs/typed-social-links-api.md b/docs/typed-social-links-api.md new file mode 100644 index 00000000..1dafed15 --- /dev/null +++ b/docs/typed-social-links-api.md @@ -0,0 +1,65 @@ +# Типизированные социальные ссылки профиля + +## Назначение + +Типизированный контракт позволяет React показывать Telegram, ВКонтакте, GitHub, +LinkedIn и сайт с однозначными подписями. Он существует рядом с legacy-массивом +`links`, который продолжает использовать Angular. + +## Модель + +`UserLink.kind` допускает значения: + +* `telegram`; +* `vk`; +* `github`; +* `linkedin`; +* `website`. + +Поле nullable. Существующие строки после миграции сохраняют `kind = null`; миграция +не классифицирует, не удаляет и не дублирует их. Условный DB constraint разрешает +не более одной типизированной ссылки каждого вида у пользователя и не ограничивает +число legacy-записей с `kind = null`. + +## Read contract + +`GET /auth/users/current/` и `GET /auth/users//` для владельца возвращают: + +```json +{ + "links": ["https://legacy.example.com/profile"], + "social_links": { + "telegram": "https://t.me/anna", + "github": "https://github.com/anna" + } +} +``` + +`GET /auth/profiles//` возвращает только `social_links`. Legacy-массив не входит +в безопасный публичный контракт и не раскрывается через новый endpoint. + +## Partial PATCH + +`PATCH /auth/users//` принимает объект `social_links`. Обновление атомарно и +применяется только к переданным ключам: + +```json +{ + "social_links": { + "telegram": "https://t.me/new_name", + "linkedin": null + } +} +``` + +В примере Telegram создаётся или обновляется, LinkedIn удаляется, а GitHub, +ВКонтакте и сайт остаются без изменений. Отсутствие `social_links` в PATCH ничего +не меняет. Неизвестные ключи, некорректные и повторяющиеся URL возвращают `400`. + +## Совместимость + +Legacy `links` остаётся массивом всех URL, поэтому Angular продолжает видеть ссылки +без изменения формата. Его обновление затрагивает только записи с `kind = null`, +не удаляет типизированные ссылки и не создаёт их дубликаты, если Angular отправит +полученный массив обратно. Тип не кодируется порядком массива. +Angular, регистрация и onboarding не меняются. diff --git a/docs/vacancies-react-api.md b/docs/vacancies-react-api.md new file mode 100644 index 00000000..fede48fc --- /dev/null +++ b/docs/vacancies-react-api.md @@ -0,0 +1,131 @@ +# API вакансий для React + +## Назначение + +Контур вакансий позволяет искать открытые роли в публичных проектах, отправлять +и отзывать собственный отклик, а руководителю проекта — рассматривать кандидатов. +Модели `Vacancy` и `VacancyResponse` и legacy API сохранены без миграций. + +## Публичный каталог + +`GET /vacancies/` возвращает только активные вакансии проектов, у которых +`draft=false` и `is_public=true`. Возраст вакансии выдачу не ограничивает. + +Поддерживаются параметры: + +- `search` — поиск без учёта регистра по роли, специализации, описанию и названию + проекта; +- `required_experience` — один или несколько вариантов опыта; +- `work_format` — один или несколько форматов работы; +- `work_schedule` — один или несколько вариантов графика; +- `salary_min` и `salary_max` — границы указанного вознаграждения; +- `project_id` — проект; +- `limit` и `offset` — пагинация. + +Фильтр `is_active=false` не позволяет получить закрытые вакансии через публичный +список. Стабильная сортировка выполняется по дате создания и идентификатору в +обратном порядке. + +`GET /vacancies//` открывает активную вакансию публичного +опубликованного проекта. Закрытую вакансию могут получить только руководитель её +проекта, staff и superuser. Для остальных закрытая или связанная с недоступным +проектом запись скрывается через `404`. + +## Отклик пользователя + +`POST /vacancies//responses/` требует авторизации и принимает: + +```json +{ + "why_me": "Почему я хочу присоединиться к проекту", + "accompanying_file": "https://cdn.example.test/cv.pdf" +} +``` + +Пользователь всегда определяется из сессии. Значение из payload не используется. +Сопроводительный `UserFile` должен принадлежать текущему пользователю. + +Отклик запрещён, если: + +- вакансия закрыта или её проект не является публичным и опубликованным; +- пользователь уже является руководителем или участником проекта; +- пользователь уже откликался на эту вакансию. + +Проверка и создание выполняются в одной транзакции под блокировкой вакансии, +поэтому параллельные запросы не создают два отклика. Уведомление руководителю +отправляется только после успешного завершения транзакции. + +## Свои отклики + +`GET /vacancies/responses/self` возвращает пагинированный список только текущего +пользователя вместе с безопасными данными вакансии и проекта. + +Статус определяется полем `is_approved`: + +- `null` — на рассмотрении; +- `true` — принят; +- `false` — отклонён. + +Пока значение равно `null`, владелец может обновить сообщение или файл через +`PATCH /vacancies/responses//` и отозвать отклик через +`DELETE /vacancies/responses//`. `null` в поле +`accompanying_file` удаляет связь с файлом. Обработанный отклик остаётся в +истории и больше не редактируется и не удаляется. + +Detail отклика доступен только его владельцу, руководителю соответствующего +проекта, staff и superuser. Для постороннего пользователя используется безопасный +`404`. + +## Работа руководителя + +`GET /vacancies//responses/` доступен руководителю проекта, staff и +superuser. Остальные авторизованные пользователи получают `403`, неизвестная +вакансия — `404`. + +Карточка кандидата построена по явному публичному allow-list и содержит только: + +- идентификатор; +- имя и фамилию; +- аватар; +- специализацию; +- публичные навыки; +- краткое описание профиля. + +Email, телефон, дата рождения, административные признаки и другие закрытые поля +не возвращаются. Сопроводительный файл сериализуется без данных его владельца и +доступен только кандидату и пользователям с правом управления вакансией. + +Решение принимается через: + +- `POST /vacancies/responses//accept/`; +- `POST /vacancies/responses//decline/`. + +При принятии одной атомарной операцией: + +1. отклик получает `is_approved=true`; +2. кандидат добавляется в `Collaborator` с ролью из вакансии; +3. вакансия закрывается; +4. остальные ожидающие отклики получают `is_approved=false`; +5. после commit отправляются уведомления принятому и отклонённым кандидатам. + +Повторная обработка уже принятого или отклонённого отклика возвращает `400` и не +создаёт дубликат участника. + +## Закрытие, возобновление и удаление + +- `POST /vacancies//close/` закрывает вакансию и отклоняет ожидающие + отклики; +- `POST /vacancies//reopen/` возобновляет вакансию только в публичном + опубликованном проекте; +- `DELETE /vacancies//` удаляет вакансию только при полном отсутствии + откликов. + +Эти операции доступны руководителю проекта, staff и superuser. История откликов +не удаляется вместе с вакансией. Проверка удаления и само удаление выполняются +под блокировкой одной транзакции. + +## Совместимость и ограничения + +Существующие модели, миграции, CRUD вакансий, workspace detail проекта, +служебные записи ленты и legacy endpoints не переименованы. Контур не добавляет +чат с кандидатом, собеседования, рейтинг кандидатов или внешние интеграции. diff --git a/feed/news_pagination.py b/feed/news_pagination.py new file mode 100644 index 00000000..e6c4a19a --- /dev/null +++ b/feed/news_pagination.py @@ -0,0 +1,11 @@ +from rest_framework.pagination import LimitOffsetPagination + + +class ReactNewsFeedPagination(LimitOffsetPagination): + default_limit = 10 + max_limit = 100 + + +class NewsCommentPagination(LimitOffsetPagination): + default_limit = 20 + max_limit = 100 diff --git a/feed/news_selectors.py b/feed/news_selectors.py new file mode 100644 index 00000000..a5fa787f --- /dev/null +++ b/feed/news_selectors.py @@ -0,0 +1,120 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Exists, OuterRef, Q, QuerySet, Value +from django.db.models.functions import Concat +from django.http import Http404 +from django.shortcuts import get_object_or_404 + +from core.models import Like +from news.access import can_view_news_in_react_feed +from news.models import News +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +NEWS_SOURCE_PROGRAM = "program" +NEWS_SOURCE_PROJECT = "project" +NEWS_SOURCE_USER = "user" +NEWS_SOURCES = ( + NEWS_SOURCE_PROGRAM, + NEWS_SOURCE_PROJECT, + NEWS_SOURCE_USER, +) + + +def _with_feed_annotations(queryset: QuerySet[News], user) -> QuerySet[News]: + news_content_type = ContentType.objects.get_for_model(News) + user_like = Like.objects.filter( + content_type=news_content_type, + object_id=OuterRef("pk"), + user=user, + ) + return queryset.annotate( + likes_count=Count("likes", distinct=True), + comments_count=Count("comments", distinct=True), + views_count=Count("views", distinct=True), + is_user_liked=Exists(user_like), + ) + + +def _content_source(source: str): + mapping = { + NEWS_SOURCE_PROGRAM: PartnerProgram, + NEWS_SOURCE_PROJECT: Project, + NEWS_SOURCE_USER: CustomUser, + } + return mapping[source] + + +def get_react_news_feed_queryset( + *, + source: str, + search: str, + user, +) -> QuerySet[News]: + """Строит вкладку только из публикаций выбранного доменного источника.""" + source_model = _content_source(source) + source_content_type = ContentType.objects.get_for_model(source_model) + source_objects = source_model.objects.all() + + if source == NEWS_SOURCE_PROJECT: + source_objects = source_objects.filter(draft=False, is_public=True) + + if search: + if source in (NEWS_SOURCE_PROGRAM, NEWS_SOURCE_PROJECT): + matching_source_ids = source_objects.filter( + name__icontains=search + ).values_list("id", flat=True) + else: + matching_source_ids = ( + source_objects.annotate( + search_full_name=Concat( + "first_name", + Value(" "), + "last_name", + ), + search_reverse_full_name=Concat( + "last_name", + Value(" "), + "first_name", + ), + ) + .filter( + Q(first_name__icontains=search) + | Q(last_name__icontains=search) + | Q(search_full_name__icontains=search) + | Q(search_reverse_full_name__icontains=search) + ) + .values_list("id", flat=True) + ) + search_filter = Q(text__icontains=search) | Q(object_id__in=matching_source_ids) + else: + search_filter = Q() + + queryset = ( + News.objects.filter( + content_type=source_content_type, + object_id__in=source_objects.values_list("id", flat=True), + audience=News.Audience.PLATFORM, + ) + .exclude(text__regex=r"^\s*$") + .filter(search_filter) + .select_related("content_type") + .prefetch_related("content_object", "files") + .order_by("-datetime_created", "-id") + ) + return _with_feed_annotations(queryset, user) + + +def get_react_feed_news_or_404(*, news_id: int, user) -> News: + queryset = _with_feed_annotations( + News.objects.select_related("content_type").prefetch_related( + "content_object", "files" + ), + user, + ) + news = get_object_or_404(queryset, pk=news_id) + if not can_view_news_in_react_feed(user, news): + # Единый 404 не раскрывает существование внутренней публикации. + raise Http404 + return news diff --git a/feed/news_serializers.py b/feed/news_serializers.py new file mode 100644 index 00000000..9003be59 --- /dev/null +++ b/feed/news_serializers.py @@ -0,0 +1,142 @@ +from rest_framework import serializers + +from files.serializers import UserFileSerializer +from news.models import News, NewsComment +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + +from feed.news_selectors import NEWS_SOURCES + + +class ReactNewsFeedQuerySerializer(serializers.Serializer): + source = serializers.ChoiceField(choices=NEWS_SOURCES, default="program") + search = serializers.CharField( + required=False, + allow_blank=True, + default="", + max_length=200, + trim_whitespace=True, + ) + + +class ReactNewsSourceSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + image_address = serializers.URLField(allow_null=True) + + +class ReactNewsFeedItemSerializer(serializers.ModelSerializer[News]): + source_type = serializers.SerializerMethodField() + source = serializers.SerializerMethodField() + files = UserFileSerializer(many=True) + likes_count = serializers.IntegerField(read_only=True) + comments_count = serializers.IntegerField(read_only=True) + views_count = serializers.IntegerField(read_only=True) + is_user_liked = serializers.BooleanField(read_only=True) + + def get_source_type(self, obj: News) -> str: + if isinstance(obj.content_object, PartnerProgram): + return "program" + if isinstance(obj.content_object, Project): + return "project" + return "user" + + def get_source(self, obj: News) -> dict: + source = obj.content_object + if isinstance(source, CustomUser): + name = f"{source.first_name} {source.last_name}".strip() + image_address = source.avatar + else: + name = source.name + image_address = source.image_address + return ReactNewsSourceSerializer( + { + "id": source.pk, + "name": name, + "image_address": image_address, + } + ).data + + class Meta: + model = News + fields = ( + "id", + "source_type", + "source", + "text", + "files", + "audience", + "datetime_created", + "datetime_updated", + "likes_count", + "comments_count", + "views_count", + "is_user_liked", + ) + + +class SetReactNewsLikedSerializer(serializers.Serializer): + is_liked = serializers.BooleanField() + + +class NewsCommentInputSerializer(serializers.Serializer): + text = serializers.CharField( + max_length=2000, + allow_blank=False, + trim_whitespace=True, + ) + + +class NewsCommentAuthorSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + image_address = serializers.URLField(allow_null=True) + + +class NewsCommentResponseSerializer(serializers.ModelSerializer[NewsComment]): + author = serializers.SerializerMethodField() + datetime_updated = serializers.SerializerMethodField() + is_edited = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_delete = serializers.SerializerMethodField() + + def get_author(self, obj: NewsComment) -> dict: + return NewsCommentAuthorSerializer( + { + "id": obj.author_id, + "name": f"{obj.author.first_name} {obj.author.last_name}".strip(), + "image_address": obj.author.avatar, + } + ).data + + def get_datetime_updated(self, obj: NewsComment): + return obj.datetime_updated or obj.datetime_created + + def get_is_edited(self, obj: NewsComment) -> bool: + return obj.datetime_updated is not None + + def get_can_edit(self, obj: NewsComment) -> bool: + request = self.context["request"] + return obj.author_id == request.user.pk + + def get_can_delete(self, obj: NewsComment) -> bool: + request = self.context["request"] + return bool( + obj.author_id == request.user.pk + or request.user.is_staff + or request.user.is_superuser + ) + + class Meta: + model = NewsComment + fields = ( + "id", + "author", + "text", + "datetime_created", + "datetime_updated", + "is_edited", + "can_edit", + "can_delete", + ) diff --git a/feed/news_views.py b/feed/news_views.py new file mode 100644 index 00000000..4b6d7ef3 --- /dev/null +++ b/feed/news_views.py @@ -0,0 +1,181 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from notifications.events import notify_news_comment_created +from core.models import Like, View +from core.services import add_view, set_like +from feed.news_pagination import NewsCommentPagination, ReactNewsFeedPagination +from feed.news_selectors import ( + get_react_feed_news_or_404, + get_react_news_feed_queryset, +) +from feed.news_serializers import ( + NewsCommentInputSerializer, + NewsCommentResponseSerializer, + ReactNewsFeedItemSerializer, + ReactNewsFeedQuerySerializer, + SetReactNewsLikedSerializer, +) +from news.models import News, NewsComment + + +class ReactNewsFeedListView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + pagination_class = ReactNewsFeedPagination + serializer_class = ReactNewsFeedItemSerializer + + def get_queryset(self): + query_serializer = ReactNewsFeedQuerySerializer(data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + return get_react_news_feed_queryset( + source=query_serializer.validated_data["source"], + search=query_serializer.validated_data["search"], + user=self.request.user, + ) + + +class ReactNewsFeedDetailView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ReactNewsFeedItemSerializer + + def get_object(self): + return get_react_feed_news_or_404( + news_id=self.kwargs["news_id"], + user=self.request.user, + ) + + +class ReactNewsSetLikedView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request: Request, news_id: int) -> Response: + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + serializer = SetReactNewsLikedSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + is_liked = serializer.validated_data["is_liked"] + set_like(news, request.user, is_liked) + + content_type = ContentType.objects.get_for_model(News) + likes_count = Like.objects.filter( + content_type=content_type, + object_id=news.pk, + ).count() + return Response( + { + "is_user_liked": is_liked, + "likes_count": likes_count, + } + ) + + +class ReactNewsSetViewedView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request: Request, news_id: int) -> Response: + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + add_view(news, request.user) + + content_type = ContentType.objects.get_for_model(News) + views_count = View.objects.filter( + content_type=content_type, + object_id=news.pk, + ).count() + return Response({"views_count": views_count}) + + +class ReactNewsCommentListCreateView(generics.ListCreateAPIView): + permission_classes = [IsAuthenticated] + pagination_class = NewsCommentPagination + serializer_class = NewsCommentResponseSerializer + + def get_news(self) -> News: + return get_react_feed_news_or_404( + news_id=self.kwargs["news_id"], + user=self.request.user, + ) + + def get_queryset(self): + return NewsComment.objects.filter(news=self.get_news()).select_related("author") + + @transaction.atomic + def post(self, request: Request, *args, **kwargs) -> Response: + news = self.get_news() + serializer = NewsCommentInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + comment = NewsComment.objects.create( + news=news, + author=request.user, + text=serializer.validated_data["text"], + ) + notify_news_comment_created(comment) + return Response( + NewsCommentResponseSerializer( + comment, + context={"request": request}, + ).data, + status=status.HTTP_201_CREATED, + ) + + +class ReactNewsCommentDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_comment(self, request: Request, news_id: int, comment_id: int): + news = get_react_feed_news_or_404(news_id=news_id, user=request.user) + return get_object_or_404( + NewsComment.objects.select_related("author"), + pk=comment_id, + news=news, + ) + + @transaction.atomic + def patch( + self, + request: Request, + news_id: int, + comment_id: int, + ) -> Response: + comment = self.get_comment(request, news_id, comment_id) + if comment.author_id != request.user.pk: + raise PermissionDenied("Редактировать комментарий может только автор.") + + serializer = NewsCommentInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + if "text" in serializer.validated_data: + comment.text = serializer.validated_data["text"] + comment.datetime_updated = timezone.now() + comment.save(update_fields=["text", "datetime_updated"]) + return Response( + NewsCommentResponseSerializer( + comment, + context={"request": request}, + ).data + ) + + @transaction.atomic + def delete( + self, + request: Request, + news_id: int, + comment_id: int, + ) -> Response: + comment = self.get_comment(request, news_id, comment_id) + can_delete = ( + comment.author_id == request.user.pk + or request.user.is_staff + or request.user.is_superuser + ) + if not can_delete: + raise PermissionDenied("Удалить комментарий может автор или администратор.") + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/feed/tests/test_react_news_comments_api.py b/feed/tests/test_react_news_comments_api.py new file mode 100644 index 00000000..5fb04615 --- /dev/null +++ b/feed/tests/test_react_news_comments_api.py @@ -0,0 +1,226 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from news.models import News, NewsComment +from notifications.models import Notification +from news.tests.helpers import ( + create_news_for, + create_partner_program, + create_user, +) +from partner_programs.models import PartnerProgramUserProfile + + +class ReactNewsCommentAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.author = create_user(prefix="comment-author") + self.other_user = create_user(prefix="comment-other") + self.admin = create_user(prefix="comment-admin") + self.admin.is_staff = True + self.admin.save(update_fields=["is_staff"]) + self.program = create_partner_program(name="Comment program") + self.news = create_news_for(self.program, text="Commented post") + self.list_url = f"/feed/news/{self.news.pk}/comments/" + self.client.force_authenticate(self.author) + + def detail_url(self, comment: NewsComment, news: News | None = None) -> str: + return f"/feed/news/{(news or self.news).pk}/comments/{comment.pk}/" + + def test_user_can_create_and_read_comments_oldest_first(self): + self.program.managers.add(self.other_user) + first_response = self.client.post( + self.list_url, + {"text": " First comment "}, + format="json", + ) + second_response = self.client.post( + self.list_url, + {"text": "Second comment"}, + format="json", + ) + list_response = self.client.get(self.list_url) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(first_response.data["text"], "First comment") + self.assertFalse(first_response.data["is_edited"]) + self.assertTrue(first_response.data["can_edit"]) + self.assertTrue(first_response.data["can_delete"]) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data["count"], 2) + self.assertEqual( + [item["id"] for item in list_response.data["results"]], + [first_response.data["id"], second_response.data["id"]], + ) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.NEWS_COMMENT_CREATED, + ).count(), + 2, + ) + + def test_author_can_edit_comment_and_user_input_is_preserved(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Before edit", + ) + + response = self.client.patch( + self.detail_url(comment), + {"text": " After edit "}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["text"], "After edit") + self.assertTrue(response.data["is_edited"]) + self.assertNotEqual( + response.data["datetime_created"], + response.data["datetime_updated"], + ) + + def test_user_cannot_edit_another_authors_comment(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Other comment", + ) + + response = self.client.patch( + self.detail_url(comment), + {"text": "Forbidden edit"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + comment.refresh_from_db() + self.assertEqual(comment.text, "Other comment") + + def test_author_and_admin_can_delete_comment(self): + author_comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Author delete", + ) + admin_comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Admin delete", + ) + + author_response = self.client.delete(self.detail_url(author_comment)) + self.client.force_authenticate(self.admin) + admin_response = self.client.delete(self.detail_url(admin_comment)) + + self.assertEqual(author_response.status_code, 204) + self.assertEqual(admin_response.status_code, 204) + self.assertFalse( + NewsComment.objects.filter( + pk__in=(author_comment.pk, admin_comment.pk) + ).exists() + ) + + def test_user_cannot_delete_another_authors_comment(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.other_user, + text="Protected comment", + ) + + response = self.client.delete(self.detail_url(comment)) + + self.assertEqual(response.status_code, 403) + self.assertTrue(NewsComment.objects.filter(pk=comment.pk).exists()) + + def test_blank_and_too_long_comments_are_rejected(self): + blank = self.client.post( + self.list_url, + {"text": " "}, + format="json", + ) + too_long = self.client.post( + self.list_url, + {"text": "x" * 2001}, + format="json", + ) + + self.assertEqual(blank.status_code, 400) + self.assertIn("text", blank.data) + self.assertEqual(too_long.status_code, 400) + self.assertIn("text", too_long.data) + self.assertFalse(NewsComment.objects.filter(news=self.news).exists()) + + def test_comment_id_cannot_be_used_through_another_news(self): + other_news = create_news_for(self.program, text="Other post") + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Scoped comment", + ) + + patch_response = self.client.patch( + self.detail_url(comment, news=other_news), + {"text": "Wrong scope"}, + format="json", + ) + delete_response = self.client.delete(self.detail_url(comment, news=other_news)) + + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + self.assertTrue(NewsComment.objects.filter(pk=comment.pk).exists()) + + def test_inaccessible_participant_news_restricts_comments(self): + internal_news = create_news_for( + self.program, + text="Internal comments", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + internal_url = f"/feed/news/{internal_news.pk}/comments/" + + forbidden_list = self.client.get(internal_url) + forbidden_create = self.client.post( + internal_url, + {"text": "Forbidden comment"}, + format="json", + ) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.author, + partner_program_data={}, + ) + allowed_create = self.client.post( + internal_url, + {"text": "Participant comment"}, + format="json", + ) + + self.assertEqual(forbidden_list.status_code, 404) + self.assertEqual(forbidden_create.status_code, 404) + self.assertEqual(allowed_create.status_code, 201) + + def test_comments_count_tracks_create_and_delete(self): + created = self.client.post( + self.list_url, + {"text": "Counted comment"}, + format="json", + ) + + after_create = self.client.get(f"/feed/news/{self.news.pk}/") + self.client.delete(f"/feed/news/{self.news.pk}/comments/{created.data['id']}/") + after_delete = self.client.get(f"/feed/news/{self.news.pk}/") + + self.assertEqual(after_create.data["comments_count"], 1) + self.assertEqual(after_delete.data["comments_count"], 0) + + def test_deleting_news_cascades_comments(self): + comment = NewsComment.objects.create( + news=self.news, + author=self.author, + text="Cascade comment", + ) + + self.news.delete() + + self.assertFalse(NewsComment.objects.filter(pk=comment.pk).exists()) diff --git a/feed/tests/test_react_news_feed_api.py b/feed/tests/test_react_news_feed_api.py new file mode 100644 index 00000000..f99d8f49 --- /dev/null +++ b/feed/tests/test_react_news_feed_api.py @@ -0,0 +1,346 @@ +from datetime import timedelta + +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from core.models import Like, View +from feed.services import create_news_for_model +from news.models import News, NewsComment +from news.tests.helpers import ( + create_news_for, + create_partner_program, + create_project, + create_user, + create_user_file, +) +from partner_programs.models import PartnerProgramUserProfile + + +class ReactNewsFeedAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="react-feed-user") + self.client.force_authenticate(self.user) + + def test_authentication_is_required(self): + self.client.force_authenticate(user=None) + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 401) + + def test_program_is_default_source_and_participant_news_is_excluded(self): + program = create_partner_program(name="Default feed program") + platform_news = create_news_for( + program, + text="Platform program post", + audience=News.Audience.PLATFORM, + ) + create_news_for( + program, + text="Internal program post", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + create_news_for(self.user, text="User post") + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [platform_news.pk], + ) + self.assertEqual(response.data["results"][0]["source_type"], "program") + + def test_each_source_returns_only_its_publications(self): + program_news = create_news_for( + create_partner_program(name="Source program"), + text="Program source post", + ) + project_news = create_news_for( + create_project(name="Source project"), + text="Project source post", + ) + user_news = create_news_for(self.user, text="User source post") + + expected = { + "program": program_news.pk, + "project": project_news.pk, + "user": user_news.pk, + } + for source, news_id in expected.items(): + with self.subTest(source=source): + response = self.client.get(f"/feed/news/?source={source}") + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [news_id], + ) + + def test_service_records_and_unavailable_projects_are_excluded(self): + public_project = create_project(name="Public publication") + public_news = create_news_for(public_project, text="Visible project post") + create_news_for_model(public_project) + create_news_for( + create_project(name="Private publication", is_public=False), + text="Private project post", + ) + create_news_for( + create_project(name="Draft publication", draft=True), + text="Draft project post", + ) + + response = self.client.get("/feed/news/?source=project") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [public_news.pk], + ) + + def test_results_are_newest_first_and_paginated(self): + program = create_partner_program(name="Ordered program") + oldest = create_news_for(program, text="Oldest") + middle = create_news_for(program, text="Middle") + newest = create_news_for(program, text="Newest") + now = timezone.now() + News.objects.filter(pk=oldest.pk).update(datetime_created=now - timedelta(days=2)) + News.objects.filter(pk=middle.pk).update(datetime_created=now - timedelta(days=1)) + News.objects.filter(pk=newest.pk).update(datetime_created=now) + + first_page = self.client.get("/feed/news/?limit=2&offset=0") + second_page = self.client.get("/feed/news/?limit=2&offset=2") + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(first_page.data["count"], 3) + self.assertEqual( + [item["id"] for item in first_page.data["results"]], + [newest.pk, middle.pk], + ) + self.assertEqual( + [item["id"] for item in second_page.data["results"]], + [oldest.pk], + ) + + def test_search_matches_text_and_source_name_inside_selected_tab(self): + matching_program = create_partner_program(name="Quantum accelerator") + name_match = create_news_for(matching_program, text="General update") + text_match = create_news_for( + create_partner_program(name="Other program"), + text="Quantum milestone", + ) + create_news_for( + create_partner_program(name="Unrelated program"), + text="Nothing relevant", + ) + create_news_for(self.user, text="Quantum user post") + + response = self.client.get("/feed/news/?source=program&search= Quantum ") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + {item["id"] for item in response.data["results"]}, + {name_match.pk, text_match.pk}, + ) + + def _assert_user_search_matches(self, search): + matching_user = create_user(prefix="react-feed-name-match") + matching_user.first_name = "Анна" + matching_user.last_name = "Иванова" + matching_user.save(update_fields=["first_name", "last_name"]) + matching_news = create_news_for( + matching_user, + text="Нейтральная публикация", + ) + create_news_for( + create_user(prefix="react-feed-name-other"), + text="Другая публикация", + ) + + response = self.client.get( + "/feed/news/", + {"source": "user", "search": search}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [matching_news.pk], + ) + + def test_user_search_matches_first_name(self): + self._assert_user_search_matches("Анна") + + def test_user_search_matches_last_name(self): + self._assert_user_search_matches("Иванова") + + def test_user_search_matches_full_name(self): + self._assert_user_search_matches("Анна Иванова") + + def test_user_search_matches_reversed_full_name(self): + self._assert_user_search_matches("Иванова Анна") + + def test_unknown_source_and_excessive_search_are_rejected(self): + unknown_source = self.client.get("/feed/news/?source=vacancy") + long_search = self.client.get(f"/feed/news/?search={'x' * 201}") + + self.assertEqual(unknown_source.status_code, 400) + self.assertIn("source", unknown_source.data) + self.assertEqual(long_search.status_code, 400) + self.assertIn("search", long_search.data) + + def test_unified_response_contains_files_counts_and_like_state(self): + program = create_partner_program(name="Contract program") + file = create_user_file(self.user) + news = create_news_for( + program, + text="Contract post", + files=[file], + ) + content_type = ContentType.objects.get_for_model(News) + Like.objects.create( + user=self.user, + content_type=content_type, + object_id=news.pk, + ) + View.objects.create( + user=self.user, + content_type=content_type, + object_id=news.pk, + ) + NewsComment.objects.create(news=news, author=self.user, text="Comment") + + response = self.client.get("/feed/news/") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual( + set(item), + { + "id", + "source_type", + "source", + "text", + "files", + "audience", + "datetime_created", + "datetime_updated", + "likes_count", + "comments_count", + "views_count", + "is_user_liked", + }, + ) + self.assertEqual(item["source"]["id"], program.pk) + self.assertEqual(item["source"]["name"], program.name) + self.assertEqual(item["files"][0]["link"], file.link) + self.assertEqual(item["likes_count"], 1) + self.assertEqual(item["comments_count"], 1) + self.assertEqual(item["views_count"], 1) + self.assertTrue(item["is_user_liked"]) + + def test_list_query_count_does_not_grow_per_publication(self): + program = create_partner_program(name="Query budget program") + for index in range(5): + create_news_for(program, text=f"Query post {index}") + ContentType.objects.get_for_model(News) + ContentType.objects.get_for_model(type(program)) + + with CaptureQueriesContext(connection) as captured: + response = self.client.get("/feed/news/?limit=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + self.assertLessEqual(len(captured), 8) + + +class ReactNewsInteractionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="react-interaction-user") + self.other_user = create_user(prefix="react-interaction-other") + self.program = create_partner_program(name="Interaction program") + self.news = create_news_for(self.program, text="Interactive post") + self.client.force_authenticate(self.user) + + def test_like_and_unlike_are_idempotent_and_user_specific(self): + like_url = f"/feed/news/{self.news.pk}/set-liked/" + + first = self.client.post(like_url, {"is_liked": True}, format="json") + repeated = self.client.post(like_url, {"is_liked": True}, format="json") + self.client.force_authenticate(self.other_user) + other = self.client.post(like_url, {"is_liked": True}, format="json") + self.client.force_authenticate(self.user) + unlike = self.client.post(like_url, {"is_liked": False}, format="json") + + self.assertEqual(first.data, {"is_user_liked": True, "likes_count": 1}) + self.assertEqual( + repeated.data, + {"is_user_liked": True, "likes_count": 1}, + ) + self.assertEqual(other.data["likes_count"], 2) + self.assertEqual( + unlike.data, + {"is_user_liked": False, "likes_count": 1}, + ) + + def test_view_is_idempotent(self): + url = f"/feed/news/{self.news.pk}/set-viewed/" + + first = self.client.post(url, {}, format="json") + repeated = self.client.post(url, {}, format="json") + + self.assertEqual(first.data, {"views_count": 1}) + self.assertEqual(repeated.data, {"views_count": 1}) + + def test_inaccessible_news_cannot_be_opened_liked_or_viewed(self): + internal = create_news_for( + self.program, + text="Internal interaction", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + + detail = self.client.get(f"/feed/news/{internal.pk}/") + liked = self.client.post( + f"/feed/news/{internal.pk}/set-liked/", + {"is_liked": True}, + format="json", + ) + viewed = self.client.post( + f"/feed/news/{internal.pk}/set-viewed/", + {}, + format="json", + ) + + self.assertEqual(detail.status_code, 404) + self.assertEqual(liked.status_code, 404) + self.assertEqual(viewed.status_code, 404) + + def test_participant_can_open_internal_program_news_by_detail_link(self): + internal = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.user, + partner_program_data={}, + ) + + response = self.client.get(f"/feed/news/{internal.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], internal.pk) + + def test_empty_service_record_cannot_be_opened(self): + project = create_project(name="Hidden service record") + create_news_for_model(project) + service_record = News.objects.get_news(project).get(text="") + + response = self.client.get(f"/feed/news/{service_record.pk}/") + + self.assertEqual(response.status_code, 404) diff --git a/feed/urls.py b/feed/urls.py index 0a63a076..a2cc93c2 100644 --- a/feed/urls.py +++ b/feed/urls.py @@ -1,9 +1,43 @@ from django.urls import path from feed.views import NewSimpleFeed +from feed.news_views import ( + ReactNewsCommentDetailView, + ReactNewsCommentListCreateView, + ReactNewsFeedDetailView, + ReactNewsFeedListView, + ReactNewsSetLikedView, + ReactNewsSetViewedView, +) app_name = "feed" urlpatterns = [ path("", NewSimpleFeed.as_view()), + path("news/", ReactNewsFeedListView.as_view(), name="react-news-list"), + path( + "news//", + ReactNewsFeedDetailView.as_view(), + name="react-news-detail", + ), + path( + "news//set-liked/", + ReactNewsSetLikedView.as_view(), + name="react-news-set-liked", + ), + path( + "news//set-viewed/", + ReactNewsSetViewedView.as_view(), + name="react-news-set-viewed", + ), + path( + "news//comments/", + ReactNewsCommentListCreateView.as_view(), + name="react-news-comment-list", + ), + path( + "news//comments//", + ReactNewsCommentDetailView.as_view(), + name="react-news-comment-detail", + ), ] diff --git a/industries/management/__init__.py b/industries/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/management/commands/__init__.py b/industries/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/management/commands/seed_react_dev_industries.py b/industries/management/commands/seed_react_dev_industries.py new file mode 100644 index 00000000..95677f45 --- /dev/null +++ b/industries/management/commands/seed_react_dev_industries.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from industries.reference_data import ensure_react_dev_industries + + +class Command(BaseCommand): + help = "Заполнить обязательный справочник отраслей в изолированной React-dev базе." + + def add_arguments(self, parser): + parser.add_argument( + "--confirm-react-dev", + action="store_true", + help="Подтвердить, что команда подключена к изолированной React-dev базе.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Показать результат и откатить созданные записи.", + ) + + def handle(self, *args, **options): + # Используем существующий закрытый по умолчанию guard, чтобы команда не могла + # случайно изменить production или legacy dev при обычной конфигурации. + if not settings.ALLOW_REACT_DEV_DEMO_SEED: + raise CommandError( + "Заполнение справочников React-dev отключено в настройках." + ) + if not options["confirm_react_dev"]: + raise CommandError("Для запуска передайте флаг --confirm-react-dev.") + + with transaction.atomic(): + summary = ensure_react_dev_industries() + if options["dry_run"]: + transaction.set_rollback(True) + + self.stdout.write(f"Создано отраслей: {summary.created}") + self.stdout.write(f"Уже существовало: {summary.existing}") + if options["dry_run"]: + self.stdout.write( + self.style.WARNING( + "Пробный запуск завершён; созданные записи не сохранены." + ) + ) + else: + self.stdout.write(self.style.SUCCESS("Справочник отраслей готов.")) diff --git a/industries/reference_data.py b/industries/reference_data.py new file mode 100644 index 00000000..0a85327d --- /dev/null +++ b/industries/reference_data.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass + +from django.db import transaction + +from industries.models import Industry + + +# Список повторяет действующий справочник legacy-контура; идентификаторы намеренно +# не фиксируются, потому что Project хранит локальный FK конкретной базы данных. +REACT_DEV_INDUSTRY_NAMES = ( + "AR/VR", + "AgTech", + "CV/ML", + "Customer Services", + "Cybersecurity", + "Devices", + "E-auto & Taxi Services", + "E-commerce", + "EdTech", + "Entertainment Tech", + "FinTech", + "FoodTech", + "Games", + "GovTech", + "HRTech", + "InsurTech", + "InvestTech", + "LegalTech", + "Logistics", + "Mapping & Geolocation", + "MedTech", + "PropTech", + "RegTech", + "Smart Home Solutions", + "SportTech", + "Другое", + "Социальные проекты", +) + + +@dataclass(frozen=True) +class IndustryReferenceDataSummary: + created: int + existing: int + + +@transaction.atomic +def ensure_react_dev_industries() -> IndustryReferenceDataSummary: + """Создаёт отсутствующие отрасли React-dev, не изменяя существующие записи.""" + existing_names = set( + Industry.objects.filter(name__in=REACT_DEV_INDUSTRY_NAMES).values_list( + "name", + flat=True, + ) + ) + missing_names = [ + name for name in REACT_DEV_INDUSTRY_NAMES if name not in existing_names + ] + Industry.objects.bulk_create([Industry(name=name) for name in missing_names]) + return IndustryReferenceDataSummary( + created=len(missing_names), + existing=len(existing_names), + ) diff --git a/industries/tests/test_react_dev_industry_seed.py b/industries/tests/test_react_dev_industry_seed.py new file mode 100644 index 00000000..9aca3183 --- /dev/null +++ b/industries/tests/test_react_dev_industry_seed.py @@ -0,0 +1,131 @@ +import io + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from industries.models import Industry +from industries.reference_data import REACT_DEV_INDUSTRY_NAMES +from industries.tests.helpers import create_user +from projects.models import Project + + +class ReactDevIndustrySeedTests(TestCase): + def run_seed(self, *extra_args, confirm=True): + args = list(extra_args) + if confirm: + args.append("--confirm-react-dev") + stdout = io.StringIO() + with override_settings(ALLOW_REACT_DEV_DEMO_SEED=True): + call_command("seed_react_dev_industries", *args, stdout=stdout) + return stdout.getvalue() + + def test_seed_is_disabled_by_default_before_any_write(self): + with self.assertRaisesMessage(CommandError, "отключено"): + call_command( + "seed_react_dev_industries", + "--confirm-react-dev", + ) + + self.assertFalse(Industry.objects.exists()) + + def test_seed_requires_explicit_react_dev_confirmation(self): + with self.assertRaisesMessage(CommandError, "--confirm-react-dev"): + self.run_seed(confirm=False) + + self.assertFalse(Industry.objects.exists()) + + def test_seed_creates_complete_reference_and_preserves_other_rows(self): + custom = Industry.objects.create(name="Локальная тестовая отрасль") + + output = self.run_seed() + + self.assertEqual( + set( + Industry.objects.filter(name__in=REACT_DEV_INDUSTRY_NAMES).values_list( + "name", + flat=True, + ) + ), + set(REACT_DEV_INDUSTRY_NAMES), + ) + self.assertTrue(Industry.objects.filter(pk=custom.pk).exists()) + self.assertIn(f"Создано отраслей: {len(REACT_DEV_INDUSTRY_NAMES)}", output) + + def test_repeated_seed_does_not_create_duplicates(self): + self.run_seed() + first_count = Industry.objects.count() + + output = self.run_seed() + + self.assertEqual(Industry.objects.count(), first_count) + self.assertIn("Создано отраслей: 0", output) + self.assertIn(f"Уже существовало: {first_count}", output) + + def test_dry_run_rolls_back_created_industries(self): + output = self.run_seed("--dry-run") + + self.assertFalse(Industry.objects.exists()) + self.assertIn("созданные записи не сохранены", output) + + +class ReactDevIndustryProjectLifecycleTests(TestCase): + def setUp(self): + with override_settings(ALLOW_REACT_DEV_DEMO_SEED=True): + call_command( + "seed_react_dev_industries", + "--confirm-react-dev", + stdout=io.StringIO(), + ) + self.user = create_user(prefix="react-dev-industry-project") + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_seeded_industry_supports_draft_edit_and_publication(self): + industries_response = self.client.get("/industries/") + self.assertEqual(industries_response.status_code, 200) + self.assertEqual(len(industries_response.data), len(REACT_DEV_INDUSTRY_NAMES)) + industry_id = industries_response.data[0]["id"] + + create_response = self.client.post("/projects/workspace/", {}, format="json") + self.assertEqual(create_response.status_code, 201) + project_id = create_response.data["id"] + + draft_response = self.client.patch( + f"/projects/{project_id}/workspace/", + { + "name": "Черновик проекта", + "industry": industry_id, + "draft": True, + "is_public": False, + }, + format="json", + ) + self.assertEqual(draft_response.status_code, 200) + self.assertEqual(draft_response.data["industry"]["id"], industry_id) + + edit_response = self.client.get(f"/projects/{project_id}/workspace/") + self.assertEqual(edit_response.status_code, 200) + self.assertEqual(edit_response.data["industry"]["id"], industry_id) + + publish_response = self.client.patch( + f"/projects/{project_id}/workspace/", + { + "region": "Москва", + "description": "Описание проекта", + "problem": "Описание проблемы", + "target_audience": "Целевая аудитория", + "cover_image_address": "https://example.com/project-cover.png", + "draft": False, + "is_public": True, + }, + format="json", + ) + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(publish_response.data["industry"]["id"], industry_id) + + project = Project.objects.get(pk=project_id) + self.assertEqual(project.industry_id, industry_id) + self.assertFalse(project.draft) + self.assertTrue(project.is_public) diff --git a/invites/admin.py b/invites/admin.py index 30527640..d1004da0 100644 --- a/invites/admin.py +++ b/invites/admin.py @@ -8,8 +8,11 @@ class InviteAdmin(admin.ModelAdmin): fields = [ "project", "user", + "invited_by", "motivational_letter", "role", "specialization", "is_accepted", + "is_revoked", + "resolved_at", ] diff --git a/invites/managers.py b/invites/managers.py index e71d0a6d..b70b959a 100644 --- a/invites/managers.py +++ b/invites/managers.py @@ -3,4 +3,15 @@ class InviteManager(Manager): def get_invite_for_list_view(self): - return self.get_queryset().select_related("project", "project__leader", "user") + return self.get_queryset().select_related( + "project", + "project__leader", + "user", + "invited_by", + ) + + def pending(self): + return self.get_queryset().filter( + is_accepted__isnull=True, + is_revoked=False, + ) diff --git a/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py b/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py new file mode 100644 index 00000000..975c0f52 --- /dev/null +++ b/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py @@ -0,0 +1,111 @@ +# Generated by Django 4.2.11 on 2026-08-06 13:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def populate_invitation_lifecycle(apps, schema_editor): + """Сохраняет legacy-состояния и устраняет активные дубли до constraint.""" + Invite = apps.get_model("invites", "Invite") + database = schema_editor.connection.alias + seen_pending = set() + changed = [] + + invitations = ( + Invite.objects.using(database) + .select_related("project") + .all() + .order_by("project_id", "user_id", "-datetime_created", "-id") + ) + for invitation in invitations.iterator(): + invitation.invited_by_id = invitation.project.leader_id + if invitation.is_accepted is not None: + invitation.resolved_at = invitation.datetime_updated + else: + key = (invitation.project_id, invitation.user_id) + if key in seen_pending: + # Новейшее pending-приглашение остается активным, предыдущие + # сохраняются в истории как отозванные. + invitation.is_revoked = True + invitation.resolved_at = invitation.datetime_updated + else: + seen_pending.add(key) + changed.append(invitation) + + if changed: + Invite.objects.using(database).bulk_update( + changed, + ["invited_by", "is_revoked", "resolved_at"], + batch_size=500, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("invites", "0002_invite_specialization"), + ] + + operations = [ + migrations.AddField( + model_name="invite", + name="invited_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_project_invites", + to=settings.AUTH_USER_MODEL, + verbose_name="Кем приглашен", + ), + ), + migrations.AddField( + model_name="invite", + name="is_revoked", + field=models.BooleanField(default=False, verbose_name="Отозвано"), + ), + migrations.AddField( + model_name="invite", + name="resolved_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата обработки" + ), + ), + migrations.RunPython( + populate_invitation_lifecycle, + migrations.RunPython.noop, + ), + migrations.AddIndex( + model_name="invite", + index=models.Index( + fields=["user", "is_accepted", "is_revoked", "datetime_created"], + name="invite_user_state_idx", + ), + ), + migrations.AddIndex( + model_name="invite", + index=models.Index( + fields=["project", "is_accepted", "is_revoked", "datetime_created"], + name="invite_project_state_idx", + ), + ), + migrations.AddConstraint( + model_name="invite", + constraint=models.UniqueConstraint( + condition=models.Q(("is_accepted__isnull", True), ("is_revoked", False)), + fields=("project", "user"), + name="uniq_pending_project_invite", + ), + ), + migrations.AddConstraint( + model_name="invite", + constraint=models.CheckConstraint( + check=models.Q( + ("is_revoked", False), ("is_accepted__isnull", True), _connector="OR" + ), + name="invite_revoked_unaccepted", + ), + ), + ] diff --git a/invites/models.py b/invites/models.py index 42118726..9c4c4717 100644 --- a/invites/models.py +++ b/invites/models.py @@ -16,12 +16,28 @@ class Invite(models.Model): user: A ForeignKey referring to the user, who got the invite motivational_letter: A TextField where the project can tell the user why they need him is_accepted: A BooleanField indicating whether the receiver accepted the invite or declined it + invited_by: Пользователь, который создал приглашение + is_revoked: Признак исторически сохраненного отзыва + resolved_at: Дата принятия, отклонения или отзыва datetime_created: A DateTimeField indicating date of creation datetime_updated: A DateTimeField indicating date of update """ + STATUS_PENDING = "pending" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REVOKED = "revoked" + project = models.ForeignKey(Project, on_delete=models.CASCADE) user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + invited_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sent_project_invites", + verbose_name="Кем приглашен", + ) motivational_letter = models.TextField( max_length=4096, blank=True, null=True, default=None @@ -35,6 +51,12 @@ class Invite(models.Model): verbose_name="Специализация", ) is_accepted = models.BooleanField(blank=False, null=True, default=None) + is_revoked = models.BooleanField(default=False, verbose_name="Отозвано") + resolved_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата обработки", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, auto_now_add=True @@ -46,11 +68,52 @@ class Invite(models.Model): objects = InviteManager() def __str__(self) -> str: - return ( - f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' - ) + return f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' + + @property + def status(self) -> str: + """Возвращает lifecycle-статус без изменения legacy `is_accepted`.""" + if self.is_revoked: + return self.STATUS_REVOKED + if self.is_accepted is True: + return self.STATUS_ACCEPTED + if self.is_accepted is False: + return self.STATUS_DECLINED + return self.STATUS_PENDING + + @property + def is_pending(self) -> bool: + return self.is_accepted is None and not self.is_revoked class Meta(TypedModelMeta): verbose_name = "Приглашение" verbose_name_plural = "Приглашения" ordering = ["-datetime_created"] + constraints = [ + # Завершенные приглашения остаются в истории и не мешают + # повторному приглашению того же пользователя. + models.UniqueConstraint( + fields=["project", "user"], + condition=models.Q(is_accepted__isnull=True, is_revoked=False), + name="uniq_pending_project_invite", + ), + models.CheckConstraint( + check=models.Q(is_revoked=False) | models.Q(is_accepted__isnull=True), + name="invite_revoked_unaccepted", + ), + ] + indexes = [ + models.Index( + fields=["user", "is_accepted", "is_revoked", "datetime_created"], + name="invite_user_state_idx", + ), + models.Index( + fields=[ + "project", + "is_accepted", + "is_revoked", + "datetime_created", + ], + name="invite_project_state_idx", + ), + ] diff --git a/invites/serializers.py b/invites/serializers.py index 42a032e2..194bb57d 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -49,7 +49,10 @@ def validate(self, attrs): ) if Invite.objects.filter( - project=project, user=user, is_accepted__isnull=True + project=project, + user=user, + is_accepted__isnull=True, + is_revoked=False, ).exists(): raise serializers.ValidationError( {"user": "У пользователя уже есть активное приглашение в этот проект."} diff --git a/invites/tests/test_project_invitation_candidates_api.py b/invites/tests/test_project_invitation_candidates_api.py new file mode 100644 index 00000000..6c2fa8b3 --- /dev/null +++ b/invites/tests/test_project_invitation_candidates_api.py @@ -0,0 +1,280 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_project, + create_user, + link_project_to_program, +) +from invites.throttling import ProjectInvitationCandidateSearchScopedRateThrottle +from projects.models import Collaborator + + +class ProjectInvitationCandidateAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.leader = self.create_named_user("candidate-leader", "Лидер", "Проекта") + self.collaborator = self.create_named_user( + "candidate-collaborator", "Участник", "Проекта" + ) + self.outsider = self.create_named_user( + "candidate-outsider", "Посторонний", "Пользователь" + ) + self.staff = self.create_named_user( + "candidate-staff", "Администратор", "Платформы", is_staff=True + ) + self.superuser = self.create_named_user( + "candidate-superuser", "Главный", "Администратор" + ) + self.superuser.is_superuser = True + self.superuser.save(update_fields=["is_superuser"]) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + add_collaborator(project=self.project, user=self.collaborator) + + @staticmethod + def create_named_user( + prefix, + first_name="Иван", + last_name="Петров", + *, + is_staff=False, + is_active=True, + ): + user = create_user(prefix=prefix, is_staff=is_staff) + user.first_name = first_name + user.last_name = last_name + user.is_active = is_active + user.save(update_fields=["first_name", "last_name", "is_active"]) + return user + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def candidates_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/invitations/candidates/" + + def search(self, query="петров", *, project=None): + return self.client.get(self.candidates_url(project), {"q": query}) + + def test_endpoint_requires_authentication(self): + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_leader_staff_and_superuser_can_search(self): + candidate = self.create_named_user("candidate-authorized", "Иван", "Петров") + + for actor in (self.leader, self.staff, self.superuser): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.search() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [candidate.pk], + ) + + def test_collaborator_gets_403_and_private_project_is_hidden_from_outsider(self): + self.authenticate(self.collaborator) + collaborator_response = self.search() + self.authenticate(self.outsider) + outsider_response = self.search() + + self.assertEqual(collaborator_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(outsider_response.status_code, status.HTTP_404_NOT_FOUND) + + def test_query_is_required_trimmed_and_has_length_limits(self): + self.authenticate(self.leader) + for params in ({}, {"q": ""}, {"q": " аб "}, {"q": "x" * 101}): + with self.subTest(params=params): + response = self.client.get(self.candidates_url(), params) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("q", response.data) + + candidate = self.create_named_user("candidate-trimmed", "Анна", "Смирнова") + response = self.search(" СМирнова ") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [candidate.pk]) + + def test_searches_by_names_both_full_name_orders_and_email_prefix(self): + candidate = self.create_named_user("candidate-searchable", "Иван", "Петров") + email_prefix = candidate.email.split("@", maxsplit=1)[0] + self.authenticate(self.leader) + + for query in ( + "иван", + "петров", + "иван петров", + "петров иван", + email_prefix, + ): + with self.subTest(query=query): + response = self.search(query) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [candidate.pk], + ) + + def test_response_contains_only_safe_profile_fields(self): + candidate = self.create_named_user("candidate-safe-response", "Иван", "Петров") + candidate.avatar = "https://example.com/avatar.png" + candidate.save(update_fields=["avatar"]) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + [ + { + "id": candidate.pk, + "display_name": "Иван Петров", + "avatar": "https://example.com/avatar.png", + } + ], + ) + self.assertNotIn("email", response.data[0]) + + def test_inactive_leader_collaborator_and_pending_recipient_are_excluded(self): + inactive = self.create_named_user("candidate-inactive", is_active=False) + pending = self.create_named_user("candidate-pending") + Invite.objects.create( + project=self.project, + user=pending, + invited_by=self.leader, + ) + self.authenticate(self.leader) + + response = self.search("проект") + pending_response = self.search("петров") + + result_ids = {item["id"] for item in response.data + pending_response.data} + self.assertNotIn(inactive.pk, result_ids) + self.assertNotIn(self.leader.pk, result_ids) + self.assertNotIn(self.collaborator.pk, result_ids) + self.assertNotIn(pending.pk, result_ids) + + def test_declined_and_revoked_invitation_history_does_not_block_candidate(self): + declined = self.create_named_user("candidate-declined") + revoked = self.create_named_user("candidate-revoked") + Invite.objects.create( + project=self.project, + user=declined, + invited_by=self.leader, + is_accepted=False, + ) + Invite.objects.create( + project=self.project, + user=revoked, + invited_by=self.leader, + is_revoked=True, + ) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {declined.pk, revoked.pk}, + ) + + def test_linked_program_limits_candidates_to_program_members(self): + member = self.create_named_user("candidate-program-member") + non_member = self.create_named_user("candidate-program-outsider") + program = link_project_to_program(project=self.project) + add_user_to_program(user=member, program=program) + self.authenticate(self.leader) + + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [member.pk]) + self.assertNotIn(non_member.pk, [item["id"] for item in response.data]) + + def test_results_are_stable_limited_to_twenty_and_do_not_write(self): + candidates = [ + self.create_named_user( + f"candidate-limit-{index}", + "Одинаковое", + "Имя", + ) + for index in range(22) + ] + before = ( + Invite.objects.count(), + Collaborator.objects.count(), + ) + self.authenticate(self.leader) + + first = self.search("одинаковое") + second = self.search("одинаковое") + + expected_ids = [candidate.pk for candidate in candidates[:20]] + self.assertEqual([item["id"] for item in first.data], expected_ids) + self.assertEqual([item["id"] for item in second.data], expected_ids) + self.assertEqual( + (Invite.objects.count(), Collaborator.objects.count()), + before, + ) + + def test_candidate_list_has_bounded_query_count(self): + for index in range(8): + self.create_named_user(f"candidate-query-{index}") + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as queries: + response = self.search() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 8) + self.assertLessEqual(len(queries), 4) + + def test_post_revalidates_eligibility_after_search(self): + candidate = self.create_named_user("candidate-revalidated") + self.authenticate(self.leader) + self.assertEqual(self.search().status_code, status.HTTP_200_OK) + add_collaborator(project=self.project, user=candidate) + + response = self.client.post( + f"/projects/{self.project.pk}/workspace/invitations/", + {"recipient_id": candidate.pk}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("recipient_id", response.data) + self.assertFalse( + Invite.objects.filter(project=self.project, user=candidate).exists() + ) + + @patch.object( + ProjectInvitationCandidateSearchScopedRateThrottle, + "rate", + "1/min", + ) + def test_search_has_dedicated_throttle(self): + self.authenticate(self.leader) + + first = self.search() + second = self.search() + + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(second.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/invites/tests/test_project_workspace_invitation_api.py b/invites/tests/test_project_workspace_invitation_api.py new file mode 100644 index 00000000..38e944ff --- /dev/null +++ b/invites/tests/test_project_workspace_invitation_api.py @@ -0,0 +1,527 @@ +from unittest.mock import patch + +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_invite, + create_project, + create_user, + link_project_to_program, +) +from projects.models import Collaborator +from notifications.models import Notification + + +class ProjectWorkspaceInvitationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-invite-leader") + self.collaborator = create_user(prefix="workspace-invite-collaborator") + self.recipient = create_user(prefix="workspace-invite-recipient") + self.outsider = create_user(prefix="workspace-invite-outsider") + self.staff = create_user(prefix="workspace-invite-staff", is_staff=True) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + add_collaborator(project=self.project, user=self.collaborator) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def invitations_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/invitations/" + + def revoke_url(self, invitation, project=None): + project = project or self.project + return f"{self.invitations_url(project)}{invitation.pk}/revoke/" + + @staticmethod + def incoming_url(): + return "/projects/workspace/invitations/incoming/" + + @staticmethod + def accept_url(invitation): + return f"/projects/workspace/invitations/{invitation.pk}/accept/" + + @staticmethod + def decline_url(invitation): + return f"/projects/workspace/invitations/{invitation.pk}/decline/" + + def create_workspace_invitation(self, *, recipient=None, **overrides): + recipient = recipient or self.recipient + defaults = { + "project": self.project, + "user": recipient, + "invited_by": self.leader, + "role": "Разработчик", + "specialization": "Backend", + "motivational_letter": "Присоединяйтесь к проекту", + } + defaults.update(overrides) + return Invite.objects.create(**defaults) + + def test_workspace_invitation_endpoints_require_authentication(self): + invitation = self.create_workspace_invitation() + + responses = ( + self.client.get(self.invitations_url()), + self.client.post( + self.invitations_url(), + {"recipient_id": self.outsider.pk}, + format="json", + ), + self.client.get(self.incoming_url()), + self.client.post(self.accept_url(invitation), {}, format="json"), + self.client.post(self.decline_url(invitation), {}, format="json"), + self.client.post(self.revoke_url(invitation), {}, format="json"), + ) + + self.assertTrue( + all( + response.status_code == status.HTTP_401_UNAUTHORIZED + for response in responses + ) + ) + + def test_leader_creates_pending_invitation_with_safe_response(self): + self.authenticate(self.leader) + + response = self.client.post( + self.invitations_url(), + { + "recipient_id": self.recipient.pk, + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + invitation = Invite.objects.get(pk=response.data["id"]) + self.assertEqual(invitation.project, self.project) + self.assertEqual(invitation.user, self.recipient) + self.assertEqual(invitation.invited_by, self.leader) + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertEqual(response.data["status"], Invite.STATUS_PENDING) + self.assertEqual(response.data["project"]["id"], self.project.pk) + self.assertEqual(response.data["sender"]["id"], self.leader.pk) + self.assertEqual(response.data["recipient"]["id"], self.recipient.pk) + self.assertNotIn("email", response.data["recipient"]) + self.assertEqual(response.data["message"], "Присоединяйтесь к проекту") + self.assertIsNone(response.data["processed_at"]) + self.assertTrue( + Notification.objects.filter( + recipient=self.recipient, + type=Notification.Type.PROJECT_INVITE_CREATED, + ).exists() + ) + + def test_only_leader_or_staff_can_create_invitation(self): + for actor, expected_status in ( + (self.collaborator, status.HTTP_403_FORBIDDEN), + (self.outsider, status.HTTP_404_NOT_FOUND), + ): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(response.status_code, expected_status) + + self.authenticate(self.staff) + staff_response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(staff_response.status_code, status.HTTP_201_CREATED) + + def test_create_rejects_leader_collaborator_duplicate_and_invalid_user(self): + self.authenticate(self.leader) + invalid_targets = ( + (self.leader.pk, status.HTTP_400_BAD_REQUEST), + (self.collaborator.pk, status.HTTP_400_BAD_REQUEST), + (999999, status.HTTP_400_BAD_REQUEST), + ) + for recipient_id, expected_status in invalid_targets: + with self.subTest(recipient_id=recipient_id): + response = self.client.post( + self.invitations_url(), + {"recipient_id": recipient_id}, + format="json", + ) + self.assertEqual(response.status_code, expected_status) + self.assertIn("recipient_id", response.data) + + self.create_workspace_invitation() + duplicate_response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(duplicate_response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 1, + ) + + def test_create_rejects_inactive_user_and_read_only_identifiers(self): + inactive = create_user(prefix="workspace-invite-inactive") + inactive.is_active = False + inactive.save(update_fields=["is_active"]) + self.authenticate(self.leader) + + inactive_response = self.client.post( + self.invitations_url(), + {"recipient_id": inactive.pk}, + format="json", + ) + self.assertEqual(inactive_response.status_code, status.HTTP_400_BAD_REQUEST) + + for forbidden_field in ("project", "user", "recipient", "sender", "status"): + with self.subTest(field=forbidden_field): + response = self.client.post( + self.invitations_url(), + { + "recipient_id": self.recipient.pk, + forbidden_field: self.outsider.pk, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(forbidden_field, response.data) + + self.assertFalse( + Invite.objects.filter(project=self.project, user=self.recipient).exists() + ) + + def test_legacy_program_project_requires_recipient_membership(self): + program = link_project_to_program(project=self.project) + self.authenticate(self.leader) + + rejected = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(rejected.status_code, status.HTTP_400_BAD_REQUEST) + + add_user_to_program(user=self.recipient, program=program) + accepted = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(accepted.status_code, status.HTTP_201_CREATED) + + def test_leader_lists_only_project_invitation_history(self): + own_pending = self.create_workspace_invitation() + own_declined = self.create_workspace_invitation( + recipient=self.outsider, + is_accepted=False, + ) + other_project = create_project(leader=self.leader) + create_invite(project=other_project) + self.authenticate(self.leader) + + response = self.client.get(self.invitations_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {own_pending.pk, own_declined.pk}, + ) + self.assertEqual( + {item["project"]["id"] for item in response.data}, + {self.project.pk}, + ) + + def test_collaborator_and_outsider_cannot_list_project_invitations(self): + self.create_workspace_invitation() + for actor, expected_status in ( + (self.collaborator, status.HTTP_403_FORBIDDEN), + (self.outsider, status.HTTP_404_NOT_FOUND), + ): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.client.get(self.invitations_url()) + self.assertEqual(response.status_code, expected_status) + + def test_recipient_lists_only_own_incoming_invitation_history(self): + own = self.create_workspace_invitation() + own_declined = self.create_workspace_invitation( + project=create_project(), + user=self.recipient, + invited_by=None, + is_accepted=False, + ) + self.create_workspace_invitation(recipient=self.outsider) + self.authenticate(self.recipient) + + response = self.client.get(self.incoming_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], [own.pk, own_declined.pk] + ) + self.assertTrue( + all(item["recipient"]["id"] == self.recipient.pk for item in response.data) + ) + + def test_recipient_accepts_invitation_atomically_and_only_once(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + response = self.client.post(self.accept_url(invitation), {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + collaborator = Collaborator.objects.get( + project=self.project, + user=self.recipient, + ) + self.assertEqual(invitation.status, Invite.STATUS_ACCEPTED) + self.assertIsNotNone(invitation.resolved_at) + self.assertEqual(collaborator.role, invitation.role) + self.assertEqual(collaborator.specialization, invitation.specialization) + self.assertEqual(response.data["status"], Invite.STATUS_ACCEPTED) + self.assertIsNotNone(response.data["processed_at"]) + self.assertTrue( + Notification.objects.filter( + recipient=self.leader, + type=Notification.Type.PROJECT_INVITE_ACCEPTED, + ).exists() + ) + + repeated = self.client.post(self.accept_url(invitation), {}, format="json") + self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) + decline_after_accept = self.client.post( + self.decline_url(invitation), {}, format="json" + ) + self.assertEqual(decline_after_accept.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + Collaborator.objects.filter( + project=self.project, + user=self.recipient, + ).count(), + 1, + ) + + def test_failed_accept_rolls_back_invitation_and_collaborator(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + with patch( + "invites.workspace_services.Collaborator.objects.create", + side_effect=IntegrityError("forced failure"), + ): + with self.assertRaises(IntegrityError): + self.client.post(self.accept_url(invitation), {}, format="json") + + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertIsNone(invitation.resolved_at) + self.assertFalse( + Collaborator.objects.filter( + project=self.project, + user=self.recipient, + ).exists() + ) + + def test_other_user_cannot_accept_or_decline_invitation(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.outsider) + + self.assertEqual( + self.client.post(self.accept_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.assertEqual( + self.client.post(self.decline_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + + def test_recipient_declines_once_and_declined_or_revoked_cannot_be_accepted(self): + declined = self.create_workspace_invitation() + revoked = self.create_workspace_invitation(recipient=self.outsider) + + self.authenticate(self.recipient) + decline_response = self.client.post(self.decline_url(declined), {}, format="json") + self.assertEqual(decline_response.status_code, status.HTTP_200_OK) + self.assertEqual(decline_response.data["status"], Invite.STATUS_DECLINED) + self.assertTrue( + Notification.objects.filter( + recipient=self.leader, + type=Notification.Type.PROJECT_INVITE_DECLINED, + ).exists() + ) + repeated_decline = self.client.post(self.decline_url(declined), {}, format="json") + self.assertEqual(repeated_decline.status_code, status.HTTP_409_CONFLICT) + declined_accept = self.client.post(self.accept_url(declined), {}, format="json") + self.assertEqual(declined_accept.status_code, status.HTTP_409_CONFLICT) + + self.authenticate(self.leader) + revoke_response = self.client.post(self.revoke_url(revoked), {}, format="json") + self.assertEqual(revoke_response.status_code, status.HTTP_200_OK) + self.authenticate(self.outsider) + revoked_accept = self.client.post(self.accept_url(revoked), {}, format="json") + self.assertEqual(revoked_accept.status_code, status.HTTP_409_CONFLICT) + + def test_leader_revokes_pending_invitation_without_deleting_history(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.leader) + + response = self.client.post(self.revoke_url(invitation), {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_REVOKED) + self.assertIsNotNone(invitation.resolved_at) + self.assertTrue(Invite.objects.filter(pk=invitation.pk).exists()) + self.assertTrue( + Notification.objects.filter( + recipient=self.recipient, + type=Notification.Type.PROJECT_INVITE_REVOKED, + ).exists() + ) + repeated = self.client.post(self.revoke_url(invitation), {}, format="json") + self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) + + def test_collaborator_outsider_and_wrong_project_cannot_revoke(self): + invitation = self.create_workspace_invitation() + other_project = create_project(leader=self.leader) + + self.authenticate(self.collaborator) + self.assertEqual( + self.client.post(self.revoke_url(invitation), {}, format="json").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.authenticate(self.outsider) + self.assertEqual( + self.client.post(self.revoke_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.authenticate(self.leader) + self.assertEqual( + self.client.post( + self.revoke_url(invitation, other_project), + {}, + format="json", + ).status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_staff_can_revoke_but_processed_invitation_cannot_be_revoked(self): + pending = self.create_workspace_invitation() + processed = self.create_workspace_invitation( + recipient=self.outsider, + is_accepted=False, + ) + self.authenticate(self.staff) + + self.assertEqual( + self.client.post(self.revoke_url(pending), {}, format="json").status_code, + status.HTTP_200_OK, + ) + self.assertEqual( + self.client.post(self.revoke_url(processed), {}, format="json").status_code, + status.HTTP_409_CONFLICT, + ) + + def test_action_payload_cannot_override_project_recipient_or_status(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + response = self.client.post( + self.accept_url(invitation), + {"project": 999, "recipient_id": self.outsider.pk, "status": "accepted"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertFalse( + Collaborator.objects.filter( + project=self.project, user=self.recipient + ).exists() + ) + + def test_database_rejects_two_active_invitations(self): + self.create_workspace_invitation() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self.create_workspace_invitation() + + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 1, + ) + + def test_completed_invitation_allows_a_new_pending_invitation(self): + self.create_workspace_invitation(is_accepted=False) + + invitation = self.create_workspace_invitation() + + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 2, + ) + + def test_project_and_incoming_lists_have_bounded_queries(self): + self.create_workspace_invitation() + for index in range(5): + recipient = create_user(prefix=f"workspace-list-recipient-{index}") + self.create_workspace_invitation(recipient=recipient) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as project_queries: + project_response = self.client.get(self.invitations_url()) + self.authenticate(self.recipient) + with CaptureQueriesContext(connection) as incoming_queries: + incoming_response = self.client.get(self.incoming_url()) + + self.assertEqual(project_response.status_code, status.HTTP_200_OK) + self.assertEqual(incoming_response.status_code, status.HTTP_200_OK) + self.assertLessEqual(len(project_queries), 4) + self.assertLessEqual(len(incoming_queries), 2) + + def test_legacy_invite_and_workspace_content_endpoints_remain_available(self): + self.authenticate(self.leader) + + legacy_response = self.client.post( + "/invites/", + { + "project": self.project.pk, + "user": self.recipient.pk, + "role": "Legacy role", + }, + format="json", + ) + workspace_response = self.client.get(f"/projects/{self.project.pk}/workspace/") + goals_response = self.client.get(f"/projects/{self.project.pk}/workspace/goals/") + achievements_response = self.client.get( + f"/projects/{self.project.pk}/workspace/achievements/" + ) + + self.assertEqual(legacy_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(workspace_response.status_code, status.HTTP_200_OK) + self.assertEqual(goals_response.status_code, status.HTTP_200_OK) + self.assertEqual(achievements_response.status_code, status.HTTP_200_OK) diff --git a/invites/throttling.py b/invites/throttling.py new file mode 100644 index 00000000..493f0e8e --- /dev/null +++ b/invites/throttling.py @@ -0,0 +1,10 @@ +from rest_framework.throttling import ScopedRateThrottle + + +class ProjectInvitationCandidateSearchScopedRateThrottle(ScopedRateThrottle): + """Ограничивает scoped-поиск без включения глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate diff --git a/invites/views.py b/invites/views.py index aeb5edfd..e158ce83 100644 --- a/invites/views.py +++ b/invites/views.py @@ -7,7 +7,11 @@ from invites.permissions import InviteDecisionPermission, InviteDetailPermission from invites.querysets import get_visible_invites_queryset from invites.serializers import InviteDetailSerializer, InviteListSerializer -from projects.models import Collaborator +from invites.workspace_services import ( + ProjectInvitationServiceError, + accept_project_invitation, + decline_project_invitation, +) class InviteList(generics.ListCreateAPIView): @@ -18,7 +22,8 @@ class InviteList(generics.ListCreateAPIView): def get_queryset(self): return get_visible_invites_queryset(self.request.user).filter( - is_accepted__isnull=True + is_accepted__isnull=True, + is_revoked=False, ) def create(self, request, *args, **kwargs): @@ -27,7 +32,7 @@ def create(self, request, *args, **kwargs): if serializer.validated_data["project"].leader != request.user: # additional check that the user is the invite's project's leader return Response(status=status.HTTP_403_FORBIDDEN) - instance = serializer.save() + instance = serializer.save(invited_by=request.user) headers = self.get_success_headers(serializer.data) # using detailed serializer so that it'll pass User and Project objects detailed @@ -51,27 +56,16 @@ class InviteAccept(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() # type: Invite - if invite.is_accepted is not None: - return Response( - {"detail": "Invite has already been processed."}, - status=status.HTTP_409_CONFLICT, + try: + accept_project_invitation( + invitation_id=invite.pk, + actor=request.user, ) - # add user to project collaborators - collaborator, created = Collaborator.objects.get_or_create( - user=invite.user, - project=invite.project, - defaults={ - "role": invite.role, - "specialization": invite.specialization, - }, - ) - if not created: + except ProjectInvitationServiceError as exc: return Response( - {"detail": "User is already a collaborator of this project."}, + {"detail": exc.detail}, status=status.HTTP_409_CONFLICT, ) - invite.is_accepted = True - invite.save() return Response(status=status.HTTP_200_OK) @@ -82,11 +76,14 @@ class InviteDecline(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() - if invite.is_accepted is not None: + try: + decline_project_invitation( + invitation_id=invite.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: return Response( - {"detail": "Invite has already been processed."}, + {"detail": exc.detail}, status=status.HTTP_409_CONFLICT, ) - invite.is_accepted = False - invite.save() return Response(status=status.HTTP_200_OK) diff --git a/invites/workspace_selectors.py b/invites/workspace_selectors.py new file mode 100644 index 00000000..3e18df1e --- /dev/null +++ b/invites/workspace_selectors.py @@ -0,0 +1,91 @@ +from django.contrib.auth import get_user_model +from django.db.models import Exists, OuterRef, Q, Value +from django.db.models.functions import Concat, Lower + +from invites.models import Invite +from partner_programs.models import PartnerProgramUserProfile +from projects.models import Collaborator, Project + +User = get_user_model() + +PROJECT_INVITATION_CANDIDATE_LIMIT = 20 + + +def get_project_invitation_candidates(*, project: Project, query: str): + """Возвращает безопасный ограниченный queryset кандидатов для Project. + + Selector только помогает лидеру выбрать пользователя. Сервис создания + приглашения повторно проверяет eligibility, поэтому изменение состава или + регистрации между поиском и POST не позволяет обойти бизнес-правила. + """ + collaborators = Collaborator.objects.filter( + project_id=project.pk, + user_id=OuterRef("pk"), + ) + pending_invitations = Invite.objects.filter( + project_id=project.pk, + user_id=OuterRef("pk"), + is_accepted__isnull=True, + is_revoked=False, + ) + + normalized_query = query.strip() + # В production PostgreSQL `icontains` регистронезависим для Unicode. Набор + # вариантов сохраняет такой же результат в локальной SQLite test-среде. + query_variants = dict.fromkeys( + ( + normalized_query, + normalized_query.casefold(), + normalized_query.lower(), + normalized_query.upper(), + normalized_query.title(), + normalized_query.capitalize(), + ) + ) + search_filter = Q() + for query_variant in query_variants: + search_filter |= ( + Q(first_name__icontains=query_variant) + | Q(last_name__icontains=query_variant) + | Q(candidate_full_name__icontains=query_variant) + | Q(candidate_reverse_name__icontains=query_variant) + | Q(email__istartswith=query_variant) + ) + + candidates = User.objects.annotate( + candidate_full_name=Concat("first_name", Value(" "), "last_name"), + candidate_reverse_name=Concat("last_name", Value(" "), "first_name"), + candidate_is_collaborator=Exists(collaborators), + candidate_has_pending_invitation=Exists(pending_invitations), + ).filter( + is_active=True, + candidate_is_collaborator=False, + candidate_has_pending_invitation=False, + ) + + # Legacy Project считается напрямую связанным максимум с одной программой. + # Повторяем invariant сервиса создания приглашения, не расширяя его контракт. + program_id = ( + project.program_links.order_by("pk") + .values_list("partner_program_id", flat=True) + .first() + ) + if program_id is not None: + program_members = PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + user_id=OuterRef("pk"), + ) + candidates = candidates.annotate( + candidate_is_program_member=Exists(program_members) + ).filter(candidate_is_program_member=True) + + # Минимальная длина query, DB-limit и project-scoped исключения не дают + # использовать endpoint как глобальный каталог пользователей платформы. + return ( + candidates.exclude(pk=project.leader_id) + .filter(search_filter) + .only("id", "first_name", "last_name", "avatar") + .order_by(Lower("last_name"), Lower("first_name"), "pk")[ + :PROJECT_INVITATION_CANDIDATE_LIMIT + ] + ) diff --git a/invites/workspace_serializers.py b/invites/workspace_serializers.py new file mode 100644 index 00000000..5b59c39a --- /dev/null +++ b/invites/workspace_serializers.py @@ -0,0 +1,138 @@ +from rest_framework import serializers + +from invites.models import Invite +from projects.models import Project +from users.models import CustomUser + + +class ProjectInvitationCandidateQuerySerializer(serializers.Serializer): + """Проверяет обязательный узкий поисковый запрос кандидатов.""" + + q = serializers.CharField( # noqa: VNE001 — имя закреплено API-контрактом. + required=True, + trim_whitespace=True, + min_length=3, + max_length=100, + error_messages={ + "required": "Укажите поисковый запрос.", + "blank": "Укажите поисковый запрос.", + "min_length": "Введите не менее 3 символов.", + "max_length": "Введите не более 100 символов.", + }, + ) + + +class ProjectInvitationCandidateSerializer(serializers.ModelSerializer): + """Возвращает только публичный минимум профиля кандидата.""" + + display_name = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ("id", "display_name", "avatar") + read_only_fields = fields + + def get_display_name(self, user: CustomUser) -> str: + """Формирует имя без fallback на закрытый email пользователя.""" + return user.get_full_name().strip() + + +class ProjectInvitationCreateSerializer(serializers.Serializer): + """Принимает пользователя и необязательные данные будущего Collaborator.""" + + recipient_id = serializers.IntegerField(min_value=1) + role = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=128, + ) + specialization = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=100, + ) + message = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=4096, + ) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference(self.fields) + if unsupported: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать при создании приглашения." + for field in sorted(unsupported) + } + ) + return attrs + + +class ProjectInvitationActionSerializer(serializers.Serializer): + """Запрещает подмену Project, получателя или статуса в action payload.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать для данного действия." + for field in sorted(self.initial_data) + } + ) + return attrs + + +class ProjectInvitationUserSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ("id", "first_name", "last_name", "avatar") + read_only_fields = fields + + +class ProjectInvitationProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ("id", "name", "draft", "is_public") + read_only_fields = fields + + +class ProjectInvitationSerializer(serializers.ModelSerializer): + """Не раскрывает email и приватные поля профилей участников Project.""" + + project = ProjectInvitationProjectSerializer(read_only=True) + sender = serializers.SerializerMethodField() + recipient = ProjectInvitationUserSerializer(source="user", read_only=True) + status = serializers.CharField(read_only=True) + message = serializers.CharField( + source="motivational_letter", + read_only=True, + allow_null=True, + ) + processed_at = serializers.DateTimeField(source="resolved_at", read_only=True) + created_at = serializers.DateTimeField(source="datetime_created", read_only=True) + updated_at = serializers.DateTimeField(source="datetime_updated", read_only=True) + + class Meta: + model = Invite + fields = ( + "id", + "project", + "sender", + "recipient", + "status", + "role", + "specialization", + "message", + "created_at", + "processed_at", + "updated_at", + ) + read_only_fields = fields + + def get_sender(self, invitation: Invite): + sender = invitation.invited_by or invitation.project.leader + return ProjectInvitationUserSerializer(sender).data diff --git a/invites/workspace_services.py b/invites/workspace_services.py new file mode 100644 index 00000000..043faf96 --- /dev/null +++ b/invites/workspace_services.py @@ -0,0 +1,197 @@ +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from invites.models import Invite +from notifications.events import ( + notify_project_invite_created, + notify_project_invite_resolved, +) +from partner_programs.models import PartnerProgramUserProfile +from projects.models import Collaborator, Project + +User = get_user_model() + + +class ProjectInvitationServiceError(Exception): + """Базовая контролируемая ошибка lifecycle приглашения в Project.""" + + code = "project_invitation_error" + default_detail = "Операция с приглашением недоступна." + default_field = "non_field_errors" + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field or self.default_field + super().__init__(self.detail) + + +class ProjectInvitationPermissionError(ProjectInvitationServiceError): + code = "project_invitation_permission_denied" + default_detail = "У вас нет прав для управления приглашениями проекта." + default_field = "project" + + +class ProjectInvitationNotPendingError(ProjectInvitationServiceError): + code = "project_invitation_not_pending" + default_detail = "Приглашение уже обработано и не может быть изменено." + default_field = "status" + + +class ProjectInvitationTargetInvalidError(ProjectInvitationServiceError): + code = "project_invitation_target_invalid" + default_detail = "Пользователя нельзя пригласить в этот проект." + default_field = "recipient_id" + + +class ProjectInvitationDuplicateError(ProjectInvitationServiceError): + code = "project_invitation_duplicate" + default_detail = "Активное приглашение для этого пользователя уже существует." + default_field = "recipient_id" + + +class ProjectInvitationNotOwnedError(ProjectInvitationServiceError): + code = "project_invitation_not_owned" + default_detail = "Это приглашение предназначено другому пользователю." + default_field = "invitation" + + +def can_manage_project_invitations(user, project: Project) -> bool: + """Разрешает управление только лидеру Project и административным ролям.""" + return bool( + user + and user.is_authenticated + and (user.is_staff or user.is_superuser or project.leader_id == user.pk) + ) + + +def _require_pending(invitation: Invite) -> None: + if not invitation.is_pending: + raise ProjectInvitationNotPendingError() + + +def _require_eligible_recipient(*, project: Project, recipient: User) -> None: + if project.leader_id == recipient.pk: + raise ProjectInvitationTargetInvalidError("Руководитель уже состоит в проекте.") + if Collaborator.objects.filter(project=project, user=recipient).exists(): + raise ProjectInvitationTargetInvalidError( + "Пользователь уже является участником проекта." + ) + + # Legacy Project может быть напрямую связан с одной PartnerProgram. + # Повторяем invariant Collaborator.clean(), иначе принятие заведомо упадет. + program_link = project.program_links.only("partner_program_id").first() + if ( + program_link + and not PartnerProgramUserProfile.objects.filter( + partner_program_id=program_link.partner_program_id, + user=recipient, + ).exists() + ): + raise ProjectInvitationTargetInvalidError( + "Пользователь не является участником программы проекта." + ) + + +def _lock_invitation_graph(invitation_id: int) -> tuple[Project, Invite]: + """Блокирует Project и Invite в едином порядке для всех переходов статуса.""" + reference = Invite.objects.only("project_id").get(pk=invitation_id) + project = Project.objects.select_for_update().get(pk=reference.project_id) + invitation = Invite.objects.select_for_update().get(pk=invitation_id) + return project, invitation + + +def create_project_invitation( + *, + project_id: int, + actor: User, + recipient: User, + role: str | None = None, + specialization: str | None = None, + motivational_letter: str | None = None, +) -> Invite: + """Создает единственное pending-приглашение под блокировкой Project.""" + with transaction.atomic(): + project = Project.objects.select_for_update().get(pk=project_id) + if not can_manage_project_invitations(actor, project): + raise ProjectInvitationPermissionError() + _require_eligible_recipient(project=project, recipient=recipient) + + pending = Invite.objects.select_for_update().filter( + project=project, + user=recipient, + is_accepted__isnull=True, + is_revoked=False, + ) + if pending.exists(): + raise ProjectInvitationDuplicateError() + + try: + # Savepoint сохраняет внешнюю транзакцию пригодной для проверки + # partial unique constraint после конкурентного INSERT. + with transaction.atomic(): + invitation = Invite.objects.create( + project=project, + user=recipient, + invited_by=actor, + role=role, + specialization=specialization, + motivational_letter=motivational_letter, + ) + except IntegrityError as exc: + if pending.exists(): + raise ProjectInvitationDuplicateError() from exc + raise + notify_project_invite_created(invitation) + return invitation + + +def accept_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Атомарно принимает приглашение и создает Collaborator один раз.""" + with transaction.atomic(): + project, invitation = _lock_invitation_graph(invitation_id) + if invitation.user_id != actor.pk: + raise ProjectInvitationNotOwnedError() + _require_pending(invitation) + recipient = User.objects.get(pk=invitation.user_id) + _require_eligible_recipient(project=project, recipient=recipient) + + Collaborator.objects.create( + project=project, + user=recipient, + role=invitation.role, + specialization=invitation.specialization, + ) + invitation.is_accepted = True + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="accepted") + return invitation + + +def decline_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Завершает pending-приглашение решением его получателя.""" + with transaction.atomic(): + _project, invitation = _lock_invitation_graph(invitation_id) + if invitation.user_id != actor.pk: + raise ProjectInvitationNotOwnedError() + _require_pending(invitation) + invitation.is_accepted = False + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="declined") + return invitation + + +def revoke_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Сохраняет отзыв в истории вместо физического удаления Invite.""" + with transaction.atomic(): + project, invitation = _lock_invitation_graph(invitation_id) + if not can_manage_project_invitations(actor, project): + raise ProjectInvitationPermissionError() + _require_pending(invitation) + invitation.is_revoked = True + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_revoked", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="revoked") + return invitation diff --git a/invites/workspace_views.py b/invites/workspace_views.py new file mode 100644 index 00000000..718154f1 --- /dev/null +++ b/invites/workspace_views.py @@ -0,0 +1,252 @@ +from django.contrib.auth import get_user_model +from django.db.models import Case, IntegerField, Value, When +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import ( + APIException, + NotFound, + PermissionDenied, + ValidationError, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from invites.models import Invite +from invites.throttling import ProjectInvitationCandidateSearchScopedRateThrottle +from invites.workspace_serializers import ( + ProjectInvitationActionSerializer, + ProjectInvitationCandidateQuerySerializer, + ProjectInvitationCandidateSerializer, + ProjectInvitationCreateSerializer, + ProjectInvitationSerializer, +) +from invites.workspace_selectors import get_project_invitation_candidates +from invites.workspace_services import ( + ProjectInvitationDuplicateError, + ProjectInvitationNotOwnedError, + ProjectInvitationNotPendingError, + ProjectInvitationPermissionError, + ProjectInvitationServiceError, + ProjectInvitationTargetInvalidError, + accept_project_invitation, + can_manage_project_invitations, + create_project_invitation, + decline_project_invitation, + revoke_project_invitation, +) +from projects.models import Project +from projects.workspace_selectors import filter_workspace_visible_projects + +User = get_user_model() + + +class ProjectInvitationConflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = "Приглашение уже обработано." + default_code = "project_invitation_conflict" + + +def _invitation_queryset(): + return Invite.objects.select_related( + "project", + "project__leader", + "user", + "invited_by", + ) + + +def _get_visible_project(*, project_id: int, user) -> Project: + projects = Project.objects.select_related("leader") + projects = filter_workspace_visible_projects(projects, user=user) + return get_object_or_404(projects, pk=project_id) + + +def _require_manager(user, project: Project) -> None: + if not can_manage_project_invitations(user, project): + raise PermissionDenied( + ProjectInvitationPermissionError.default_detail, + code=ProjectInvitationPermissionError.code, + ) + + +def _raise_domain_error(exc: ProjectInvitationServiceError): + if isinstance(exc, ProjectInvitationPermissionError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + if isinstance(exc, ProjectInvitationNotOwnedError): + raise NotFound("Project invitation not found.") from exc + if isinstance( + exc, (ProjectInvitationDuplicateError, ProjectInvitationNotPendingError) + ): + raise ProjectInvitationConflict( + {exc.field: [exc.detail]}, + code=exc.code, + ) from exc + raise ValidationError({exc.field: [exc.detail]}, code=exc.code) from exc + + +def _validate_empty_action_payload(data) -> None: + serializer = ProjectInvitationActionSerializer(data=data) + serializer.is_valid(raise_exception=True) + + +class ProjectInvitationCandidateSearchView(APIView): + """Ищет кандидатов только внутри доступного лидеру Project.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [ProjectInvitationCandidateSearchScopedRateThrottle] + throttle_scope = "project_invitation_candidate_search" + + def get(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + query_serializer = ProjectInvitationCandidateQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + candidates = get_project_invitation_candidates( + project=project, + query=query_serializer.validated_data["q"], + ) + return Response(ProjectInvitationCandidateSerializer(candidates, many=True).data) + + +class ProjectInvitationListCreateView(APIView): + """Возвращает историю приглашений Project и создает pending-запись.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + invitations = ( + _invitation_queryset() + .filter(project=project) + .order_by( + "-datetime_created", + "-id", + ) + ) + return Response(ProjectInvitationSerializer(invitations, many=True).data) + + def post(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + serializer = ProjectInvitationCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + recipient = User.objects.filter( + pk=serializer.validated_data["recipient_id"], + is_active=True, + ).first() + if recipient is None: + _raise_domain_error(ProjectInvitationTargetInvalidError()) + + try: + invitation = create_project_invitation( + project_id=project.pk, + actor=request.user, + recipient=recipient, + role=serializer.validated_data.get("role"), + specialization=serializer.validated_data.get("specialization"), + motivational_letter=serializer.validated_data.get("message"), + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response( + ProjectInvitationSerializer(invitation).data, + status=status.HTTP_201_CREATED, + ) + + +class IncomingProjectInvitationListView(APIView): + """Возвращает только историю приглашений текущего пользователя.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + invitations = ( + _invitation_queryset() + .filter(user=request.user) + .annotate( + pending_order=Case( + When( + is_accepted__isnull=True, + is_revoked=False, + then=Value(0), + ), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("pending_order", "-datetime_created", "-id") + ) + return Response(ProjectInvitationSerializer(invitations, many=True).data) + + +class ProjectInvitationAcceptView(APIView): + """Принимает pending-приглашение только его получателем.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, invitation_id): + _validate_empty_action_payload(request.data) + invitation = get_object_or_404( + _invitation_queryset().filter(user=request.user), + pk=invitation_id, + ) + try: + invitation = accept_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) + + +class ProjectInvitationDeclineView(APIView): + """Отклоняет pending-приглашение только его получателем.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, invitation_id): + _validate_empty_action_payload(request.data) + invitation = get_object_or_404( + _invitation_queryset().filter(user=request.user), + pk=invitation_id, + ) + try: + invitation = decline_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) + + +class ProjectInvitationRevokeView(APIView): + """Отзывает pending-приглашение через project-scoped URL без удаления истории.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, project_id, invitation_id): + _validate_empty_action_payload(request.data) + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + invitation = get_object_or_404( + _invitation_queryset().filter(project=project), + pk=invitation_id, + ) + try: + invitation = revoke_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) diff --git a/mailing/tests/test_models_rendering.py b/mailing/tests/test_models_rendering.py index f0f19dfe..0ed2de23 100644 --- a/mailing/tests/test_models_rendering.py +++ b/mailing/tests/test_models_rendering.py @@ -75,10 +75,9 @@ def test_template_render_context_contains_schema_users_and_fields(self): self.assertTrue(context["picked_users"][0]["picked"]) self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id) self.assertFalse(context["unpicked_users"][0]["picked"]) - self.assertEqual( - context["template_fields"], - [ - {"key": "title", "title": "Title", "default": "Default title"}, - {"key": "text", "title": "Text", "default": ""}, - ], - ) + template_fields = {field["key"]: field for field in context["template_fields"]} + self.assertEqual(set(template_fields), {"title", "text"}) + self.assertEqual(template_fields["title"]["title"], "Title") + self.assertEqual(template_fields["title"]["default"], "Default title") + self.assertEqual(template_fields["text"]["title"], "Text") + self.assertEqual(template_fields["text"]["default"], "") diff --git a/news/access.py b/news/access.py new file mode 100644 index 00000000..1374a486 --- /dev/null +++ b/news/access.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import AnonymousUser + +from news.models import News +from news.services import is_content_news +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +def is_administrative_user(user: CustomUser | AnonymousUser | None) -> bool: + """Проверяет административный доступ без привязки к конкретной программе.""" + return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) + + +def can_view_program_participant_news(user, program: PartnerProgram) -> bool: + """Разрешает внутренние новости участникам, менеджерам и администраторам.""" + if not user or not user.is_authenticated: + return False + if is_administrative_user(user) or program.is_manager(user): + return True + return program.users.filter(pk=user.pk).exists() + + +def can_view_news_in_react_feed(user, news: News) -> bool: + """Проверяет доступ к полноценной публикации нового React-контура.""" + if not user or not user.is_authenticated or not is_content_news(news): + return False + + source = news.content_object + if isinstance(source, PartnerProgram): + if news.audience == News.Audience.PLATFORM: + return True + return can_view_program_participant_news(user, source) + + if isinstance(source, Project): + return not source.draft and source.is_public + + return isinstance(source, CustomUser) diff --git a/news/admin.py b/news/admin.py index dcefeb48..83892f1f 100644 --- a/news/admin.py +++ b/news/admin.py @@ -2,7 +2,7 @@ from django.db.models import QuerySet from django.http import HttpRequest -from news.models import News +from news.models import News, NewsComment from partner_programs.models import PartnerProgram @@ -15,6 +15,7 @@ class NewsAdmin(admin.ModelAdmin): "object_id", "text", "pin", + "audience", "datetime_created", "datetime_updated", ) @@ -24,10 +25,12 @@ class NewsAdmin(admin.ModelAdmin): "object_id", "text", "pin", + "audience", "datetime_created", "datetime_updated", ) list_filter = ( + "audience", "datetime_created", "datetime_updated", ) @@ -51,6 +54,20 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[News]: ) return qs + +@admin.register(NewsComment) +class NewsCommentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "news", + "author", + "datetime_created", + "datetime_updated", + ) + list_filter = ("datetime_created", "datetime_updated") + search_fields = ("text", "author__email") + readonly_fields = ("datetime_created", "datetime_updated") + # fieldsets = ( # ( # None, diff --git a/news/migrations/0010_news_audience_newscomment.py b/news/migrations/0010_news_audience_newscomment.py new file mode 100644 index 00000000..903f341d --- /dev/null +++ b/news/migrations/0010_news_audience_newscomment.py @@ -0,0 +1,121 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def set_existing_news_audiences(apps, schema_editor): + News = apps.get_model("news", "News") + ContentType = apps.get_model("contenttypes", "ContentType") + + News.objects.all().update(audience="platform") + program_content_type_id = ( + ContentType.objects.filter( + app_label="partner_programs", + model="partnerprogram", + ) + .values_list("id", flat=True) + .first() + ) + if program_content_type_id is not None: + # Старые новости программ считаются внутренними: миграция не должна + # случайно опубликовать их во всей платформе. + News.objects.filter(content_type_id=program_content_type_id).update( + audience="program_participants" + ) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("news", "0009_news_pin"), + ] + + operations = [ + migrations.AddField( + model_name="news", + name="audience", + field=models.CharField( + choices=[ + ("platform", "Вся платформа"), + ("program_participants", "Участники программы"), + ], + db_index=True, + default="platform", + max_length=24, + verbose_name="Аудитория", + ), + ), + migrations.RunPython( + set_existing_news_audiences, + migrations.RunPython.noop, + ), + migrations.AddConstraint( + model_name="news", + constraint=models.CheckConstraint( + check=models.Q(audience__in=("platform", "program_participants")), + name="news_valid_audience", + ), + ), + migrations.CreateModel( + name="NewsComment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=2000, verbose_name="Текст")), + ( + "datetime_created", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Дата создания", + ), + ), + ( + "datetime_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата изменения", + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="news_comments", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "news", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="news.news", + verbose_name="Новость", + ), + ), + ], + options={ + "verbose_name": "Комментарий к новости", + "verbose_name_plural": "Комментарии к новостям", + "ordering": ["datetime_created", "id"], + "indexes": [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ], + }, + ), + ] diff --git a/news/models.py b/news/models.py index a1a5a189..d6eebe32 100644 --- a/news/models.py +++ b/news/models.py @@ -1,5 +1,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone @@ -10,6 +12,10 @@ class News(models.Model): + class Audience(models.TextChoices): + PLATFORM = "platform", "Вся платформа" + PROGRAM_PARTICIPANTS = "program_participants", "Участники программы" + content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, @@ -38,6 +44,13 @@ class News(models.Model): verbose_name="Закрепить новость", help_text="Закрепить новость (пока только для профиля программ)", ) + audience = models.CharField( + max_length=24, + choices=Audience.choices, + default=Audience.PLATFORM, + db_index=True, + verbose_name="Аудитория", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, default=timezone.now ) @@ -49,7 +62,70 @@ class News(models.Model): objects = NewsManager() + def clean(self): + """Запрещает внутреннюю аудиторию вне контекста партнерской программы.""" + super().clean() + if ( + self.audience == self.Audience.PROGRAM_PARTICIPANTS + and self.content_type_id + and ( + self.content_type.app_label != "partner_programs" + or self.content_type.model != "partnerprogram" + ) + ): + raise ValidationError( + { + "audience": ( + "Закрытая аудитория доступна только для новостей программы." + ) + } + ) + class Meta(TypedModelMeta): verbose_name = "Новость" verbose_name_plural = "Новости" ordering = ["-datetime_created"] + constraints = [ + models.CheckConstraint( + check=models.Q(audience__in=("platform", "program_participants")), + name="news_valid_audience", + ) + ] + + +class NewsComment(models.Model): + news = models.ForeignKey( + News, + on_delete=models.CASCADE, + related_name="comments", + verbose_name="Новость", + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="news_comments", + verbose_name="Автор", + ) + text = models.TextField(max_length=2000, verbose_name="Текст") + datetime_created = models.DateTimeField( + default=timezone.now, + verbose_name="Дата создания", + ) + # Null означает, что комментарий ещё не редактировали. В API при этом + # возвращается дата создания, поэтому контракт datetime_updated стабилен. + datetime_updated = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата изменения", + ) + + class Meta(TypedModelMeta): + verbose_name = "Комментарий к новости" + verbose_name_plural = "Комментарии к новостям" + ordering = ["datetime_created", "id"] + indexes = [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ] diff --git a/news/querysets.py b/news/querysets.py index dc40b78e..0dd59083 100644 --- a/news/querysets.py +++ b/news/querysets.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from news.models import News +from news.access import can_view_program_participant_news from news.services import FEED_RECORD_TEXT from partner_programs.models import PartnerProgram from projects.models import Project @@ -23,16 +24,26 @@ def get_user_news_queryset(user_id: int) -> QuerySet[News]: return News.objects.get_news(obj=user) -def get_program_news_queryset(program_id: int) -> QuerySet[News]: +def get_program_news_queryset(program_id: int, user=None) -> QuerySet[News]: program = get_object_or_404(PartnerProgram, pk=program_id) - return News.objects.get_news(obj=program).order_by("-pin", "-datetime_created") - - -def get_news_queryset_for_context(kwargs: dict) -> QuerySet[News]: + queryset = News.objects.get_news(obj=program) + if can_view_program_participant_news(user, program): + queryset = queryset.filter( + audience__in=( + News.Audience.PLATFORM, + News.Audience.PROGRAM_PARTICIPANTS, + ) + ) + else: + queryset = queryset.filter(audience=News.Audience.PLATFORM) + return queryset.order_by("-pin", "-datetime_created") + + +def get_news_queryset_for_context(kwargs: dict, user=None) -> QuerySet[News]: if kwargs.get("project_pk") is not None: return get_project_news_queryset(kwargs["project_pk"]) if kwargs.get("partnerprogram_pk") is not None: - return get_program_news_queryset(kwargs["partnerprogram_pk"]) + return get_program_news_queryset(kwargs["partnerprogram_pk"], user=user) if kwargs.get("user_pk") is not None: return get_user_news_queryset(kwargs["user_pk"]) return News.objects.none() diff --git a/news/serializers.py b/news/serializers.py index e0aa69a2..94f93be2 100644 --- a/news/serializers.py +++ b/news/serializers.py @@ -23,13 +23,38 @@ def __init__(self, *args, **kwargs): request = self.context.get("request") user = getattr(request, "user", None) if user and user.is_authenticated: - self.fields["files"].queryset = UserFile.objects.filter(user=user) + self.fields["files"].child_relation.queryset = UserFile.objects.filter( + user=user + ) + + def validate(self, attrs): + attrs = super().validate(attrs) + context_type = self.context.get("news_context") + audience = attrs.get("audience") + + if context_type == "program": + if self.instance is None and audience is None: + attrs["audience"] = News.Audience.PROGRAM_PARTICIPANTS + return attrs + + if audience not in (None, News.Audience.PLATFORM): + raise serializers.ValidationError( + { + "audience": ( + "Закрытая аудитория доступна только для новостей программы." + ) + } + ) + if self.instance is None: + attrs["audience"] = News.Audience.PLATFORM + return attrs class Meta: model = News fields = [ "text", "files", + "audience", ] @@ -78,6 +103,7 @@ class Meta: "pin", "image_address", "text", + "audience", "datetime_created", "views_count", "likes_count", @@ -100,7 +126,6 @@ class ProgramNewsListResponseSerializer(BaseNewsListResponseSerializer): class BaseNewsDetailResponseSerializer(BaseNewsResponseSerializer): - class Meta: model = News fields = [ @@ -109,6 +134,7 @@ class Meta: "pin", "image_address", "text", + "audience", "datetime_created", "datetime_updated", "views_count", diff --git a/news/tests/helpers.py b/news/tests/helpers.py index d8062b36..39182948 100644 --- a/news/tests/helpers.py +++ b/news/tests/helpers.py @@ -78,7 +78,7 @@ def create_user_file( ) -> UserFile: suffix = unique_suffix() return UserFile.objects.create( - link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}", + link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}", # noqa: E231 user=user, name=name, extension=extension, @@ -87,8 +87,20 @@ def create_user_file( ) -def create_news_for(obj, *, text: str = "News text", files=None, pin: bool = False) -> News: - news = News.objects.add_news(obj, text=text, files=files or []) +def create_news_for( + obj, + *, + text: str = "News text", + files=None, + pin: bool = False, + audience: str = News.Audience.PLATFORM, +) -> News: + news = News.objects.add_news( + obj, + text=text, + files=files or [], + audience=audience, + ) if pin: news.pin = True news.save(update_fields=["pin"]) diff --git a/news/tests/test_news_attachment_contract.py b/news/tests/test_news_attachment_contract.py new file mode 100644 index 00000000..680c6389 --- /dev/null +++ b/news/tests/test_news_attachment_contract.py @@ -0,0 +1,161 @@ +from unittest.mock import patch + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework.test import APIClient, APIRequestFactory + +from files.models import UserFile +from files.typings import FileInfo +from news.models import News +from news.serializers import NewsCreateSerializer + +from .helpers import create_partner_program, create_project, create_user + + +NEWS_IMAGE_LINK = "https://api.selcdn.ru/test/news-image.png" + + +class NewsAttachmentContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_project_news_accepts_current_user_file_link(self): + user = create_user(prefix="project-news-attachment-owner") + project = create_project(leader=user) + user_file = self.create_user_file(user) + self.client.force_authenticate(user) + + response = self.client.post( + f"/projects/{project.id}/news/", + { + "text": "News with image", + "files": [NEWS_IMAGE_LINK], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201, response.data) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(list(news.files.all()), [user_file]) + + def test_news_serializer_accepts_current_user_file_link(self): + user = create_user(prefix="serializer-news-attachment-owner") + user_file = self.create_user_file(user) + request = APIRequestFactory().post( + "/projects/1/news/", + { + "text": "News with image", + "files": [NEWS_IMAGE_LINK], + }, + format="json", + ) + request.user = user + + serializer = NewsCreateSerializer( + data={ + "text": "News with image", + "files": [NEWS_IMAGE_LINK], + }, + context={ + "request": request, + "news_context": "project", + }, + ) + + self.assertTrue(UserFile.objects.filter(user=user, link=NEWS_IMAGE_LINK).exists()) + self.assertEqual(UserFile.objects.get(pk=NEWS_IMAGE_LINK), user_file) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["files"], [user_file]) + + def test_user_news_accepts_current_user_file_link(self): + user = create_user(prefix="user-news-attachment-owner") + user_file = self.create_user_file(user) + self.client.force_authenticate(user) + + response = self.client.post( + f"/auth/users/{user.id}/news/", + { + "text": "News with image", + "files": [NEWS_IMAGE_LINK], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201, response.data) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(list(news.files.all()), [user_file]) + + def test_program_news_accepts_current_user_file_link(self): + user = create_user(prefix="program-news-attachment-owner") + program = create_partner_program(manager=user) + user_file = self.create_user_file(user) + self.client.force_authenticate(user) + + response = self.client.post( + f"/programs/{program.id}/news/", + { + "text": "News with image", + "files": [NEWS_IMAGE_LINK], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201, response.data) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(list(news.files.all()), [user_file]) + + def test_uploaded_file_link_can_be_attached_to_project_news(self): + user = create_user(prefix="project-news-upload-flow-owner") + project = create_project(leader=user) + self.client.force_authenticate(user) + uploaded_file = SimpleUploadedFile( + "news-image.png", + b"image-content", + content_type="image/png", + ) + + with patch( + "files.views.FileView.cdn.upload", + return_value=FileInfo( + url=NEWS_IMAGE_LINK, + size=len(b"image-content"), + name="news-image", + extension="png", + mime_type="image/png", + ), + ): + upload_response = self.client.post( + "/files/", + {"file": uploaded_file}, + format="multipart", + ) + + self.assertEqual(upload_response.status_code, 201) + url = upload_response.data["url"] + user_file = UserFile.objects.get(link=url) + self.assertEqual(upload_response.data["url"], user_file.link) + self.assertEqual(user_file.user, user) + + news_response = self.client.post( + f"/projects/{project.id}/news/", + { + "text": "News with image", + "files": [url], + }, + format="json", + ) + + self.assertEqual(news_response.status_code, 201, news_response.data) + news = News.objects.get(pk=news_response.data["id"]) + self.assertEqual(list(news.files.all()), [user_file]) + + @staticmethod + def create_user_file(user): + return UserFile.objects.create( + user=user, + link=NEWS_IMAGE_LINK, + name="news-image", + extension="png", + mime_type="image/png", + size=1024, + ) diff --git a/news/tests/test_news_migration_0010.py b/news/tests/test_news_migration_0010.py new file mode 100644 index 00000000..fc42639c --- /dev/null +++ b/news/tests/test_news_migration_0010.py @@ -0,0 +1,81 @@ +from importlib import import_module +import unittest + +from django.db import connection, migrations +from django.db.migrations.executor import MigrationExecutor +from django.test import SimpleTestCase, TransactionTestCase + + +class NewsMigration0010MetadataTests(SimpleTestCase): + def test_migration_is_non_atomic(self): + migration = import_module("news.migrations.0010_news_audience_newscomment") + + self.assertIs(migration.Migration.atomic, False) + + def test_runpython_does_not_reintroduce_atomic_transaction(self): + migration = import_module("news.migrations.0010_news_audience_newscomment") + run_python_operations = [ + operation + for operation in migration.Migration.operations + if isinstance(operation, migrations.RunPython) + ] + + self.assertEqual(len(run_python_operations), 1) + self.assertIsNone(run_python_operations[0].atomic) + + +@unittest.skipUnless( + connection.vendor == "postgresql", + "PostgreSQL-only regression for pending trigger events during migration 0010.", +) +class NewsMigration0010PostgreSQLTests(TransactionTestCase): + migrate_from = [("news", "0009_news_pin")] + migrate_to = [("news", "0010_news_audience_newscomment")] + + def setUp(self): + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self): + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + super().tearDown() + + def test_applies_after_updating_existing_news_rows(self): + ContentType = self.apps.get_model("contenttypes", "ContentType") + News = self.apps.get_model("news", "News") + project_content_type, _ = ContentType.objects.get_or_create( + app_label="projects", + model="project", + ) + program_content_type, _ = ContentType.objects.get_or_create( + app_label="partner_programs", + model="partnerprogram", + ) + + News.objects.create( + content_type=project_content_type, + object_id=1, + text="Project news", + ) + News.objects.create( + content_type=program_content_type, + object_id=1, + text="Program news", + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + migrated_apps = self.executor.loader.project_state(self.migrate_to).apps + MigratedNews = migrated_apps.get_model("news", "News") + + self.assertEqual( + MigratedNews.objects.get(content_type_id=project_content_type.id).audience, + "platform", + ) + self.assertEqual( + MigratedNews.objects.get(content_type_id=program_content_type.id).audience, + "program_participants", + ) diff --git a/news/tests/test_program_news_audience.py b/news/tests/test_program_news_audience.py new file mode 100644 index 00000000..3b888927 --- /dev/null +++ b/news/tests/test_program_news_audience.py @@ -0,0 +1,187 @@ +from importlib import import_module + +from django.apps import apps +from django.test import TestCase +from rest_framework.test import APIClient + +from news.models import News +from partner_programs.models import PartnerProgramUserProfile + +from .helpers import create_news_for, create_partner_program, create_user + + +class ProgramNewsAudienceAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="audience-manager") + self.participant = create_user(prefix="audience-participant") + self.outsider = create_user(prefix="audience-outsider") + self.admin = create_user(prefix="audience-admin") + self.admin.is_staff = True + self.admin.save(update_fields=["is_staff"]) + self.program = create_partner_program(manager=self.manager) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=self.participant, + partner_program_data={}, + ) + + def test_manager_can_create_platform_news(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Public program news", "audience": "platform"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["audience"], News.Audience.PLATFORM) + self.assertEqual( + News.objects.get(pk=response.data["id"]).audience, + News.Audience.PLATFORM, + ) + + def test_manager_can_create_participant_news(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + { + "text": "Internal program news", + "audience": "program_participants", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["audience"], News.Audience.PROGRAM_PARTICIPANTS) + + def test_omitted_audience_is_participant_only_for_angular_compatibility(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Legacy Angular program news"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + News.objects.get(pk=response.data["id"]).audience, + News.Audience.PROGRAM_PARTICIPANTS, + ) + + def test_manager_can_change_program_news_audience(self): + news = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + self.client.force_authenticate(self.manager) + + response = self.client.patch( + f"/programs/{self.program.pk}/news/{news.pk}/", + {"audience": "platform"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + news.refresh_from_db() + self.assertEqual(news.audience, News.Audience.PLATFORM) + + def test_outsider_cannot_create_or_change_program_news(self): + news = create_news_for(self.program) + self.client.force_authenticate(self.outsider) + + create_response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": "Forbidden"}, + format="json", + ) + update_response = self.client.patch( + f"/programs/{self.program.pk}/news/{news.pk}/", + {"audience": "program_participants"}, + format="json", + ) + + self.assertEqual(create_response.status_code, 403) + self.assertEqual(update_response.status_code, 403) + + def test_participant_manager_and_admin_see_internal_news(self): + news = create_news_for( + self.program, + text="Internal audience", + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + + for user in (self.participant, self.manager, self.admin): + with self.subTest(user=user.email): + self.client.force_authenticate(user) + list_response = self.client.get(f"/programs/{self.program.pk}/news/") + detail_response = self.client.get( + f"/programs/{self.program.pk}/news/{news.pk}/" + ) + self.assertEqual(list_response.status_code, 200) + self.assertIn( + news.pk, + [item["id"] for item in list_response.data["results"]], + ) + self.assertEqual(detail_response.status_code, 200) + + def test_outsider_cannot_discover_internal_news(self): + internal_news = create_news_for( + self.program, + audience=News.Audience.PROGRAM_PARTICIPANTS, + ) + platform_news = create_news_for( + self.program, + text="Public audience", + audience=News.Audience.PLATFORM, + ) + self.client.force_authenticate(self.outsider) + + list_response = self.client.get(f"/programs/{self.program.pk}/news/") + detail_response = self.client.get( + f"/programs/{self.program.pk}/news/{internal_news.pk}/" + ) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual( + [item["id"] for item in list_response.data["results"]], + [platform_news.pk], + ) + self.assertEqual(detail_response.status_code, 404) + + def test_closed_audience_is_rejected_for_user_news(self): + self.client.force_authenticate(self.outsider) + + response = self.client.post( + f"/auth/users/{self.outsider.pk}/news/", + {"text": "Invalid audience", "audience": "program_participants"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("audience", response.data) + + +class ProgramNewsAudienceDataMigrationTests(TestCase): + def test_data_migration_keeps_existing_program_news_internal(self): + program = create_partner_program(name="Migrated program") + user = create_user(prefix="migrated-user-news") + program_news = create_news_for(program, audience=News.Audience.PLATFORM) + user_news = create_news_for(user) + News.objects.filter(pk=user_news.pk).update( + audience=News.Audience.PROGRAM_PARTICIPANTS + ) + migration = import_module("news.migrations.0010_news_audience_newscomment") + + migration.set_existing_news_audiences(apps, None) + + program_news.refresh_from_db() + user_news.refresh_from_db() + self.assertEqual( + program_news.audience, + News.Audience.PROGRAM_PARTICIPANTS, + ) + self.assertEqual(user_news.audience, News.Audience.PLATFORM) diff --git a/news/views.py b/news/views.py index e7c5c4be..e6d70565 100644 --- a/news/views.py +++ b/news/views.py @@ -47,7 +47,7 @@ class ContextNewsAPIView: def get_queryset(self): - return get_news_queryset_for_context(self.kwargs) + return get_news_queryset_for_context(self.kwargs, user=self.request.user) def get_news_object(self): return get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"]) @@ -76,7 +76,10 @@ class NewsList(ContextNewsAPIView, generics.ListCreateAPIView): def post(self, request: Request, *args, **kwargs) -> Response: serializer = NewsCreateSerializer( data=request.data, - context={"request": request}, + context={ + "request": request, + "news_context": self.get_news_context(), + }, ) serializer.is_valid(raise_exception=True) data = serializer.validated_data @@ -85,14 +88,18 @@ def post(self, request: Request, *args, **kwargs) -> Response: project = get_object_or_404(Project, pk=kwargs["project_pk"]) news = create_project_news(project, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) if kwargs.get("user_pk"): user = get_object_or_404(User, pk=kwargs["user_pk"]) news = create_user_news(user, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) @@ -100,7 +107,9 @@ def post(self, request: Request, *args, **kwargs) -> Response: program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) news = create_program_news(program, request.user, data) return Response( - self.get_detail_response_serializer_class()(news).data, + self.get_detail_response_serializer_class()( + news, context={"user": request.user} + ).data, status=status.HTTP_201_CREATED, ) return Response(status=status.HTTP_400_BAD_REQUEST) @@ -133,7 +142,10 @@ def update(self, request: Request, *args, **kwargs) -> Response: serializer = NewsUpdateSerializer( news, data=request.data, - context={"request": request}, + context={ + "request": request, + "news_context": self.get_news_context(), + }, partial=kwargs.get("partial", False), ) serializer.is_valid(raise_exception=True) diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 00000000..b6483bf0 --- /dev/null +++ b/notifications/__init__.py @@ -0,0 +1 @@ +"""Внутренние уведомления PROCOLLAB.""" diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 00000000..fe3ab7fc --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + verbose_name = "Уведомления" diff --git a/notifications/events.py b/notifications/events.py new file mode 100644 index 00000000..fb761c87 --- /dev/null +++ b/notifications/events.py @@ -0,0 +1,309 @@ +from notifications.models import Notification +from notifications.services import create_notification, create_notifications + + +def _event_key(*parts) -> str: + """Собирает стабильный ключ события без пользовательских данных.""" + return ":".join(str(part) for part in parts) + + +def _user_name(user) -> str: + """Возвращает безопасное отображаемое имя без email и служебных полей.""" + name = user.get_full_name().strip() + return name or "Пользователь" + + +def notify_project_invite_created(invite) -> None: + """Уведомляет пользователя о новом приглашении в проект.""" + create_notification( + recipient_id=invite.user_id, + actor_id=invite.invited_by_id, + notification_type=Notification.Type.PROJECT_INVITE_CREATED, + title="Приглашение в проект", + message=f"Вас пригласили в проект «{invite.project.name}».", + action_url="/office/projects/invites", + event_key=_event_key("project-invite", invite.pk, "created"), + ) + + +def notify_project_invite_resolved(invite, *, actor, status: str) -> None: + """Уведомляет нужную сторону о принятии, отклонении или отзыве приглашения.""" + config = { + "accepted": ( + invite.project.leader_id, + Notification.Type.PROJECT_INVITE_ACCEPTED, + "Приглашение принято", + f"{_user_name(actor)} принял приглашение в проект «{invite.project.name}».", + f"/office/projects/{invite.project_id}/edit?section=team", + ), + "declined": ( + invite.project.leader_id, + Notification.Type.PROJECT_INVITE_DECLINED, + "Приглашение отклонено", + f"{_user_name(actor)} отклонил приглашение в проект «{invite.project.name}».", + f"/office/projects/{invite.project_id}/edit?section=team", + ), + "revoked": ( + invite.user_id, + Notification.Type.PROJECT_INVITE_REVOKED, + "Приглашение отозвано", + f"Приглашение в проект «{invite.project.name}» было отозвано.", + "/office/projects/invites", + ), + } + recipient_id, notification_type, title, message, action_url = config[status] + create_notification( + recipient_id=recipient_id, + actor_id=actor.pk, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + event_key=_event_key("project-invite", invite.pk, status), + ) + + +def notify_vacancy_response_created(response) -> None: + """Уведомляет руководителя проекта о новом отклике на вакансию.""" + vacancy = response.vacancy + create_notification( + recipient_id=vacancy.project.leader_id, + actor_id=response.user_id, + notification_type=Notification.Type.VACANCY_RESPONSE_CREATED, + title="Новый отклик на вакансию", + message=f"Получен отклик на вакансию «{vacancy.role}».", + action_url=( + f"/office/projects/{vacancy.project_id}/vacancies/" f"{vacancy.pk}/responses" + ), + event_key=_event_key("vacancy-response", response.pk, "created"), + ) + + +def notify_vacancy_response_resolved(response, *, actor, accepted: bool) -> None: + """Уведомляет кандидата о принятии либо отклонении его отклика.""" + notification_type = ( + Notification.Type.VACANCY_RESPONSE_ACCEPTED + if accepted + else Notification.Type.VACANCY_RESPONSE_DECLINED + ) + decision = "принят" if accepted else "отклонён" + create_notification( + recipient_id=response.user_id, + actor_id=actor.pk, + notification_type=notification_type, + title=f"Отклик {decision}", + message=f"Ваш отклик на вакансию «{response.vacancy.role}» {decision}.", + action_url="/office/vacancies/my", + event_key=_event_key( + "vacancy-response", + response.pk, + "accepted" if accepted else "declined", + ), + ) + + +def notify_team_invite_created(invite) -> None: + """Уведомляет пользователя о новом приглашении в команду заявки.""" + create_notification( + recipient_id=invite.user_id, + actor_id=invite.invited_by_id, + notification_type=Notification.Type.TEAM_INVITE_CREATED, + title="Приглашение в команду", + message=f"Вас пригласили в команду «{invite.team.name or 'Без названия'}».", + action_url="/office/team-invites", + event_key=_event_key("team-invite", invite.pk, "created"), + ) + + +def notify_team_invite_resolved(invite, *, actor, status: str) -> None: + """Уведомляет капитана либо приглашённого о завершении приглашения.""" + application_id = invite.team.application_id + config = { + "accepted": ( + invite.team.captain_id, + Notification.Type.TEAM_INVITE_ACCEPTED, + "Приглашение принято", + f"{_user_name(actor)} присоединился к команде.", + f"/office/applications/{application_id}/team", + ), + "declined": ( + invite.team.captain_id, + Notification.Type.TEAM_INVITE_DECLINED, + "Приглашение отклонено", + f"{_user_name(actor)} отклонил приглашение в команду.", + f"/office/applications/{application_id}/team", + ), + "revoked": ( + invite.user_id, + Notification.Type.TEAM_INVITE_REVOKED, + "Приглашение отозвано", + "Приглашение в команду было отозвано.", + "/office/team-invites", + ), + } + recipient_id, notification_type, title, message, action_url = config[status] + create_notification( + recipient_id=recipient_id, + actor_id=actor.pk, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + event_key=_event_key("team-invite", invite.pk, status), + ) + + +def notify_application_submitted(application, *, actor) -> None: + """Уведомляет всех менеджеров программы об отправленной заявке.""" + create_notifications( + recipient_ids=application.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + title="Новая заявка", + message=f"Отправлена заявка в программу «{application.program.name}».", + action_url=f"/office/program/{application.program_id}", + event_key=_event_key( + "application", + application.pk, + "submitted", + application.submitted_at.isoformat(), + ), + ) + + +def notify_application_status_changed(application, *, actor) -> None: + """Уведомляет владельца заявки о подтверждённом изменении статуса.""" + if application.user_id is None: + return + create_notification( + recipient_id=application.user_id, + actor_id=actor.pk, + notification_type=Notification.Type.APPLICATION_STATUS_CHANGED, + title="Статус заявки изменён", + message=f"Новый статус заявки: {application.get_status_display()}.", + action_url=f"/office/program/{application.program_id}", + event_key=_event_key( + "application", + application.pk, + "status", + application.status, + application.updated_at.isoformat(), + ), + ) + + +def notify_submission_submitted(submission, *, actor) -> None: + """Уведомляет менеджеров программы об отправленном решении.""" + create_notifications( + recipient_ids=submission.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.SUBMISSION_SUBMITTED, + title="Новое решение", + message=f"В программу «{submission.program.name}» отправлено решение.", + action_url=f"/office/program/{submission.program_id}", + event_key=_event_key( + "submission", + submission.pk, + "submitted", + submission.submitted_at.isoformat(), + ), + ) + + +def notify_submission_status_changed(submission, *, actor) -> None: + """Уведомляет владельца заявки и принятых участников её команды.""" + from partner_programs.models import TeamMember + + recipient_ids = [submission.application.user_id] + recipient_ids.extend( + TeamMember.objects.filter( + team__application_id=submission.application_id, + status=TeamMember.STATUS_ACCEPTED, + ).values_list("user_id", flat=True) + ) + create_notifications( + recipient_ids=recipient_ids, + actor_id=actor.pk, + notification_type=Notification.Type.SUBMISSION_STATUS_CHANGED, + title="Статус решения изменён", + message=f"Новый статус решения: {submission.get_status_display()}.", + action_url=f"/office/program/{submission.program_id}/submission", + event_key=_event_key( + "submission", + submission.pk, + "status", + submission.status, + submission.updated_at.isoformat(), + ), + ) + + +def notify_expert_assignment_created(assignment) -> None: + """Уведомляет эксперта о назначенной работе.""" + create_notification( + recipient_id=assignment.expert.user_id, + actor_id=assignment.assigned_by_id, + notification_type=Notification.Type.EXPERT_ASSIGNMENT_CREATED, + title="Назначена экспертиза", + message="Вам назначено решение для оценки.", + action_url="/office/expert/submissions", + event_key=_event_key("expert-assignment", assignment.pk, "created"), + ) + + +def notify_expert_assignment_revoked(assignment, *, actor) -> None: + """Уведомляет эксперта об отзыве назначения.""" + create_notification( + recipient_id=assignment.expert.user_id, + actor_id=actor.pk, + notification_type=Notification.Type.EXPERT_ASSIGNMENT_REVOKED, + title="Назначение отозвано", + message="Назначение на оценивание решения было отозвано.", + action_url="/office/expert/submissions", + event_key=_event_key("expert-assignment", assignment.pk, "revoked"), + ) + + +def notify_evaluation_submitted(evaluation, *, actor) -> None: + """Уведомляет менеджеров программы о финально отправленной оценке.""" + submission = evaluation.submission + create_notifications( + recipient_ids=submission.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.EVALUATION_SUBMITTED, + title="Оценка отправлена", + message=f"Эксперт отправил оценку решения «{submission.title}».", + action_url=f"/office/analytics?programId={submission.program_id}", + event_key=_event_key("evaluation", evaluation.pk, "submitted"), + ) + + +def notify_news_comment_created(comment) -> None: + """Уведомляет владельцев источника новости о новом комментарии.""" + news = comment.news + model = news.content_type.model + if model == "customuser": + recipient_ids = [news.object_id] + elif model == "project": + from projects.models import Project + + recipient_ids = Project.objects.filter(pk=news.object_id).values_list( + "leader_id", flat=True + ) + elif model == "partnerprogram": + from partner_programs.models import PartnerProgram + + recipient_ids = PartnerProgram.objects.filter(pk=news.object_id).values_list( + "managers__id", flat=True + ) + else: + return + create_notifications( + recipient_ids=recipient_ids, + actor_id=comment.author_id, + notification_type=Notification.Type.NEWS_COMMENT_CREATED, + title="Новый комментарий", + message="К вашей публикации добавлен комментарий.", + action_url=f"/office/news/{news.pk}", + event_key=_event_key("news-comment", comment.pk, "created"), + ) diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..1d9cb1d5 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2026-08-15 13:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('project_invite_created', 'Приглашение в проект'), ('project_invite_accepted', 'Приглашение в проект принято'), ('project_invite_declined', 'Приглашение в проект отклонено'), ('project_invite_revoked', 'Приглашение в проект отозвано'), ('vacancy_response_created', 'Новый отклик на вакансию'), ('vacancy_response_accepted', 'Отклик принят'), ('vacancy_response_declined', 'Отклик отклонён'), ('team_invite_created', 'Приглашение в команду'), ('team_invite_accepted', 'Приглашение в команду принято'), ('team_invite_declined', 'Приглашение в команду отклонено'), ('team_invite_revoked', 'Приглашение в команду отозвано'), ('application_submitted', 'Заявка отправлена'), ('application_status_changed', 'Статус заявки изменён'), ('submission_submitted', 'Решение отправлено'), ('submission_status_changed', 'Статус решения изменён'), ('expert_assignment_created', 'Назначена экспертиза'), ('expert_assignment_revoked', 'Экспертиза отозвана'), ('evaluation_submitted', 'Оценка отправлена'), ('news_comment_created', 'Новый комментарий')], max_length=64)), + ('category', models.CharField(choices=[('project', 'Проекты'), ('vacancy', 'Вакансии'), ('program', 'Программы'), ('expert', 'Экспертиза'), ('news', 'Новости'), ('system', 'Система')], max_length=16)), + ('title', models.CharField(max_length=160)), + ('message', models.TextField()), + ('action_url', models.CharField(blank=True, max_length=500, null=True)), + ('event_key', models.CharField(max_length=255)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acted_notifications', to=settings.AUTH_USER_MODEL)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['recipient', '-created_at'], name='notif_rec_created_idx'), models.Index(fields=['recipient', 'read_at', '-created_at'], name='notif_rec_read_created_idx')], + }, + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.UniqueConstraint(fields=('recipient', 'event_key'), name='uniq_notification_recipient_event'), + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 00000000..1be9ba7d --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db import models + + +class Notification(models.Model): + class Type(models.TextChoices): + PROJECT_INVITE_CREATED = "project_invite_created", "Приглашение в проект" + PROJECT_INVITE_ACCEPTED = ( + "project_invite_accepted", + "Приглашение в проект принято", + ) + PROJECT_INVITE_DECLINED = ( + "project_invite_declined", + "Приглашение в проект отклонено", + ) + PROJECT_INVITE_REVOKED = "project_invite_revoked", "Приглашение в проект отозвано" + VACANCY_RESPONSE_CREATED = "vacancy_response_created", "Новый отклик на вакансию" + VACANCY_RESPONSE_ACCEPTED = "vacancy_response_accepted", "Отклик принят" + VACANCY_RESPONSE_DECLINED = "vacancy_response_declined", "Отклик отклонён" + TEAM_INVITE_CREATED = "team_invite_created", "Приглашение в команду" + TEAM_INVITE_ACCEPTED = "team_invite_accepted", "Приглашение в команду принято" + TEAM_INVITE_DECLINED = "team_invite_declined", "Приглашение в команду отклонено" + TEAM_INVITE_REVOKED = "team_invite_revoked", "Приглашение в команду отозвано" + APPLICATION_SUBMITTED = "application_submitted", "Заявка отправлена" + APPLICATION_STATUS_CHANGED = "application_status_changed", "Статус заявки изменён" + SUBMISSION_SUBMITTED = "submission_submitted", "Решение отправлено" + SUBMISSION_STATUS_CHANGED = "submission_status_changed", "Статус решения изменён" + EXPERT_ASSIGNMENT_CREATED = "expert_assignment_created", "Назначена экспертиза" + EXPERT_ASSIGNMENT_REVOKED = "expert_assignment_revoked", "Экспертиза отозвана" + EVALUATION_SUBMITTED = "evaluation_submitted", "Оценка отправлена" + NEWS_COMMENT_CREATED = "news_comment_created", "Новый комментарий" + + class Category(models.TextChoices): + PROJECT = "project", "Проекты" + VACANCY = "vacancy", "Вакансии" + PROGRAM = "program", "Программы" + EXPERT = "expert", "Экспертиза" + NEWS = "news", "Новости" + SYSTEM = "system", "Система" + + TYPE_CATEGORY = { + Type.PROJECT_INVITE_CREATED: Category.PROJECT, + Type.PROJECT_INVITE_ACCEPTED: Category.PROJECT, + Type.PROJECT_INVITE_DECLINED: Category.PROJECT, + Type.PROJECT_INVITE_REVOKED: Category.PROJECT, + Type.VACANCY_RESPONSE_CREATED: Category.VACANCY, + Type.VACANCY_RESPONSE_ACCEPTED: Category.VACANCY, + Type.VACANCY_RESPONSE_DECLINED: Category.VACANCY, + Type.TEAM_INVITE_CREATED: Category.PROGRAM, + Type.TEAM_INVITE_ACCEPTED: Category.PROGRAM, + Type.TEAM_INVITE_DECLINED: Category.PROGRAM, + Type.TEAM_INVITE_REVOKED: Category.PROGRAM, + Type.APPLICATION_SUBMITTED: Category.PROGRAM, + Type.APPLICATION_STATUS_CHANGED: Category.PROGRAM, + Type.SUBMISSION_SUBMITTED: Category.PROGRAM, + Type.SUBMISSION_STATUS_CHANGED: Category.PROGRAM, + Type.EXPERT_ASSIGNMENT_CREATED: Category.EXPERT, + Type.EXPERT_ASSIGNMENT_REVOKED: Category.EXPERT, + Type.EVALUATION_SUBMITTED: Category.EXPERT, + Type.NEWS_COMMENT_CREATED: Category.NEWS, + } + + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="received_notifications", + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="acted_notifications", + null=True, + blank=True, + ) + type = models.CharField(max_length=64, choices=Type.choices) + category = models.CharField(max_length=16, choices=Category.choices) + title = models.CharField(max_length=160) + message = models.TextField() + action_url = models.CharField(max_length=500, null=True, blank=True) + event_key = models.CharField(max_length=255) + read_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["recipient", "event_key"], + name="uniq_notification_recipient_event", + ) + ] + indexes = [ + models.Index( + fields=["recipient", "-created_at"], + name="notif_rec_created_idx", + ), + models.Index( + fields=["recipient", "read_at", "-created_at"], + name="notif_rec_read_created_idx", + ), + ] + + def __str__(self): + return f"Notification<{self.pk}> recipient={self.recipient_id} type={self.type}" diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 00000000..76fe2b85 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from notifications.models import Notification +from users.models import CustomUser + + +class NotificationActorSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ["id", "first_name", "last_name", "avatar"] + read_only_fields = fields + + +class NotificationSerializer(serializers.ModelSerializer): + actor = NotificationActorSerializer(read_only=True) + + class Meta: + model = Notification + fields = [ + "id", + "type", + "category", + "title", + "message", + "action_url", + "read_at", + "created_at", + "actor", + ] + read_only_fields = fields + + +class NotificationListQuerySerializer(serializers.Serializer): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + unread = serializers.BooleanField(default=False) diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 00000000..eea9111d --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,113 @@ +from collections.abc import Iterable +from urllib.parse import urlsplit + +from django.db import transaction + +from notifications.models import Notification + + +def _validate_action_url(action_url: str | None) -> str | None: + """Разрешает только внутренние маршруты office, сформированные backend-кодом.""" + if action_url is None: + return None + parsed = urlsplit(action_url) + if ( + parsed.scheme + or parsed.netloc + or parsed.fragment + or not parsed.path.startswith("/office/") + or parsed.path.startswith("//") + or "\\" in action_url + ): + raise ValueError("action_url должен быть относительным маршрутом /office/.") + return action_url + + +def _notification_defaults( + *, + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, +) -> dict: + try: + category = Notification.TYPE_CATEGORY[notification_type] + except KeyError as exc: + raise ValueError("Неизвестный тип уведомления.") from exc + return { + "actor_id": actor_id, + "type": notification_type, + "category": category, + "title": title, + "message": message, + "action_url": _validate_action_url(action_url), + } + + +@transaction.atomic +def create_notification( + *, + recipient_id: int, + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, + event_key: str, +) -> Notification | None: + """Создаёт одно идемпотентное уведомление внутри транзакции события.""" + if actor_id is not None and actor_id == recipient_id: + return None + notification, _created = Notification.objects.get_or_create( + recipient_id=recipient_id, + event_key=event_key, + defaults=_notification_defaults( + actor_id=actor_id, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + ), + ) + return notification + + +@transaction.atomic +def create_notifications( + *, + recipient_ids: Iterable[int | None], + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, + event_key: str, +) -> list[Notification]: + """Создаёт уведомления нескольким уникальным получателям одним INSERT.""" + recipients = sorted( + { + recipient_id + for recipient_id in recipient_ids + if recipient_id is not None and recipient_id != actor_id + } + ) + if not recipients: + return [] + defaults = _notification_defaults( + actor_id=actor_id, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + ) + notifications = [ + Notification( + recipient_id=recipient_id, + event_key=event_key, + **defaults, + ) + for recipient_id in recipients + ] + # UniqueConstraint остаётся окончательной защитой от retry и гонок. + return Notification.objects.bulk_create(notifications, ignore_conflicts=True) diff --git a/notifications/tests/__init__.py b/notifications/tests/__init__.py new file mode 100644 index 00000000..ad533d8a --- /dev/null +++ b/notifications/tests/__init__.py @@ -0,0 +1 @@ +"""Тесты API и доменных событий уведомлений.""" diff --git a/notifications/tests/test_notification_events.py b/notifications/tests/test_notification_events.py new file mode 100644 index 00000000..fd48d2cb --- /dev/null +++ b/notifications/tests/test_notification_events.py @@ -0,0 +1,164 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from news.tests.helpers import create_partner_program, create_user +from notifications.events import ( + notify_application_status_changed, + notify_application_submitted, + notify_submission_status_changed, + notify_submission_submitted, +) +from notifications.models import Notification +from partner_programs.models import Application, Submission, Team, TeamMember + + +class NotificationEventTests(TestCase): + def test_application_transition_key_distinguishes_repeated_status_cycles(self): + owner = create_user(prefix="application-transition-owner") + manager = create_user(prefix="application-transition-manager") + program = create_partner_program(manager=manager) + first_submitted_at = timezone.now() + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + status=Application.STATUS_SUBMITTED, + submitted_at=first_submitted_at, + ) + + notify_application_submitted(application, actor=owner) + notify_application_submitted(application, actor=owner) + application.submitted_at = first_submitted_at + timedelta(seconds=1) + application.save(update_fields=["submitted_at", "updated_at"]) + notify_application_submitted(application, actor=owner) + + application.status = Application.STATUS_WITHDRAWN + application.save(update_fields=["status", "updated_at"]) + notify_application_status_changed(application, actor=manager) + notify_application_status_changed(application, actor=manager) + application.status = Application.STATUS_DRAFT + application.save(update_fields=["status", "updated_at"]) + application.status = Application.STATUS_WITHDRAWN + application.save(update_fields=["status", "updated_at"]) + notify_application_status_changed(application, actor=manager) + + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.APPLICATION_SUBMITTED + ).count(), + 2, + ) + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.APPLICATION_STATUS_CHANGED + ).count(), + 2, + ) + + def test_submission_transition_key_distinguishes_repeated_status_cycles(self): + owner = create_user(prefix="submission-transition-owner") + manager = create_user(prefix="submission-transition-manager") + program = create_partner_program(manager=manager) + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + status=Application.STATUS_SUBMITTED, + ) + first_submitted_at = timezone.now() + submission = Submission.objects.create( + application=application, + program=program, + submitted_by=owner, + title="Решение", + status=Submission.STATUS_SUBMITTED, + submitted_at=first_submitted_at, + ) + + notify_submission_submitted(submission, actor=owner) + notify_submission_submitted(submission, actor=owner) + submission.submitted_at = first_submitted_at + timedelta(seconds=1) + submission.save(update_fields=["submitted_at", "updated_at"]) + notify_submission_submitted(submission, actor=owner) + + submission.status = Submission.STATUS_RETURNED + submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=manager) + notify_submission_status_changed(submission, actor=manager) + submission.status = Submission.STATUS_SUBMITTED + submission.save(update_fields=["status", "updated_at"]) + submission.status = Submission.STATUS_RETURNED + submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=manager) + + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.SUBMISSION_SUBMITTED + ).count(), + 2, + ) + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.SUBMISSION_STATUS_CHANGED + ).count(), + 2, + ) + + def test_submission_status_notifies_owner_and_only_accepted_team_members(self): + owner = create_user(prefix="notification-owner") + accepted = create_user(prefix="notification-accepted") + invited = create_user(prefix="notification-invited") + removed = create_user(prefix="notification-removed") + actor = create_user(prefix="notification-manager") + program = create_partner_program(manager=actor) + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + status=Application.STATUS_SUBMITTED, + ) + team = Team.objects.create( + application=application, + name="Команда", + captain=owner, + ) + for user, role, status in ( + (owner, TeamMember.ROLE_CAPTAIN, TeamMember.STATUS_ACCEPTED), + (accepted, TeamMember.ROLE_MEMBER, TeamMember.STATUS_ACCEPTED), + (invited, TeamMember.ROLE_MEMBER, TeamMember.STATUS_INVITED), + (removed, TeamMember.ROLE_MEMBER, TeamMember.STATUS_REMOVED), + ): + TeamMember.objects.create( + team=team, + user=user, + role=role, + status=status, + invited_by=owner, + ) + submission = Submission.objects.create( + application=application, + program=program, + submitted_by=owner, + title="Решение", + status=Submission.STATUS_RETURNED, + ) + + notify_submission_status_changed(submission, actor=actor) + + notifications = Notification.objects.filter( + type=Notification.Type.SUBMISSION_STATUS_CHANGED + ) + self.assertEqual( + set(notifications.values_list("recipient_id", flat=True)), + {owner.pk, accepted.pk}, + ) + self.assertFalse(notifications.filter(recipient__in=[invited, removed]).exists()) + self.assertTrue( + all( + item.action_url == f"/office/program/{program.pk}/submission" + for item in notifications + ) + ) diff --git a/notifications/tests/test_notification_services.py b/notifications/tests/test_notification_services.py new file mode 100644 index 00000000..9033ade1 --- /dev/null +++ b/notifications/tests/test_notification_services.py @@ -0,0 +1,124 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from news.tests.helpers import create_user +from notifications.models import Notification +from notifications.services import create_notification, create_notifications + + +class NotificationServiceTests(TestCase): + def setUp(self): + self.recipient = create_user(prefix="service-recipient") + self.actor = create_user(prefix="service-actor") + + def create(self, **overrides): + params = { + "recipient_id": self.recipient.pk, + "actor_id": self.actor.pk, + "notification_type": Notification.Type.PROJECT_INVITE_CREATED, + "title": "Приглашение", + "message": "Вас пригласили в проект.", + "action_url": "/office/projects/invites", + "event_key": "project-invite:1:created", + } + params.update(overrides) + return create_notification(**params) + + def test_duplicate_event_is_idempotent(self): + first = self.create() + second = self.create(title="Повтор не должен изменить снимок") + + self.assertEqual(first.pk, second.pk) + self.assertEqual(Notification.objects.count(), 1) + self.assertEqual(second.title, "Приглашение") + + def test_self_notification_is_skipped(self): + result = self.create(actor_id=self.recipient.pk) + + self.assertIsNone(result) + self.assertFalse(Notification.objects.exists()) + + def test_bulk_service_deduplicates_recipients_and_skips_actor(self): + second = create_user(prefix="service-second") + + created = create_notifications( + recipient_ids=[ + self.recipient.pk, + self.recipient.pk, + self.actor.pk, + second.pk, + ], + actor_id=self.actor.pk, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + title="Заявка", + message="Отправлена новая заявка.", + action_url="/office/program/1", + event_key="application:1:submitted", + ) + + self.assertEqual(len(created), 2) + self.assertEqual( + set(Notification.objects.values_list("recipient_id", flat=True)), + {self.recipient.pk, second.pk}, + ) + + def test_only_internal_office_urls_are_allowed(self): + invalid_urls = ( + "https://example.com/office/news/1", + "//example.com/office/news/1", + "/projects/1", + "/office/news/1#fragment", + "/office/..\\admin", + ) + + for index, action_url in enumerate(invalid_urls): + with self.subTest(action_url=action_url), self.assertRaises(ValueError): + self.create(action_url=action_url, event_key="invalid:" + str(index)) + + self.assertFalse(Notification.objects.exists()) + + def test_unknown_type_is_rejected(self): + with self.assertRaises(ValueError): + self.create(notification_type="unknown") + + def test_notification_rolls_back_with_business_transaction(self): + with self.assertRaises(RuntimeError): + with transaction.atomic(): + self.create() + raise RuntimeError("rollback") + + self.assertFalse(Notification.objects.exists()) + + def test_database_constraint_guards_duplicate_recipient_event(self): + self.create() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Notification.objects.create( + recipient=self.recipient, + type=Notification.Type.NEWS_COMMENT_CREATED, + category=Notification.Category.NEWS, + title="Дубликат", + message="Дубликат", + event_key="project-invite:1:created", + ) + + def test_every_declared_type_has_category_and_can_be_created(self): + for index, (notification_type, _label) in enumerate(Notification.Type.choices): + self.create( + notification_type=notification_type, + event_key="type:" + str(index), + ) + + self.assertEqual(Notification.objects.count(), len(Notification.Type.choices)) + self.assertFalse(Notification.objects.filter(category="").exists()) + + def test_actor_deletion_keeps_notification_and_recipient_deletion_cascades(self): + notification = self.create() + self.actor.delete() + + notification.refresh_from_db() + self.assertIsNone(notification.actor_id) + + self.recipient.delete() + self.assertFalse(Notification.objects.exists()) diff --git a/notifications/tests/test_notifications_api.py b/notifications/tests/test_notifications_api.py new file mode 100644 index 00000000..7641d247 --- /dev/null +++ b/notifications/tests/test_notifications_api.py @@ -0,0 +1,147 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from news.tests.helpers import create_user +from notifications.models import Notification + + +def create_notification(*, recipient, actor=None, suffix="1", read=False): + return Notification.objects.create( + recipient=recipient, + actor=actor, + type=Notification.Type.PROJECT_INVITE_CREATED, + category=Notification.Category.PROJECT, + title=f"Уведомление {suffix}", + message="Безопасное сообщение", + action_url="/office/projects/invites", + event_key="test:" + suffix, + read_at=timezone.now() if read else None, + ) + + +class NotificationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="notification-recipient") + self.actor = create_user(prefix="notification-actor") + self.other = create_user(prefix="notification-other") + + def test_all_endpoints_require_authentication(self): + notification = create_notification(recipient=self.user) + + responses = ( + self.client.get("/notifications/"), + self.client.get("/notifications/unread-count/"), + self.client.post(f"/notifications/{notification.pk}/read/"), + self.client.post("/notifications/read-all/"), + ) + + self.assertTrue(all(response.status_code == 401 for response in responses)) + + def test_list_is_paginated_newest_first_and_scoped_to_recipient(self): + first = create_notification(recipient=self.user, actor=self.actor, suffix="1") + second = create_notification(recipient=self.user, actor=self.actor, suffix="2") + create_notification(recipient=self.other, suffix="other") + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/?limit=1&offset=0") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 2) + self.assertEqual(response.data["unread_count"], 2) + self.assertIsNotNone(response.data["next"]) + self.assertIsNone(response.data["previous"]) + self.assertEqual(response.data["results"][0]["id"], second.pk) + self.assertNotEqual(response.data["results"][0]["id"], first.pk) + + next_response = self.client.get("/notifications/?limit=1&offset=1") + self.assertEqual(next_response.data["results"][0]["id"], first.pk) + self.assertIsNone(next_response.data["next"]) + self.assertIsNotNone(next_response.data["previous"]) + + def test_unread_filter_and_count_use_all_user_notifications(self): + unread = create_notification(recipient=self.user, suffix="unread") + create_notification(recipient=self.user, suffix="read", read=True) + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/?unread=true") + count_response = self.client.get("/notifications/unread-count/") + + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response.data["results"][0]["id"], unread.pk) + self.assertEqual(count_response.data, {"unread_count": 1}) + + def test_read_is_idempotent_and_preserves_first_timestamp(self): + notification = create_notification(recipient=self.user) + self.client.force_authenticate(self.user) + + first = self.client.post(f"/notifications/{notification.pk}/read/") + first_read_at = first.data["read_at"] + second = self.client.post(f"/notifications/{notification.pk}/read/") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["read_at"], first_read_at) + + def test_foreign_notification_is_hidden_by_404(self): + notification = create_notification(recipient=self.other) + self.client.force_authenticate(self.user) + + response = self.client.post(f"/notifications/{notification.pk}/read/") + + self.assertEqual(response.status_code, 404) + notification.refresh_from_db() + self.assertIsNone(notification.read_at) + + def test_read_all_changes_only_current_users_unread_notifications(self): + own_unread = create_notification(recipient=self.user, suffix="own-unread") + own_read = create_notification(recipient=self.user, suffix="own-read", read=True) + other_unread = create_notification(recipient=self.other, suffix="other-unread") + self.client.force_authenticate(self.user) + + response = self.client.post("/notifications/read-all/") + + self.assertEqual(response.data, {"updated": 1, "unread_count": 0}) + own_unread.refresh_from_db() + own_read.refresh_from_db() + other_unread.refresh_from_db() + self.assertIsNotNone(own_unread.read_at) + self.assertIsNotNone(own_read.read_at) + self.assertIsNone(other_unread.read_at) + + def test_actor_contract_does_not_expose_private_fields(self): + self.actor.avatar = "https://cdn.example.com/avatar.png" + self.actor.save(update_fields=["avatar"]) + create_notification(recipient=self.user, actor=self.actor) + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/") + + actor = response.data["results"][0]["actor"] + self.assertEqual( + set(actor), + {"id", "first_name", "last_name", "avatar"}, + ) + serialized = str(response.data).lower() + for forbidden in ("email", "phone", "birthday", "is_staff", "password"): + self.assertNotIn(forbidden, serialized) + + def test_list_has_fixed_query_count_for_many_notifications(self): + for index in range(12): + create_notification( + recipient=self.user, + actor=self.actor, + suffix=f"query-{index}", + ) + self.client.force_authenticate(self.user) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/notifications/?limit=20") + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 12) + self.assertEqual(len(queries), 3) diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 00000000..2fb2b780 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from notifications.views import ( + NotificationListView, + NotificationReadAllView, + NotificationReadView, + NotificationUnreadCountView, +) + +app_name = "notifications" + +urlpatterns = [ + path("", NotificationListView.as_view(), name="list"), + path("unread-count/", NotificationUnreadCountView.as_view(), name="unread-count"), + path("read-all/", NotificationReadAllView.as_view(), name="read-all"), + path("/read/", NotificationReadView.as_view(), name="read"), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 00000000..827f811a --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,90 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from notifications.models import Notification +from notifications.serializers import ( + NotificationListQuerySerializer, + NotificationSerializer, +) + + +def _user_notifications(user): + return Notification.objects.filter(recipient=user).select_related("actor") + + +class NotificationListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + query_serializer = NotificationListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + queryset = _user_notifications(request.user) + unread_count = queryset.filter(read_at__isnull=True).count() + if query_serializer.validated_data["unread"]: + queryset = queryset.filter(read_at__isnull=True) + + paginator = LimitOffsetPagination() + paginator.default_limit = query_serializer.validated_data["limit"] + paginator.max_limit = 100 + page = paginator.paginate_queryset(queryset, request, view=self) + response = paginator.get_paginated_response( + NotificationSerializer(page, many=True).data + ) + response.data = { + "count": response.data["count"], + "unread_count": unread_count, + "next": response.data["next"], + "previous": response.data["previous"], + "results": response.data["results"], + } + return response + + +class NotificationUnreadCountView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + unread_count = Notification.objects.filter( + recipient=request.user, + read_at__isnull=True, + ).count() + return Response({"unread_count": unread_count}) + + +class NotificationReadView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request, notification_id): + notification = get_object_or_404( + # Блокируем только само уведомление: actor nullable, а PostgreSQL + # запрещает FOR UPDATE для nullable-стороны LEFT OUTER JOIN. + Notification.objects.select_for_update(), + pk=notification_id, + recipient=request.user, + ) + if notification.read_at is None: + notification.read_at = timezone.now() + notification.save(update_fields=["read_at"]) + return Response(NotificationSerializer(notification).data) + + +class NotificationReadAllView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request): + updated = Notification.objects.filter( + recipient=request.user, + read_at__isnull=True, + ).update(read_at=timezone.now()) + return Response( + {"updated": updated, "unread_count": 0}, + status=status.HTTP_200_OK, + ) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 17709716..b85d250b 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -1,3 +1,5 @@ +# Roadmap: DEV-073 + import re import tablib @@ -11,16 +13,430 @@ from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from partner_programs.models import ( + Application, + Evaluation, + EvaluationAmendment, + EvaluationScore, PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, PartnerProgramMaterial, PartnerProgramProject, PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + Team, + TeamInvite, + TeamMember, ) from partner_programs.services import prepare_project_scores_export_data +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "program", + "user", + "created_by", + "participation_mode", + "status", + "project", + "submitted_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "participation_mode", + "program", + "created_at", + ) + search_fields = ( + "user__email", + "created_by__email", + "program__name", + "program__tag", + "project__name", + ) + raw_id_fields = ( + "program", + "user", + "created_by", + "project", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ( + "id", + "application", + "get_program", + "name", + "captain", + "created_at", + "updated_at", + ) + list_filter = ( + "application__program", + "created_at", + ) + search_fields = ( + "name", + "captain__email", + "captain__first_name", + "captain__last_name", + "application__program__name", + "application__program__tag", + "=application__id", + ) + raw_id_fields = ( + "application", + "captain", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "application", + "application__program", + "captain", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="application__program") + def get_program(self, obj): + return obj.application.program + + +@admin.register(TeamMember) +class TeamMemberAdmin(admin.ModelAdmin): + list_display = ( + "id", + "team", + "get_program", + "user", + "role", + "status", + "invited_by", + "joined_at", + "created_at", + ) + list_filter = ( + "role", + "status", + "team__application__program", + "created_at", + ) + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "team__name", + "team__application__program__name", + "team__application__program__tag", + ) + raw_id_fields = ( + "team", + "user", + "invited_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "team", + "team__application", + "team__application__program", + "user", + "invited_by", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="team__application__program") + def get_program(self, obj): + return obj.team.application.program + + +@admin.register(TeamInvite) +class TeamInviteAdmin(admin.ModelAdmin): + list_display = ( + "id", + "team", + "get_program", + "user", + "invited_by", + "status", + "resolved_at", + "created_at", + ) + list_filter = ( + "status", + "team__application__program", + "created_at", + ) + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "invited_by__email", + "team__name", + "team__application__program__name", + "team__application__program__tag", + ) + raw_id_fields = ( + "team", + "user", + "invited_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "team", + "team__application", + "team__application__program", + "user", + "invited_by", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="team__application__program") + def get_program(self, obj): + return obj.team.application.program + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "application", + "program", + "submitted_by", + "title", + "status", + "stage_key", + "version", + "submitted_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "program", + "stage_key", + "created_at", + ) + search_fields = ( + "title", + "submitted_by__email", + "submitted_by__first_name", + "submitted_by__last_name", + "program__name", + "program__tag", + "=application__id", + ) + raw_id_fields = ( + "application", + "program", + "submitted_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + +@admin.register(SubmissionExpertAssignment) +class SubmissionExpertAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "submission", + "expert", + "status", + "assigned_by", + "assigned_at", + "completed_at", + "revoked_at", + ) + list_filter = ( + "status", + "submission__program", + "assigned_at", + "completed_at", + "revoked_at", + ) + search_fields = ( + "=submission__id", + "submission__title", + "submission__program__name", + "submission__program__tag", + "expert__user__email", + "expert__user__first_name", + "expert__user__last_name", + "assigned_by__email", + ) + raw_id_fields = ( + "submission", + "expert", + "assigned_by", + "revoked_by", + ) + readonly_fields = ( + "assigned_at", + "created_at", + "updated_at", + ) + list_select_related = ( + "submission", + "submission__program", + "expert", + "expert__user", + "assigned_by", + "revoked_by", + ) + date_hierarchy = "assigned_at" + + +@admin.register(Evaluation) +class EvaluationAdmin(admin.ModelAdmin): + list_display = ( + "submission", + "expert", + "status", + "submitted_at", + "amended_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "submission__program", + "submitted_at", + ) + search_fields = ( + "=submission__id", + "submission__title", + "submission__program__name", + "submission__program__tag", + "expert__user__email", + "expert__user__first_name", + "expert__user__last_name", + ) + raw_id_fields = ( + "submission", + "expert", + ) + readonly_fields = ( + "created_at", + "updated_at", + "amended_at", + ) + list_select_related = ( + "submission", + "submission__program", + "expert", + "expert__user", + ) + date_hierarchy = "created_at" + + +@admin.register(EvaluationScore) +class EvaluationScoreAdmin(admin.ModelAdmin): + list_display = ( + "evaluation", + "criterion", + "value", + "criterion_name", + ) + list_filter = ( + "criterion__partner_program", + "criterion_type", + "created_at", + ) + search_fields = ( + "=evaluation__id", + "=evaluation__submission__id", + "criterion__name", + "criterion_name", + "evaluation__expert__user__email", + ) + raw_id_fields = ( + "evaluation", + "criterion", + ) + readonly_fields = ( + "criterion_name", + "criterion_type", + "min_value", + "max_value", + "created_at", + "updated_at", + ) + list_select_related = ( + "evaluation", + "evaluation__submission", + "evaluation__expert", + "criterion", + ) + date_hierarchy = "created_at" + + +@admin.register(EvaluationAmendment) +class EvaluationAmendmentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "evaluation", + "changed_by", + "created_at", + ) + list_filter = ( + "evaluation__submission__program", + "created_at", + ) + search_fields = ( + "=evaluation__id", + "=evaluation__submission__id", + "changed_by__email", + ) + readonly_fields = ( + "evaluation", + "changed_by", + "previous_comment", + "comment", + "previous_scores", + "scores", + "previous_total_score", + "total_score", + "created_at", + ) + list_select_related = ( + "evaluation", + "evaluation__submission", + "changed_by", + ) + date_hierarchy = "created_at" + actions = None + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + class PartnerProgramMaterialInline(admin.StackedInline): model = PartnerProgramMaterial extra = 1 @@ -49,12 +465,20 @@ class Meta: inlines = [PartnerProgramMaterialInline, PartnerProgramFieldInline] form = PartnerProgramAdminForm - list_display = ("id", "name", "tag", "city", "datetime_created") + list_display = ( + "id", + "name", + "tag", + "city", + "participation_format", + "datetime_created", + ) list_display_links = ( "id", "name", "tag", "city", + "participation_format", "datetime_created", ) search_fields = ( @@ -62,7 +486,7 @@ class Meta: "city", "tag", ) - list_filter = ("city",) + list_filter = ("city", "participation_format") autocomplete_fields = ("managers",) date_hierarchy = "datetime_started" @@ -99,6 +523,16 @@ class Meta: ) }, ), + ( + "Участие и заявки", + { + "fields": ( + "participation_format", + ("team_min_size", "team_max_size"), + "datetime_application_ends", + ) + }, + ), ("Менеджеры программы", {"fields": ("managers",)}), ("Служебная информация", {"fields": ("datetime_created", "datetime_updated")}), ) diff --git a/partner_programs/application_urls.py b/partner_programs/application_urls.py new file mode 100644 index 00000000..b8df2f7c --- /dev/null +++ b/partner_programs/application_urls.py @@ -0,0 +1,69 @@ +from django.urls import path + +from partner_programs.applications_views import ( + ApplicationDetailView, + ApplicationSubmitView, + ApplicationWithdrawView, +) +from partner_programs.submission_views import ApplicationSubmissionListCreateView +from partner_programs.team_invite_views import ( + TeamInviteCandidateSearchView, + TeamInviteListCreateView, +) +from partner_programs.team_views import ( + TeamDetailView, + TeamLeaveView, + TeamMemberRemoveView, + TeamTransferCaptainView, +) + +app_name = "applications" + +urlpatterns = [ + path( + "/team/invite-candidates/", + TeamInviteCandidateSearchView.as_view(), + name="team-invite-candidate-search", + ), + path( + "/team/invites/", + TeamInviteListCreateView.as_view(), + name="team-invite-list-create", + ), + path( + "/team/members//remove/", + TeamMemberRemoveView.as_view(), + name="team-member-remove", + ), + path( + "/team/transfer-captain/", + TeamTransferCaptainView.as_view(), + name="team-transfer-captain", + ), + path( + "/team/leave/", + TeamLeaveView.as_view(), + name="team-leave", + ), + path( + "/team/", + TeamDetailView.as_view(), + name="team-detail", + ), + path( + "/submissions/", + ApplicationSubmissionListCreateView.as_view(), + name="submission-list-create", + ), + path("/", ApplicationDetailView.as_view(), name="detail"), + path( + "/submit/", + ApplicationSubmitView.as_view(), + name="submit", + ), + path( + "/withdraw/", + ApplicationWithdrawView.as_view(), + name="withdraw", + ), +] diff --git a/partner_programs/applications_views.py b/partner_programs/applications_views.py new file mode 100644 index 00000000..89f23c76 --- /dev/null +++ b/partner_programs/applications_views.py @@ -0,0 +1,293 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from notifications.events import notify_application_status_changed +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.models import Application, PartnerProgram, TeamMember +from partner_programs.permissions import can_edit_application +from partner_programs.serializers import ApplicationSerializer +from partner_programs.services.application_team import ( + ApplicationNotEditableError, + ApplicationTeamServiceError, + change_application_participation_mode, + create_or_get_application, + submit_application, +) + + +def _application_queryset_for(user): + queryset = Application.objects.select_related( + "program", + "user", + "created_by", + "project", + ) + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter( + Q(user=user) + | Q( + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() + + +def _owned_active_application(*, program, user): + return ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + ) + .filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ) + .order_by("-created_at") + .first() + ) + + +def _member_active_application(*, program, user): + return ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + "team", + "team__captain", + ) + .filter( + program=program, + status__in=Application.ACTIVE_STATUSES, + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + .order_by("-created_at") + .first() + ) + + +def _application_response(application, request, response_status=status.HTTP_200_OK): + serializer = ApplicationSerializer(application, context={"request": request}) + return Response(serializer.data, status=response_status) + + +def _raise_domain_validation_error(exc: ApplicationTeamServiceError): + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + +class ProgramApplicationCreateView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "application_create" + + def post(self, request, program_id): + program = get_object_or_404(PartnerProgram, pk=program_id) + serializer = ApplicationSerializer( + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + validated_data = dict(serializer.validated_data) + # Старый frontend не передает формат: до появления wizard такой запрос + # остается индивидуальным и сохраняет прежний create contract. + participation_mode = validated_data.pop( + "participation_mode", + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + team_name = validated_data.pop("team_name", None) + try: + result = create_or_get_application( + program=program, + user=request.user, + created_by=request.user, + participation_mode=participation_mode, + form_data=validated_data.pop("form_data", None), + project=validated_data.pop("project", None), + team_name=team_name, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + + return _application_response( + result.application, + request, + response_status=( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ), + ) + + +class MyProgramApplicationView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, program_id): + program = get_object_or_404(PartnerProgram, pk=program_id) + application = _owned_active_application(program=program, user=request.user) + if application is None: + application = _member_active_application( + program=program, + user=request.user, + ) + if application is None: + application = ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + "team", + "team__captain", + ) + .filter(program=program) + .filter( + Q(user=request.user) + | Q( + team__members__user=request.user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + ) + .exclude(status__in=Application.ACTIVE_STATUSES) + .order_by("-created_at") + .first() + ) + if application is None: + raise NotFound("Application not found.") + return _application_response(application, request) + + +class ApplicationDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, request, application_id): + return get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + + def get(self, request, application_id): + application = self.get_object(request, application_id) + return _application_response(application, request) + + def patch(self, request, application_id): + application = self.get_object(request, application_id) + if not can_edit_application(request.user, application): + raise PermissionDenied("Only the application owner or staff can update it.") + + serializer = ApplicationSerializer( + application, + data=request.data, + partial=True, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + participation_mode = serializer.validated_data.get("participation_mode") + team_name_supplied = "team_name" in serializer.validated_data + team_name = serializer.validated_data.get("team_name") + try: + with transaction.atomic(): + if participation_mode is not None or team_name_supplied: + application = change_application_participation_mode( + application=application, + actor=request.user, + participation_mode=( + participation_mode or application.participation_mode + ), + team_name=team_name if team_name_supplied else None, + ) + else: + application = Application.objects.select_for_update().get( + pk=application.pk + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can update it." + ) + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Изменить можно только черновик заявки." + ) + serializer.instance = application + serializer.save() + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ApplicationSubmitView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, application_id): + application = get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + if not can_edit_application(request.user, application): + raise PermissionDenied("Only the application owner or staff can submit it.") + try: + application = submit_application( + application=application, + actor=request.user, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _application_response(application, request) + + +class ApplicationWithdrawView(APIView): + permission_classes = [IsAuthenticated] + allowed_statuses = frozenset( + { + Application.STATUS_DRAFT, + Application.STATUS_SUBMITTED, + Application.STATUS_APPROVED, + } + ) + + def post(self, request, application_id): + with transaction.atomic(): + visible_application = get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + if not can_edit_application(request.user, visible_application): + raise PermissionDenied( + "Only the application owner or staff can withdraw it." + ) + application = Application.objects.select_for_update().get( + pk=visible_application.pk + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can withdraw it." + ) + if application.status == Application.STATUS_WITHDRAWN: + return _application_response(application, request) + if application.status not in self.allowed_statuses: + raise ValidationError({"status": "This application cannot be withdrawn."}) + + application.status = Application.STATUS_WITHDRAWN + application.withdrawn_at = timezone.now() + application.save(update_fields=["status", "withdrawn_at", "updated_at"]) + notify_application_status_changed(application, actor=request.user) + return _application_response(application, request) diff --git a/partner_programs/evaluation_urls.py b/partner_programs/evaluation_urls.py new file mode 100644 index 00000000..82a8c43a --- /dev/null +++ b/partner_programs/evaluation_urls.py @@ -0,0 +1,31 @@ +# Roadmap: DEV-073 + +from django.urls import path + +from partner_programs.evaluation_views import ( + EvaluationAmendmentListView, + EvaluationAmendView, + EvaluationDetailView, + EvaluationSubmitView, +) + +app_name = "evaluations" + +urlpatterns = [ + path("/", EvaluationDetailView.as_view(), name="detail"), + path( + "/submit/", + EvaluationSubmitView.as_view(), + name="submit", + ), + path( + "/amend/", + EvaluationAmendView.as_view(), + name="amend", + ), + path( + "/amendments/", + EvaluationAmendmentListView.as_view(), + name="amendment-list", + ), +] diff --git a/partner_programs/evaluation_views.py b/partner_programs/evaluation_views.py new file mode 100644 index 00000000..a25bd155 --- /dev/null +++ b/partner_programs/evaluation_views.py @@ -0,0 +1,471 @@ +# Roadmap: DEV-050, DEV-051, DEV-052, DEV-073 +# Контур экспертного доступа к Submission и управления Evaluation. + +from drf_yasg import openapi +from drf_yasg.utils import no_body, swagger_auto_schema +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.pagination import PartnerProgramPagination +from partner_programs.permissions import IsAdminOrManagerOfProgram +from partner_programs.serializers.evaluations import ( + EvaluationAmendmentSerializer, + EvaluationAmendSerializer, + EvaluationDraftCreateSerializer, + EvaluationDraftUpdateSerializer, + EvaluationReadSerializer, + ExpertSubmissionDetailSerializer, + ExpertSubmissionFilterSerializer, + ExpertSubmissionListSerializer, + ManagerEvaluationFilterSerializer, + ManagerEvaluationSerializer, +) +from partner_programs.services.evaluations import ( + EvaluationAccessDeniedError, + EvaluationConflictError, + EvaluationNotFoundError, + EvaluationServiceError, + EvaluationValidationError, + amend_submitted_evaluation, + create_or_get_draft_evaluation, + expert_submission_assignments, + get_evaluation_amendments, + get_expert_submission_detail, + get_my_evaluation, + get_visible_evaluation, + manager_evaluations_queryset, + submit_evaluation, + update_draft_evaluation, +) +from partner_programs.submission_assignment_views import ProgramPermissionMixin +from partner_programs.throttling import PatchOnlyScopedRateThrottle + + +PROGRAM_ID_PARAMETER = openapi.Parameter( + "program_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору программы.", + type=openapi.TYPE_INTEGER, +) +SUBMISSION_STATUS_PARAMETER = openapi.Parameter( + "submission_status", + openapi.IN_QUERY, + description="Фильтр по статусу Submission.", + type=openapi.TYPE_STRING, +) +EVALUATION_STATUS_PARAMETER = openapi.Parameter( + "evaluation_status", + openapi.IN_QUERY, + description="Фильтр по статусу Evaluation: draft, submitted или none.", + type=openapi.TYPE_STRING, +) +SUBMISSION_ID_PARAMETER = openapi.Parameter( + "submission_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору Submission.", + type=openapi.TYPE_INTEGER, +) +EXPERT_ID_PARAMETER = openapi.Parameter( + "expert_id", + openapi.IN_QUERY, + description="Фильтр по идентификатору Expert.", + type=openapi.TYPE_INTEGER, +) +ASSIGNMENT_STATUS_PARAMETER = openapi.Parameter( + "assignment_status", + openapi.IN_QUERY, + description="Фильтр по статусу назначения.", + type=openapi.TYPE_STRING, +) +STAGE_KEY_PARAMETER = openapi.Parameter( + "stage_key", + openapi.IN_QUERY, + description="Фильтр по этапу Submission.", + type=openapi.TYPE_STRING, +) + + +def _domain_error_response(exc): + if isinstance(exc, EvaluationAccessDeniedError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + if isinstance(exc, EvaluationNotFoundError): + raise NotFound(exc.detail, code=exc.code) from exc + if isinstance(exc, EvaluationValidationError): + return Response( + {exc.field or "detail": [exc.detail]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, EvaluationConflictError): + return Response( + {"detail": exc.detail, "code": exc.code}, + status=status.HTTP_409_CONFLICT, + ) + raise exc + + +class ExpertSubmissionListView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = PartnerProgramPagination + + @swagger_auto_schema( + operation_description=( + "PII-safe список Submission, назначенных текущему эксперту. " + "Роль Expert без назначения не даёт доступ к решению." + ), + manual_parameters=[ + PROGRAM_ID_PARAMETER, + SUBMISSION_STATUS_PARAMETER, + EVALUATION_STATUS_PARAMETER, + ], + responses={ + 200: ExpertSubmissionListSerializer(many=True), + 400: "Некорректный фильтр.", + 401: "Требуется авторизация.", + 403: "У пользователя нет профиля Expert.", + }, + ) + def get(self, request): + filter_serializer = ExpertSubmissionFilterSerializer(data=request.query_params) + filter_serializer.is_valid(raise_exception=True) + try: + queryset = expert_submission_assignments(user=request.user) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + + filters = filter_serializer.validated_data + if "program_id" in filters: + queryset = queryset.filter(submission__program_id=filters["program_id"]) + if "submission_status" in filters: + queryset = queryset.filter(submission__status=filters["submission_status"]) + if "evaluation_status" in filters: + evaluation_status = filters["evaluation_status"] + if evaluation_status == "none": + queryset = queryset.filter(my_evaluation_id__isnull=True) + else: + queryset = queryset.filter(my_evaluation_status=evaluation_status) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response( + ExpertSubmissionListSerializer(page, many=True).data + ) + + +class ExpertSubmissionDetailView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=( + "PII-safe detail назначенной Submission. form_data участника " + "намеренно не включается; возвращаются description и отдельные links." + ), + responses={ + 200: ExpertSubmissionDetailSerializer, + 401: "Требуется авторизация.", + 404: "Submission не назначена текущему эксперту.", + }, + ) + def get(self, request, submission_id): + try: + result = get_expert_submission_detail( + submission_id=submission_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(ExpertSubmissionDetailSerializer(result).data) + + +class MyEvaluationView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=( + "Возвращает существующую Evaluation текущего назначенного эксперта. " + "GET никогда не создаёт черновик." + ), + responses={ + 200: EvaluationReadSerializer, + 401: "Требуется авторизация.", + 404: "Evaluation не существует или Submission не назначена.", + }, + ) + def get(self, request, submission_id): + try: + evaluation = get_my_evaluation( + submission_id=submission_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationCreateView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "evaluation_create" + + @swagger_auto_schema( + operation_description=( + "Создаёт draft Evaluation для assigned-назначения. Первый запрос " + "возвращает 201; повторный POST возвращает тот же draft с 200 и " + "не изменяет его. Для submitted Evaluation возвращается 409." + ), + request_body=EvaluationDraftCreateSerializer, + responses={ + 200: EvaluationReadSerializer, + 201: EvaluationReadSerializer, + 400: "Некорректные Criteria или значения.", + 401: "Требуется авторизация.", + 404: "Submission не назначена.", + 409: "Конфликт статуса назначения, Submission или Evaluation.", + 429: "Превышен evaluation_create throttle.", + }, + ) + def post(self, request, submission_id): + serializer = EvaluationDraftCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = create_or_get_draft_evaluation( + submission_id=submission_id, + user=request.user, + comment=serializer.validated_data["comment"], + scores=serializer.validated_data["scores"], + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + EvaluationReadSerializer(result.evaluation).data, + status=response_status, + ) + + +class EvaluationDetailView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PatchOnlyScopedRateThrottle] + throttle_scope = "evaluation_update" + + @swagger_auto_schema( + operation_description=( + "Read-only detail для владельца-эксперта, manager программы и staff." + ), + responses={ + 200: EvaluationReadSerializer, + 401: "Требуется авторизация.", + 404: "Evaluation скрыта или не существует.", + }, + ) + def get(self, request, evaluation_id): + try: + evaluation = get_visible_evaluation( + evaluation_id=evaluation_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + @swagger_auto_schema( + operation_description=( + "Autosave draft Evaluation владельцем. Если scores передан, набор " + "заменяется целиком в одной транзакции. Submitted Evaluation " + "неизменяема." + ), + request_body=EvaluationDraftUpdateSerializer, + responses={ + 200: EvaluationReadSerializer, + 400: "Некорректные Criteria или значения.", + 401: "Требуется авторизация.", + 404: "Чужая или скрытая Evaluation.", + 409: "Evaluation или assignment недоступны для изменения.", + 429: "Превышен evaluation_update throttle.", + }, + ) + def patch(self, request, evaluation_id): + serializer = EvaluationDraftUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + evaluation = update_draft_evaluation( + evaluation_id=evaluation_id, + user=request.user, + comment_supplied="comment" in serializer.validated_data, + comment=serializer.validated_data.get("comment", ""), + scores_supplied="scores" in serializer.validated_data, + scores=serializer.validated_data.get("scores"), + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationSubmitView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "evaluation_submit" + + @swagger_auto_schema( + operation_description=( + "Атомарно отправляет полную Evaluation и завершает assignment. " + "Повторный submit идемпотентен и не меняет submitted_at." + ), + request_body=no_body, + responses={ + 200: EvaluationReadSerializer, + 400: "Заполнены не все Criteria или значения невалидны.", + 401: "Требуется авторизация.", + 404: "Чужая или скрытая Evaluation.", + 409: "Assignment или Submission недоступны.", + 429: "Превышен evaluation_submit throttle.", + }, + ) + def post(self, request, evaluation_id): + try: + evaluation = submit_evaluation( + evaluation_id=evaluation_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationAmendView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PatchOnlyScopedRateThrottle] + throttle_scope = "evaluation_amend" + + @swagger_auto_schema( + operation_description=( + "Изменяет comment и/или полный набор scores уже отправленной Evaluation. " + "Операция доступна только эксперту-владельцу и сохраняет submitted_at." + ), + request_body=EvaluationAmendSerializer, + responses={ + 200: EvaluationReadSerializer, + 400: "Передан неполный или некорректный набор Criteria.", + 401: "Требуется авторизация.", + 404: "Evaluation скрыта или не существует.", + 409: "Статус Evaluation, Submission или assignment не допускает изменение.", + 429: "Превышен evaluation_amend throttle.", + }, + ) + def patch(self, request, evaluation_id): + serializer = EvaluationAmendSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + evaluation = amend_submitted_evaluation( + evaluation_id=evaluation_id, + user=request.user, + comment_supplied="comment" in serializer.validated_data, + comment=serializer.validated_data.get("comment", ""), + scores_supplied="scores" in serializer.validated_data, + scores=serializer.validated_data.get("scores"), + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationReadSerializer(evaluation).data) + + +class EvaluationAmendmentListView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=( + "Возвращает неизменяемую историю правок Evaluation владельцу, " + "manager программы или staff." + ), + responses={ + 200: EvaluationAmendmentSerializer(many=True), + 401: "Требуется авторизация.", + 404: "Evaluation скрыта или не существует.", + }, + ) + def get(self, request, evaluation_id): + try: + amendments = get_evaluation_amendments( + evaluation_id=evaluation_id, + user=request.user, + ) + except EvaluationServiceError as exc: + return _domain_error_response(exc) + return Response(EvaluationAmendmentSerializer(amendments, many=True).data) + + +class ProgramEvaluationListView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + pagination_class = PartnerProgramPagination + + @swagger_auto_schema( + operation_description=( + "Read-only список Evaluation указанной программы для manager и staff." + ), + manual_parameters=[ + SUBMISSION_ID_PARAMETER, + EXPERT_ID_PARAMETER, + EVALUATION_STATUS_PARAMETER, + ASSIGNMENT_STATUS_PARAMETER, + STAGE_KEY_PARAMETER, + ], + responses={ + 200: ManagerEvaluationSerializer(many=True), + 400: "Некорректный фильтр.", + 401: "Требуется авторизация.", + 403: "Нет прав manager этой программы.", + 404: "Программа не найдена.", + }, + ) + def get(self, request, program_id): + serializer = ManagerEvaluationFilterSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + queryset = manager_evaluations_queryset(program=self.program) + filters = serializer.validated_data + if "submission_id" in filters: + queryset = queryset.filter(submission_id=filters["submission_id"]) + if "expert_id" in filters: + queryset = queryset.filter(expert_id=filters["expert_id"]) + if "evaluation_status" in filters: + queryset = queryset.filter(status=filters["evaluation_status"]) + if "assignment_status" in filters: + queryset = queryset.filter(assignment_status=filters["assignment_status"]) + if "stage_key" in filters: + queryset = queryset.filter(submission__stage_key=filters["stage_key"]) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response( + ManagerEvaluationSerializer(page, many=True).data + ) + + +class ProgramEvaluationDetailView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + + @swagger_auto_schema( + operation_description=( + "Read-only Evaluation detail для manager указанной программы и staff." + ), + responses={ + 200: ManagerEvaluationSerializer, + 401: "Требуется авторизация.", + 403: "Нет прав manager этой программы.", + 404: "Программа или Evaluation не найдена.", + }, + ) + def get(self, request, program_id, evaluation_id): + evaluation = get_object_or_404( + manager_evaluations_queryset(program=self.program), + pk=evaluation_id, + ) + return Response(ManagerEvaluationSerializer(evaluation).data) + + amend_submitted_evaluation, + get_evaluation_amendments, diff --git a/partner_programs/expert_urls.py b/partner_programs/expert_urls.py new file mode 100644 index 00000000..639c043e --- /dev/null +++ b/partner_programs/expert_urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from partner_programs.evaluation_views import ( + ExpertSubmissionDetailView, + ExpertSubmissionListView, +) + +app_name = "expert_submissions" + +urlpatterns = [ + path("submissions/", ExpertSubmissionListView.as_view(), name="list"), + path( + "submissions//", + ExpertSubmissionDetailView.as_view(), + name="detail", + ), +] 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..3bf1b397 --- /dev/null +++ b/partner_programs/management/commands/seed_react_dev_demo.py @@ -0,0 +1,94 @@ +# Roadmap: DEV-072, DEV-083 +# Безопасная точка запуска демонстрационного набора только для 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": "баллы", + "projects": "проекты", + "public_news": "публичные новости", + "internal_news": "внутренние новости", + "likes": "лайки", + "views": "просмотры", + "comments": "комментарии", + "internal_news_id": "ID внутренней новости", + } + 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/manager_overview_views.py b/partner_programs/manager_overview_views.py new file mode 100644 index 00000000..c241db8e --- /dev/null +++ b/partner_programs/manager_overview_views.py @@ -0,0 +1,55 @@ +# Roadmap: DEV-076, DEV-056 + +from drf_yasg.utils import swagger_auto_schema +from rest_framework.generics import ListAPIView +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.permissions import IsAdminOrManagerOfProgram +from partner_programs.serializers.manager_overview import ( + ManagedProgramSerializer, + ManagerProgramOverviewSerializer, +) +from partner_programs.services.manager_overview import ( + build_manager_program_overview, +) +from partner_programs.submission_assignment_views import ProgramPermissionMixin + + +class ManagerProgramOverviewView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + + @swagger_auto_schema( + operation_description=( + "Обезличенная read-only сводка этапов участия в программе для manager " + "этой программы и staff." + ), + responses={ + 200: ManagerProgramOverviewSerializer, + 401: "Требуется авторизация.", + 403: "Нет прав manager этой программы.", + 404: "Программа не найдена.", + }, + ) + def get(self, request, program_id): + overview = build_manager_program_overview(self.program) + serializer = ManagerProgramOverviewSerializer(data=overview) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +class ManagedProgramListView(ListAPIView): + """Возвращает программы, для которых пользователь имеет права организатора.""" + + permission_classes = [IsAuthenticated] + serializer_class = ManagedProgramSerializer + pagination_class = None + + def get_queryset(self): + queryset = PartnerProgram.objects.only("id", "name", "draft") + user = self.request.user + if not (user.is_staff or user.is_superuser): + queryset = queryset.filter(managers=user) + return queryset.order_by("name", "id") diff --git a/partner_programs/migrations/0018_application.py b/partner_programs/migrations/0018_application.py new file mode 100644 index 00000000..e3f6bee8 --- /dev/null +++ b/partner_programs/migrations/0018_application.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.24 on 2026-07-20 + +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", "0017_alter_partnerprogramproject_options"), + ("projects", "0033_delete_projectnews"), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitted", "Submitted"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ("withdrawn", "Withdrawn"), + ("cancelled", "Cancelled"), + ], + default="draft", + max_length=16, + ), + ), + ("form_data", models.JSONField(blank=True, default=dict)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("approved_at", models.DateTimeField(blank=True, null=True)), + ("rejected_at", models.DateTimeField(blank=True, null=True)), + ("withdrawn_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_program_applications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="applications", + to="partner_programs.partnerprogram", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="applications", + to="projects.project", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_applications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Application", + "verbose_name_plural": "Applications", + }, + ), + migrations.AddConstraint( + model_name="application", + constraint=models.UniqueConstraint( + condition=models.Q( + ("status__in", ("draft", "submitted", "approved")), + ("user__isnull", False), + ), + fields=("user", "program"), + name="uniq_active_application_user_program", + ), + ), + ] diff --git a/partner_programs/migrations/0019_submission.py b/partner_programs/migrations/0019_submission.py new file mode 100644 index 00000000..84af4b43 --- /dev/null +++ b/partner_programs/migrations/0019_submission.py @@ -0,0 +1,94 @@ +# Generated by Django 4.2.11 on 2026-07-20 13:30 + +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", "0018_application"), + ] + + operations = [ + migrations.CreateModel( + name="Submission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("form_data", models.JSONField(blank=True, default=dict)), + ("links", models.JSONField(blank=True, default=list)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitted", "Submitted"), + ("returned", "Returned"), + ("final", "Final"), + ("cancelled", "Cancelled"), + ], + default="draft", + max_length=16, + ), + ), + ("version", models.PositiveIntegerField(default=1)), + ("stage_key", models.CharField(default="main", max_length=128)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="partner_programs.application", + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="partner_programs.partnerprogram", + ), + ), + ( + "submitted_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="program_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Submission", + "verbose_name_plural": "Submissions", + }, + ), + migrations.AddConstraint( + model_name="submission", + constraint=models.UniqueConstraint( + fields=("application", "stage_key", "version"), + name="uniq_submission_application_stage_version", + ), + ), + migrations.AddConstraint( + model_name="submission", + constraint=models.CheckConstraint( + check=models.Q(("version__gte", 1)), name="submission_version_gte_1" + ), + ), + ] diff --git a/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py b/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py new file mode 100644 index 00000000..fcca6f89 --- /dev/null +++ b/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py @@ -0,0 +1,162 @@ +# Generated by Django 4.2.11 on 2026-07-21 12:16 + +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", "0019_submission"), + ] + + operations = [ + migrations.CreateModel( + name="Team", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Команда заявки", + "verbose_name_plural": "Команды заявок", + }, + ), + migrations.AddField( + model_name="application", + name="participation_mode", + field=models.CharField( + choices=[ + ("undecided", "Не определен"), + ("individual", "Индивидуально"), + ("team", "В команде"), + ], + default="individual", + max_length=16, + ), + ), + migrations.CreateModel( + name="TeamMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("captain", "Капитан"), ("member", "Участник")], + default="member", + max_length=16, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("invited", "Приглашен"), + ("accepted", "Принят"), + ("declined", "Отклонил приглашение"), + ("removed", "Исключен"), + ("left", "Покинул команду"), + ], + default="invited", + max_length=16, + ), + ), + ("joined_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "invited_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invited_application_team_members", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="partner_programs.team", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="application_team_memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Участник команды заявки", + "verbose_name_plural": "Участники команд заявок", + }, + ), + migrations.AddField( + model_name="team", + name="application", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="team", + to="partner_programs.application", + ), + ), + migrations.AddField( + model_name="team", + name="captain", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="captained_application_teams", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.UniqueConstraint( + fields=("team", "user"), name="uniq_team_member_user" + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.UniqueConstraint( + condition=models.Q(("role", "captain"), ("status", "accepted")), + fields=("team",), + name="uniq_accepted_captain_team", + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("role", "captain"), _negated=True), + ("status", "accepted"), + _connector="OR", + ), + name="captain_team_member_is_accepted", + ), + ), + ] diff --git a/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py b/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py new file mode 100644 index 00000000..1db1b5d3 --- /dev/null +++ b/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.11 on 2026-07-21 12:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "partner_programs", + "0020_team_application_participation_mode_teammember_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="datetime_application_ends", + field=models.DateTimeField( + blank=True, + help_text="Отдельный дедлайн Application; другие дедлайны не используются как fallback", + null=True, + verbose_name="Дата окончания подачи заявок", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="participation_format", + field=models.CharField( + choices=[ + ("individual_only", "Только индивидуально"), + ("team_only", "Только в команде"), + ("individual_or_team", "Индивидуально или в команде"), + ], + default="individual_only", + max_length=24, + verbose_name="Разрешенный формат участия", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="team_max_size", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Количество принятых участников с учетом капитана", + null=True, + verbose_name="Максимальный размер команды", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="team_min_size", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Количество принятых участников с учетом капитана", + null=True, + verbose_name="Минимальный размер команды", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_min_size__isnull", True), + ("team_min_size__gte", 2), + _connector="OR", + ), + name="program_team_min_gte_2_or_null", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_max_size__isnull", True), + ("team_max_size__gte", 2), + _connector="OR", + ), + name="program_team_max_gte_2_or_null", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_min_size__isnull", True), + ("team_max_size__isnull", True), + ("team_max_size__gte", models.F("team_min_size")), + _connector="OR", + ), + name="program_team_max_gte_min", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("participation_format", "individual_only"), + ("team_max_size__isnull", True), + ("team_min_size__isnull", True), + ), + models.Q( + ("participation_format__in", ("team_only", "individual_or_team")), + ("team_max_size__isnull", False), + ("team_min_size__isnull", False), + ), + _connector="OR", + ), + name="program_team_sizes_match_format", + ), + ), + ] diff --git a/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py b/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py new file mode 100644 index 00000000..4bb2d029 --- /dev/null +++ b/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.11 on 2026-07-22 12:29 + +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', '0021_partnerprogram_datetime_application_ends_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TeamInvite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Ожидает ответа'), ('accepted', 'Принято'), ('declined', 'Отклонено'), ('revoked', 'Отозвано')], default='pending', max_length=16, verbose_name='Статус')), + ('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата ответа')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_application_team_invites', to=settings.AUTH_USER_MODEL, verbose_name='Кем приглашен')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='partner_programs.team', verbose_name='Команда')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='application_team_invites', to=settings.AUTH_USER_MODEL, verbose_name='Приглашенный пользователь')), + ], + options={ + 'verbose_name': 'Приглашение в команду заявки', + 'verbose_name_plural': 'Приглашения в команды заявок', + 'indexes': [models.Index(fields=['user', 'status', 'created_at'], name='team_invite_user_status_idx'), models.Index(fields=['team', 'status', 'created_at'], name='team_invite_team_status_idx')], + }, + ), + migrations.AddConstraint( + model_name='teaminvite', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('team', 'user'), name='uniq_pending_invite_team_user'), + ), + ] diff --git a/partner_programs/migrations/0023_submission_evaluation_models.py b/partner_programs/migrations/0023_submission_evaluation_models.py new file mode 100644 index 00000000..cb20c391 --- /dev/null +++ b/partner_programs/migrations/0023_submission_evaluation_models.py @@ -0,0 +1,294 @@ +# Generated by Django 4.2.11 on 2026-07-25 + +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_teaminvite_teaminvite_uniq_pending_invite_team_user"), + ("project_rates", "0003_projectexpertassignment"), + ("users", "0060_alter_userachievement_year"), + ] + + operations = [ + migrations.CreateModel( + name="Evaluation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Черновик"), + ("submitted", "Отправлено"), + ], + default="draft", + max_length=16, + ), + ), + ("comment", models.TextField(blank=True)), + ( + "total_score", + models.DecimalField( + blank=True, + decimal_places=6, + max_digits=18, + null=True, + ), + ), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "expert", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="submission_evaluations", + to="users.expert", + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="evaluations", + to="partner_programs.submission", + ), + ), + ], + options={ + "verbose_name": "Оценка Submission", + "verbose_name_plural": "Оценки Submission", + }, + ), + migrations.CreateModel( + name="SubmissionExpertAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("assigned", "Назначено"), + ("completed", "Завершено"), + ("revoked", "Отозвано"), + ], + default="assigned", + max_length=16, + ), + ), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("revoke_reason", models.TextField(blank=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "assigned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="assigned_submission_experts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "expert", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="submission_assignments", + to="users.expert", + ), + ), + ( + "revoked_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="revoked_submission_experts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="expert_assignments", + to="partner_programs.submission", + ), + ), + ], + options={ + "verbose_name": "Назначение эксперта на Submission", + "verbose_name_plural": "Назначения экспертов на Submission", + }, + ), + migrations.CreateModel( + name="EvaluationScore", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.DecimalField(decimal_places=6, max_digits=18)), + ( + "criterion_name", + models.CharField(editable=False, max_length=50), + ), + ( + "criterion_type", + models.CharField(editable=False, max_length=8), + ), + ( + "min_value", + models.FloatField(blank=True, editable=False, null=True), + ), + ( + "max_value", + models.FloatField(blank=True, editable=False, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "criterion", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="evaluation_scores", + to="project_rates.criteria", + ), + ), + ( + "evaluation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scores", + to="partner_programs.evaluation", + ), + ), + ], + options={ + "verbose_name": "Оценка по критерию", + "verbose_name_plural": "Оценки по критериям", + }, + ), + migrations.AddIndex( + model_name="evaluation", + index=models.Index( + fields=["submission", "status"], + name="eval_submission_status_idx", + ), + ), + migrations.AddIndex( + model_name="evaluation", + index=models.Index( + fields=["expert", "status"], + name="evaluation_expert_status_idx", + ), + ), + migrations.AddConstraint( + model_name="evaluation", + constraint=models.UniqueConstraint( + fields=("submission", "expert"), + name="uniq_evaluation_submission_expert", + ), + ), + migrations.AddConstraint( + model_name="evaluation", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("status", "draft"), ("submitted_at__isnull", True)), + models.Q( + ("status", "submitted"), + ("submitted_at__isnull", False), + ), + _connector="OR", + ), + name="evaluation_status_submitted_at", + ), + ), + migrations.AddIndex( + model_name="submissionexpertassignment", + index=models.Index( + fields=["submission", "status"], + name="subm_assign_status_idx", + ), + ), + migrations.AddIndex( + model_name="submissionexpertassignment", + index=models.Index( + fields=["expert", "status"], + name="expert_assignment_status_idx", + ), + ), + migrations.AddConstraint( + model_name="submissionexpertassignment", + constraint=models.UniqueConstraint( + condition=models.Q(("status__in", ("assigned", "completed"))), + fields=("submission", "expert"), + name="uniq_active_submission_expert_assignment", + ), + ), + migrations.AddConstraint( + model_name="submissionexpertassignment", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("completed_at__isnull", True), + ("revoked_at__isnull", True), + ("status", "assigned"), + ), + models.Q( + ("completed_at__isnull", False), + ("revoked_at__isnull", True), + ("status", "completed"), + ), + models.Q( + ("completed_at__isnull", True), + ("revoked_at__isnull", False), + ("status", "revoked"), + ), + _connector="OR", + ), + name="submission_assignment_status_timestamps", + ), + ), + migrations.AddIndex( + model_name="evaluationscore", + index=models.Index( + fields=["criterion", "created_at"], + name="eval_score_criterion_idx", + ), + ), + migrations.AddConstraint( + model_name="evaluationscore", + constraint=models.UniqueConstraint( + fields=("evaluation", "criterion"), + name="uniq_evaluation_score_criterion", + ), + ), + ] diff --git a/partner_programs/migrations/0024_evaluation_amended_at_evaluationamendment.py b/partner_programs/migrations/0024_evaluation_amended_at_evaluationamendment.py new file mode 100644 index 00000000..333cedee --- /dev/null +++ b/partner_programs/migrations/0024_evaluation_amended_at_evaluationamendment.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.11 on 2026-07-28 23:30 +# Roadmap: DEV-073 + +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", "0023_submission_evaluation_models"), + ] + + operations = [ + migrations.AddField( + model_name="evaluation", + name="amended_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.CreateModel( + name="EvaluationAmendment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("previous_comment", models.TextField(blank=True)), + ("comment", models.TextField(blank=True)), + ("previous_scores", models.JSONField(default=list)), + ("scores", models.JSONField(default=list)), + ( + "previous_total_score", + models.DecimalField( + blank=True, decimal_places=6, max_digits=18, null=True + ), + ), + ( + "total_score", + models.DecimalField( + blank=True, decimal_places=6, max_digits=18, null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "changed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="evaluation_amendments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "evaluation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="amendments", + to="partner_programs.evaluation", + ), + ), + ], + options={ + "verbose_name": "Изменение экспертной оценки", + "verbose_name_plural": "История изменений экспертных оценок", + "ordering": ("created_at", "id"), + "indexes": [ + models.Index( + fields=["evaluation", "created_at"], + name="eval_amend_eval_created_idx", + ) + ], + }, + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 33c6abe6..8b67ce37 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -1,3 +1,5 @@ +# Roadmap: DEV-073 + from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import models @@ -35,6 +37,16 @@ class PartnerProgram(models.Model): ("experts_only", "Только экспертам"), ] + PARTICIPATION_FORMAT_INDIVIDUAL_ONLY = "individual_only" + PARTICIPATION_FORMAT_TEAM_ONLY = "team_only" + PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM = "individual_or_team" + + PARTICIPATION_FORMAT_CHOICES = ( + (PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, "Только индивидуально"), + (PARTICIPATION_FORMAT_TEAM_ONLY, "Только в команде"), + (PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, "Индивидуально или в команде"), + ) + name = models.TextField( verbose_name="Название", ) @@ -125,9 +137,35 @@ class PartnerProgram(models.Model): verbose_name="Публиковать проекты после окончания программы", help_text="Если включено, проекты участников станут публичными после завершения программы", ) + # До появления выбора формата весь существующий flow остается индивидуальным. + participation_format = models.CharField( + max_length=24, + choices=PARTICIPATION_FORMAT_CHOICES, + default=PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, + verbose_name="Разрешенный формат участия", + ) + # Будущий service считает accepted-участников вместе с капитаном. + team_min_size = models.PositiveSmallIntegerField( + null=True, + blank=True, + verbose_name="Минимальный размер команды", + help_text="Количество принятых участников с учетом капитана", + ) + team_max_size = models.PositiveSmallIntegerField( + null=True, + blank=True, + verbose_name="Максимальный размер команды", + help_text="Количество принятых участников с учетом капитана", + ) datetime_registration_ends = models.DateTimeField( verbose_name="Дата окончания регистрации", ) + datetime_application_ends = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата окончания подачи заявок", + help_text="Отдельный дедлайн Application; другие дедлайны не используются как fallback", + ) datetime_project_submission_ends = models.DateTimeField( null=True, blank=True, @@ -160,9 +198,122 @@ def is_manager(self, user: User) -> bool: return False return self.managers.filter(pk=user.pk).exists() + def clean(self): + """Проверяет согласованность формата участия и размеров команды.""" + super().clean() + errors = {} + + if self.participation_format == self.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY: + if self.team_min_size is not None: + errors["team_min_size"] = ( + "Для индивидуального формата минимальный размер команды не задается." + ) + if self.team_max_size is not None: + errors["team_max_size"] = ( + "Для индивидуального формата максимальный размер команды не задается." + ) + elif self.participation_format in { + self.PARTICIPATION_FORMAT_TEAM_ONLY, + self.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + }: + if self.team_min_size is None: + errors["team_min_size"] = ( + "Для командного формата укажите минимальный размер команды." + ) + elif self.team_min_size < 2: + errors["team_min_size"] = ( + "Минимальный размер команды должен быть не меньше двух." + ) + + if self.team_max_size is None: + errors["team_max_size"] = ( + "Для командного формата укажите максимальный размер команды." + ) + elif self.team_max_size < 2: + errors["team_max_size"] = ( + "Максимальный размер команды должен быть не меньше двух." + ) + elif ( + self.team_min_size is not None + and self.team_max_size < self.team_min_size + ): + errors["team_max_size"] = ( + "Максимальный размер команды не может быть меньше минимального." + ) + + if errors: + raise ValidationError(errors) + + def allows_participation_mode(self, mode: str) -> bool: + """Возвращает, разрешен ли выбранный финальный формат заявки.""" + allowed_modes = { + self.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY: { + Application.PARTICIPATION_MODE_INDIVIDUAL + }, + self.PARTICIPATION_FORMAT_TEAM_ONLY: { + Application.PARTICIPATION_MODE_TEAM + }, + self.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM: { + Application.PARTICIPATION_MODE_INDIVIDUAL, + Application.PARTICIPATION_MODE_TEAM, + }, + } + return mode in allowed_modes.get(self.participation_format, set()) + + def is_application_deadline_passed(self, at=None) -> bool: + """Проверяет отдельный дедлайн Application без fallback на legacy-поля.""" + if self.datetime_application_ends is None: + return False + moment = at if at is not None else timezone.now() + return moment > self.datetime_application_ends + class Meta: verbose_name = "Программа" verbose_name_plural = "Программы" + # Constraints защищают policy одной программы. Фактический состав Team + # требует проверки связанных строк в будущем транзакционном service. + constraints = [ + models.CheckConstraint( + check=( + models.Q(team_min_size__isnull=True) + | models.Q(team_min_size__gte=2) + ), + name="program_team_min_gte_2_or_null", + ), + models.CheckConstraint( + check=( + models.Q(team_max_size__isnull=True) + | models.Q(team_max_size__gte=2) + ), + name="program_team_max_gte_2_or_null", + ), + models.CheckConstraint( + check=( + models.Q(team_min_size__isnull=True) + | models.Q(team_max_size__isnull=True) + | models.Q(team_max_size__gte=models.F("team_min_size")) + ), + name="program_team_max_gte_min", + ), + models.CheckConstraint( + check=( + models.Q( + participation_format="individual_only", + team_min_size__isnull=True, + team_max_size__isnull=True, + ) + | models.Q( + participation_format__in=( + "team_only", + "individual_or_team", + ), + team_min_size__isnull=False, + team_max_size__isnull=False, + ) + ), + name="program_team_sizes_match_format", + ), + ] def __str__(self): return f"PartnerProgram<{self.pk}> - {self.name}" @@ -176,6 +327,834 @@ def is_project_submission_open(self) -> bool: return deadline is None or deadline >= timezone.now() +class Application(models.Model): + """Заявка пользователя на участие в партнерской программе.""" + + PARTICIPATION_MODE_UNDECIDED = "undecided" + PARTICIPATION_MODE_INDIVIDUAL = "individual" + PARTICIPATION_MODE_TEAM = "team" + + PARTICIPATION_MODE_CHOICES = ( + (PARTICIPATION_MODE_UNDECIDED, "Не определен"), + (PARTICIPATION_MODE_INDIVIDUAL, "Индивидуально"), + (PARTICIPATION_MODE_TEAM, "В команде"), + ) + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + STATUS_APPROVED = "approved" + STATUS_REJECTED = "rejected" + STATUS_WITHDRAWN = "withdrawn" + STATUS_CANCELLED = "cancelled" + + ACTIVE_STATUSES = ( + STATUS_DRAFT, + STATUS_SUBMITTED, + STATUS_APPROVED, + ) + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Draft"), + (STATUS_SUBMITTED, "Submitted"), + (STATUS_APPROVED, "Approved"), + (STATUS_REJECTED, "Rejected"), + (STATUS_WITHDRAWN, "Withdrawn"), + (STATUS_CANCELLED, "Cancelled"), + ) + + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="applications", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="program_applications", + null=True, + blank=True, + ) + created_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="created_program_applications", + ) + # Временный default сохраняет совместимость с API и frontend, которые пока + # не передают формат участия. `undecided` станет default вместе с wizard. + participation_mode = models.CharField( + max_length=16, + choices=PARTICIPATION_MODE_CHOICES, + default=PARTICIPATION_MODE_INDIVIDUAL, + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + form_data = models.JSONField(default=dict, blank=True) + project = models.ForeignKey( + Project, + on_delete=models.SET_NULL, + related_name="applications", + null=True, + blank=True, + ) + submitted_at = models.DateTimeField(null=True, blank=True) + approved_at = models.DateTimeField(null=True, blank=True) + rejected_at = models.DateTimeField(null=True, blank=True) + withdrawn_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + super().clean() + + errors = {} + + if not self.user_id: + errors["user"] = ( + "User is required as the application owner and team captain in MVP." + ) + + if self.user_id and self.program_id and self.status in self.ACTIVE_STATUSES: + duplicate_qs = Application.objects.filter( + user_id=self.user_id, + program_id=self.program_id, + status__in=self.ACTIVE_STATUSES, + ) + if self.pk: + duplicate_qs = duplicate_qs.exclude(pk=self.pk) + if duplicate_qs.exists(): + errors["user"] = ( + "User already has an active application for this program." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Application" + verbose_name_plural = "Applications" + constraints = [ + models.UniqueConstraint( + fields=["user", "program"], + condition=models.Q( + user__isnull=False, + status__in=("draft", "submitted", "approved"), + ), + name="uniq_active_application_user_program", + ), + ] + + def __str__(self): + return ( + f"Application<{self.pk}> " + f"user={self.user_id} program={self.program_id} status={self.status}" + ) + + +class Team(models.Model): + """Команда конкретной заявки, независимая от состава связанного Project.""" + + application = models.OneToOneField( + Application, + on_delete=models.CASCADE, + related_name="team", + ) + name = models.CharField(max_length=255, blank=True, default="") + captain = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="captained_application_teams", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + """Проверяет формат заявки и единого владельца командного MVP.""" + super().clean() + + errors = {} + if self.application_id: + application = self.application + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + errors["application"] = ( + "Команду можно создать только для заявки с командным форматом." + ) + if self.captain_id and self.captain_id != application.user_id: + errors["captain"] = ( + "Капитан команды должен совпадать с владельцем заявки." + ) + + # Полный invariant намеренно не требует captain TeamMember: при первом + # сохранении Team связанная запись участника еще не может существовать. + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Команда заявки" + verbose_name_plural = "Команды заявок" + + def __str__(self): + return f"Team<{self.pk}> application={self.application_id} name={self.name}" + + +class TeamMember(models.Model): + """Членство пользователя в команде конкретной заявки.""" + + ROLE_CAPTAIN = "captain" + ROLE_MEMBER = "member" + + ROLE_CHOICES = ( + (ROLE_CAPTAIN, "Капитан"), + (ROLE_MEMBER, "Участник"), + ) + + STATUS_INVITED = "invited" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REMOVED = "removed" + STATUS_LEFT = "left" + + STATUS_CHOICES = ( + (STATUS_INVITED, "Приглашен"), + (STATUS_ACCEPTED, "Принят"), + (STATUS_DECLINED, "Отклонил приглашение"), + (STATUS_REMOVED, "Исключен"), + (STATUS_LEFT, "Покинул команду"), + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="members", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="application_team_memberships", + ) + role = models.CharField( + max_length=16, + choices=ROLE_CHOICES, + default=ROLE_MEMBER, + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_INVITED, + ) + invited_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="invited_application_team_members", + null=True, + blank=True, + ) + joined_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + """Проверяет роль капитана и фиксирует первое принятие в команду.""" + super().clean() + + errors = {} + if self.role == self.ROLE_CAPTAIN: + if self.status != self.STATUS_ACCEPTED: + errors["status"] = "Капитан должен быть принятым участником команды." + if self.team_id and self.user_id != self.team.captain_id: + errors["user"] = "Капитан-участник должен совпадать с капитаном команды." + + if self.status == self.STATUS_ACCEPTED and self.joined_at is None: + self.joined_at = timezone.now() + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + joined_at_was_missing = self.joined_at is None + self.full_clean() + if ( + joined_at_was_missing + and self.joined_at is not None + and kwargs.get("update_fields") is not None + ): + kwargs["update_fields"] = set(kwargs["update_fields"]) | {"joined_at"} + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Участник команды заявки" + verbose_name_plural = "Участники команд заявок" + constraints = [ + models.UniqueConstraint( + fields=["team", "user"], + name="uniq_team_member_user", + ), + models.UniqueConstraint( + fields=["team"], + condition=models.Q( + role="captain", + status="accepted", + ), + name="uniq_accepted_captain_team", + ), + models.CheckConstraint( + check=( + ~models.Q(role="captain") + | models.Q(status="accepted") + ), + name="captain_team_member_is_accepted", + ), + ] + # Конфликт участий между командами и индивидуальными заявками проходит + # через несколько таблиц и требует отдельного транзакционного service. + + def __str__(self): + return ( + f"TeamMember<{self.pk}> team={self.team_id} " + f"user={self.user_id} role={self.role} status={self.status}" + ) + + +class TeamInvite(models.Model): + """Приглашение зарегистрированного пользователя в команду заявки.""" + + STATUS_PENDING = "pending" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REVOKED = "revoked" + + STATUS_CHOICES = ( + (STATUS_PENDING, "Ожидает ответа"), + (STATUS_ACCEPTED, "Принято"), + (STATUS_DECLINED, "Отклонено"), + (STATUS_REVOKED, "Отозвано"), + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="invites", + verbose_name="Команда", + ) + user = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="application_team_invites", + verbose_name="Приглашенный пользователь", + ) + invited_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="sent_application_team_invites", + verbose_name="Кем приглашен", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_PENDING, + verbose_name="Статус", + ) + resolved_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата ответа", + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создано") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлено") + + class Meta: + verbose_name = "Приглашение в команду заявки" + verbose_name_plural = "Приглашения в команды заявок" + constraints = [ + # Завершенные приглашения сохраняются в истории и не мешают + # повторно пригласить пользователя в ту же команду. + models.UniqueConstraint( + fields=["team", "user"], + condition=models.Q(status="pending"), + name="uniq_pending_invite_team_user", + ), + ] + indexes = [ + models.Index( + fields=["user", "status", "created_at"], + name="team_invite_user_status_idx", + ), + models.Index( + fields=["team", "status", "created_at"], + name="team_invite_team_status_idx", + ), + ] + + def __str__(self): + return ( + f"TeamInvite<{self.pk}> team={self.team_id} " + f"user={self.user_id} status={self.status}" + ) + + +class Submission(models.Model): + """Versioned solution submitted for an application and program stage.""" + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + STATUS_RETURNED = "returned" + STATUS_FINAL = "final" + STATUS_CANCELLED = "cancelled" + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Draft"), + (STATUS_SUBMITTED, "Submitted"), + (STATUS_RETURNED, "Returned"), + (STATUS_FINAL, "Final"), + (STATUS_CANCELLED, "Cancelled"), + ) + + EDITABLE_STATUSES = ( + STATUS_DRAFT, + STATUS_RETURNED, + ) + + application = models.ForeignKey( + Application, + on_delete=models.CASCADE, + related_name="submissions", + ) + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="submissions", + ) + submitted_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="program_submissions", + ) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + form_data = models.JSONField(default=dict, blank=True) + links = models.JSONField(default=list, blank=True) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + version = models.PositiveIntegerField(default=1) + stage_key = models.CharField(max_length=128, default="main") + submitted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + super().clean() + + errors = {} + if self.application_id: + application = self.application + + if self.program_id and self.program_id != application.program_id: + errors["program"] = "Program must match the application's program." + + allowed_submitter_ids = { + application.user_id, + application.created_by_id, + } + allowed_submitter_ids.discard(None) + if ( + self.submitted_by_id + and self.submitted_by_id not in allowed_submitter_ids + and not ( + self.submitted_by.is_staff or self.submitted_by.is_superuser + ) + ): + errors["submitted_by"] = ( + "Submitted by must match the application user or creator." + ) + + if self._state.adding and application.status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + errors["application"] = ( + "Submissions cannot be created for inactive applications." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + @property + def is_draft(self): + return self.status == self.STATUS_DRAFT + + @property + def is_submitted(self): + return self.status == self.STATUS_SUBMITTED + + @property + def is_final(self): + return self.status == self.STATUS_FINAL + + @property + def can_edit(self): + return self.status in self.EDITABLE_STATUSES + + @property + def can_submit(self): + return self.status in self.EDITABLE_STATUSES + + class Meta: + verbose_name = "Submission" + verbose_name_plural = "Submissions" + constraints = [ + models.UniqueConstraint( + fields=["application", "stage_key", "version"], + name="uniq_submission_application_stage_version", + ), + models.CheckConstraint( + check=models.Q(version__gte=1), + name="submission_version_gte_1", + ), + ] + + def __str__(self): + return ( + f"Submission<{self.pk}> application={self.application_id} " + f"stage={self.stage_key} version={self.version} status={self.status}" + ) + + +class SubmissionExpertAssignment(models.Model): + """Назначение эксперта на конкретную зафиксированную сдачу.""" + + STATUS_ASSIGNED = "assigned" + STATUS_COMPLETED = "completed" + STATUS_REVOKED = "revoked" + + STATUS_CHOICES = ( + (STATUS_ASSIGNED, "Назначено"), + (STATUS_COMPLETED, "Завершено"), + (STATUS_REVOKED, "Отозвано"), + ) + + ACTIVE_STATUSES = ( + STATUS_ASSIGNED, + STATUS_COMPLETED, + ) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="expert_assignments", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.PROTECT, + related_name="submission_assignments", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_ASSIGNED, + ) + assigned_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="assigned_submission_experts", + ) + assigned_at = models.DateTimeField(auto_now_add=True) + revoked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="revoked_submission_experts", + null=True, + blank=True, + ) + revoked_at = models.DateTimeField(null=True, blank=True) + revoke_reason = models.TextField(blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Назначение эксперта на Submission" + verbose_name_plural = "Назначения экспертов на Submission" + constraints = [ + models.UniqueConstraint( + fields=["submission", "expert"], + condition=models.Q(status__in=("assigned", "completed")), + name="uniq_active_submission_expert_assignment", + ), + models.CheckConstraint( + check=( + models.Q( + status="assigned", + completed_at__isnull=True, + revoked_at__isnull=True, + ) + | models.Q( + status="completed", + completed_at__isnull=False, + revoked_at__isnull=True, + ) + | models.Q( + status="revoked", + completed_at__isnull=True, + revoked_at__isnull=False, + ) + ), + name="submission_assignment_status_timestamps", + ), + ] + indexes = [ + models.Index( + fields=["submission", "status"], + name="subm_assign_status_idx", + ), + models.Index( + fields=["expert", "status"], + name="expert_assignment_status_idx", + ), + ] + + def __str__(self): + return ( + f"SubmissionExpertAssignment<{self.pk}> " + f"submission={self.submission_id} expert={self.expert_id} " + f"status={self.status}" + ) + + +class Evaluation(models.Model): + """Единая форма оценки конкретной Submission конкретным экспертом.""" + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Черновик"), + (STATUS_SUBMITTED, "Отправлено"), + ) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="evaluations", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.PROTECT, + related_name="submission_evaluations", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + comment = models.TextField(blank=True) + total_score = models.DecimalField( + max_digits=18, + decimal_places=6, + null=True, + blank=True, + ) + submitted_at = models.DateTimeField(null=True, blank=True) + amended_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Оценка Submission" + verbose_name_plural = "Оценки Submission" + constraints = [ + models.UniqueConstraint( + fields=["submission", "expert"], + name="uniq_evaluation_submission_expert", + ), + models.CheckConstraint( + check=( + models.Q(status="draft", submitted_at__isnull=True) + | models.Q(status="submitted", submitted_at__isnull=False) + ), + name="evaluation_status_submitted_at", + ), + ] + indexes = [ + models.Index( + fields=["submission", "status"], + name="eval_submission_status_idx", + ), + models.Index( + fields=["expert", "status"], + name="evaluation_expert_status_idx", + ), + ] + + def __str__(self): + return ( + f"Evaluation<{self.pk}> submission={self.submission_id} " + f"expert={self.expert_id} status={self.status}" + ) + + +class EvaluationScore(models.Model): + """Числовая оценка по одному критерию внутри Evaluation.""" + + NUMERIC_CRITERION_TYPES = ("int", "float") + + evaluation = models.ForeignKey( + Evaluation, + on_delete=models.CASCADE, + related_name="scores", + ) + criterion = models.ForeignKey( + "project_rates.Criteria", + on_delete=models.PROTECT, + related_name="evaluation_scores", + ) + value = models.DecimalField(max_digits=18, decimal_places=6) + criterion_name = models.CharField(max_length=50, editable=False) + criterion_type = models.CharField(max_length=8, editable=False) + min_value = models.FloatField(null=True, blank=True, editable=False) + max_value = models.FloatField(null=True, blank=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def _capture_criterion_snapshot(self): + if self._state.adding and self.criterion_id: + self.criterion_name = self.criterion.name + self.criterion_type = self.criterion.type + self.min_value = self.criterion.min_value + self.max_value = self.criterion.max_value + + def _get_criterion_type_for_validation(self): + if self._state.adding: + return self.criterion.type if self.criterion_id else None + return self.criterion_type + + def clean(self): + super().clean() + criterion_type = self._get_criterion_type_for_validation() + errors = {} + + if criterion_type and criterion_type not in self.NUMERIC_CRITERION_TYPES: + errors["criterion"] = "Для EvaluationScore допустим только числовой критерий." + if ( + criterion_type == "int" + and self.value is not None + and self.value != self.value.to_integral_value() + ): + errors["value"] = "Для целочисленного критерия укажите целое значение." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self._capture_criterion_snapshot() + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Оценка по критерию" + verbose_name_plural = "Оценки по критериям" + constraints = [ + models.UniqueConstraint( + fields=["evaluation", "criterion"], + name="uniq_evaluation_score_criterion", + ), + ] + indexes = [ + models.Index( + fields=["criterion", "created_at"], + name="eval_score_criterion_idx", + ), + ] + + def __str__(self): + return ( + f"EvaluationScore<{self.pk}> evaluation={self.evaluation_id} " + f"criterion={self.criterion_id} value={self.value}" + ) + + +class EvaluationAmendment(models.Model): + """Неизменяемый снимок изменения уже отправленной экспертной оценки.""" + + evaluation = models.ForeignKey( + Evaluation, + on_delete=models.CASCADE, + related_name="amendments", + ) + changed_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="evaluation_amendments", + ) + previous_comment = models.TextField(blank=True) + comment = models.TextField(blank=True) + previous_scores = models.JSONField(default=list) + scores = models.JSONField(default=list) + previous_total_score = models.DecimalField( + max_digits=18, + decimal_places=6, + null=True, + blank=True, + ) + total_score = models.DecimalField( + max_digits=18, + decimal_places=6, + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if self.pk: + raise ValidationError("Историю изменения оценки нельзя редактировать.") + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise ValidationError("Историю изменения оценки нельзя удалять.") + + class Meta: + verbose_name = "Изменение экспертной оценки" + verbose_name_plural = "История изменений экспертных оценок" + ordering = ("created_at", "id") + indexes = [ + models.Index( + fields=["evaluation", "created_at"], + name="eval_amend_eval_created_idx", + ), + ] + + def __str__(self): + return ( + f"EvaluationAmendment<{self.pk}> evaluation={self.evaluation_id} " + f"changed_by={self.changed_by_id}" + ) + + class PartnerProgramUserProfile(models.Model): """ PartnerProgramUserProfile model diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index 33c6a28c..7993b812 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -1,6 +1,82 @@ from rest_framework.permissions import BasePermission -from partner_programs.models import PartnerProgram +from partner_programs.models import Application, PartnerProgram, Submission, Team, TeamMember + + +def _is_authenticated(user) -> bool: + return bool(user and user.is_authenticated) + + +def _is_staff(user) -> bool: + return _is_authenticated(user) and bool(user.is_staff or user.is_superuser) + + +def is_application_owner(user, application: Application) -> bool: + """Проверяет, является ли пользователь текущим владельцем заявки.""" + return _is_authenticated(user) and application.user_id == user.pk + + +def is_team_captain(user, team: Team) -> bool: + """Проверяет, является ли пользователь текущим капитаном команды.""" + return _is_authenticated(user) and team.captain_id == user.pk + + +def is_accepted_team_member(user, team: Team) -> bool: + """Учитывает только активное принятое членство, а не историю состава.""" + if not _is_authenticated(user): + return False + return TeamMember.objects.filter( + team=team, + user=user, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + + +def is_program_manager(user, program: PartnerProgram) -> bool: + """Проверяет membership пользователя в managers конкретной программы.""" + return _is_authenticated(user) and program.is_manager(user) + + +def can_view_application(user, application: Application) -> bool: + """Разрешает чтение владельцу, accepted-команде, менеджеру и staff.""" + if _is_staff(user) or is_application_owner(user, application): + return True + if is_program_manager(user, application.program): + return True + try: + team = application.team + except Team.DoesNotExist: + return False + return is_accepted_team_member(user, team) + + +def can_edit_application(user, application: Application) -> bool: + """Ограничивает изменения владельцем/капитаном и staff.""" + return _is_staff(user) or is_application_owner(user, application) + + +def can_view_team(user, team: Team) -> bool: + """Разрешает чтение accepted-составу, менеджеру программы и staff.""" + return ( + _is_staff(user) + or is_program_manager(user, team.application.program) + or is_accepted_team_member(user, team) + ) + + +def can_manage_team(user, team: Team) -> bool: + """Ограничивает управление текущим капитаном и staff.""" + return _is_staff(user) or is_team_captain(user, team) + + +def can_view_submission(user, submission: Submission) -> bool: + """Наследует read-доступ Submission от связанной Application.""" + return can_view_application(user, submission.application) + + +def can_edit_submission(user, submission: Submission) -> bool: + """Оставляет mutation Submission владельцу заявки и staff.""" + return can_edit_application(user, submission.application) class IsProjectLeader(BasePermission): @@ -45,13 +121,15 @@ def has_permission(self, request, view): if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - program_id = view.kwargs.get("pk") or view.kwargs.get("program_id") - if not program_id: - return False + program = getattr(view, "program", None) + if program is None: + program_id = view.kwargs.get("pk") or view.kwargs.get("program_id") + if not program_id: + return False - try: - program = PartnerProgram.objects.get(pk=program_id) - except PartnerProgram.DoesNotExist: - return False + try: + program = PartnerProgram.objects.get(pk=program_id) + except PartnerProgram.DoesNotExist: + return False return program.is_manager(user) diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 42b2f311..e08e8933 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -1,5 +1,7 @@ +from .applications import ApplicationSerializer from .fields import PartnerProgramFieldValueUpdateSerializer from .programs import ( + PartnerProgramApplicationPolicySerializer, PartnerProgramBaseSerializerMixin, PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -15,8 +17,26 @@ ProgramProjectFilterRequestSerializer, UserProgramsSerializer, ) +from .submissions import SubmissionSerializer +from .team_invites import ( + MyTeamInviteSerializer, + TeamInviteCandidateQuerySerializer, + TeamInviteCreateSerializer, + TeamInviteSerializer, +) +from .teams import ( + ApplicationTeamSummarySerializer, + TeamMemberSerializer, + TeamSerializer, + TeamTransferCaptainSerializer, + TeamUpdateSerializer, + TeamUserSerializer, +) __all__ = [ + "ApplicationSerializer", + "ApplicationTeamSummarySerializer", + "PartnerProgramApplicationPolicySerializer", "PartnerProgramBaseSerializerMixin", "PartnerProgramDataSchemaSerializer", "PartnerProgramFieldSerializer", @@ -31,5 +51,15 @@ "PartnerProgramUserSerializer", "ProgramProjectCreateSerializer", "ProgramProjectFilterRequestSerializer", + "SubmissionSerializer", + "MyTeamInviteSerializer", + "TeamInviteCandidateQuerySerializer", + "TeamInviteCreateSerializer", + "TeamInviteSerializer", + "TeamMemberSerializer", + "TeamSerializer", + "TeamTransferCaptainSerializer", + "TeamUpdateSerializer", + "TeamUserSerializer", "UserProgramsSerializer", ] diff --git a/partner_programs/serializers/applications.py b/partner_programs/serializers/applications.py new file mode 100644 index 00000000..552f1682 --- /dev/null +++ b/partner_programs/serializers/applications.py @@ -0,0 +1,152 @@ +from rest_framework import serializers + +from partner_programs.models import Application, Team +from partner_programs.serializers.teams import ApplicationTeamSummarySerializer +from projects.models import Project + + +class ApplicationProjectSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ("id", "name", "draft", "is_public") + + +class ApplicationSerializer(serializers.ModelSerializer): + team = serializers.SerializerMethodField() + project_summary = ApplicationProjectSummarySerializer( + source="project", + read_only=True, + ) + team_name = serializers.CharField( + required=False, + allow_blank=True, + max_length=255, + write_only=True, + ) + project = serializers.PrimaryKeyRelatedField( + queryset=Project.objects.all(), + allow_null=True, + required=False, + ) + project_id = serializers.PrimaryKeyRelatedField( + source="project", + queryset=Project.objects.all(), + allow_null=True, + required=False, + write_only=True, + ) + + immutable_input_fields = frozenset( + { + "id", + "program", + "user", + "created_by", + "status", + "team", + "project_summary", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + } + ) + + class Meta: + model = Application + fields = ( + "id", + "program", + "user", + "created_by", + "status", + "participation_mode", + "team", + "team_name", + "form_data", + "project", + "project_summary", + "project_id", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + ) + read_only_fields = ( + "id", + "program", + "user", + "created_by", + "status", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + ) + extra_kwargs = { + "form_data": {"required": False}, + "participation_mode": {"required": False}, + } + + def get_team(self, application: Application): + try: + team = application.team + except Team.DoesNotExist: + return None + return ApplicationTeamSummarySerializer( + team, + context=self.context, + ).data + + def update(self, instance, validated_data): + # Формат и Team изменяет только domain service; serializer сохраняет + # остальные редактируемые поля в общей транзакции view. + validated_data.pop("participation_mode", None) + validated_data.pop("team_name", None) + return super().update(instance, validated_data) + + def validate(self, attrs): + supplied_immutable_fields = self.immutable_input_fields.intersection( + self.initial_data + ) + if supplied_immutable_fields: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(supplied_immutable_fields) + } + ) + + if "project" in self.initial_data and "project_id" in self.initial_data: + raise serializers.ValidationError( + {"project": "Use either project or project_id, not both."} + ) + + if self.instance and self.instance.status != Application.STATUS_DRAFT: + raise serializers.ValidationError( + {"status": "Only draft applications can be updated."} + ) + + project = attrs.get("project", serializers.empty) + if project is not serializers.empty and project is not None: + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + not user + or not user.is_authenticated + or ( + project.leader_id != user.pk + and not (user.is_staff or user.is_superuser) + ) + ): + raise serializers.ValidationError( + {"project": "You can only use a project that you lead."} + ) + + return attrs diff --git a/partner_programs/serializers/evaluations.py b/partner_programs/serializers/evaluations.py new file mode 100644 index 00000000..aef396ef --- /dev/null +++ b/partner_programs/serializers/evaluations.py @@ -0,0 +1,325 @@ +# Roadmap: DEV-050, DEV-051, DEV-052, DEV-073 +# PII-safe контракты решений и экспертных оценок. + +from rest_framework import serializers + +from partner_programs.models import ( + Evaluation, + EvaluationAmendment, + EvaluationScore, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from project_rates.models import Criteria +from users.models import Expert + + +class EvaluationScoreWriteSerializer(serializers.Serializer): + criterion_id = serializers.IntegerField(min_value=1) + value = serializers.DecimalField( + max_digits=18, + decimal_places=6, + coerce_to_string=False, + ) + + +class EvaluationDraftCreateSerializer(serializers.Serializer): + comment = serializers.CharField( + required=False, + allow_blank=True, + default="", + ) + scores = EvaluationScoreWriteSerializer( + many=True, + required=False, + default=list, + ) + + +class EvaluationDraftUpdateSerializer(serializers.Serializer): + comment = serializers.CharField(required=False, allow_blank=True) + scores = EvaluationScoreWriteSerializer(many=True, required=False) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError("Передайте comment или scores.") + return attrs + + +class EvaluationAmendSerializer(EvaluationDraftUpdateSerializer): + pass + + +class ProgramSummarySerializer(serializers.ModelSerializer): + class Meta: + model = PartnerProgram + fields = ("id", "name") + read_only_fields = fields + + +class EvaluationExpertSummarySerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(read_only=True) + first_name = serializers.CharField(source="user.first_name", read_only=True) + last_name = serializers.CharField(source="user.last_name", read_only=True) + + class Meta: + model = Expert + fields = ("id", "user_id", "first_name", "last_name") + read_only_fields = fields + + +class EvaluationCriterionSerializer(serializers.ModelSerializer): + class Meta: + model = Criteria + fields = ( + "id", + "name", + "description", + "type", + "min_value", + "max_value", + ) + read_only_fields = fields + + +class EvaluationScoreReadSerializer(serializers.ModelSerializer): + criterion_id = serializers.IntegerField(read_only=True) + value = serializers.DecimalField( + max_digits=18, + decimal_places=6, + read_only=True, + ) + + class Meta: + model = EvaluationScore + fields = ( + "id", + "criterion_id", + "value", + "criterion_name", + "criterion_type", + "min_value", + "max_value", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class EvaluationReadSerializer(serializers.ModelSerializer): + submission_id = serializers.IntegerField(read_only=True) + expert = EvaluationExpertSummarySerializer(read_only=True) + scores = EvaluationScoreReadSerializer(many=True, read_only=True) + + class Meta: + model = Evaluation + fields = ( + "id", + "submission_id", + "expert", + "status", + "comment", + "scores", + "total_score", + "submitted_at", + "amended_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class ExpertAssignmentSummarySerializer(serializers.ModelSerializer): + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "status", + "assigned_at", + "completed_at", + ) + read_only_fields = fields + + +class ExpertEvaluationSummarySerializer(serializers.Serializer): + id = serializers.IntegerField(allow_null=True, read_only=True) + status = serializers.CharField(allow_null=True, read_only=True) + updated_at = serializers.DateTimeField(allow_null=True, read_only=True) + submitted_at = serializers.DateTimeField(allow_null=True, read_only=True) + amended_at = serializers.DateTimeField(allow_null=True, read_only=True) + + +class ExpertSubmissionListSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="submission_id", read_only=True) + program = ProgramSummarySerializer(source="submission.program", read_only=True) + title = serializers.CharField(source="submission.title", read_only=True) + status = serializers.CharField( + source="submission.status", + read_only=True, + ) + stage_key = serializers.CharField(source="submission.stage_key", read_only=True) + version = serializers.IntegerField(source="submission.version", read_only=True) + submitted_at = serializers.DateTimeField( + source="submission.submitted_at", + read_only=True, + ) + assignment = ExpertAssignmentSummarySerializer(source="*", read_only=True) + my_evaluation = serializers.SerializerMethodField() + + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "program", + "title", + "status", + "stage_key", + "version", + "submitted_at", + "assignment", + "my_evaluation", + ) + read_only_fields = fields + + def get_my_evaluation(self, assignment): + if assignment.my_evaluation_id is None: + return None + return { + "id": assignment.my_evaluation_id, + "status": assignment.my_evaluation_status, + "updated_at": assignment.my_evaluation_updated_at, + "submitted_at": assignment.my_evaluation_submitted_at, + "amended_at": assignment.my_evaluation_amended_at, + } + + +class ExpertSubmissionDetailSerializer(serializers.Serializer): + id = serializers.IntegerField(source="submission.id", read_only=True) + program = ProgramSummarySerializer(source="submission.program", read_only=True) + title = serializers.CharField(source="submission.title", read_only=True) + description = serializers.CharField( + source="submission.description", + read_only=True, + ) + links = serializers.JSONField(source="submission.links", read_only=True) + status = serializers.CharField(source="submission.status", read_only=True) + stage_key = serializers.CharField( + source="submission.stage_key", + read_only=True, + ) + version = serializers.IntegerField(source="submission.version", read_only=True) + submitted_at = serializers.DateTimeField( + source="submission.submitted_at", + read_only=True, + ) + assignment = ExpertAssignmentSummarySerializer(read_only=True) + my_evaluation = EvaluationReadSerializer( + source="evaluation", + allow_null=True, + read_only=True, + ) + criteria = EvaluationCriterionSerializer(many=True, read_only=True) + + +class ExpertSubmissionFilterSerializer(serializers.Serializer): + program_id = serializers.IntegerField(min_value=1, required=False) + submission_status = serializers.ChoiceField( + choices=Submission.STATUS_CHOICES, + required=False, + ) + evaluation_status = serializers.ChoiceField( + choices=( + (Evaluation.STATUS_DRAFT, Evaluation.STATUS_DRAFT), + (Evaluation.STATUS_SUBMITTED, Evaluation.STATUS_SUBMITTED), + ("none", "none"), + ), + required=False, + ) + + +class ManagerEvaluationFilterSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, required=False) + expert_id = serializers.IntegerField(min_value=1, required=False) + evaluation_status = serializers.ChoiceField( + choices=Evaluation.STATUS_CHOICES, + required=False, + ) + assignment_status = serializers.ChoiceField( + choices=SubmissionExpertAssignment.STATUS_CHOICES, + required=False, + ) + stage_key = serializers.CharField(max_length=128, required=False) + + +class ManagerSubmissionSummarySerializer(serializers.ModelSerializer): + program = ProgramSummarySerializer(read_only=True) + + class Meta: + model = Submission + fields = ( + "id", + "program", + "title", + "status", + "stage_key", + "version", + "submitted_at", + ) + read_only_fields = fields + + +class ManagerEvaluationSerializer(serializers.ModelSerializer): + submission = ManagerSubmissionSummarySerializer(read_only=True) + expert = EvaluationExpertSummarySerializer(read_only=True) + assignment = serializers.SerializerMethodField() + scores = EvaluationScoreReadSerializer(many=True, read_only=True) + + class Meta: + model = Evaluation + fields = ( + "id", + "status", + "submission", + "expert", + "assignment", + "scores", + "comment", + "total_score", + "submitted_at", + "amended_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + def get_assignment(self, evaluation): + if not hasattr(evaluation, "assignment_id") or evaluation.assignment_id is None: + return None + return { + "id": evaluation.assignment_id, + "status": evaluation.assignment_status, + "assigned_at": evaluation.assignment_assigned_at, + "completed_at": evaluation.assignment_completed_at, + } + + +class EvaluationAmendmentSerializer(serializers.ModelSerializer): + evaluation_id = serializers.IntegerField(read_only=True) + changed_by_id = serializers.IntegerField(read_only=True) + + class Meta: + model = EvaluationAmendment + fields = ( + "id", + "evaluation_id", + "changed_by_id", + "previous_comment", + "comment", + "previous_scores", + "scores", + "previous_total_score", + "total_score", + "created_at", + ) + read_only_fields = fields diff --git a/partner_programs/serializers/manager_overview.py b/partner_programs/serializers/manager_overview.py new file mode 100644 index 00000000..d48f8082 --- /dev/null +++ b/partner_programs/serializers/manager_overview.py @@ -0,0 +1,85 @@ +# Roadmap: DEV-076, DEV-056 + +from rest_framework import serializers + + +class ProgramOverviewProgramSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + + +class ProgramOverviewTotalSerializer(serializers.Serializer): + total = serializers.IntegerField(min_value=0) + + +class ApplicationStatusCountsSerializer(serializers.Serializer): + draft = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + approved = serializers.IntegerField(min_value=0) + rejected = serializers.IntegerField(min_value=0) + withdrawn = serializers.IntegerField(min_value=0) + cancelled = serializers.IntegerField(min_value=0) + + +class ParticipationModeCountsSerializer(serializers.Serializer): + undecided = serializers.IntegerField(min_value=0) + individual = serializers.IntegerField(min_value=0) + team = serializers.IntegerField(min_value=0) + + +class ProgramOverviewApplicationsSerializer(ProgramOverviewTotalSerializer): + by_status = ApplicationStatusCountsSerializer() + by_participation_mode = ParticipationModeCountsSerializer() + + +class ProgramOverviewTeamsSerializer(ProgramOverviewTotalSerializer): + accepted_members = serializers.IntegerField(min_value=0) + + +class SubmissionStatusCountsSerializer(serializers.Serializer): + draft = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + returned = serializers.IntegerField(min_value=0) + final = serializers.IntegerField(min_value=0) + cancelled = serializers.IntegerField(min_value=0) + + +class ProgramOverviewSubmissionsSerializer(ProgramOverviewTotalSerializer): + by_status = SubmissionStatusCountsSerializer() + applications_with_submitted_solution = serializers.IntegerField(min_value=0) + + +class AssignmentStatusCountsSerializer(serializers.Serializer): + assigned = serializers.IntegerField(min_value=0) + completed = serializers.IntegerField(min_value=0) + revoked = serializers.IntegerField(min_value=0) + + +class ProgramOverviewAssignmentsSerializer(ProgramOverviewTotalSerializer): + by_status = AssignmentStatusCountsSerializer() + + +class EvaluationStatusCountsSerializer(serializers.Serializer): + draft = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + + +class ProgramOverviewEvaluationsSerializer(ProgramOverviewTotalSerializer): + by_status = EvaluationStatusCountsSerializer() + + +class ManagerProgramOverviewSerializer(serializers.Serializer): + program = ProgramOverviewProgramSerializer() + registrations = ProgramOverviewTotalSerializer() + participants = ProgramOverviewTotalSerializer() + applications = ProgramOverviewApplicationsSerializer() + teams = ProgramOverviewTeamsSerializer() + submissions = ProgramOverviewSubmissionsSerializer() + expert_assignments = ProgramOverviewAssignmentsSerializer() + evaluations = ProgramOverviewEvaluationsSerializer() + + +class ManagedProgramSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + draft = serializers.BooleanField() diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 80f02375..7fc084e1 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -5,6 +5,7 @@ from courses.models import CourseContentStatus from courses.services.access import resolve_course_availability from partner_programs.models import ( + Application, PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, @@ -18,6 +19,41 @@ User = get_user_model() +class PartnerProgramApplicationPolicySerializer(serializers.Serializer): + """Публичный read-only контракт правил создания Application.""" + + participation_format = serializers.ChoiceField( + choices=PartnerProgram.PARTICIPATION_FORMAT_CHOICES, + read_only=True, + ) + allowed_participation_modes = serializers.SerializerMethodField() + team_min_size = serializers.IntegerField(read_only=True, allow_null=True) + team_max_size = serializers.IntegerField(read_only=True, allow_null=True) + datetime_application_ends = serializers.DateTimeField( + read_only=True, + allow_null=True, + ) + is_application_deadline_passed = serializers.SerializerMethodField() + + def get_allowed_participation_modes( + self, + program: PartnerProgram, + ) -> list[str]: + # Клиенту нужны только финальные режимы; undecided остается состоянием + # черновика и намеренно не участвует в выборе формата заявки. + final_modes = ( + Application.PARTICIPATION_MODE_INDIVIDUAL, + Application.PARTICIPATION_MODE_TEAM, + ) + return [mode for mode in final_modes if program.allows_participation_mode(mode)] + + def get_is_application_deadline_passed( + self, + program: PartnerProgram, + ) -> bool: + return program.is_application_deadline_passed() + + class PartnerProgramListSerializer(serializers.ModelSerializer): """Serializer for PartnerProgram model for list view.""" @@ -94,6 +130,10 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): materials = serializers.SerializerMethodField() is_user_manager = serializers.SerializerMethodField() courses = serializers.SerializerMethodField() + application_policy = PartnerProgramApplicationPolicySerializer( + source="*", + read_only=True, + ) def get_materials(self, program: PartnerProgram): materials = program.materials.all() @@ -180,6 +220,7 @@ class Meta: "publish_projects_after_finish", "is_user_manager", "courses", + "application_policy", ) @@ -205,6 +246,7 @@ class Meta: "publish_projects_after_finish", "is_user_manager", "courses", + "application_policy", ) diff --git a/partner_programs/serializers/submission_assignments.py b/partner_programs/serializers/submission_assignments.py new file mode 100644 index 00000000..5eb0844d --- /dev/null +++ b/partner_programs/serializers/submission_assignments.py @@ -0,0 +1,126 @@ +from rest_framework import serializers + +from partner_programs.models import ( + Evaluation, + Submission, + SubmissionExpertAssignment, +) +from users.models import Expert + + +class SubmissionAssignmentSubmissionSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = ( + "id", + "title", + "status", + "stage_key", + "version", + "submitted_at", + ) + read_only_fields = fields + + +class SubmissionAssignmentExpertSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(read_only=True) + first_name = serializers.CharField(source="user.first_name", read_only=True) + last_name = serializers.CharField(source="user.last_name", read_only=True) + + class Meta: + model = Expert + fields = ( + "id", + "user_id", + "first_name", + "last_name", + ) + read_only_fields = fields + + +class SubmissionAssignmentReadSerializer(serializers.ModelSerializer): + submission = SubmissionAssignmentSubmissionSerializer(read_only=True) + expert = SubmissionAssignmentExpertSerializer(read_only=True) + assigned_by_id = serializers.IntegerField(read_only=True) + revoked_by_id = serializers.IntegerField(read_only=True) + evaluation_status = serializers.SerializerMethodField() + evaluation = serializers.SerializerMethodField() + + class Meta: + model = SubmissionExpertAssignment + fields = ( + "id", + "status", + "submission", + "expert", + "assigned_by_id", + "assigned_at", + "completed_at", + "revoked_by_id", + "revoked_at", + "revoke_reason", + "evaluation_status", + "evaluation", + ) + read_only_fields = fields + + def get_evaluation_status(self, assignment): + if hasattr(assignment, "annotated_evaluation_status"): + return assignment.annotated_evaluation_status + return ( + Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + ) + .values_list("status", flat=True) + .first() + ) + + def get_evaluation(self, assignment): + if not hasattr(assignment, "annotated_evaluation_id"): + evaluation = ( + Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + ) + .values( + "id", + "status", + "updated_at", + "submitted_at", + "total_score", + ) + .first() + ) + return evaluation + if assignment.annotated_evaluation_id is None: + return None + return { + "id": assignment.annotated_evaluation_id, + "status": assignment.annotated_evaluation_status, + "updated_at": assignment.annotated_evaluation_updated_at, + "submitted_at": assignment.annotated_evaluation_submitted_at, + "total_score": assignment.annotated_evaluation_total_score, + } + + +class SubmissionAssignmentCreateSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, write_only=True) + expert_id = serializers.IntegerField(min_value=1, write_only=True) + + +class SubmissionAssignmentRevokeSerializer(serializers.Serializer): + reason = serializers.CharField( + allow_blank=False, + trim_whitespace=True, + write_only=True, + ) + + +class SubmissionAssignmentFilterSerializer(serializers.Serializer): + submission_id = serializers.IntegerField(min_value=1, required=False) + expert_id = serializers.IntegerField(min_value=1, required=False) + status = serializers.ChoiceField( + choices=SubmissionExpertAssignment.STATUS_CHOICES, + required=False, + ) diff --git a/partner_programs/serializers/submissions.py b/partner_programs/serializers/submissions.py new file mode 100644 index 00000000..51d850cc --- /dev/null +++ b/partner_programs/serializers/submissions.py @@ -0,0 +1,78 @@ +from rest_framework import serializers + +from partner_programs.models import Submission + + +class SubmissionSerializer(serializers.ModelSerializer): + create_fields = frozenset( + { + "title", + "description", + "form_data", + "links", + "stage_key", + "version", + } + ) + update_fields = frozenset( + { + "title", + "description", + "form_data", + "links", + } + ) + + class Meta: + model = Submission + fields = ( + "id", + "application", + "program", + "submitted_by", + "title", + "description", + "form_data", + "links", + "status", + "stage_key", + "version", + "submitted_at", + "created_at", + "updated_at", + ) + read_only_fields = ( + "id", + "application", + "program", + "submitted_by", + "status", + "submitted_at", + "created_at", + "updated_at", + ) + extra_kwargs = { + "description": {"required": False}, + "form_data": {"required": False}, + "links": {"required": False}, + "stage_key": {"required": False}, + "version": {"required": False, "min_value": 1}, + } + + def validate(self, attrs): + allowed_fields = self.update_fields if self.instance else self.create_fields + unsupported_fields = set(self.initial_data).difference(allowed_fields) + if unsupported_fields: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported_fields) + } + ) + + if self.instance and not self.instance.can_edit: + raise serializers.ValidationError( + {"status": "Only draft or returned submissions can be updated."} + ) + + return attrs diff --git a/partner_programs/serializers/team_invites.py b/partner_programs/serializers/team_invites.py new file mode 100644 index 00000000..73abc3d0 --- /dev/null +++ b/partner_programs/serializers/team_invites.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from partner_programs.models import Application, Team, TeamInvite +from partner_programs.serializers.teams import TeamUserSerializer + + +class TeamInviteCreateSerializer(serializers.Serializer): + """Принимает только публичный идентификатор зарегистрированного пользователя.""" + + user_id = serializers.IntegerField(min_value=1) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"user_id"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs + + +class TeamInviteCandidateQuerySerializer(serializers.Serializer): + """Не допускает пустой или слишком широкий поиск пользователей.""" + + q = serializers.CharField( # noqa: VNE001 — имя закреплено API-контрактом. + required=True, + trim_whitespace=True, + min_length=3, + max_length=100, + error_messages={ + "required": "Укажите поисковый запрос.", + "blank": "Укажите поисковый запрос.", + "min_length": "Введите не менее 3 символов.", + "max_length": "Введите не более 100 символов.", + }, + ) + + +class TeamInviteSerializer(serializers.ModelSerializer): + """Безопасное представление приглашения для капитана и staff.""" + + user = TeamUserSerializer(read_only=True) + invited_by = TeamUserSerializer(read_only=True) + + class Meta: + model = TeamInvite + fields = ( + "id", + "user", + "invited_by", + "status", + "resolved_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class TeamInviteTeamSerializer(serializers.ModelSerializer): + class Meta: + model = Team + fields = ("id", "name") + read_only_fields = fields + + +class TeamInviteProgramSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + + +def _invite_is_actionable(invite: TeamInvite) -> bool: + """Учитывает status приглашения, draft заявки и отдельный дедлайн.""" + application = invite.team.application + return ( + invite.status == TeamInvite.STATUS_PENDING + and application.status == Application.STATUS_DRAFT + and not application.program.is_application_deadline_passed() + ) + + +class MyTeamInviteSerializer(serializers.ModelSerializer): + """Показывает адресату контекст приглашения без приватных полей профиля.""" + + team = TeamInviteTeamSerializer(read_only=True) + application_id = serializers.IntegerField( + source="team.application_id", + read_only=True, + ) + captain = TeamUserSerializer(source="team.captain", read_only=True) + program = TeamInviteProgramSerializer( + source="team.application.program", + read_only=True, + ) + can_accept = serializers.SerializerMethodField() + can_decline = serializers.SerializerMethodField() + + class Meta: + model = TeamInvite + fields = ( + "id", + "status", + "team", + "application_id", + "captain", + "program", + "created_at", + "resolved_at", + "can_accept", + "can_decline", + ) + read_only_fields = fields + + def get_can_accept(self, invite: TeamInvite) -> bool: + return _invite_is_actionable(invite) + + def get_can_decline(self, invite: TeamInvite) -> bool: + return _invite_is_actionable(invite) diff --git a/partner_programs/serializers/teams.py b/partner_programs/serializers/teams.py new file mode 100644 index 00000000..b7c12aa8 --- /dev/null +++ b/partner_programs/serializers/teams.py @@ -0,0 +1,198 @@ +from rest_framework import serializers + +from partner_programs.models import Application, Team, TeamMember +from partner_programs.permissions import can_manage_team, is_accepted_team_member +from users.models import CustomUser + + +class TeamUserSerializer(serializers.ModelSerializer): + display_name = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ("id", "display_name", "avatar") + read_only_fields = fields + + def get_display_name(self, user: CustomUser) -> str: + """Возвращает безопасное имя без email и закрытых полей профиля.""" + return user.get_full_name().strip() + + +class TeamMemberSerializer(serializers.ModelSerializer): + user = TeamUserSerializer(read_only=True) + + class Meta: + model = TeamMember + fields = ( + "id", + "user", + "role", + "status", + "joined_at", + "created_at", + ) + read_only_fields = fields + + +def _current_user_role(team: Team, request): + """Не выдает роль по приглашенному или историческому membership.""" + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return None + member = team.members.filter( + user=user, + status=TeamMember.STATUS_ACCEPTED, + ).first() + return member.role if member else None + + +def _team_is_mutable(team: Team) -> bool: + """Учитывает статус заявки и отдельный deadline без legacy fallback.""" + return ( + team.application.status == Application.STATUS_DRAFT + and not team.application.program.is_application_deadline_passed() + ) + + +class ApplicationTeamSummarySerializer(serializers.ModelSerializer): + captain_id = serializers.IntegerField(read_only=True) + accepted_members_count = serializers.SerializerMethodField() + current_user_role = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = ( + "id", + "name", + "captain_id", + "accepted_members_count", + "current_user_role", + ) + read_only_fields = fields + + def get_accepted_members_count(self, team: Team) -> int: + return team.members.filter(status=TeamMember.STATUS_ACCEPTED).count() + + def get_current_user_role(self, team: Team): + return _current_user_role(team, self.context.get("request")) + + +class TeamSerializer(serializers.ModelSerializer): + application_id = serializers.IntegerField(read_only=True) + program_id = serializers.IntegerField(source="application.program_id", read_only=True) + captain = TeamUserSerializer(read_only=True) + members = serializers.SerializerMethodField() + accepted_members_count = serializers.SerializerMethodField() + team_min_size = serializers.IntegerField( + source="application.program.team_min_size", + read_only=True, + allow_null=True, + ) + team_max_size = serializers.IntegerField( + source="application.program.team_max_size", + read_only=True, + allow_null=True, + ) + current_user_role = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_manage_members = serializers.SerializerMethodField() + can_leave = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = ( + "id", + "application_id", + "program_id", + "name", + "captain", + "members", + "accepted_members_count", + "team_min_size", + "team_max_size", + "current_user_role", + "can_edit", + "can_manage_members", + "can_leave", + "created_at", + "updated_at", + ) + read_only_fields = fields + + def get_members(self, team: Team): + members = list(team.members.select_related("user").all()) + + def sort_key(member: TeamMember): + if ( + member.role == TeamMember.ROLE_CAPTAIN + and member.status == TeamMember.STATUS_ACCEPTED + ): + rank = 0 + elif member.status == TeamMember.STATUS_ACCEPTED: + rank = 1 + else: + rank = 2 + return rank, member.created_at, member.pk + + members.sort(key=sort_key) + return TeamMemberSerializer(members, many=True).data + + def get_accepted_members_count(self, team: Team) -> int: + return team.members.filter(status=TeamMember.STATUS_ACCEPTED).count() + + def get_current_user_role(self, team: Team): + return _current_user_role(team, self.context.get("request")) + + def get_can_edit(self, team: Team) -> bool: + request = self.context.get("request") + return bool( + request + and can_manage_team(request.user, team) + and _team_is_mutable(team) + ) + + def get_can_manage_members(self, team: Team) -> bool: + return self.get_can_edit(team) + + def get_can_leave(self, team: Team) -> bool: + request = self.context.get("request") + if not request or not _team_is_mutable(team): + return False + user = request.user + if not is_accepted_team_member(user, team): + return False + return team.members.filter( + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + + +class TeamUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, allow_blank=True, trim_whitespace=True) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"name"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs + + +class TeamTransferCaptainSerializer(serializers.Serializer): + member_id = serializers.IntegerField(min_value=1) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"member_id"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index 828cde87..ddc830eb 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -1,3 +1,53 @@ +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationCreationResult, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + CaptainMemberMissingError, + CaptainMismatchError, + ParticipationModeNotAllowedError, + ParticipationModeUndecidedError, + RegistrationRequiredError, + TeamHasOtherMembersError, + TeamMemberRegistrationMissingError, + TeamNotAllowedError, + TeamRequiredError, + TeamSizeInvalidError, + change_application_participation_mode, + create_or_get_application, + require_no_active_conflict, + submit_application, + validate_team_invariants, +) +from partner_programs.services.team_management import ( + CaptainTransferRequiredError, + CaptainTransferTargetInvalidError, + TeamManagementPermissionError, + TeamMemberNotFoundError, + TeamMemberNotRemovableError, + TeamMembershipNotActiveError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInviteCreationResult, + TeamInviteDuplicateError, + TeamInviteNotOwnedError, + TeamInviteNotPendingError, + TeamInvitePermissionError, + TeamInviteRegistrationMissingError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + get_team_invite_candidates, + revoke_team_invite, +) from partner_programs.services.exports import ( BASE_COLUMNS, ProgramExportFile, @@ -28,24 +78,68 @@ ) __all__ = [ + "ActiveApplicationConflictError", + "ApplicationCreationResult", + "ApplicationDeadlinePassedError", + "ApplicationNotEditableError", + "ApplicationTeamServiceError", "BASE_COLUMNS", + "CaptainMemberMissingError", + "CaptainMismatchError", + "CaptainTransferRequiredError", + "CaptainTransferTargetInvalidError", + "ParticipationModeNotAllowedError", + "ParticipationModeUndecidedError", "ProgramExportFile", "ProgramProjectAlreadyApplied", "ProgramProjectApplicationResult", "ProgramProjectFilterError", "ProgramRegistrationError", "ProjectScoreDataPreparer", + "RegistrationRequiredError", + "TeamHasOtherMembersError", + "TeamInviteActiveApplicationConflictError", + "TeamInviteCapacityReachedError", + "TeamInviteCreationResult", + "TeamInviteDuplicateError", + "TeamInviteNotOwnedError", + "TeamInviteNotPendingError", + "TeamInvitePermissionError", + "TeamInviteRegistrationMissingError", + "TeamInviteTargetInvalidError", + "TeamManagementPermissionError", + "TeamMemberNotFoundError", + "TeamMemberNotRemovableError", + "TeamMemberRegistrationMissingError", + "TeamMembershipNotActiveError", + "TeamNotAllowedError", + "TeamRequiredError", + "TeamSizeInvalidError", "apply_project_to_program", + "accept_team_invite", "build_program_field_columns", "build_program_project_scores_export_file", "build_program_projects_export_file", + "change_application_participation_mode", + "create_or_get_application", + "create_team_invite", "create_user_and_register_to_program", "get_filterable_program_fields", "get_filtered_program_project_links", + "get_team_invite_candidates", + "decline_team_invite", "prepare_project_scores_export_data", "publish_finished_program_projects", "register_user_to_program", + "remove_team_member", + "revoke_team_invite", + "rename_team", + "require_no_active_conflict", "require_can_apply_project_to_program", "row_dict_for_link", + "submit_application", + "transfer_team_captain", + "validate_team_invariants", "validate_program_project_filters", + "leave_team", ] diff --git a/partner_programs/services/application_team.py b/partner_programs/services/application_team.py new file mode 100644 index 00000000..4cf1fc64 --- /dev/null +++ b/partner_programs/services/application_team.py @@ -0,0 +1,573 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from notifications.events import notify_application_submitted +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamInvite, + TeamMember, +) +from projects.models import Project + +User = get_user_model() + + +class ApplicationTeamServiceError(Exception): + """Базовая ошибка доменного сервиса Application/Team.""" + + code = "application_team_error" + default_detail = "Операция с заявкой недоступна." + default_field = "non_field_errors" + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field or self.default_field + super().__init__(self.detail) + + +class RegistrationRequiredError(ApplicationTeamServiceError): + code = "registration_required" + default_detail = "Сначала зарегистрируйтесь на активность." + default_field = "registration" + + +class ApplicationDeadlinePassedError(ApplicationTeamServiceError): + code = "application_deadline_passed" + default_detail = "Срок подачи заявок завершен." + default_field = "datetime_application_ends" + + +class ParticipationModeNotAllowedError(ApplicationTeamServiceError): + code = "participation_mode_not_allowed" + default_detail = "Выбранный формат участия недоступен для этой активности." + default_field = "participation_mode" + + +class ParticipationModeUndecidedError(ApplicationTeamServiceError): + code = "participation_mode_undecided" + default_detail = "Перед отправкой выберите формат участия." + default_field = "participation_mode" + + +class ActiveApplicationConflictError(ApplicationTeamServiceError): + code = "active_application_conflict" + default_detail = "Пользователь уже участвует в другой активной заявке." + default_field = "user" + + +class TeamRequiredError(ApplicationTeamServiceError): + code = "team_required" + default_detail = "Для командной заявки необходимо создать команду." + default_field = "team" + + +class TeamNotAllowedError(ApplicationTeamServiceError): + code = "team_not_allowed" + default_detail = "Для выбранного формата команда недопустима." + default_field = "team" + + +class TeamHasOtherMembersError(ApplicationTeamServiceError): + code = "team_has_other_members" + default_detail = "Нельзя сменить формат, пока в команде есть другие участники." + default_field = "team" + + +class CaptainMemberMissingError(ApplicationTeamServiceError): + code = "captain_member_missing" + default_detail = "В команде отсутствует единственный принятый капитан." + default_field = "team" + + +class CaptainMismatchError(ApplicationTeamServiceError): + code = "captain_mismatch" + default_detail = "Капитан команды не совпадает с владельцем заявки." + default_field = "team" + + +class TeamSizeInvalidError(ApplicationTeamServiceError): + code = "team_size_invalid" + default_detail = "Размер команды не соответствует настройкам активности." + default_field = "team" + + +class TeamMemberRegistrationMissingError(ApplicationTeamServiceError): + code = "team_member_registration_missing" + default_detail = "Не все принятые участники зарегистрированы на активность." + default_field = "team" + + +class ApplicationNotEditableError(ApplicationTeamServiceError): + code = "application_not_editable" + default_detail = "Заявку нельзя изменить в текущем состоянии." + default_field = "status" + + +@dataclass(frozen=True) +class ApplicationCreationResult: + """Результат idempotent create с признаком новой записи.""" + + application: Application + created: bool + + +def _lock_program(program: PartnerProgram) -> PartnerProgram: + # Одна строка Program служит общей точкой сериализации cross-table проверок + # Application и TeamMember внутри этого service. + return PartnerProgram.objects.select_for_update().get(pk=program.pk) + + +def _lock_application(application: Application) -> Application: + return ( + Application.objects.select_for_update(of=("self",)) + .select_related("program", "user", "created_by", "project") + .get(pk=application.pk) + ) + + +def _require_registration(*, program: PartnerProgram, user: User) -> None: + if ( + user is None + or not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=user, + ).exists() + ): + raise RegistrationRequiredError() + + +def _require_open_application_deadline(program: PartnerProgram) -> None: + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def _validate_participation_mode( + *, + program: PartnerProgram, + participation_mode: str, + allow_undecided: bool, +) -> None: + known_modes = {value for value, _label in Application.PARTICIPATION_MODE_CHOICES} + if participation_mode not in known_modes: + raise ParticipationModeNotAllowedError() + if participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + if allow_undecided: + return + raise ParticipationModeUndecidedError() + if not program.allows_participation_mode(participation_mode): + raise ParticipationModeNotAllowedError() + + +def _active_application_ids_for_user( + *, + program: PartnerProgram, + user: User, +) -> set[int]: + owned_ids = Application.objects.filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ).values_list("pk", flat=True) + membership_ids = ( + TeamMember.objects.select_for_update() + .filter( + user=user, + status=TeamMember.STATUS_ACCEPTED, + team__application__program=program, + team__application__status__in=Application.ACTIVE_STATUSES, + ) + .values_list("team__application_id", flat=True) + ) + return set(owned_ids).union(membership_ids) + + +def require_no_active_conflict( + *, + program: PartnerProgram, + user: User, + exclude_application: Application | None = None, +) -> None: + """Запрещает второе активное участие пользователя в одной программе. + + Вызывающий service должен сначала заблокировать строку Program: простого + UniqueConstraint недостаточно для конфликта между Application и TeamMember. + """ + conflict_ids = _active_application_ids_for_user(program=program, user=user) + if exclude_application is not None: + conflict_ids.discard(exclude_application.pk) + if not conflict_ids: + return + + # Блокируем найденные Application после общей блокировки Program, чтобы + # последовательные domain-операции не приняли решения по устаревшим данным. + if Application.objects.select_for_update().filter(pk__in=conflict_ids).exists(): + raise ActiveApplicationConflictError() + + +def _create_team_with_captain( + *, + application: Application, + team_name: str | None, +) -> Team: + team = Team.objects.create( + application=application, + captain=application.user, + name=team_name or "", + ) + TeamMember.objects.create( + team=team, + user=application.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + invited_by=None, + ) + return team + + +def _existing_application_is_compatible( + *, + application: Application, + participation_mode: str, + form_data: dict | None, + project: Project | None, + team_name: str | None, +) -> bool: + if application.participation_mode != participation_mode: + return False + if form_data is not None and application.form_data != form_data: + return False + if project is not None and application.project_id != project.pk: + return False + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + team = Team.objects.select_for_update().filter(application=application).first() + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if team_name is not None and team.name != team_name: + return False + elif team_name: + raise TeamNotAllowedError() + return True + + +def create_or_get_application( + *, + program: PartnerProgram, + user: User, + created_by: User, + participation_mode: str, + form_data: dict | None = None, + project: Project | None = None, + team_name: str | None = None, +) -> ApplicationCreationResult: + """Создает draft Application или возвращает совместимую активную заявку. + + Аргументы описывают Program, владельца/автора, формат и начальные данные. + Возвращает ApplicationCreationResult с признаком создания. Может выбросить + ApplicationTeamServiceError при отсутствии Registration, закрытом дедлайне, + конфликте участия или несовместимой policy. Операция атомарна: для team + Application, Team и accepted-капитан либо создаются вместе, либо полностью + откатываются. + """ + with transaction.atomic(): + program = _lock_program(program) + _require_registration(program=program, user=user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=participation_mode, + allow_undecided=True, + ) + + existing_application = ( + Application.objects.select_for_update() + .filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ) + .order_by("-created_at") + .first() + ) + require_no_active_conflict( + program=program, + user=user, + exclude_application=existing_application, + ) + if existing_application is not None: + if not _existing_application_is_compatible( + application=existing_application, + participation_mode=participation_mode, + form_data=form_data, + project=project, + team_name=team_name, + ): + raise ActiveApplicationConflictError( + "Активная заявка несовместима с повторным запросом." + ) + return ApplicationCreationResult(existing_application, created=False) + + if participation_mode != Application.PARTICIPATION_MODE_TEAM and team_name: + raise TeamNotAllowedError() + + try: + application = Application.objects.create( + program=program, + user=user, + created_by=created_by, + participation_mode=participation_mode, + form_data={} if form_data is None else form_data, + project=project, + status=Application.STATUS_DRAFT, + ) + except IntegrityError as exc: + raise ActiveApplicationConflictError() from exc + + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + _create_team_with_captain( + application=application, + team_name=team_name, + ) + + return ApplicationCreationResult(application, created=True) + + +def _locked_team(application: Application) -> Team | None: + return ( + Team.objects.select_for_update() + .select_related("application", "captain") + .filter(application=application) + .first() + ) + + +def _require_valid_captain_member(*, application: Application, team: Team) -> None: + if team.application_id != application.pk or team.captain_id != application.user_id: + raise CaptainMismatchError() + + captain_members = list( + TeamMember.objects.filter( + team=team, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ) + if len(captain_members) != 1: + raise CaptainMemberMissingError() + if captain_members[0].user_id != team.captain_id: + raise CaptainMismatchError() + + +def change_application_participation_mode( + *, + application: Application, + actor: User, + participation_mode: str, + team_name: str | None = None, +) -> Application: + """Меняет формат draft Application и синхронно управляет базовой Team. + + Принимает заявку, владельца-actor, новый формат и необязательное название + команды. Возвращает обновленную Application. Может выбросить доменные + ошибки доступа, Registration, deadline, policy, конфликта или структуры + Team. Транзакция гарантирует атомарное создание/удаление Team и капитана; + переход team → individual разрешен только для команды без других записей. + """ + with transaction.atomic(): + program = _lock_program(application.program) + application = _lock_application(application) + if application.user_id != actor.pk and not (actor.is_staff or actor.is_superuser): + raise ApplicationNotEditableError( + "Только владелец может изменить формат заявки." + ) + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError() + + _require_registration(program=program, user=application.user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=participation_mode, + allow_undecided=True, + ) + require_no_active_conflict( + program=program, + user=application.user, + exclude_application=application, + ) + + current_mode = application.participation_mode + team = _locked_team(application) + + if current_mode == participation_mode: + if current_mode == Application.PARTICIPATION_MODE_TEAM: + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if team_name is not None and team.name != team_name: + team.name = team_name + team.save(update_fields=["name", "updated_at"]) + elif team is not None: + raise TeamNotAllowedError() + elif team_name: + raise TeamNotAllowedError() + return application + + if current_mode == Application.PARTICIPATION_MODE_TEAM: + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + raise TeamNotAllowedError( + "Командную заявку нельзя вернуть в неопределенный формат." + ) + if participation_mode == Application.PARTICIPATION_MODE_INDIVIDUAL: + # Любая дополнительная запись сохраняет историю состава, поэтому + # team → individual допустим только для единственного капитана. + if team.members.exclude( + role=TeamMember.ROLE_CAPTAIN, + user=team.captain, + ).exists(): + raise TeamHasOtherMembersError() + if team.invites.filter(status=TeamInvite.STATUS_PENDING).exists(): + raise TeamHasOtherMembersError( + "Нельзя сменить формат, пока у команды есть активные приглашения." + ) + team.delete() + team = None + elif team is not None: + raise TeamNotAllowedError() + + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + application.participation_mode = participation_mode + application.save(update_fields=["participation_mode", "updated_at"]) + _create_team_with_captain( + application=application, + team_name=team_name, + ) + return application + + if team_name: + raise TeamNotAllowedError() + application.participation_mode = participation_mode + application.save(update_fields=["participation_mode", "updated_at"]) + return application + + +def validate_team_invariants(application: Application) -> None: + """Проверяет полный invariant Team, не изменяя Application или команду. + + Для individual требует отсутствие Team, для undecided запрещает submit, а + для team проверяет капитана, Registration accepted-участников и границы + размера из Program policy. Возвращаемого значения нет; нарушение выражается + специализированной ApplicationTeamServiceError. + """ + if application.participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + raise ParticipationModeUndecidedError() + + team = ( + Team.objects.select_related("application", "captain") + .filter(application=application) + .first() + ) + if application.participation_mode == Application.PARTICIPATION_MODE_INDIVIDUAL: + if team is not None: + raise TeamNotAllowedError() + return + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + raise ParticipationModeNotAllowedError() + if team is None: + raise TeamRequiredError() + + _require_valid_captain_member(application=application, team=team) + # Invited/declined/removed/left сохраняют историю, но участниками команды + # при submit считаются только accepted-записи, включая капитана. + accepted_members = list( + TeamMember.objects.filter( + team=team, status=TeamMember.STATUS_ACCEPTED + ).select_related("user") + ) + accepted_user_ids = {member.user_id for member in accepted_members} + registered_user_ids = set( + PartnerProgramUserProfile.objects.filter( + partner_program=application.program, + user_id__in=accepted_user_ids, + ).values_list("user_id", flat=True) + ) + if accepted_user_ids != registered_user_ids: + raise TeamMemberRegistrationMissingError() + + accepted_count = len(accepted_members) + minimum = application.program.team_min_size + maximum = application.program.team_max_size + if ( + minimum is None + or maximum is None + or accepted_count < minimum + or accepted_count > maximum + ): + raise TeamSizeInvalidError() + + +def _require_no_team_member_conflicts(application: Application) -> None: + if application.participation_mode == Application.PARTICIPATION_MODE_TEAM: + user_ids = TeamMember.objects.filter( + team__application=application, + status=TeamMember.STATUS_ACCEPTED, + ).values_list("user_id", flat=True) + else: + user_ids = [application.user_id] + + for user_id in user_ids: + require_no_active_conflict( + program=application.program, + user=User.objects.get(pk=user_id), + exclude_application=application, + ) + + +def submit_application(*, application: Application, actor: User) -> Application: + """Валидирует и атомарно отправляет Application. + + Принимает заявку и actor; владельцу и staff сохраняет текущий contract + доступа. Возвращает submitted Application. Повторный submit идемпотентен и + не меняет submitted_at даже после deadline. Для draft возможны доменные + ошибки Registration, deadline, policy, конфликта или Team invariant. Все + проверки и переход выполняются в одной транзакции. + """ + with transaction.atomic(): + program = _lock_program(application.program) + application = _lock_application(application) + if application.user_id != actor.pk and not (actor.is_staff or actor.is_superuser): + raise ApplicationNotEditableError( + "Только владелец или staff может отправить заявку." + ) + + # Идемпотентный повтор не зависит от изменившегося deadline и не должен + # повторно записывать submitted_at. + if application.status == Application.STATUS_SUBMITTED: + return application + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError("Отправить можно только черновик заявки.") + + _require_registration(program=program, user=application.user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=application.participation_mode, + allow_undecided=False, + ) + validate_team_invariants(application) + _require_no_team_member_conflicts(application) + + application.status = Application.STATUS_SUBMITTED + application.submitted_at = timezone.now() + application.save(update_fields=["status", "submitted_at", "updated_at"]) + notify_application_submitted(application, actor=actor) + return application diff --git a/partner_programs/services/evaluations.py b/partner_programs/services/evaluations.py new file mode 100644 index 00000000..5223c89e --- /dev/null +++ b/partner_programs/services/evaluations.py @@ -0,0 +1,760 @@ +# Roadmap: DEV-050, DEV-051, DEV-052, DEV-073 +# Контур экспертного доступа к Submission и управления Evaluation. + +from dataclasses import dataclass +from decimal import Decimal + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.db.models import OuterRef, Prefetch, Subquery +from django.utils import timezone + +from notifications.events import notify_evaluation_submitted +from partner_programs.models import ( + Evaluation, + EvaluationAmendment, + EvaluationScore, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from project_rates.models import Criteria +from users.models import Expert + + +class EvaluationServiceError(Exception): + code = "evaluation_error" + default_detail = "Не удалось выполнить операцию с оценкой." + field = None + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field if field is not None else self.field + super().__init__(self.detail) + + +class EvaluationAccessDeniedError(EvaluationServiceError): + code = "evaluation_access_denied" + default_detail = "Доступ к экспертному контуру запрещён." + + +class EvaluationNotFoundError(EvaluationServiceError): + code = "evaluation_not_found" + default_detail = "Оценка или назначенное решение не найдено." + + +class EvaluationValidationError(EvaluationServiceError): + code = "evaluation_validation" + + +class EvaluationConflictError(EvaluationServiceError): + code = "evaluation_conflict" + + +class AssignmentUnavailableError(EvaluationConflictError): + code = "assignment_unavailable" + default_detail = "Активное назначение эксперта недоступно." + + +class SubmissionUnavailableError(EvaluationConflictError): + code = "submission_unavailable" + default_detail = "Оценивать можно только отправленное или финальное решение." + + +class EvaluationSubmittedError(EvaluationConflictError): + code = "evaluation_submitted" + default_detail = "Отправленная оценка недоступна для изменения." + + +class EvaluationNotSubmittedError(EvaluationConflictError): + code = "evaluation_not_submitted" + default_detail = "Изменить можно только отправленную оценку." + + +@dataclass(frozen=True) +class EvaluationCreationResult: + evaluation: Evaluation + created: bool + + +@dataclass(frozen=True) +class ExpertSubmissionDetailResult: + submission: Submission + assignment: SubmissionExpertAssignment + evaluation: Evaluation | None + criteria: list[Criteria] + + +def get_numeric_criteria(program: PartnerProgram): + return Criteria.objects.filter( + partner_program=program, + type__in=EvaluationScore.NUMERIC_CRITERION_TYPES, + ).order_by("id") + + +def _expert_for_user(user, *, list_access=False): + try: + return user.expert + except Expert.DoesNotExist as exc: + error_class = ( + EvaluationAccessDeniedError if list_access else EvaluationNotFoundError + ) + raise error_class() from exc + + +def _require_expert_membership(expert: Expert, program: PartnerProgram): + if not expert.programs.filter(pk=program.pk).exists(): + raise EvaluationNotFoundError() + + +def _require_submission_status(submission: Submission): + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionUnavailableError() + + +def _assignment_queryset(*, submission_id, expert_id, for_update=False): + queryset = SubmissionExpertAssignment.objects.select_related( + "submission", + "submission__program", + "expert", + ).filter( + submission_id=submission_id, + expert_id=expert_id, + ) + if for_update: + queryset = queryset.select_for_update() + return queryset.order_by("-created_at", "-id") + + +def _require_assigned_episode(*, submission_id, expert_id, for_update=False): + queryset = _assignment_queryset( + submission_id=submission_id, + expert_id=expert_id, + for_update=for_update, + ) + assignment = queryset.filter( + status=SubmissionExpertAssignment.STATUS_ASSIGNED + ).first() + if assignment is not None: + return assignment + if queryset.exists(): + raise AssignmentUnavailableError() + raise EvaluationNotFoundError() + + +def _active_assignment(*, submission_id, expert_id): + return ( + _assignment_queryset( + submission_id=submission_id, + expert_id=expert_id, + ) + .filter(status__in=SubmissionExpertAssignment.ACTIVE_STATUSES) + .first() + ) + + +def expert_submission_assignments(*, user): + expert = _expert_for_user(user, list_access=True) + evaluation = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=expert.pk, + ) + return ( + SubmissionExpertAssignment.objects.filter( + expert=expert, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + submission__program__experts=expert, + ) + .select_related( + "submission", + "submission__program", + ) + .annotate( + my_evaluation_id=Subquery(evaluation.values("id")[:1]), + my_evaluation_status=Subquery(evaluation.values("status")[:1]), + my_evaluation_updated_at=Subquery(evaluation.values("updated_at")[:1]), + my_evaluation_submitted_at=Subquery(evaluation.values("submitted_at")[:1]), + my_evaluation_amended_at=Subquery(evaluation.values("amended_at")[:1]), + ) + .order_by("-assigned_at", "-id") + ) + + +def get_expert_submission_detail(*, submission_id, user): + submission = ( + Submission.objects.select_related("program").filter(pk=submission_id).first() + ) + if submission is None: + raise EvaluationNotFoundError() + + if user.is_staff or user.is_superuser: + assignment = ( + SubmissionExpertAssignment.objects.select_related("expert") + .filter( + submission=submission, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ) + .order_by("-created_at", "-id") + .first() + ) + else: + expert = _expert_for_user(user) + assignment = _active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if assignment is not None: + _require_expert_membership(expert, submission.program) + if assignment is None: + raise EvaluationNotFoundError() + + evaluation = ( + Evaluation.objects.prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter( + submission=submission, + expert_id=assignment.expert_id, + ) + .first() + ) + return ExpertSubmissionDetailResult( + submission=submission, + assignment=assignment, + evaluation=evaluation, + criteria=list(get_numeric_criteria(submission.program)), + ) + + +def get_my_evaluation(*, submission_id, user): + expert = _expert_for_user(user) + assignment = _active_assignment( + submission_id=submission_id, + expert_id=expert.pk, + ) + if assignment is None: + raise EvaluationNotFoundError() + _require_expert_membership(expert, assignment.submission.program) + evaluation = ( + Evaluation.objects.select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter( + submission_id=submission_id, + expert=expert, + ) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + return evaluation + + +def _validated_scores(*, program, scores): + criterion_ids = [item["criterion_id"] for item in scores] + if len(criterion_ids) != len(set(criterion_ids)): + raise EvaluationValidationError( + "Критерии в scores не должны повторяться.", + field="scores", + ) + + criteria = { + criterion.pk: criterion + for criterion in Criteria.objects.filter(pk__in=criterion_ids) + } + validated = [] + for item in scores: + criterion = criteria.get(item["criterion_id"]) + if criterion is None: + raise EvaluationValidationError( + "Указан неизвестный критерий.", + field="scores", + ) + if criterion.partner_program_id != program.pk: + raise EvaluationValidationError( + "Критерий относится к другой программе.", + field="scores", + ) + if criterion.type not in EvaluationScore.NUMERIC_CRITERION_TYPES: + raise EvaluationValidationError( + "В scores разрешены только числовые критерии.", + field="scores", + ) + + value = item["value"] + if criterion.type == "int" and value != value.to_integral_value(): + raise EvaluationValidationError( + "Для целочисленного критерия требуется целое значение.", + field="scores", + ) + if criterion.min_value is not None and value < Decimal(str(criterion.min_value)): + raise EvaluationValidationError( + f"Значение критерия {criterion.pk} меньше допустимого минимума.", + field="scores", + ) + if criterion.max_value is not None and value > Decimal(str(criterion.max_value)): + raise EvaluationValidationError( + f"Значение критерия {criterion.pk} больше допустимого максимума.", + field="scores", + ) + validated.append((criterion, value)) + return validated + + +def _replace_scores(*, evaluation, validated_scores): + evaluation.scores.all().delete() + for criterion, value in validated_scores: + EvaluationScore.objects.create( + evaluation=evaluation, + criterion=criterion, + value=value, + ) + + +def _validated_complete_scores(*, program, scores): + validated_scores = _validated_scores(program=program, scores=scores) + expected_ids = set(get_numeric_criteria(program).values_list("id", flat=True)) + actual_ids = {criterion.pk for criterion, _value in validated_scores} + if actual_ids != expected_ids: + raise EvaluationValidationError( + "Передайте полный набор числовых критериев программы.", + field="scores", + ) + return validated_scores + + +def _score_snapshot(evaluation): + return [ + { + "criterion_id": score.criterion_id, + "criterion_name": score.criterion_name, + "criterion_type": score.criterion_type, + "min_value": score.min_value, + "max_value": score.max_value, + "value": str(score.value), + } + for score in evaluation.scores.order_by("criterion_id", "id") + ] + + +def _existing_creation_result(evaluation): + if evaluation.status == Evaluation.STATUS_SUBMITTED: + raise EvaluationSubmittedError() + return EvaluationCreationResult( + evaluation=evaluation, + created=False, + ) + + +def create_or_get_draft_evaluation( + *, + submission_id, + user, + comment="", + scores=None, +): + expert = _expert_for_user(user) + with transaction.atomic(): + assignment = _require_assigned_episode( + submission_id=submission_id, + expert_id=expert.pk, + for_update=True, + ) + submission = assignment.submission + _require_expert_membership(expert, submission.program) + _require_submission_status(submission) + + existing = ( + Evaluation.objects.select_for_update() + .filter(submission=submission, expert=expert) + .first() + ) + if existing is not None: + return _existing_creation_result(existing) + + validated_scores = _validated_scores( + program=submission.program, + scores=scores or [], + ) + try: + with transaction.atomic(): + evaluation = Evaluation.objects.create( + submission=submission, + expert=expert, + comment=comment, + ) + except (IntegrityError, DjangoValidationError) as exc: + existing = ( + Evaluation.objects.select_for_update() + .filter(submission=submission, expert=expert) + .first() + ) + if existing is not None: + return _existing_creation_result(existing) + raise EvaluationConflictError( + "Оценка изменилась конкурентно. Повторите запрос." + ) from exc + + _replace_scores( + evaluation=evaluation, + validated_scores=validated_scores, + ) + return EvaluationCreationResult( + evaluation=evaluation, + created=True, + ) + + +def _evaluation_identity_for_owner(*, evaluation_id, user): + expert = _expert_for_user(user) + evaluation = ( + Evaluation.objects.select_related("submission", "submission__program") + .filter(pk=evaluation_id, expert=expert) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + _require_expert_membership(expert, evaluation.submission.program) + return evaluation, expert + + +def update_draft_evaluation( + *, + evaluation_id, + user, + comment_supplied=False, + comment="", + scores_supplied=False, + scores=None, +): + identity, expert = _evaluation_identity_for_owner( + evaluation_id=evaluation_id, + user=user, + ) + with transaction.atomic(): + _require_assigned_episode( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ) + evaluation = ( + Evaluation.objects.select_for_update() + .select_related("submission", "submission__program") + .get(pk=identity.pk) + ) + if evaluation.status == Evaluation.STATUS_SUBMITTED: + raise EvaluationSubmittedError() + _require_submission_status(evaluation.submission) + + if scores_supplied: + validated_scores = _validated_scores( + program=evaluation.submission.program, + scores=scores or [], + ) + _replace_scores( + evaluation=evaluation, + validated_scores=validated_scores, + ) + if comment_supplied: + evaluation.comment = comment + if comment_supplied or scores_supplied: + evaluation.save(update_fields=["comment", "updated_at"]) + + return evaluation + + +def _validate_complete_evaluation(*, evaluation): + criteria = list(get_numeric_criteria(evaluation.submission.program)) + criteria_by_id = {criterion.pk: criterion for criterion in criteria} + scores = list(evaluation.scores.select_related("criterion").all()) + score_ids = [score.criterion_id for score in scores] + if len(score_ids) != len(set(score_ids)): + raise EvaluationValidationError( + "Критерии в оценке не должны повторяться.", + field="scores", + ) + if set(score_ids) != set(criteria_by_id): + raise EvaluationValidationError( + "Перед отправкой заполните все числовые критерии программы.", + field="scores", + ) + _validated_scores( + program=evaluation.submission.program, + scores=[ + { + "criterion_id": score.criterion_id, + "value": score.value, + } + for score in scores + ], + ) + + +def submit_evaluation(*, evaluation_id, user): + identity, expert = _evaluation_identity_for_owner( + evaluation_id=evaluation_id, + user=user, + ) + with transaction.atomic(): + assignment = ( + _assignment_queryset( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ) + .filter(status__in=SubmissionExpertAssignment.ACTIVE_STATUSES) + .first() + ) + if assignment is None: + if _assignment_queryset( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ).exists(): + raise AssignmentUnavailableError() + raise EvaluationNotFoundError() + + evaluation = ( + Evaluation.objects.select_for_update() + .select_related("submission", "submission__program") + .get(pk=identity.pk) + ) + if evaluation.status == Evaluation.STATUS_SUBMITTED: + return evaluation + if assignment.status != SubmissionExpertAssignment.STATUS_ASSIGNED: + raise AssignmentUnavailableError() + _require_submission_status(evaluation.submission) + _validate_complete_evaluation(evaluation=evaluation) + + now = timezone.now() + evaluation.status = Evaluation.STATUS_SUBMITTED + evaluation.submitted_at = now + evaluation.total_score = None + evaluation.save( + update_fields=[ + "status", + "submitted_at", + "total_score", + "updated_at", + ] + ) + + assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + assignment.completed_at = now + assignment.save( + update_fields=[ + "status", + "completed_at", + "updated_at", + ] + ) + notify_evaluation_submitted(evaluation, actor=user) + return evaluation + + +def amend_submitted_evaluation( + *, + evaluation_id, + user, + comment_supplied=False, + comment="", + scores_supplied=False, + scores=None, +): + identity, expert = _evaluation_identity_for_owner( + evaluation_id=evaluation_id, + user=user, + ) + with transaction.atomic(): + assignment = _assignment_queryset( + submission_id=identity.submission_id, + expert_id=expert.pk, + for_update=True, + ).first() + if assignment is None: + raise EvaluationNotFoundError() + if assignment.status not in SubmissionExpertAssignment.ACTIVE_STATUSES: + raise AssignmentUnavailableError() + + evaluation = ( + Evaluation.objects.select_for_update() + .select_related("submission", "submission__program") + .get(pk=identity.pk) + ) + _require_expert_membership(expert, evaluation.submission.program) + if evaluation.status != Evaluation.STATUS_SUBMITTED: + raise EvaluationNotSubmittedError() + _require_submission_status(evaluation.submission) + + validated_scores = None + if scores_supplied: + validated_scores = _validated_complete_scores( + program=evaluation.submission.program, + scores=scores or [], + ) + + previous_comment = evaluation.comment + previous_scores = _score_snapshot(evaluation) + next_comment = comment if comment_supplied else previous_comment + current_score_values = { + score.criterion_id: score.value + for score in evaluation.scores.only("criterion_id", "value") + } + next_score_values = ( + {criterion.pk: value for criterion, value in validated_scores} + if validated_scores is not None + else current_score_values + ) + if next_comment == previous_comment and next_score_values == current_score_values: + return evaluation + + previous_total_score = evaluation.total_score + if validated_scores is not None: + _replace_scores( + evaluation=evaluation, + validated_scores=validated_scores, + ) + + evaluation.comment = next_comment + evaluation.total_score = None + evaluation.amended_at = timezone.now() + evaluation.save( + update_fields=[ + "comment", + "total_score", + "amended_at", + "updated_at", + ] + ) + EvaluationAmendment.objects.create( + evaluation=evaluation, + changed_by=user, + previous_comment=previous_comment, + comment=evaluation.comment, + previous_scores=previous_scores, + scores=_score_snapshot(evaluation), + previous_total_score=previous_total_score, + total_score=evaluation.total_score, + ) + return evaluation + + +def get_evaluation_amendments(*, evaluation_id, user): + evaluation = ( + Evaluation.objects.select_related( + "submission", + "submission__program", + "expert", + ) + .filter(pk=evaluation_id) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + if user.is_staff or user.is_superuser: + pass + elif evaluation.submission.program.is_manager(user): + pass + else: + try: + expert = user.expert + except Expert.DoesNotExist as exc: + raise EvaluationNotFoundError() from exc + if evaluation.expert_id != expert.pk: + raise EvaluationNotFoundError() + return evaluation.amendments.select_related("changed_by").all() + + +def get_visible_evaluation(*, evaluation_id, user): + evaluation = ( + Evaluation.objects.select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .filter(pk=evaluation_id) + .first() + ) + if evaluation is None: + raise EvaluationNotFoundError() + if user.is_staff or user.is_superuser: + return evaluation + try: + expert = user.expert + except Expert.DoesNotExist: + expert = None + if ( + expert is not None + and evaluation.expert_id == expert.pk + and expert.programs.filter(pk=evaluation.submission.program_id).exists() + and _active_assignment( + submission_id=evaluation.submission_id, + expert_id=expert.pk, + ) + is not None + ): + return evaluation + if evaluation.submission.program.is_manager(user): + return evaluation + raise EvaluationNotFoundError() + + +def manager_evaluations_queryset(*, program): + latest_assignment = SubmissionExpertAssignment.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ).order_by("-created_at", "-id") + return ( + Evaluation.objects.filter(submission__program=program) + .select_related( + "submission", + "submission__program", + "expert", + "expert__user", + ) + .prefetch_related( + Prefetch( + "scores", + queryset=EvaluationScore.objects.select_related("criterion").order_by( + "criterion_id" + ), + ) + ) + .annotate( + assignment_id=Subquery(latest_assignment.values("id")[:1]), + assignment_status=Subquery(latest_assignment.values("status")[:1]), + assignment_assigned_at=Subquery(latest_assignment.values("assigned_at")[:1]), + assignment_completed_at=Subquery( + latest_assignment.values("completed_at")[:1] + ), + ) + .order_by("-updated_at", "-id") + ) diff --git a/partner_programs/services/manager_overview.py b/partner_programs/services/manager_overview.py new file mode 100644 index 00000000..b1c9889d --- /dev/null +++ b/partner_programs/services/manager_overview.py @@ -0,0 +1,139 @@ +# Roadmap: DEV-076, DEV-056 + +from django.db.models import Count, Q + +from partner_programs.models import ( + Application, + Evaluation, + PartnerProgram, + PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + Team, + TeamMember, +) + + +def _choice_counts(metrics, prefix, choices): + return {value: metrics[f"{prefix}_{value}"] for value, _label in choices} + + +def _count_by_choice(choices, field_name): + return { + f"{field_name}_{value}": Count( + "id", + filter=Q(**{field_name: value}), + ) + for value, _label in choices + } + + +def build_manager_program_overview(program: PartnerProgram) -> dict: + """Возвращает обезличенные агрегаты этапов участия в одной программе.""" + registration_metrics = PartnerProgramUserProfile.objects.filter( + partner_program=program + ).aggregate( + total=Count("id"), + participants_total=Count( + "user_id", + filter=Q(user_id__isnull=False), + distinct=True, + ), + ) + + application_metrics = Application.objects.filter(program=program).aggregate( + total=Count("id"), + **_count_by_choice(Application.STATUS_CHOICES, "status"), + **_count_by_choice( + Application.PARTICIPATION_MODE_CHOICES, + "participation_mode", + ), + ) + + team_metrics = Team.objects.filter(application__program=program).aggregate( + total=Count("id", distinct=True), + accepted_members=Count( + "members", + filter=Q(members__status=TeamMember.STATUS_ACCEPTED), + distinct=True, + ), + ) + + submission_metrics = Submission.objects.filter(program=program).aggregate( + total=Count("id"), + applications_with_submitted_solution=Count( + "application_id", + filter=Q( + status__in=( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ) + ), + distinct=True, + ), + **_count_by_choice(Submission.STATUS_CHOICES, "status"), + ) + + assignment_metrics = SubmissionExpertAssignment.objects.filter( + submission__program=program + ).aggregate( + total=Count("id"), + **_count_by_choice(SubmissionExpertAssignment.STATUS_CHOICES, "status"), + ) + + evaluation_metrics = Evaluation.objects.filter(submission__program=program).aggregate( + total=Count("id"), + **_count_by_choice(Evaluation.STATUS_CHOICES, "status"), + ) + + return { + "program": { + "id": program.pk, + "name": program.name, + }, + "registrations": {"total": registration_metrics["total"]}, + "participants": { + "total": registration_metrics["participants_total"], + }, + "applications": { + "total": application_metrics["total"], + "by_status": _choice_counts( + application_metrics, + "status", + Application.STATUS_CHOICES, + ), + "by_participation_mode": _choice_counts( + application_metrics, + "participation_mode", + Application.PARTICIPATION_MODE_CHOICES, + ), + }, + "teams": team_metrics, + "submissions": { + "total": submission_metrics["total"], + "by_status": _choice_counts( + submission_metrics, + "status", + Submission.STATUS_CHOICES, + ), + "applications_with_submitted_solution": submission_metrics[ + "applications_with_submitted_solution" + ], + }, + "expert_assignments": { + "total": assignment_metrics["total"], + "by_status": _choice_counts( + assignment_metrics, + "status", + SubmissionExpertAssignment.STATUS_CHOICES, + ), + }, + "evaluations": { + "total": evaluation_metrics["total"], + "by_status": _choice_counts( + evaluation_metrics, + "status", + Evaluation.STATUS_CHOICES, + ), + }, + } diff --git a/partner_programs/services/react_dev_demo.py b/partner_programs/services/react_dev_demo.py new file mode 100644 index 00000000..56c668bc --- /dev/null +++ b/partner_programs/services/react_dev_demo.py @@ -0,0 +1,645 @@ +# Roadmap: DEV-072, DEV-083 +# Повторяемый связанный набор данных для ручной проверки 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 core.models import Like, View +from news.models import NewsComment +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.react_dev_news_demo import ( + ReactDevNewsDemoDataError, + delete_react_dev_news_demo_data, + ensure_react_dev_news_demo_data, +) +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 + projects: int + public_news: int + internal_news: int + likes: int + views: int + comments: int + internal_news_id: 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, + "projects": self.projects, + "public_news": self.public_news, + "internal_news": self.internal_news, + "likes": self.likes, + "views": self.views, + "comments": self.comments, + "internal_news_id": self.internal_news_id, + } + + +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, news_demo): + 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(), + projects=1, + public_news=news_demo.public_news_count, + internal_news=1, + likes=Like.objects.filter( + content_type__app_label="news", + content_type__model="news", + object_id__in=news_demo.all_news_ids, + ).count(), + views=View.objects.filter( + content_type__app_label="news", + content_type__model="news", + object_id__in=news_demo.all_news_ids, + ).count(), + comments=NewsComment.objects.filter(news_id__in=news_demo.all_news_ids).count(), + internal_news_id=news_demo.internal_news.pk, + ) + + +def build_react_dev_demo_data(*, password, reset=False, dry_run=False): + """Создать полный набор DEV-072 и при необходимости откатить изменения.""" + + with transaction.atomic(): + if reset: + existing_users = { + spec["key"]: User.objects.filter(email=spec["email"]).first() + for spec in DEMO_USER_SPECS + } + try: + delete_react_dev_news_demo_data( + program=_find_owned_program(), + users_by_key=existing_users, + ) + except ReactDevNewsDemoDataError as exc: + raise ReactDevDemoDataError(str(exc)) from exc + _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"], + ) + try: + news_demo = ensure_react_dev_news_demo_data( + program=program, + users_by_key=users, + ) + except ReactDevNewsDemoDataError as exc: + raise ReactDevDemoDataError(str(exc)) from exc + summary = _summary(program, news_demo) + + if dry_run: + transaction.set_rollback(True) + return summary diff --git a/partner_programs/services/react_dev_news_demo.py b/partner_programs/services/react_dev_news_demo.py new file mode 100644 index 00000000..8b96728c --- /dev/null +++ b/partner_programs/services/react_dev_news_demo.py @@ -0,0 +1,275 @@ +# Roadmap: DEV-083.3 +"""Повторяемый набор новостей для ручной проверки React-dev.""" + +from dataclasses import dataclass + +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet +from django.utils import timezone + +from core.models import Like, View +from news.models import News, NewsComment +from projects.models import Project + + +DEMO_NEWS_PROJECT_NAME = "[DEMO] Новостная лента React-dev" +DEMO_NEWS_PROJECT_DESCRIPTION = ( + "Публичный демонстрационный проект для проверки новостной ленты React-dev." +) +DEMO_INTERNAL_PROGRAM_NEWS_TEXT = ( + "[DEMO][DEV-083] Внутренняя новость для участников программы" +) +DEMO_PROGRAM_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость программы {str(index).zfill(2)}: этап демонстрации" + for index in range(1, 12) +) +DEMO_PROJECT_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость проекта {str(index).zfill(2)}: развитие решения" + for index in range(1, 12) +) +DEMO_USER_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость пользователя {str(index).zfill(2)}: заметка участника" + for index in range(1, 12) +) +DEMO_PAGINATED_COMMENT_TEXTS = tuple( + f"[DEMO][DEV-083] Комментарий к новости {str(index).zfill(2)}" + for index in range(1, 22) +) +DEMO_EXTRA_COMMENT_TEXTS = ( + "[DEMO][DEV-083] Комментарий к новости проекта", + "[DEMO][DEV-083] Комментарий к новости пользователя", +) + + +class ReactDevNewsDemoDataError(Exception): + """Набор новостей нельзя безопасно создать или обновить.""" + + +@dataclass(frozen=True) +class ReactDevNewsDemoResult: + project: Project + program_news: tuple[News, ...] + project_news: tuple[News, ...] + user_news: tuple[News, ...] + internal_news: News + + @property + def all_news_ids(self) -> tuple[int, ...]: + return tuple( + news.pk + for news in ( + *self.program_news, + *self.project_news, + *self.user_news, + self.internal_news, + ) + ) + + @property + def public_news_count(self) -> int: + return len(self.program_news) + len(self.project_news) + len(self.user_news) + + +def _find_owned_project(*, expected_leader) -> Project | None: + candidates = list( + Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME) + .select_related("leader") + .order_by("pk") + ) + if not candidates: + return None + if len(candidates) != 1: + raise ReactDevNewsDemoDataError( + "Точное имя DEMO-проекта новостной ленты соответствует нескольким " + "проектам; требуется ручная проверка." + ) + + project = candidates[0] + if expected_leader is None or project.leader_id != expected_leader.pk: + raise ReactDevNewsDemoDataError( + "Точное имя DEMO-проекта новостной ленты уже занято посторонним проектом." + ) + return project + + +def _news_for_source(source, texts: tuple[str, ...]) -> QuerySet[News]: + content_type = ContentType.objects.get_for_model(source) + return News.objects.filter( + content_type=content_type, + object_id=source.pk, + text__in=texts, + ) + + +def delete_react_dev_news_demo_data(*, program, users_by_key) -> None: + """Удалить только записи с точными идентификаторами набора DEV-083.""" + + expected_leader = users_by_key.get("participant1") + project = _find_owned_project(expected_leader=expected_leader) + + # Тексты и ожидаемый источник вместе образуют идентификатор записи seed-набора: + # общий префикс [DEMO] намеренно не используется для удаления. + if program is not None: + _news_for_source( + program, + (*DEMO_PROGRAM_NEWS_TEXTS, DEMO_INTERNAL_PROGRAM_NEWS_TEXT), + ).delete() + users = tuple(users_by_key.values()) + for index, text in enumerate(DEMO_USER_NEWS_TEXTS): + user = users[index % len(users)] + if user is not None: + _news_for_source(user, (text,)).delete() + if project is not None: + _news_for_source(project, DEMO_PROJECT_NEWS_TEXTS).delete() + project.delete() + + +def _ensure_project(*, leader) -> Project: + project = _find_owned_project(expected_leader=leader) + if project is None: + return Project.objects.create( + name=DEMO_NEWS_PROJECT_NAME, + description=DEMO_NEWS_PROJECT_DESCRIPTION, + leader=leader, + draft=False, + is_public=True, + ) + + project.description = DEMO_NEWS_PROJECT_DESCRIPTION + project.draft = False + project.is_public = True + project.save( + update_fields=( + "description", + "draft", + "is_public", + "datetime_updated", + ) + ) + return project + + +def _ensure_news(*, source, text, audience, datetime_created) -> News: + candidates = list(_news_for_source(source, (text,)).order_by("pk")) + if len(candidates) > 1: + raise ReactDevNewsDemoDataError(f"Найдены дубли точной DEMO-новости: {text}") + + if candidates: + news = candidates[0] + news.audience = audience + news.pin = False + news.save(update_fields=("audience", "pin", "datetime_updated")) + news.files.clear() + else: + news = News.objects.add_news( + source, + text=text, + files=[], + audience=audience, + ) + + # Даты задаются явно, чтобы порядок страниц был стабильным и отличался у записей. + News.objects.filter(pk=news.pk).update( + datetime_created=datetime_created, + datetime_updated=datetime_created, + ) + news.datetime_created = datetime_created + news.datetime_updated = datetime_created + return news + + +def _ensure_news_group(*, source, texts, start_offset_hours) -> tuple[News, ...]: + now = timezone.now() + return tuple( + _ensure_news( + source=source, + text=text, + audience=News.Audience.PLATFORM, + datetime_created=now - timezone.timedelta(hours=start_offset_hours + index), + ) + for index, text in enumerate(texts) + ) + + +def _ensure_reactions_and_comments(*, result, users_by_key) -> None: + users = tuple(users_by_key.values()) + news_content_type = ContentType.objects.get_for_model(News) + targets = ( + (result.program_news[0], users[:2], users[:3]), + (result.project_news[0], users[:1], users[:2]), + (result.user_news[0], users[:3], users), + ) + for news, like_users, view_users in targets: + for user in like_users: + Like.objects.get_or_create( + user=user, + content_type=news_content_type, + object_id=news.pk, + ) + for user in view_users: + View.objects.get_or_create( + user=user, + content_type=news_content_type, + object_id=news.pk, + ) + + comment_start = result.program_news[0].datetime_created + for index, text in enumerate(DEMO_PAGINATED_COMMENT_TEXTS): + comment, _created = NewsComment.objects.get_or_create( + news=result.program_news[0], + author=users[index % len(users)], + text=text, + ) + NewsComment.objects.filter(pk=comment.pk).update( + datetime_created=comment_start + timezone.timedelta(minutes=index + 1), + datetime_updated=None, + ) + + for news, text, author in ( + (result.project_news[0], DEMO_EXTRA_COMMENT_TEXTS[0], users[1]), + (result.user_news[0], DEMO_EXTRA_COMMENT_TEXTS[1], users[2]), + ): + NewsComment.objects.get_or_create(news=news, author=author, text=text) + + +def ensure_react_dev_news_demo_data(*, program, users_by_key) -> ReactDevNewsDemoResult: + """Создать или обновить точный набор DEV-083 без дублирования записей.""" + + project = _ensure_project(leader=users_by_key["participant1"]) + program_news = _ensure_news_group( + source=program, + texts=DEMO_PROGRAM_NEWS_TEXTS, + start_offset_hours=1, + ) + project_news = _ensure_news_group( + source=project, + texts=DEMO_PROJECT_NEWS_TEXTS, + start_offset_hours=20, + ) + + users = tuple(users_by_key.values()) + user_news = tuple( + _ensure_news( + source=users[index % len(users)], + text=text, + audience=News.Audience.PLATFORM, + datetime_created=timezone.now() - timezone.timedelta(hours=40 + index), + ) + for index, text in enumerate(DEMO_USER_NEWS_TEXTS) + ) + internal_news = _ensure_news( + source=program, + text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + audience=News.Audience.PROGRAM_PARTICIPANTS, + datetime_created=timezone.now() - timezone.timedelta(minutes=30), + ) + + result = ReactDevNewsDemoResult( + project=project, + program_news=program_news, + project_news=project_news, + user_news=user_news, + internal_news=internal_news, + ) + _ensure_reactions_and_comments(result=result, users_by_key=users_by_key) + return result diff --git a/partner_programs/services/submission_assignments.py b/partner_programs/services/submission_assignments.py new file mode 100644 index 00000000..fc9e7803 --- /dev/null +++ b/partner_programs/services/submission_assignments.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.utils import timezone + +from notifications.events import ( + notify_expert_assignment_created, + notify_expert_assignment_revoked, +) +from partner_programs.models import ( + Evaluation, + PartnerProgram, + Submission, + SubmissionExpertAssignment, +) +from users.models import Expert + + +class SubmissionAssignmentServiceError(Exception): + code = "submission_assignment_error" + default_detail = "Unable to change the submission assignment." + field = None + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field if field is not None else self.field + super().__init__(self.detail) + + +class SubmissionAssignmentValidationError(SubmissionAssignmentServiceError): + code = "invalid_assignment" + + +class SubmissionAssignmentConflictError(SubmissionAssignmentServiceError): + code = "assignment_conflict" + + +class SubmissionStatusConflictError(SubmissionAssignmentConflictError): + code = "submission_status_conflict" + default_detail = "Only submitted or final submissions can be assigned." + + +class AssignmentCompletedError(SubmissionAssignmentConflictError): + code = "assignment_completed" + default_detail = "A completed assignment cannot be changed." + + +class SubmittedEvaluationExistsError(SubmissionAssignmentConflictError): + code = "submitted_evaluation_exists" + default_detail = "An assignment with a submitted evaluation cannot be revoked." + + +@dataclass(frozen=True) +class SubmissionAssignmentCreationResult: + assignment: SubmissionExpertAssignment + created: bool + + +def _get_active_assignment(*, submission_id, expert_id): + return ( + SubmissionExpertAssignment.objects.filter( + submission_id=submission_id, + expert_id=expert_id, + status__in=SubmissionExpertAssignment.ACTIVE_STATUSES, + ) + .order_by("-created_at", "-id") + .first() + ) + + +def _result_for_existing_active(assignment): + if assignment.status == SubmissionExpertAssignment.STATUS_COMPLETED: + raise AssignmentCompletedError() + return SubmissionAssignmentCreationResult( + assignment=assignment, + created=False, + ) + + +def _get_submission(*, program, submission_id): + submission = ( + Submission.objects.select_for_update() + .select_related("program") + .filter(pk=submission_id) + .first() + ) + if submission is None: + raise SubmissionAssignmentValidationError( + "Submission not found.", + field="submission_id", + ) + if submission.program_id != program.pk: + raise SubmissionAssignmentValidationError( + "Submission does not belong to this program.", + field="submission_id", + ) + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionStatusConflictError() + return submission + + +def _get_expert(*, program, expert_id): + expert = Expert.objects.select_related("user").filter(pk=expert_id).first() + if expert is None: + raise SubmissionAssignmentValidationError( + "Expert not found.", + field="expert_id", + ) + if not expert.programs.filter(pk=program.pk).exists(): + raise SubmissionAssignmentValidationError( + "Expert does not belong to this program.", + field="expert_id", + ) + return expert + + +def create_submission_assignment( + *, + program: PartnerProgram, + submission_id: int, + expert_id: int, + actor, +) -> SubmissionAssignmentCreationResult: + with transaction.atomic(): + submission = _get_submission( + program=program, + submission_id=submission_id, + ) + expert = _get_expert(program=program, expert_id=expert_id) + + existing = _get_active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if existing is not None: + return _result_for_existing_active(existing) + + try: + with transaction.atomic(): + assignment = SubmissionExpertAssignment.objects.create( + submission=submission, + expert=expert, + assigned_by=actor, + ) + except (IntegrityError, DjangoValidationError) as exc: + existing = _get_active_assignment( + submission_id=submission.pk, + expert_id=expert.pk, + ) + if existing is not None: + return _result_for_existing_active(existing) + raise SubmissionAssignmentConflictError( + "The assignment changed concurrently. Please retry." + ) from exc + + notify_expert_assignment_created(assignment) + return SubmissionAssignmentCreationResult( + assignment=assignment, + created=True, + ) + + +def revoke_submission_assignment( + *, + assignment: SubmissionExpertAssignment, + actor, + reason: str, +) -> SubmissionExpertAssignment: + reason = reason.strip() + if not reason: + raise SubmissionAssignmentValidationError( + "A non-empty reason is required.", + field="reason", + ) + + with transaction.atomic(): + assignment = ( + SubmissionExpertAssignment.objects.select_for_update() + .select_related("submission", "expert") + .get(pk=assignment.pk) + ) + if assignment.status == SubmissionExpertAssignment.STATUS_REVOKED: + return assignment + if assignment.status == SubmissionExpertAssignment.STATUS_COMPLETED: + raise AssignmentCompletedError() + if Evaluation.objects.filter( + submission_id=assignment.submission_id, + expert_id=assignment.expert_id, + status=Evaluation.STATUS_SUBMITTED, + ).exists(): + raise SubmittedEvaluationExistsError() + + assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + assignment.revoked_by = actor + assignment.revoked_at = timezone.now() + assignment.revoke_reason = reason + assignment.completed_at = None + assignment.save( + update_fields=[ + "status", + "revoked_by", + "revoked_at", + "revoke_reason", + "completed_at", + "updated_at", + ] + ) + notify_expert_assignment_revoked(assignment, actor=actor) + return assignment diff --git a/partner_programs/services/submission_project.py b/partner_programs/services/submission_project.py new file mode 100644 index 00000000..e759defd --- /dev/null +++ b/partner_programs/services/submission_project.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.db import transaction + +from partner_programs.models import Application, Submission, TeamMember +from projects.models import Collaborator, Project, ProjectLink + + +class SubmissionProjectError(Exception): + """Доменная ошибка создания постоянного Project из Submission.""" + + +class SubmissionProjectAccessError(SubmissionProjectError): + """Пользователь не может создавать Project из указанного Submission.""" + + +class SubmissionProjectStatusError(SubmissionProjectError): + """Текущий статус Submission не допускает создание Project.""" + + +@dataclass(frozen=True) +class SubmissionProjectResult: + project: Project + created: bool + + +def _valid_submission_links(links): + """Возвращает уникальные HTTP(S)-ссылки без падения на старых JSON-данных.""" + if not isinstance(links, list): + return [] + + validator = URLValidator(schemes=("http", "https")) + max_length = ProjectLink._meta.get_field("link").max_length + result = [] + for raw_link in links: + if not isinstance(raw_link, str): + continue + link = raw_link.strip() + if not link or len(link) > max_length or link in result: + continue + try: + validator(link) + except ValidationError: + continue + result.append(link) + return result + + +@transaction.atomic +def create_project_from_submission(*, submission_id, actor): + """Идемпотентно создает Project из зафиксированного Submission. + + Submission остается историческим снимком решения. Повторные версии одной + Application используют одну связь Application.project и не клонируют Project. + """ + submission = Submission.objects.select_for_update().get(pk=submission_id) + application = Application.objects.select_for_update().get( + pk=submission.application_id + ) + + if not (actor.is_staff or actor.is_superuser or application.user_id == actor.pk): + raise SubmissionProjectAccessError() + + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionProjectStatusError( + "Создать проект можно только из отправленного или финального решения." + ) + + if application.project_id: + return SubmissionProjectResult(project=application.project, created=False) + + project = Project.objects.create( + leader=application.user, + name=submission.title, + description=submission.description, + draft=True, + is_public=False, + ) + ProjectLink.objects.bulk_create( + [ + ProjectLink(project=project, link=link) + for link in _valid_submission_links(submission.links) + ], + ignore_conflicts=True, + ) + + application.project = project + application.save(update_fields=["project", "updated_at"]) + + if application.participation_mode == Application.PARTICIPATION_MODE_TEAM: + accepted_members = ( + TeamMember.objects.select_for_update() + .filter( + team__application=application, + status=TeamMember.STATUS_ACCEPTED, + ) + .exclude(user_id=application.user_id) + ) + for member in accepted_members.select_related("user"): + Collaborator.objects.get_or_create( + project=project, + user=member.user, + defaults={"role": "Участник команды"}, + ) + + return SubmissionProjectResult(project=project, created=True) diff --git a/partner_programs/services/team_invites.py b/partner_programs/services/team_invites.py new file mode 100644 index 00000000..f41b2d74 --- /dev/null +++ b/partner_programs/services/team_invites.py @@ -0,0 +1,484 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.db.models import Exists, OuterRef, Q, Value +from django.db.models.functions import Concat, Lower +from django.utils import timezone + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamInvite, + TeamMember, +) +from notifications.events import ( + notify_team_invite_created, + notify_team_invite_resolved, +) +from partner_programs.permissions import can_manage_team +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + require_no_active_conflict, +) + +User = get_user_model() + +TEAM_INVITE_CANDIDATE_LIMIT = 20 + + +class TeamInvitePermissionError(ApplicationTeamServiceError): + code = "team_invite_permission_denied" + default_detail = "Недостаточно прав для управления приглашениями команды." + default_field = "team" + + +class TeamInviteNotPendingError(ApplicationTeamServiceError): + code = "team_invite_not_pending" + default_detail = "Приглашение уже завершено и не может быть изменено." + default_field = "status" + + +class TeamInviteTargetInvalidError(ApplicationTeamServiceError): + code = "team_invite_target_invalid" + default_detail = "Пользователя нельзя пригласить в эту команду." + default_field = "user_id" + + +class TeamInviteRegistrationMissingError(ApplicationTeamServiceError): + code = "team_invite_registration_missing" + default_detail = "Пользователь не зарегистрирован на активность." + default_field = "user_id" + + +class TeamInviteDuplicateError(ApplicationTeamServiceError): + code = "team_invite_duplicate" + default_detail = "Активное приглашение для этого пользователя уже существует." + default_field = "user_id" + + +class TeamInviteCapacityReachedError(ApplicationTeamServiceError): + code = "team_invite_capacity_reached" + default_detail = "В команде нет свободного места для нового приглашения." + default_field = "team" + + +class TeamInviteActiveApplicationConflictError(ApplicationTeamServiceError): + code = "team_invite_active_application_conflict" + default_detail = "Пользователь уже участвует в другой активной заявке этой программы." + default_field = "user_id" + + +class TeamInviteNotOwnedError(ApplicationTeamServiceError): + code = "team_invite_not_owned" + default_detail = "Это приглашение предназначено другому пользователю." + default_field = "invite" + + +@dataclass(frozen=True) +class TeamInviteCreationResult: + """Результат идемпотентного создания приглашения.""" + + invite: TeamInvite + created: bool + + +def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: + """Блокирует Program → Application → Team в общем порядке domain-операций.""" + program_id = team.application.program_id + application_id = team.application_id + + program = PartnerProgram.objects.select_for_update().get(pk=program_id) + application = ( + Application.objects.select_for_update(of=("self",)) + .select_related("program", "user", "created_by", "project") + .get(pk=application_id) + ) + locked_team = ( + Team.objects.select_for_update(of=("self",)) + .select_related("application", "application__program", "captain") + .get(pk=team.pk) + ) + return program, application, locked_team + + +def _lock_invite_graph( + invite: TeamInvite, +) -> tuple[PartnerProgram, Application, Team, TeamInvite]: + """Блокирует граф приглашения в том же порядке, что и операции Team.""" + program, application, team = _lock_team_graph(invite.team) + locked_invite = ( + TeamInvite.objects.select_for_update(of=("self",)) + .select_related("team", "team__application", "user", "invited_by") + .get(pk=invite.pk) + ) + return program, application, team, locked_invite + + +def _require_mutable_invites( + *, + application: Application, + program: PartnerProgram, +) -> None: + """Разрешает приглашения только для draft-заявки до ее дедлайна.""" + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Приглашения можно изменять только в черновике заявки." + ) + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def _require_registered_target(*, program: PartnerProgram, user: User) -> None: + if not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=user, + ).exists(): + raise TeamInviteRegistrationMissingError() + + +def _require_no_target_conflict(*, program: PartnerProgram, user: User) -> None: + try: + require_no_active_conflict(program=program, user=user) + except ActiveApplicationConflictError as exc: + raise TeamInviteActiveApplicationConflictError() from exc + + +def _accepted_members_count(team: Team) -> int: + return ( + TeamMember.objects.select_for_update() + .filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ) + .count() + ) + + +def _pending_invites_count(team: Team) -> int: + return ( + TeamInvite.objects.select_for_update() + .filter( + team=team, + status=TeamInvite.STATUS_PENDING, + ) + .count() + ) + + +def _require_invite_capacity(*, team: Team, program: PartnerProgram) -> None: + maximum = program.team_max_size + if maximum is None or ( + _accepted_members_count(team) + _pending_invites_count(team) >= maximum + ): + raise TeamInviteCapacityReachedError() + + +def _require_accept_capacity(*, team: Team, program: PartnerProgram) -> None: + maximum = program.team_max_size + if maximum is None or _accepted_members_count(team) >= maximum: + raise TeamInviteCapacityReachedError() + + +def get_team_invite_candidates( + *, + team: Team, + actor: User, + query: str, +): + """Возвращает ограниченный queryset потенциальных участников Team. + + Eligibility выражена через Exists-подзапросы: связанные Application, + TeamMember и TeamInvite не размножают строки пользователя и не требуют + широкого `distinct()`. Selector не резервирует место; create service + повторяет проверки под блокировкой Program. + """ + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + + application = team.application + program = application.program + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + raise TeamInviteTargetInvalidError( + "Поиск кандидатов доступен только для командной заявки.", + field="team", + ) + _require_mutable_invites(application=application, program=program) + + accepted_count = TeamMember.objects.filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ).count() + pending_count = TeamInvite.objects.filter( + team=team, + status=TeamInvite.STATUS_PENDING, + ).count() + if ( + program.team_max_size is None + or accepted_count + pending_count >= program.team_max_size + ): + raise TeamInviteCapacityReachedError() + + registrations = PartnerProgramUserProfile.objects.filter( + partner_program=program, + user_id=OuterRef("pk"), + ) + pending_current_invites = TeamInvite.objects.filter( + team=team, + user_id=OuterRef("pk"), + status=TeamInvite.STATUS_PENDING, + ) + active_owned_applications = Application.objects.filter( + program=program, + user_id=OuterRef("pk"), + status__in=Application.ACTIVE_STATUSES, + ) + active_team_memberships = TeamMember.objects.filter( + user_id=OuterRef("pk"), + status=TeamMember.STATUS_ACCEPTED, + team__application__program=program, + team__application__status__in=Application.ACTIVE_STATUSES, + ) + + normalized_query = query.strip() + # SQLite не нормализует регистр кириллицы для ILIKE так же, как production + # PostgreSQL. Небольшой набор Unicode-вариантов сохраняет ORM-фильтрацию и + # одинаковый контракт для обычного lower/UPPER/Title ввода. + query_variants = dict.fromkeys( + ( + normalized_query, + normalized_query.casefold(), + normalized_query.lower(), + normalized_query.upper(), + normalized_query.title(), + normalized_query.capitalize(), + ) + ) + search_filter = Q() + for query_variant in query_variants: + search_filter |= ( + Q(first_name__icontains=query_variant) + | Q(last_name__icontains=query_variant) + | Q(candidate_full_name__icontains=query_variant) + | Q(candidate_reverse_name__icontains=query_variant) + | Q(email__istartswith=query_variant) + ) + + # Минимальная длина query и жесткий limit не позволяют использовать этот + # scoped endpoint как глобальное перечисление пользователей платформы. + return ( + User.objects.annotate( + candidate_full_name=Concat( + "first_name", + Value(" "), + "last_name", + ), + candidate_reverse_name=Concat( + "last_name", + Value(" "), + "first_name", + ), + candidate_is_registered=Exists(registrations), + candidate_has_pending_invite=Exists(pending_current_invites), + candidate_has_active_application=Exists(active_owned_applications), + candidate_has_active_membership=Exists(active_team_memberships), + ) + .filter( + is_active=True, + candidate_is_registered=True, + candidate_has_pending_invite=False, + candidate_has_active_application=False, + candidate_has_active_membership=False, + ) + .exclude(pk=team.captain_id) + .filter(search_filter) + .only("id", "first_name", "last_name", "avatar") + .order_by(Lower("last_name"), Lower("first_name"), "pk")[ + :TEAM_INVITE_CANDIDATE_LIMIT + ] + ) + + +def create_team_invite( + *, + team: Team, + actor: User, + target: User, +) -> TeamInviteCreationResult: + """Создает pending-приглашение без создания предварительного TeamMember. + + Повторный запрос для того же pending-приглашения возвращает существующую + запись. Program блокируется первым, чтобы проверки конфликтов и вместимости + оставались согласованными с параллельными операциями над другими командами. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + _require_mutable_invites(application=application, program=program) + + if ( + application.participation_mode != Application.PARTICIPATION_MODE_TEAM + or target.pk == team.captain_id + or TeamMember.objects.select_for_update() + .filter( + team=team, + user=target, + status=TeamMember.STATUS_ACCEPTED, + ) + .exists() + ): + raise TeamInviteTargetInvalidError() + + existing = ( + TeamInvite.objects.select_for_update() + .filter( + team=team, + user=target, + status=TeamInvite.STATUS_PENDING, + ) + .first() + ) + if existing is not None: + return TeamInviteCreationResult(existing, created=False) + + _require_registered_target(program=program, user=target) + _require_no_target_conflict(program=program, user=target) + _require_invite_capacity(team=team, program=program) + + try: + # Вложенная транзакция сохраняет внешнюю при IntegrityError в гонке. + with transaction.atomic(): + invite = TeamInvite.objects.create( + team=team, + user=target, + invited_by=actor, + ) + except IntegrityError as exc: + invite = TeamInvite.objects.filter( + team=team, + user=target, + status=TeamInvite.STATUS_PENDING, + ).first() + if invite is None: + raise TeamInviteDuplicateError() from exc + return TeamInviteCreationResult(invite, created=False) + + notify_team_invite_created(invite) + return TeamInviteCreationResult(invite, created=True) + + +def accept_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Атомарно принимает приглашение и создает либо восстанавливает membership. + + Принятое приглашение идемпотентно. Только первый переход из pending повторно + проверяет Registration, конфликт участия, вместимость и открытый draft. + """ + with transaction.atomic(): + program, application, team, invite = _lock_invite_graph(invite) + if invite.user_id != actor.pk: + raise TeamInviteNotOwnedError() + if invite.status == TeamInvite.STATUS_ACCEPTED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + _require_registered_target(program=program, user=invite.user) + _require_no_target_conflict(program=program, user=invite.user) + + member = ( + TeamMember.objects.select_for_update() + .filter( + team=team, + user=invite.user, + ) + .first() + ) + if member is not None and member.status == TeamMember.STATUS_ACCEPTED: + raise TeamInviteTargetInvalidError( + "Пользователь уже является участником этой команды." + ) + _require_accept_capacity(team=team, program=program) + + if member is None: + member = TeamMember( + team=team, + user=invite.user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=invite.invited_by, + ) + else: + member.role = TeamMember.ROLE_MEMBER + member.status = TeamMember.STATUS_ACCEPTED + member.invited_by = invite.invited_by + member.save() + + resolved_at = timezone.now() + invite.status = TeamInvite.STATUS_ACCEPTED + invite.resolved_at = resolved_at + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="accepted") + + # Принятие места в одной команде закрывает конкурирующие pending-инвайты + # этого пользователя в рамках той же Program, но сохраняет их историю. + other_invites = ( + TeamInvite.objects.select_for_update() + .filter( + user=invite.user, + status=TeamInvite.STATUS_PENDING, + team__application__program=program, + ) + .exclude(pk=invite.pk) + ) + list(other_invites.values_list("pk", flat=True)) + other_invites.update( + status=TeamInvite.STATUS_REVOKED, + resolved_at=resolved_at, + updated_at=resolved_at, + ) + return invite + + +def decline_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Отклоняет принадлежащее пользователю pending-приглашение.""" + with transaction.atomic(): + program, application, _team, invite = _lock_invite_graph(invite) + if invite.user_id != actor.pk: + raise TeamInviteNotOwnedError() + if invite.status == TeamInvite.STATUS_DECLINED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + invite.status = TeamInvite.STATUS_DECLINED + invite.resolved_at = timezone.now() + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="declined") + return invite + + +def revoke_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Отзывает pending-приглашение капитаном команды или staff.""" + with transaction.atomic(): + program, application, team, invite = _lock_invite_graph(invite) + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + if invite.status == TeamInvite.STATUS_REVOKED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + invite.status = TeamInvite.STATUS_REVOKED + invite.resolved_at = timezone.now() + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="revoked") + return invite diff --git a/partner_programs/services/team_management.py b/partner_programs/services/team_management.py new file mode 100644 index 00000000..cd73b7e0 --- /dev/null +++ b/partner_programs/services/team_management.py @@ -0,0 +1,253 @@ +from django.contrib.auth import get_user_model +from django.db import models, transaction + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamMember, +) +from partner_programs.permissions import can_manage_team, is_team_captain +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + CaptainMemberMissingError, + CaptainMismatchError, + TeamMemberRegistrationMissingError, + require_no_active_conflict, +) + +User = get_user_model() + + +class TeamManagementPermissionError(ApplicationTeamServiceError): + code = "team_permission_denied" + default_detail = "Недостаточно прав для управления командой." + default_field = "team" + + +class CaptainTransferRequiredError(ApplicationTeamServiceError): + code = "captain_transfer_required" + default_detail = "Капитан должен сначала передать капитанство." + default_field = "team" + + +class TeamMembershipNotActiveError(ApplicationTeamServiceError): + code = "team_membership_not_active" + default_detail = "Активное членство в команде не найдено." + default_field = "team" + + +class TeamMemberNotFoundError(ApplicationTeamServiceError): + code = "team_member_not_found" + default_detail = "Участник этой команды не найден." + default_field = "member_id" + + +class TeamMemberNotRemovableError(ApplicationTeamServiceError): + code = "team_member_not_removable" + default_detail = "Участника нельзя удалить в текущем состоянии." + default_field = "member_id" + + +class CaptainTransferTargetInvalidError(ApplicationTeamServiceError): + code = "captain_transfer_target_invalid" + default_detail = "Капитанство можно передать принятому участнику команды." + default_field = "member_id" + + +def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: + """Блокирует Program → Application → Team в едином порядке операций.""" + program_id = team.application.program_id + application_id = team.application_id + team_id = team.pk + + program = PartnerProgram.objects.select_for_update().get(pk=program_id) + application = ( + Application.objects.select_for_update(of=("self",)) + .select_related("program", "user", "created_by", "project") + .get(pk=application_id) + ) + locked_team = ( + Team.objects.select_for_update(of=("self",)) + .select_related("application", "application__program", "captain") + .get(pk=team_id) + ) + return program, application, locked_team + + +def _require_mutable_team( + *, + application: Application, + program: PartnerProgram, +) -> None: + """Разрешает изменение состава только draft-команде до дедлайна.""" + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Состав команды можно менять только в черновике заявки." + ) + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def rename_team(*, team: Team, actor: User, name: str) -> Team: + """Атомарно меняет название draft-команды до дедлайна. + + Операция доступна только текущему капитану и staff. Остальные поля Team + сервис не принимает и не изменяет. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + team.name = name + team.save(update_fields=["name", "updated_at"]) + return team + + +def leave_team(*, team: Team, actor: User) -> TeamMember: + """Фиксирует выход accepted-участника без удаления истории членства. + + Повторный leave возвращает стабильную team_membership_not_active. Капитан + обязан сначала передать роль; joined_at при выходе намеренно сохраняется. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if is_team_captain(actor, team): + raise CaptainTransferRequiredError() + + member = ( + TeamMember.objects.select_for_update().filter(team=team, user=actor).first() + ) + if ( + member is None + or member.role != TeamMember.ROLE_MEMBER + or member.status != TeamMember.STATUS_ACCEPTED + ): + raise TeamMembershipNotActiveError() + + _require_mutable_team(application=application, program=program) + member.status = TeamMember.STATUS_LEFT + member.save(update_fields=["status", "updated_at"]) + return member + + +def remove_team_member(*, team: Team, actor: User, member_id: int) -> TeamMember: + """Переводит обычного accepted/invited-участника в removed. + + Запись и joined_at сохраняются как история состава. Исторические статусы + возвращают стабильную team_member_not_removable вместо удаления строки. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + member = ( + TeamMember.objects.select_for_update().filter(team=team, pk=member_id).first() + ) + if member is None: + raise TeamMemberNotFoundError() + if member.role == TeamMember.ROLE_CAPTAIN or member.user_id == team.captain_id: + raise CaptainTransferRequiredError( + "Капитана нельзя удалить из команды без передачи капитанства." + ) + if member.status not in { + TeamMember.STATUS_ACCEPTED, + TeamMember.STATUS_INVITED, + }: + raise TeamMemberNotRemovableError() + + member.status = TeamMember.STATUS_REMOVED + member.save(update_fields=["status", "updated_at"]) + return member + + +def transfer_team_captain( + *, + team: Team, + actor: User, + member_id: int, +) -> Team: + """Атомарно передает капитанство accepted-участнику draft-команды. + + После общей блокировки Program блокируются Application, Team и обе записи + TeamMember. Старый капитан сначала становится member, чтобы условная + уникальность капитана не нарушилась в промежуточном состоянии. Затем вместе + обновляются Team.captain и Application.user; Application.created_by и Project + намеренно остаются без изменений. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + locked_members = list( + TeamMember.objects.select_for_update() + .select_related("user") + .filter(team=team) + .filter( + models.Q(pk=member_id) + | models.Q( + user_id=team.captain_id, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ) + .order_by("pk") + ) + target = next((item for item in locked_members if item.pk == member_id), None) + captain_members = [ + item + for item in locked_members + if item.user_id == team.captain_id + and item.role == TeamMember.ROLE_CAPTAIN + and item.status == TeamMember.STATUS_ACCEPTED + ] + + if team.captain_id != application.user_id: + raise CaptainMismatchError() + if len(captain_members) != 1: + raise CaptainMemberMissingError() + if target is None: + raise TeamMemberNotFoundError() + if ( + target.role != TeamMember.ROLE_MEMBER + or target.status != TeamMember.STATUS_ACCEPTED + ): + raise CaptainTransferTargetInvalidError() + if not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=target.user, + ).exists(): + raise TeamMemberRegistrationMissingError( + "Новый капитан должен быть зарегистрирован на активность." + ) + + require_no_active_conflict( + program=program, + user=target.user, + exclude_application=application, + ) + + old_captain_member = captain_members[0] + old_captain_member.role = TeamMember.ROLE_MEMBER + old_captain_member.save(update_fields=["role", "updated_at"]) + + application.user = target.user + application.save(update_fields=["user", "updated_at"]) + + team.application = application + team.captain = target.user + team.save(update_fields=["captain", "updated_at"]) + + target.team = team + target.role = TeamMember.ROLE_CAPTAIN + target.save(update_fields=["role", "updated_at"]) + return team diff --git a/partner_programs/submission_assignment_views.py b/partner_programs/submission_assignment_views.py new file mode 100644 index 00000000..2e9bae44 --- /dev/null +++ b/partner_programs/submission_assignment_views.py @@ -0,0 +1,156 @@ +from django.db.models import OuterRef, Subquery +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.models import ( + Evaluation, + PartnerProgram, + SubmissionExpertAssignment, +) +from partner_programs.pagination import PartnerProgramPagination +from partner_programs.permissions import IsAdminOrManagerOfProgram +from partner_programs.serializers.submission_assignments import ( + SubmissionAssignmentCreateSerializer, + SubmissionAssignmentFilterSerializer, + SubmissionAssignmentReadSerializer, + SubmissionAssignmentRevokeSerializer, +) +from partner_programs.services.submission_assignments import ( + SubmissionAssignmentConflictError, + SubmissionAssignmentServiceError, + SubmissionAssignmentValidationError, + create_submission_assignment, + revoke_submission_assignment, +) + + +def _assignment_queryset(): + evaluation_status = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ).values("status")[:1] + evaluation = Evaluation.objects.filter( + submission_id=OuterRef("submission_id"), + expert_id=OuterRef("expert_id"), + ) + return SubmissionExpertAssignment.objects.select_related( + "submission", + "expert", + "expert__user", + "assigned_by", + "revoked_by", + ).annotate( + annotated_evaluation_status=Subquery(evaluation_status), + annotated_evaluation_id=Subquery(evaluation.values("id")[:1]), + annotated_evaluation_updated_at=Subquery(evaluation.values("updated_at")[:1]), + annotated_evaluation_submitted_at=Subquery(evaluation.values("submitted_at")[:1]), + annotated_evaluation_total_score=Subquery(evaluation.values("total_score")[:1]), + ) + + +def _domain_error_response(exc): + if isinstance(exc, SubmissionAssignmentValidationError): + return Response( + {exc.field: [exc.detail]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, SubmissionAssignmentConflictError): + return Response( + { + "detail": exc.detail, + "code": exc.code, + }, + status=status.HTTP_409_CONFLICT, + ) + raise exc + + +class ProgramPermissionMixin: + def check_permissions(self, request): + program_id = self.kwargs.get("program_id") + if program_id is None and getattr(self, "swagger_fake_view", False): + return super().check_permissions(request) + self.program = get_object_or_404( + PartnerProgram, + pk=program_id, + ) + return super().check_permissions(request) + + +class ProgramSubmissionAssignmentListCreateView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "submission_assignment_create" + pagination_class = PartnerProgramPagination + + def get(self, request, program_id): + filter_serializer = SubmissionAssignmentFilterSerializer( + data=request.query_params + ) + filter_serializer.is_valid(raise_exception=True) + + queryset = _assignment_queryset().filter( + submission__program_id=self.program.pk, + ) + filters = filter_serializer.validated_data + if "submission_id" in filters: + queryset = queryset.filter(submission_id=filters["submission_id"]) + if "expert_id" in filters: + queryset = queryset.filter(expert_id=filters["expert_id"]) + if "status" in filters: + queryset = queryset.filter(status=filters["status"]) + queryset = queryset.order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = SubmissionAssignmentReadSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request, program_id): + serializer = SubmissionAssignmentCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = create_submission_assignment( + program=self.program, + submission_id=serializer.validated_data["submission_id"], + expert_id=serializer.validated_data["expert_id"], + actor=request.user, + ) + except SubmissionAssignmentServiceError as exc: + return _domain_error_response(exc) + + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + SubmissionAssignmentReadSerializer(result.assignment).data, + status=response_status, + ) + + +class SubmissionAssignmentRevokeView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, user, assignment_id): + queryset = _assignment_queryset() + if not (user.is_staff or user.is_superuser): + queryset = queryset.filter(submission__program__managers=user) + return get_object_or_404(queryset, pk=assignment_id) + + def post(self, request, assignment_id): + assignment = self.get_object(request.user, assignment_id) + serializer = SubmissionAssignmentRevokeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + assignment = revoke_submission_assignment( + assignment=assignment, + actor=request.user, + reason=serializer.validated_data["reason"], + ) + except SubmissionAssignmentServiceError as exc: + return _domain_error_response(exc) + return Response(SubmissionAssignmentReadSerializer(assignment).data) diff --git a/partner_programs/submission_project_views.py b/partner_programs/submission_project_views.py new file mode 100644 index 00000000..bd557c68 --- /dev/null +++ b/partner_programs/submission_project_views.py @@ -0,0 +1,55 @@ +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import Submission +from partner_programs.services.submission_project import ( + SubmissionProjectAccessError, + SubmissionProjectStatusError, + create_project_from_submission, +) +from projects.workspace_selectors import get_workspace_project_queryset +from projects.workspace_serializers import ProjectWorkspaceDetailSerializer + + +class SubmissionProjectCreateView(APIView): + """Создает постоянный Project из отправленной версии решения.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, submission_id): + visible_submissions = Submission.objects.select_related("application") + if not (request.user.is_staff or request.user.is_superuser): + # Accepted TeamMember может читать Submission, но постоянный Project + # создает только владелец Application (капитан по текущему invariant). + visible_submissions = visible_submissions.filter( + Q(application__user=request.user) + ) + submission = get_object_or_404(visible_submissions, pk=submission_id) + + try: + result = create_project_from_submission( + submission_id=submission.pk, + actor=request.user, + ) + except SubmissionProjectAccessError as exc: + raise NotFound("Решение не найдено.") from exc + except SubmissionProjectStatusError as exc: + raise ValidationError({"status": str(exc)}) from exc + + project = get_object_or_404( + get_workspace_project_queryset(user=request.user), + pk=result.project.pk, + ) + serializer = ProjectWorkspaceDetailSerializer( + project, + context={"request": request}, + ) + return Response( + {"created": result.created, "project": serializer.data}, + status=(status.HTTP_201_CREATED if result.created else status.HTTP_200_OK), + ) diff --git a/partner_programs/submission_urls.py b/partner_programs/submission_urls.py new file mode 100644 index 00000000..407b76e3 --- /dev/null +++ b/partner_programs/submission_urls.py @@ -0,0 +1,40 @@ +from django.urls import path + +from partner_programs.evaluation_views import EvaluationCreateView, MyEvaluationView +from partner_programs.submission_views import ( + SubmissionCancelView, + SubmissionDetailView, + SubmissionSubmitView, +) +from partner_programs.submission_project_views import SubmissionProjectCreateView + +app_name = "submissions" + +urlpatterns = [ + path( + "/project/", + SubmissionProjectCreateView.as_view(), + name="project-create", + ), + path( + "/evaluations/my/", + MyEvaluationView.as_view(), + name="my-evaluation", + ), + path( + "/evaluations/", + EvaluationCreateView.as_view(), + name="evaluation-create", + ), + path("/", SubmissionDetailView.as_view(), name="detail"), + path( + "/submit/", + SubmissionSubmitView.as_view(), + name="submit", + ), + path( + "/cancel/", + SubmissionCancelView.as_view(), + name="cancel", + ), +] diff --git a/partner_programs/submission_views.py b/partner_programs/submission_views.py new file mode 100644 index 00000000..09100da0 --- /dev/null +++ b/partner_programs/submission_views.py @@ -0,0 +1,288 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.db.models import Max, Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from notifications.events import ( + notify_submission_status_changed, + notify_submission_submitted, +) +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.models import Application, Submission, TeamMember +from partner_programs.permissions import can_edit_application, can_edit_submission +from partner_programs.serializers import SubmissionSerializer + + +def _application_queryset_for(user): + queryset = Application.objects.select_related("program", "user", "created_by") + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter( + Q(user=user) + | Q( + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() + + +def _submission_queryset_for(user): + queryset = Submission.objects.select_related( + "application", + "application__user", + "application__created_by", + "program", + "submitted_by", + ) + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter( + Q(application__user=user) + | Q( + application__team__members__user=user, + application__team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() + + +def _submission_response(submission, response_status=status.HTTP_200_OK): + return Response( + SubmissionSerializer(submission).data, + status=response_status, + ) + + +class ApplicationSubmissionListCreateView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "submission_create" + + def get_application(self, request, application_id): + return get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + + def get(self, request, application_id): + application = self.get_application(request, application_id) + submissions = ( + Submission.objects.select_related("application", "program", "submitted_by") + .filter(application=application) + .order_by("-created_at", "-id") + ) + return Response(SubmissionSerializer(submissions, many=True).data) + + def post(self, request, application_id): + application = self.get_application(request, application_id) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can create submissions." + ) + if application.status not in ( + Application.STATUS_SUBMITTED, + Application.STATUS_APPROVED, + ): + raise ValidationError( + { + "application": ( + "Submissions require a submitted or approved application." + ) + } + ) + + serializer = SubmissionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + submission_data = dict(serializer.validated_data) + stage_key = submission_data.pop("stage_key", "main") + validated_version = submission_data.pop("version", None) + requested_version = validated_version if "version" in request.data else None + version = requested_version + + try: + with transaction.atomic(): + locked_application = get_object_or_404( + Application.objects.select_for_update(), + pk=application.pk, + ) + if not can_edit_application(request.user, locked_application): + raise PermissionDenied( + "Only the application owner or staff can create submissions." + ) + if locked_application.status not in ( + Application.STATUS_SUBMITTED, + Application.STATUS_APPROVED, + ): + raise ValidationError( + { + "application": ( + "Submissions require a submitted or approved application." + ) + } + ) + if version is None: + latest_version = ( + Submission.objects.filter( + application=locked_application, + stage_key=stage_key, + ).aggregate(max_version=Max("version"))["max_version"] + or 0 + ) + version = latest_version + 1 + + submission = Submission.objects.create( + application=locked_application, + program=locked_application.program, + submitted_by=request.user, + stage_key=stage_key, + version=version, + **submission_data, + ) + except (DjangoValidationError, IntegrityError) as exc: + if Submission.objects.filter( + application=application, + stage_key=stage_key, + version=version, + ).exists(): + raise ValidationError( + { + "version": ( + "A submission with this stage and version already exists." + ) + } + ) from exc + if isinstance(exc, DjangoValidationError): + raise ValidationError(exc.message_dict) from exc + raise + + return _submission_response(submission, status.HTTP_201_CREATED) + + +class SubmissionDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, request, submission_id): + return get_object_or_404( + _submission_queryset_for(request.user), + pk=submission_id, + ) + + def get(self, request, submission_id): + return _submission_response(self.get_object(request, submission_id)) + + def patch(self, request, submission_id): + visible_submission = self.get_object(request, submission_id) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can update submissions." + ) + with transaction.atomic(): + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can update submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application + if not submission.can_edit: + raise ValidationError( + {"status": "Only draft or returned submissions can be updated."} + ) + + serializer = SubmissionSerializer( + submission, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + try: + serializer.save() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return Response(serializer.data) + + +class SubmissionSubmitView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, submission_id): + with transaction.atomic(): + visible_submission = get_object_or_404( + _submission_queryset_for(request.user), + pk=submission_id, + ) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can submit submissions." + ) + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can submit submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application + if submission.status == Submission.STATUS_SUBMITTED: + return _submission_response(submission) + if not submission.can_submit: + raise ValidationError( + {"status": "Only draft or returned submissions can be submitted."} + ) + + submission.status = Submission.STATUS_SUBMITTED + submission.submitted_at = timezone.now() + submission.save(update_fields=["status", "submitted_at", "updated_at"]) + notify_submission_submitted(submission, actor=request.user) + return _submission_response(submission) + + +class SubmissionCancelView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, submission_id): + with transaction.atomic(): + visible_submission = get_object_or_404( + _submission_queryset_for(request.user), + pk=submission_id, + ) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can cancel submissions." + ) + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can cancel submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application + if submission.status == Submission.STATUS_CANCELLED: + return _submission_response(submission) + if not submission.can_edit: + raise ValidationError( + {"status": "Only draft or returned submissions can be cancelled."} + ) + + submission.status = Submission.STATUS_CANCELLED + submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=request.user) + return _submission_response(submission) diff --git a/partner_programs/team_invite_urls.py b/partner_programs/team_invite_urls.py new file mode 100644 index 00000000..e165fa50 --- /dev/null +++ b/partner_programs/team_invite_urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from partner_programs.team_invite_views import ( + MyTeamInviteListView, + TeamInviteAcceptView, + TeamInviteDeclineView, + TeamInviteRevokeView, +) + +app_name = "team_invites" + +urlpatterns = [ + path("my/", MyTeamInviteListView.as_view(), name="my-list"), + path("/accept/", TeamInviteAcceptView.as_view(), name="accept"), + path( + "/decline/", + TeamInviteDeclineView.as_view(), + name="decline", + ), + path("/revoke/", TeamInviteRevokeView.as_view(), name="revoke"), +] diff --git a/partner_programs/team_invite_views.py b/partner_programs/team_invite_views.py new file mode 100644 index 00000000..7219b2ed --- /dev/null +++ b/partner_programs/team_invite_views.py @@ -0,0 +1,240 @@ +from django.contrib.auth import get_user_model +from django.db.models import Case, IntegerField, Value, When +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import Team, TeamInvite +from partner_programs.permissions import can_manage_team, can_view_team +from partner_programs.serializers import ( + MyTeamInviteSerializer, + TeamInviteCandidateQuerySerializer, + TeamInviteCreateSerializer, + TeamInviteSerializer, + TeamUserSerializer, +) +from partner_programs.services.application_team import ApplicationTeamServiceError +from partner_programs.services.team_invites import ( + TeamInviteNotOwnedError, + TeamInvitePermissionError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + get_team_invite_candidates, + revoke_team_invite, +) +from partner_programs.throttling import ( + TeamInviteCandidateSearchScopedRateThrottle, + TeamInviteMutationScopedRateThrottle, +) + +User = get_user_model() + + +def _team_queryset(): + return Team.objects.select_related( + "application", + "application__program", + "application__user", + "captain", + ) + + +def _invite_queryset(): + return TeamInvite.objects.select_related( + "team", + "team__application", + "team__application__program", + "team__captain", + "user", + "invited_by", + ) + + +def _get_team(application_id: int) -> Team: + return get_object_or_404(_team_queryset(), application_id=application_id) + + +def _get_invite(invite_id: int) -> TeamInvite: + return get_object_or_404(_invite_queryset(), pk=invite_id) + + +def _raise_domain_error(exc: ApplicationTeamServiceError): + if isinstance(exc, (TeamInvitePermissionError, TeamInviteNotOwnedError)): + raise PermissionDenied(exc.detail, code=exc.code) from exc + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + +def _require_invite_manager(user, team: Team) -> None: + # Незнакомому пользователю скрываем существование Team; участник и manager + # знают команду, но получают явный запрет на закрытый список приглашений. + if not can_view_team(user, team): + raise NotFound("Team not found.") + if not can_manage_team(user, team): + raise PermissionDenied( + TeamInvitePermissionError.default_detail, + code=TeamInvitePermissionError.code, + ) + + +class TeamInviteListCreateView(APIView): + """Возвращает историю приглашений и создает pending для капитана/staff.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + invites = _invite_queryset().filter(team=team).order_by("-created_at") + return Response(TeamInviteSerializer(invites, many=True).data) + + def post(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + serializer = TeamInviteCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + target = User.objects.filter(pk=serializer.validated_data["user_id"]).first() + if target is None: + _raise_domain_error(TeamInviteTargetInvalidError()) + + try: + result = create_team_invite( + team=team, + actor=request.user, + target=target, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + TeamInviteSerializer(result.invite).data, + status=response_status, + ) + + def get_throttles(self): + if self.request.method == "POST": + self.throttle_scope = "team_invite_create" + return [TeamInviteMutationScopedRateThrottle()] + return [] + + +class TeamInviteCandidateSearchView(APIView): + """Ищет безопасные профили кандидатов только внутри управляемой Team.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteCandidateSearchScopedRateThrottle] + throttle_scope = "team_invite_candidate_search" + + def get(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + query_serializer = TeamInviteCandidateQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + try: + candidates = get_team_invite_candidates( + team=team, + actor=request.user, + query=query_serializer.validated_data["q"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response( + TeamUserSerializer( + candidates, + many=True, + context={"request": request}, + ).data + ) + + +class MyTeamInviteListView(APIView): + """Возвращает только приглашения текущего пользователя: pending первыми.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + invites = ( + _invite_queryset() + .filter(user=request.user) + .annotate( + pending_order=Case( + When(status=TeamInvite.STATUS_PENDING, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("pending_order", "-created_at") + ) + return Response(MyTeamInviteSerializer(invites, many=True).data) + + +class TeamInviteAcceptView(APIView): + """Принимает приглашение адресатом через атомарный domain service.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_accept" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + if invite.user_id != request.user.pk: + if can_manage_team(request.user, invite.team): + raise PermissionDenied( + TeamInviteNotOwnedError.default_detail, + code=TeamInviteNotOwnedError.code, + ) + raise NotFound("Team invite not found.") + try: + invite = accept_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(MyTeamInviteSerializer(invite).data) + + +class TeamInviteDeclineView(APIView): + """Отклоняет приглашение адресатом без создания TeamMember.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_decline" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + if invite.user_id != request.user.pk: + if can_manage_team(request.user, invite.team): + raise PermissionDenied( + TeamInviteNotOwnedError.default_detail, + code=TeamInviteNotOwnedError.code, + ) + raise NotFound("Team invite not found.") + try: + invite = decline_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(MyTeamInviteSerializer(invite).data) + + +class TeamInviteRevokeView(APIView): + """Отзывает приглашение капитаном команды или staff.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_revoke" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + _require_invite_manager(request.user, invite.team) + try: + invite = revoke_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamInviteSerializer(invite).data) diff --git a/partner_programs/team_views.py b/partner_programs/team_views.py new file mode 100644 index 00000000..cb46c73e --- /dev/null +++ b/partner_programs/team_views.py @@ -0,0 +1,172 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import Team, TeamMember +from partner_programs.permissions import can_manage_team, can_view_team +from partner_programs.serializers import ( + TeamMemberSerializer, + TeamSerializer, + TeamTransferCaptainSerializer, + TeamUpdateSerializer, +) +from partner_programs.services.application_team import ApplicationTeamServiceError +from partner_programs.services.team_management import ( + TeamManagementPermissionError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) +from partner_programs.throttling import TeamMutationScopedRateThrottle + + +def _team_queryset(): + return Team.objects.select_related( + "application", + "application__program", + "application__user", + "captain", + ).prefetch_related("members__user") + + +def _get_team(application_id: int) -> Team: + return get_object_or_404(_team_queryset(), application_id=application_id) + + +def _team_response(team: Team, request) -> Response: + team = _team_queryset().get(pk=team.pk) + return Response(TeamSerializer(team, context={"request": request}).data) + + +def _raise_domain_error(exc: ApplicationTeamServiceError): + if isinstance(exc, TeamManagementPermissionError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + +class TeamDetailView(APIView): + """Возвращает Team и позволяет капитану/staff изменить только название.""" + permission_classes = [IsAuthenticated] + + def get(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + return _team_response(team, request) + + def patch(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + + serializer = TeamUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + team = rename_team( + team=team, + actor=request.user, + name=serializer.validated_data["name"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _team_response(team, request) + + def get_throttles(self): + if self.request.method == "PATCH": + self.throttle_scope = "team_rename" + return [TeamMutationScopedRateThrottle()] + return [] + + +class TeamLeaveView(APIView): + """Фиксирует самостоятельный выход обычного accepted-участника.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_leave" + + def post(self, request, application_id): + team = _get_team(application_id) + has_membership_history = TeamMember.objects.filter( + team=team, + user=request.user, + ).exists() + if not can_view_team(request.user, team) and not has_membership_history: + raise NotFound("Team not found.") + if not has_membership_history: + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + try: + member = leave_team(team=team, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamMemberSerializer(member).data, status=status.HTTP_200_OK) + + +class TeamMemberRemoveView(APIView): + """Фиксирует исключение участника капитаном или staff без удаления строки.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_remove_member" + + def post(self, request, application_id, member_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + try: + member = remove_team_member( + team=team, + actor=request.user, + member_id=member_id, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamMemberSerializer(member).data, status=status.HTTP_200_OK) + + +class TeamTransferCaptainView(APIView): + """Передает капитанство через атомарный domain service.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_transfer_captain" + + def post(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + serializer = TeamTransferCaptainSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + team = transfer_team_captain( + team=team, + actor=request.user, + member_id=serializer.validated_data["member_id"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _team_response(team, request) diff --git a/partner_programs/tests/test_application_api.py b/partner_programs/tests/test_application_api.py new file mode 100644 index 00000000..0383c2f2 --- /dev/null +++ b/partner_programs/tests/test_application_api.py @@ -0,0 +1,525 @@ +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, + Team, + TeamMember, +) +from notifications.models import Notification +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) +from projects.models import Project + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class ApplicationAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user(prefix="application-api-owner") + self.other_user = create_user(prefix="application-api-other") + self.staff_user = create_user(prefix="application-api-staff", is_staff=True) + self.program = create_partner_program() + self.registration = create_program_member(self.program, user=self.user) + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.user) + + def create_application(self, *, user=None, status=Application.STATUS_DRAFT, **data): + owner = user or self.user + return Application.objects.create( + program=self.program, + user=owner, + created_by=owner, + status=status, + **data, + ) + + def test_unauthenticated_user_cannot_create_application(self): + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 401) + self.assertFalse(Application.objects.exists()) + + def test_authenticated_user_can_create_draft_application(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"form_data": {"motivation": "Build a useful project"}}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + application = Application.objects.get() + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.program, self.program) + self.assertEqual(application.user, self.user) + self.assertEqual(application.created_by, self.user) + self.assertEqual( + application.form_data, + {"motivation": "Build a useful project"}, + ) + self.assertIsNone(application.project) + self.assertEqual(response.data["id"], application.id) + self.assertEqual(response.data["project"], None) + self.assertEqual( + response.data["participation_mode"], + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertEqual(PartnerProgramUserProfile.objects.count(), 1) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).exists() + ) + self.assertFalse(PartnerProgramProject.objects.exists()) + self.assertFalse(Project.objects.exists()) + + def test_application_create_requires_program_registration(self): + unregistered_user = create_user(prefix="application-api-unregistered") + self.authenticate(unregistered_user) + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("registration", response.data) + self.assertFalse(Application.objects.exists()) + + def test_team_application_can_be_created_through_extended_request(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "team_name": "API team", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + application = Application.objects.get() + self.assertEqual( + response.data["participation_mode"], + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertNotIn("team_name", response.data) + self.assertEqual(application.team.name, "API team") + self.assertTrue( + application.team.members.filter( + user=self.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + ) + + def test_my_application_returns_current_users_application(self): + application = self.create_application() + self.authenticate() + + response = self.client.get(f"/programs/{self.program.id}/applications/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + self.assertEqual(response.data["user"], self.user.id) + + def test_my_application_does_not_return_another_users_application(self): + self.create_application(user=self.other_user) + self.authenticate() + + response = self.client.get(f"/programs/{self.program.id}/applications/my/") + + self.assertEqual(response.status_code, 404) + + def test_owner_can_read_application_detail(self): + application = self.create_application() + self.authenticate() + + response = self.client.get(f"/applications/{application.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + + def test_repeated_create_returns_existing_active_application(self): + self.authenticate() + url = f"/programs/{self.program.id}/applications/" + + first_response = self.client.post( + url, + {"form_data": {"version": 1}}, + format="json", + ) + second_response = self.client.post( + url, + {"form_data": {"version": 1}}, + format="json", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.data["id"], second_response.data["id"]) + self.assertEqual(Application.objects.count(), 1) + self.assertEqual(Application.objects.get().form_data, {"version": 1}) + + def test_patch_draft_updates_form_data(self): + application = self.create_application(form_data={"version": 1}) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + {"form_data": {"version": 2}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.form_data, {"version": 2}) + + def test_patch_participation_mode_uses_team_service(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + application = self.create_application() + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "team_name": "Patched team", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertEqual(Team.objects.get(application=application).name, "Patched team") + + def test_patch_rolls_back_other_fields_when_mode_change_fails(self): + application = self.create_application(form_data={"version": 1}) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "form_data": {"version": 2}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertEqual(application.form_data, {"version": 1}) + self.assertFalse(Team.objects.filter(application=application).exists()) + + def test_patch_rejects_immutable_application_fields(self): + application = self.create_application(form_data={"version": 1}) + other_program = create_partner_program() + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "program": other_program.id, + "user": self.other_user.id, + "created_by": self.other_user.id, + "status": Application.STATUS_APPROVED, + "form_data": {"version": 2}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.program, self.program) + self.assertEqual(application.user, self.user) + self.assertEqual(application.created_by, self.user) + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.form_data, {"version": 1}) + + def test_patch_submitted_application_is_rejected(self): + application = self.create_application( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + {"form_data": {"changed": True}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.form_data, {}) + + def test_submit_transitions_draft_and_sets_submitted_at(self): + application = self.create_application() + self.program.managers.add(self.other_user) + self.authenticate() + + response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_SUBMITTED) + self.assertIsNotNone(application.submitted_at) + self.assertEqual(response.data["status"], Application.STATUS_SUBMITTED) + self.assertTrue( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.APPLICATION_SUBMITTED, + ).exists() + ) + + def test_repeated_submit_preserves_submitted_at(self): + application = self.create_application() + self.program.managers.add(self.other_user) + self.authenticate() + url = f"/applications/{application.id}/submit/" + + first_response = self.client.post(url, {}, format="json") + application.refresh_from_db() + first_submitted_at = application.submitted_at + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.submitted_at, first_submitted_at) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.APPLICATION_SUBMITTED, + ).count(), + 1, + ) + + def test_withdraw_transitions_application_and_is_idempotent(self): + application = self.create_application( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + url = f"/applications/{application.id}/withdraw/" + + first_response = self.client.post(url, {}, format="json") + application.refresh_from_db() + first_withdrawn_at = application.withdrawn_at + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_WITHDRAWN) + self.assertIsNotNone(application.withdrawn_at) + self.assertEqual(application.withdrawn_at, first_withdrawn_at) + + def test_rejected_or_cancelled_application_cannot_be_withdrawn(self): + self.authenticate() + + for application_status in ( + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + application = self.create_application(status=application_status) + + response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.status, application_status) + + def test_user_can_create_new_application_after_withdrawal(self): + withdrawn_application = self.create_application( + status=Application.STATUS_WITHDRAWN, + withdrawn_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertNotEqual(response.data["id"], withdrawn_application.id) + self.assertEqual(Application.objects.count(), 2) + + def test_user_cannot_read_or_change_another_users_application(self): + application = self.create_application(user=self.other_user) + self.authenticate() + detail_url = f"/applications/{application.id}/" + + get_response = self.client.get(detail_url) + patch_response = self.client.patch( + detail_url, + {"form_data": {"changed": True}}, + format="json", + ) + submit_response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + withdraw_response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(get_response.status_code, 404) + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(submit_response.status_code, 404) + self.assertEqual(withdraw_response.status_code, 404) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.form_data, {}) + + def test_staff_can_read_application(self): + application = self.create_application() + self.authenticate(self.staff_user) + + response = self.client.get(f"/applications/{application.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + + def test_staff_can_submit_and_withdraw_application(self): + application = self.create_application() + self.authenticate(self.staff_user) + + submit_response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + withdraw_response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(submit_response.status_code, 200) + self.assertEqual(withdraw_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_WITHDRAWN) + self.assertTrue( + Notification.objects.filter( + recipient=self.user, + type=Notification.Type.APPLICATION_STATUS_CHANGED, + ).exists() + ) + + def test_project_can_be_null(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": None}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["project"], None) + self.assertIsNone(Application.objects.get().project) + + def test_user_can_attach_project_they_lead(self): + project = create_project(leader=self.user) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": project.id}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["project"], project.id) + self.assertEqual(Application.objects.get().project, project) + + def test_user_cannot_attach_another_users_project(self): + project = create_project(leader=self.other_user) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": project.id}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(Application.objects.exists()) + + @override_settings(REST_FRAMEWORK=throttle_settings(application_create="1/min")) + def test_application_create_is_scoped_throttled(self): + self.authenticate() + url = f"/programs/{self.program.id}/applications/" + + first_response = self.client.post( + url, + {}, + format="json", + REMOTE_ADDR="203.0.113.30", + ) + second_response = self.client.post( + url, + {}, + format="json", + REMOTE_ADDR="203.0.113.30", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) diff --git a/partner_programs/tests/test_application_model.py b/partner_programs/tests/test_application_model.py new file mode 100644 index 00000000..46547d52 --- /dev/null +++ b/partner_programs/tests/test_application_model.py @@ -0,0 +1,155 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from partner_programs.models import Application +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) + + +class ApplicationModelTests(TestCase): + def test_can_create_draft_application_for_user_and_program(self): + user = create_user(prefix="application-user") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.program, program) + self.assertEqual(application.user, user) + self.assertEqual(application.created_by, user) + + def test_cannot_create_second_active_application_for_same_user_and_program(self): + user = create_user(prefix="application-active") + program = create_partner_program() + Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_DRAFT, + ) + + with self.assertRaises(ValidationError) as error: + Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_SUBMITTED, + ) + + self.assertIn("user", error.exception.message_dict) + + def test_can_create_new_application_after_inactive_status(self): + for inactive_status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + user = create_user(prefix=f"application-{inactive_status}") + program = create_partner_program() + Application.objects.create( + program=program, + user=user, + created_by=user, + status=inactive_status, + ) + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_DRAFT, + ) + + self.assertEqual(application.status, Application.STATUS_DRAFT) + + def test_created_by_is_required(self): + user = create_user(prefix="application-created-by") + program = create_partner_program() + application = Application( + program=program, + user=user, + ) + + with self.assertRaises(ValidationError) as error: + application.full_clean() + + self.assertIn("created_by", error.exception.message_dict) + + def test_form_data_defaults_to_dict(self): + user = create_user(prefix="application-form-data") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertEqual(application.form_data, {}) + self.assertIsInstance(application.form_data, dict) + + def test_project_and_status_timestamps_can_be_null(self): + user = create_user(prefix="application-nullable") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertIsNone(application.project) + self.assertIsNone(application.submitted_at) + self.assertIsNone(application.approved_at) + self.assertIsNone(application.rejected_at) + self.assertIsNone(application.withdrawn_at) + + def test_project_can_be_attached(self): + user = create_user(prefix="application-project") + program = create_partner_program() + project = create_project(leader=user) + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=project, + ) + + self.assertEqual(application.project, project) + + def test_status_choices_accept_expected_values(self): + for status, _label in Application.STATUS_CHOICES: + user = create_user(prefix=f"application-{status}") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + status=status, + ) + + self.assertEqual(application.status, status) + + def test_status_choices_reject_unknown_value(self): + user = create_user(prefix="application-invalid-status") + program = create_partner_program() + application = Application( + program=program, + user=user, + created_by=user, + status="unknown", + ) + + with self.assertRaises(ValidationError) as error: + application.full_clean() + + self.assertIn("status", error.exception.message_dict) diff --git a/partner_programs/tests/test_application_team_models.py b/partner_programs/tests/test_application_team_models.py new file mode 100644 index 00000000..c3ee8ff8 --- /dev/null +++ b/partner_programs/tests/test_application_team_models.py @@ -0,0 +1,336 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, Team, TeamMember +from partner_programs.tests.helpers import create_partner_program, create_user + + +class ApplicationTeamModelTests(TestCase): + def setUp(self): + self.program = create_partner_program() + self.captain = create_user(prefix="team-captain") + + def create_application(self, *, participation_mode=None, user=None): + owner = user or self.captain + data = { + "program": self.program, + "user": owner, + "created_by": owner, + } + if participation_mode is not None: + data["participation_mode"] = participation_mode + return Application.objects.create(**data) + + def create_team(self, *, application=None, captain=None, name="Team"): + return Team.objects.create( + application=application + or self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ), + captain=captain or self.captain, + name=name, + ) + + def test_participation_mode_defaults_to_individual(self): + application = self.create_application() + + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + def test_participation_mode_choices_contain_expected_values(self): + self.assertEqual( + {value for value, _label in Application.PARTICIPATION_MODE_CHOICES}, + {"undecided", "individual", "team"}, + ) + + def test_existing_individual_application_flow_still_works(self): + application = self.create_application() + + self.assertEqual(application.user, self.captain) + self.assertEqual(application.created_by, self.captain) + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertFalse(hasattr(application, "team")) + + def test_team_can_be_created_for_team_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + team = self.create_team(application=application) + + self.assertEqual(team.application, application) + self.assertEqual(team.captain, self.captain) + + def test_team_cannot_be_created_for_individual_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL + ) + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application) + + self.assertIn("application", error.exception.message_dict) + + def test_team_cannot_be_created_for_undecided_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ) + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application) + + self.assertIn("application", error.exception.message_dict) + + def test_team_captain_must_match_application_user(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + another_user = create_user(prefix="another-captain") + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application, captain=another_user) + + self.assertIn("captain", error.exception.message_dict) + + def test_application_can_have_only_one_team(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + self.create_team(application=application) + + with self.assertRaises(ValidationError): + self.create_team(application=application, name="Another team") + + def test_accepted_captain_member_can_be_created(self): + team = self.create_team() + + member = TeamMember.objects.create( + team=team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + + self.assertEqual(member.user, team.captain) + self.assertIsNone(member.invited_by) + self.assertIsNotNone(member.joined_at) + + def test_captain_member_must_match_team_captain(self): + team = self.create_team() + another_user = create_user(prefix="captain-member") + + with self.assertRaises(ValidationError) as error: + TeamMember.objects.create( + team=team, + user=another_user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + + self.assertIn("user", error.exception.message_dict) + + def test_captain_member_must_be_accepted(self): + team = self.create_team() + + for member_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + with self.subTest(status=member_status): + with self.assertRaises(ValidationError) as error: + TeamMember.objects.create( + team=team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=member_status, + ) + + self.assertIn("status", error.exception.message_dict) + + def test_user_cannot_have_two_memberships_in_same_team(self): + team = self.create_team() + user = create_user(prefix="duplicate-member") + TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + with self.assertRaises(ValidationError): + TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + + def test_regular_member_can_be_invited(self): + team = self.create_team() + user = create_user(prefix="invited-member") + + member = TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + invited_by=self.captain, + ) + + self.assertEqual(member.status, TeamMember.STATUS_INVITED) + self.assertEqual(member.invited_by, self.captain) + self.assertIsNone(member.joined_at) + + def test_regular_member_can_become_accepted(self): + team = self.create_team() + user = create_user(prefix="accepted-member") + member = TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + member.status = TeamMember.STATUS_ACCEPTED + member.save() + member.refresh_from_db() + + self.assertEqual(member.status, TeamMember.STATUS_ACCEPTED) + self.assertIsNotNone(member.joined_at) + + def test_explicit_joined_at_is_preserved(self): + team = self.create_team() + joined_at = timezone.now() - timezone.timedelta(days=1) + + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="joined-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + joined_at=joined_at, + ) + + self.assertEqual(member.joined_at, joined_at) + + def test_removed_or_left_member_keeps_joined_at(self): + team = self.create_team() + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="former-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + joined_at = member.joined_at + + for member_status in ( + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + member.status = member_status + member.save() + member.refresh_from_db() + + self.assertEqual(member.joined_at, joined_at) + + def test_deleting_team_deletes_members(self): + team = self.create_team() + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="deleted-team-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + team.delete() + + self.assertFalse(TeamMember.objects.filter(pk=member.pk).exists()) + + +class ApplicationTeamDatabaseConstraintTests(TransactionTestCase): + def setUp(self): + self.program = create_partner_program() + self.captain = create_user(prefix="constraint-captain") + self.application = Application.objects.create( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + self.team = Team.objects.create( + application=self.application, + captain=self.captain, + name="Constraint team", + ) + + def test_one_to_one_application_constraint_is_enforced_by_database(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + Team.objects.bulk_create( + [ + Team( + application=self.application, + captain=self.captain, + name="Duplicate team", + ) + ] + ) + + def test_team_user_constraint_is_enforced_by_database(self): + user = create_user(prefix="constraint-member") + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + ] + ) + + def test_only_one_accepted_captain_constraint_is_enforced_by_database(self): + TeamMember.objects.create( + team=self.team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + another_user = create_user(prefix="second-constraint-captain") + + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=another_user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ] + ) + + def test_captain_status_check_constraint_is_enforced_by_database(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_INVITED, + ) + ] + ) diff --git a/partner_programs/tests/test_application_team_service.py b/partner_programs/tests/test_application_team_service.py new file mode 100644 index 00000000..64d4e614 --- /dev/null +++ b/partner_programs/tests/test_application_team_service.py @@ -0,0 +1,683 @@ +from unittest.mock import patch + +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram, Team, TeamMember +from partner_programs.services.application_team import ( + _lock_application, + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + CaptainMemberMissingError, + CaptainMismatchError, + ParticipationModeNotAllowedError, + ParticipationModeUndecidedError, + RegistrationRequiredError, + TeamHasOtherMembersError, + TeamMemberRegistrationMissingError, + TeamNotAllowedError, + TeamRequiredError, + TeamSizeInvalidError, + change_application_participation_mode, + create_or_get_application, + submit_application, + validate_team_invariants, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class ApplicationTeamServiceTests(TestCase): + def setUp(self): + self.program = create_partner_program() + self.user = create_user(prefix="team-service-owner") + self.registration = create_program_member(self.program, user=self.user) + + def test_application_lock_targets_only_application_row(self): + application = Application(pk=123) + + with patch( + "partner_programs.services.application_team." + "Application.objects.select_for_update" + ) as select_for_update: + queryset = select_for_update.return_value + related_queryset = queryset.select_related.return_value + related_queryset.get.return_value = application + + result = _lock_application(application) + + select_for_update.assert_called_once_with(of=("self",)) + queryset.select_related.assert_called_once_with( + "program", + "user", + "created_by", + "project", + ) + related_queryset.get.assert_called_once_with(pk=application.pk) + self.assertIs(result, application) + + def configure_team_policy( + self, + *, + participation_format=PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + minimum=2, + maximum=5, + ): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=participation_format, + team_min_size=minimum, + team_max_size=maximum, + ) + self.program.refresh_from_db() + + def create_application( + self, + *, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + user=None, + program=None, + form_data=None, + team_name=None, + ): + owner = user or self.user + target_program = program or self.program + return create_or_get_application( + program=target_program, + user=owner, + created_by=owner, + participation_mode=participation_mode, + form_data=form_data, + team_name=team_name, + ) + + def add_team_member( + self, + application, + *, + user=None, + status=TeamMember.STATUS_ACCEPTED, + register=True, + ): + member_user = user or create_user(prefix="team-service-member") + if register: + create_program_member(application.program, user=member_user) + member = TeamMember.objects.create( + team=application.team, + user=member_user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=application.user, + ) + return member + + def test_registered_user_creates_individual_draft_without_team(self): + result = self.create_application(form_data={"motivation": "test"}) + + self.assertTrue(result.created) + self.assertEqual(result.application.status, Application.STATUS_DRAFT) + self.assertEqual( + result.application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertFalse(Team.objects.filter(application=result.application).exists()) + + def test_create_requires_registration(self): + self.registration.delete() + + with self.assertRaises(RegistrationRequiredError): + self.create_application() + + self.assertFalse(Application.objects.exists()) + + def test_create_is_blocked_after_application_deadline(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + self.create_application() + + def test_individual_is_forbidden_in_team_only_program(self): + self.configure_team_policy( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + ) + + with self.assertRaises(ParticipationModeNotAllowedError): + self.create_application() + + def test_repeated_compatible_create_returns_existing_application(self): + first = self.create_application(form_data={"version": 1}) + second = self.create_application(form_data={"version": 1}) + + self.assertTrue(first.created) + self.assertFalse(second.created) + self.assertEqual(first.application.pk, second.application.pk) + self.assertEqual(Application.objects.count(), 1) + + def test_repeated_incompatible_create_is_rejected(self): + self.create_application(form_data={"version": 1}) + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application(form_data={"version": 2}) + + def test_undecided_draft_can_be_created_without_team(self): + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ) + + self.assertEqual( + result.application.participation_mode, + Application.PARTICIPATION_MODE_UNDECIDED, + ) + self.assertFalse(Team.objects.filter(application=result.application).exists()) + + def test_undecided_application_cannot_be_submitted(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ).application + + with self.assertRaises(ParticipationModeUndecidedError): + submit_application(application=application, actor=self.user) + + def test_team_can_be_created_in_team_only_program(self): + self.configure_team_policy( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + ) + + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Команда", + ) + + self.assertTrue(result.created) + self.assertEqual(result.application.team.name, "Команда") + + def test_team_can_be_created_in_individual_or_team_program(self): + self.configure_team_policy() + + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + self.assertTrue(Team.objects.filter(application=result.application).exists()) + + def test_team_is_forbidden_in_individual_only_program(self): + with self.assertRaises(ParticipationModeNotAllowedError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + def test_team_create_atomically_creates_matching_accepted_captain(self): + self.configure_team_policy() + + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + captain_member = application.team.members.get(role=TeamMember.ROLE_CAPTAIN) + + self.assertEqual(application.team.captain, application.user) + self.assertEqual(captain_member.user, application.user) + self.assertEqual(captain_member.status, TeamMember.STATUS_ACCEPTED) + self.assertIsNone(captain_member.invited_by) + self.assertIsNotNone(captain_member.joined_at) + + def test_team_create_rolls_back_application_after_internal_error(self): + self.configure_team_policy() + + with patch( + "partner_programs.services.application_team._create_team_with_captain", + side_effect=RuntimeError("forced error"), + ): + with self.assertRaises(RuntimeError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + self.assertFalse(Application.objects.exists()) + self.assertFalse(Team.objects.exists()) + self.assertFalse(TeamMember.objects.exists()) + + def test_owner_cannot_create_incompatible_second_active_application(self): + self.configure_team_policy() + self.create_application() + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + def test_accepted_member_of_another_application_has_conflict(self): + self.configure_team_policy() + captain = create_user(prefix="other-team-captain") + create_program_member(self.program, user=captain) + other_application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + user=captain, + ).application + self.add_team_member(other_application, user=self.user, register=False) + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application() + + def test_terminal_application_does_not_block_new_application(self): + first = self.create_application().application + first.status = Application.STATUS_WITHDRAWN + first.withdrawn_at = timezone.now() + first.save(update_fields=["status", "withdrawn_at", "updated_at"]) + + second = self.create_application() + + self.assertTrue(second.created) + self.assertNotEqual(first.pk, second.application.pk) + + def test_membership_in_another_program_does_not_block_create(self): + other_program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + captain = create_user(prefix="other-program-captain") + create_program_member(other_program, user=captain) + create_program_member(other_program, user=self.user) + other_application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + user=captain, + program=other_program, + ).application + self.add_team_member( + other_application, + user=self.user, + register=False, + ) + + result = self.create_application() + + self.assertTrue(result.created) + + def test_individual_to_team_creates_team_and_captain(self): + self.configure_team_policy() + application = self.create_application().application + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Новая команда", + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertEqual(updated.team.name, "Новая команда") + self.assertTrue( + updated.team.members.filter( + user=self.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + ) + + def test_team_to_individual_deletes_captain_only_team(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + team_id = application.team.pk + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertFalse(Team.objects.filter(pk=team_id).exists()) + self.assertFalse(TeamMember.objects.filter(team_id=team_id).exists()) + + def test_team_name_can_change_for_draft_before_deadline(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Старое название", + ).application + + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Новое название", + ) + + application.team.refresh_from_db() + self.assertEqual(application.team.name, "Новое название") + + def test_only_owner_can_change_participation_mode(self): + application = self.create_application().application + another_user = create_user(prefix="mode-change-actor") + + with self.assertRaises(ApplicationNotEditableError): + change_application_participation_mode( + application=application, + actor=another_user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_team_to_individual_is_forbidden_with_another_member_record(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member( + application, + status=TeamMember.STATUS_INVITED, + register=False, + ) + + with self.assertRaises(TeamHasOtherMembersError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + + def test_team_to_undecided_is_forbidden(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + + with self.assertRaises(TeamNotAllowedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_individual_to_undecided_is_allowed_for_draft(self): + application = self.create_application().application + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_cannot_change_after_submit(self): + application = self.create_application().application + submit_application(application=application, actor=self.user) + + with self.assertRaises(ApplicationNotEditableError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_cannot_change_after_deadline(self): + application = self.create_application().application + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_must_match_program_policy(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ).application + + with self.assertRaises(ParticipationModeNotAllowedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + + def test_team_invariant_requires_team(self): + self.configure_team_policy() + application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + + with self.assertRaises(TeamRequiredError): + validate_team_invariants(application) + + def test_team_invariant_requires_captain_member(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + application.team.members.all().delete() + + with self.assertRaises(CaptainMemberMissingError): + validate_team_invariants(application) + + def test_team_invariant_rejects_captain_mismatch(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + another_user = create_user(prefix="mismatched-captain") + Team.objects.filter(pk=application.team.pk).update(captain=another_user) + + with self.assertRaises(CaptainMismatchError): + validate_team_invariants(application) + + def test_team_invariant_requires_registration_for_accepted_member(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application, register=False) + + with self.assertRaises(TeamMemberRegistrationMissingError): + validate_team_invariants(application) + + def test_team_invariant_rejects_team_smaller_than_minimum(self): + self.configure_team_policy(minimum=2, maximum=5) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + + with self.assertRaises(TeamSizeInvalidError): + validate_team_invariants(application) + + def test_team_invariant_rejects_team_larger_than_maximum(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + self.add_team_member(application) + + with self.assertRaises(TeamSizeInvalidError): + validate_team_invariants(application) + + def test_valid_accepted_team_passes_invariant(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + + validate_team_invariants(application) + + def test_invited_member_is_not_counted_in_team_size(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + self.add_team_member( + application, + status=TeamMember.STATUS_INVITED, + register=False, + ) + + validate_team_invariants(application) + + def test_inactive_member_statuses_are_not_counted_in_team_size(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + for member_status in ( + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.add_team_member( + application, + status=member_status, + register=False, + ) + + validate_team_invariants(application) + + def test_individual_submit_transitions_application(self): + application = self.create_application().application + + submitted = submit_application(application=application, actor=self.user) + + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + self.assertIsNotNone(submitted.submitted_at) + + def test_team_submit_with_valid_team_transitions_application(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + + submitted = submit_application(application=application, actor=self.user) + + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + + def test_repeated_submit_is_idempotent(self): + application = self.create_application().application + first = submit_application(application=application, actor=self.user) + + second = submit_application(application=first, actor=self.user) + + self.assertEqual(second.status, Application.STATUS_SUBMITTED) + self.assertEqual(second.pk, first.pk) + + def test_repeated_submit_preserves_submitted_at(self): + application = self.create_application().application + first = submit_application(application=application, actor=self.user) + submitted_at = first.submitted_at + + second = submit_application(application=first, actor=self.user) + + self.assertEqual(second.submitted_at, submitted_at) + + def test_draft_submit_is_blocked_after_deadline(self): + application = self.create_application().application + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + submit_application(application=application, actor=self.user) + + def test_submitted_application_is_idempotent_after_deadline(self): + application = self.create_application().application + submitted = submit_application(application=application, actor=self.user) + submitted_at = submitted.submitted_at + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + repeated = submit_application(application=submitted, actor=self.user) + + self.assertEqual(repeated.submitted_at, submitted_at) + + def test_member_conflict_blocks_team_submit(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + member = self.add_team_member(application) + Application.objects.create( + program=self.program, + user=member.user, + created_by=member.user, + ) + + with self.assertRaises(ActiveApplicationConflictError): + submit_application(application=application, actor=self.user) + + def test_submit_requires_owner_registration(self): + self.registration.delete() + application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + + with self.assertRaises(RegistrationRequiredError): + submit_application(application=application, actor=self.user) + + +class ApplicationTeamServiceSequentialConflictTests(TransactionTestCase): + def test_sequential_operations_do_not_create_second_active_participation(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + team_min_size=2, + team_max_size=5, + ) + captain = create_user(prefix="sequential-captain") + member = create_user(prefix="sequential-member") + create_program_member(program, user=captain) + create_program_member(program, user=member) + application = create_or_get_application( + program=program, + user=captain, + created_by=captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + TeamMember.objects.create( + team=application.team, + user=member, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=captain, + ) + + with self.assertRaises(ActiveApplicationConflictError): + create_or_get_application( + program=program, + user=member, + created_by=member, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + self.assertEqual(Application.objects.count(), 1) diff --git a/partner_programs/tests/test_expert_evaluation_api.py b/partner_programs/tests/test_expert_evaluation_api.py new file mode 100644 index 00000000..4fa9f5dd --- /dev/null +++ b/partner_programs/tests/test_expert_evaluation_api.py @@ -0,0 +1,1291 @@ +# Roadmap: DEV-050, DEV-051, DEV-052, DEV-073 +# Проверки экспертного доступа, autosave, submit и manager read-only API. + +from decimal import Decimal +from threading import Barrier, Thread + +from django.conf import settings +from django.core.cache import cache +from django.db import close_old_connections +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.utils import timezone +from rest_framework.test import APIClient + +from notifications.models import Notification +from partner_programs.models import ( + Application, + Evaluation, + EvaluationAmendment, + EvaluationScore, + Submission, + SubmissionExpertAssignment, +) +from partner_programs.services.evaluations import submit_evaluation +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class ExpertEvaluationAPITestCase(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.manager = create_user(prefix="evaluation-api-manager") + self.other_manager = create_user(prefix="evaluation-api-other-manager") + self.staff = create_user(prefix="evaluation-api-staff", is_staff=True) + self.participant = create_user(prefix="evaluation-api-participant") + self.program = create_partner_program() + self.other_program = create_partner_program() + self.program.managers.add(self.manager) + self.other_program.managers.add(self.other_manager) + self.expert_user = create_rate_expert( + prefix="evaluation-api-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + self.other_expert_user = create_rate_expert( + prefix="evaluation-api-other-expert", + program=self.program, + ) + self.other_expert = self.other_expert_user.expert + self.foreign_expert_user = create_rate_expert( + prefix="evaluation-api-foreign-expert", + program=self.other_program, + ) + self.foreign_expert = self.foreign_expert_user.expert + self.submission = self.create_submission(participant=self.participant) + self.assignment = self.create_assignment() + self.int_criterion = create_rate_criteria( + self.program, + name="Impact", + type="int", + min_value=1, + max_value=10, + ) + self.float_criterion = create_rate_criteria( + self.program, + name="Feasibility", + type="float", + min_value=0.5, + max_value=5.5, + ) + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.expert_user) + + def create_submission( + self, + *, + program=None, + participant=None, + status=Submission.STATUS_SUBMITTED, + title="Expert solution", + form_data=None, + links=None, + ): + program = program or self.program + participant = participant or create_user( + prefix="evaluation-submission-participant" + ) + application = Application.objects.create( + program=program, + user=participant, + created_by=participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + form_data={"registration_secret": "private"}, + ) + return Submission.objects.create( + application=application, + program=program, + submitted_by=participant, + title=title, + description="Solution description", + form_data=form_data or {"solution_secret": "private"}, + links=links or ["https://example.com/solution"], + status=status, + submitted_at=( + timezone.now() + if status in (Submission.STATUS_SUBMITTED, Submission.STATUS_FINAL) + else None + ), + ) + + def create_assignment(self, *, submission=None, expert=None, **overrides): + values = { + "submission": submission or self.submission, + "expert": expert or self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def create_evaluation(self, *, submission=None, expert=None, **overrides): + values = { + "submission": submission or self.submission, + "expert": expert or self.expert, + } + values.update(overrides) + return Evaluation.objects.create(**values) + + def score_payload(self, *, int_value="8", float_value="4.5"): + return [ + {"criterion_id": self.int_criterion.pk, "value": int_value}, + {"criterion_id": self.float_criterion.pk, "value": float_value}, + ] + + +class ExpertSubmissionAPITests(ExpertEvaluationAPITestCase): + def test_list_contains_only_current_expert_assignments(self): + other_submission = self.create_submission(title="Other expert solution") + self.create_assignment( + submission=other_submission, + expert=self.other_expert, + ) + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["id"], self.submission.pk) + + def test_completed_assignment_remains_visible(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + self.assignment.completed_at = timezone.now() + self.assignment.save() + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["results"][0]["assignment"]["status"], + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_unassigned_and_foreign_submissions_are_hidden(self): + self.create_submission(title="Unassigned") + foreign_submission = self.create_submission( + program=self.other_program, + title="Foreign", + ) + self.create_assignment( + submission=foreign_submission, + expert=self.foreign_expert, + assigned_by=self.other_manager, + ) + self.authenticate() + + response = self.client.get("/expert/submissions/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_and_participant_cannot_use_expert_list(self): + for user in (self.manager, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get("/expert/submissions/") + self.assertEqual(response.status_code, 403) + + def test_list_filters_program_submission_and_evaluation_status(self): + evaluation = self.create_evaluation() + other_submission = self.create_submission(status=Submission.STATUS_FINAL) + self.create_assignment(submission=other_submission) + self.authenticate() + + response = self.client.get( + "/expert/submissions/", + { + "program_id": self.program.pk, + "submission_status": Submission.STATUS_SUBMITTED, + "evaluation_status": Evaluation.STATUS_DRAFT, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [evaluation.submission_id], + ) + + def test_list_supports_limit_offset_pagination(self): + for index in range(11): + submission = self.create_submission(title=f"Solution {index}") + self.create_assignment(submission=submission) + self.authenticate() + + first = self.client.get("/expert/submissions/") + second = self.client.get("/expert/submissions/", {"offset": 10}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(first.data["count"], 12) + self.assertEqual(len(first.data["results"]), 10) + self.assertEqual(second.status_code, 200) + self.assertEqual(len(second.data["results"]), 2) + + def test_detail_returns_safe_solution_fields_and_numeric_criteria(self): + create_rate_criteria(self.program, name="Comment", type="str") + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["description"], "Solution description") + self.assertEqual(response.data["links"], ["https://example.com/solution"]) + self.assertEqual( + {item["id"] for item in response.data["criteria"]}, + {self.int_criterion.pk, self.float_criterion.pk}, + ) + + def test_detail_does_not_expose_participant_pii_or_form_data(self): + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + forbidden = { + "application", + "form_data", + "submitted_by", + "user", + "created_by", + "email", + "phone", + "team", + "team_members", + } + self.assertFalse(forbidden.intersection(response.data)) + self.assertNotIn("registration_secret", str(response.data)) + self.assertNotIn("solution_secret", str(response.data)) + self.assertNotIn(self.participant.email, str(response.data)) + + def test_unassigned_expert_gets_404_for_detail(self): + self.authenticate(self.other_expert_user) + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 404) + + def test_assignment_without_program_membership_does_not_grant_access(self): + self.create_assignment(expert=self.foreign_expert) + evaluation = self.create_evaluation(expert=self.foreign_expert) + self.authenticate(self.foreign_expert_user) + + list_response = self.client.get("/expert/submissions/") + detail_response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + evaluation_response = self.client.get(f"/evaluations/{evaluation.pk}/") + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data["count"], 0) + self.assertEqual(detail_response.status_code, 404) + self.assertEqual(evaluation_response.status_code, 404) + + def test_detail_does_not_expose_other_experts_evaluation(self): + self.create_assignment(expert=self.other_expert) + other_evaluation = self.create_evaluation(expert=self.other_expert) + other_evaluation.comment = "Private expert comment" + other_evaluation.save() + self.authenticate() + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["my_evaluation"]) + self.assertNotIn("Private expert comment", str(response.data)) + + def test_staff_can_open_expert_submission_detail(self): + self.authenticate(self.staff) + + response = self.client.get(f"/expert/submissions/{self.submission.pk}/") + + self.assertEqual(response.status_code, 200) + + +class EvaluationDraftCreateAPITests(ExpertEvaluationAPITestCase): + @property + def create_url(self): + return f"/submissions/{self.submission.pk}/evaluations/" + + def test_create_empty_draft(self): + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["status"], Evaluation.STATUS_DRAFT) + self.assertEqual(response.data["comment"], "") + self.assertEqual(response.data["scores"], []) + + def test_create_draft_with_scores(self): + self.authenticate() + + response = self.client.post( + self.create_url, + {"comment": "Strong", "scores": self.score_payload()}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["comment"], "Strong") + self.assertEqual(len(response.data["scores"]), 2) + + def test_repeated_post_returns_existing_without_changes(self): + self.authenticate() + first = self.client.post( + self.create_url, + {"comment": "Original", "scores": self.score_payload()}, + format="json", + ) + + second = self.client.post( + self.create_url, + {"comment": "Replacement", "scores": []}, + format="json", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["id"], first.data["id"]) + self.assertEqual(second.data["comment"], "Original") + self.assertEqual(len(second.data["scores"]), 2) + + def test_existing_submitted_evaluation_returns_409(self): + self.create_evaluation( + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_missing_assignment_returns_404(self): + submission = self.create_submission() + self.authenticate() + + response = self.client.post( + f"/submissions/{submission.pk}/evaluations/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + + def test_revoked_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + self.assignment.revoked_by = self.manager + self.assignment.revoked_at = timezone.now() + self.assignment.revoke_reason = "Reassigned" + self.assignment.save() + self.authenticate() + + response = self.client.post(self.create_url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_invalid_submission_status_returns_409(self): + for submission_status in ( + Submission.STATUS_DRAFT, + Submission.STATUS_RETURNED, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=submission_status): + submission = self.create_submission(status=submission_status) + self.create_assignment(submission=submission) + self.authenticate() + response = self.client.post( + f"/submissions/{submission.pk}/evaluations/", + {}, + format="json", + ) + self.assertEqual(response.status_code, 409) + + def test_criterion_from_another_program_returns_400(self): + criterion = create_rate_criteria(self.other_program, type="int") + self.authenticate() + + response = self.client.post( + self.create_url, + {"scores": [{"criterion_id": criterion.pk, "value": "5"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_non_numeric_criterion_returns_400(self): + criterion = create_rate_criteria(self.program, type="str") + self.authenticate() + + response = self.client.post( + self.create_url, + {"scores": [{"criterion_id": criterion.pk, "value": "5"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_fractional_int_and_out_of_range_values_return_400(self): + self.authenticate() + payloads = ( + {"scores": [{"criterion_id": self.int_criterion.pk, "value": "7.5"}]}, + {"scores": [{"criterion_id": self.int_criterion.pk, "value": "11"}]}, + {"scores": [{"criterion_id": self.float_criterion.pk, "value": "0.4"}]}, + ) + + for payload in payloads: + with self.subTest(payload=payload): + response = self.client.post( + self.create_url, + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(Evaluation.objects.exists()) + + def test_duplicate_criteria_return_400(self): + self.authenticate() + + response = self.client.post( + self.create_url, + { + "scores": [ + {"criterion_id": self.int_criterion.pk, "value": "7"}, + {"criterion_id": self.int_criterion.pk, "value": "8"}, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_my_evaluation_get_does_not_create(self): + self.authenticate() + + response = self.client.get(f"/submissions/{self.submission.pk}/evaluations/my/") + + self.assertEqual(response.status_code, 404) + self.assertFalse(Evaluation.objects.exists()) + + def test_my_evaluation_get_returns_own_scores(self): + evaluation = self.create_evaluation() + EvaluationScore.objects.create( + evaluation=evaluation, + criterion=self.int_criterion, + value=Decimal("8"), + ) + self.authenticate() + + response = self.client.get(f"/submissions/{self.submission.pk}/evaluations/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], evaluation.pk) + self.assertEqual(len(response.data["scores"]), 1) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_create="1/min")) + def test_create_is_scoped_throttled(self): + self.authenticate() + + first = self.client.post( + self.create_url, + {}, + format="json", + REMOTE_ADDR="203.0.113.90", + ) + second = self.client.post( + self.create_url, + {}, + format="json", + REMOTE_ADDR="203.0.113.90", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 429) + + +class EvaluationPatchAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Initial") + self.url = f"/evaluations/{self.evaluation.pk}/" + + def test_owner_updates_comment(self): + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Updated"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Updated") + + def test_scores_are_replaced_as_complete_set(self): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + self.authenticate() + + response = self.client.patch( + self.url, + {"scores": [{"criterion_id": self.float_criterion.pk, "value": "4.25"}]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(self.evaluation.scores.values_list("criterion_id", flat=True)), + {self.float_criterion.pk}, + ) + + def test_empty_scores_removes_all_scores(self): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + self.authenticate() + + response = self.client.patch(self.url, {"scores": []}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.evaluation.scores.exists()) + + def test_invalid_score_rolls_back_comment_and_scores(self): + original = EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("5"), + ) + foreign = create_rate_criteria(self.other_program, type="int") + self.authenticate() + + response = self.client.patch( + self.url, + { + "comment": "Must rollback", + "scores": [ + {"criterion_id": self.float_criterion.pk, "value": "4"}, + {"criterion_id": foreign.pk, "value": "3"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Initial") + self.assertEqual( + list(self.evaluation.scores.values_list("id", flat=True)), + [original.pk], + ) + + def test_other_expert_and_manager_cannot_patch(self): + for user in (self.other_expert_user, self.manager): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + def test_revoked_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + self.assignment.revoked_by = self.manager + self.assignment.revoked_at = timezone.now() + self.assignment.revoke_reason = "Reassigned" + self.assignment.save() + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_submitted_evaluation_returns_409(self): + self.evaluation.status = Evaluation.STATUS_SUBMITTED + self.evaluation.submitted_at = timezone.now() + self.evaluation.save() + self.authenticate() + + response = self.client.patch( + self.url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_identical_patch_is_safe(self): + self.authenticate() + payload = {"comment": "Initial", "scores": self.score_payload()} + + first = self.client.patch(self.url, payload, format="json") + second = self.client.patch(self.url, payload, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(self.evaluation.scores.count(), 2) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_update="1/min")) + def test_patch_is_scoped_throttled(self): + self.authenticate() + + first = self.client.patch( + self.url, + {"comment": "First"}, + format="json", + REMOTE_ADDR="203.0.113.91", + ) + second = self.client.patch( + self.url, + {"comment": "Second"}, + format="json", + REMOTE_ADDR="203.0.113.91", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + +class EvaluationSubmitAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Ready") + for criterion, value in ( + (self.int_criterion, Decimal("8")), + (self.float_criterion, Decimal("4.5")), + ): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=criterion, + value=value, + ) + self.url = f"/evaluations/{self.evaluation.pk}/submit/" + + def test_submit_completes_evaluation_and_assignment(self): + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 200) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_SUBMITTED) + self.assertIsNotNone(self.evaluation.submitted_at) + self.assertIsNone(self.evaluation.total_score) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + self.assertIsNotNone(self.assignment.completed_at) + self.assertEqual( + self.assignment.completed_at, + self.evaluation.submitted_at, + ) + self.assertTrue( + Notification.objects.filter( + recipient=self.manager, + type=Notification.Type.EVALUATION_SUBMITTED, + ).exists() + ) + + def test_incomplete_criteria_return_400(self): + self.evaluation.scores.filter(criterion=self.float_criterion).delete() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 400) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_DRAFT) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_changed_range_is_revalidated_on_submit(self): + self.int_criterion.max_value = 5 + self.int_criterion.save() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 400) + + def test_wrong_submission_status_returns_409(self): + self.submission.status = Submission.STATUS_RETURNED + self.submission.submitted_at = None + self.submission.save() + self.authenticate() + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 409) + + def test_repeated_submit_is_idempotent_and_preserves_timestamp(self): + self.authenticate() + first = self.client.post(self.url, {}, format="json") + self.evaluation.refresh_from_db() + submitted_at = self.evaluation.submitted_at + + second = self.client.post(self.url, {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.submitted_at, submitted_at) + + def test_other_expert_cannot_submit(self): + self.authenticate(self.other_expert_user) + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, 404) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_submit="1/min")) + def test_submit_is_scoped_throttled(self): + self.authenticate() + + first = self.client.post( + self.url, + {}, + format="json", + REMOTE_ADDR="203.0.113.92", + ) + second = self.client.post( + self.url, + {}, + format="json", + REMOTE_ADDR="203.0.113.92", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + +class EvaluationAmendmentAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.submitted_at = timezone.now() + self.evaluation = self.create_evaluation( + comment="Initial submitted comment", + status=Evaluation.STATUS_SUBMITTED, + submitted_at=self.submitted_at, + total_score=Decimal("12.5"), + ) + for criterion, value in ( + (self.int_criterion, Decimal("6")), + (self.float_criterion, Decimal("3.5")), + ): + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=criterion, + value=value, + ) + self.assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + self.assignment.completed_at = self.submitted_at + self.assignment.save() + self.amend_url = f"/evaluations/{self.evaluation.pk}/amend/" + self.history_url = f"/evaluations/{self.evaluation.pk}/amendments/" + + def test_owner_amends_comment_and_complete_scores(self): + completed_at = self.assignment.completed_at + self.authenticate() + + response = self.client.patch( + self.amend_url, + { + "comment": "Corrected comment", + "scores": self.score_payload(int_value="9", float_value="5.25"), + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_SUBMITTED) + self.assertEqual(self.evaluation.submitted_at, self.submitted_at) + self.assertIsNotNone(self.evaluation.amended_at) + self.assertIsNone(self.evaluation.total_score) + self.assertIsNotNone(response.data["amended_at"]) + self.assertEqual( + {score.criterion_id: score.value for score in self.evaluation.scores.all()}, + { + self.int_criterion.pk: Decimal("9"), + self.float_criterion.pk: Decimal("5.25"), + }, + ) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + self.assertEqual(self.assignment.completed_at, completed_at) + self.assertEqual( + Evaluation.objects.filter( + submission=self.submission, + expert=self.expert, + ).count(), + 1, + ) + + amendment = EvaluationAmendment.objects.get(evaluation=self.evaluation) + self.assertEqual(amendment.changed_by, self.expert_user) + self.assertEqual(amendment.previous_comment, "Initial submitted comment") + self.assertEqual(amendment.comment, "Corrected comment") + self.assertEqual(amendment.previous_total_score, Decimal("12.5")) + self.assertIsNone(amendment.total_score) + self.assertEqual( + {item["criterion_id"] for item in amendment.previous_scores}, + {self.int_criterion.pk, self.float_criterion.pk}, + ) + self.assertEqual( + {item["criterion_id"]: Decimal(item["value"]) for item in amendment.scores}, + { + self.int_criterion.pk: Decimal("9"), + self.float_criterion.pk: Decimal("5.25"), + }, + ) + + list_response = self.client.get("/expert/submissions/") + self.assertEqual( + list_response.data["results"][0]["my_evaluation"]["amended_at"], + self.evaluation.amended_at, + ) + + def test_comment_only_preserves_scores_and_creates_history(self): + score_ids = tuple( + self.evaluation.scores.order_by("id").values_list("id", flat=True) + ) + self.authenticate() + + response = self.client.patch( + self.amend_url, + {"comment": "Comment only"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + tuple(self.evaluation.scores.order_by("id").values_list("id", flat=True)), + score_ids, + ) + amendment = EvaluationAmendment.objects.get(evaluation=self.evaluation) + self.assertEqual(amendment.previous_scores, amendment.scores) + + def test_identical_request_does_not_create_history(self): + self.authenticate() + payload = { + "comment": "Changed once", + "scores": self.score_payload(int_value="8", float_value="4.5"), + } + + first = self.client.patch(self.amend_url, payload, format="json") + self.evaluation.refresh_from_db() + first_amended_at = self.evaluation.amended_at + second = self.client.patch(self.amend_url, payload, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.amended_at, first_amended_at) + self.assertEqual( + EvaluationAmendment.objects.filter(evaluation=self.evaluation).count(), + 1, + ) + + def test_incomplete_scores_roll_back_comment_and_scores(self): + score_ids = tuple( + self.evaluation.scores.order_by("id").values_list("id", flat=True) + ) + self.authenticate() + + response = self.client.patch( + self.amend_url, + { + "comment": "Must rollback", + "scores": [ + {"criterion_id": self.int_criterion.pk, "value": "8"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Initial submitted comment") + self.assertEqual( + tuple(self.evaluation.scores.order_by("id").values_list("id", flat=True)), + score_ids, + ) + self.assertFalse( + EvaluationAmendment.objects.filter(evaluation=self.evaluation).exists() + ) + + def test_draft_evaluation_cannot_be_amended(self): + self.evaluation.status = Evaluation.STATUS_DRAFT + self.evaluation.submitted_at = None + self.evaluation.save() + self.authenticate() + + response = self.client.patch( + self.amend_url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_revoked_assignment_cannot_be_amended(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_REVOKED + self.assignment.completed_at = None + self.assignment.revoked_by = self.manager + self.assignment.revoked_at = timezone.now() + self.assignment.revoke_reason = "Revoked" + self.assignment.save() + self.authenticate() + + response = self.client.patch( + self.amend_url, + {"comment": "Forbidden"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_other_expert_and_manager_cannot_amend(self): + for user in (self.other_expert_user, self.manager): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.patch( + self.amend_url, + {"comment": "Forbidden"}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + def test_history_is_visible_to_owner_manager_and_staff(self): + self.authenticate() + amend_response = self.client.patch( + self.amend_url, + {"comment": "Visible history"}, + format="json", + ) + self.assertEqual(amend_response.status_code, 200) + + for user in (self.expert_user, self.manager, self.staff): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.history_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual( + response.data[0]["evaluation_id"], + self.evaluation.pk, + ) + + self.authenticate(self.manager) + manager_response = self.client.get( + f"/programs/{self.program.pk}/evaluations/{self.evaluation.pk}/" + ) + self.assertEqual(manager_response.status_code, 200) + self.assertEqual( + manager_response.data["amended_at"], + amend_response.data["amended_at"], + ) + + self.authenticate(self.other_expert_user) + hidden_response = self.client.get(self.history_url) + self.assertEqual(hidden_response.status_code, 404) + + def test_assigned_episode_can_be_amended_and_stays_assigned(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_ASSIGNED + self.assignment.completed_at = None + self.assignment.save() + self.authenticate() + + response = self.client.patch( + self.amend_url, + {"comment": "Assigned correction"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_invalid_submission_or_missing_membership_blocks_amendment(self): + self.authenticate() + self.submission.status = Submission.STATUS_RETURNED + self.submission.submitted_at = None + self.submission.save() + + invalid_submission = self.client.patch( + self.amend_url, + {"comment": "Forbidden"}, + format="json", + ) + self.assertEqual(invalid_submission.status_code, 409) + + self.submission.status = Submission.STATUS_SUBMITTED + self.submission.submitted_at = timezone.now() + self.submission.save() + self.expert.programs.remove(self.program) + missing_membership = self.client.patch( + self.amend_url, + {"comment": "Still forbidden"}, + format="json", + ) + self.assertEqual(missing_membership.status_code, 404) + + @override_settings(REST_FRAMEWORK=throttle_settings(evaluation_amend="1/min")) + def test_amend_is_scoped_throttled(self): + self.authenticate() + + first = self.client.patch( + self.amend_url, + {"comment": "First amendment"}, + format="json", + REMOTE_ADDR="203.0.113.93", + ) + second = self.client.patch( + self.amend_url, + {"comment": "Second amendment"}, + format="json", + REMOTE_ADDR="203.0.113.93", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + +class EvaluationReadAndManagerAPITests(ExpertEvaluationAPITestCase): + def setUp(self): + super().setUp() + self.evaluation = self.create_evaluation(comment="Manager-visible") + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=self.int_criterion, + value=Decimal("7"), + ) + self.detail_url = f"/evaluations/{self.evaluation.pk}/" + self.manager_list_url = f"/programs/{self.program.pk}/evaluations/" + self.manager_detail_url = ( + f"/programs/{self.program.pk}/evaluations/{self.evaluation.pk}/" + ) + + def test_owner_manager_and_staff_can_read_evaluation(self): + for user in (self.expert_user, self.manager, self.staff): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, 200) + + def test_other_expert_and_participant_cannot_read_evaluation(self): + for user in (self.other_expert_user, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, 404) + + def test_manager_sees_own_program_evaluations_and_scores(self): + self.authenticate(self.manager) + + response = self.client.get(self.manager_list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + item = response.data["results"][0] + self.assertEqual(item["id"], self.evaluation.pk) + self.assertEqual(item["comment"], "Manager-visible") + self.assertEqual(len(item["scores"]), 1) + self.assertEqual(item["assignment"]["id"], self.assignment.pk) + + def test_other_manager_and_participant_cannot_use_manager_list(self): + for user in (self.other_manager, self.participant): + with self.subTest(user=user.pk): + self.authenticate(user) + response = self.client.get(self.manager_list_url) + self.assertEqual(response.status_code, 403) + + def test_staff_can_use_manager_list(self): + self.authenticate(self.staff) + + response = self.client.get(self.manager_list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_filters_work(self): + self.authenticate(self.manager) + + response = self.client.get( + self.manager_list_url, + { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + "evaluation_status": Evaluation.STATUS_DRAFT, + "assignment_status": SubmissionExpertAssignment.STATUS_ASSIGNED, + "stage_key": "main", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_manager_detail_is_scoped_to_program(self): + self.authenticate(self.manager) + own = self.client.get(self.manager_detail_url) + + self.authenticate(self.other_manager) + hidden = self.client.get(self.manager_detail_url) + + self.assertEqual(own.status_code, 200) + self.assertEqual(hidden.status_code, 403) + + def test_manager_api_is_read_only(self): + self.authenticate(self.manager) + + for method in ("post", "patch", "delete"): + with self.subTest(method=method): + response = getattr(self.client, method)( + self.manager_detail_url, + {"comment": "Forbidden", "scores": []}, + format="json", + ) + self.assertEqual(response.status_code, 405) + self.evaluation.refresh_from_db() + self.assertEqual(self.evaluation.comment, "Manager-visible") + + def test_assignment_list_contains_evaluation_summary_without_pii(self): + self.authenticate(self.manager) + + response = self.client.get(f"/programs/{self.program.pk}/submission-assignments/") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["evaluation"]["id"], self.evaluation.pk) + self.assertEqual( + item["evaluation"]["status"], + Evaluation.STATUS_DRAFT, + ) + self.assertNotIn("form_data", str(item)) + + +class ConcurrentEvaluationSubmitTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + self.manager = create_user(prefix="concurrent-evaluation-manager") + self.participant = create_user(prefix="concurrent-evaluation-participant") + self.program = create_partner_program() + application = Application.objects.create( + program=self.program, + user=self.participant, + created_by=self.participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.submission = Submission.objects.create( + application=application, + program=self.program, + submitted_by=self.participant, + title="Concurrent solution", + status=Submission.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.expert_user = create_rate_expert( + prefix="concurrent-evaluation-expert", + program=self.program, + ) + self.assignment = SubmissionExpertAssignment.objects.create( + submission=self.submission, + expert=self.expert_user.expert, + assigned_by=self.manager, + ) + criterion = create_rate_criteria( + self.program, + type="int", + min_value=1, + max_value=10, + ) + self.evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert_user.expert, + ) + EvaluationScore.objects.create( + evaluation=self.evaluation, + criterion=criterion, + value=Decimal("8"), + ) + + @skipUnlessDBFeature("has_select_for_update") + def test_concurrent_submit_keeps_consistent_terminal_state(self): + barrier = Barrier(2) + results = [] + errors = [] + + def worker(): + close_old_connections() + try: + user = type(self.expert_user).objects.get(pk=self.expert_user.pk) + barrier.wait() + evaluation = submit_evaluation( + evaluation_id=self.evaluation.pk, + user=user, + ) + results.append(evaluation.status) + except Exception as exc: # pragma: no cover - диагностируется результатом + errors.append(exc) + finally: + close_old_connections() + + threads = [Thread(target=worker), Thread(target=worker)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + self.assertFalse(errors) + self.assertEqual(results, [Evaluation.STATUS_SUBMITTED] * 2) + self.evaluation.refresh_from_db() + self.assignment.refresh_from_db() + self.assertEqual(self.evaluation.status, Evaluation.STATUS_SUBMITTED) + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + self.assertEqual( + self.evaluation.submitted_at, + self.assignment.completed_at, + ) diff --git a/partner_programs/tests/test_managed_programs_api.py b/partner_programs/tests/test_managed_programs_api.py new file mode 100644 index 00000000..d48dd9a2 --- /dev/null +++ b/partner_programs/tests/test_managed_programs_api.py @@ -0,0 +1,125 @@ +# Roadmap: DEV-091 + +from django.db import connection +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.tests.helpers import create_partner_program, create_user + + +class ManagedProgramListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="managed-program-manager") + self.other_manager = create_user(prefix="managed-program-other-manager") + self.participant = create_user(prefix="managed-program-participant") + self.staff = create_user(prefix="managed-program-staff", is_staff=True) + self.superuser = create_user( + prefix="managed-program-superuser", + is_staff=True, + is_superuser=True, + ) + self.published_program = create_partner_program( + name="Янтарная программа", + draft=False, + ) + self.draft_program = create_partner_program( + name="Альфа программа", + draft=True, + ) + self.other_program = create_partner_program( + name="Бета программа", + draft=False, + ) + self.published_program.managers.add(self.manager) + self.draft_program.managers.add(self.manager) + self.other_program.managers.add(self.other_manager) + self.url = reverse("partner_programs:managed-program-list") + + def get_as(self, user): + self.client.force_authenticate(user=user) + return self.client.get(self.url) + + def test_manager_sees_only_own_programs_including_drafts(self): + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + [ + { + "id": self.draft_program.pk, + "name": "Альфа программа", + "draft": True, + }, + { + "id": self.published_program.pk, + "name": "Янтарная программа", + "draft": False, + }, + ], + ) + + def test_programs_of_another_manager_are_not_disclosed(self): + response = self.get_as(self.manager) + + returned_ids = {program["id"] for program in response.data} + self.assertNotIn(self.other_program.pk, returned_ids) + + def test_participant_without_managed_programs_gets_empty_list(self): + response = self.get_as(self.participant) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + def test_staff_and_superuser_see_all_programs(self): + expected_ids = [ + self.draft_program.pk, + self.other_program.pk, + self.published_program.pk, + ] + for user in (self.staff, self.superuser): + with self.subTest(user=user.email): + response = self.get_as(user) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [program["id"] for program in response.data], + expected_ids, + ) + + def test_anonymous_user_gets_401(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + + def test_order_is_stable_for_programs_with_same_name(self): + first = create_partner_program(name="Одинаковое имя") + second = create_partner_program(name="Одинаковое имя") + first.managers.add(self.manager) + second.managers.add(self.manager) + + response = self.get_as(self.manager) + + same_name_ids = [ + program["id"] + for program in response.data + if program["name"] == "Одинаковое имя" + ] + self.assertEqual(same_name_ids, [first.pk, second.pk]) + + def test_program_count_does_not_increase_query_count(self): + for index in range(10): + program = create_partner_program( + name="Масштаб {}".format(str(index).zfill(2)) + ) + program.managers.add(self.manager) + self.client.force_authenticate(user=self.manager) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(queries), 1) diff --git a/partner_programs/tests/test_manager_program_overview_api.py b/partner_programs/tests/test_manager_program_overview_api.py new file mode 100644 index 00000000..1582e0d6 --- /dev/null +++ b/partner_programs/tests/test_manager_program_overview_api.py @@ -0,0 +1,468 @@ +# Roadmap: DEV-076, DEV-056, DEV-091 + +import json + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + Evaluation, + PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + Team, + TeamMember, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_expert + + +class ManagerProgramOverviewAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="overview-manager") + self.other_manager = create_user(prefix="overview-other-manager") + self.participant = create_user(prefix="overview-participant") + self.staff = create_user(prefix="overview-staff", is_staff=True) + self.superuser = create_user( + prefix="overview-superuser", + is_staff=True, + is_superuser=True, + ) + self.program = create_partner_program(name="Overview program") + self.other_program = create_partner_program(name="Other program") + self.program.managers.add(self.manager) + self.other_program.managers.add(self.other_manager) + self.url = reverse( + "partner_programs:manager-overview", + kwargs={"program_id": self.program.pk}, + ) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def create_application( + self, + *, + program=None, + status=Application.STATUS_DRAFT, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + prefix="overview-application", + ): + program = program or self.program + user = create_user(prefix=prefix) + return Application.objects.create( + program=program, + user=user, + created_by=user, + status=status, + participation_mode=participation_mode, + form_data={ + "email": f"{prefix}@private.example", + "private_answer": "hidden application data", + }, + ) + + def create_submission(self, application, status_value, version): + return Submission.objects.create( + application=application, + program=application.program, + submitted_by=application.user, + title=f"Private solution {version}", + description="Hidden solution contents", + form_data={"private_solution": True}, + links=["https://private.example/file"], + status=status_value, + version=version, + ) + + def seed_complete_overview(self, program, manager, prefix): + for index in range(2): + PartnerProgramUserProfile.objects.create( + user=create_user(prefix=f"{prefix}-registration-{index}"), + partner_program=program, + partner_program_data={"private_registration": index}, + ) + + application_specs = ( + ( + Application.STATUS_DRAFT, + Application.PARTICIPATION_MODE_UNDECIDED, + ), + ( + Application.STATUS_SUBMITTED, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ), + ( + Application.STATUS_APPROVED, + Application.PARTICIPATION_MODE_TEAM, + ), + ( + Application.STATUS_REJECTED, + Application.PARTICIPATION_MODE_UNDECIDED, + ), + ( + Application.STATUS_WITHDRAWN, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ), + ( + Application.STATUS_CANCELLED, + Application.PARTICIPATION_MODE_TEAM, + ), + ) + applications = {} + for index, (status_value, participation_mode) in enumerate( + application_specs, + start=1, + ): + applications[status_value] = self.create_application( + program=program, + status=status_value, + participation_mode=participation_mode, + prefix=f"{prefix}-application-{index}", + ) + + team_application = applications[Application.STATUS_APPROVED] + team = Team.objects.create( + application=team_application, + captain=team_application.user, + name="Private team name", + ) + TeamMember.objects.create( + team=team, + user=team_application.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + for member_status in ( + TeamMember.STATUS_ACCEPTED, + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + TeamMember.objects.create( + team=team, + user=create_user(prefix=f"{prefix}-member-{member_status}"), + status=member_status, + ) + + submission_application = applications[Application.STATUS_SUBMITTED] + submissions = {} + for version, status_value in enumerate( + ( + Submission.STATUS_DRAFT, + Submission.STATUS_SUBMITTED, + Submission.STATUS_RETURNED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ), + start=1, + ): + submissions[status_value] = self.create_submission( + submission_application, + status_value, + version, + ) + + expert_user = create_rate_expert(prefix=f"{prefix}-expert", program=program) + expert = expert_user.expert + SubmissionExpertAssignment.objects.create( + submission=submissions[Submission.STATUS_SUBMITTED], + expert=expert, + assigned_by=manager, + status=SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + SubmissionExpertAssignment.objects.create( + submission=submissions[Submission.STATUS_FINAL], + expert=expert, + assigned_by=manager, + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + SubmissionExpertAssignment.objects.create( + submission=submissions[Submission.STATUS_RETURNED], + expert=expert, + assigned_by=manager, + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=manager, + revoked_at=timezone.now(), + revoke_reason="Private reason", + ) + + Evaluation.objects.create( + submission=submissions[Submission.STATUS_SUBMITTED], + expert=expert, + status=Evaluation.STATUS_DRAFT, + comment="Private draft comment", + ) + Evaluation.objects.create( + submission=submissions[Submission.STATUS_FINAL], + expert=expert, + status=Evaluation.STATUS_SUBMITTED, + comment="Private submitted comment", + submitted_at=timezone.now(), + ) + + def get_as(self, user): + self.authenticate(user) + return self.client.get(self.url) + + def test_program_manager_gets_overview(self): + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["program"]["id"], self.program.pk) + + def test_staff_gets_overview(self): + response = self.get_as(self.staff) + + self.assertEqual(response.status_code, 200) + + def test_superuser_gets_overview(self): + response = self.get_as(self.superuser) + + self.assertEqual(response.status_code, 200) + + def test_unauthenticated_user_gets_401(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + + def test_participant_gets_403(self): + response = self.get_as(self.participant) + + self.assertEqual(response.status_code, 403) + + def test_manager_of_another_program_gets_403(self): + response = self.get_as(self.other_manager) + + self.assertEqual(response.status_code, 403) + + def test_unknown_program_gets_404(self): + self.authenticate(self.manager) + + response = self.client.get( + reverse( + "partner_programs:manager-overview", + kwargs={"program_id": self.program.pk + self.other_program.pk + 1000}, + ) + ) + + self.assertEqual(response.status_code, 404) + + def test_empty_program_returns_full_zero_contract(self): + response = self.get_as(self.manager) + + self.assertEqual( + response.data, + { + "program": {"id": self.program.pk, "name": "Overview program"}, + "registrations": {"total": 0}, + "participants": {"total": 0}, + "applications": { + "total": 0, + "by_status": { + "draft": 0, + "submitted": 0, + "approved": 0, + "rejected": 0, + "withdrawn": 0, + "cancelled": 0, + }, + "by_participation_mode": { + "undecided": 0, + "individual": 0, + "team": 0, + }, + }, + "teams": {"total": 0, "accepted_members": 0}, + "submissions": { + "total": 0, + "by_status": { + "draft": 0, + "submitted": 0, + "returned": 0, + "final": 0, + "cancelled": 0, + }, + "applications_with_submitted_solution": 0, + }, + "expert_assignments": { + "total": 0, + "by_status": { + "assigned": 0, + "completed": 0, + "revoked": 0, + }, + }, + "evaluations": { + "total": 0, + "by_status": {"draft": 0, "submitted": 0}, + }, + }, + ) + + def test_all_domain_counters_follow_the_contract(self): + self.seed_complete_overview(self.program, self.manager, "overview-own") + + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["registrations"], {"total": 2}) + self.assertEqual(response.data["participants"], {"total": 2}) + self.assertEqual(response.data["applications"]["total"], 6) + self.assertEqual( + response.data["applications"]["by_status"], + { + "draft": 1, + "submitted": 1, + "approved": 1, + "rejected": 1, + "withdrawn": 1, + "cancelled": 1, + }, + ) + self.assertEqual( + response.data["applications"]["by_participation_mode"], + {"undecided": 2, "individual": 2, "team": 2}, + ) + self.assertEqual( + response.data["teams"], + {"total": 1, "accepted_members": 2}, + ) + self.assertEqual(response.data["submissions"]["total"], 5) + self.assertEqual( + response.data["submissions"]["by_status"], + { + "draft": 1, + "submitted": 1, + "returned": 1, + "final": 1, + "cancelled": 1, + }, + ) + self.assertEqual( + response.data["submissions"]["applications_with_submitted_solution"], + 1, + ) + self.assertEqual( + response.data["expert_assignments"], + { + "total": 3, + "by_status": {"assigned": 1, "completed": 1, "revoked": 1}, + }, + ) + self.assertEqual( + response.data["evaluations"], + {"total": 2, "by_status": {"draft": 1, "submitted": 1}}, + ) + + def test_other_program_data_does_not_change_counters(self): + self.seed_complete_overview(self.program, self.manager, "overview-own") + self.seed_complete_overview( + self.other_program, + self.other_manager, + "overview-other", + ) + + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["program"]["id"], self.program.pk) + self.assertEqual(response.data["registrations"]["total"], 2) + self.assertEqual(response.data["participants"]["total"], 2) + self.assertEqual(response.data["applications"]["total"], 6) + self.assertEqual(response.data["teams"]["total"], 1) + self.assertEqual(response.data["submissions"]["total"], 5) + self.assertEqual(response.data["expert_assignments"]["total"], 3) + self.assertEqual(response.data["evaluations"]["total"], 2) + + def test_participants_count_only_non_null_unique_users(self): + for index in range(2): + PartnerProgramUserProfile.objects.create( + user=create_user(prefix=f"overview-unique-participant-{index}"), + partner_program=self.program, + partner_program_data={"registration": index}, + ) + for index in range(2): + PartnerProgramUserProfile.objects.create( + user=None, + partner_program=self.program, + partner_program_data={"deleted_user_registration": index}, + ) + + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["registrations"], {"total": 4}) + self.assertEqual(response.data["participants"], {"total": 2}) + + def test_response_does_not_contain_pii_or_private_content(self): + self.seed_complete_overview(self.program, self.manager, "overview-private") + + response = self.get_as(self.manager) + + self.assertEqual(response.status_code, 200) + serialized = json.dumps(response.data, ensure_ascii=False) + for private_value in ( + "private.example", + "hidden application data", + "Private solution", + "Hidden solution contents", + "Private team name", + "Private reason", + "Private draft comment", + "Private submitted comment", + ): + self.assertNotIn(private_value, serialized) + + forbidden_keys = { + "email", + "first_name", + "last_name", + "form_data", + "partner_program_data", + "links", + "description", + "comment", + "scores", + } + + def assert_safe_keys(value): + if isinstance(value, dict): + self.assertTrue(forbidden_keys.isdisjoint(value.keys())) + for nested_value in value.values(): + assert_safe_keys(nested_value) + elif isinstance(value, list): + for nested_value in value: + assert_safe_keys(nested_value) + + assert_safe_keys(response.data) + + def test_manager_query_count_has_constant_upper_bound(self): + self.seed_complete_overview(self.program, self.manager, "overview-query") + self.authenticate(self.manager) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertLessEqual(len(queries), 8) + + def test_staff_query_count_has_constant_upper_bound_and_same_contract(self): + self.seed_complete_overview(self.program, self.manager, "overview-staff-query") + manager_response = self.get_as(self.manager) + self.authenticate(self.staff) + + with CaptureQueriesContext(connection) as queries: + staff_response = self.client.get(self.url) + + self.assertEqual(staff_response.status_code, 200) + self.assertLessEqual(len(queries), 7) + self.assertEqual(staff_response.data, manager_response.data) diff --git a/partner_programs/tests/test_program_application_policy_api.py b/partner_programs/tests/test_program_application_policy_api.py new file mode 100644 index 00000000..f7bd0aa8 --- /dev/null +++ b/partner_programs/tests/test_program_application_policy_api.py @@ -0,0 +1,123 @@ +from django.test import TestCase +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from rest_framework.test import APIClient + +from partner_programs.models import Application, PartnerProgram +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class PartnerProgramApplicationPolicyAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def get_policy(self, program: PartnerProgram) -> dict: + response = self.client.get(f"/programs/{program.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertIn("application_policy", response.data) + return response.data["application_policy"] + + def test_individual_only_detail_exposes_individual_policy(self): + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY), + team_min_size=None, + team_max_size=None, + datetime_application_ends=None, + ) + + policy = self.get_policy(program) + + self.assertEqual( + policy, + { + "participation_format": "individual_only", + "allowed_participation_modes": [ + Application.PARTICIPATION_MODE_INDIVIDUAL + ], + "team_min_size": None, + "team_max_size": None, + "datetime_application_ends": None, + "is_application_deadline_passed": False, + }, + ) + self.assertNotIn( + Application.PARTICIPATION_MODE_UNDECIDED, + policy["allowed_participation_modes"], + ) + + def test_team_only_detail_exposes_sizes_and_future_deadline(self): + deadline = timezone.now() + timezone.timedelta(days=10) + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + datetime_application_ends=deadline, + ) + + policy = self.get_policy(program) + + self.assertEqual(policy["participation_format"], "team_only") + self.assertEqual( + policy["allowed_participation_modes"], + [Application.PARTICIPATION_MODE_TEAM], + ) + self.assertEqual(policy["team_min_size"], 2) + self.assertEqual(policy["team_max_size"], 5) + self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline) + self.assertFalse(policy["is_application_deadline_passed"]) + self.assertNotIn( + Application.PARTICIPATION_MODE_UNDECIDED, + policy["allowed_participation_modes"], + ) + + def test_individual_or_team_detail_marks_passed_deadline(self): + deadline = timezone.now() - timezone.timedelta(seconds=1) + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=4, + datetime_application_ends=deadline, + ) + + policy = self.get_policy(program) + + self.assertEqual(policy["participation_format"], "individual_or_team") + self.assertEqual( + policy["allowed_participation_modes"], + [ + Application.PARTICIPATION_MODE_INDIVIDUAL, + Application.PARTICIPATION_MODE_TEAM, + ], + ) + self.assertEqual(policy["team_min_size"], 2) + self.assertEqual(policy["team_max_size"], 4) + self.assertEqual(parse_datetime(policy["datetime_application_ends"]), deadline) + self.assertTrue(policy["is_application_deadline_passed"]) + self.assertNotIn( + Application.PARTICIPATION_MODE_UNDECIDED, + policy["allowed_participation_modes"], + ) + + def test_member_detail_uses_the_same_public_policy_contract(self): + program = create_partner_program() + member = create_user(prefix="policy-program-member") + create_program_member(program, user=member) + self.client.force_authenticate(member) + + policy = self.get_policy(program) + + self.assertEqual(policy["participation_format"], "individual_only") + self.assertEqual(policy["allowed_participation_modes"], ["individual"]) + + def test_program_list_contract_does_not_include_application_policy(self): + create_partner_program() + + response = self.client.get("/programs/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("application_policy", response.data["results"][0]) diff --git a/partner_programs/tests/test_program_participation_policy.py b/partner_programs/tests/test_program_participation_policy.py new file mode 100644 index 00000000..09ff28f3 --- /dev/null +++ b/partner_programs/tests/test_program_participation_policy.py @@ -0,0 +1,272 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram +from partner_programs.serializers.programs import ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + PartnerProgramListSerializer, +) +from partner_programs.tests.helpers import create_partner_program + + +class PartnerProgramParticipationPolicyTests(TestCase): + def assert_policy_invalid(self, expected_field, **overrides): + program = create_partner_program(data_schema={"field": "value"}) + for field, value in overrides.items(): + setattr(program, field, value) + + with self.assertRaises(ValidationError) as error: + program.full_clean() + + self.assertIn(expected_field, error.exception.message_dict) + + def test_participation_format_defaults_to_individual_only(self): + program = create_partner_program() + + self.assertEqual( + program.participation_format, + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, + ) + + def test_team_min_size_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.team_min_size) + + def test_team_max_size_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.team_max_size) + + def test_application_deadline_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.datetime_application_ends) + + def test_participation_format_choices_contain_expected_values(self): + self.assertEqual( + {value for value, _label in PartnerProgram.PARTICIPATION_FORMAT_CHOICES}, + {"individual_only", "team_only", "individual_or_team"}, + ) + + def test_individual_only_without_team_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + + program.full_clean() + + def test_individual_only_with_team_min_size_is_invalid(self): + self.assert_policy_invalid("team_min_size", team_min_size=2) + + def test_individual_only_with_team_max_size_is_invalid(self): + self.assert_policy_invalid("team_max_size", team_max_size=5) + + def test_team_only_without_team_sizes_is_invalid(self): + self.assert_policy_invalid( + "team_min_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + ) + + def test_individual_or_team_without_team_sizes_is_invalid(self): + self.assert_policy_invalid( + "team_max_size", + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + ) + + def test_team_min_size_less_than_two_is_invalid(self): + self.assert_policy_invalid( + "team_min_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=1, + team_max_size=5, + ) + + def test_team_max_size_less_than_minimum_is_invalid(self): + self.assert_policy_invalid( + "team_max_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=5, + team_max_size=4, + ) + + def test_team_only_with_valid_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + program.participation_format = PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + program.team_min_size = 2 + program.team_max_size = 5 + + program.full_clean() + + def test_individual_or_team_with_valid_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + program.participation_format = ( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ) + program.team_min_size = 2 + program.team_max_size = 5 + + program.full_clean() + + def test_individual_only_allows_individual_mode(self): + program = create_partner_program() + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + + def test_individual_only_rejects_team_mode(self): + program = create_partner_program() + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_team_only_allows_team_mode(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_team_only_rejects_individual_mode(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + + def test_individual_or_team_allows_both_final_modes(self): + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_undecided_is_not_an_allowed_final_mode(self): + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_UNDECIDED) + ) + + def test_unknown_participation_mode_is_not_allowed(self): + program = create_partner_program() + + self.assertFalse(program.allows_participation_mode("unknown")) + + def test_null_application_deadline_is_not_passed(self): + program = create_partner_program(datetime_application_ends=None) + + self.assertFalse(program.is_application_deadline_passed()) + + def test_future_application_deadline_is_not_passed(self): + program = create_partner_program( + datetime_application_ends=timezone.now() + timezone.timedelta(days=1) + ) + + self.assertFalse(program.is_application_deadline_passed()) + + def test_past_application_deadline_is_passed(self): + program = create_partner_program( + datetime_application_ends=timezone.now() - timezone.timedelta(days=1) + ) + + self.assertTrue(program.is_application_deadline_passed()) + + def test_application_deadline_accepts_explicit_moment(self): + deadline = timezone.now() + program = create_partner_program(datetime_application_ends=deadline) + + self.assertFalse( + program.is_application_deadline_passed( + at=deadline - timezone.timedelta(seconds=1) + ) + ) + self.assertTrue( + program.is_application_deadline_passed( + at=deadline + timezone.timedelta(seconds=1) + ) + ) + + def test_public_program_serializers_do_not_expose_policy_fields(self): + policy_fields = { + "participation_format", + "team_min_size", + "team_max_size", + "datetime_application_ends", + } + + for serializer_class in ( + PartnerProgramListSerializer, + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ): + with self.subTest(serializer=serializer_class.__name__): + self.assertTrue( + policy_fields.isdisjoint(serializer_class().fields.keys()) + ) + + +class PartnerProgramParticipationPolicyConstraintTests(TransactionTestCase): + def test_database_rejects_team_min_size_less_than_two(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=1, + team_max_size=5, + ) + + def test_database_rejects_team_max_size_less_than_minimum(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=5, + team_max_size=4, + ) + + def test_database_rejects_team_format_without_sizes(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=None, + team_max_size=None, + ) + + def test_database_rejects_individual_format_with_team_sizes(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY + ), + team_min_size=2, + team_max_size=5, + ) diff --git a/partner_programs/tests/test_program_throttling.py b/partner_programs/tests/test_program_throttling.py new file mode 100644 index 00000000..33428d4b --- /dev/null +++ b/partner_programs/tests/test_program_throttling.py @@ -0,0 +1,61 @@ +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import create_partner_program + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class PartnerProgramThrottleTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.program = create_partner_program() + + @override_settings( + REST_FRAMEWORK=throttle_settings(program_register_new="1/min") + ) + @patch("partner_programs.services.registration.send_email.delay") + def test_external_program_registration_post_is_scoped_throttled( + self, + send_email_delay, + ): + first_response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-throttle-one@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + }, + format="json", + REMOTE_ADDR="203.0.113.20", + ) + second_response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-throttle-two@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + }, + format="json", + REMOTE_ADDR="203.0.113.20", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) + send_email_delay.assert_called_once() 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..b1d01978 --- /dev/null +++ b/partner_programs/tests/test_react_dev_demo_seed.py @@ -0,0 +1,658 @@ +# Roadmap: DEV-072, DEV-083 +# Безопасность, повторяемость и API-контракт демонстрационного React-dev набора. + +import io +import os +import secrets +from unittest.mock import patch + +from django.contrib.contenttypes.models import ContentType +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 core.models import Like, View +from news.models import News, NewsComment +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.services.react_dev_news_demo import ( + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + DEMO_NEWS_PROJECT_NAME, + DEMO_PAGINATED_COMMENT_TEXTS, + DEMO_PROGRAM_NEWS_TEXTS, + DEMO_PROJECT_NEWS_TEXTS, + DEMO_USER_NEWS_TEXTS, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.models import Criteria +from projects.models import Project +from users.models import CustomUser + +urlpatterns = [ + path("expert/", include("partner_programs.expert_urls")), + path("feed/", include("feed.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() + ) + self.assertFalse(Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME).exists()) + self.assertFalse( + News.objects.filter(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT).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, + ) + + project = Project.objects.get(name=DEMO_NEWS_PROJECT_NAME) + self.assertEqual(project.leader, users["participant1"]) + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + self.assertEqual(len(self.news_ids_for(program, DEMO_PROGRAM_NEWS_TEXTS)), 11) + self.assertEqual(len(self.news_ids_for(project, DEMO_PROJECT_NEWS_TEXTS)), 11) + self.assertEqual( + News.objects.filter(text__in=DEMO_USER_NEWS_TEXTS).count(), + 11, + ) + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + self.assertEqual(internal.content_object, program) + self.assertEqual(internal.audience, News.Audience.PROGRAM_PARTICIPANTS) + seeded_news = News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ) + self.assertEqual(seeded_news.values("text").distinct().count(), 34) + self.assertGreater(seeded_news.values("datetime_created").distinct().count(), 3) + + def test_each_public_feed_tab_has_a_second_page_without_duplicates(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + client = APIClient() + client.force_authenticate(participant) + + for source in ("program", "project", "user"): + with self.subTest(source=source): + first = client.get("/feed/news/", {"source": source}) + second = client.get( + "/feed/news/", + {"source": source, "offset": 10}, + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["count"], 11) + self.assertEqual(len(first.data["results"]), 10) + self.assertEqual(len(second.data["results"]), 1) + first_ids = {item["id"] for item in first.data["results"]} + second_ids = {item["id"] for item in second.data["results"]} + self.assertFalse(first_ids.intersection(second_ids)) + + def test_feed_exposes_seeded_reactions_views_and_paginated_comments(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + news = News.objects.get(text=DEMO_PROGRAM_NEWS_TEXTS[0]) + client = APIClient() + client.force_authenticate(participant) + + feed = client.get("/feed/news/", {"source": "program", "limit": 100}) + first_comments = client.get(f"/feed/news/{news.pk}/comments/") + second_comments = client.get( + f"/feed/news/{news.pk}/comments/", + {"offset": 20}, + ) + + item = next(item for item in feed.data["results"] if item["id"] == news.pk) + self.assertEqual(item["likes_count"], 2) + self.assertEqual(item["views_count"], 3) + self.assertEqual(item["comments_count"], 21) + self.assertEqual(first_comments.data["count"], 21) + self.assertEqual(len(first_comments.data["results"]), 20) + self.assertEqual(len(second_comments.data["results"]), 1) + comment_ids = [ + *(comment["id"] for comment in first_comments.data["results"]), + *(comment["id"] for comment in second_comments.data["results"]), + ] + self.assertEqual(len(comment_ids), len(set(comment_ids))) + self.assertEqual( + [comment["text"] for comment in first_comments.data["results"]] + + [comment["text"] for comment in second_comments.data["results"]], + list(DEMO_PAGINATED_COMMENT_TEXTS), + ) + + def test_internal_program_news_is_detail_only_for_program_member(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + outsider = create_user(prefix="react-news-demo-outsider") + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + client = APIClient() + client.force_authenticate(participant) + + list_response = client.get("/feed/news/", {"source": "program", "limit": 100}) + member_detail = client.get(f"/feed/news/{internal.pk}/") + client.force_authenticate(outsider) + outsider_detail = client.get(f"/feed/news/{internal.pk}/") + + self.assertNotIn( + internal.pk, + [item["id"] for item in list_response.data["results"]], + ) + self.assertEqual(member_detail.status_code, 200) + self.assertEqual( + member_detail.data["audience"], + News.Audience.PROGRAM_PARTICIPANTS, + ) + self.assertEqual(outsider_detail.status_code, 404) + + def test_command_reports_news_counts_and_internal_news_id(self): + stdout, _stderr = self.run_seed() + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + + self.assertIn("программы: 1", stdout) + self.assertIn("проекты: 1", stdout) + self.assertIn("публичные новости: 33", stdout) + self.assertIn("внутренние новости: 1", stdout) + self.assertIn("лайки: 6", stdout) + self.assertIn("просмотры: 9", stdout) + self.assertIn("комментарии: 23", stdout) + self.assertIn(f"ID внутренней новости: {internal.pk}", stdout) + + 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) + self.assertEqual(len(first_ids["projects"]), 1) + self.assertEqual(len(first_ids["news"]), 34) + self.assertEqual(len(first_ids["likes"]), 6) + self.assertEqual(len(first_ids["views"]), 9) + self.assertEqual(len(first_ids["comments"]), 23) + + 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.assertIn("публичные новости: 33", stdout) + self.assertIn("внутренние новости: 1", 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 + original_project_id = Project.objects.get(name=DEMO_NEWS_PROJECT_NAME).pk + original_internal_news_id = News.objects.get( + text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT + ).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", + ) + demo_user = CustomUser.objects.get(email="demo.participant1@procollab.test") + unrelated_news = News.objects.add_news( + demo_user, + text="[DEMO] Посторонняя новость DEMO-пользователя", + audience=News.Audience.PLATFORM, + ) + unrelated_project = Project.objects.create( + leader=outsider, + name="[DEMO] Другой публичный проект", + description="Не принадлежит seed-набору DEV-083.", + draft=False, + is_public=True, + ) + + self.run_seed("--reset") + + rebuilt = PartnerProgram.objects.get( + name=DEMO_PROGRAM_NAME, + tag=DEMO_PROGRAM_TAG, + ) + self.assertNotEqual(rebuilt.pk, original_program_id) + self.assertNotEqual( + Project.objects.get(name=DEMO_NEWS_PROJECT_NAME).pk, + original_project_id, + ) + self.assertNotEqual( + News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT).pk, + original_internal_news_id, + ) + self.assertTrue(CustomUser.objects.filter(pk=outsider.pk).exists()) + self.assertTrue(PartnerProgram.objects.filter(pk=outsider_program.pk).exists()) + self.assertTrue(News.objects.filter(pk=unrelated_news.pk).exists()) + self.assertTrue(Project.objects.filter(pk=unrelated_project.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, + ) + self.assertEqual(Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME).count(), 1) + self.assertEqual( + News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ).count(), + 34, + ) + + def test_foreign_exact_project_name_aborts_without_partial_writes(self): + outsider = create_user(prefix="react-news-demo-project-owner") + project = Project.objects.create( + leader=outsider, + name=DEMO_NEWS_PROJECT_NAME, + description="Посторонний проект с конфликтующим точным именем.", + draft=False, + is_public=True, + ) + + with self.assertRaisesMessage(CommandError, "занято посторонним проектом"): + self.run_seed() + + self.assertTrue(Project.objects.filter(pk=project.pk).exists()) + self.assertFalse(PartnerProgram.objects.filter(name=DEMO_PROGRAM_NAME).exists()) + self.assertFalse( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).exists() + ) + + 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, + ) + news = News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ) + news_content_type = ContentType.objects.get_for_model(News) + 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) + ), + "projects": tuple( + Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME) + .order_by("pk") + .values_list("pk", flat=True) + ), + "news": tuple(news.order_by("pk").values_list("pk", flat=True)), + "likes": tuple( + Like.objects.filter( + content_type=news_content_type, + object_id__in=news.values("pk"), + ) + .order_by("pk") + .values_list("pk", flat=True) + ), + "views": tuple( + View.objects.filter( + content_type=news_content_type, + object_id__in=news.values("pk"), + ) + .order_by("pk") + .values_list("pk", flat=True) + ), + "comments": tuple( + NewsComment.objects.filter(news__in=news) + .order_by("pk") + .values_list("pk", flat=True) + ), + } + + def news_ids_for(self, source, texts): + content_type = ContentType.objects.get_for_model(source) + return tuple( + News.objects.filter( + content_type=content_type, + object_id=source.pk, + text__in=texts, + ) + .order_by("pk") + .values_list("pk", flat=True) + ) diff --git a/partner_programs/tests/test_submission_api.py b/partner_programs/tests/test_submission_api.py new file mode 100644 index 00000000..49ed0587 --- /dev/null +++ b/partner_programs/tests/test_submission_api.py @@ -0,0 +1,429 @@ +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import Application, Submission +from notifications.models import Notification +from partner_programs.tests.helpers import create_partner_program, create_user + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class SubmissionAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user(prefix="submission-api-owner") + self.other_user = create_user(prefix="submission-api-other") + self.staff_user = create_user(prefix="submission-api-staff", is_staff=True) + self.program = create_partner_program() + self.application = self.create_application() + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.user) + + def create_application( + self, + *, + user=None, + status=Application.STATUS_SUBMITTED, + ): + owner = user or self.user + return Application.objects.create( + program=self.program, + user=owner, + created_by=owner, + status=status, + submitted_at=( + timezone.now() if status == Application.STATUS_SUBMITTED else None + ), + ) + + def create_submission(self, **overrides): + values = { + "application": self.application, + "program": self.program, + "submitted_by": self.user, + "title": "Solution", + } + values.update(overrides) + return Submission.objects.create(**values) + + @property + def list_url(self): + return f"/applications/{self.application.id}/submissions/" + + def test_unauthenticated_user_cannot_list_or_create_submissions(self): + list_response = self.client.get(self.list_url) + create_response = self.client.post( + self.list_url, + {"title": "Unauthorized solution"}, + format="json", + ) + + self.assertEqual(list_response.status_code, 401) + self.assertEqual(create_response.status_code, 401) + self.assertFalse(Submission.objects.exists()) + + def test_owner_can_list_own_application_submissions_newest_first(self): + first = self.create_submission(version=1) + second = self.create_submission(title="Version two", version=2) + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [second.id, first.id]) + + def test_listing_empty_application_does_not_create_submission(self): + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + self.assertFalse(Submission.objects.exists()) + + def test_user_cannot_list_another_users_application_submissions(self): + other_application = self.create_application(user=self.other_user) + self.authenticate() + + response = self.client.get(f"/applications/{other_application.id}/submissions/") + + self.assertEqual(response.status_code, 404) + + def test_owner_can_create_draft_for_submitted_or_approved_application(self): + self.authenticate() + + submitted_response = self.client.post( + self.list_url, + {"title": "Submitted application solution"}, + format="json", + ) + self.application.status = Application.STATUS_APPROVED + self.application.save() + approved_response = self.client.post( + self.list_url, + {"title": "Approved application solution"}, + format="json", + ) + + self.assertEqual(submitted_response.status_code, 201) + self.assertEqual(approved_response.status_code, 201) + self.assertEqual(submitted_response.data["status"], Submission.STATUS_DRAFT) + self.assertEqual(approved_response.data["status"], Submission.STATUS_DRAFT) + + def test_cannot_create_for_ineligible_application_status(self): + self.authenticate() + + for application_status in ( + Application.STATUS_DRAFT, + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + with self.subTest(status=application_status): + self.application.status = application_status + self.application.save() + + response = self.client.post( + self.list_url, + {"title": "Invalid application solution"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + self.assertFalse(Submission.objects.exists()) + + def test_create_fills_program_and_submitted_by(self): + self.authenticate() + + response = self.client.post( + self.list_url, + {"title": "Owned solution"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + submission = Submission.objects.get() + self.assertEqual(submission.program, self.application.program) + self.assertEqual(submission.submitted_by, self.user) + self.assertEqual(response.data["program"], self.program.id) + self.assertEqual(response.data["submitted_by"], self.user.id) + + def test_staff_can_create_submission(self): + self.authenticate(self.staff_user) + + response = self.client.post( + self.list_url, + {"title": "Staff-created solution"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(Submission.objects.get().submitted_by, self.staff_user) + + def test_default_stage_key_is_main(self): + self.authenticate() + + response = self.client.post( + self.list_url, + {"title": "Main stage solution"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["stage_key"], "main") + + def test_default_version_uses_next_stage_version(self): + self.create_submission(stage_key="main", version=1) + self.create_submission(title="Other stage", stage_key="final", version=7) + self.authenticate() + + response = self.client.post( + self.list_url, + {"title": "Next main version", "stage_key": "main"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["version"], 2) + + def test_duplicate_stage_and_version_returns_validation_error(self): + self.create_submission(stage_key="main", version=1) + self.authenticate() + + response = self.client.post( + self.list_url, + {"title": "Duplicate", "stage_key": "main", "version": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("version", response.data) + self.assertEqual(Submission.objects.count(), 1) + + def test_owner_can_get_submission_detail(self): + submission = self.create_submission() + self.authenticate() + + response = self.client.get(f"/submissions/{submission.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], submission.id) + + def test_user_cannot_get_another_users_submission(self): + other_application = self.create_application(user=self.other_user) + submission = self.create_submission( + application=other_application, + submitted_by=self.other_user, + ) + self.authenticate() + + response = self.client.get(f"/submissions/{submission.id}/") + + self.assertEqual(response.status_code, 404) + + def test_patch_draft_updates_editable_fields(self): + submission = self.create_submission() + self.authenticate() + + response = self.client.patch( + f"/submissions/{submission.id}/", + { + "title": "Updated solution", + "description": "Updated description", + "form_data": {"answer": 42}, + "links": ["https://example.com/solution"], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.title, "Updated solution") + self.assertEqual(submission.description, "Updated description") + self.assertEqual(submission.form_data, {"answer": 42}) + self.assertEqual(submission.links, ["https://example.com/solution"]) + + def test_patch_non_editable_status_is_rejected(self): + submission = self.create_submission() + self.authenticate() + + for submission_status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=submission_status): + submission.status = submission_status + submission.save() + + response = self.client.patch( + f"/submissions/{submission.id}/", + {"title": "Should not change"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + submission.refresh_from_db() + self.assertEqual(submission.title, "Solution") + + def test_patch_rejects_immutable_fields(self): + submission = self.create_submission() + other_application = self.create_application(user=self.other_user) + other_program = create_partner_program() + self.authenticate() + + response = self.client.patch( + f"/submissions/{submission.id}/", + { + "application": other_application.id, + "program": other_program.id, + "submitted_by": self.other_user.id, + "status": Submission.STATUS_FINAL, + "stage_key": "other", + "version": 9, + "submitted_at": timezone.now().isoformat(), + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + submission.refresh_from_db() + self.assertEqual(submission.application, self.application) + self.assertEqual(submission.program, self.program) + self.assertEqual(submission.submitted_by, self.user) + self.assertEqual(submission.status, Submission.STATUS_DRAFT) + self.assertEqual(submission.stage_key, "main") + self.assertEqual(submission.version, 1) + self.assertIsNone(submission.submitted_at) + + def test_submit_draft_sets_status_and_submitted_at(self): + submission = self.create_submission() + self.program.managers.add(self.other_user) + self.authenticate() + + response = self.client.post( + f"/submissions/{submission.id}/submit/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.status, Submission.STATUS_SUBMITTED) + self.assertIsNotNone(submission.submitted_at) + self.assertTrue( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.SUBMISSION_SUBMITTED, + ).exists() + ) + + def test_repeated_submit_preserves_submitted_at(self): + submission = self.create_submission() + self.program.managers.add(self.other_user) + self.authenticate() + url = f"/submissions/{submission.id}/submit/" + + first_response = self.client.post(url, {}, format="json") + submission.refresh_from_db() + first_submitted_at = submission.submitted_at + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.submitted_at, first_submitted_at) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.SUBMISSION_SUBMITTED, + ).count(), + 1, + ) + + def test_submit_final_or_cancelled_returns_bad_request(self): + submission = self.create_submission() + self.authenticate() + + for submission_status in ( + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=submission_status): + submission.status = submission_status + submission.save() + + response = self.client.post( + f"/submissions/{submission.id}/submit/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_cancel_draft_is_idempotent(self): + submission = self.create_submission() + self.authenticate() + url = f"/submissions/{submission.id}/cancel/" + + first_response = self.client.post(url, {}, format="json") + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.status, Submission.STATUS_CANCELLED) + + def test_cancel_submitted_or_final_returns_bad_request(self): + submission = self.create_submission() + self.authenticate() + + for submission_status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + with self.subTest(status=submission_status): + submission.status = submission_status + submission.save() + + response = self.client.post( + f"/submissions/{submission.id}/cancel/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + @override_settings(REST_FRAMEWORK=throttle_settings(submission_create="1/min")) + def test_submission_create_is_scoped_throttled(self): + self.authenticate() + + first_response = self.client.post( + self.list_url, + {"title": "First solution"}, + format="json", + REMOTE_ADDR="203.0.113.50", + ) + second_response = self.client.post( + self.list_url, + {"title": "Second solution"}, + format="json", + REMOTE_ADDR="203.0.113.50", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) diff --git a/partner_programs/tests/test_submission_assignment_api.py b/partner_programs/tests/test_submission_assignment_api.py new file mode 100644 index 00000000..5c6e4133 --- /dev/null +++ b/partner_programs/tests/test_submission_assignment_api.py @@ -0,0 +1,965 @@ +from decimal import Decimal +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.db import transaction +from django.test import SimpleTestCase, TestCase, override_settings +from django.urls import Resolver404, resolve, reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from notifications.models import Notification +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + Submission, + SubmissionExpertAssignment, +) +from partner_programs.services.submission_assignments import ( + AssignmentCompletedError, + create_submission_assignment, +) +from partner_programs.services import ( + submission_assignments as submission_assignment_service, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class SubmissionAssignmentTestCase(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.manager = create_user(prefix="assignment-manager") + self.other_manager = create_user(prefix="assignment-other-manager") + self.outsider = create_user(prefix="assignment-outsider") + self.staff = create_user(prefix="assignment-staff", is_staff=True) + self.participant = create_user(prefix="assignment-participant") + self.program = create_partner_program() + self.other_program = create_partner_program() + self.program.managers.add(self.manager) + self.other_program.managers.add(self.other_manager) + self.expert_user = create_rate_expert( + prefix="assignment-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + self.other_expert_user = create_rate_expert( + prefix="assignment-other-expert", + program=self.other_program, + ) + self.other_expert = self.other_expert_user.expert + self.submission = self.create_submission(participant=self.participant) + self.list_url = f"/programs/{self.program.pk}/submission-assignments/" + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.manager) + + def create_submission( + self, + *, + program=None, + participant=None, + status=Submission.STATUS_SUBMITTED, + title="Assignment solution", + ): + program = program or self.program + participant = participant or create_user( + prefix="assignment-submission-participant" + ) + application = Application.objects.create( + program=program, + user=participant, + created_by=participant, + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + return Submission.objects.create( + application=application, + program=program, + submitted_by=participant, + title=title, + status=status, + submitted_at=( + timezone.now() + if status in (Submission.STATUS_SUBMITTED, Submission.STATUS_FINAL) + else None + ), + ) + + def create_assignment(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def create_payload(self, **overrides): + payload = { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + } + payload.update(overrides) + return payload + + +class SubmissionAssignmentListAPITests(SubmissionAssignmentTestCase): + def test_unauthenticated_user_gets_401(self): + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 401) + + def test_program_manager_can_list_assignments(self): + self.create_assignment() + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_outsider_gets_403(self): + self.authenticate(self.outsider) + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 403) + + def test_staff_can_list_assignments(self): + self.create_assignment() + self.authenticate(self.staff) + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_get_unknown_program_gets_404(self): + self.authenticate() + + response = self.client.get( + f"/programs/{self.program.pk + self.other_program.pk + 1000}/" + "submission-assignments/" + ) + + self.assertEqual(response.status_code, 404) + + def test_post_unknown_program_gets_404(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.pk + self.other_program.pk + 1000}/" + "submission-assignments/", + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 404) + + def test_list_contains_only_requested_program(self): + own_assignment = self.create_assignment() + other_submission = self.create_submission( + program=self.other_program, + participant=create_user(prefix="other-program-participant"), + ) + SubmissionExpertAssignment.objects.create( + submission=other_submission, + expert=self.other_expert, + assigned_by=self.other_manager, + ) + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [own_assignment.pk], + ) + + def test_list_includes_revoked_history_newest_first(self): + older = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="First episode", + ) + newer = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="Second episode", + ) + self.authenticate() + + response = self.client.get(self.list_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [newer.pk, older.pk], + ) + + def test_list_uses_project_limit_offset_pagination(self): + for index in range(11): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason=f"History {index}", + ) + self.authenticate() + + first_page = self.client.get(self.list_url) + second_page = self.client.get(self.list_url, {"offset": 10}) + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(first_page.data["count"], 11) + self.assertEqual(len(first_page.data["results"]), 10) + self.assertEqual(second_page.status_code, 200) + self.assertEqual(len(second_page.data["results"]), 1) + + def test_submission_expert_and_status_filters_work(self): + matching = self.create_assignment() + second_expert_user = create_rate_expert( + prefix="assignment-filter-expert", + program=self.program, + ) + second_submission = self.create_submission(title="Second solution") + SubmissionExpertAssignment.objects.create( + submission=second_submission, + expert=second_expert_user.expert, + assigned_by=self.manager, + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="History", + ) + self.authenticate() + + response = self.client.get( + self.list_url, + { + "submission_id": self.submission.pk, + "expert_id": self.expert.pk, + "status": SubmissionExpertAssignment.STATUS_ASSIGNED, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [matching.pk], + ) + + def test_invalid_filters_get_400(self): + self.authenticate() + + for query in ( + {"submission_id": "not-a-number"}, + {"expert_id": "not-a-number"}, + {"status": "unknown"}, + ): + with self.subTest(query=query): + response = self.client.get(self.list_url, query) + self.assertEqual(response.status_code, 400) + + def test_response_is_minimal_and_does_not_expose_participant_data(self): + self.submission.form_data = {"private": "secret"} + self.submission.save() + self.create_assignment() + self.authenticate() + + response = self.client.get(self.list_url) + + item = response.data["results"][0] + self.assertEqual( + set(item), + { + "id", + "status", + "submission", + "expert", + "assigned_by_id", + "assigned_at", + "completed_at", + "revoked_by_id", + "revoked_at", + "revoke_reason", + "evaluation_status", + "evaluation", + }, + ) + self.assertEqual( + set(item["submission"]), + {"id", "title", "status", "stage_key", "version", "submitted_at"}, + ) + self.assertEqual( + set(item["expert"]), + {"id", "user_id", "first_name", "last_name"}, + ) + self.assertNotIn("form_data", item["submission"]) + self.assertNotIn("email", item["expert"]) + self.assertNotIn("participant", item) + self.assertIsNone(item["evaluation"]) + + def test_evaluation_status_reports_null_draft_and_submitted(self): + no_evaluation = self.create_assignment() + draft_submission = self.create_submission(title="Draft evaluation") + draft_expert_user = create_rate_expert( + prefix="assignment-draft-evaluation-expert", + program=self.program, + ) + draft_assignment = SubmissionExpertAssignment.objects.create( + submission=draft_submission, + expert=draft_expert_user.expert, + assigned_by=self.manager, + ) + Evaluation.objects.create( + submission=draft_submission, + expert=draft_expert_user.expert, + ) + submitted_submission = self.create_submission(title="Submitted evaluation") + submitted_expert_user = create_rate_expert( + prefix="assignment-submitted-evaluation-expert", + program=self.program, + ) + submitted_assignment = SubmissionExpertAssignment.objects.create( + submission=submitted_submission, + expert=submitted_expert_user.expert, + assigned_by=self.manager, + ) + Evaluation.objects.create( + submission=submitted_submission, + expert=submitted_expert_user.expert, + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.get(self.list_url, {"limit": 20}) + + statuses = { + item["id"]: item["evaluation_status"] for item in response.data["results"] + } + self.assertIsNone(statuses[no_evaluation.pk]) + self.assertEqual(statuses[draft_assignment.pk], Evaluation.STATUS_DRAFT) + self.assertEqual( + statuses[submitted_assignment.pk], + Evaluation.STATUS_SUBMITTED, + ) + + +class SubmissionAssignmentCreateAPITests(SubmissionAssignmentTestCase): + def test_manager_creates_assigned_assignment_with_201(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["status"], "assigned") + self.assertEqual(SubmissionExpertAssignment.objects.count(), 1) + self.assertTrue( + Notification.objects.filter( + recipient=self.expert_user, + type=Notification.Type.EXPERT_ASSIGNMENT_CREATED, + ).exists() + ) + + def test_assigned_by_is_request_user(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["assigned_by_id"], self.manager.pk) + self.assertEqual( + SubmissionExpertAssignment.objects.get().assigned_by, + self.manager, + ) + + def test_repeated_post_returns_same_assignment_with_200(self): + self.authenticate() + + first = self.client.post(self.list_url, self.create_payload(), format="json") + second = self.client.post(self.list_url, self.create_payload(), format="json") + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["id"], first.data["id"]) + self.assertEqual(SubmissionExpertAssignment.objects.count(), 1) + + def test_repeated_post_preserves_assigned_timestamp_and_actor(self): + original = self.create_assignment() + original_assigned_at = original.assigned_at + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 200) + original.refresh_from_db() + self.assertEqual(original.assigned_at, original_assigned_at) + self.assertEqual(original.assigned_by, self.manager) + + def test_new_episode_is_created_after_revocation(self): + revoked = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason="Previous episode", + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertNotEqual(response.data["id"], revoked.pk) + self.assertEqual(SubmissionExpertAssignment.objects.count(), 2) + + def test_completed_assignment_returns_409(self): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "assignment_completed") + + def test_submission_from_another_program_gets_field_error(self): + other_submission = self.create_submission( + program=self.other_program, + participant=create_user(prefix="wrong-program-participant"), + ) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=other_submission.pk), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("submission_id", response.data) + + def test_expert_from_another_program_gets_field_error(self): + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(expert_id=self.other_expert.pk), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("expert_id", response.data) + + def test_submitted_submission_is_assignable(self): + self.assertEqual(self.submission.status, Submission.STATUS_SUBMITTED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + + def test_final_submission_is_assignable(self): + final_submission = self.create_submission(status=Submission.STATUS_FINAL) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=final_submission.pk), + format="json", + ) + + self.assertEqual(response.status_code, 201) + + def test_draft_submission_returns_409(self): + draft = self.create_submission(status=Submission.STATUS_DRAFT) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=draft.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_returned_submission_returns_409(self): + returned = self.create_submission(status=Submission.STATUS_RETURNED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=returned.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_cancelled_submission_returns_409(self): + cancelled = self.create_submission(status=Submission.STATUS_CANCELLED) + self.authenticate() + + response = self.client.post( + self.list_url, + self.create_payload(submission_id=cancelled.pk), + format="json", + ) + + self.assertEqual(response.status_code, 409) + + def test_unknown_ids_get_field_errors(self): + self.authenticate() + + for payload, field in ( + (self.create_payload(submission_id=999999), "submission_id"), + (self.create_payload(expert_id=999999), "expert_id"), + ): + with self.subTest(field=field): + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.data) + + def test_outsider_cannot_create_assignment(self): + self.authenticate(self.outsider) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(SubmissionExpertAssignment.objects.exists()) + + def test_staff_can_create_assignment(self): + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + self.create_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["assigned_by_id"], self.staff.pk) + + def test_invalid_payload_gets_400(self): + self.authenticate() + + for payload in ( + {}, + {"submission_id": "bad", "expert_id": self.expert.pk}, + {"submission_id": self.submission.pk, "expert_id": "bad"}, + ): + with self.subTest(payload=payload): + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, 400) + + @override_settings( + REST_FRAMEWORK=throttle_settings(submission_assignment_create="1/min") + ) + def test_create_is_scoped_throttled(self): + self.authenticate() + second_expert = create_rate_expert( + prefix="assignment-throttle-expert", + program=self.program, + ).expert + + first = self.client.post( + self.list_url, + self.create_payload(), + format="json", + REMOTE_ADDR="203.0.113.80", + ) + second = self.client.post( + self.list_url, + self.create_payload(expert_id=second_expert.pk), + format="json", + REMOTE_ADDR="203.0.113.80", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 429) + + +class SubmissionAssignmentRevokeAPITests(SubmissionAssignmentTestCase): + def setUp(self): + super().setUp() + self.assignment = self.create_assignment() + self.revoke_url = f"/submission-assignments/{self.assignment.pk}/revoke/" + + def test_manager_revokes_assigned_assignment(self): + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Workload redistribution"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_REVOKED, + ) + self.assertTrue( + Notification.objects.filter( + recipient=self.expert_user, + type=Notification.Type.EXPERT_ASSIGNMENT_REVOKED, + ).exists() + ) + + def test_revoke_records_actor_timestamp_and_trimmed_reason(self): + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": " Workload redistribution "}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.revoked_by, self.manager) + self.assertIsNotNone(self.assignment.revoked_at) + self.assertEqual( + self.assignment.revoke_reason, + "Workload redistribution", + ) + self.assertIsNone(self.assignment.completed_at) + + def test_repeated_revoke_is_idempotent(self): + self.authenticate() + + first = self.client.post( + self.revoke_url, + {"reason": "Initial reason"}, + format="json", + ) + second = self.client.post( + self.revoke_url, + {"reason": "Replacement reason"}, + format="json", + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["id"], second.data["id"]) + + def test_repeated_revoke_preserves_original_audit_fields(self): + self.authenticate() + self.client.post( + self.revoke_url, + {"reason": "Initial reason"}, + format="json", + ) + self.assignment.refresh_from_db() + original = ( + self.assignment.revoked_at, + self.assignment.revoked_by_id, + self.assignment.revoke_reason, + ) + + self.authenticate(self.staff) + response = self.client.post( + self.revoke_url, + {"reason": "Replacement reason"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual( + ( + self.assignment.revoked_at, + self.assignment.revoked_by_id, + self.assignment.revoke_reason, + ), + original, + ) + + def test_completed_assignment_returns_409(self): + self.assignment.status = SubmissionExpertAssignment.STATUS_COMPLETED + self.assignment.completed_at = timezone.now() + self.assignment.save() + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Cannot revoke"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_submitted_evaluation_blocks_revoke(self): + Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + status=Evaluation.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Cannot revoke"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_draft_evaluation_does_not_block_revoke(self): + Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Reassign"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + + def test_draft_evaluation_and_scores_are_preserved(self): + evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + criterion = create_rate_criteria( + self.program, + type="float", + ) + score = EvaluationScore.objects.create( + evaluation=evaluation, + criterion=criterion, + value=Decimal("7.5"), + ) + self.authenticate() + + response = self.client.post( + self.revoke_url, + {"reason": "Reassign"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Evaluation.objects.filter(pk=evaluation.pk).exists()) + self.assertTrue(EvaluationScore.objects.filter(pk=score.pk).exists()) + score.refresh_from_db() + self.assertEqual(score.value, Decimal("7.500000")) + + def test_other_program_manager_gets_404(self): + self.authenticate(self.other_manager) + + response = self.client.post( + self.revoke_url, + {"reason": "Hidden assignment"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + def test_staff_can_revoke_assignment(self): + self.authenticate(self.staff) + + response = self.client.post( + self.revoke_url, + {"reason": "Staff reassignment"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.revoked_by, self.staff) + + def test_delete_is_not_supported(self): + self.authenticate() + + response = self.client.delete(self.revoke_url) + + self.assertEqual(response.status_code, 405) + self.assertTrue( + SubmissionExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + + def test_reason_is_required_and_nonempty(self): + self.authenticate() + + for payload in ({}, {"reason": ""}, {"reason": " "}): + with self.subTest(payload=payload): + response = self.client.post( + self.revoke_url, + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + + +class SubmissionAssignmentServiceRaceTests(SubmissionAssignmentTestCase): + def test_integrity_error_race_returns_existing_assigned_episode(self): + existing = self.create_assignment() + real_get_active_assignment = submission_assignment_service._get_active_assignment + lookup_count = 0 + + def simulate_race_lookup(**kwargs): + nonlocal lookup_count + lookup_count += 1 + if lookup_count == 1: + return None + return real_get_active_assignment(**kwargs) + + with patch( + "partner_programs.services.submission_assignments." "_get_active_assignment", + side_effect=simulate_race_lookup, + ), patch.object( + SubmissionExpertAssignment, + "full_clean", + return_value=None, + ): + result = create_submission_assignment( + program=self.program, + submission_id=self.submission.pk, + expert_id=self.expert.pk, + actor=self.manager, + ) + + self.assertFalse(result.created) + self.assertEqual(result.assignment, existing) + self.assertEqual(lookup_count, 2) + self.assertEqual( + SubmissionExpertAssignment.objects.get(pk=existing.pk), + existing, + ) + self.assertFalse(transaction.get_connection().needs_rollback) + + def test_integrity_error_race_with_completed_episode_is_conflict(self): + existing = self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + real_get_active_assignment = submission_assignment_service._get_active_assignment + lookup_count = 0 + + def simulate_race_lookup(**kwargs): + nonlocal lookup_count + lookup_count += 1 + if lookup_count == 1: + return None + return real_get_active_assignment(**kwargs) + + with patch( + "partner_programs.services.submission_assignments." "_get_active_assignment", + side_effect=simulate_race_lookup, + ), patch.object( + SubmissionExpertAssignment, + "full_clean", + return_value=None, + ): + with self.assertRaises(AssignmentCompletedError): + create_submission_assignment( + program=self.program, + submission_id=self.submission.pk, + expert_id=self.expert.pk, + actor=self.manager, + ) + + self.assertEqual(lookup_count, 2) + self.assertEqual( + SubmissionExpertAssignment.objects.get(pk=existing.pk), + existing, + ) + self.assertFalse(transaction.get_connection().needs_rollback) + + +class SubmissionAssignmentURLContractTests(SimpleTestCase): + def test_reverse_builds_exact_assignment_urls(self): + self.assertEqual( + reverse( + "partner_programs:submission-assignment-list-create", + kwargs={"program_id": 17}, + ), + "/programs/17/submission-assignments/", + ) + self.assertEqual( + reverse( + "submission-assignment-revoke", + kwargs={"assignment_id": 23}, + ), + "/submission-assignments/23/revoke/", + ) + + def test_incorrect_prefixed_assignment_urls_do_not_resolve(self): + for url in ( + "/programs/programs/17/submission-assignments/", + "/programs/submission-assignments/23/revoke/", + ): + with self.subTest(url=url): + with self.assertRaises(Resolver404): + resolve(url) diff --git a/partner_programs/tests/test_submission_evaluation_models.py b/partner_programs/tests/test_submission_evaluation_models.py new file mode 100644 index 00000000..83258396 --- /dev/null +++ b/partner_programs/tests/test_submission_evaluation_models.py @@ -0,0 +1,290 @@ +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db.models.deletion import ProtectedError +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationScore, + Submission, + SubmissionExpertAssignment, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +class SubmissionEvaluationModelTestCase(TestCase): + def setUp(self): + self.user = create_user(prefix="evaluation-participant") + self.manager = create_user(prefix="evaluation-manager") + self.program = create_partner_program() + self.application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + self.submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.user, + title="Evaluation solution", + ) + self.expert_user = create_rate_expert( + prefix="submission-expert", + program=self.program, + ) + self.expert = self.expert_user.expert + + +class SubmissionExpertAssignmentModelTests(SubmissionEvaluationModelTestCase): + def create_assignment(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + "assigned_by": self.manager, + } + values.update(overrides) + return SubmissionExpertAssignment.objects.create(**values) + + def test_can_create_assigned_assignment_with_defaults(self): + assignment = self.create_assignment() + + self.assertEqual( + assignment.status, + SubmissionExpertAssignment.STATUS_ASSIGNED, + ) + self.assertIsNotNone(assignment.assigned_at) + self.assertIsNotNone(assignment.created_at) + self.assertIsNotNone(assignment.updated_at) + self.assertIsNone(assignment.completed_at) + self.assertIsNone(assignment.revoked_at) + self.assertIsNone(assignment.revoked_by) + self.assertEqual(assignment.revoke_reason, "") + + def test_duplicate_assigned_assignment_is_rejected(self): + self.create_assignment() + + with self.assertRaises(ValidationError): + self.create_assignment() + + def test_assigned_assignment_is_rejected_after_completed_assignment(self): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + completed_at=timezone.now(), + ) + + with self.assertRaises(ValidationError): + self.create_assignment() + + def test_multiple_revoked_assignments_are_allowed(self): + for reason in ("Переназначение", "Изменение состава экспертов"): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + revoked_at=timezone.now(), + revoke_reason=reason, + ) + + self.assertEqual( + SubmissionExpertAssignment.objects.filter( + submission=self.submission, + expert=self.expert, + ).count(), + 2, + ) + + def test_completed_assignment_requires_completed_at(self): + with self.assertRaises(ValidationError): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_COMPLETED, + ) + + def test_revoked_assignment_requires_revoked_at(self): + with self.assertRaises(ValidationError): + self.create_assignment( + status=SubmissionExpertAssignment.STATUS_REVOKED, + revoked_by=self.manager, + ) + + def test_assigned_assignment_rejects_lifecycle_timestamps(self): + for field in ("completed_at", "revoked_at"): + with self.subTest(field=field): + with self.assertRaises(ValidationError): + self.create_assignment(**{field: timezone.now()}) + + +class EvaluationModelTests(SubmissionEvaluationModelTestCase): + def create_evaluation(self, **overrides): + values = { + "submission": self.submission, + "expert": self.expert, + } + values.update(overrides) + return Evaluation.objects.create(**values) + + def test_can_create_draft_evaluation(self): + evaluation = self.create_evaluation() + + self.assertEqual(evaluation.status, Evaluation.STATUS_DRAFT) + self.assertEqual(evaluation.comment, "") + self.assertIsNone(evaluation.total_score) + self.assertIsNone(evaluation.submitted_at) + + def test_submission_and_expert_are_unique(self): + self.create_evaluation() + + with self.assertRaises(ValidationError): + self.create_evaluation() + + def test_draft_evaluation_rejects_submitted_at(self): + with self.assertRaises(ValidationError): + self.create_evaluation(submitted_at=timezone.now()) + + def test_submitted_evaluation_requires_submitted_at(self): + with self.assertRaises(ValidationError): + self.create_evaluation(status=Evaluation.STATUS_SUBMITTED) + + def test_submitted_evaluation_with_submitted_at_is_allowed(self): + submitted_at = timezone.now() + + evaluation = self.create_evaluation( + status=Evaluation.STATUS_SUBMITTED, + submitted_at=submitted_at, + ) + + self.assertEqual(evaluation.submitted_at, submitted_at) + + +class EvaluationScoreModelTests(SubmissionEvaluationModelTestCase): + def setUp(self): + super().setUp() + self.evaluation = Evaluation.objects.create( + submission=self.submission, + expert=self.expert, + ) + self.criterion = create_rate_criteria( + self.program, + name="Impact", + type="float", + min_value=0.5, + max_value=10.5, + ) + + def create_score(self, **overrides): + values = { + "evaluation": self.evaluation, + "criterion": self.criterion, + "value": Decimal("7.250000"), + } + values.update(overrides) + return EvaluationScore.objects.create(**values) + + def test_can_create_numeric_evaluation_score_with_decimal_value(self): + score = self.create_score() + + self.assertEqual(score.value, Decimal("7.250000")) + self.assertIsInstance(score.value, Decimal) + + def test_int_criterion_rejects_fractional_decimal(self): + criterion = create_rate_criteria( + self.program, + name="Integer impact", + type="int", + ) + + with self.assertRaises(ValidationError) as error: + self.create_score( + criterion=criterion, + value=Decimal("7.5"), + ) + + self.assertIn("value", error.exception.message_dict) + + def test_int_criterion_accepts_whole_decimal(self): + criterion = create_rate_criteria( + self.program, + name="Integer impact", + type="int", + ) + + score = self.create_score( + criterion=criterion, + value=Decimal("7"), + ) + + self.assertEqual(score.value, Decimal("7")) + + def test_evaluation_and_criterion_are_unique(self): + self.create_score() + + with self.assertRaises(ValidationError): + self.create_score(value=Decimal("8.000000")) + + def test_used_criterion_is_protected(self): + self.create_score() + + with self.assertRaises(ProtectedError): + self.criterion.delete() + + def test_criterion_snapshot_is_captured(self): + score = self.create_score() + + self.assertEqual(score.criterion_name, self.criterion.name) + self.assertEqual(score.criterion_type, "float") + self.assertEqual(score.min_value, 0.5) + self.assertEqual(score.max_value, 10.5) + + def test_existing_score_uses_snapshot_type_after_criterion_changes(self): + score = self.create_score(value=Decimal("7.5")) + self.criterion.type = "str" + self.criterion.save() + + score.value = Decimal("8.5") + score.save() + score.refresh_from_db() + + self.assertEqual(score.value, Decimal("8.500000")) + self.assertEqual(score.criterion_type, "float") + + def test_existing_score_save_does_not_overwrite_snapshot(self): + score = self.create_score() + original_snapshot = ( + score.criterion_name, + score.criterion_type, + score.min_value, + score.max_value, + ) + self.criterion.name = "Updated impact" + self.criterion.type = "str" + self.criterion.min_value = 1 + self.criterion.max_value = 20 + self.criterion.save() + + score.value = Decimal("8.5") + score.save() + score.refresh_from_db() + + self.assertEqual( + ( + score.criterion_name, + score.criterion_type, + score.min_value, + score.max_value, + ), + original_snapshot, + ) + + def test_non_numeric_criterion_is_rejected(self): + criterion = create_rate_criteria( + self.program, + name="Comment", + type="str", + ) + + with self.assertRaises(ValidationError) as error: + self.create_score(criterion=criterion) + + self.assertIn("criterion", error.exception.message_dict) diff --git a/partner_programs/tests/test_submission_model.py b/partner_programs/tests/test_submission_model.py new file mode 100644 index 00000000..805f1380 --- /dev/null +++ b/partner_programs/tests/test_submission_model.py @@ -0,0 +1,199 @@ +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.test import TestCase + +from partner_programs.models import Application, Submission +from partner_programs.tests.helpers import create_partner_program, create_user + + +class SubmissionModelTests(TestCase): + def setUp(self): + self.user = create_user(prefix="submission-user") + self.program = create_partner_program() + self.application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + + def create_submission(self, **overrides): + values = { + "application": self.application, + "program": self.program, + "submitted_by": self.user, + "title": "MVP solution", + } + values.update(overrides) + return Submission.objects.create(**values) + + def test_can_create_draft_submission_for_application(self): + submission = self.create_submission() + + self.assertEqual(submission.application, self.application) + self.assertEqual(submission.program, self.program) + self.assertEqual(submission.submitted_by, self.user) + self.assertEqual(submission.status, Submission.STATUS_DRAFT) + + def test_form_data_defaults_to_dict(self): + submission = self.create_submission() + + self.assertEqual(submission.form_data, {}) + self.assertIsInstance(submission.form_data, dict) + + def test_links_default_to_list(self): + submission = self.create_submission() + + self.assertEqual(submission.links, []) + self.assertIsInstance(submission.links, list) + + def test_status_defaults_to_draft(self): + submission = self.create_submission() + + self.assertEqual(submission.status, Submission.STATUS_DRAFT) + + def test_version_defaults_to_one(self): + submission = self.create_submission() + + self.assertEqual(submission.version, 1) + + def test_stage_key_defaults_to_main(self): + submission = self.create_submission() + + self.assertEqual(submission.stage_key, "main") + + def test_project_is_not_required_or_present_on_submission(self): + submission = self.create_submission() + + with self.assertRaises(FieldDoesNotExist): + Submission._meta.get_field("project") + self.assertIsNotNone(submission.pk) + + def test_application_is_required(self): + submission = Submission( + program=self.program, + submitted_by=self.user, + title="Missing application", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("application", error.exception.message_dict) + + def test_program_is_required(self): + submission = Submission( + application=self.application, + submitted_by=self.user, + title="Missing program", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("program", error.exception.message_dict) + + def test_submitted_by_is_required(self): + submission = Submission( + application=self.application, + program=self.program, + title="Missing submitter", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("submitted_by", error.exception.message_dict) + + def test_program_must_match_application_program(self): + other_program = create_partner_program() + + with self.assertRaises(ValidationError) as error: + self.create_submission(program=other_program) + + self.assertIn("program", error.exception.message_dict) + + def test_submitted_by_must_match_application_owner_or_creator(self): + other_user = create_user(prefix="submission-other-user") + + with self.assertRaises(ValidationError) as error: + self.create_submission(submitted_by=other_user) + + self.assertIn("submitted_by", error.exception.message_dict) + + def test_inactive_application_cannot_have_submission(self): + for status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + with self.subTest(status=status): + self.application.status = status + self.application.save() + + with self.assertRaises(ValidationError) as error: + self.create_submission() + + self.assertIn("application", error.exception.message_dict) + + def test_duplicate_application_stage_and_version_is_rejected(self): + self.create_submission() + + with self.assertRaises(ValidationError): + self.create_submission(title="Duplicate solution") + + def test_version_two_is_allowed_for_same_application_and_stage(self): + self.create_submission() + + version_two = self.create_submission(title="Version two", version=2) + + self.assertEqual(version_two.version, 2) + + def test_other_stage_is_allowed_for_same_application_and_version(self): + self.create_submission() + + other_stage = self.create_submission(title="Final stage", stage_key="final") + + self.assertEqual(other_stage.stage_key, "final") + + def test_version_must_be_at_least_one(self): + with self.assertRaises(ValidationError): + self.create_submission(version=0) + + def test_can_edit_for_draft_and_returned_only(self): + for status in (Submission.STATUS_DRAFT, Submission.STATUS_RETURNED): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertTrue(submission.can_edit) + + for status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertFalse(submission.can_edit) + + def test_can_submit_for_draft_and_returned_only(self): + for status in (Submission.STATUS_DRAFT, Submission.STATUS_RETURNED): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertTrue(submission.can_submit) + + for status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertFalse(submission.can_submit) + + def test_status_helpers(self): + self.assertTrue(Submission(status=Submission.STATUS_DRAFT).is_draft) + self.assertTrue(Submission(status=Submission.STATUS_SUBMITTED).is_submitted) + self.assertTrue(Submission(status=Submission.STATUS_FINAL).is_final) + + def test_submitted_at_can_be_null(self): + submission = self.create_submission() + + self.assertIsNone(submission.submitted_at) diff --git a/partner_programs/tests/test_submission_project_api.py b/partner_programs/tests/test_submission_project_api.py new file mode 100644 index 00000000..c2740403 --- /dev/null +++ b/partner_programs/tests/test_submission_project_api.py @@ -0,0 +1,265 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import Application, Submission, Team, TeamMember +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) +from projects.models import Collaborator, Project, ProjectLink + + +class SubmissionProjectAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.owner = create_user(prefix="submission-project-owner") + self.other = create_user(prefix="submission-project-other") + self.member = create_user(prefix="submission-project-member") + self.inactive_member = create_user(prefix="submission-project-inactive") + self.staff = create_user(prefix="submission-project-staff", is_staff=True) + self.superuser = create_user( + prefix="submission-project-superuser", + is_staff=True, + is_superuser=True, + ) + self.program = create_partner_program() + self.application = self.create_application() + + def create_application(self, **overrides): + values = { + "program": self.program, + "user": self.owner, + "created_by": self.owner, + "status": Application.STATUS_SUBMITTED, + "submitted_at": timezone.now(), + } + values.update(overrides) + return Application.objects.create(**values) + + def create_submission(self, **overrides): + values = { + "application": self.application, + "program": self.application.program, + "submitted_by": self.application.user, + "title": "Reusable solution", + "description": "Submission description", + "links": [ + "https://example.com/demo", + "invalid-link", + " https://example.com/demo ", + 123, + ], + "status": Submission.STATUS_SUBMITTED, + } + values.update(overrides) + return Submission.objects.create(**values) + + def post(self, submission, user=None): + self.client.force_authenticate(user=user or self.owner) + return self.client.post( + f"/submissions/{submission.pk}/project/", {}, format="json" + ) + + def test_endpoint_requires_authentication(self): + submission = self.create_submission() + self.client.force_authenticate(user=None) + + response = self.client.post(f"/submissions/{submission.pk}/project/") + + self.assertEqual(response.status_code, 401) + + def test_owner_creates_private_draft_project_from_submitted_submission(self): + submission = self.create_submission() + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + self.assertTrue(response.data["created"]) + project = Project.objects.get(pk=response.data["project"]["id"]) + self.application.refresh_from_db() + self.assertEqual(self.application.project, project) + self.assertEqual(project.leader, self.owner) + self.assertEqual(project.name, submission.title) + self.assertEqual(project.description, submission.description) + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + self.assertEqual( + list( + ProjectLink.objects.filter(project=project).values_list("link", flat=True) + ), + ["https://example.com/demo"], + ) + + def test_final_submission_can_create_project(self): + submission = self.create_submission(status=Submission.STATUS_FINAL) + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + + def test_ineligible_submission_statuses_are_rejected(self): + for index, submission_status in enumerate( + ( + Submission.STATUS_DRAFT, + Submission.STATUS_RETURNED, + Submission.STATUS_CANCELLED, + ), + start=1, + ): + with self.subTest(status=submission_status): + submission = self.create_submission( + status=submission_status, + version=index, + ) + response = self.post(submission) + self.assertEqual(response.status_code, 400) + self.application.refresh_from_db() + self.assertIsNone(self.application.project) + + def test_outsider_and_accepted_member_receive_safe_not_found(self): + self.application.participation_mode = Application.PARTICIPATION_MODE_TEAM + self.application.save(update_fields=["participation_mode", "updated_at"]) + submission = self.create_submission() + team = Team.objects.create( + application=self.application, + captain=self.owner, + name="Team", + ) + TeamMember.objects.create( + team=team, + user=self.owner, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.member, + status=TeamMember.STATUS_ACCEPTED, + ) + + outsider_response = self.post(submission, self.other) + member_response = self.post(submission, self.member) + + self.assertEqual(outsider_response.status_code, 404) + self.assertEqual(member_response.status_code, 404) + + def test_staff_can_create_project(self): + submission = self.create_submission() + + response = self.post(submission, self.staff) + superuser_response = self.post(submission, self.superuser) + + self.assertEqual(response.status_code, 201) + self.assertEqual(superuser_response.status_code, 200) + self.assertEqual(response.data["project"]["leader"]["id"], self.owner.pk) + + def test_team_project_adds_only_accepted_non_captain_members(self): + self.application.participation_mode = Application.PARTICIPATION_MODE_TEAM + self.application.save(update_fields=["participation_mode", "updated_at"]) + team = Team.objects.create( + application=self.application, + captain=self.owner, + name="Accepted team", + ) + TeamMember.objects.create( + team=team, + user=self.owner, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.member, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.inactive_member, + status=TeamMember.STATUS_INVITED, + ) + submission = self.create_submission() + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + project_id = response.data["project"]["id"] + collaborators = set( + Collaborator.objects.filter(project_id=project_id).values_list( + "user_id", flat=True + ) + ) + self.assertEqual(collaborators, {self.owner.pk, self.member.pk}) + + def test_individual_project_does_not_add_other_collaborators(self): + submission = self.create_submission() + + response = self.post(submission) + + project_id = response.data["project"]["id"] + self.assertEqual( + list( + Collaborator.objects.filter(project_id=project_id).values_list( + "user_id", flat=True + ) + ), + [self.owner.pk], + ) + + def test_repeated_request_and_other_version_are_idempotent(self): + first = self.create_submission(version=1) + second = self.create_submission(version=2, title="Newer title") + + created_response = self.post(first) + repeated_response = self.post(first) + other_version_response = self.post(second) + + self.assertEqual(created_response.status_code, 201) + self.assertEqual(repeated_response.status_code, 200) + self.assertEqual(other_version_response.status_code, 200) + self.assertFalse(repeated_response.data["created"]) + self.assertFalse(other_version_response.data["created"]) + project_ids = { + created_response.data["project"]["id"], + repeated_response.data["project"]["id"], + other_version_response.data["project"]["id"], + } + self.assertEqual(len(project_ids), 1) + project = Project.objects.get(pk=project_ids.pop()) + self.assertEqual(project.name, first.title) + + def test_existing_application_project_is_returned_without_changes(self): + existing_project = create_project( + leader=self.owner, + name="Existing project", + description="Existing description", + draft=False, + is_public=True, + ) + self.application.project = existing_project + self.application.save(update_fields=["project", "updated_at"]) + submission = self.create_submission(title="Must not overwrite") + + response = self.post(submission) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["created"]) + existing_project.refresh_from_db() + self.assertIn("Existing project", existing_project.name) + self.assertEqual(existing_project.description, "Existing description") + + def test_one_project_can_be_reused_by_applications_in_different_programs(self): + project = create_project(leader=self.owner, draft=True, is_public=False) + self.application.project = project + self.application.save(update_fields=["project", "updated_at"]) + second_program = create_partner_program() + second_application = Application.objects.create( + program=second_program, + user=self.owner, + created_by=self.owner, + project=project, + ) + + self.assertEqual(self.application.project, second_application.project) + self.assertEqual(project.applications.count(), 2) diff --git a/partner_programs/tests/test_team_api.py b/partner_programs/tests/test_team_api.py new file mode 100644 index 00000000..ecb64494 --- /dev/null +++ b/partner_programs/tests/test_team_api.py @@ -0,0 +1,528 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + Submission, + TeamMember, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import TeamMutationScopedRateThrottle + + +class TeamAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.captain = create_user(prefix="team-api-captain") + self.member_user = create_user(prefix="team-api-member") + self.manager = create_user(prefix="team-api-manager") + self.staff = create_user(prefix="team-api-staff", is_staff=True) + self.outsider = create_user(prefix="team-api-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="API команда", + ).application + self.team = self.application.team + self.member = TeamMember.objects.create( + team=self.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + @property + def team_url(self): + return f"/applications/{self.application.pk}/team/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def add_member(self, *, status=TeamMember.STATUS_ACCEPTED, register=True): + user = create_user(prefix="team-api-extra") + if register: + create_program_member(self.program, user=user) + return TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=self.captain, + ) + + def test_unauthenticated_user_cannot_read_team(self): + response = self.client.get(self.team_url) + self.assertEqual(response.status_code, 401) + + def test_captain_member_manager_and_staff_can_read_team(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + self.authenticate(user) + with self.subTest(user=user): + response = self.client.get(self.team_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.team.pk) + + def test_outsider_and_historical_members_cannot_read_team(self): + self.authenticate(self.outsider) + self.assertEqual(self.client.get(self.team_url).status_code, 404) + + for membership_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = membership_status + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.member_user) + with self.subTest(status=membership_status): + self.assertEqual(self.client.get(self.team_url).status_code, 404) + + def test_individual_application_has_no_team_endpoint(self): + owner = create_user(prefix="individual-team-api") + individual_program = create_partner_program() + application = Application.objects.create( + program=individual_program, + user=owner, + created_by=owner, + ) + self.authenticate(owner) + + response = self.client.get(f"/applications/{application.pk}/team/") + + self.assertEqual(response.status_code, 404) + + def test_team_response_is_sorted_and_does_not_expose_private_user_data(self): + invited = self.add_member(status=TeamMember.STATUS_INVITED, register=False) + self.authenticate(self.captain) + + response = self.client.get(self.team_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["application_id"], self.application.pk) + self.assertEqual(response.data["program_id"], self.program.pk) + self.assertEqual(response.data["team_min_size"], 2) + self.assertEqual(response.data["team_max_size"], 5) + self.assertEqual(response.data["accepted_members_count"], 2) + self.assertEqual(response.data["current_user_role"], TeamMember.ROLE_CAPTAIN) + member_ids = [item["id"] for item in response.data["members"]] + self.assertEqual(member_ids[0], self.team.members.get(role="captain").pk) + self.assertEqual(member_ids[-1], invited.pk) + for item in response.data["members"]: + self.assertEqual( + set(item["user"]), + {"id", "display_name", "avatar"}, + ) + self.assertNotIn("email", item["user"]) + + def test_capability_flags_follow_role_and_draft_state(self): + expected = { + self.captain: (True, True, False), + self.member_user: (False, False, True), + self.manager: (False, False, False), + self.staff: (True, True, False), + } + for user, flags in expected.items(): + self.authenticate(user) + response = self.client.get(self.team_url) + with self.subTest(user=user): + self.assertEqual( + ( + response.data["can_edit"], + response.data["can_manage_members"], + response.data["can_leave"], + ), + flags, + ) + + def test_captain_can_rename_team_and_only_name_is_writable(self): + self.authenticate(self.captain) + response = self.client.patch( + self.team_url, + {"name": "Новое имя"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Новое имя") + + blocked = self.client.patch( + self.team_url, + { + "name": "Попытка", + "captain": self.member_user.pk, + "application": 999, + "members": [], + }, + format="json", + ) + self.assertEqual(blocked.status_code, 400) + self.team.refresh_from_db() + self.assertEqual(self.team.name, "Новое имя") + self.assertEqual(self.team.captain, self.captain) + + def test_member_and_manager_cannot_rename_team(self): + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + response = self.client.patch( + self.team_url, + {"name": "Запрещено"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + + def test_rename_is_blocked_after_deadline_or_submit(self): + self.authenticate(self.captain) + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=( + timezone.now() - timezone.timedelta(seconds=1) + ) + ) + self.assertEqual( + self.client.patch( + self.team_url, + {"name": "Поздно"}, + format="json", + ).status_code, + 400, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + self.assertEqual( + self.client.patch( + self.team_url, + {"name": "Отправлено"}, + format="json", + ).status_code, + 400, + ) + + @patch.object(TeamMutationScopedRateThrottle, "rate", "1/min") + def test_team_rename_has_scoped_throttle(self): + self.authenticate(self.captain) + first = self.client.patch(self.team_url, {"name": "Раз"}, format="json") + second = self.client.patch(self.team_url, {"name": "Два"}, format="json") + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + def test_member_can_leave_and_repeat_is_stable_error(self): + self.authenticate(self.member_user) + url = f"/applications/{self.application.pk}/team/leave/" + first = self.client.post(url, {}, format="json") + second = self.client.post(url, {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(first.data["status"], TeamMember.STATUS_LEFT) + self.assertEqual(second.status_code, 400) + self.member.refresh_from_db() + self.assertIsNotNone(self.member.joined_at) + + def test_captain_cannot_leave_and_member_cannot_leave_after_submit(self): + self.authenticate(self.captain) + url = f"/applications/{self.application.pk}/team/leave/" + self.assertEqual(self.client.post(url, {}, format="json").status_code, 400) + + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + self.authenticate(self.member_user) + self.assertEqual(self.client.post(url, {}, format="json").status_code, 400) + + def test_captain_can_remove_member_but_member_and_manager_cannot(self): + url = ( + f"/applications/{self.application.pk}/team/members/" + f"{self.member.pk}/remove/" + ) + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual( + self.client.post(url, {}, format="json").status_code, + 403, + ) + + self.authenticate(self.captain) + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], TeamMember.STATUS_REMOVED) + + def test_remove_rejects_captain_or_member_of_another_team(self): + captain_member = self.team.members.get(role=TeamMember.ROLE_CAPTAIN) + self.authenticate(self.captain) + captain_url = ( + f"/applications/{self.application.pk}/team/members/" + f"{captain_member.pk}/remove/" + ) + self.assertEqual( + self.client.post(captain_url, {}, format="json").status_code, + 400, + ) + + other = self.add_member() + other.team = self.team + wrong_url = ( + f"/applications/{self.application.pk + 999}/team/members/" + f"{other.pk}/remove/" + ) + self.assertEqual( + self.client.post(wrong_url, {}, format="json").status_code, + 404, + ) + + def test_captain_can_transfer_captainship(self): + self.authenticate(self.captain) + response = self.client.post( + f"/applications/{self.application.pk}/team/transfer-captain/", + {"member_id": self.member.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.application.refresh_from_db() + self.team.refresh_from_db() + self.assertEqual(self.application.user, self.member_user) + self.assertEqual(self.team.captain, self.member_user) + + def test_transfer_rejects_manager_and_invalid_target(self): + url = f"/applications/{self.application.pk}/team/transfer-captain/" + self.authenticate(self.manager) + self.assertEqual( + self.client.post( + url, + {"member_id": self.member.pk}, + format="json", + ).status_code, + 403, + ) + + self.member.status = TeamMember.STATUS_INVITED + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + url, + {"member_id": self.member.pk}, + format="json", + ).status_code, + 400, + ) + + def test_application_response_contains_compact_team_summary(self): + self.authenticate(self.captain) + response = self.client.get(f"/applications/{self.application.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data["team"]), + { + "id", + "name", + "captain_id", + "accepted_members_count", + "current_user_role", + }, + ) + self.assertEqual(response.data["team"]["accepted_members_count"], 2) + self.assertIn("form_data", response.data) + self.assertIn("participation_mode", response.data) + + def test_member_and_manager_get_read_only_application_access(self): + detail_url = f"/applications/{self.application.pk}/" + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual(self.client.get(detail_url).status_code, 200) + self.assertEqual( + self.client.patch( + detail_url, + {"form_data": {"changed": True}}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/applications/{self.application.pk}/submit/", + {}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/applications/{self.application.pk}/withdraw/", + {}, + format="json", + ).status_code, + 403, + ) + + def test_my_application_prefers_owned_then_accepted_team_membership(self): + self.authenticate(self.member_user) + url = f"/programs/{self.program.pk}/applications/my/" + membership_response = self.client.get(url) + self.assertEqual(membership_response.status_code, 200) + self.assertEqual(membership_response.data["id"], self.application.pk) + + own = Application.objects.create( + program=self.program, + user=self.member_user, + created_by=self.member_user, + ) + own_response = self.client.get(url) + self.assertEqual(own_response.data["id"], own.pk) + + def test_my_application_returns_latest_terminal_team_membership(self): + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_WITHDRAWN, + withdrawn_at=timezone.now(), + ) + self.authenticate(self.member_user) + + response = self.client.get( + f"/programs/{self.program.pk}/applications/my/" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.application.pk) + + def test_historical_member_does_not_get_application_access(self): + self.member.status = TeamMember.STATUS_REMOVED + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.member_user) + + self.assertEqual( + self.client.get(f"/applications/{self.application.pk}/").status_code, + 404, + ) + self.assertEqual( + self.client.get( + f"/programs/{self.program.pk}/applications/my/" + ).status_code, + 404, + ) + + def test_member_and_manager_have_read_only_submission_access(self): + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Командное решение", + ) + list_url = f"/applications/{self.application.pk}/submissions/" + detail_url = f"/submissions/{submission.pk}/" + + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual(self.client.get(list_url).status_code, 200) + self.assertEqual(self.client.get(detail_url).status_code, 200) + self.assertEqual( + self.client.post( + list_url, + {"title": "Запрещено"}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.patch( + detail_url, + {"title": "Запрещено"}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/submissions/{submission.pk}/submit/", + {}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/submissions/{submission.pk}/cancel/", + {}, + format="json", + ).status_code, + 403, + ) + + def test_outsider_cannot_read_submission(self): + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Закрытое решение", + ) + self.authenticate(self.outsider) + self.assertEqual( + self.client.get(f"/submissions/{submission.pk}/").status_code, + 404, + ) + + def test_transfer_moves_submission_write_access_to_new_captain(self): + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Черновик", + ) + self.authenticate(self.captain) + transfer_response = self.client.post( + f"/applications/{self.application.pk}/team/transfer-captain/", + {"member_id": self.member.pk}, + format="json", + ) + self.assertEqual(transfer_response.status_code, 200) + + detail_url = f"/submissions/{submission.pk}/" + old_captain_response = self.client.patch( + detail_url, + {"title": "Запрещено"}, + format="json", + ) + self.assertEqual(old_captain_response.status_code, 403) + self.assertEqual(self.client.get(detail_url).status_code, 200) + + self.authenticate(self.member_user) + new_captain_response = self.client.patch( + detail_url, + {"title": "Новый капитан"}, + format="json", + ) + self.assertEqual(new_captain_response.status_code, 200) + self.assertEqual(new_captain_response.data["title"], "Новый капитан") diff --git a/partner_programs/tests/test_team_invite_api.py b/partner_programs/tests/test_team_invite_api.py new file mode 100644 index 00000000..d352f6c2 --- /dev/null +++ b/partner_programs/tests/test_team_invite_api.py @@ -0,0 +1,451 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + Submission, + TeamInvite, + TeamMember, +) +from notifications.models import Notification +from partner_programs.services.application_team import create_or_get_application +from partner_programs.services.team_invites import create_team_invite +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import TeamInviteMutationScopedRateThrottle + + +class TeamInviteAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.captain = create_user(prefix="invite-api-captain") + self.target = create_user(prefix="invite-api-target") + self.member_user = create_user(prefix="invite-api-member") + self.manager = create_user(prefix="invite-api-manager") + self.staff = create_user(prefix="invite-api-staff", is_staff=True) + self.outsider = create_user(prefix="invite-api-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=6, + ) + self.program.managers.add(self.manager) + for user in (self.captain, self.target, self.member_user): + create_program_member(self.program, user=user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="API приглашения", + ).application + self.team = self.application.team + self.member = TeamMember.objects.create( + team=self.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + @property + def list_url(self): + return f"/applications/{self.application.pk}/team/invites/" + + @staticmethod + def action_url(invite, action): + return f"/team-invites/{invite.pk}/{action}/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def create_target(self, prefix="invite-api-extra"): + user = create_user(prefix=prefix) + create_program_member(self.program, user=user) + return user + + def create_invite(self, *, target=None, actor=None): + return create_team_invite( + team=self.team, + actor=actor or self.captain, + target=target or self.target, + ).invite + + def test_anonymous_user_cannot_use_invite_endpoints(self): + invite = self.create_invite() + + self.assertEqual(self.client.get(self.list_url).status_code, 401) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 401, + ) + self.assertEqual(self.client.get("/team-invites/my/").status_code, 401) + for action in ("accept", "decline", "revoke"): + with self.subTest(action=action): + self.assertEqual( + self.client.post(self.action_url(invite, action)).status_code, + 401, + ) + + def test_captain_and_staff_can_list_but_member_manager_cannot(self): + self.create_invite() + for user in (self.captain, self.staff): + self.authenticate(user) + with self.subTest(allowed=user): + self.assertEqual(self.client.get(self.list_url).status_code, 200) + + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(forbidden=user): + self.assertEqual(self.client.get(self.list_url).status_code, 403) + + self.authenticate(self.outsider) + self.assertEqual(self.client.get(self.list_url).status_code, 404) + + def test_create_is_idempotent_and_exposes_only_safe_user_fields(self): + self.authenticate(self.captain) + first = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + second = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["id"], second.data["id"]) + self.assertEqual( + set(first.data["user"]), + {"id", "display_name", "avatar"}, + ) + self.assertEqual( + set(first.data["invited_by"]), + {"id", "display_name", "avatar"}, + ) + self.assertNotIn("email", first.data["user"]) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) + self.assertEqual( + Notification.objects.filter( + recipient=self.target, + type=Notification.Type.TEAM_INVITE_CREATED, + ).count(), + 1, + ) + + def test_member_and_manager_cannot_create_outsider_gets_hidden_404(self): + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 403, + ) + + self.authenticate(self.outsider) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 404, + ) + + def test_staff_can_create_invite(self): + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["invited_by"]["id"], self.staff.pk) + + def test_unknown_target_and_domain_state_errors_return_400(self): + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": 99999999}, + format="json", + ).status_code, + 400, + ) + + self.program.team_max_size = 2 + self.program.save(update_fields=["team_max_size"]) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 400, + ) + + def test_my_invites_returns_only_current_user_pending_first(self): + resolved = TeamInvite.objects.create( + team=self.team, + user=self.target, + invited_by=self.captain, + status=TeamInvite.STATUS_DECLINED, + resolved_at=timezone.now(), + ) + pending = self.create_invite() + other_target = self.create_target() + self.create_invite(target=other_target) + self.authenticate(self.target) + + response = self.client.get("/team-invites/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data], [pending.pk, resolved.pk] + ) + self.assertEqual(response.data[0]["application_id"], self.application.pk) + self.assertEqual(response.data[0]["program"]["id"], self.program.pk) + self.assertEqual(response.data[0]["captain"]["id"], self.captain.pk) + self.assertTrue(response.data[0]["can_accept"]) + self.assertTrue(response.data[0]["can_decline"]) + + def test_pending_invite_does_not_grant_access_but_accept_does(self): + invite = self.create_invite() + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Закрытое решение", + ) + application_url = f"/applications/{self.application.pk}/" + team_url = f"/applications/{self.application.pk}/team/" + submission_url = f"/submissions/{submission.pk}/" + self.authenticate(self.target) + + self.assertEqual(self.client.get(application_url).status_code, 404) + self.assertEqual(self.client.get(team_url).status_code, 404) + self.assertEqual(self.client.get(submission_url).status_code, 404) + + accepted = self.client.post(self.action_url(invite, "accept"), {}, format="json") + + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.data["status"], TeamInvite.STATUS_ACCEPTED) + self.assertTrue( + Notification.objects.filter( + recipient=self.captain, + type=Notification.Type.TEAM_INVITE_ACCEPTED, + ).exists() + ) + self.assertEqual(self.client.get(application_url).status_code, 200) + self.assertEqual(self.client.get(team_url).status_code, 200) + self.assertEqual(self.client.get(submission_url).status_code, 200) + self.assertEqual( + self.client.patch( + application_url, {"form_data": {}}, format="json" + ).status_code, + 403, + ) + self.assertEqual( + self.client.patch(team_url, {"name": "Нельзя"}, format="json").status_code, + 403, + ) + self.assertEqual( + self.client.patch( + submission_url, {"title": "Нельзя"}, format="json" + ).status_code, + 403, + ) + + def test_accept_decline_and_revoke_actions(self): + accepted_invite = self.create_invite() + self.authenticate(self.target) + accepted = self.client.post( + self.action_url(accepted_invite, "accept"), + {}, + format="json", + ) + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.data["status"], TeamInvite.STATUS_ACCEPTED) + + decline_target = self.create_target(prefix="invite-api-decline") + decline_invite = self.create_invite(target=decline_target) + self.authenticate(decline_target) + declined = self.client.post( + self.action_url(decline_invite, "decline"), + {}, + format="json", + ) + self.assertEqual(declined.status_code, 200) + self.assertEqual(declined.data["status"], TeamInvite.STATUS_DECLINED) + self.assertTrue( + Notification.objects.filter( + recipient=self.captain, + type=Notification.Type.TEAM_INVITE_DECLINED, + ).exists() + ) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=decline_target).exists() + ) + + revoke_target = self.create_target(prefix="invite-api-revoke") + revoke_invite = self.create_invite(target=revoke_target) + self.authenticate(self.captain) + revoked = self.client.post( + self.action_url(revoke_invite, "revoke"), + {}, + format="json", + ) + self.assertTrue( + Notification.objects.filter( + recipient=revoke_target, + type=Notification.Type.TEAM_INVITE_REVOKED, + ).exists() + ) + self.assertEqual(revoked.status_code, 200) + self.assertEqual(revoked.data["status"], TeamInvite.STATUS_REVOKED) + + def test_action_permission_boundaries_are_404_or_403(self): + invite = self.create_invite() + self.authenticate(self.outsider) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 404, + ) + + self.authenticate(self.captain) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 403, + ) + + self.authenticate(self.target) + declined = self.client.post(self.action_url(invite, "decline")) + self.assertEqual(declined.status_code, 200) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 400, + ) + self.assertEqual( + self.client.post("/team-invites/999999/accept/").status_code, 404 + ) + + def test_invite_mutations_are_blocked_after_application_submit(self): + accept_target = self.target + decline_target = self.create_target(prefix="invite-api-locked-decline") + revoke_target = self.create_target(prefix="invite-api-locked-revoke") + accept_invite = self.create_invite(target=accept_target) + decline_invite = self.create_invite(target=decline_target) + revoke_invite = self.create_invite(target=revoke_target) + create_target = self.create_target(prefix="invite-api-locked-create") + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": create_target.pk}, + format="json", + ).status_code, + 400, + ) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 400, + ) + self.authenticate(accept_target) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 400, + ) + self.authenticate(decline_target) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 400, + ) + + @patch.object(TeamInviteMutationScopedRateThrottle, "rate", "1/min") + def test_create_has_own_throttle_and_get_is_not_throttled(self): + self.authenticate(self.captain) + first_target = self.target + second_target = self.create_target(prefix="invite-api-throttle-create") + + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": first_target.pk}, + format="json", + ).status_code, + 201, + ) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": second_target.pk}, + format="json", + ).status_code, + 429, + ) + self.assertEqual(self.client.get(self.list_url).status_code, 200) + self.assertEqual(self.client.get(self.list_url).status_code, 200) + + @patch.object(TeamInviteMutationScopedRateThrottle, "rate", "1/min") + def test_accept_decline_and_revoke_have_separate_throttle_scopes(self): + accept_invite = self.create_invite() + self.authenticate(self.target) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 429, + ) + + decline_target = self.create_target(prefix="invite-api-throttle-decline") + decline_invite = self.create_invite(target=decline_target) + self.authenticate(decline_target) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 429, + ) + + revoke_target = self.create_target(prefix="invite-api-throttle-revoke") + revoke_invite = self.create_invite(target=revoke_target) + self.authenticate(self.captain) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 429, + ) diff --git a/partner_programs/tests/test_team_invite_candidates.py b/partner_programs/tests/test_team_invite_candidates.py new file mode 100644 index 00000000..a28e5d4a --- /dev/null +++ b/partner_programs/tests/test_team_invite_candidates.py @@ -0,0 +1,502 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + Team, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + create_or_get_application, +) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInvitePermissionError, + TeamInviteTargetInvalidError, + create_team_invite, + get_team_invite_candidates, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import ( + TeamInviteCandidateSearchScopedRateThrottle, +) + + +class TeamInviteCandidateFixtureMixin: + def setUp(self): + cache.clear() + self.captain = create_user( + prefix="candidate-captain", + first_name="Капитан", + last_name="Команды", + ) + self.accepted_user = create_user( + prefix="candidate-member", + first_name="Принятый", + last_name="Участник", + ) + self.manager = create_user(prefix="candidate-manager") + self.staff = create_user(prefix="candidate-staff", is_staff=True) + self.outsider = create_user(prefix="candidate-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=50, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.accepted_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Поиск кандидатов", + ).application + self.team = self.application.team + self.accepted_member = TeamMember.objects.create( + team=self.team, + user=self.accepted_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + def create_candidate( + self, + *, + first_name="Иван", + last_name="Кандидатов", + register=True, + is_active=True, + prefix="candidate-user", + ): + user = create_user( + prefix=prefix, + first_name=first_name, + last_name=last_name, + is_active=is_active, + ) + if register: + create_program_member(self.program, user=user, data={"secret": "hidden"}) + return user + + def candidate_ids(self, query="кандидат"): + return list( + get_team_invite_candidates( + team=self.team, + actor=self.captain, + query=query, + ).values_list("pk", flat=True) + ) + + def reload_team(self): + self.team = Team.objects.select_related( + "application", + "application__program", + "captain", + ).get(pk=self.team.pk) + + +class TeamInviteCandidateSelectorTests(TeamInviteCandidateFixtureMixin, TestCase): + def test_searches_by_first_name_last_name_and_both_orders(self): + user = self.create_candidate(first_name="Иван", last_name="Петров") + + for query in ("иван", "петров", "иван петров", "петров иван"): + with self.subTest(query=query): + self.assertEqual(self.candidate_ids(query), [user.pk]) + + def test_search_is_case_insensitive_and_supports_email_prefix(self): + user = self.create_candidate(first_name="Анна", last_name="Смирнова") + + self.assertEqual(self.candidate_ids("АННА"), [user.pk]) + self.assertEqual( + self.candidate_ids(user.email.split("@", maxsplit=1)[0]), + [user.pk], + ) + + def test_results_have_stable_case_insensitive_sorting(self): + first = self.create_candidate(first_name="Борис", last_name="Тест Альфа") + second = self.create_candidate(first_name="Анна", last_name="тест альфа") + third = self.create_candidate(first_name="Анна", last_name="Тест Бета") + + first_result = self.candidate_ids("тест") + second_result = self.candidate_ids("ТЕСТ") + + self.assertEqual(first_result, second_result) + self.assertEqual(set(first_result), {first.pk, second.pk, third.pk}) + + def test_related_history_does_not_duplicate_candidate(self): + user = self.create_candidate() + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=TeamInvite.STATUS_DECLINED, + resolved_at=timezone.now(), + ) + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=TeamInvite.STATUS_REVOKED, + resolved_at=timezone.now(), + ) + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_LEFT, + invited_by=self.captain, + ) + + self.assertEqual(self.candidate_ids(), [user.pk]) + + def test_result_count_is_limited_to_twenty(self): + for index in range(25): + self.create_candidate( + first_name=f"Кандидат {str(index).zfill(2)}", + last_name="Лимитов", + prefix="candidate-limit", + ) + + self.assertEqual(len(self.candidate_ids("лимитов")), 20) + + def test_registered_free_user_is_returned(self): + user = self.create_candidate() + + self.assertEqual(self.candidate_ids(), [user.pk]) + + def test_unregistered_and_inactive_users_are_excluded(self): + unregistered = self.create_candidate(register=False) + inactive = self.create_candidate(is_active=False) + + result = self.candidate_ids() + + self.assertNotIn(unregistered.pk, result) + self.assertNotIn(inactive.pk, result) + + def test_captain_and_accepted_member_are_excluded(self): + self.assertNotIn(self.captain.pk, self.candidate_ids("капитан")) + self.assertNotIn(self.accepted_user.pk, self.candidate_ids("принятый")) + + def test_pending_invite_in_current_team_is_excluded(self): + user = self.create_candidate() + create_team_invite( + team=self.team, + actor=self.captain, + target=user, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_active_owned_application_is_excluded(self): + user = self.create_candidate() + Application.objects.create( + program=self.program, + user=user, + created_by=user, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_accepted_membership_in_other_active_team_is_excluded(self): + user = self.create_candidate() + other_captain = self.create_candidate( + first_name="Другой", + last_name="Капитан", + prefix="candidate-other-captain", + ) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Другая команда", + ).application + TeamMember.objects.create( + team=other_application.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + self.assertNotIn(user.pk, self.candidate_ids()) + + def test_resolved_invites_do_not_exclude_candidate(self): + declined = self.create_candidate(prefix="candidate-declined") + revoked = self.create_candidate(prefix="candidate-revoked") + for user, invite_status in ( + (declined, TeamInvite.STATUS_DECLINED), + (revoked, TeamInvite.STATUS_REVOKED), + ): + TeamInvite.objects.create( + team=self.team, + user=user, + invited_by=self.captain, + status=invite_status, + resolved_at=timezone.now(), + ) + + result = self.candidate_ids() + + self.assertIn(declined.pk, result) + self.assertIn(revoked.pk, result) + + def test_removed_and_left_members_can_be_candidates_again(self): + removed = self.create_candidate(prefix="candidate-removed") + left = self.create_candidate(prefix="candidate-left") + for user, member_status in ( + (removed, TeamMember.STATUS_REMOVED), + (left, TeamMember.STATUS_LEFT), + ): + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=member_status, + invited_by=self.captain, + ) + + result = self.candidate_ids() + + self.assertIn(removed.pk, result) + self.assertIn(left.pk, result) + + def test_terminal_application_does_not_exclude_candidate(self): + user = self.create_candidate() + Application.objects.create( + program=self.program, + user=user, + created_by=user, + status=Application.STATUS_WITHDRAWN, + ) + + self.assertIn(user.pk, self.candidate_ids()) + + def test_selector_enforces_permission_and_team_state(self): + self.create_candidate() + with self.assertRaises(TeamInvitePermissionError): + get_team_invite_candidates( + team=self.team, + actor=self.outsider, + query="кандидат", + ) + + Application.objects.filter(pk=self.application.pk).update( + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL + ) + self.reload_team() + with self.assertRaises(TeamInviteTargetInvalidError): + self.candidate_ids() + + def test_non_draft_deadline_and_capacity_are_rejected(self): + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + self.reload_team() + with self.assertRaises(ApplicationNotEditableError): + self.candidate_ids() + + self.application.status = Application.STATUS_DRAFT + self.application.save(update_fields=["status", "updated_at"]) + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + self.reload_team() + with self.assertRaises(ApplicationDeadlinePassedError): + self.candidate_ids() + + self.program.datetime_application_ends = None + self.program.team_max_size = 2 + self.program.save( + update_fields=["datetime_application_ends", "team_max_size"] + ) + self.reload_team() + with self.assertRaises(TeamInviteCapacityReachedError): + self.candidate_ids() + + def test_search_does_not_bypass_create_service_rechecks(self): + user = self.create_candidate() + self.assertIn(user.pk, self.candidate_ids()) + Application.objects.create( + program=self.program, + user=user, + created_by=user, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + create_team_invite( + team=self.team, + actor=self.captain, + target=user, + ) + + +class TeamInviteCandidateAPITests(TeamInviteCandidateFixtureMixin, TestCase): + def setUp(self): + super().setUp() + self.client = APIClient() + + @property + def url(self): + return f"/applications/{self.application.pk}/team/invite-candidates/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def test_captain_and_staff_can_search(self): + user = self.create_candidate() + for actor in (self.captain, self.staff): + self.authenticate(actor) + with self.subTest(actor=actor): + response = self.client.get(self.url, {"q": "кандидат"}) + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [user.pk]) + + def test_member_and_manager_get_403_outsider_gets_404(self): + for actor in (self.accepted_user, self.manager): + self.authenticate(actor) + with self.subTest(actor=actor): + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 403, + ) + + self.authenticate(self.outsider) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 404, + ) + + invited_user = self.create_candidate(prefix="candidate-invited-user") + create_team_invite( + team=self.team, + actor=self.captain, + target=invited_user, + ) + self.authenticate(invited_user) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 404, + ) + + def test_anonymous_gets_401(self): + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 401, + ) + + def test_query_is_required_trimmed_and_limited(self): + self.authenticate(self.captain) + for params in ({}, {"q": ""}, {"q": " аб "}, {"q": "x" * 101}): + with self.subTest(params=params): + self.assertEqual(self.client.get(self.url, params).status_code, 400) + + user = self.create_candidate(first_name="Иван", last_name="Иванов") + response = self.client.get(self.url, {"q": " ИВАНОВ "}) + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [user.pk]) + + def test_response_contains_only_safe_profile_fields(self): + user = self.create_candidate() + self.authenticate(self.captain) + + response = self.client.get(self.url, {"q": "кандидат"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["id"], user.pk) + self.assertEqual( + set(response.data[0]), + {"id", "display_name", "avatar"}, + ) + for forbidden in ( + "email", + "phone_number", + "birthday", + "partner_program_data", + "application", + "team", + ): + self.assertNotIn(forbidden, response.data[0]) + + def test_non_draft_deadline_and_capacity_return_400(self): + self.authenticate(self.captain) + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + self.application.status = Application.STATUS_DRAFT + self.application.save(update_fields=["status", "updated_at"]) + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + self.program.datetime_application_ends = None + self.program.team_max_size = 3 + self.program.save( + update_fields=["datetime_application_ends", "team_max_size"] + ) + pending_target = self.create_candidate(prefix="candidate-capacity-pending") + create_team_invite( + team=self.team, + actor=self.captain, + target=pending_target, + ) + self.assertEqual( + self.client.get(self.url, {"q": "кандидат"}).status_code, + 400, + ) + + def test_individual_application_without_team_returns_404(self): + owner = create_user(prefix="candidate-individual-owner") + create_program_member(self.program, user=owner) + individual = Application.objects.create( + program=self.program, + user=owner, + created_by=owner, + ) + self.authenticate(owner) + + response = self.client.get( + f"/applications/{individual.pk}/team/invite-candidates/", + {"q": "кандидат"}, + ) + + self.assertEqual(response.status_code, 404) + + @patch.object( + TeamInviteCandidateSearchScopedRateThrottle, + "rate", + "1/min", + ) + def test_search_has_dedicated_throttle(self): + self.authenticate(self.captain) + + first = self.client.get(self.url, {"q": "кандидат"}) + second = self.client.get(self.url, {"q": "кандидат"}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) diff --git a/partner_programs/tests/test_team_invite_model.py b/partner_programs/tests/test_team_invite_model.py new file mode 100644 index 00000000..aadeb58e --- /dev/null +++ b/partner_programs/tests/test_team_invite_model.py @@ -0,0 +1,84 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from partner_programs.models import ( + Application, + PartnerProgram, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class TeamInviteModelTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="invite-model-captain") + self.target = create_user(prefix="invite-model-target") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.target) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Модельная команда", + ).application + self.team = self.application.team + + def create_invite(self, **overrides): + defaults = { + "team": self.team, + "user": self.target, + "invited_by": self.captain, + } + defaults.update(overrides) + return TeamInvite.objects.create(**defaults) + + def test_statuses_and_default(self): + invite = self.create_invite() + + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + self.assertEqual( + {value for value, _label in TeamInvite.STATUS_CHOICES}, + {"pending", "accepted", "declined", "revoked"}, + ) + + def test_only_one_pending_invite_per_team_and_user(self): + self.create_invite() + + with self.assertRaises(IntegrityError), transaction.atomic(): + self.create_invite() + + def test_resolved_invite_allows_new_pending_invite(self): + resolved = self.create_invite(status=TeamInvite.STATUS_DECLINED) + pending = self.create_invite() + + self.assertNotEqual(resolved.pk, pending.pk) + self.assertEqual( + TeamInvite.objects.filter(team=self.team, user=self.target).count(), + 2, + ) + + def test_timestamps_are_filled(self): + invite = self.create_invite() + + self.assertIsNotNone(invite.created_at) + self.assertIsNotNone(invite.updated_at) + self.assertIsNone(invite.resolved_at) + + def test_creating_invite_does_not_create_team_member(self): + self.create_invite() + + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) diff --git a/partner_programs/tests/test_team_invite_service.py b/partner_programs/tests/test_team_invite_service.py new file mode 100644 index 00000000..491c0111 --- /dev/null +++ b/partner_programs/tests/test_team_invite_service.py @@ -0,0 +1,330 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import ( + Application, + PartnerProgram, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + TeamHasOtherMembersError, + change_application_participation_mode, + create_or_get_application, + submit_application, +) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInviteNotOwnedError, + TeamInviteNotPendingError, + TeamInvitePermissionError, + TeamInviteRegistrationMissingError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + revoke_team_invite, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class TeamInviteServiceTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="invite-service-captain") + self.target = create_user(prefix="invite-service-target") + self.outsider = create_user(prefix="invite-service-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=4, + ) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.target) + self.application = self.create_team_application(self.captain, "Команда один") + self.team = self.application.team + + def create_team_application(self, captain, name): + if not self.program.partner_program_profiles.filter(user=captain).exists(): + create_program_member(self.program, user=captain) + return create_or_get_application( + program=self.program, + user=captain, + created_by=captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name=name, + ).application + + def create_pending_invite(self, *, team=None, target=None, actor=None): + return create_team_invite( + team=team or self.team, + actor=actor or self.captain, + target=target or self.target, + ).invite + + def test_create_is_successful_idempotent_and_does_not_create_member(self): + first = create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + second = create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + + self.assertTrue(first.created) + self.assertFalse(second.created) + self.assertEqual(first.invite.pk, second.invite.pk) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) + + def test_only_captain_or_staff_can_create_and_revoke(self): + with self.assertRaises(TeamInvitePermissionError): + create_team_invite( + team=self.team, + actor=self.outsider, + target=self.target, + ) + + staff = create_user(prefix="invite-service-staff", is_staff=True) + invite = create_team_invite( + team=self.team, + actor=staff, + target=self.target, + ).invite + revoked = revoke_team_invite(invite=invite, actor=staff) + self.assertEqual(revoked.status, TeamInvite.STATUS_REVOKED) + + def test_target_must_not_be_captain_or_accepted_member(self): + with self.assertRaises(TeamInviteTargetInvalidError): + create_team_invite( + team=self.team, + actor=self.captain, + target=self.captain, + ) + + TeamMember.objects.create( + team=self.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + with self.assertRaises(TeamInviteTargetInvalidError): + create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + + def test_registration_is_required(self): + unregistered = create_user(prefix="invite-unregistered") + + with self.assertRaises(TeamInviteRegistrationMissingError): + create_team_invite( + team=self.team, + actor=self.captain, + target=unregistered, + ) + + def test_active_own_application_is_a_conflict(self): + Application.objects.create( + program=self.program, + user=self.target, + created_by=self.target, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + self.create_pending_invite() + + def test_accepted_membership_in_other_team_is_a_conflict(self): + other_captain = create_user(prefix="invite-other-captain") + other_application = self.create_team_application(other_captain, "Другая команда") + TeamMember.objects.create( + team=other_application.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + self.create_pending_invite() + + def test_pending_invites_reserve_capacity(self): + self.program.team_max_size = 2 + self.program.save(update_fields=["team_max_size"]) + self.create_pending_invite() + second_target = create_user(prefix="invite-capacity-target") + create_program_member(self.program, user=second_target) + + with self.assertRaises(TeamInviteCapacityReachedError): + self.create_pending_invite(target=second_target) + + def test_deadline_and_non_draft_application_block_mutations(self): + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + with self.assertRaises(ApplicationDeadlinePassedError): + self.create_pending_invite() + + self.program.datetime_application_ends = None + self.program.save(update_fields=["datetime_application_ends"]) + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + with self.assertRaises(ApplicationNotEditableError): + self.create_pending_invite() + + def test_pending_invite_blocks_switch_to_individual(self): + self.program.participation_format = ( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ) + self.program.save(update_fields=["participation_format"]) + self.create_pending_invite() + + with self.assertRaises(TeamHasOtherMembersError): + change_application_participation_mode( + application=self.application, + actor=self.captain, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + def test_pending_invite_is_not_counted_as_member_on_submit(self): + accepted_user = create_user(prefix="invite-submit-member") + create_program_member(self.program, user=accepted_user) + TeamMember.objects.create( + team=self.team, + user=accepted_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + invite = self.create_pending_invite() + + submitted = submit_application( + application=self.application, + actor=self.captain, + ) + + invite.refresh_from_db() + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + + def test_accept_creates_member_and_is_idempotent(self): + invite = self.create_pending_invite() + + accepted = accept_team_invite(invite=invite, actor=self.target) + repeated = accept_team_invite(invite=accepted, actor=self.target) + + self.assertEqual(repeated.status, TeamInvite.STATUS_ACCEPTED) + self.assertIsNotNone(repeated.resolved_at) + members = TeamMember.objects.filter(team=self.team, user=self.target) + self.assertEqual(members.count(), 1) + self.assertEqual(members.get().status, TeamMember.STATUS_ACCEPTED) + + def test_accept_restores_historical_member_and_preserves_joined_at(self): + joined_at = timezone.now() - timezone.timedelta(days=10) + member = TeamMember.objects.create( + team=self.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_LEFT, + invited_by=self.captain, + joined_at=joined_at, + ) + invite = self.create_pending_invite() + + accept_team_invite(invite=invite, actor=self.target) + + member.refresh_from_db() + self.assertEqual(member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual(member.role, TeamMember.ROLE_MEMBER) + self.assertEqual(member.joined_at, joined_at) + self.assertEqual( + TeamMember.objects.filter(team=self.team, user=self.target).count(), + 1, + ) + + def test_only_invitee_can_accept_or_decline(self): + invite = self.create_pending_invite() + + with self.assertRaises(TeamInviteNotOwnedError): + accept_team_invite(invite=invite, actor=self.outsider) + with self.assertRaises(TeamInviteNotOwnedError): + decline_team_invite(invite=invite, actor=self.outsider) + + def test_decline_and_revoke_are_idempotent(self): + declined = decline_team_invite( + invite=self.create_pending_invite(), + actor=self.target, + ) + repeated_decline = decline_team_invite( + invite=declined, + actor=self.target, + ) + self.assertEqual(repeated_decline.status, TeamInvite.STATUS_DECLINED) + self.assertIsNotNone(repeated_decline.resolved_at) + + second_target = create_user(prefix="invite-revoke-target") + create_program_member(self.program, user=second_target) + revoked = revoke_team_invite( + invite=self.create_pending_invite(target=second_target), + actor=self.captain, + ) + repeated_revoke = revoke_team_invite( + invite=revoked, + actor=self.captain, + ) + self.assertEqual(repeated_revoke.status, TeamInvite.STATUS_REVOKED) + + def test_other_terminal_transitions_return_stable_error(self): + declined = decline_team_invite( + invite=self.create_pending_invite(), + actor=self.target, + ) + + with self.assertRaises(TeamInviteNotPendingError): + accept_team_invite(invite=declined, actor=self.target) + with self.assertRaises(TeamInviteNotPendingError): + revoke_team_invite(invite=declined, actor=self.captain) + + def test_accept_revokes_other_pending_invites_in_same_program(self): + first = self.create_pending_invite() + other_captain = create_user(prefix="invite-second-captain") + other_application = self.create_team_application(other_captain, "Команда два") + second = self.create_pending_invite( + team=other_application.team, + target=self.target, + actor=other_captain, + ) + + accept_team_invite(invite=first, actor=self.target) + + second.refresh_from_db() + self.assertEqual(second.status, TeamInvite.STATUS_REVOKED) + self.assertIsNotNone(second.resolved_at) + + def test_accept_rolls_back_membership_if_invite_update_fails(self): + invite = self.create_pending_invite() + + with patch.object(TeamInvite, "save", side_effect=RuntimeError("failure")): + with self.assertRaises(RuntimeError): + accept_team_invite(invite=invite, actor=self.target) + + invite.refresh_from_db() + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) diff --git a/partner_programs/tests/test_team_management_service.py b/partner_programs/tests/test_team_management_service.py new file mode 100644 index 00000000..5ced63e5 --- /dev/null +++ b/partner_programs/tests/test_team_management_service.py @@ -0,0 +1,415 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram, Team, TeamMember +from partner_programs.permissions import can_edit_application +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + TeamMemberRegistrationMissingError, + create_or_get_application, +) +from partner_programs.services.team_management import ( + CaptainTransferRequiredError, + CaptainTransferTargetInvalidError, + TeamManagementPermissionError, + TeamMemberNotFoundError, + TeamMemberNotRemovableError, + TeamMembershipNotActiveError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) + + +class TeamManagementServiceTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="team-service-captain") + self.member_user = create_user(prefix="team-service-member") + self.manager = create_user(prefix="team-service-manager") + self.staff = create_user(prefix="team-service-staff", is_staff=True) + self.outsider = create_user(prefix="team-service-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Команда", + ).application + self.team = self.application.team + self.member = TeamMember.objects.create( + team=self.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + def add_member(self, *, status=TeamMember.STATUS_ACCEPTED, register=True): + user = create_user(prefix="team-service-extra") + if register: + create_program_member(self.program, user=user) + return TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=self.captain, + ) + + def close_deadline(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=( + timezone.now() - timezone.timedelta(seconds=1) + ) + ) + + def test_captain_and_staff_can_rename_draft_team(self): + renamed = rename_team(team=self.team, actor=self.captain, name="Новое имя") + self.assertEqual(renamed.name, "Новое имя") + + renamed = rename_team(team=renamed, actor=self.staff, name="Имя staff") + self.assertEqual(renamed.name, "Имя staff") + + def test_member_manager_and_outsider_cannot_rename_team(self): + for actor in (self.member_user, self.manager, self.outsider): + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + rename_team(team=self.team, actor=actor, name="Запрещено") + + def test_rename_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + rename_team(team=self.team, actor=self.captain, name="Поздно") + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + rename_team(team=self.team, actor=self.captain, name="Отправлено") + + def test_accepted_member_can_leave_and_joined_at_is_preserved(self): + joined_at = self.member.joined_at + left = leave_team(team=self.team, actor=self.member_user) + + self.assertEqual(left.status, TeamMember.STATUS_LEFT) + self.assertEqual(left.joined_at, joined_at) + + def test_repeated_leave_returns_stable_domain_error(self): + leave_team(team=self.team, actor=self.member_user) + + with self.assertRaises(TeamMembershipNotActiveError) as context: + leave_team(team=self.team, actor=self.member_user) + + self.assertEqual(context.exception.code, "team_membership_not_active") + + def test_captain_must_transfer_before_leaving(self): + with self.assertRaises(CaptainTransferRequiredError) as context: + leave_team(team=self.team, actor=self.captain) + self.assertEqual(context.exception.code, "captain_transfer_required") + + def test_leave_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + leave_team(team=self.team, actor=self.member_user) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + leave_team(team=self.team, actor=self.member_user) + + def test_captain_removes_accepted_or_invited_member_without_deleting_history(self): + joined_at = self.member.joined_at + removed = remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + self.assertEqual(removed.status, TeamMember.STATUS_REMOVED) + self.assertEqual(removed.joined_at, joined_at) + + invited = self.add_member(status=TeamMember.STATUS_INVITED, register=False) + removed_invited = remove_team_member( + team=self.team, + actor=self.captain, + member_id=invited.pk, + ) + self.assertEqual(removed_invited.status, TeamMember.STATUS_REMOVED) + self.assertTrue(TeamMember.objects.filter(pk=invited.pk).exists()) + + def test_staff_can_remove_member_but_member_and_manager_cannot(self): + removed = remove_team_member( + team=self.team, + actor=self.staff, + member_id=self.member.pk, + ) + self.assertEqual(removed.status, TeamMember.STATUS_REMOVED) + + for actor in (self.member_user, self.manager): + other = self.add_member() + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + remove_team_member( + team=self.team, + actor=actor, + member_id=other.pk, + ) + + def test_captain_cannot_remove_self(self): + captain_member = self.team.members.get(role=TeamMember.ROLE_CAPTAIN) + with self.assertRaises(CaptainTransferRequiredError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=captain_member.pk, + ) + + def test_remove_rejects_member_of_other_team_and_historical_status(self): + other_captain = create_user(prefix="other-team-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + other_member = other_application.team.members.get() + + with self.assertRaises(TeamMemberNotFoundError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=other_member.pk, + ) + + self.member.status = TeamMember.STATUS_LEFT + self.member.save(update_fields=["status", "updated_at"]) + with self.assertRaises(TeamMemberNotRemovableError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_remove_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_updates_owner_captain_roles_and_preserves_history(self): + project = create_project(leader=self.captain) + self.application.project = project + self.application.save(update_fields=["project", "updated_at"]) + created_by_id = self.application.created_by_id + + transferred = transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + self.application.refresh_from_db() + self.member.refresh_from_db() + old_member = transferred.members.get(user=self.captain) + self.assertEqual(transferred.captain, self.member_user) + self.assertEqual(self.application.user, self.member_user) + self.assertEqual(self.application.created_by_id, created_by_id) + self.assertEqual(self.application.project, project) + self.assertEqual(self.member.role, TeamMember.ROLE_CAPTAIN) + self.assertEqual(self.member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual(old_member.role, TeamMember.ROLE_MEMBER) + self.assertEqual(old_member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual( + transferred.members.filter( + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).count(), + 1, + ) + self.assertTrue(can_edit_application(self.member_user, self.application)) + self.assertFalse(can_edit_application(self.captain, self.application)) + + def test_staff_can_transfer_captain(self): + transferred = transfer_team_captain( + team=self.team, + actor=self.staff, + member_id=self.member.pk, + ) + self.assertEqual(transferred.captain, self.member_user) + + def test_transfer_rejects_non_manager_actor(self): + for actor in (self.member_user, self.manager, self.outsider): + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + transfer_team_captain( + team=self.team, + actor=actor, + member_id=self.member.pk, + ) + + def test_transfer_rejects_non_accepted_or_non_member_target(self): + for member_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = member_status + self.member.save(update_fields=["status", "updated_at"]) + with self.subTest(status=member_status): + with self.assertRaises(CaptainTransferTargetInvalidError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_member_of_other_team(self): + other_captain = create_user(prefix="transfer-other-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + target = other_application.team.members.get() + + with self.assertRaises(TeamMemberNotFoundError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=target.pk, + ) + + def test_transfer_requires_program_registration(self): + self.member_user.partner_program_profiles.filter( + partner_program=self.program + ).delete() + + with self.assertRaises(TeamMemberRegistrationMissingError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_target_with_owned_active_application(self): + Application.objects.create( + program=self.program, + user=self.member_user, + created_by=self.member_user, + ) + + with self.assertRaises(ActiveApplicationConflictError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_target_in_another_active_team(self): + other_captain = create_user(prefix="conflict-team-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + TeamMember.objects.create( + team=other_application.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + with self.assertRaises(ActiveApplicationConflictError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rolls_back_if_update_fails_midway(self): + with patch.object(Team, "save", side_effect=RuntimeError("forced error")): + with self.assertRaises(RuntimeError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + self.application.refresh_from_db() + self.team.refresh_from_db() + self.member.refresh_from_db() + old_captain = self.team.members.get(user=self.captain) + self.assertEqual(self.application.user, self.captain) + self.assertEqual(self.team.captain, self.captain) + self.assertEqual(old_captain.role, TeamMember.ROLE_CAPTAIN) + self.assertEqual(self.member.role, TeamMember.ROLE_MEMBER) diff --git a/partner_programs/tests/test_team_permissions.py b/partner_programs/tests/test_team_permissions.py new file mode 100644 index 00000000..43c393c5 --- /dev/null +++ b/partner_programs/tests/test_team_permissions.py @@ -0,0 +1,115 @@ +from django.test import TestCase + +from partner_programs.models import Application, PartnerProgram, Submission, TeamMember +from partner_programs.permissions import ( + can_edit_application, + can_edit_submission, + can_manage_team, + can_view_application, + can_view_submission, + can_view_team, + is_accepted_team_member, + is_application_owner, + is_program_manager, + is_team_captain, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class TeamPermissionHelperTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="team-permission-captain") + self.member_user = create_user(prefix="team-permission-member") + self.manager = create_user(prefix="team-permission-manager") + self.staff = create_user(prefix="team-permission-staff", is_staff=True) + self.outsider = create_user(prefix="team-permission-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + self.member = TeamMember.objects.create( + team=self.application.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + self.submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Решение", + ) + + def test_role_helpers_distinguish_owner_captain_member_and_manager(self): + self.assertTrue(is_application_owner(self.captain, self.application)) + self.assertTrue(is_team_captain(self.captain, self.application.team)) + self.assertTrue( + is_accepted_team_member(self.member_user, self.application.team) + ) + self.assertTrue(is_program_manager(self.manager, self.program)) + self.assertFalse(is_application_owner(self.member_user, self.application)) + self.assertFalse(is_team_captain(self.member_user, self.application.team)) + + def test_application_read_access_matches_role_boundary(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_application(user, self.application)) + self.assertFalse(can_view_application(self.outsider, self.application)) + + def test_application_write_access_is_owner_or_staff_only(self): + self.assertTrue(can_edit_application(self.captain, self.application)) + self.assertTrue(can_edit_application(self.staff, self.application)) + self.assertFalse(can_edit_application(self.member_user, self.application)) + self.assertFalse(can_edit_application(self.manager, self.application)) + + def test_team_access_separates_view_and_manage(self): + team = self.application.team + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_team(user, team)) + self.assertTrue(can_manage_team(self.captain, team)) + self.assertTrue(can_manage_team(self.staff, team)) + self.assertFalse(can_manage_team(self.member_user, team)) + self.assertFalse(can_manage_team(self.manager, team)) + + def test_historical_membership_does_not_grant_read_access(self): + for membership_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = membership_status + self.member.save(update_fields=["status", "updated_at"]) + with self.subTest(status=membership_status): + self.assertFalse( + is_accepted_team_member(self.member_user, self.application.team) + ) + self.assertFalse(can_view_team(self.member_user, self.application.team)) + self.assertFalse(can_view_application(self.member_user, self.application)) + + def test_submission_read_is_inherited_but_write_is_not(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_submission(user, self.submission)) + self.assertFalse(can_view_submission(self.outsider, self.submission)) + self.assertTrue(can_edit_submission(self.captain, self.submission)) + self.assertTrue(can_edit_submission(self.staff, self.submission)) + self.assertFalse(can_edit_submission(self.member_user, self.submission)) + self.assertFalse(can_edit_submission(self.manager, self.submission)) diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py new file mode 100644 index 00000000..41070023 --- /dev/null +++ b/partner_programs/throttling.py @@ -0,0 +1,46 @@ +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.settings import api_settings + +# Roadmap: DEV-051, DEV-073 +# Отдельный лимит частого autosave черновика Evaluation. + + +class PatchOnlyScopedRateThrottle(ScopedRateThrottle): + """Ограничивает только PATCH, не затрагивая чтение Evaluation.""" + + def get_rate(self): + if not getattr(self, "scope", None): + return None + return api_settings.DEFAULT_THROTTLE_RATES.get(self.scope) + + def allow_request(self, request, view): + if request.method != "PATCH": + return True + return super().allow_request(request, view) + + +class TeamMutationScopedRateThrottle(ScopedRateThrottle): + """Ограничивает только mutation Team, не меняя глобальную throttle policy.""" + + rate = "20/min" + + def get_rate(self): + return self.rate + + +class TeamInviteMutationScopedRateThrottle(ScopedRateThrottle): + """Ограничивает mutation приглашений без глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate + + +class TeamInviteCandidateSearchScopedRateThrottle(ScopedRateThrottle): + """Ограничивает scoped-поиск кандидатов без глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 41313a82..47a304ab 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,6 +1,23 @@ +# Roadmap: DEV-076, DEV-056 + from django.urls import path from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList +from partner_programs.applications_views import ( + MyProgramApplicationView, + ProgramApplicationCreateView, +) +from partner_programs.evaluation_views import ( + ProgramEvaluationDetailView, + ProgramEvaluationListView, +) +from partner_programs.manager_overview_views import ( + ManagedProgramListView, + ManagerProgramOverviewView, +) +from partner_programs.submission_assignment_views import ( + ProgramSubmissionAssignmentListCreateView, +) from partner_programs.views import ( PartnerProgramCreateUserAndRegister, PartnerProgramDataSchema, @@ -22,6 +39,41 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), + path( + "managed/", + ManagedProgramListView.as_view(), + name="managed-program-list", + ), + path( + "/manager-overview/", + ManagerProgramOverviewView.as_view(), + name="manager-overview", + ), + path( + "/evaluations/", + ProgramEvaluationListView.as_view(), + name="evaluation-list", + ), + path( + "/evaluations//", + ProgramEvaluationDetailView.as_view(), + name="evaluation-detail", + ), + path( + "/submission-assignments/", + ProgramSubmissionAssignmentListCreateView.as_view(), + name="submission-assignment-list-create", + ), + path( + "/applications/my/", + MyProgramApplicationView.as_view(), + name="my-application", + ), + path( + "/applications/", + ProgramApplicationCreateView.as_view(), + name="application-create", + ), path("/", PartnerProgramDetail.as_view()), path( "partner-program-projects//submit/", diff --git a/partner_programs/views.py b/partner_programs/views.py index b778865a..9f48b313 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -15,6 +15,7 @@ from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like +from core.throttling import PostOnlyScopedRateThrottle from core.utils import build_xlsx_download_response from partner_programs.models import ( PartnerProgram, @@ -182,6 +183,8 @@ class PartnerProgramCreateUserAndRegister(generics.GenericAPIView): permission_classes = [AllowAny] serializer_class = PartnerProgramNewUserSerializer queryset = PartnerProgram.objects.all() + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "program_register_new" def post(self, request, *args, **kwargs): data = request.data diff --git a/procollab/settings.py b/procollab/settings.py index 1b1ed91e..8c7ad03c 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -6,6 +6,8 @@ from decouple import config +from procollab.settings_helpers import extend_with_env_list + mimetypes.add_type("application/javascript", ".js", True) mimetypes.add_type("text/css", ".css", True) mimetypes.add_type("text/html", ".html", True) @@ -16,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) @@ -23,28 +31,35 @@ TAGGIT_CASE_INSENSITIVE = True -CSRF_TRUSTED_ORIGINS = [ - "http://localhost:8000", - "http://127.0.0.1:8000", - "http://0.0.0.0:8000", - "https://api.procollab.ru", - "https://procollab.ru", - "https://www.procollab.ru", - "https://app.procollab.ru", - "https://dev.procollab.ru", -] +# Дополнительные домены позволяют отдельным dev-контурам обходиться без локальных правок settings.py. +CSRF_TRUSTED_ORIGINS = extend_with_env_list( + [ + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://0.0.0.0:8000", + "https://api.procollab.ru", + "https://procollab.ru", + "https://www.procollab.ru", + "https://app.procollab.ru", + "https://dev.procollab.ru", + ], + config("EXTRA_CSRF_TRUSTED_ORIGINS", default="", cast=str), +) -ALLOWED_HOSTS = [ - "127.0.0.1:8000", - "127.0.0.1", - "localhost", - "0.0.0.0", - "api.procollab.ru", - "app.procollab.ru", - "dev.procollab.ru", - "procollab.ru", - "web", # From Docker -] +ALLOWED_HOSTS = extend_with_env_list( + [ + "127.0.0.1:8000", + "127.0.0.1", + "localhost", + "0.0.0.0", + "api.procollab.ru", + "app.procollab.ru", + "dev.procollab.ru", + "procollab.ru", + "web", # Имя сервиса в Docker + ], + config("EXTRA_ALLOWED_HOSTS", default="", cast=str), +) PASSWORD_HASHERS = [ "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", @@ -82,6 +97,7 @@ "mailing.apps.MailingConfig", "feed.apps.FeedConfig", "project_rates.apps.ProjectRatesConfig", + "notifications.apps.NotificationsConfig", # Rest framework "rest_framework", "rest_framework_simplejwt", @@ -163,6 +179,46 @@ "rest_framework.renderers.BrowsableAPIRenderer", "rest_framework.renderers.AdminRenderer", ], + "DEFAULT_THROTTLE_RATES": { + "auth_register": config( + "DRF_THROTTLE_AUTH_REGISTER", default="5/min", cast=str + ), + "auth_resend_email": config( + "DRF_THROTTLE_AUTH_RESEND_EMAIL", default="3/min", cast=str + ), + "auth_reset_password": config( + "DRF_THROTTLE_AUTH_RESET_PASSWORD", default="3/min", cast=str + ), + "token_obtain": config( + "DRF_THROTTLE_TOKEN_OBTAIN", default="10/min", cast=str + ), + "program_register_new": config( + "DRF_THROTTLE_PROGRAM_REGISTER_NEW", default="10/min", cast=str + ), + "application_create": config( + "DRF_THROTTLE_APPLICATION_CREATE", default="10/min", cast=str + ), + "submission_create": config( + "DRF_THROTTLE_SUBMISSION_CREATE", default="10/min", cast=str + ), + "submission_assignment_create": config( + "DRF_THROTTLE_SUBMISSION_ASSIGNMENT_CREATE", + default="60/min", + cast=str, + ), + "evaluation_create": config( + "DRF_THROTTLE_EVALUATION_CREATE", default="10/min", cast=str + ), + "evaluation_update": config( + "DRF_THROTTLE_EVALUATION_UPDATE", default="120/min", cast=str + ), + "evaluation_submit": config( + "DRF_THROTTLE_EVALUATION_SUBMIT", default="20/min", cast=str + ), + "evaluation_amend": config( + "DRF_THROTTLE_EVALUATION_AMEND", default="30/min", cast=str + ), + }, } ASGI_APPLICATION = "procollab.asgi.application" @@ -318,7 +374,17 @@ SESSION_COOKIE_SECURE = not DEBUG CSRF_COOKIE_SECURE = not DEBUG -EMAIL_BACKEND = "anymail.backends.unisender_go.EmailBackend" +EMAIL_BACKEND = config( + "EMAIL_BACKEND", + default="anymail.backends.unisender_go.EmailBackend", + cast=str, +) + +VERIFY_EMAIL_REDIRECT_URL = config( + "VERIFY_EMAIL_REDIRECT_URL", + default="https://app.procollab.ru/auth/verification/", + cast=str, +) UNISENDER_GO_API_KEY = config("UNISENDER_GO_API_KEY", default="", cast=str) ANYMAIL = { diff --git a/procollab/settings_ci.py b/procollab/settings_ci.py new file mode 100644 index 00000000..e2267071 --- /dev/null +++ b/procollab/settings_ci.py @@ -0,0 +1,50 @@ +# Roadmap: DEV-074 +# Изолированная конфигурация тестов backend на PostgreSQL в CI. +from .settings import * # noqa: F401,F403 + +from decouple import config + +DEBUG = False +SECURE_SSL_REDIRECT = False + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": config("DATABASE_NAME", default="procollab_ci", cast=str), + "USER": config("DATABASE_USER", default="procollab_ci", cast=str), + "PASSWORD": config( + "DATABASE_PASSWORD", + default="procollab_ci_password", + cast=str, + ), + "HOST": config("DATABASE_HOST", default="127.0.0.1", cast=str), + "PORT": config("DATABASE_PORT", default="5432", cast=str), + "TEST": { + "NAME": "test_procollab_ci", + }, + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "procollab-ci-cache", + } +} + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } +} + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + +ALLOW_REACT_DEV_DEMO_SEED = False diff --git a/procollab/settings_helpers.py b/procollab/settings_helpers.py new file mode 100644 index 00000000..e367d669 --- /dev/null +++ b/procollab/settings_helpers.py @@ -0,0 +1,5 @@ +def extend_with_env_list(base_values: list[str], raw_value: str) -> list[str]: + """Добавляет к базовому списку непустые значения из env-строки через запятую.""" + extra_values = [value.strip() for value in raw_value.split(",") if value.strip()] + + return [*base_values, *extra_values] diff --git a/procollab/tests/__init__.py b/procollab/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/procollab/tests/test_settings_helpers.py b/procollab/tests/test_settings_helpers.py new file mode 100644 index 00000000..b4976c00 --- /dev/null +++ b/procollab/tests/test_settings_helpers.py @@ -0,0 +1,28 @@ +from unittest import TestCase + +from procollab.settings_helpers import extend_with_env_list + + +class ExtendWithEnvListTests(TestCase): + def test_keeps_base_values_when_env_value_is_empty(self): + base_values = ["localhost", "procollab.ru"] + + result = extend_with_env_list(base_values, "") + + self.assertEqual(result, base_values) + self.assertIsNot(result, base_values) + + def test_trims_and_appends_non_empty_values(self): + result = extend_with_env_list( + ["localhost"], + " api-react-dev.procollab.ru, ,react-dev.procollab.ru ,", + ) + + self.assertEqual( + result, + [ + "localhost", + "api-react-dev.procollab.ru", + "react-dev.procollab.ru", + ], + ) diff --git a/procollab/urls.py b/procollab/urls.py index 730d8143..3fdc1d2e 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -6,11 +6,14 @@ from drf_yasg.views import get_schema_view from rest_framework import authentication, permissions from rest_framework_simplejwt.views import ( - TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) +from partner_programs.submission_assignment_views import ( + SubmissionAssignmentRevokeView, +) from users.authentication import ActivityTrackingJWTAuthentication +from users.token_views import ThrottledTokenObtainPairView schema_view = get_schema_view( openapi.Info( @@ -55,10 +58,43 @@ # отключены. Для возврата модуля раскомментировать строку ниже. # path("events/", include("events.urls", namespace="events")), path("programs/", include("partner_programs.urls", namespace="partner_programs")), + path( + "applications/", + include("partner_programs.application_urls", namespace="applications"), + ), + path( + "submissions/", + include("partner_programs.submission_urls", namespace="submissions"), + ), + path( + "evaluations/", + include("partner_programs.evaluation_urls", namespace="evaluations"), + ), + path( + "expert/", + include("partner_programs.expert_urls", namespace="expert_submissions"), + ), + path( + "submission-assignments//revoke/", + SubmissionAssignmentRevokeView.as_view(), + name="submission-assignment-revoke", + ), + path( + "team-invites/", + include("partner_programs.team_invite_urls", namespace="team_invites"), + ), path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")), - path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path( + "notifications/", + include("notifications.urls", namespace="notifications"), + ), + path( + "api/token/", + ThrottledTokenObtainPairView.as_view(), + name="token_obtain_pair", + ), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("", include("metrics.urls", namespace="metrics")), diff --git a/projects/pagination.py b/projects/pagination.py index ab4caeeb..e3ff1b76 100644 --- a/projects/pagination.py +++ b/projects/pagination.py @@ -13,3 +13,9 @@ class ProjectsPagination(pagination.LimitOffsetPagination): default_limit = 10 limit_query_param = "limit" offset_query_param = "offset" + + +class SubscribedProjectsPagination(pagination.PageNumberPagination): + """Сохраняет размер и структуру списков проектов с параметром `page`.""" + + page_size = ProjectsPagination.default_limit diff --git a/projects/tests/test_project_workspace_api.py b/projects/tests/test_project_workspace_api.py new file mode 100644 index 00000000..fa9e3c0b --- /dev/null +++ b/projects/tests/test_project_workspace_api.py @@ -0,0 +1,512 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from partner_programs.models import Application, Submission +from projects.models import Project, ProjectLink +from projects.tests.helpers import ( + create_collaborator, + create_industry, + create_partner_program, + create_project, + create_user, +) + + +class ProjectWorkspaceAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-leader") + self.collaborator_user = create_user(prefix="workspace-collaborator") + self.outsider = create_user(prefix="workspace-outsider") + self.staff = create_user(prefix="workspace-staff") + self.staff.is_staff = True + self.staff.save(update_fields=["is_staff"]) + self.industry = create_industry(name="Workspace industry") + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def create_complete_draft(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + industry=self.industry, + ) + project.region = "Москва" + project.problem = "Проблема" + project.target_audience = "Целевая аудитория" + project.cover_image_address = "https://example.com/cover.png" + project.save() + return project + + def test_workspace_endpoints_require_authentication(self): + project = create_project(leader=self.leader) + + self.assertEqual(self.client.get("/projects/catalog/").status_code, 401) + self.assertEqual(self.client.get("/projects/my/").status_code, 401) + self.assertEqual(self.client.post("/projects/workspace/").status_code, 401) + self.assertEqual( + self.client.get(f"/projects/{project.pk}/workspace/").status_code, + 401, + ) + + def test_workspace_create_makes_private_draft_for_current_user(self): + self.authenticate(self.leader) + + response = self.client.post("/projects/workspace/", {}, format="json") + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.leader) + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + self.assertTrue(response.data["can_edit"]) + self.assertEqual(response.data["current_user_role"], "leader") + + def test_workspace_create_rejects_client_owned_fields(self): + self.authenticate(self.leader) + + response = self.client.post( + "/projects/workspace/", + {"leader": self.outsider.pk, "draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(Project.objects.filter(leader=self.leader).exists()) + self.assertFalse(Project.objects.filter(leader=self.outsider).exists()) + + def test_workspace_create_does_not_create_application_or_submission(self): + self.authenticate(self.leader) + + response = self.client.post("/projects/workspace/", {}, format="json") + + self.assertEqual(response.status_code, 201) + self.assertFalse(Application.objects.exists()) + self.assertFalse(Submission.objects.exists()) + + def test_catalog_returns_only_public_published_projects(self): + visible = create_project( + leader=self.leader, + draft=False, + is_public=True, + industry=self.industry, + ) + create_project(leader=self.leader, draft=True, is_public=True) + create_project(leader=self.leader, draft=False, is_public=False) + self.authenticate(self.outsider) + + response = self.client.get("/projects/catalog/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [visible.pk]) + + def test_catalog_supports_search_and_industry_filter(self): + match = create_project( + leader=self.leader, + name="Solar laboratory", + draft=False, + is_public=True, + industry=self.industry, + ) + create_project( + leader=self.leader, + name="Other project", + draft=False, + is_public=True, + ) + self.authenticate(self.outsider) + + response = self.client.get( + "/projects/catalog/", + {"search": "Solar", "industry": self.industry.pk}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [match.pk]) + + def test_my_projects_include_private_draft_for_leader_and_collaborator(self): + led = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + joined = create_project( + leader=self.outsider, + draft=True, + is_public=False, + ) + create_collaborator(joined, user=self.leader) + self.authenticate(self.leader) + + response = self.client.get("/projects/my/") + + self.assertEqual(response.status_code, 200) + by_id = {item["id"]: item for item in response.data["results"]} + self.assertEqual(by_id[led.pk]["current_user_role"], "leader") + self.assertTrue(by_id[led.pk]["can_edit"]) + self.assertTrue(by_id[led.pk]["can_use_in_application"]) + self.assertEqual(by_id[joined.pk]["current_user_role"], "collaborator") + self.assertFalse(by_id[joined.pk]["can_edit"]) + self.assertFalse(by_id[joined.pk]["can_use_in_application"]) + + def test_list_contract_includes_related_activities_without_n_plus_one(self): + projects = [ + create_project(leader=self.leader, draft=True, is_public=False) + for _index in range(3) + ] + for index, project in enumerate(projects): + program = create_partner_program(name=f"Activity {index}") + Application.objects.create( + program=program, + user=self.leader, + created_by=self.leader, + project=project, + ) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/projects/my/", {"limit": 20}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 3) + self.assertTrue(response.data["results"][0]["activities"]) + self.assertLessEqual(len(queries), 10) + + def test_workspace_detail_hides_private_project_from_outsider(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + self.authenticate(self.outsider) + + response = self.client.get(f"/projects/{project.pk}/workspace/") + + self.assertEqual(response.status_code, 404) + + def test_workspace_detail_is_public_but_private_data_is_not_exposed(self): + project = create_project( + leader=self.leader, + draft=False, + is_public=True, + industry=self.industry, + ) + self.authenticate(self.outsider) + + response = self.client.get(f"/projects/{project.pk}/workspace/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["leader"]["id"], self.leader.pk) + self.assertNotIn("email", response.data["leader"]) + self.assertNotIn("phone_number", response.data["leader"]) + self.assertNotIn("form_data", response.data) + + def test_leader_can_patch_only_workspace_fields(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"name": "Updated", "draft": True, "is_public": False}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.name, "Updated") + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_draft_requires_main_fields_before_publication(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + project.name = None + project.description = None + project.region = None + project.industry = None + project.cover_image_address = None + project.save() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + set(response.data), + { + "name", + "region", + "industry", + "description", + "problem", + "target_audience", + "cover_image_address", + }, + ) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_complete_draft_can_be_published(self): + project = self.create_complete_draft() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "name": "Published project", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + + def test_publication_patch_prefers_payload_over_stored_values(self): + project = self.create_complete_draft() + project.description = None + project.save() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "description": "Описание из запроса", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.description, "Описание из запроса") + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + + def test_publication_rejects_whitespace_only_fields(self): + project = self.create_complete_draft() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "region": " ", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("region", response.data) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_incomplete_draft_cannot_bypass_publication_with_single_flag(self): + payloads = ({"draft": False}, {"is_public": True}) + + for payload in payloads: + with self.subTest(payload=payload): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + payload, + format="json", + ) + + self.assertEqual(response.status_code, 400) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_workspace_update_does_not_allow_put(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.leader) + + response = self.client.put( + f"/projects/{project.pk}/workspace/", + {"draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 405) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_leader_can_patch_industry_and_project_links(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + old_link = ProjectLink.objects.create( + project=project, + link="https://old.example.com", + ) + replacement_industry = create_industry(name="Replacement industry") + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "industry": replacement_industry.pk, + "links": [ + "https://example.com/one", + "https://example.com/one", + "https://example.com/two", + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.industry, replacement_industry) + self.assertFalse(ProjectLink.objects.filter(pk=old_link.pk).exists()) + self.assertEqual( + response.data["links"], + ["https://example.com/one", "https://example.com/two"], + ) + + def test_legacy_post_projects_contract_remains_available(self): + self.authenticate(self.leader) + + response = self.client.post( + "/projects/", + {"name": "Legacy project"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.leader) + self.assertTrue(project.draft) + + def test_collaborator_cannot_patch_and_leader_cannot_replace_leader(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + create_collaborator(project, user=self.collaborator_user) + self.authenticate(self.collaborator_user) + forbidden = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"name": "Forbidden"}, + format="json", + ) + + self.authenticate(self.leader) + immutable = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"leader": self.outsider.pk}, + format="json", + ) + + self.assertEqual(forbidden.status_code, 403) + self.assertEqual(immutable.status_code, 400) + project.refresh_from_db() + self.assertEqual(project.leader, self.leader) + + def test_staff_can_patch_private_project(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.staff) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"description": "Administrative correction"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.description, "Administrative correction") + + def test_legacy_patch_remains_partial(self): + project = create_project( + leader=self.leader, + description="Must remain", + draft=True, + ) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/", + {"name": "Legacy partial update"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.name, "Legacy partial update") + self.assertEqual(project.description, "Must remain") + + +class ApplicationProjectSummaryTests(TestCase): + def test_application_response_keeps_project_id_and_adds_summary(self): + client = APIClient() + user = create_user(prefix="application-project-summary") + program = create_partner_program() + project = create_project( + leader=user, + draft=True, + is_public=False, + ) + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=project, + ) + client.force_authenticate(user=user) + + response = client.get(f"/applications/{application.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["project"], project.pk) + self.assertEqual( + response.data["project_summary"], + { + "id": project.pk, + "name": project.name, + "draft": True, + "is_public": False, + }, + ) + + def test_submitted_application_cannot_replace_reused_project(self): + client = APIClient() + user = create_user(prefix="application-project-immutable") + program = create_partner_program() + original = create_project(leader=user, draft=True, is_public=False) + replacement = create_project(leader=user, draft=True, is_public=False) + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=original, + status=Application.STATUS_SUBMITTED, + ) + client.force_authenticate(user=user) + + response = client.patch( + f"/applications/{application.pk}/", + {"project_id": replacement.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.project, original) diff --git a/projects/tests/test_project_workspace_content_api.py b/projects/tests/test_project_workspace_content_api.py new file mode 100644 index 00000000..01c50873 --- /dev/null +++ b/projects/tests/test_project_workspace_content_api.py @@ -0,0 +1,368 @@ +from datetime import date + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from projects.models import Achievement, ProjectGoal +from projects.tests.helpers import ( + create_collaborator, + create_project, + create_project_goal, + create_user, +) + + +class ProjectWorkspaceContentAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-content-leader") + self.collaborator = create_user(prefix="workspace-content-collaborator") + self.outsider = create_user(prefix="workspace-content-outsider") + self.staff = create_user(prefix="workspace-content-staff") + self.staff.is_staff = True + self.staff.save(update_fields=["is_staff"]) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + create_collaborator(self.project, user=self.collaborator) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def goals_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/goals/" + + def goal_url(self, goal, project=None): + project = project or self.project + return f"{self.goals_url(project)}{goal.pk}/" + + def achievements_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/achievements/" + + def achievement_url(self, achievement, project=None): + project = project or self.project + return f"{self.achievements_url(project)}{achievement.pk}/" + + def test_workspace_content_requires_authentication(self): + self.assertEqual(self.client.get(self.goals_url()).status_code, 401) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 401) + + def test_leader_can_create_list_update_and_delete_goal(self): + self.authenticate(self.leader) + + create_response = self.client.post( + self.goals_url(), + { + "title": " Подготовить прототип ", + "completion_date": "2026-12-15", + "responsible": self.collaborator.pk, + }, + format="json", + ) + + self.assertEqual(create_response.status_code, 201) + goal = ProjectGoal.objects.get(pk=create_response.data["id"]) + self.assertEqual(goal.project, self.project) + self.assertEqual(goal.title, "Подготовить прототип") + self.assertEqual(goal.responsible, self.collaborator) + + list_response = self.client.get(self.goals_url()) + self.assertEqual(list_response.status_code, 200) + self.assertEqual( + list_response.data, + [ + { + "id": goal.pk, + "title": "Подготовить прототип", + "completion_date": "2026-12-15", + "responsible": self.collaborator.pk, + } + ], + ) + + update_response = self.client.patch( + self.goal_url(goal), + {"title": "Провести пилот", "responsible": self.leader.pk}, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + goal.refresh_from_db() + self.assertEqual(goal.title, "Провести пилот") + self.assertEqual(goal.responsible, self.leader) + + delete_response = self.client.delete(self.goal_url(goal)) + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(ProjectGoal.objects.filter(pk=goal.pk).exists()) + + def test_collaborator_can_read_private_project_but_cannot_change_goal(self): + goal = create_project_goal(self.project, responsible=self.collaborator) + Achievement.objects.create( + project=self.project, + title="Командное достижение", + status="2024", + ) + self.authenticate(self.collaborator) + + self.assertEqual(self.client.get(self.goals_url()).status_code, 200) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 200) + create_response = self.client.post( + self.goals_url(), + {"title": "Новая цель", "responsible": self.collaborator.pk}, + format="json", + ) + update_response = self.client.patch( + self.goal_url(goal), + {"title": "Измененная цель"}, + format="json", + ) + delete_response = self.client.delete(self.goal_url(goal)) + + self.assertEqual(create_response.status_code, 403) + self.assertEqual(update_response.status_code, 403) + self.assertEqual(delete_response.status_code, 403) + + def test_outsider_cannot_see_private_content_but_can_read_published_content(self): + create_project_goal(self.project, responsible=self.leader) + public_project = create_project( + leader=self.leader, + draft=False, + is_public=True, + ) + public_goal = create_project_goal(public_project, responsible=self.leader) + self.authenticate(self.outsider) + + self.assertEqual(self.client.get(self.goals_url()).status_code, 404) + self.assertEqual(self.client.get(self.achievements_url()).status_code, 404) + public_response = self.client.get(self.goals_url(public_project)) + self.assertEqual(public_response.status_code, 200) + self.assertEqual(public_response.data[0]["id"], public_goal.pk) + public_write_response = self.client.post( + self.goals_url(public_project), + {"title": "Чужая цель", "responsible": self.leader.pk}, + format="json", + ) + self.assertEqual(public_write_response.status_code, 403) + + def test_responsible_must_belong_to_current_project(self): + other_project = create_project() + other_collaborator = create_collaborator(other_project).user + self.authenticate(self.leader) + + for invalid_user in (self.outsider, other_project.leader, other_collaborator): + with self.subTest(user_id=invalid_user.pk): + response = self.client.post( + self.goals_url(), + {"title": "Недопустимая цель", "responsible": invalid_user.pk}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("responsible", response.data) + + self.assertFalse(ProjectGoal.objects.filter(project=self.project).exists()) + + def test_goal_rejects_whitespace_title(self): + self.authenticate(self.leader) + + response = self.client.post( + self.goals_url(), + {"title": " \t", "responsible": self.leader.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("title", response.data) + + def test_goal_cannot_be_changed_through_another_project_url(self): + goal = create_project_goal(self.project, responsible=self.leader) + other_project = create_project(leader=self.leader) + self.authenticate(self.leader) + + patch_response = self.client.patch( + self.goal_url(goal, other_project), + {"title": "Чужой URL"}, + format="json", + ) + delete_response = self.client.delete(self.goal_url(goal, other_project)) + + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + list_response = self.client.get(self.goals_url(other_project)) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data, []) + goal.refresh_from_db() + self.assertNotEqual(goal.title, "Чужой URL") + + def test_failed_goal_update_does_not_apply_other_fields(self): + goal = create_project_goal( + self.project, + responsible=self.leader, + title="Исходная цель", + ) + original_title = goal.title + self.authenticate(self.leader) + + response = self.client.patch( + self.goal_url(goal), + {"title": "Не сохранять", "responsible": self.outsider.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + goal.refresh_from_db() + self.assertEqual(goal.title, original_title) + self.assertEqual(goal.responsible, self.leader) + + def test_staff_retains_administrative_write_access(self): + self.authenticate(self.staff) + + goal_response = self.client.post( + self.goals_url(), + {"title": "Административная цель", "responsible": self.leader.pk}, + format="json", + ) + achievement_response = self.client.post( + self.achievements_url(), + {"title": "Административное достижение", "year": 2020}, + format="json", + ) + + self.assertEqual(goal_response.status_code, 201) + self.assertEqual(achievement_response.status_code, 201) + + def test_achievement_full_crud_maps_year_to_legacy_status(self): + self.authenticate(self.leader) + + create_response = self.client.post( + self.achievements_url(), + {"title": " Победа в конкурсе ", "year": 2024}, + format="json", + ) + self.assertEqual(create_response.status_code, 201) + self.assertEqual(create_response.data["year"], 2024) + achievement = Achievement.objects.get(pk=create_response.data["id"]) + self.assertEqual(achievement.project, self.project) + self.assertEqual(achievement.title, "Победа в конкурсе") + self.assertEqual(achievement.status, "2024") + + list_response = self.client.get(self.achievements_url()) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data[0]["year"], 2024) + + update_response = self.client.patch( + self.achievement_url(achievement), + {"title": "Финал конкурса", "year": 2025}, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + achievement.refresh_from_db() + self.assertEqual(achievement.title, "Финал конкурса") + self.assertEqual(achievement.status, "2025") + + delete_response = self.client.delete(self.achievement_url(achievement)) + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(Achievement.objects.filter(pk=achievement.pk).exists()) + + def test_achievement_rejects_invalid_year_and_whitespace_title(self): + self.authenticate(self.leader) + + payloads = ( + {"title": "Слишком рано", "year": 1999}, + {"title": "Слишком поздно", "year": date.today().year + 1}, + {"title": " ", "year": 2024}, + ) + for payload in payloads: + with self.subTest(payload=payload): + response = self.client.post( + self.achievements_url(), + payload, + format="json", + ) + self.assertEqual(response.status_code, 400) + + self.assertFalse(Achievement.objects.filter(project=self.project).exists()) + + def test_achievement_cannot_be_changed_through_another_project_url(self): + achievement = Achievement.objects.create( + project=self.project, + title="Достижение", + status="2024", + ) + other_project = create_project(leader=self.leader) + self.authenticate(self.leader) + + response = self.client.patch( + self.achievement_url(achievement, other_project), + {"year": 2025}, + format="json", + ) + delete_response = self.client.delete( + self.achievement_url(achievement, other_project) + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + achievement.refresh_from_db() + self.assertEqual(achievement.status, "2024") + + def test_failed_achievement_update_is_atomic(self): + achievement = Achievement.objects.create( + project=self.project, + title="Исходное достижение", + status="2024", + ) + self.authenticate(self.leader) + + response = self.client.patch( + self.achievement_url(achievement), + {"title": "Не сохранять", "year": 1999}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + achievement.refresh_from_db() + self.assertEqual(achievement.title, "Исходное достижение") + self.assertEqual(achievement.status, "2024") + + def test_workspace_lists_do_not_create_n_plus_one_queries(self): + for index in range(5): + participant = create_user(prefix=f"goal-responsible-{index}") + create_collaborator(self.project, user=participant) + create_project_goal(self.project, responsible=participant) + Achievement.objects.create( + project=self.project, + title=f"Достижение {index}", + status=str(2020 + index), + ) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as goal_queries: + goal_response = self.client.get(self.goals_url()) + with CaptureQueriesContext(connection) as achievement_queries: + achievement_response = self.client.get(self.achievements_url()) + + self.assertEqual(goal_response.status_code, 200) + self.assertEqual(achievement_response.status_code, 200) + self.assertEqual(len(goal_response.data), 5) + self.assertEqual(len(achievement_response.data), 5) + self.assertLessEqual(len(goal_queries), 3) + self.assertLessEqual(len(achievement_queries), 3) + + def test_legacy_and_workspace_project_endpoints_remain_available(self): + goal = create_project_goal(self.project, responsible=self.leader) + self.authenticate(self.leader) + + legacy_response = self.client.get(f"/projects/{self.project.pk}/goals/") + legacy_achievements_response = self.client.get("/projects/achievements/") + workspace_response = self.client.get(f"/projects/{self.project.pk}/workspace/") + + self.assertEqual(legacy_response.status_code, 200) + self.assertEqual(legacy_response.data[0]["id"], goal.pk) + self.assertEqual(legacy_achievements_response.status_code, 200) + self.assertEqual(workspace_response.status_code, 200) + self.assertEqual(workspace_response.data["id"], self.project.pk) diff --git a/projects/tests/test_project_workspace_subscriptions.py b/projects/tests/test_project_workspace_subscriptions.py new file mode 100644 index 00000000..66b6651e --- /dev/null +++ b/projects/tests/test_project_workspace_subscriptions.py @@ -0,0 +1,259 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from invites.models import Invite +from news.models import News +from projects.models import Collaborator +from projects.tests.helpers import ( + create_collaborator, + create_project, + create_staff_user, + create_user, +) +from projects.workspace_selectors import get_workspace_subscription_queryset +from projects.workspace_serializers import ProjectSubscriptionStateSerializer + + +class ProjectWorkspaceSubscriptionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="subscription-leader") + self.collaborator = create_user(prefix="subscription-collaborator") + self.outsider = create_user(prefix="subscription-outsider") + self.staff = create_user(prefix="subscription-staff") + self.staff.is_staff = True + self.staff.save(update_fields=["is_staff"]) + self.superuser = create_staff_user(prefix="subscription-superuser") + self.public_project = create_project( + leader=self.leader, + draft=False, + is_public=True, + ) + self.private_project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def url(self, project=None): + project = project or self.public_project + return f"/projects/{project.pk}/workspace/subscription/" + + def test_all_methods_require_authentication(self): + self.assertEqual(self.client.get(self.url()).status_code, 401) + self.assertEqual( + self.client.post(self.url(), {}, format="json").status_code, + 401, + ) + self.assertEqual( + self.client.delete(self.url(), {}, format="json").status_code, + 401, + ) + + def test_get_returns_unsubscribed_state_for_current_user(self): + self.public_project.subscribers.add(self.leader) + self.authenticate(self.outsider) + + response = self.client.get(self.url()) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"is_subscribed": False, "subscribers_count": 1}, + ) + + def test_subscribe_is_idempotent_and_updates_count(self): + other_subscriber = create_user(prefix="subscription-other") + self.public_project.subscribers.add(other_subscriber) + self.authenticate(self.outsider) + + first = self.client.post(self.url(), {}, format="json") + second = self.client.post(self.url(), {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual( + first.data, + {"is_subscribed": True, "subscribers_count": 2}, + ) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data, first.data) + self.assertEqual( + self.public_project.subscribers.filter(pk=self.outsider.pk).count(), + 1, + ) + + def test_unsubscribe_is_idempotent_and_updates_count(self): + self.public_project.subscribers.add(self.outsider, self.leader) + self.authenticate(self.outsider) + + first = self.client.delete(self.url(), {}, format="json") + second = self.client.delete(self.url(), {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual( + first.data, + {"is_subscribed": False, "subscribers_count": 1}, + ) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data, first.data) + self.assertFalse( + self.public_project.subscribers.filter(pk=self.outsider.pk).exists() + ) + + def test_multiple_users_have_independent_state(self): + second_user = create_user(prefix="subscription-second") + self.public_project.subscribers.add(self.outsider) + + self.authenticate(self.outsider) + subscribed = self.client.get(self.url()) + self.authenticate(second_user) + unsubscribed = self.client.get(self.url()) + + self.assertTrue(subscribed.data["is_subscribed"]) + self.assertFalse(unsubscribed.data["is_subscribed"]) + self.assertEqual(subscribed.data["subscribers_count"], 1) + self.assertEqual(unsubscribed.data["subscribers_count"], 1) + + def test_workspace_detail_contains_annotated_subscription_state(self): + other_subscriber = create_user(prefix="subscription-detail-other") + self.public_project.subscribers.add(other_subscriber) + self.authenticate(self.outsider) + + response = self.client.get(f"/projects/{self.public_project.pk}/workspace/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["is_subscribed"]) + self.assertEqual(response.data["subscribers_count"], 1) + self.public_project.subscribers.add(self.outsider) + response = self.client.get(f"/projects/{self.public_project.pk}/workspace/") + self.assertTrue(response.data["is_subscribed"]) + self.assertEqual(response.data["subscribers_count"], 2) + + def test_private_project_is_visible_to_workspace_members_and_admins(self): + create_collaborator(self.private_project, user=self.collaborator) + + for user in (self.leader, self.collaborator, self.staff, self.superuser): + with self.subTest(user=user.email): + self.authenticate(user) + response = self.client.get(self.url(self.private_project)) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"is_subscribed": False, "subscribers_count": 0}, + ) + subscribed = self.client.post( + self.url(self.private_project), + {}, + format="json", + ) + self.assertEqual(subscribed.status_code, 200) + self.assertTrue(subscribed.data["is_subscribed"]) + removed = self.client.delete( + self.url(self.private_project), + {}, + format="json", + ) + self.assertEqual(removed.status_code, 200) + self.assertEqual( + removed.data, + {"is_subscribed": False, "subscribers_count": 0}, + ) + + def test_private_project_is_hidden_from_outsider(self): + self.authenticate(self.outsider) + + for method in ("get", "post", "delete"): + with self.subTest(method=method): + response = getattr(self.client, method)( + self.url(self.private_project), + {}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + def test_missing_project_returns_safe_not_found(self): + self.authenticate(self.outsider) + + url = "/projects/999999999/workspace/subscription/" + for method in ("get", "post", "delete"): + with self.subTest(method=method): + response = getattr(self.client, method)(url, {}, format="json") + self.assertEqual(response.status_code, 404) + self.assertNotIn(str(self.outsider.pk), str(response.data)) + self.assertNotIn("999999999", str(response.data)) + + def test_invalid_action_payload_is_rejected_without_mutation(self): + self.authenticate(self.outsider) + + post_response = self.client.post( + self.url(), {"user_id": self.leader.pk}, format="json" + ) + delete_response = self.client.delete( + self.url(), {"project_id": self.public_project.pk}, format="json" + ) + + self.assertEqual(post_response.status_code, 400) + self.assertEqual(delete_response.status_code, 400) + self.assertFalse( + self.public_project.subscribers.filter(pk=self.outsider.pk).exists() + ) + + def test_response_never_contains_subscriber_profiles(self): + self.public_project.subscribers.add(self.leader) + self.authenticate(self.outsider) + + response = self.client.get(self.url()) + detail = self.client.get(f"/projects/{self.public_project.pk}/workspace/") + + self.assertEqual(set(response.data), {"is_subscribed", "subscribers_count"}) + self.assertNotIn("subscribers", detail.data) + self.assertNotIn(self.leader.email, str(response.data)) + self.assertNotIn(self.leader.email, str(detail.data)) + + def test_subscription_does_not_create_related_domain_objects(self): + self.authenticate(self.outsider) + counts_before = { + "collaborators": Collaborator.objects.count(), + "invites": Invite.objects.count(), + "news": News.objects.count(), + } + + response = self.client.post(self.url(), {}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertEqual(Collaborator.objects.count(), counts_before["collaborators"]) + self.assertEqual(Invite.objects.count(), counts_before["invites"]) + self.assertEqual(News.objects.count(), counts_before["news"]) + + def test_state_serializer_performs_no_database_queries(self): + self.authenticate(self.outsider) + project = get_workspace_subscription_queryset(user=self.outsider).get( + pk=self.public_project.pk + ) + + with self.assertNumQueries(0): + data = ProjectSubscriptionStateSerializer(project).data + + self.assertEqual( + data, + {"is_subscribed": False, "subscribers_count": 0}, + ) + + def test_state_endpoint_query_budget_does_not_depend_on_subscriber_count(self): + subscribers = [ + create_user(prefix=f"subscription-budget-{index}") for index in range(8) + ] + self.public_project.subscribers.add(*subscribers) + self.authenticate(self.outsider) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["subscribers_count"], len(subscribers)) + self.assertLessEqual(len(queries), 2) diff --git a/projects/tests/test_project_workspace_vacancies.py b/projects/tests/test_project_workspace_vacancies.py new file mode 100644 index 00000000..811958d5 --- /dev/null +++ b/projects/tests/test_project_workspace_vacancies.py @@ -0,0 +1,149 @@ +from datetime import timedelta + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from projects.tests.helpers import create_project, create_user +from vacancy.models import Vacancy +from vacancy.tests.helpers import ( + create_skill, + create_vacancy, + create_vacancy_response, +) + + +class ProjectWorkspaceVacanciesTests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-vacancies-leader") + + def get_workspace(self, project): + self.client.force_authenticate(user=self.leader) + return self.client.get(f"/projects/{project.pk}/workspace/") + + def test_workspace_detail_returns_only_current_project_vacancies(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + foreign_project = create_project() + active = create_vacancy(project=project, role="Активная", is_active=True) + inactive = create_vacancy( + project=project, + role="Неактивная", + is_active=False, + ) + old = create_vacancy(project=project, role="Старая", is_active=True) + Vacancy.objects.filter(pk=old.pk).update( + datetime_created=timezone.now() - timedelta(days=120) + ) + create_vacancy(project=foreign_project, role="Чужая", is_active=True) + + response = self.get_workspace(project) + + self.assertEqual(response.status_code, 200) + vacancies = {item["id"]: item for item in response.data["vacancies"]} + self.assertEqual(set(vacancies), {active.pk, inactive.pk, old.pk}) + self.assertTrue(vacancies[active.pk]["is_active"]) + self.assertFalse(vacancies[inactive.pk]["is_active"]) + self.assertIn(old.pk, vacancies) + + def test_workspace_detail_returns_empty_vacancy_list(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + + response = self.get_workspace(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["vacancies"], []) + + def test_workspace_vacancy_reuses_legacy_short_contract(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + vacancy = create_vacancy( + project=project, + role="Backend-разработчик", + is_active=False, + ) + skill = create_skill(name="Python") + vacancy.required_skills.create(skill=skill) + create_vacancy_response(vacancy=vacancy, is_approved=None) + create_vacancy_response(vacancy=vacancy, is_approved=False) + + response = self.get_workspace(project) + + self.assertEqual(response.status_code, 200) + item = response.data["vacancies"][0] + self.assertEqual( + set(item), + { + "id", + "role", + "specialization", + "required_skills", + "description", + "project", + "is_active", + "datetime_closed", + "response_count", + "date_create_time", + }, + ) + self.assertEqual(item["project"], project.pk) + self.assertEqual(item["required_skills"][0]["id"], skill.pk) + self.assertEqual(item["response_count"], 1) + + def test_workspace_fields_and_access_flags_remain_available(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + create_vacancy(project=project) + + response = self.get_workspace(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["current_user_role"], "leader") + self.assertTrue(response.data["can_edit"]) + self.assertTrue(response.data["can_use_in_application"]) + self.assertIn("activities", response.data) + self.assertIn("collaborators", response.data) + self.assertIn("vacancies", response.data) + + def test_workspace_vacancies_do_not_create_n_plus_one_queries(self): + def capture_for(vacancy_count): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + for index in range(vacancy_count): + vacancy = create_vacancy( + project=project, + role=f"Vacancy {index}", + ) + vacancy.required_skills.create(skill=create_skill(name=f"Skill {index}")) + create_vacancy_response(vacancy=vacancy, is_approved=None) + + self.client.force_authenticate(user=self.leader) + with CaptureQueriesContext(connection) as queries: + response = self.client.get(f"/projects/{project.pk}/workspace/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["vacancies"]), vacancy_count) + return len(queries) + + one_vacancy_queries = capture_for(1) + five_vacancies_queries = capture_for(5) + + self.assertLessEqual(five_vacancies_queries, one_vacancy_queries) diff --git a/projects/tests/test_subscribed_projects_api.py b/projects/tests/test_subscribed_projects_api.py new file mode 100644 index 00000000..e25bd3ec --- /dev/null +++ b/projects/tests/test_subscribed_projects_api.py @@ -0,0 +1,416 @@ +from datetime import timedelta + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient, APIRequestFactory + +from invites.models import Invite +from news.models import News +from partner_programs.models import Application +from projects.models import Collaborator, Project +from projects.tests.helpers import ( + create_collaborator, + create_partner_program, + create_project, + create_staff_user, + create_user, +) +from projects.workspace_selectors import get_subscribed_projects_queryset +from projects.workspace_serializers import ProjectWorkspaceListSerializer + + +class SubscribedProjectsAPITests(TestCase): + url = "/projects/subscribed/" + + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="subscribed-projects-user") + self.other_user = create_user(prefix="subscribed-projects-other") + self.leader = create_user(prefix="subscribed-projects-leader") + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.user) + + def subscribe(self, project, user=None): + project.subscribers.add(user or self.user) + + def create_public_project(self, **kwargs): + return create_project( + leader=kwargs.pop("leader", self.leader), + draft=False, + is_public=True, + **kwargs, + ) + + def response_ids(self, response): + return [item["id"] for item in response.data["results"]] + + def test_endpoint_requires_authentication(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + + def test_user_without_subscriptions_gets_empty_paginated_response(self): + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"count": 0, "next": None, "previous": None, "results": []}, + ) + + def test_only_current_users_public_subscriptions_are_returned(self): + subscribed = self.create_public_project(name="Current subscription") + other_subscription = self.create_public_project(name="Other subscription") + self.subscribe(subscribed) + self.subscribe(other_subscription, self.other_user) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), [subscribed.pk]) + + def test_multiple_subscriptions_are_unique(self): + first = self.create_public_project(name="First subscription") + second = self.create_public_project(name="Second subscription") + self.subscribe(first) + self.subscribe(first) + self.subscribe(second) + for project in (first, second): + for index in range(2): + Application.objects.create( + program=create_partner_program(name=f"Program {project.pk}-{index}"), + user=self.user, + created_by=self.user, + project=project, + ) + self.authenticate() + + response = self.client.get(self.url) + ids = self.response_ids(response) + + self.assertEqual(response.status_code, 200) + self.assertEqual(set(ids), {first.pk, second.pk}) + self.assertEqual(len(ids), 2) + + def test_workspace_subscribe_adds_and_unsubscribe_removes_project(self): + project = self.create_public_project() + self.authenticate() + + subscribed = self.client.post( + f"/projects/{project.pk}/workspace/subscription/", + {}, + format="json", + ) + after_subscribe = self.client.get(self.url) + unsubscribed = self.client.delete( + f"/projects/{project.pk}/workspace/subscription/", + {}, + format="json", + ) + after_unsubscribe = self.client.get(self.url) + + self.assertEqual(subscribed.status_code, 200) + self.assertEqual(self.response_ids(after_subscribe), [project.pk]) + self.assertEqual(unsubscribed.status_code, 200) + self.assertEqual(self.response_ids(after_unsubscribe), []) + + def test_foreign_private_and_draft_subscriptions_are_hidden(self): + hidden_projects = ( + create_project( + leader=self.leader, + name="Private project", + draft=False, + is_public=False, + ), + create_project( + leader=self.leader, + name="Draft project", + draft=True, + is_public=True, + ), + ) + for project in hidden_projects: + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), []) + + def test_leader_sees_subscribed_private_and_draft_projects(self): + projects = ( + create_project( + leader=self.user, + draft=False, + is_public=False, + ), + create_project( + leader=self.user, + draft=True, + is_public=True, + ), + ) + for project in projects: + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(set(self.response_ids(response)), {item.pk for item in projects}) + + def test_collaborator_sees_subscribed_private_and_draft_projects(self): + projects = ( + create_project( + leader=self.leader, + draft=False, + is_public=False, + ), + create_project( + leader=self.leader, + draft=True, + is_public=True, + ), + ) + for project in projects: + create_collaborator(project, user=self.user) + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(set(self.response_ids(response)), {item.pk for item in projects}) + self.assertTrue( + all( + item["current_user_role"] == "collaborator" + for item in response.data["results"] + ) + ) + + def test_staff_and_superuser_see_their_private_subscriptions(self): + private_project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + staff = create_user(prefix="subscribed-projects-staff") + staff.is_staff = True + staff.save(update_fields=["is_staff"]) + superuser = create_staff_user(prefix="subscribed-projects-superuser") + + for admin_user in (staff, superuser): + with self.subTest(user=admin_user.email): + self.subscribe(private_project, admin_user) + self.authenticate(admin_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), [private_project.pk]) + + def test_lost_access_hides_project_without_removing_subscription(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + collaboration = create_collaborator(project, user=self.user) + self.subscribe(project) + self.authenticate() + self.assertEqual(self.response_ids(self.client.get(self.url)), [project.pk]) + + collaboration.delete() + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), []) + self.assertTrue(project.subscribers.filter(pk=self.user.pk).exists()) + + def test_search_is_trimmed_case_insensitive_and_subscription_scoped(self): + match = self.create_public_project(name="Solar Laboratory") + non_match = self.create_public_project(name="Marine Laboratory") + foreign_match = self.create_public_project(name="Solar Foreign") + self.subscribe(match) + self.subscribe(non_match) + self.subscribe(foreign_match, self.other_user) + self.authenticate() + + response = self.client.get(self.url, {"search": " sOLAR labORATORY "}) + empty_search = self.client.get(self.url, {"search": " "}) + + self.assertEqual(self.response_ids(response), [match.pk]) + self.assertEqual( + set(self.response_ids(empty_search)), + {match.pk, non_match.pk}, + ) + + def test_page_pagination_uses_existing_shape_and_size(self): + projects = [ + self.create_public_project(name=f"Paginated {index}") for index in range(11) + ] + for project in projects: + self.subscribe(project) + self.authenticate() + + first_page = self.client.get(self.url, {"page": 1}) + second_page = self.client.get(self.url, {"page": 2}) + missing_page = self.client.get(self.url, {"page": 99}) + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(set(first_page.data), {"count", "next", "previous", "results"}) + self.assertEqual(first_page.data["count"], 11) + self.assertEqual(len(first_page.data["results"]), 10) + self.assertIn("page=2", first_page.data["next"]) + self.assertIsNone(first_page.data["previous"]) + self.assertEqual(second_page.status_code, 200) + self.assertEqual(len(second_page.data["results"]), 1) + self.assertIsNotNone(second_page.data["previous"]) + self.assertIsNone(second_page.data["next"]) + self.assertEqual(missing_page.status_code, 404) + + def test_invalid_page_returns_safe_not_found(self): + self.authenticate() + + for page in ("invalid", "0", "-1"): + with self.subTest(page=page): + response = self.client.get(self.url, {"page": page}) + self.assertEqual(response.status_code, 404) + self.assertEqual(set(response.data), {"detail"}) + + def test_projects_are_ordered_by_update_time_and_stable_id(self): + older = self.create_public_project(name="Older") + first_same_time = self.create_public_project(name="Same time first") + second_same_time = self.create_public_project(name="Same time second") + now = timezone.now() + Project.objects.filter(pk=older.pk).update( + datetime_updated=now - timedelta(days=1) + ) + Project.objects.filter(pk__in=(first_same_time.pk, second_same_time.pk)).update( + datetime_updated=now + ) + for project in (older, first_same_time, second_same_time): + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual( + self.response_ids(response), + [second_same_time.pk, first_same_time.pk, older.pk], + ) + + def test_card_contract_has_no_subscriber_or_personal_data(self): + project = self.create_public_project() + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + card = response.data["results"][0] + + self.assertEqual( + set(card), + { + "id", + "name", + "short_description", + "image_address", + "cover_image_address", + "draft", + "is_public", + "current_user_role", + "can_edit", + "can_use_in_application", + "activities", + "datetime_updated", + }, + ) + for forbidden in ( + "subscribers", + "email", + "phone_number", + "birthday", + "invitations", + ): + self.assertNotIn(forbidden, card) + + def test_get_does_not_change_domain_objects(self): + project = self.create_public_project() + self.subscribe(project) + Invite.objects.create( + project=project, + user=self.user, + invited_by=self.leader, + ) + News.objects.create(content_object=project, text="Existing project news") + before = { + "projects": Project.objects.count(), + "subscriptions": Project.subscribers.through.objects.count(), + "collaborators": Collaborator.objects.count(), + "invites": Invite.objects.count(), + "news": News.objects.count(), + } + updated_at = project.datetime_updated + self.authenticate() + + response = self.client.get(self.url) + project.refresh_from_db() + + self.assertEqual(response.status_code, 200) + self.assertEqual( + { + "projects": Project.objects.count(), + "subscriptions": Project.subscribers.through.objects.count(), + "collaborators": Collaborator.objects.count(), + "invites": Invite.objects.count(), + "news": News.objects.count(), + }, + before, + ) + self.assertEqual(project.datetime_updated, updated_at) + + def test_serializer_uses_only_prefetched_data(self): + projects = [self.create_public_project() for _index in range(3)] + for project in projects: + self.subscribe(project) + Application.objects.create( + program=create_partner_program(), + user=self.user, + created_by=self.user, + project=project, + ) + prepared_projects = list(get_subscribed_projects_queryset(user=self.user)) + request = APIRequestFactory().get(self.url) + request.user = self.user + + with self.assertNumQueries(0): + data = ProjectWorkspaceListSerializer( + prepared_projects, + many=True, + context={"request": request}, + ).data + + self.assertEqual(len(data), 3) + self.assertTrue(all(item["activities"] for item in data)) + + def test_endpoint_query_budget_does_not_grow_with_project_count(self): + projects = [self.create_public_project() for _index in range(5)] + for project in projects: + self.subscribe(project) + Application.objects.create( + program=create_partner_program(), + user=self.user, + created_by=self.user, + project=project, + ) + self.authenticate() + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url, {"page": 1}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + self.assertLessEqual(len(queries), 5) diff --git a/projects/urls.py b/projects/urls.py index b9810e61..eac4b9b8 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -1,5 +1,13 @@ from django.urls import path +from invites.workspace_views import ( + IncomingProjectInvitationListView, + ProjectInvitationAcceptView, + ProjectInvitationCandidateSearchView, + ProjectInvitationDeclineView, + ProjectInvitationListCreateView, + ProjectInvitationRevokeView, +) from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList from partner_programs.views import PartnerProgramFieldValueBulkUpdateView from projects.views import ( @@ -24,6 +32,20 @@ SetLikeOnProject, SwitchLeaderRole, ) +from projects.workspace_content_views import ( + ProjectWorkspaceAchievementDetailView, + ProjectWorkspaceAchievementListView, + ProjectWorkspaceGoalDetailView, + ProjectWorkspaceGoalListView, +) +from projects.workspace_views import ( + MyProjectsView, + ProjectCatalogView, + SubscribedProjectsView, + ProjectWorkspaceCreateView, + ProjectWorkspaceDetailView, + ProjectWorkspaceSubscriptionView, +) app_name = "projects" project_goal_list = GoalViewSet.as_view( @@ -58,6 +80,78 @@ ) urlpatterns = [ path("", ProjectList.as_view()), + path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), + path("my/", MyProjectsView.as_view(), name="workspace-my"), + path( + "subscribed/", + SubscribedProjectsView.as_view(), + name="workspace-subscribed", + ), + path( + "workspace/", + ProjectWorkspaceCreateView.as_view(), + name="workspace-create", + ), + path( + "workspace/invitations/incoming/", + IncomingProjectInvitationListView.as_view(), + name="workspace-invitations-incoming", + ), + path( + "workspace/invitations//accept/", + ProjectInvitationAcceptView.as_view(), + name="workspace-invitation-accept", + ), + path( + "workspace/invitations//decline/", + ProjectInvitationDeclineView.as_view(), + name="workspace-invitation-decline", + ), + path( + "/workspace/", + ProjectWorkspaceDetailView.as_view(), + name="workspace-detail", + ), + path( + "/workspace/invitations/", + ProjectInvitationListCreateView.as_view(), + name="workspace-invitations", + ), + path( + "/workspace/subscription/", + ProjectWorkspaceSubscriptionView.as_view(), + name="workspace-subscription", + ), + path( + "/workspace/invitations/candidates/", + ProjectInvitationCandidateSearchView.as_view(), + name="workspace-invitation-candidates", + ), + path( + "/workspace/invitations//revoke/", + ProjectInvitationRevokeView.as_view(), + name="workspace-invitation-revoke", + ), + path( + "/workspace/goals/", + ProjectWorkspaceGoalListView.as_view(), + name="workspace-goals", + ), + path( + "/workspace/goals//", + ProjectWorkspaceGoalDetailView.as_view(), + name="workspace-goal-detail", + ), + path( + "/workspace/achievements/", + ProjectWorkspaceAchievementListView.as_view(), + name="workspace-achievements", + ), + path( + "/workspace/achievements//", + ProjectWorkspaceAchievementDetailView.as_view(), + name="workspace-achievement-detail", + ), path("/like/", SetLikeOnProject.as_view()), path("/news/", NewsList.as_view()), path("/subscribe/", ProjectSubscribe.as_view()), diff --git a/projects/views.py b/projects/views.py index aa252fc2..45da647d 100644 --- a/projects/views.py +++ b/projects/views.py @@ -198,7 +198,7 @@ def patch(self, request, pk, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) check_related_fields_update(request.data, pk) - return super(ProjectDetail, self).put(request, pk) + return super(ProjectDetail, self).patch(request, pk) class ProjectRecommendedUsers(generics.RetrieveAPIView): diff --git a/projects/workspace_content_serializers.py b/projects/workspace_content_serializers.py new file mode 100644 index 00000000..c0cede4b --- /dev/null +++ b/projects/workspace_content_serializers.py @@ -0,0 +1,82 @@ +from datetime import date + +from rest_framework import serializers + +from projects.models import Achievement, Collaborator, ProjectGoal + + +class ProjectWorkspaceGoalSerializer(serializers.ModelSerializer): + """Читает и изменяет цель только в контексте проекта из URL.""" + + class Meta: + model = ProjectGoal + fields = ("id", "title", "completion_date", "responsible") + read_only_fields = ("id",) + + def validate_title(self, value): + normalized_title = value.strip() + if not normalized_title: + raise serializers.ValidationError("Укажите название цели.") + return normalized_title + + def validate(self, attrs): + project = self.context["project"] + responsible = attrs.get( + "responsible", + getattr(self.instance, "responsible", None), + ) + if responsible is None: + return attrs + + is_project_member = ( + responsible.pk == project.leader_id + or Collaborator.objects.filter( + project_id=project.pk, + user_id=responsible.pk, + ).exists() + ) + if not is_project_member: + raise serializers.ValidationError( + { + "responsible": ( + "Ответственным может быть только руководитель или участник " + "этого проекта." + ) + } + ) + return attrs + + +class ProjectWorkspaceAchievementSerializer(serializers.ModelSerializer): + """Предоставляет год достижения поверх legacy-поля Achievement.status.""" + + year = serializers.IntegerField( + source="status", + min_value=2000, + max_value=date.today().year, + error_messages={ + "invalid": "Укажите год целым числом.", + "min_value": "Год не может быть раньше 2000.", + "max_value": "Год не может быть позже текущего года.", + }, + ) + + class Meta: + model = Achievement + fields = ("id", "title", "year") + read_only_fields = ("id",) + + def validate_title(self, value): + normalized_title = value.strip() + if not normalized_title: + raise serializers.ValidationError("Укажите название достижения.") + return normalized_title + + def create(self, validated_data): + validated_data["status"] = str(validated_data["status"]) + return super().create(validated_data) + + def update(self, instance, validated_data): + if "status" in validated_data: + validated_data["status"] = str(validated_data["status"]) + return super().update(instance, validated_data) diff --git a/projects/workspace_content_views.py b/projects/workspace_content_views.py new file mode 100644 index 00000000..922de35c --- /dev/null +++ b/projects/workspace_content_views.py @@ -0,0 +1,164 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import generics, mixins +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated + +from projects.models import Achievement, Project, ProjectGoal +from projects.workspace_content_serializers import ( + ProjectWorkspaceAchievementSerializer, + ProjectWorkspaceGoalSerializer, +) +from projects.workspace_selectors import filter_workspace_visible_projects + + +class ProjectWorkspaceNestedObjectMixin: + """Применяет единые workspace-права к вложенным объектам проекта.""" + + permission_classes = (IsAuthenticated,) + project_context_key = "project" + + def get_project(self): + if not hasattr(self, "_workspace_project"): + projects = Project.objects.only( + "id", + "leader_id", + "draft", + "is_public", + ) + projects = filter_workspace_visible_projects( + projects, + user=self.request.user, + ) + self._workspace_project = get_object_or_404( + projects, + pk=self.kwargs["project_id"], + ) + return self._workspace_project + + def check_workspace_write_access(self): + project = self.get_project() + user = self.request.user + if not (user.is_staff or user.is_superuser or project.leader_id == user.pk): + raise PermissionDenied("Редактировать проект может только руководитель.") + + def get_serializer_context(self): + context = super().get_serializer_context() + context[self.project_context_key] = self.get_project() + return context + + +class ProjectWorkspaceGoalListView( + ProjectWorkspaceNestedObjectMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + """Возвращает цели проекта и создает цель для руководителя или staff.""" + + serializer_class = ProjectWorkspaceGoalSerializer + + def get_queryset(self): + return ( + ProjectGoal.objects.filter(project=self.get_project()) + .select_related("responsible") + .order_by("id") + ) + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @transaction.atomic + def post(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(project=self.get_project()) + + +class ProjectWorkspaceGoalDetailView( + ProjectWorkspaceNestedObjectMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + """Изменяет или удаляет цель, принадлежащую проекту из URL.""" + + serializer_class = ProjectWorkspaceGoalSerializer + + def get_queryset(self): + return ProjectGoal.objects.filter(project=self.get_project()).select_related( + "responsible" + ) + + def get_object(self): + return get_object_or_404( + self.get_queryset(), + pk=self.kwargs["goal_id"], + ) + + @transaction.atomic + def patch(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.partial_update(request, *args, **kwargs) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.destroy(request, *args, **kwargs) + + +class ProjectWorkspaceAchievementListView( + ProjectWorkspaceNestedObjectMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + """Возвращает достижения и создает их внутри текущего проекта.""" + + serializer_class = ProjectWorkspaceAchievementSerializer + + def get_queryset(self): + return Achievement.objects.filter(project=self.get_project()).order_by("id") + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @transaction.atomic + def post(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(project=self.get_project()) + + +class ProjectWorkspaceAchievementDetailView( + ProjectWorkspaceNestedObjectMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + """Изменяет или удаляет достижение только в рамках проекта из URL.""" + + serializer_class = ProjectWorkspaceAchievementSerializer + + def get_queryset(self): + return Achievement.objects.filter(project=self.get_project()) + + def get_object(self): + return get_object_or_404( + self.get_queryset(), + pk=self.kwargs["achievement_id"], + ) + + @transaction.atomic + def patch(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.partial_update(request, *args, **kwargs) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.check_workspace_write_access() + return self.destroy(request, *args, **kwargs) diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py new file mode 100644 index 00000000..4265626e --- /dev/null +++ b/projects/workspace_selectors.py @@ -0,0 +1,122 @@ +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet + +from core.models import SkillToObject +from partner_programs.models import Application +from projects.models import Collaborator, Project +from vacancy.models import Vacancy + + +def annotate_workspace_subscription_state( + queryset: QuerySet[Project], *, user +) -> QuerySet[Project]: + """Добавляет состояние подписки без загрузки списка пользователей.""" + subscribers_field = Project._meta.get_field("subscribers") + through = Project.subscribers.through + current_subscription = through.objects.filter( + **{ + f"{subscribers_field.m2m_field_name()}_id": OuterRef("pk"), + f"{subscribers_field.m2m_reverse_field_name()}_id": user.pk, + } + ) + return queryset.annotate( + is_subscribed=Exists(current_subscription), + subscribers_count=Count("subscribers", distinct=True), + ) + + +def _with_workspace_relations(queryset: QuerySet[Project], user) -> QuerySet[Project]: + """Загружает роли и активности заранее, исключая N+1 в API проектов.""" + current_user_collaborations = Collaborator.objects.filter(user=user) + applications = Application.objects.select_related("program").order_by( + "-updated_at", "-id" + ) + return queryset.prefetch_related( + Prefetch( + "collaborator_set", + queryset=current_user_collaborations, + to_attr="_current_user_collaborations", + ), + Prefetch( + "applications", + queryset=applications, + to_attr="_workspace_applications", + ), + ) + + +def get_project_catalog_queryset(*, user, search=None, industry_id=None): + """Возвращает опубликованные публичные проекты в стабильном порядке.""" + queryset = Project.objects.filter(draft=False, is_public=True) + if search: + queryset = queryset.filter(name__icontains=search.strip()) + if industry_id: + queryset = queryset.filter(industry_id=industry_id) + return _with_workspace_relations(queryset, user).order_by("-datetime_updated", "-id") + + +def get_user_projects_queryset(*, user, search=None): + """Возвращает проекты пользователя как руководителя или участника.""" + queryset = Project.objects.filter( + Q(leader=user) | Q(collaborator__user=user) + ).distinct() + if search: + queryset = queryset.filter(name__icontains=search.strip()) + return _with_workspace_relations(queryset, user).order_by("-datetime_updated", "-id") + + +def get_subscribed_projects_queryset(*, user, search=None): + """Возвращает пересечение подписок пользователя и доступных workspace-проектов.""" + queryset = Project.objects.filter(subscribers=user) + queryset = filter_workspace_visible_projects(queryset, user=user) + if search and search.strip(): + queryset = queryset.filter(name__icontains=search.strip()) + return _with_workspace_relations(queryset.distinct(), user).order_by( + "-datetime_updated", "-id" + ) + + +def get_workspace_project_queryset(*, user): + """Готовит detail queryset с безопасными пользовательскими связями проекта.""" + collaborators = Collaborator.objects.select_related("user").order_by( + "datetime_created", "id" + ) + required_skills = SkillToObject.objects.select_related("skill__category") + vacancies = ( + Vacancy.objects.annotate( + workspace_response_count=Count( + "vacancy_requests", + filter=Q(vacancy_requests__is_approved__isnull=True), + ) + ) + .prefetch_related(Prefetch("required_skills", queryset=required_skills)) + .order_by("-datetime_created", "-id") + ) + detail_projects = Project.objects.select_related("leader", "industry") + queryset = annotate_workspace_subscription_state( + _with_workspace_relations(detail_projects, user), + user=user, + ) + return queryset.prefetch_related( + Prefetch( + "collaborator_set", + queryset=collaborators, + to_attr="_workspace_collaborators", + ), + "links", + Prefetch("vacancies", queryset=vacancies), + ) + + +def filter_workspace_visible_projects(queryset: QuerySet[Project], *, user): + """Ограничивает queryset действующими правилами видимости workspace.""" + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter( + Q(draft=False, is_public=True) | Q(leader=user) | Q(collaborator__user=user) + ).distinct() + + +def get_workspace_subscription_queryset(*, user) -> QuerySet[Project]: + """Возвращает доступные проекты с read-only состоянием текущей подписки.""" + queryset = annotate_workspace_subscription_state(Project.objects.all(), user=user) + return filter_workspace_visible_projects(queryset, user=user) diff --git a/projects/workspace_serializers.py b/projects/workspace_serializers.py new file mode 100644 index 00000000..b1205118 --- /dev/null +++ b/projects/workspace_serializers.py @@ -0,0 +1,272 @@ +from django.db import transaction +from rest_framework import serializers + +from projects.models import Project, ProjectLink +from vacancy.serializers import ProjectVacancyListSerializer + + +PROJECT_WORKSPACE_EDITABLE_FIELDS = frozenset( + { + "name", + "description", + "region", + "actuality", + "problem", + "target_audience", + "implementation_deadline", + "trl", + "presentation_address", + "image_address", + "cover_image_address", + "industry", + "links", + "draft", + "is_public", + } +) + +PROJECT_PUBLICATION_REQUIRED_FIELDS = { + "name": "Укажите название проекта.", + "region": "Укажите регион.", + "industry": "Выберите отрасль.", + "description": "Добавьте описание проекта.", + "problem": "Опишите проблему.", + "target_audience": "Опишите целевую аудиторию.", + "cover_image_address": "Загрузите обложку проекта.", +} + + +class ProjectWorkspaceVacancySerializer(ProjectVacancyListSerializer): + """Переиспользует legacy-контракт с заранее подсчитанными откликами.""" + + def get_response_count(self, vacancy): + return vacancy.workspace_response_count + + +class ProjectWorkspaceUserSerializer(serializers.Serializer): + id = serializers.IntegerField() + first_name = serializers.CharField(allow_blank=True) + last_name = serializers.CharField(allow_blank=True) + avatar = serializers.URLField(allow_blank=True, allow_null=True) + + +class ProjectWorkspaceCollaboratorSerializer(serializers.Serializer): + user = ProjectWorkspaceUserSerializer() + role = serializers.CharField(allow_blank=True, allow_null=True) + specialization = serializers.CharField(allow_blank=True, allow_null=True) + + +class ProjectSubscriptionStateSerializer(serializers.Serializer): + """Стабильный workspace-контракт без персональных данных подписчиков.""" + + is_subscribed = serializers.BooleanField(read_only=True) + subscribers_count = serializers.IntegerField(read_only=True, min_value=0) + + +class ProjectSubscriptionActionSerializer(serializers.Serializer): + """Разрешает только обязательный пустой payload действия подписки.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать для данного действия." + for field in sorted(self.initial_data) + } + ) + return attrs + + +class ProjectActivitySerializer(serializers.Serializer): + id = serializers.IntegerField(source="program_id") + name = serializers.CharField(source="program.name") + application_id = serializers.IntegerField(source="id") + application_status = serializers.CharField(source="status") + + +class ProjectWorkspaceListSerializer(serializers.ModelSerializer): + short_description = serializers.SerializerMethodField() + current_user_role = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_use_in_application = serializers.SerializerMethodField() + activities = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ( + "id", + "name", + "short_description", + "image_address", + "cover_image_address", + "draft", + "is_public", + "current_user_role", + "can_edit", + "can_use_in_application", + "activities", + "datetime_updated", + ) + + def get_short_description(self, project): + return project.get_short_description() + + def get_current_user_role(self, project): + user = self.context["request"].user + if project.leader_id == user.pk: + return "leader" + collaborations = getattr(project, "_current_user_collaborations", ()) + return "collaborator" if collaborations else None + + def get_can_edit(self, project): + user = self.context["request"].user + return bool(user.is_staff or user.is_superuser or project.leader_id == user.pk) + + def get_can_use_in_application(self, project): + return project.leader_id == self.context["request"].user.pk + + def get_activities(self, project): + applications = getattr(project, "_workspace_applications", ()) + return ProjectActivitySerializer(applications, many=True).data + + +class ProjectWorkspaceDetailSerializer(ProjectWorkspaceListSerializer): + leader = ProjectWorkspaceUserSerializer(read_only=True) + collaborators = serializers.SerializerMethodField() + links = serializers.SerializerMethodField() + industry = serializers.SerializerMethodField() + vacancies = ProjectWorkspaceVacancySerializer(many=True, read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + subscribers_count = serializers.IntegerField(read_only=True, min_value=0) + + class Meta(ProjectWorkspaceListSerializer.Meta): + fields = ProjectWorkspaceListSerializer.Meta.fields + ( + "description", + "region", + "actuality", + "problem", + "target_audience", + "implementation_deadline", + "trl", + "presentation_address", + "leader", + "collaborators", + "links", + "industry", + "datetime_created", + "vacancies", + "is_subscribed", + "subscribers_count", + ) + + def get_collaborators(self, project): + collaborators = getattr(project, "_workspace_collaborators", ()) + return ProjectWorkspaceCollaboratorSerializer(collaborators, many=True).data + + def get_links(self, project): + return [item.link for item in project.links.all()] + + def get_industry(self, project): + if project.industry is None: + return None + return {"id": project.industry_id, "name": project.industry.name} + + +class ProjectWorkspaceUpdateSerializer(serializers.ModelSerializer): + editable_fields = PROJECT_WORKSPACE_EDITABLE_FIELDS + links = serializers.ListField( + child=serializers.URLField(), + required=False, + allow_empty=True, + write_only=True, + ) + + class Meta: + model = Project + fields = tuple(sorted(PROJECT_WORKSPACE_EDITABLE_FIELDS)) + extra_kwargs = { + "name": {"allow_blank": False, "allow_null": False}, + "description": {"allow_blank": True, "allow_null": True}, + } + + def _get_resulting_value(self, attrs, field): + """Возвращает значение поля после применения частичного обновления.""" + if field in attrs: + return attrs[field] + return getattr(self.instance, field) + + def _requires_publication_validation(self, attrs): + """Определяет, переводит ли запрос Project в публикуемое состояние. + + Legacy Project может уже иметь сочетание draft=true и is_public=true, + поэтому обычное редактирование такого объекта не считаем публикацией. + Явное включение публичности или снятие флага черновика всегда требует + заполненного проекта, как и изменение уже опубликованного Project. + """ + resulting_draft = self._get_resulting_value(attrs, "draft") + resulting_is_public = self._get_resulting_value(attrs, "is_public") + return ( + ("draft" in attrs and resulting_draft is False) + or ("is_public" in attrs and resulting_is_public is True) + or (resulting_draft is False and resulting_is_public is True) + ) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference(self.editable_fields) + if unsupported: + raise serializers.ValidationError( + { + field: "Это поле нельзя изменить через API рабочего пространства." + for field in sorted(unsupported) + } + ) + + if self._requires_publication_validation(attrs): + errors = {} + for field, message in PROJECT_PUBLICATION_REQUIRED_FIELDS.items(): + value = self._get_resulting_value(attrs, field) + if value is None or (isinstance(value, str) and not value.strip()): + errors[field] = message + if errors: + raise serializers.ValidationError(errors) + return attrs + + def validate_links(self, links): + """Удаляет дубликаты ссылок, сохраняя пользовательский порядок.""" + return list(dict.fromkeys(links)) + + @transaction.atomic + def update(self, instance, validated_data): + """Атомарно обновляет Project и его отдельные строки ProjectLink.""" + links = validated_data.pop("links", None) + project = super().update(instance, validated_data) + if links is not None: + ProjectLink.objects.filter(project=project).delete() + ProjectLink.objects.bulk_create( + [ProjectLink(project=project, link=link) for link in links] + ) + return project + + +class ProjectWorkspaceCreateSerializer(serializers.Serializer): + """Создает пустой приватный черновик без клиентских полей владения.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Поле нельзя передавать при создании черновика." + for field in sorted(self.initial_data) + } + ) + return attrs + + @transaction.atomic + def create(self, validated_data): + # Отдельный React-контур явно создает private draft; legacy POST /projects/ + # сохраняет прежний контракт и model default is_public. + return Project.objects.create( + leader=self.context["request"].user, + draft=True, + is_public=False, + ) diff --git a/projects/workspace_subscription_services.py b/projects/workspace_subscription_services.py new file mode 100644 index 00000000..a7193f2b --- /dev/null +++ b/projects/workspace_subscription_services.py @@ -0,0 +1,43 @@ +from django.db import transaction + +from projects.models import Project +from projects.workspace_selectors import ( + filter_workspace_visible_projects, + get_workspace_subscription_queryset, +) + + +@transaction.atomic +def set_workspace_project_subscription( + *, project_id: int, user, is_subscribed: bool +) -> Project: + """Идемпотентно меняет подписку и возвращает актуальное состояние. + + Сначала проверяем workspace-видимость, затем блокируем только строку Project. + Все новые mutation endpoints используют одинаковый порядок блокировки, поэтому + параллельные POST/DELETE не расходятся по count и не блокируют nullable JOIN. + """ + visible_project_id = ( + filter_workspace_visible_projects(Project.objects.all(), user=user) + .filter(pk=project_id) + .values_list("pk", flat=True) + .first() + ) + if visible_project_id is None: + raise Project.DoesNotExist + + project = Project.objects.select_for_update().get(pk=visible_project_id) + # Видимость проверяется повторно после блокировки: проект мог стать private/draft + # между первым чтением и началом изменения M2M-связи. + if not filter_workspace_visible_projects( + Project.objects.filter(pk=project.pk), + user=user, + ).exists(): + raise Project.DoesNotExist + + if is_subscribed: + project.subscribers.add(user) + else: + project.subscribers.remove(user) + + return get_workspace_subscription_queryset(user=user).get(pk=project.pk) diff --git a/projects/workspace_views.py b/projects/workspace_views.py new file mode 100644 index 00000000..33e64423 --- /dev/null +++ b/projects/workspace_views.py @@ -0,0 +1,197 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from projects.models import Project +from projects.pagination import ProjectsPagination, SubscribedProjectsPagination +from projects.workspace_selectors import ( + filter_workspace_visible_projects, + get_project_catalog_queryset, + get_subscribed_projects_queryset, + get_user_projects_queryset, + get_workspace_project_queryset, + get_workspace_subscription_queryset, +) +from projects.workspace_serializers import ( + ProjectSubscriptionActionSerializer, + ProjectSubscriptionStateSerializer, + ProjectWorkspaceCreateSerializer, + ProjectWorkspaceDetailSerializer, + ProjectWorkspaceListSerializer, + ProjectWorkspaceUpdateSerializer, +) +from projects.workspace_subscription_services import ( + set_workspace_project_subscription, +) + + +def _parse_industry_id(raw_value): + if raw_value in (None, ""): + return None + try: + value = int(raw_value) + except (TypeError, ValueError) as exc: + raise ValidationError( + {"industry": "Укажите корректный идентификатор отрасли."} + ) from exc + if value <= 0: + raise ValidationError({"industry": "Укажите корректный идентификатор отрасли."}) + return value + + +class ProjectCatalogView(generics.ListAPIView): + """Публичный каталог опубликованных проектов для авторизованного React-клиента.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = ProjectsPagination + + def get_queryset(self): + return get_project_catalog_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + industry_id=_parse_industry_id(self.request.query_params.get("industry")), + ) + + +class MyProjectsView(generics.ListAPIView): + """Проекты, которыми пользователь руководит или в которых участвует.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = ProjectsPagination + + def get_queryset(self): + return get_user_projects_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + ) + + +class SubscribedProjectsView(generics.ListAPIView): + """Доступные workspace-проекты из подписок текущего пользователя.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = SubscribedProjectsPagination + + def get_queryset(self): + return get_subscribed_projects_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + ) + + +class ProjectWorkspaceCreateView(APIView): + """Создает самостоятельный приватный черновик для React workspace.""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ProjectWorkspaceCreateSerializer( + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + project = serializer.save() + created_project = get_workspace_project_queryset(user=request.user).get( + pk=project.pk + ) + response_serializer = ProjectWorkspaceDetailSerializer( + created_project, + context={"request": request}, + ) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class ProjectWorkspaceDetailView(APIView): + """Безопасная карточка Project и ограниченное редактирование лидером.""" + + permission_classes = [IsAuthenticated] + + def get_object(self, request, project_id): + queryset = get_workspace_project_queryset(user=request.user) + queryset = filter_workspace_visible_projects(queryset, user=request.user) + return get_object_or_404(queryset, pk=project_id) + + def get(self, request, project_id): + project = self.get_object(request, project_id) + serializer = ProjectWorkspaceDetailSerializer( + project, + context={"request": request}, + ) + return Response(serializer.data) + + def patch(self, request, project_id): + project = self.get_object(request, project_id) + if not ( + request.user.is_staff + or request.user.is_superuser + or project.leader_id == request.user.pk + ): + raise PermissionDenied("Редактировать проект может только руководитель.") + + serializer = ProjectWorkspaceUpdateSerializer( + project, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + # Повторная выборка возвращает тот же полный контракт, что и GET, + # включая вычисленные права и связанные активности. + updated_project = self.get_object(request, project_id) + response_serializer = ProjectWorkspaceDetailSerializer( + updated_project, + context={"request": request}, + ) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + +class ProjectWorkspaceSubscriptionView(APIView): + """Возвращает и идемпотентно меняет подписку текущего пользователя.""" + + permission_classes = [IsAuthenticated] + + def get_object(self, request, project_id): + queryset = get_workspace_subscription_queryset(user=request.user) + project = queryset.filter(pk=project_id).first() + if project is None: + raise NotFound("Проект не найден.") + return project + + def get(self, request, project_id): + project = self.get_object(request, project_id) + return Response(ProjectSubscriptionStateSerializer(project).data) + + def update_subscription(self, request, project_id, *, is_subscribed): + """Проверяет пустой payload и преобразует отсутствие доступа в safe 404.""" + serializer = ProjectSubscriptionActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + project = set_workspace_project_subscription( + project_id=project_id, + user=request.user, + is_subscribed=is_subscribed, + ) + except Project.DoesNotExist as exc: + raise NotFound("Проект не найден.") from exc + return Response(ProjectSubscriptionStateSerializer(project).data) + + def post(self, request, project_id): + return self.update_subscription( + request, + project_id, + is_subscribed=True, + ) + + def delete(self, request, project_id): + return self.update_subscription( + request, + project_id, + is_subscribed=False, + ) diff --git a/scripts/deploy_react_dev.sh b/scripts/deploy_react_dev.sh new file mode 100755 index 00000000..eb6a57b4 --- /dev/null +++ b/scripts/deploy_react_dev.sh @@ -0,0 +1,599 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# Roadmap: DEV-075 + +readonly DEPLOY_DIR="/root/api-react-dev" +readonly FORBIDDEN_DIR="/root/api" +readonly EXPECTED_REPOSITORY="PROCOLLAB-github/api" +readonly EXPECTED_COMPOSE_PROJECT="api-react" +readonly CELERY_SERVICE="celerys" +readonly HEALTH_URL="https://api-react-dev.procollab.ru/programs/?limit=1" +readonly LOCK_FILE="${DEPLOY_DIR}/.react-dev-deploy.lock" +readonly LOCK_TIMEOUT_SECONDS=10 +readonly SERVICE_WAIT_ATTEMPTS=30 +readonly SERVICE_WAIT_DELAY_SECONDS=2 +readonly HEALTH_ATTEMPTS=8 +readonly HEALTH_RETRY_DELAY_SECONDS=5 + +DEPLOY_SHA="${1:-}" +GITHUB_ACTIONS_RUN_ID="${2:-}" +DEPLOY_IMAGE="${3:-}" + +PREVIOUS_SHA="" +PREVIOUS_WEB_CONTAINER_ID="" +PREVIOUS_CELERY_CONTAINER_ID="" +PREVIOUS_WEB_IMAGE_ID="" +PREVIOUS_CELERY_IMAGE_ID="" +PREVIOUS_WEB_IMAGE_REF="" +PREVIOUS_CELERY_IMAGE_REF="" +TARGET_WEB_IMAGE_REF="" +TARGET_CELERY_IMAGE_REF="" +HEALTH_JSON_IMAGE_ID="" +HEALTH_BODY_FILE="" +REPOSITORY_MOVED=false +IMAGE_REFERENCES_CHANGED=false +MIGRATION_STARTED=false +MIGRATION_COMPLETED=false +CONTAINERS_MAY_HAVE_CHANGED=false +ERROR_HANDLING=false +COMPOSE_PROJECT="" +COMPOSE_CMD=() + +log() { + printf '[react-dev-deploy] %s\n' "$*" +} + +fail() { + printf '[react-dev-deploy] ERROR: %s\n' "$*" >&2 + return 1 +} + +cleanup_health_file() { + if [[ -n "$HEALTH_BODY_FILE" && -f "$HEALTH_BODY_FILE" ]]; then + rm -f -- "$HEALTH_BODY_FILE" + fi + HEALTH_BODY_FILE="" +} + +restore_repository() { + if [[ "$REPOSITORY_MOVED" == true && -n "$PREVIOUS_SHA" ]]; then + log "Возврат repository к предыдущему commit." + if ! git reset --hard "$PREVIOUS_SHA" >/dev/null; then + return 1 + fi + REPOSITORY_MOVED=false + fi +} + +restore_image_references() { + local result=0 + + if [[ "$IMAGE_REFERENCES_CHANGED" != true ]]; then + return 0 + fi + + if [[ -n "$PREVIOUS_WEB_IMAGE_ID" && -n "$PREVIOUS_WEB_IMAGE_REF" ]]; then + docker image tag "$PREVIOUS_WEB_IMAGE_ID" "$PREVIOUS_WEB_IMAGE_REF" || + result=1 + fi + if [[ -n "$PREVIOUS_CELERY_IMAGE_ID" && -n "$PREVIOUS_CELERY_IMAGE_REF" ]]; then + docker image tag "$PREVIOUS_CELERY_IMAGE_ID" "$PREVIOUS_CELERY_IMAGE_REF" || + result=1 + fi + + return "$result" +} + +compose_service_container_id() { + local service="$1" + local -n result_variable="$2" + local output + local container_ids=() + + output="$("${COMPOSE_CMD[@]}" ps --all --quiet "$service")" + if [[ -n "$output" ]]; then + mapfile -t container_ids <<< "$output" + fi + if ((${#container_ids[@]} != 1)); then + printf '[react-dev-deploy] Service %s has %d containers; expected 1.\n' \ + "$service" "${#container_ids[@]}" >&2 + return 1 + fi + + result_variable="${container_ids[0]}" +} + +compose_service_image_ref() { + local service="$1" + local -n result_variable="$2" + local output="" + + output="$( + "${COMPOSE_CMD[@]}" config --format json | + docker run \ + --rm \ + --interactive \ + --entrypoint python \ + "$PREVIOUS_WEB_IMAGE_ID" \ + -c ' +import json +import sys + +service = sys.argv[1] +config = json.load(sys.stdin) +image = config.get("services", {}).get(service, {}).get("image") +if not isinstance(image, str) or not image: + raise SystemExit(f"Image reference is missing for service {service}") +print(image) +' \ + "$service" + )" + if [[ -z "$output" || "$output" == *$'\n'* ]]; then + printf '[react-dev-deploy] Service %s has invalid image reference.\n' \ + "$service" >&2 + return 1 + fi + + result_variable="$output" +} + +wait_for_service_running() { + local service="$1" + local attempt + local container_id="" + local status="" + local restarting="" + + for ((attempt = 1; attempt <= SERVICE_WAIT_ATTEMPTS; attempt++)); do + if compose_service_container_id "$service" container_id; then + status="$(docker inspect --format '{{.State.Status}}' "$container_id")" + restarting="$( + docker inspect --format '{{.State.Restarting}}' "$container_id" + )" + if [[ "$status" == "running" && "$restarting" == "false" ]]; then + return 0 + fi + fi + sleep "$SERVICE_WAIT_DELAY_SECONDS" + done + + printf '[react-dev-deploy] Service %s did not reach running state; status=%s.\n' \ + "$service" "${status:-missing}" >&2 + return 1 +} + +health_check() { + local label="$1" + local attempt + local metadata="" + local http_status="" + local content_type="" + + if [[ -z "$HEALTH_JSON_IMAGE_ID" ]]; then + printf '[react-dev-deploy] JSON parser image is not initialized.\n' >&2 + return 1 + fi + + HEALTH_BODY_FILE="$(mktemp /tmp/procollab-react-dev-health.XXXXXX)" + for ((attempt = 1; attempt <= HEALTH_ATTEMPTS; attempt++)); do + metadata="" + if metadata="$( + curl \ + --silent \ + --show-error \ + --proto '=https' \ + --max-redirs 0 \ + --connect-timeout 5 \ + --max-time 15 \ + --output "$HEALTH_BODY_FILE" \ + --write-out '%{http_code}|%{content_type}' \ + "$HEALTH_URL" + )"; then + http_status="${metadata%%|*}" + content_type="${metadata#*|}" + log "${label} health-check attempt ${attempt}: HTTP ${http_status}, content-type=${content_type:-unknown}." + + if [[ "$http_status" == "200" ]] && + [[ "$content_type" == *"application/json"* ]] && + [[ -s "$HEALTH_BODY_FILE" ]] && + ! grep -Eiq '^[[:space:]]*<(html|!doctype)' "$HEALTH_BODY_FILE" && + docker run \ + --rm \ + --interactive \ + --entrypoint python \ + "$HEALTH_JSON_IMAGE_ID" \ + -c 'import json, sys; json.load(sys.stdin)' \ + < "$HEALTH_BODY_FILE" \ + >/dev/null; then + cleanup_health_file + return 0 + fi + else + log "${label} health-check attempt ${attempt}: connection failed." + fi + + sleep "$HEALTH_RETRY_DELAY_SECONDS" + done + + cleanup_health_file + return 1 +} + +rollback_deployment() { + local rollback_failed=0 + + log "Запуск rollback React-dev code и container images." + restore_repository || rollback_failed=1 + restore_image_references || rollback_failed=1 + + if [[ "$CONTAINERS_MAY_HAVE_CHANGED" == true && "$rollback_failed" -eq 0 ]]; then + "${COMPOSE_CMD[@]}" up \ + --detach \ + --no-deps \ + --no-build \ + --force-recreate \ + web "$CELERY_SERVICE" || + rollback_failed=1 + + if ((rollback_failed == 0)); then + wait_for_service_running web || rollback_failed=1 + wait_for_service_running "$CELERY_SERVICE" || rollback_failed=1 + fi + + if ((rollback_failed == 0)); then + HEALTH_JSON_IMAGE_ID="$PREVIOUS_WEB_IMAGE_ID" + health_check "Rollback" || rollback_failed=1 + fi + fi + + if ((rollback_failed != 0)); then + printf '[react-dev-deploy] ERROR: rollback React-dev завершился ошибкой.\n' >&2 + return 1 + fi + + log "Rollback React-dev успешно завершен." + return 0 +} + +handle_error() { + local exit_code="$?" + local line_number="${1:-unknown}" + local rollback_status=0 + + if [[ "$ERROR_HANDLING" == true ]]; then + exit "$exit_code" + fi + + ERROR_HANDLING=true + trap - ERR + set +e + cleanup_health_file + printf '[react-dev-deploy] ERROR: deploy failed near line %s.\n' \ + "$line_number" >&2 + + if [[ "$REPOSITORY_MOVED" == true || + "$IMAGE_REFERENCES_CHANGED" == true || + "$CONTAINERS_MAY_HAVE_CHANGED" == true ]]; then + rollback_deployment + rollback_status="$?" + fi + + if [[ "$MIGRATION_STARTED" == true ]]; then + printf '%s\n' \ + '[react-dev-deploy] NOTICE: примененные миграции автоматически назад не откатывались.' >&2 + fi + if ((rollback_status != 0)); then + printf '%s\n' \ + '[react-dev-deploy] ERROR: исходная ошибка deploy и ошибка rollback требуют ручной проверки React-dev.' >&2 + fi + + exit "$exit_code" +} + +trap 'handle_error "$LINENO"' ERR + +if [[ ! "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then + fail "DEPLOY_SHA должен состоять ровно из 40 lowercase hexadecimal символов." +fi +if [[ ! "$GITHUB_ACTIONS_RUN_ID" =~ ^[0-9]+$ ]]; then + fail "GitHub Actions run ID должен быть числовым." +fi +if [[ ! "$DEPLOY_IMAGE" =~ ^ghcr\.io/procollab-github/api@sha256:[0-9a-f]{64}$ ]]; then + fail "DEPLOY_IMAGE должен быть immutable GHCR reference по sha256 digest." +fi +if [[ "$(uname -s)" != "Linux" ]]; then + fail "Deploy script разрешено запускать только на Linux." +fi + +for required_command in git docker curl flock realpath mktemp grep; do + if ! command -v "$required_command" >/dev/null 2>&1; then + fail "Не найдена обязательная команда: ${required_command}." + fi +done +if ! docker compose version >/dev/null 2>&1; then + fail "Docker Compose plugin недоступен." +fi + +if [[ ! -d "$DEPLOY_DIR" || ! -d "$DEPLOY_DIR/.git" ]]; then + fail "React-dev repository не найден в ${DEPLOY_DIR}." +fi +actual_deploy_dir="$(realpath -e "$DEPLOY_DIR")" +if [[ "$actual_deploy_dir" != "$DEPLOY_DIR" ]]; then + fail "React-dev repository должен иметь точный realpath ${DEPLOY_DIR}." +fi +if [[ -e "$FORBIDDEN_DIR" ]] && + [[ "$(realpath -e "$FORBIDDEN_DIR")" == "$actual_deploy_dir" ]]; then + fail "Production/legacy backend path запрещен." +fi + +cd -- "$actual_deploy_dir" +if [[ "$(pwd -P)" != "$DEPLOY_DIR" ]]; then + fail "Текущий каталог не является разрешенным React-dev repository." +fi + +exec 9> "$LOCK_FILE" +if ! flock --wait "$LOCK_TIMEOUT_SECONDS" 9; then + fail "Другой React-dev deploy уже выполняется; lock не получен." +fi + +origin_url="$(git remote get-url origin)" +case "$origin_url" in + "https://github.com/${EXPECTED_REPOSITORY}" | \ + "https://github.com/${EXPECTED_REPOSITORY}.git" | \ + "git@github.com:${EXPECTED_REPOSITORY}.git" | \ + "ssh://git@github.com/${EXPECTED_REPOSITORY}" | \ + "ssh://git@github.com/${EXPECTED_REPOSITORY}.git") + ;; + *) + fail "Origin не соответствует разрешенному backend repository." + ;; +esac + +PREVIOUS_SHA="$(git rev-parse HEAD)" +if ! git diff --quiet --ignore-submodules --; then + fail "В React-dev repository есть unstaged tracked changes." +fi +if ! git diff --cached --quiet --ignore-submodules --; then + fail "В React-dev repository есть staged tracked changes." +fi + +mapfile -t current_web_containers < <( + docker ps \ + --quiet \ + --filter "label=com.docker.compose.project=${EXPECTED_COMPOSE_PROJECT}" \ + --filter "label=com.docker.compose.service=web" +) +if ((${#current_web_containers[@]} != 1)); then + fail "Не найден ровно один running web container проекта api-react." +fi +PREVIOUS_WEB_CONTAINER_ID="${current_web_containers[0]}" + +COMPOSE_PROJECT="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" +compose_working_dir="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" +compose_config_files_label="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" + +if [[ "$COMPOSE_PROJECT" != "$EXPECTED_COMPOSE_PROJECT" ]]; then + fail "Обнаружен неожиданный Docker Compose project." +fi +if [[ -z "$compose_working_dir" || -z "$compose_config_files_label" ]]; then + fail "У running web container отсутствуют обязательные Compose labels." +fi + +compose_working_dir="$(realpath -e "$compose_working_dir")" +if [[ "$compose_working_dir" != "$DEPLOY_DIR" ]]; then + fail "Docker Compose working_dir находится вне React-dev repository." +fi + +legacy_compose_file="$(realpath -m "${DEPLOY_DIR}/docker-compose.yml")" +compose_config_files=() +IFS=',' read -r -a raw_compose_config_files <<< "$compose_config_files_label" +for raw_config_file in "${raw_compose_config_files[@]}"; do + config_file="${raw_config_file#"${raw_config_file%%[![:space:]]*}"}" + config_file="${config_file%"${config_file##*[![:space:]]}"}" + if [[ -z "$config_file" ]]; then + fail "Compose config_files label содержит пустой путь." + fi + if [[ "$config_file" != /* ]]; then + config_file="${compose_working_dir}/${config_file}" + fi + config_file="$(realpath -e "$config_file")" + if [[ "$config_file" != "${DEPLOY_DIR}/"* ]]; then + fail "Compose config file находится вне React-dev repository." + fi + if [[ "$config_file" == "$legacy_compose_file" ]]; then + fail "Repository legacy docker-compose.yml запрещен для React-dev deploy." + fi + compose_config_files+=("$config_file") +done +if ((${#compose_config_files[@]} == 0)); then + fail "Compose config files не обнаружены." +fi + +COMPOSE_CMD=( + docker compose + --project-name "$COMPOSE_PROJECT" + --project-directory "$compose_working_dir" +) +for config_file in "${compose_config_files[@]}"; do + COMPOSE_CMD+=(--file "$config_file") +done + +validate_compose_services() { + local services_output + local service + local expected_service + local services=() + local found + + services_output="$("${COMPOSE_CMD[@]}" config --services)" + mapfile -t services <<< "$services_output" + for expected_service in web "$CELERY_SERVICE" redis; do + found=false + for service in "${services[@]}"; do + if [[ "$service" == "$expected_service" ]]; then + found=true + break + fi + done + if [[ "$found" != true ]]; then + printf '[react-dev-deploy] Expected Compose service is missing: %s.\n' \ + "$expected_service" >&2 + return 1 + fi + done +} + +validate_compose_services + +mapfile -t current_celery_containers < <( + docker ps \ + --quiet \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" \ + --filter "label=com.docker.compose.service=${CELERY_SERVICE}" +) +if ((${#current_celery_containers[@]} != 1)); then + fail "Не найден ровно один running celery container проекта api-react." +fi +PREVIOUS_CELERY_CONTAINER_ID="${current_celery_containers[0]}" + +PREVIOUS_WEB_IMAGE_ID="$( + docker inspect --format '{{.Image}}' "$PREVIOUS_WEB_CONTAINER_ID" +)" +PREVIOUS_CELERY_IMAGE_ID="$( + docker inspect --format '{{.Image}}' "$PREVIOUS_CELERY_CONTAINER_ID" +)" +PREVIOUS_WEB_IMAGE_REF="$( + docker inspect --format '{{.Config.Image}}' "$PREVIOUS_WEB_CONTAINER_ID" +)" +PREVIOUS_CELERY_IMAGE_REF="$( + docker inspect --format '{{.Config.Image}}' "$PREVIOUS_CELERY_CONTAINER_ID" +)" +if [[ -z "$PREVIOUS_WEB_IMAGE_ID" || -z "$PREVIOUS_CELERY_IMAGE_ID" || + -z "$PREVIOUS_WEB_IMAGE_REF" || -z "$PREVIOUS_CELERY_IMAGE_REF" ]]; then + fail "Не удалось сохранить image state текущих React-dev containers." +fi + +compose_service_image_ref web configured_web_image_ref +compose_service_image_ref "$CELERY_SERVICE" configured_celery_image_ref +if [[ "$configured_web_image_ref" != "$PREVIOUS_WEB_IMAGE_REF" ]] || + [[ "$configured_celery_image_ref" != "$PREVIOUS_CELERY_IMAGE_REF" ]]; then + fail "Running image references не совпадают с текущей Compose-конфигурацией." +fi + +git fetch origin master --prune +if ! git cat-file -e "${DEPLOY_SHA}^{commit}"; then + fail "DEPLOY_SHA отсутствует в backend repository." +fi +origin_master_sha="$(git rev-parse --verify 'origin/master^{commit}')" +if ! git merge-base --is-ancestor "$DEPLOY_SHA" "$origin_master_sha"; then + fail "DEPLOY_SHA не является commit из текущего origin/master." +fi +if [[ "$origin_master_sha" != "$DEPLOY_SHA" ]]; then + log "Deploy устарел: origin/master уже содержит более новый commit." + log "Код и containers React-dev не изменялись." + trap - ERR + exit 0 +fi + +REPOSITORY_MOVED=true +git reset --hard "$DEPLOY_SHA" >/dev/null +actual_sha="$(git rev-parse HEAD)" +if [[ "$actual_sha" != "$DEPLOY_SHA" ]]; then + fail "Repository не перешел на DEPLOY_SHA." +fi + +validate_compose_services +compose_service_image_ref web TARGET_WEB_IMAGE_REF +compose_service_image_ref "$CELERY_SERVICE" TARGET_CELERY_IMAGE_REF + +log "Загрузка проверенного backend image из GHCR по immutable digest." +docker pull "$DEPLOY_IMAGE" +new_image_id="$(docker image inspect --format '{{.Id}}' "$DEPLOY_IMAGE")" +image_revision="$( + docker image inspect \ + --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \ + "$DEPLOY_IMAGE" +)" +if [[ -z "$new_image_id" ]]; then + fail "Не удалось определить загруженный backend image." +fi +if [[ "$image_revision" != "$DEPLOY_SHA" ]]; then + fail "Revision label загруженного image не совпадает с DEPLOY_SHA." +fi + +IMAGE_REFERENCES_CHANGED=true +docker image tag "$new_image_id" "$TARGET_WEB_IMAGE_REF" +docker image tag "$new_image_id" "$TARGET_CELERY_IMAGE_REF" +if [[ "$(docker image inspect --format '{{.Id}}' "$TARGET_WEB_IMAGE_REF")" != "$new_image_id" ]] || + [[ "$(docker image inspect --format '{{.Id}}' "$TARGET_CELERY_IMAGE_REF")" != "$new_image_id" ]]; then + fail "Compose image references не указывают на загруженный backend image." +fi +HEALTH_JSON_IMAGE_ID="$new_image_id" + +log "Django system check на новом web image." +"${COMPOSE_CMD[@]}" run \ + --rm \ + --no-deps \ + -T \ + web python manage.py check - {self.user.first_name} {self.user.last_name}" @@ -547,6 +560,14 @@ class Meta(TypedModelMeta): verbose_name = "Ссылка пользователя" verbose_name_plural = "Ссылки пользователей" unique_together = ("user", "link") + constraints = [ + # Legacy-ссылки остаются без типа; ограничение применяется только к новому контракту. + models.UniqueConstraint( + fields=("user", "kind"), + condition=models.Q(kind__isnull=False), + name="unique_typed_user_link_kind", + ) + ] class AbstractUserExperience(models.Model): diff --git a/users/password_reset_urls.py b/users/password_reset_urls.py new file mode 100644 index 00000000..70bf102c --- /dev/null +++ b/users/password_reset_urls.py @@ -0,0 +1,26 @@ +from django.urls import path +from django_rest_passwordreset.views import ( + ResetPasswordConfirm, + ResetPasswordRequestToken, + ResetPasswordValidateToken, +) + +from core.throttling import PostOnlyScopedRateThrottle + +app_name = "password_reset" + + +class ThrottledResetPasswordRequestToken(ResetPasswordRequestToken): + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_reset_password" + + +urlpatterns = [ + path("", ThrottledResetPasswordRequestToken.as_view(), name="reset-password-request"), + path("confirm/", ResetPasswordConfirm.as_view(), name="reset-password-confirm"), + path( + "validate_token/", + ResetPasswordValidateToken.as_view(), + name="reset-password-validate", + ), +] diff --git a/users/public_profile_filters.py b/users/public_profile_filters.py new file mode 100644 index 00000000..934a68ee --- /dev/null +++ b/users/public_profile_filters.py @@ -0,0 +1,31 @@ +from django.db.models import Q, QuerySet +from django_filters import rest_framework as filters + +from users.models import CustomUser + + +class PublicProfileFilter(filters.FilterSet): + """Фильтры каталога участников без обращения к приватным полям профиля.""" + + search = filters.CharFilter(method="filter_search") + user_type = filters.NumberFilter(field_name="user_type") + specialization = filters.NumberFilter(field_name="v2_speciality_id") + skill = filters.NumberFilter(field_name="skills__skill_id") + + @staticmethod + def filter_search( + queryset: QuerySet[CustomUser], name: str, value: str + ) -> QuerySet[CustomUser]: + """Ищет каждую часть запроса в имени или фамилии в любом порядке.""" + + del name + terms = [term for term in value.split() if term] + for term in terms: + queryset = queryset.filter( + Q(first_name__icontains=term) | Q(last_name__icontains=term) + ) + return queryset + + class Meta: + model = CustomUser + fields = ("user_type", "specialization", "skill") diff --git a/users/public_profile_selectors.py b/users/public_profile_selectors.py new file mode 100644 index 00000000..98963a31 --- /dev/null +++ b/users/public_profile_selectors.py @@ -0,0 +1,50 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Prefetch, QuerySet + +from core.models import SkillToObject +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserLink, + UserWorkExperience, +) + + +def get_public_profiles_queryset(*, detailed: bool = False) -> QuerySet[CustomUser]: + """Возвращает активные профили с данными, разрешёнными публичным контрактом.""" + + user_content_type = ContentType.objects.get_for_model(CustomUser) + skills = ( + SkillToObject.objects.filter(content_type=user_content_type) + .select_related("skill", "skill__category") + .order_by("skill__name", "skill_id") + ) + + queryset = ( + CustomUser.objects.filter(is_active=True) + .select_related("v2_speciality", "v2_speciality__category") + .prefetch_related(Prefetch("skills", queryset=skills, to_attr="public_skills")) + ) + + if detailed: + queryset = queryset.prefetch_related( + Prefetch( + "links", + queryset=UserLink.objects.filter(kind__isnull=False).order_by("kind"), + to_attr="public_social_links", + ), + Prefetch("education", queryset=UserEducation.objects.order_by("id")), + Prefetch( + "work_experience", + queryset=UserWorkExperience.objects.order_by("id"), + ), + Prefetch("user_languages", queryset=UserLanguages.objects.order_by("id")), + Prefetch( + "achievements", + queryset=UserAchievement.objects.order_by("id").prefetch_related("files"), + ), + ) + + return queryset.distinct().order_by("first_name", "last_name", "id") diff --git a/users/public_profile_serializers.py b/users/public_profile_serializers.py new file mode 100644 index 00000000..dfb034f8 --- /dev/null +++ b/users/public_profile_serializers.py @@ -0,0 +1,143 @@ +from rest_framework import serializers + +from core.models import SkillCategory, Specialization, SpecializationCategory +from files.models import UserFile +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserWorkExperience, +) + + +class PublicProfileCategorySerializer(serializers.ModelSerializer): + class Meta: + model = SkillCategory + fields = ("id", "name") + + +class PublicProfileSpecializationCategorySerializer(serializers.ModelSerializer): + class Meta: + model = SpecializationCategory + fields = ("id", "name") + + +class PublicProfileSpecializationSerializer(serializers.ModelSerializer): + category = PublicProfileSpecializationCategorySerializer(read_only=True) + + class Meta: + model = Specialization + fields = ("id", "name", "category") + + +class PublicProfileFileSerializer(serializers.ModelSerializer): + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class PublicProfileAchievementSerializer(serializers.ModelSerializer): + files = PublicProfileFileSerializer(many=True, read_only=True) + + class Meta: + model = UserAchievement + fields = ("id", "title", "status", "year", "files") + + +class PublicProfileEducationSerializer(serializers.ModelSerializer): + class Meta: + model = UserEducation + fields = ( + "organization_name", + "description", + "entry_year", + "completion_year", + "education_level", + "education_status", + ) + + +class PublicProfileWorkExperienceSerializer(serializers.ModelSerializer): + class Meta: + model = UserWorkExperience + fields = ( + "organization_name", + "description", + "entry_year", + "completion_year", + "job_position", + ) + + +class PublicProfileLanguageSerializer(serializers.ModelSerializer): + class Meta: + model = UserLanguages + fields = ("language", "language_level") + + +class PublicProfileListSerializer(serializers.ModelSerializer): + user_type_label = serializers.CharField( + source="get_user_type_display", read_only=True + ) + specialization = PublicProfileSpecializationSerializer( + source="v2_speciality", read_only=True + ) + skills = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + # Публичный контракт строится только через явный allow-list. + fields = ( + "id", + "first_name", + "last_name", + "avatar", + "city", + "user_type", + "user_type_label", + "specialization", + "skills", + ) + + @staticmethod + def get_skills(user: CustomUser) -> list[dict]: + """Сериализует только справочные данные заранее загруженных навыков.""" + + return [ + { + "id": relation.skill_id, + "name": relation.skill.name, + "category": PublicProfileCategorySerializer(relation.skill.category).data, + } + for relation in getattr(user, "public_skills", []) + ] + + +class PublicProfileDetailSerializer(PublicProfileListSerializer): + education = PublicProfileEducationSerializer(many=True, read_only=True) + work_experience = PublicProfileWorkExperienceSerializer(many=True, read_only=True) + user_languages = PublicProfileLanguageSerializer(many=True, read_only=True) + achievements = PublicProfileAchievementSerializer(many=True, read_only=True) + social_links = serializers.SerializerMethodField() + + class Meta(PublicProfileListSerializer.Meta): + fields = PublicProfileListSerializer.Meta.fields + ( + "patronymic", + "about_me", + "social_links", + "education", + "work_experience", + "user_languages", + "achievements", + ) + + @staticmethod + def get_social_links(user: CustomUser) -> dict[str, str]: + """Возвращает только типизированные ссылки без внутренних полей модели.""" + + return { + link.kind: link.link + for link in getattr(user, "public_social_links", []) + if link.kind is not None + } diff --git a/users/public_profile_views.py b/users/public_profile_views.py new file mode 100644 index 00000000..990810e5 --- /dev/null +++ b/users/public_profile_views.py @@ -0,0 +1,34 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticated + +from users.pagination import UsersPagination +from users.public_profile_filters import PublicProfileFilter +from users.public_profile_selectors import get_public_profiles_queryset +from users.public_profile_serializers import ( + PublicProfileDetailSerializer, + PublicProfileListSerializer, +) + + +class PublicProfileListView(ListAPIView): + """Каталог доступных профилей для авторизованных пользователей.""" + + serializer_class = PublicProfileListSerializer + permission_classes = [IsAuthenticated] + pagination_class = UsersPagination + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = PublicProfileFilter + + def get_queryset(self): + return get_public_profiles_queryset() + + +class PublicProfileDetailView(RetrieveAPIView): + """Безопасный публичный профиль по идентификатору пользователя.""" + + serializer_class = PublicProfileDetailSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return get_public_profiles_queryset(detailed=True) diff --git a/users/serializers.py b/users/serializers.py index c13754ed..fa0900c0 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -34,6 +34,7 @@ UserSkillConfirmation, UserWorkExperience, ) +from users.social_links import SocialLinksField, get_social_links, update_social_links from users.utils import normalize_user_phone from users.validators import specialization_exists_validator @@ -459,6 +460,7 @@ class UserDetailSerializer( work_experience = UserWorkExperienceSerializer(required=False, many=True) user_languages = UserLanguagesSerializer(required=False, many=True) links = serializers.SerializerMethodField() + social_links = SocialLinksField(required=False, write_only=True) is_online = serializers.SerializerMethodField() projects = serializers.SerializerMethodField() programs = serializers.SerializerMethodField() @@ -492,6 +494,28 @@ def get_programs(self, user: CustomUser): def get_links(cls, user: CustomUser): return [user_link.link for user_link in user.links.all()] + def validate_social_links(self, value: dict[str, str | None]): + """Не допускает один URL одновременно в разных legacy/typed записях.""" + + instance = self.instance + errors: dict[str, str] = {} + seen_urls: dict[str, str] = {} + for kind, link in value.items(): + if link is None: + continue + if link in seen_urls: + errors[kind] = "Эта ссылка уже указана для другого типа." + continue + seen_urls[link] = kind + if ( + instance is not None + and instance.links.filter(link=link).exclude(kind=kind).exists() + ): + errors[kind] = "Эта ссылка уже сохранена в профиле." + if errors: + raise serializers.ValidationError(errors) + return value + def get_is_online(self, user: CustomUser): request = self.context.get("request") if request and request.user.is_authenticated and request.user.id == user.id: @@ -520,6 +544,7 @@ class Meta: "about_me", "avatar", "links", + "social_links", "city", "phone_number", "is_active", @@ -543,6 +568,7 @@ def update(self, instance, validated_data): IMMUTABLE_FIELDS = ("email", "is_active", "password") USER_TYPE_FIELDS = ("member", "investor", "expert", "mentor") RELATED_FIELDS = ("achievements",) + social_links = validated_data.pop("social_links", None) if instance.user_type == CustomUser.MEMBER: IMMUTABLE_FIELDS = ("email", "user_type", "is_active", "password") @@ -630,6 +656,9 @@ def update(self, instance, validated_data): instance.save() + if social_links is not None: + update_social_links(instance, social_links) + return instance @transaction.atomic @@ -730,6 +759,7 @@ def to_representation(self, instance) -> dict[str, Any]: is available only to the profile owner (used for CV). """ representation = super().to_representation(instance) + representation["social_links"] = get_social_links(instance) request = self.context.get("request") if request and request.user != instance: representation.pop("phone_number", None) diff --git a/users/social_links.py b/users/social_links.py new file mode 100644 index 00000000..ee7b5a21 --- /dev/null +++ b/users/social_links.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping + +from django.db import transaction +from rest_framework import serializers + +from users.models import CustomUser, UserLink + + +class SocialLinksField(serializers.Field): + """Проверяет частичное обновление типизированных социальных ссылок.""" + + default_error_messages = { + "not_object": "Ожидается объект социальных ссылок.", + "unknown_kind": "Неизвестный тип социальной ссылки.", + "invalid_url": "Укажите корректный URL.", + } + + def to_internal_value(self, data): + if not isinstance(data, Mapping): + self.fail("not_object") + + allowed_kinds = set(UserLink.Kind.values) + unknown_kinds = set(data) - allowed_kinds + if unknown_kinds: + raise serializers.ValidationError( + { + kind: self.error_messages["unknown_kind"] + for kind in sorted(unknown_kinds) + } + ) + + url_field = serializers.URLField(allow_null=True) + result: dict[str, str | None] = {} + errors: dict[str, str] = {} + for kind, value in data.items(): + try: + result[kind] = url_field.run_validation(value) + except serializers.ValidationError: + errors[kind] = self.error_messages["invalid_url"] + + if errors: + raise serializers.ValidationError(errors) + return result + + def to_representation(self, value): + return value + + +def get_social_links(user: CustomUser) -> dict[str, str]: + """Возвращает словарь типизированных ссылок текущего пользователя.""" + + return {link.kind: link.link for link in user.links.all() if link.kind is not None} + + +@transaction.atomic +def update_social_links( + user: CustomUser, + social_links: dict[str, str | None], +) -> None: + """Обновляет только переданные типы, сохраняя legacy-ссылки и остальные ключи.""" + + for kind, link in social_links.items(): + if link is None: + UserLink.objects.filter(user=user, kind=kind).delete() + continue + UserLink.objects.update_or_create( + user=user, + kind=kind, + defaults={"link": link}, + ) diff --git a/users/tests/test_auth_api.py b/users/tests/test_auth_api.py index 4cff999b..34425044 100644 --- a/users/tests/test_auth_api.py +++ b/users/tests/test_auth_api.py @@ -1,8 +1,13 @@ +import runpy +from pathlib import Path from unittest.mock import patch +from urllib.parse import urlparse -from django.test import TestCase +from django.conf import settings +from django.test import SimpleTestCase, TestCase, override_settings from django.urls import reverse from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken from tests.constants import USER_CREATE_DATA from users.models import CustomUser @@ -48,6 +53,21 @@ def test_user_registration_rejects_invalid_payload(self): self.assertEqual(response.status_code, 400) + @override_settings(EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend") + @patch( + "django.core.mail.backends.console.EmailBackend.write_message", + return_value=1, + ) + def test_user_registration_works_with_console_email_backend(self, write_message_mock): + response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["email"], USER_CREATE_DATA["email"]) + self.assertEqual(response.data["is_active"], False) + user = CustomUser.objects.get(email=USER_CREATE_DATA["email"]) + self.assertFalse(user.is_active) + write_message_mock.assert_called_once() + def test_token_obtain_pair_updates_last_login(self): user = build_user(email="login@example.com") @@ -83,3 +103,49 @@ def test_removed_legacy_routes_return_404(self): self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) + + +class EmailSettingsDefaultsTests(SimpleTestCase): + def test_email_settings_keep_existing_defaults_without_env(self): + settings_path = Path(__file__).resolve().parents[2] / "procollab/settings.py" + + def return_default(_name, default=None, cast=None): + return cast(default) if cast else default + + with patch("decouple.config", side_effect=return_default): + loaded_settings = runpy.run_path(settings_path) + + self.assertEqual( + loaded_settings["EMAIL_BACKEND"], + "anymail.backends.unisender_go.EmailBackend", + ) + self.assertEqual( + loaded_settings["VERIFY_EMAIL_REDIRECT_URL"], + "https://app.procollab.ru/auth/verification/", + ) + + +class VerifyEmailRedirectTests(TestCase): + def setUp(self): + self.client = APIClient() + + @override_settings( + VERIFY_EMAIL_REDIRECT_URL=("https://react-dev.procollab.ru/auth/verification") + ) + def test_confirmation_handler_uses_configured_redirect(self): + user = build_user(email="confirm@example.com", is_active=False) + token = str(RefreshToken.for_user(user).access_token) + + response = self.client.get( + "/auth/account-confirm-email/", + {"token": token}, + ) + + self.assertEqual(response.status_code, 302) + redirect = urlparse(response["Location"]) + self.assertEqual( + redirect._replace(query="", fragment="").geturl(), + settings.VERIFY_EMAIL_REDIRECT_URL, + ) + self.assertTrue(redirect.query.startswith("access_token=")) + self.assertIn("&refresh_token=", redirect.query) diff --git a/users/tests/test_auth_throttling.py b/users/tests/test_auth_throttling.py new file mode 100644 index 00000000..3dd94bf6 --- /dev/null +++ b/users/tests/test_auth_throttling.py @@ -0,0 +1,173 @@ +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.test import APIClient, APIRequestFactory +from rest_framework.views import APIView + +from core.throttling import PostOnlyScopedRateThrottle +from tests.constants import USER_CREATE_DATA +from users.tests.helpers import build_user + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class DummyPostOnlyThrottleView(APIView): + permission_classes = [AllowAny] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_register" + + def get(self, _request): + return Response({"ok": True}) + + def post(self, _request): + return Response({"ok": True}) + + +class AuthThrottleTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.factory = APIRequestFactory() + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_register="1/min") + ) + @patch("users.views.verify_email") + def test_user_registration_post_is_scoped_throttled(self, verify_email_mock): + first_response = self.client.post( + "/auth/users/", + USER_CREATE_DATA, + format="json", + REMOTE_ADDR="203.0.113.10", + ) + second_payload = { + **USER_CREATE_DATA, + "email": "second-user@example.com", + } + second_response = self.client.post( + "/auth/users/", + second_payload, + format="json", + REMOTE_ADDR="203.0.113.10", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) + verify_email_mock.assert_called_once() + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_resend_email="1/min") + ) + @patch("users.views.verify_email") + def test_resend_verify_email_post_is_scoped_throttled(self, verify_email_mock): + user = build_user(email="inactive-throttle@example.com", is_active=False) + + first_response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.11", + ) + second_response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.11", + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) + verify_email_mock.assert_called_once() + + @override_settings( + REST_FRAMEWORK=throttle_settings(token_obtain="1/min") + ) + def test_token_obtain_post_is_scoped_throttled(self): + user = build_user(email="token-throttle@example.com") + payload = {"email": user.email, "password": "very_strong_password"} + + first_response = self.client.post( + "/api/token/", + payload, + format="json", + REMOTE_ADDR="203.0.113.12", + ) + second_response = self.client.post( + "/api/token/", + payload, + format="json", + REMOTE_ADDR="203.0.113.12", + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_reset_password="1/min") + ) + @patch("users.signals.EmailMultiAlternatives") + def test_password_reset_request_post_is_scoped_throttled(self, _email_message): + user = build_user(email="reset-throttle@example.com") + + first_response = self.client.post( + "/auth/reset_password/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.13", + ) + second_response = self.client.post( + "/auth/reset_password/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.13", + ) + + self.assertNotEqual(first_response.status_code, 429) + self.assertEqual(second_response.status_code, 429) + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_register="1/min") + ) + def test_post_only_throttle_allows_get_and_options(self): + view = DummyPostOnlyThrottleView.as_view() + + for method in ("get", "options"): + for _ in range(2): + response = view( + getattr(self.factory, method)( + "/unused/", + REMOTE_ADDR="203.0.113.14", + ) + ) + self.assertNotEqual(response.status_code, 429) + + first_response = view( + self.factory.post( + "/unused/", + {}, + format="json", + REMOTE_ADDR="203.0.113.14", + ) + ) + second_response = view( + self.factory.post( + "/unused/", + {}, + format="json", + REMOTE_ADDR="203.0.113.14", + ) + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) diff --git a/users/tests/test_public_profiles_api.py b/users/tests/test_public_profiles_api.py new file mode 100644 index 00000000..0c611cea --- /dev/null +++ b/users/tests/test_public_profiles_api.py @@ -0,0 +1,367 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from core.models import SkillToObject +from users.models import ( + CustomUser, + UserAchievement, + UserEducation, + UserLanguages, + UserLink, + UserWorkExperience, +) + +from .helpers import ( + attach_skill, + build_skill, + build_specialization, + build_user, + build_user_file, +) + + +PUBLIC_LIST_URL = "/auth/profiles/" + + +class PublicProfileListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.viewer = build_user(email="viewer@example.com") + self.client.force_authenticate(self.viewer) + + def test_authenticated_user_can_list_profiles(self): + target = build_user( + email="target@example.com", + first_name="Анна", + last_name="Смирнова", + city="Москва", + ) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertEqual(response.status_code, 200) + target_item = next( + item for item in response.data["results"] if item["id"] == target.id + ) + self.assertEqual(target_item["first_name"], "Анна") + self.assertEqual(target_item["city"], "Москва") + + def test_anonymous_user_cannot_list_profiles(self): + self.client.force_authenticate(user=None) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertEqual(response.status_code, 401) + + def test_pagination_returns_stable_non_overlapping_pages(self): + for index in range(12): + build_user( + email=f"member-{index}@example.com", + first_name="Имя{:02d}".format(index), + last_name="Участник", + ) + + first = self.client.get(PUBLIC_LIST_URL, {"limit": 5, "offset": 0}) + second = self.client.get(PUBLIC_LIST_URL, {"limit": 5, "offset": 5}) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + first_ids = [item["id"] for item in first.data["results"]] + second_ids = [item["id"] for item in second.data["results"]] + self.assertEqual(len(first_ids), 5) + self.assertEqual(len(second_ids), 5) + self.assertFalse(set(first_ids) & set(second_ids)) + + def test_search_matches_first_last_and_full_name_in_both_orders(self): + target = build_user( + email="search@example.com", first_name="Мария", last_name="Петрова" + ) + build_user( + email="unrelated@example.com", first_name="Алексей", last_name="Сидоров" + ) + + for query in ("Мария", "Петрова", "Мария Петрова", "Петрова Мария"): + with self.subTest(query=query): + response = self.client.get(PUBLIC_LIST_URL, {"search": query}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["id"] for item in response.data["results"]], [target.id] + ) + + def test_confirmed_role_specialization_and_skill_filters_work(self): + specialization = build_specialization("Backend") + skill = build_skill("Python") + target = build_user( + email="filtered@example.com", + user_type=CustomUser.EXPERT, + v2_speciality=specialization, + ) + attach_skill(target, skill) + build_user(email="other@example.com") + + response = self.client.get( + PUBLIC_LIST_URL, + { + "user_type": CustomUser.EXPERT, + "specialization": specialization.id, + "skill": skill.id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [target.id]) + + def test_multiple_skills_do_not_duplicate_profile(self): + target = build_user(email="skilled@example.com") + attach_skill(target, build_skill("Python")) + attach_skill(target, build_skill("Django")) + + response = self.client.get(PUBLIC_LIST_URL) + + result_ids = [item["id"] for item in response.data["results"]] + self.assertEqual(result_ids.count(target.id), 1) + + def test_inactive_users_are_excluded(self): + inactive = build_user(email="inactive@example.com", is_active=False) + + response = self.client.get(PUBLIC_LIST_URL) + + self.assertNotIn(inactive.id, [item["id"] for item in response.data["results"]]) + + def test_empty_result_has_paginated_shape(self): + response = self.client.get(PUBLIC_LIST_URL, {"search": "НетТакогоПользователя"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + def test_list_query_count_does_not_grow_with_profiles_or_skills(self): + content_type = ContentType.objects.get_for_model(CustomUser) + skills = [build_skill(f"Навык {index}") for index in range(3)] + for index in range(8): + user = build_user(email=f"queries-{index}@example.com") + SkillToObject.objects.bulk_create( + [ + SkillToObject( + skill=skill, + content_type=content_type, + object_id=user.id, + ) + for skill in skills + ] + ) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(PUBLIC_LIST_URL, {"limit": 20}) + + self.assertEqual(response.status_code, 200) + self.assertLessEqual(len(queries), 4) + + +class PublicProfileDetailAPITests(TestCase): + forbidden_keys = { + "email", + "phone", + "phone_number", + "birthday", + "password", + "is_active", + "is_staff", + "is_superuser", + "is_online", + "last_login", + "date_joined", + "onboarding_stage", + "verification_date", + "ordering_score", + "dataset_migration_applied", + "user", + } + + def setUp(self): + self.client = APIClient() + self.viewer = build_user(email="viewer-detail@example.com") + self.client.force_authenticate(self.viewer) + + def _assert_forbidden_keys_absent(self, value): + if isinstance(value, dict): + self.assertFalse(self.forbidden_keys & set(value.keys())) + for nested in value.values(): + self._assert_forbidden_keys_absent(nested) + elif isinstance(value, list): + for nested in value: + self._assert_forbidden_keys_absent(nested) + + def test_public_detail_returns_only_allow_list_in_expected_format(self): + specialization = build_specialization("Аналитик") + user = build_user( + email="private@example.com", + first_name="Ирина", + last_name="Орлова", + patronymic="Сергеевна", + city="Казань", + about_me="Работаю с данными", + phone_number="+79991234567", + v2_speciality=specialization, + ) + attach_skill(user, build_skill("Аналитика")) + UserLink.objects.create(user=user, link="https://legacy.example.com/irina") + UserLink.objects.create( + user=user, + kind=UserLink.Kind.TELEGRAM, + link="https://t.me/irina", + ) + UserEducation.objects.create(user=user, organization_name="Университет") + UserWorkExperience.objects.create(user=user, organization_name="Компания") + UserLanguages.objects.create( + user=user, language="Английский", language_level="B2" + ) + achievement = UserAchievement.objects.create( + user=user, title="Хакатон", status="Победитель", year=2025 + ) + achievement.files.add( + build_user_file(user, link="https://cdn.example.com/diploma.pdf") + ) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data.keys()), + { + "id", + "first_name", + "last_name", + "avatar", + "city", + "user_type", + "user_type_label", + "specialization", + "skills", + "patronymic", + "about_me", + "social_links", + "education", + "work_experience", + "user_languages", + "achievements", + }, + ) + self.assertEqual(response.data["specialization"]["id"], specialization.id) + self.assertEqual( + response.data["social_links"], {"telegram": "https://t.me/irina"} + ) + self.assertNotContains(response, "legacy.example.com") + self.assertEqual(response.data["achievements"][0]["files"][0]["name"], "file") + self._assert_forbidden_keys_absent(response.data) + + def test_anonymous_user_cannot_open_public_detail(self): + user = build_user(email="anonymous-target@example.com") + self.client.force_authenticate(user=None) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 401) + + def test_unknown_profile_returns_404(self): + response = self.client.get("/auth/profiles/999999/") + + self.assertEqual(response.status_code, 404) + + def test_inactive_profile_returns_safe_404(self): + user = build_user(email="hidden@example.com", is_active=False) + + response = self.client.get(f"/auth/profiles/{user.id}/") + + self.assertEqual(response.status_code, 404) + self.assertNotContains(response, user.email, status_code=404) + + +class OwnProfileUpdateRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="owner-profile@example.com") + self.client.force_authenticate(self.user) + + def test_owner_updates_profile_and_avatar_url(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Мария", + "avatar": "https://cdn.example.com/avatar.webp", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "Мария") + self.assertEqual(self.user.avatar, "https://cdn.example.com/avatar.webp") + + def test_user_cannot_update_another_profile(self): + other = build_user(email="other-profile@example.com") + + response = self.client.patch( + f"/auth/users/{other.id}/", {"first_name": "Подмена"}, format="json" + ) + + self.assertEqual(response.status_code, 403) + other.refresh_from_db() + self.assertNotEqual(other.first_name, "Подмена") + + def test_auth_and_service_fields_cannot_be_changed(self): + original_email = self.user.email + original_stage = self.user.onboarding_stage + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "email": "attacker@example.com", + "is_active": False, + "is_staff": True, + "is_superuser": True, + "onboarding_stage": None, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.email, original_email) + self.assertTrue(self.user.is_active) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) + self.assertEqual(self.user.onboarding_stage, original_stage) + + def test_invalid_specialization_id_is_rejected(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + {"v2_speciality_id": 999999}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_invalid_nested_collection_rolls_back_whole_profile_update(self): + self.user.first_name = "До изменения" + self.user.save(update_fields=["first_name"]) + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Не должно сохраниться", + "user_languages": [ + {"language": "Английский", "language_level": "B1"}, + {"language": "Английский", "language_level": "B2"}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "До изменения") + self.assertFalse(UserLanguages.objects.filter(user=self.user).exists()) diff --git a/users/tests/test_social_links_api.py b/users/tests/test_social_links_api.py new file mode 100644 index 00000000..3150e8ae --- /dev/null +++ b/users/tests/test_social_links_api.py @@ -0,0 +1,219 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserLink + +from .helpers import build_user + + +class SocialLinksAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="social-links@example.com") + self.client.force_authenticate(self.user) + self.url = f"/auth/users/{self.user.id}/" + + def test_owner_creates_and_reads_typed_social_links(self): + response = self.client.patch( + self.url, + { + "social_links": { + "telegram": "https://t.me/anna", + "vk": "https://vk.com/anna", + "github": "https://github.com/anna", + "linkedin": "https://linkedin.com/in/anna", + "website": "https://anna.dev", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["social_links"], + { + "telegram": "https://t.me/anna", + "vk": "https://vk.com/anna", + "github": "https://github.com/anna", + "linkedin": "https://linkedin.com/in/anna", + "website": "https://anna.dev", + }, + ) + self.assertEqual( + UserLink.objects.filter(user=self.user, kind__isnull=False).count(), 5 + ) + + def test_patch_updates_only_present_keys_and_null_deletes_one_link(self): + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.TELEGRAM, + link="https://t.me/old", + ) + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch( + self.url, + {"social_links": {"telegram": None}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["social_links"], {"github": "https://github.com/anna"} + ) + self.assertFalse( + UserLink.objects.filter(user=self.user, kind=UserLink.Kind.TELEGRAM).exists() + ) + + def test_missing_social_links_does_not_change_typed_links(self): + link = UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch(self.url, {"first_name": "Анна"}, format="json") + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertEqual(link.link, "https://github.com/anna") + + def test_unknown_kind_is_rejected_atomically(self): + self.user.first_name = "До изменения" + self.user.save(update_fields=["first_name"]) + + response = self.client.patch( + self.url, + { + "first_name": "Не должно сохраниться", + "social_links": { + "telegram": "https://t.me/anna", + "discord": "https://discord.com/users/anna", + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("discord", response.data["social_links"]) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "До изменения") + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_invalid_url_is_rejected_without_partial_update(self): + response = self.client.patch( + self.url, + { + "social_links": { + "telegram": "not-a-url", + "website": "https://valid.example.com", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("telegram", response.data["social_links"]) + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_duplicate_urls_are_rejected_as_validation_error(self): + response = self.client.patch( + self.url, + { + "social_links": { + "github": "https://example.com/anna", + "website": "https://example.com/anna", + } + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("website", response.data["social_links"]) + self.assertFalse(UserLink.objects.filter(user=self.user).exists()) + + def test_legacy_links_remain_separate_and_legacy_patch_preserves_typed_links(self): + legacy = UserLink.objects.create( + user=self.user, link="https://legacy.example.com/profile" + ) + typed = UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.GITHUB, + link="https://github.com/anna", + ) + + response = self.client.patch( + self.url, + {"links": ["https://legacy.example.com/new"]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(UserLink.objects.filter(pk=legacy.pk).exists()) + typed.refresh_from_db() + self.assertEqual(typed.link, "https://github.com/anna") + self.assertCountEqual( + response.data["links"], + ["https://legacy.example.com/new", "https://github.com/anna"], + ) + self.assertEqual( + response.data["social_links"], {"github": "https://github.com/anna"} + ) + + second_response = self.client.patch( + self.url, + {"links": response.data["links"]}, + format="json", + ) + self.assertEqual(second_response.status_code, 200) + self.assertEqual( + UserLink.objects.filter( + user=self.user, link="https://github.com/anna" + ).count(), + 1, + ) + + def test_two_legacy_links_are_allowed_but_typed_kind_is_unique(self): + UserLink.objects.create(user=self.user, link="https://legacy.example.com/one") + UserLink.objects.create(user=self.user, link="https://legacy.example.com/two") + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.VK, + link="https://vk.com/one", + ) + + with self.assertRaises(IntegrityError), transaction.atomic(): + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.VK, + link="https://vk.com/two", + ) + + self.assertEqual( + UserLink.objects.filter(user=self.user, kind__isnull=True).count(), 2 + ) + + def test_public_profile_returns_only_typed_links(self): + UserLink.objects.create( + user=self.user, link="https://legacy.example.com/private-shape" + ) + UserLink.objects.create( + user=self.user, + kind=UserLink.Kind.LINKEDIN, + link="https://linkedin.com/in/anna", + ) + + response = self.client.get(f"/auth/profiles/{self.user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("links", response.data) + self.assertEqual( + response.data["social_links"], + {"linkedin": "https://linkedin.com/in/anna"}, + ) + self.assertNotContains(response, "legacy.example.com") diff --git a/users/token_views.py b/users/token_views.py new file mode 100644 index 00000000..cd9aa006 --- /dev/null +++ b/users/token_views.py @@ -0,0 +1,8 @@ +from rest_framework_simplejwt.views import TokenObtainPairView + +from core.throttling import PostOnlyScopedRateThrottle + + +class ThrottledTokenObtainPairView(TokenObtainPairView): + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "token_obtain" diff --git a/users/urls.py b/users/urls.py index b5e376c0..998adf36 100644 --- a/users/urls.py +++ b/users/urls.py @@ -29,10 +29,17 @@ UserCVDownload, UserCVMailing, ) +from users.public_profile_views import PublicProfileDetailView, PublicProfileListView app_name = "users" urlpatterns = [ + path("profiles/", PublicProfileListView.as_view(), name="public-profile-list"), + path( + "profiles//", + PublicProfileDetailView.as_view(), + name="public-profile-detail", + ), path( "specialists/", SpecialistsList.as_view() ), # this url actually returns mentors, experts and investors @@ -81,6 +88,6 @@ ), path( "reset_password/", - include("django_rest_passwordreset.urls", namespace="password_reset"), + include("users.password_reset_urls", namespace="password_reset"), ), ] diff --git a/users/views.py b/users/views.py index 9300d2cd..abe49592 100644 --- a/users/views.py +++ b/users/views.py @@ -32,6 +32,7 @@ from core.models import SkillToObject, Specialization, SpecializationCategory from core.pagination import Pagination from core.permissions import IsOwnerOrReadOnly +from core.throttling import PostOnlyScopedRateThrottle from events.models import Event from events.serializers import EventsListSerializer from partner_programs.models import PartnerProgram @@ -41,11 +42,7 @@ ) from projects.pagination import ProjectsPagination from projects.serializers import ProjectListSerializer -from users.constants import ( - VERBOSE_ROLE_TYPES, - VERBOSE_USER_TYPES, - VERIFY_EMAIL_REDIRECT_URL, -) +from users.constants import VERBOSE_ROLE_TYPES, VERBOSE_USER_TYPES from users.helpers import check_related_fields_update, force_verify_user, verify_email from users.models import LikesOnProject, UserAchievement, UserSkillConfirmation from users.permissions import IsAchievementOwnerOrReadOnly @@ -83,6 +80,8 @@ class UserList(ListCreateAPIView): pagination_class = UsersPagination filter_backends = (filters.DjangoFilterBackend,) filterset_class = UserFilter + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_register" def get_permissions(self): if self.request.method == "POST": @@ -276,6 +275,7 @@ class VerifyEmail(GenericAPIView): def get(self, request): token = request.GET.get("token") + redirect_url = settings.VERIFY_EMAIL_REDIRECT_URL try: payload = jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"]) @@ -288,20 +288,20 @@ def get(self, request): user.save() return redirect( - f"{VERIFY_EMAIL_REDIRECT_URL}?access_token={access_token}&refresh_token={refresh_token}", + f"{redirect_url}?access_token={access_token}&refresh_token={refresh_token}", status=status.HTTP_200_OK, message="Succeed", ) except jwt.ExpiredSignatureError: return redirect( - VERIFY_EMAIL_REDIRECT_URL, + redirect_url, status=status.HTTP_400_BAD_REQUEST, message="Activate Expired", ) except jwt.DecodeError: return redirect( - VERIFY_EMAIL_REDIRECT_URL, + redirect_url, status=status.HTTP_400_BAD_REQUEST, message="Decode error", ) @@ -481,6 +481,8 @@ def put(self, request: Request, pk): class ResendVerifyEmail(GenericAPIView): permission_classes = [AllowAny] serializer_class = ResendVerifyEmailSerializer + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_resend_email" def post(self, request, *args, **kwargs): try: diff --git a/vacancy/filters.py b/vacancy/filters.py index 17e4159c..a654eede 100644 --- a/vacancy/filters.py +++ b/vacancy/filters.py @@ -1,4 +1,4 @@ -from django.db.models import QuerySet, Q, F +from django.db.models import Q, QuerySet from django_filters import rest_framework as filters @@ -11,91 +11,62 @@ def project_id_filter(queryset, name, value) -> QuerySet: - return queryset.filter( - **{ - "project_id": value[0], - } - ) + return queryset.filter(project_id=value) class VacancyFilter(filters.FilterSet): - """Filter for Vacancies - - Adds filtering to DRF list retrieve views - - Parameters to filter by: - project_id (int), - is_active (boolean) (default to True if not set otherwise), - required_experience (multiple choise) - work_schedule (multiple choise) - work_format (multiple choise) - salary_min (int) - salary_max (int) - - Examples: - ?project_id=1 equals to .filter(project_id=1) - (no params passed) equals to .filter(is_active=True) - ?is_active=false equals to .filter(is_active=False) - ?work_schedule=full_time&work_schedule=part_time equals to .filter(required_experience__in=value) - ?salary_min=100&salary_max=150 equals to .filter(salary__range=(100, 150)) - """ - - def __init__(self, *args, **kwargs): - """if is_active filter is not passed, default to True""" - super().__init__(*args, **kwargs) - if self.data.get("is_active") is None: - self.data = dict(self.data) - self.data["is_active"] = True + """Фильтрует уже ограниченный безопасный queryset публичного каталога.""" def filter_by_experience( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter( - Q(required_experience__in=value) | Q(required_experience=None) - ).order_by(F("required_experience").asc(nulls_last=True)) + return queryset.filter(required_experience__in=value) def filter_by_schedule( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter( - Q(work_schedule__in=value) | Q(work_schedule=None) - ).order_by(F("work_schedule").asc(nulls_last=True)) + return queryset.filter(work_schedule__in=value) def filter_by_format( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter(Q(work_format__in=value) | Q(work_format=None)).order_by( - F("work_format").asc(nulls_last=True) - ) + return queryset.filter(work_format__in=value) def filter_by_salary_min( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - min_salary = int(value[0]) - return queryset.filter(Q(salary__gte=min_salary) | Q(salary=None)).order_by( - F("salary").asc(nulls_last=True) - ) - except ValueError: + min_salary = int(value) + return queryset.filter(salary__gte=min_salary) + except (TypeError, ValueError): return queryset def filter_by_salary_max( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - max_salary = int(value[0]) - return queryset.filter(Q(salary__lte=max_salary) | Q(salary=None)).order_by( - F("salary").asc(nulls_last=True) - ) - except ValueError: + max_salary = int(value) + return queryset.filter(salary__lte=max_salary) + except (TypeError, ValueError): return queryset def filter_by_role( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: - if not value: + return queryset.filter(role__icontains=value) + + def filter_by_search(self, queryset, name, value): + """Ищет вакансию по роли, специализации, описанию и названию проекта.""" + + search = value.strip() + if not search: return queryset - return queryset.filter(role__icontains=value[0]) + return queryset.filter( + Q(role__icontains=search) + | Q(specialization__icontains=search) + | Q(description__icontains=search) + | Q(project__name__icontains=search) + ) project_id = filters.Filter(method=project_id_filter) is_active = filters.BooleanFilter(field_name="is_active") @@ -114,6 +85,7 @@ def filter_by_role( ) role_contains = filters.Filter(method="filter_by_role") + search = filters.CharFilter(method="filter_by_search") salary_min = filters.Filter(method="filter_by_salary_min") salary_max = filters.Filter(method="filter_by_salary_max") @@ -121,6 +93,7 @@ class Meta: model = Vacancy fields = ( "role_contains", + "search", "project_id", "is_active", "required_experience", diff --git a/vacancy/permissions.py b/vacancy/permissions.py index c145ebb2..78fbf0d9 100644 --- a/vacancy/permissions.py +++ b/vacancy/permissions.py @@ -9,18 +9,28 @@ def has_object_permission(self, request, view, obj): class IsVacancyProjectLeader(BasePermission): - """ - Allows access to vacancy update only to project leader. - """ + """Разрешает изменение вакансии руководителю проекта и администрации.""" def has_object_permission(self, request, view, obj): - if request.method in SAFE_METHODS or obj.project.leader == request.user: + if request.method in SAFE_METHODS: + return True + user = request.user + if ( + obj.project.leader == user + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): return True return False class IsProjectLeaderForVacancyResponse(BasePermission): def has_object_permission(self, request, view, obj): - if obj.vacancy.project.leader == request.user: + user = request.user + if ( + obj.vacancy.project.leader == user + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): return True return False diff --git a/vacancy/response_services.py b/vacancy/response_services.py new file mode 100644 index 00000000..979a6162 --- /dev/null +++ b/vacancy/response_services.py @@ -0,0 +1,235 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import NotFound, PermissionDenied + +from projects.models import Collaborator +from notifications.events import ( + notify_vacancy_response_created, + notify_vacancy_response_resolved, +) +from vacancy.mapping import CeleryEmailParams, MessageTypeEnum +from vacancy.models import Vacancy, VacancyResponse +from vacancy.tasks import send_email + + +def _ensure_can_manage(vacancy: Vacancy, user) -> None: + """Повторно проверяет право внутри транзакции после блокировки вакансии.""" + + if not ( + vacancy.project.leader_id == user.id + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): + raise PermissionDenied() + + +def _lock_vacancy_then_response( + response_id: int, +) -> tuple[Vacancy, VacancyResponse]: + """Берёт блокировки в едином порядке, чтобы параллельные решения не взаимоблокировались.""" + + try: + vacancy_id = ( + VacancyResponse.objects.only("vacancy_id").get(pk=response_id).vacancy_id + ) + vacancy = ( + Vacancy.objects.select_for_update() + .select_related("project") + .get(pk=vacancy_id) + ) + response = VacancyResponse.objects.select_for_update().get( + pk=response_id, vacancy_id=vacancy.id + ) + except (Vacancy.DoesNotExist, VacancyResponse.DoesNotExist) as error: + raise NotFound() from error + response.vacancy = vacancy + return vacancy, response + + +@transaction.atomic +def create_vacancy_response( + *, vacancy_id: int, user, validated_data: dict +) -> VacancyResponse: + """Создаёт отклик под блокировкой вакансии и не доверяет user из payload.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + if not vacancy.is_active or vacancy.project.draft or not vacancy.project.is_public: + raise serializers.ValidationError("На эту вакансию больше нельзя откликнуться.") + if ( + vacancy.project.leader_id == user.id + or Collaborator.objects.filter( + project=vacancy.project, + user=user, + ).exists() + ): + raise serializers.ValidationError( + "Участник проекта не может откликнуться на его вакансию." + ) + if VacancyResponse.objects.filter(vacancy=vacancy, user=user).exists(): + raise serializers.ValidationError("Вы уже откликнулись на эту вакансию.") + response = VacancyResponse.objects.create( + vacancy=vacancy, + user=user, + **validated_data, + ) + notify_vacancy_response_created(response) + transaction.on_commit( + lambda: send_email.delay( + CeleryEmailParams( + message_type=MessageTypeEnum.RESPONDED.value, + user_id=vacancy.project.leader_id, + project_name=vacancy.project.name, + project_id=vacancy.project_id, + vacancy_role=vacancy.role, + schema_id=2, + ) + ) + ) + return response + + +def _email_payload(response: VacancyResponse, message_type: str) -> CeleryEmailParams: + project = response.vacancy.project + return CeleryEmailParams( + message_type=message_type, + user_id=response.user_id, + project_name=project.name, + project_id=project.id, + vacancy_role=response.vacancy.role, + schema_id=2, + ) + + +@transaction.atomic +def accept_vacancy_response(response_id: int, *, actor) -> VacancyResponse: + """Атомарно принимает кандидата, закрывает вакансию и отклоняет остальные отклики.""" + + vacancy, response = _lock_vacancy_then_response(response_id) + _ensure_can_manage(vacancy, actor) + if response.is_approved is not None: + raise serializers.ValidationError("Отклик уже обработан.") + if Collaborator.objects.filter( + project=vacancy.project, + user_id=response.user_id, + ).exists(): + raise serializers.ValidationError("Пользователь уже состоит в команде проекта.") + + Collaborator.objects.create( + project=vacancy.project, + user_id=response.user_id, + role=vacancy.role, + ) + response.is_approved = True + response.save(update_fields=("is_approved", "datetime_updated")) + vacancy.is_active = False + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + + rejected = list( + VacancyResponse.objects.select_for_update() + .filter(vacancy=vacancy, is_approved__isnull=True) + .exclude(pk=response.pk) + ) + VacancyResponse.objects.filter(pk__in=[item.pk for item in rejected]).update( + is_approved=False, + datetime_updated=timezone.now(), + ) + notify_vacancy_response_resolved(response, actor=actor, accepted=True) + for rejected_response in rejected: + rejected_response.vacancy = vacancy + notify_vacancy_response_resolved( + rejected_response, + actor=actor, + accepted=False, + ) + + transaction.on_commit( + lambda: send_email.delay(_email_payload(response, MessageTypeEnum.ACCEPTED.value)) + ) + for rejected_response in rejected: + rejected_response.vacancy = vacancy + transaction.on_commit( + lambda item=rejected_response: send_email.delay( + _email_payload(item, MessageTypeEnum.REJECTED.value) + ) + ) + return response + + +@transaction.atomic +def decline_vacancy_response(response_id: int, *, actor) -> VacancyResponse: + """Атомарно отклоняет только ещё не обработанный отклик.""" + + vacancy, response = _lock_vacancy_then_response(response_id) + _ensure_can_manage(vacancy, actor) + if response.is_approved is not None: + raise serializers.ValidationError("Отклик уже обработан.") + response.is_approved = False + response.save(update_fields=("is_approved", "datetime_updated")) + notify_vacancy_response_resolved(response, actor=actor, accepted=False) + transaction.on_commit( + lambda: send_email.delay(_email_payload(response, MessageTypeEnum.REJECTED.value)) + ) + return response + + +@transaction.atomic +def close_vacancy(vacancy_id: int, *, actor) -> Vacancy: + """Закрывает вакансию и сохраняет историю, отклоняя ожидающие отклики.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if vacancy.is_active: + vacancy.is_active = False + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + pending_responses = list( + VacancyResponse.objects.select_for_update().filter( + vacancy=vacancy, + is_approved__isnull=True, + ) + ) + VacancyResponse.objects.filter( + vacancy=vacancy, + is_approved__isnull=True, + ).update(is_approved=False, datetime_updated=timezone.now()) + for response in pending_responses: + response.vacancy = vacancy + notify_vacancy_response_resolved(response, actor=actor, accepted=False) + return vacancy + + +@transaction.atomic +def reopen_vacancy(vacancy_id: int, *, actor) -> Vacancy: + """Возобновляет вакансию только у опубликованного публичного проекта.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if vacancy.project.draft or not vacancy.project.is_public: + raise serializers.ValidationError( + "Возобновить вакансию можно только в опубликованном публичном проекте." + ) + if not vacancy.is_active: + vacancy.is_active = True + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + return vacancy + + +@transaction.atomic +def delete_vacancy_without_responses(vacancy_id: int, *, actor) -> None: + """Удаляет вакансию под блокировкой, не допуская потери истории откликов.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if VacancyResponse.objects.filter(vacancy=vacancy).exists(): + raise serializers.ValidationError( + "Вакансию с откликами нельзя удалить. Закройте её." + ) + vacancy.delete() diff --git a/vacancy/selectors.py b/vacancy/selectors.py new file mode 100644 index 00000000..b4c8ae5f --- /dev/null +++ b/vacancy/selectors.py @@ -0,0 +1,88 @@ +from django.db.models import Count, Prefetch, Q, QuerySet + +from core.models import SkillToObject +from users.public_profile_selectors import get_public_profiles_queryset +from vacancy.models import Vacancy, VacancyResponse + + +def _vacancy_queryset() -> QuerySet[Vacancy]: + skills = SkillToObject.objects.select_related("skill", "skill__category") + return ( + Vacancy.objects.select_related( + "project", + "project__industry", + ) + .prefetch_related( + Prefetch("required_skills", queryset=skills), + "project__links", + ) + .annotate( + pending_response_count=Count( + "vacancy_requests", + filter=Q(vacancy_requests__is_approved__isnull=True), + ) + ) + ) + + +def get_public_vacancies_queryset() -> QuerySet[Vacancy]: + """Возвращает только активные вакансии опубликованных публичных проектов.""" + + return ( + _vacancy_queryset() + .filter( + is_active=True, + project__draft=False, + project__is_public=True, + ) + .order_by("-datetime_created", "-id") + ) + + +def get_vacancy_queryset() -> QuerySet[Vacancy]: + return _vacancy_queryset().order_by("-datetime_created", "-id") + + +def get_response_queryset() -> QuerySet[VacancyResponse]: + candidate_profiles = get_public_profiles_queryset() + return ( + VacancyResponse.objects.select_related( + "vacancy", + "vacancy__project", + "accompanying_file", + ) + .prefetch_related(Prefetch("user", queryset=candidate_profiles)) + .order_by("datetime_created", "id") + ) + + +def get_self_response_queryset() -> QuerySet[VacancyResponse]: + """Загружает отклики пользователя вместе с карточками вакансий без N+1.""" + + return ( + VacancyResponse.objects.select_related("accompanying_file") + .prefetch_related( + Prefetch("vacancy", queryset=_vacancy_queryset()), + ) + .order_by("datetime_created", "id") + ) + + +def is_staff(user) -> bool: + return bool( + user + and user.is_authenticated + and (getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)) + ) + + +def can_manage_vacancy(user, vacancy: Vacancy) -> bool: + return is_staff(user) or ( + bool(user and user.is_authenticated) and vacancy.project.leader_id == user.id + ) + + +def can_view_vacancy(user, vacancy: Vacancy) -> bool: + return can_manage_vacancy(user, vacancy) or ( + vacancy.is_active and not vacancy.project.draft and vacancy.project.is_public + ) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 5dda57c7..ad758b71 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -11,6 +11,7 @@ from projects.models import Project from projects.validators import validate_project from users.serializers import UserDetailSerializer +from users.public_profile_serializers import PublicProfileListSerializer from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.models import Vacancy, VacancyResponse @@ -55,21 +56,21 @@ def to_representation(self, instance): representation["required_experience"] = WorkExperience.to_display( instance.required_experience ) - representation["work_schedule"] = WorkSchedule.to_display( - instance.work_schedule - ) + representation["work_schedule"] = WorkSchedule.to_display(instance.work_schedule) representation["work_format"] = WorkFormat.to_display(instance.work_format) return representation class AbstractVacancyReadOnlyFields(serializers.Serializer): - """Abstract read-only fields for Vacancy.""" + """Общие вычисляемые поля read-only контрактов вакансии.""" datetime_closed = serializers.DateTimeField(read_only=True) response_count = serializers.SerializerMethodField(read_only=True) def get_response_count(self, obj): - """Returns count non status responses.""" + """Возвращает число откликов, которые ещё ожидают решения.""" + if hasattr(obj, "pending_response_count"): + return obj.pending_response_count return obj.vacancy_requests.filter(is_approved=None).count() @@ -99,7 +100,6 @@ class ProjectVacancyListSerializer( AbstractVacancyReadOnlyFields, RequiredSkillsSerializerMixin[Vacancy], ): - class Meta: model = Vacancy fields = [ @@ -175,7 +175,6 @@ class VacancyListSerializer( RequiredSkillsSerializerMixin[Vacancy], AbstractVacancyReadOnlyFields, ): - class Meta: model = Vacancy fields = [ @@ -189,9 +188,40 @@ class Meta: "response_count", "date_create_time", ] - read_only_fields = [ + + +class VacancyCatalogSerializer( + VacancyCreationDateSerializerMixin, + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + AbstractVacancyEnumFields, + RequiredSkillsSerializerMixin[Vacancy], +): + """Публичный контракт каталога без закрытых данных проекта.""" + + project = ProjectForVacancySerializer(read_only=True) + + class Meta: + model = Vacancy + fields = [ + "id", + "role", + "specialization", + "required_skills", + "description", "project", + "is_active", + "datetime_created", + "datetime_updated", + "datetime_closed", + "response_count", + "date_create_time", + "required_experience", + "work_schedule", + "work_format", + "salary", ] + read_only_fields = fields # TODO FIX This (Copied serializer from projects) - hotfix: rename @@ -237,10 +267,10 @@ class ProjectVacancyCreateListSerializer( AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): - def create(self, validated_data): project = validated_data["project"] - if project.leader != self.context["request"].user: + user = self.context["request"].user + if project.leader != user and not (user.is_staff or user.is_superuser): raise serializers.ValidationError("You are not the leader of the project") required_skills_ids = validated_data.pop("required_skills_ids") @@ -386,3 +416,82 @@ class VacancyResponseDetailReadSerializer(VacancyResponseDetailSerializer): """Returns full file info for detail view without breaking writes.""" accompanying_file = UserFileSerializer(read_only=True) + + +class VacancyResponseFileSerializer(serializers.ModelSerializer): + """Не раскрывает владельца файла за пределами разрешённого отклика.""" + + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class VacancyCandidateSerializer(PublicProfileListSerializer): + """Безопасная карточка кандидата для руководителя проекта.""" + + class Meta(PublicProfileListSerializer.Meta): + fields = ( + "id", + "first_name", + "last_name", + "avatar", + "specialization", + "skills", + "about_me", + ) + + +class VacancyResponseWriteSerializer(serializers.ModelSerializer): + accompanying_file = serializers.SlugRelatedField( + slug_field="link", + queryset=UserFile.objects.all(), + required=False, + allow_null=True, + ) + + class Meta: + model = VacancyResponse + fields = ("why_me", "accompanying_file") + + def validate_accompanying_file(self, value): + """Разрешает прикладывать только файл текущего пользователя.""" + + if value is not None and value.user_id != self.context["request"].user.id: + raise serializers.ValidationError("Можно прикрепить только собственный файл.") + return value + + +class VacancyResponseSelfSerializer(serializers.ModelSerializer): + vacancy = VacancyCatalogSerializer(read_only=True) + accompanying_file = VacancyResponseFileSerializer(read_only=True) + + class Meta: + model = VacancyResponse + fields = ( + "id", + "vacancy", + "why_me", + "accompanying_file", + "is_approved", + "datetime_created", + "datetime_updated", + ) + + +class VacancyResponseManagerSerializer(serializers.ModelSerializer): + user = VacancyCandidateSerializer(read_only=True) + accompanying_file = VacancyResponseFileSerializer(read_only=True) + + class Meta: + model = VacancyResponse + fields = ( + "id", + "user", + "why_me", + "accompanying_file", + "is_approved", + "vacancy", + "datetime_created", + "datetime_updated", + ) + read_only_fields = fields diff --git a/vacancy/tests/helpers.py b/vacancy/tests/helpers.py index cc337c95..c1118bcb 100644 --- a/vacancy/tests/helpers.py +++ b/vacancy/tests/helpers.py @@ -15,6 +15,8 @@ def create_user( *, prefix: str = "vacancy-user", is_active: bool = True, + is_staff: bool = False, + is_superuser: bool = False, ) -> CustomUser: user = CustomUser.objects.create_user( email=f"{prefix}-{uuid4().hex}@example.com", @@ -24,7 +26,9 @@ def create_user( birthday=date(2000, 1, 1), ) user.is_active = is_active - user.save(update_fields=["is_active"]) + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save(update_fields=["is_active", "is_staff", "is_superuser"]) return user @@ -54,9 +58,10 @@ def create_user_file( user: CustomUser, link: str | None = None, ) -> UserFile: + file_link = link or "https://cdn.example.com/{}.pdf".format(uuid4().hex) return UserFile.objects.create( user=user, - link=link or f"https://cdn.example.com/{uuid4().hex}.pdf", + link=file_link, name="cv", extension="pdf", mime_type="application/pdf", diff --git a/vacancy/tests/test_vacancies_api.py b/vacancy/tests/test_vacancies_api.py index 012ceb8b..c536e93e 100644 --- a/vacancy/tests/test_vacancies_api.py +++ b/vacancy/tests/test_vacancies_api.py @@ -79,16 +79,16 @@ def test_public_list_returns_only_active_vacancies_by_default(self): [active_vacancy.id], ) - def test_list_can_include_inactive_vacancies_by_filter(self): + def test_public_list_cannot_include_inactive_vacancies_by_filter(self): create_vacancy(role="Active vacancy", is_active=True) - inactive_vacancy = create_vacancy(role="Inactive vacancy", is_active=False) + create_vacancy(role="Inactive vacancy", is_active=False) response = self.client.get("/vacancies/", {"is_active": "false"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( [item["id"] for item in response.data["results"]], - [inactive_vacancy.id], + [], ) def test_list_filters_by_project_role_salary_and_work_conditions(self): @@ -116,8 +116,8 @@ def test_list_filters_by_project_role_salary_and_work_conditions(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data["results"]], [target.id]) - def test_list_excludes_vacancies_older_than_90_days(self): - create_vacancy( + def test_list_includes_active_vacancies_older_than_90_days(self): + old = create_vacancy( role="Old vacancy", datetime_created=timezone.now() - timedelta(days=91), ) @@ -126,7 +126,10 @@ def test_list_excludes_vacancies_older_than_90_days(self): response = self.client.get("/vacancies/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual([item["id"] for item in response.data["results"]], [fresh.id]) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [fresh.id, old.id], + ) def test_detail_returns_vacancy_with_project_info(self): vacancy = create_vacancy(role="Detail vacancy") diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py new file mode 100644 index 00000000..56ff4009 --- /dev/null +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -0,0 +1,507 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from projects.models import Collaborator +from notifications.models import Notification +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule +from vacancy.models import Vacancy, VacancyResponse +from vacancy.tests.helpers import ( + create_project, + create_skill, + create_user, + create_user_file, + create_vacancy, + create_vacancy_response, +) + + +PRIVATE_FIELDS = { + "auth_token", + "date_joined", + "email", + "groups", + "phone", + "phone_number", + "birthday", + "is_staff", + "is_superuser", + "last_login", + "onboarding_stage", + "password", + "user_permissions", +} + + +def assert_private_fields_absent(test_case, value): + if isinstance(value, dict): + test_case.assertTrue(PRIVATE_FIELDS.isdisjoint(value)) + for nested in value.values(): + assert_private_fields_absent(test_case, nested) + elif isinstance(value, list): + for nested in value: + assert_private_fields_absent(test_case, nested) + + +class VacancyCatalogContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_catalog_isolated_to_active_public_published_projects(self): + visible = create_vacancy(role="Visible") + create_vacancy(project=create_project(draft=True), role="Draft") + create_vacancy(project=create_project(is_public=False), role="Private") + create_vacancy(role="Closed", is_active=False) + + response = self.client.get("/vacancies/", {"is_active": "false"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + response = self.client.get("/vacancies/") + self.assertEqual([item["id"] for item in response.data["results"]], [visible.id]) + + def test_search_covers_role_specialization_description_and_project_name(self): + project = create_project(name="арктическая платформа") + vacancy = create_vacancy(project=project, role="Python инженер") + Vacancy.objects.filter(pk=vacancy.pk).update( + specialization="Data science", + description="Разработка рекомендательной системы", + ) + for search in ("python", "DATA SCIENCE", "рекомендательной", "арктическая"): + with self.subTest(search=search): + response = self.client.get("/vacancies/", {"search": f" {search} "}) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [vacancy.id], + ) + + def test_filters_and_limit_offset_pagination(self): + target = create_vacancy( + required_experience=WorkExperience.FROM_THREE_YEARS.name.lower(), + work_format=WorkFormat.HYBRID.name.lower(), + work_schedule=WorkSchedule.PART_TIME.name.lower(), + salary=180000, + ) + create_vacancy(salary=90000) + + response = self.client.get( + "/vacancies/", + { + "required_experience": "from_three_years", + "work_format": "hybrid", + "work_schedule": "part_time", + "salary_min": "150000", + "salary_max": "200000", + "limit": "1", + "offset": "0", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["id"], target.id) + + def test_old_vacancy_is_visible_and_list_query_count_is_constant(self): + old = create_vacancy( + role="Old", + datetime_created=timezone.now() - timedelta(days=120), + ) + skill = create_skill(name="Analytics") + old.required_skills.create(skill=skill) + + def query_count(count): + for index in range(count): + vacancy = create_vacancy(role=f"Vacancy {count}-{index}") + vacancy.required_skills.create( + skill=create_skill(name=f"Skill {count}-{index}") + ) + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/vacancies/", {"limit": "100"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return len(queries) + + one_count = query_count(1) + five_count = query_count(5) + self.assertLessEqual(five_count, one_count) + + +class VacancyDetailContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_inactive_or_private_detail_is_hidden_from_outsider(self): + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + for vacancy in ( + create_vacancy(is_active=False), + create_vacancy(project=create_project(is_public=False)), + create_vacancy(project=create_project(draft=True)), + ): + with self.subTest(vacancy=vacancy.id): + response = self.client.get(f"/vacancies/{vacancy.id}/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_leader_staff_and_superuser_can_open_inactive_detail(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader), is_active=False) + for user in ( + leader, + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.get(f"/vacancies/{vacancy.id}/").status_code, + status.HTTP_200_OK, + ) + + +class VacancyResponseContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_request_user_is_used_and_foreign_file_is_rejected(self, send_email): + applicant = create_user(prefix="applicant") + other = create_user(prefix="other") + vacancy = create_vacancy() + foreign_file = create_user_file(user=other) + self.client.force_authenticate(applicant) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "user_id": other.id, + "why_me": "Подхожу", + "accompanying_file": foreign_file.link, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(VacancyResponse.objects.exists()) + own_file = create_user_file(user=applicant) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "user_id": other.id, + "why_me": "Подхожу", + "accompanying_file": own_file.link, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VacancyResponse.objects.get().user, applicant) + send_email.assert_called_once() + + def test_member_and_duplicate_response_are_rejected(self): + leader = create_user(prefix="leader") + member = create_user(prefix="member") + project = create_project(leader=leader) + Collaborator.objects.create(project=project, user=member, role="Developer") + vacancy = create_vacancy(project=project) + self.client.force_authenticate(member) + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", {"why_me": "Member"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + outsider = create_user(prefix="outsider") + create_vacancy_response(user=outsider, vacancy=vacancy) + self.client.force_authenticate(outsider) + duplicate = self.client.post( + f"/vacancies/{vacancy.id}/responses/", {"why_me": "Again"}, format="json" + ) + self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST) + + def test_response_list_requires_manager_and_never_exposes_private_profile_fields( + self, + ): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + create_vacancy_response(user=applicant, vacancy=vacancy) + + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + self.assertEqual( + self.client.get(f"/vacancies/{vacancy.id}/responses/").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.client.force_authenticate(leader) + response = self.client.get(f"/vacancies/{vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + assert_private_fields_absent(self, response.data) + + def test_response_detail_is_visible_only_to_owner_or_vacancy_manager(self): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader) + vacancy_response = create_vacancy_response( + user=applicant, + vacancy=create_vacancy(project=project), + ) + + self.assertEqual( + self.client.get(f"/vacancies/responses/{vacancy_response.id}/").status_code, + status.HTTP_401_UNAUTHORIZED, + ) + self.client.force_authenticate(create_user(prefix="outsider")) + self.assertEqual( + self.client.get(f"/vacancies/responses/{vacancy_response.id}/").status_code, + status.HTTP_404_NOT_FOUND, + ) + for user in (applicant, leader, create_user(prefix="staff", is_staff=True)): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + response = self.client.get(f"/vacancies/responses/{vacancy_response.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + assert_private_fields_absent(self, response.data) + + def test_unknown_vacancy_and_response_return_not_found(self): + user = create_user(prefix="user") + self.client.force_authenticate(user) + self.assertEqual( + self.client.get("/vacancies/999999/responses/").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.assertEqual( + self.client.get("/vacancies/responses/999999/").status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_manager_response_list_query_count_is_constant(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) + self.client.force_authenticate(leader) + + def query_count(count): + for index in range(count): + user = create_user(prefix=f"candidate-{count}-{index}") + user.skills.create(skill=create_skill(name=f"Candidate {count}-{index}")) + create_vacancy_response(user=user, vacancy=vacancy) + with CaptureQueriesContext(connection) as queries: + response = self.client.get(f"/vacancies/{vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + return len(queries) + + one_count = query_count(1) + five_count = query_count(5) + self.assertLessEqual(five_count, one_count) + + def test_pending_owner_can_edit_replace_remove_file_and_withdraw(self): + applicant = create_user(prefix="applicant") + first_file = create_user_file(user=applicant) + second_file = create_user_file(user=applicant) + vacancy_response = create_vacancy_response( + user=applicant, + accompanying_file=first_file, + ) + self.client.force_authenticate(applicant) + + updated = self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"why_me": "Обновлено", "accompanying_file": second_file.link}, + format="json", + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(updated.data["why_me"], "Обновлено") + removed = self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"accompanying_file": None}, + format="json", + ) + self.assertIsNone(removed.data["accompanying_file"]) + withdrawn = self.client.delete(f"/vacancies/responses/{vacancy_response.id}/") + self.assertEqual(withdrawn.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(VacancyResponse.objects.filter(pk=vacancy_response.pk).exists()) + + def test_processed_response_cannot_be_edited_or_withdrawn(self): + applicant = create_user(prefix="applicant") + vacancy_response = create_vacancy_response(user=applicant, is_approved=True) + self.client.force_authenticate(applicant) + self.assertEqual( + self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"why_me": "Changed"}, + format="json", + ).status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual( + self.client.delete( + f"/vacancies/responses/{vacancy_response.id}/" + ).status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_self_list_requires_auth_and_contains_vacancy_project(self): + self.assertEqual( + self.client.get("/vacancies/responses/self").status_code, + status.HTTP_401_UNAUTHORIZED, + ) + applicant = create_user(prefix="applicant") + own = create_vacancy_response(user=applicant) + create_vacancy_response() + self.client.force_authenticate(applicant) + response = self.client.get("/vacancies/responses/self") + self.assertEqual(response.data["results"][0]["id"], own.id) + self.assertEqual( + response.data["results"][0]["vacancy"]["project"]["id"], + own.vacancy.project_id, + ) + + def test_anonymous_user_cannot_create_response(self): + vacancy = create_vacancy() + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Подхожу"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertFalse(VacancyResponse.objects.exists()) + + +class VacancyDecisionContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_accept_is_atomic_closes_vacancy_and_declines_other_pending(self, send_email): + leader = create_user(prefix="leader") + accepted_user = create_user(prefix="accepted") + rejected_user = create_user(prefix="rejected") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project, role="Designer") + accepted = create_vacancy_response(user=accepted_user, vacancy=vacancy) + rejected = create_vacancy_response(user=rejected_user, vacancy=vacancy) + self.client.force_authenticate(leader) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post(f"/vacancies/responses/{accepted.id}/accept/") + + accepted.refresh_from_db() + rejected.refresh_from_db() + vacancy.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(accepted.is_approved) + self.assertFalse(rejected.is_approved) + self.assertFalse(vacancy.is_active) + self.assertTrue( + Collaborator.objects.filter( + project=project, + user=accepted_user, + role="Designer", + ).exists() + ) + self.assertEqual(send_email.call_count, 2) + self.assertTrue( + Notification.objects.filter( + recipient=accepted_user, + type=Notification.Type.VACANCY_RESPONSE_ACCEPTED, + ).exists() + ) + self.assertTrue( + Notification.objects.filter( + recipient=rejected_user, + type=Notification.Type.VACANCY_RESPONSE_DECLINED, + ).exists() + ) + self.assertEqual( + self.client.post(f"/vacancies/responses/{accepted.id}/accept/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual( + Collaborator.objects.filter( + project=project, + user=accepted_user, + ).count(), + 1, + ) + + def test_failed_accept_rolls_back_all_changes(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + vacancy_response = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + with patch( + "vacancy.response_services.Collaborator.objects.create", + side_effect=RuntimeError("database error"), + ): + with self.assertRaises(RuntimeError): + self.client.post(f"/vacancies/responses/{vacancy_response.id}/accept/") + + vacancy.refresh_from_db() + vacancy_response.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertIsNone(vacancy_response.is_approved) + + def test_close_reopen_and_safe_delete(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + pending = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + closed = self.client.post(f"/vacancies/{vacancy.id}/close/") + self.assertEqual(closed.status_code, status.HTTP_200_OK) + pending.refresh_from_db() + self.assertFalse(pending.is_approved) + self.assertEqual( + self.client.delete(f"/vacancies/{vacancy.id}/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + reopened = self.client.post(f"/vacancies/{vacancy.id}/reopen/") + self.assertEqual(reopened.status_code, status.HTTP_200_OK) + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + + project.draft = True + project.save(update_fields=("draft",)) + self.client.post(f"/vacancies/{vacancy.id}/close/") + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_only_manager_can_close_or_reopen_vacancy(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/close/").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").status_code, + status.HTTP_403_FORBIDDEN, + ) + + for user in ( + leader, + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/close/").status_code, + status.HTTP_200_OK, + ) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").status_code, + status.HTTP_200_OK, + ) diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index ed28e868..2d35cc16 100644 --- a/vacancy/tests/test_vacancy_responses_api.py +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -4,6 +4,7 @@ from rest_framework import status from rest_framework.test import APIClient +from notifications.models import Notification from projects.models import Collaborator from vacancy.models import VacancyResponse from vacancy.tests.helpers import ( @@ -19,21 +20,22 @@ class VacancyResponseAPITests(TestCase): def setUp(self): self.client = APIClient() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_can_apply_to_active_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy(role="Apply vacancy", is_active=True) file = create_user_file(user=user) self.client.force_authenticate(user) - response = self.client.post( - f"/vacancies/{vacancy.id}/responses/", - { - "why_me": "Есть опыт", - "accompanying_file": file.link, - }, - format="json", - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "why_me": "Есть опыт", + "accompanying_file": file.link, + }, + format="json", + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) vacancy_response = VacancyResponse.objects.get() @@ -41,8 +43,14 @@ def test_user_can_apply_to_active_vacancy(self, send_email_delay): self.assertEqual(vacancy_response.vacancy, vacancy) self.assertEqual(vacancy_response.accompanying_file, file) send_email_delay.assert_called_once() + self.assertTrue( + Notification.objects.filter( + recipient=vacancy.project.leader, + type=Notification.Type.VACANCY_RESPONSE_CREATED, + ).exists() + ) - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy(is_active=False) @@ -54,11 +62,11 @@ def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertFalse(VacancyResponse.objects.exists()) send_email_delay.assert_not_called() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_cannot_apply_twice_to_same_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy() @@ -75,16 +83,19 @@ def test_user_cannot_apply_twice_to_same_vacancy(self, send_email_delay): self.assertEqual(VacancyResponse.objects.count(), 1) send_email_delay.assert_not_called() - def test_response_list_returns_responses_for_vacancy(self): - vacancy = create_vacancy() + def test_project_leader_gets_safe_responses_for_vacancy(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) target_response = create_vacancy_response(vacancy=vacancy, why_me="Target") create_vacancy_response(why_me="Other") + self.client.force_authenticate(leader) response = self.client.get(f"/vacancies/{vacancy.id}/responses/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data], [target_response.id]) self.assertEqual(response.data[0]["why_me"], "Target") + self.assertNotIn("email", response.data[0]["user"]) def test_current_user_responses_returns_only_own_responses(self): user = create_user(prefix="applicant") @@ -116,7 +127,7 @@ class VacancyResponseDecisionAPITests(TestCase): def setUp(self): self.client = APIClient() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_accept_response(self, send_email_delay): leader = create_user(prefix="leader") applicant = create_user(prefix="applicant") @@ -125,9 +136,10 @@ def test_project_leader_can_accept_response(self, send_email_delay): vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) self.client.force_authenticate(leader) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/accept/" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/accept/" + ) vacancy_response.refresh_from_db() vacancy.refresh_from_db() @@ -143,8 +155,14 @@ def test_project_leader_can_accept_response(self, send_email_delay): ).exists() ) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + self.assertTrue( + Notification.objects.filter( + recipient=applicant, + type=Notification.Type.VACANCY_RESPONSE_ACCEPTED, + ).exists() + ) - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_decline_response(self, send_email_delay): leader = create_user(prefix="leader") applicant = create_user(prefix="applicant") @@ -153,31 +171,36 @@ def test_project_leader_can_decline_response(self, send_email_delay): vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) self.client.force_authenticate(leader) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/decline/" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/decline/" + ) vacancy_response.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(vacancy_response.is_approved) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + self.assertTrue( + Notification.objects.filter( + recipient=applicant, + type=Notification.Type.VACANCY_RESPONSE_DECLINED, + ).exists() + ) - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_non_leader_cannot_accept_response(self, send_email_delay): vacancy_response = create_vacancy_response() outsider = create_user(prefix="outsider") self.client.force_authenticate(outsider) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/accept/" - ) + response = self.client.post(f"/vacancies/responses/{vacancy_response.id}/accept/") vacancy_response.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertIsNone(vacancy_response.is_approved) send_email_delay.assert_not_called() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_cannot_accept_or_decline_already_processed_response(self, send_email_delay): leader = create_user(prefix="leader") project = create_project(leader=leader) diff --git a/vacancy/urls.py b/vacancy/urls.py index aa46cd4d..4900f782 100644 --- a/vacancy/urls.py +++ b/vacancy/urls.py @@ -9,6 +9,8 @@ VacancyResponseAccept, VacancyResponseDecline, UserVacancyResponses, + VacancyClose, + VacancyReopen, ) app_name = "vacancies" @@ -16,6 +18,8 @@ urlpatterns = [ path("", VacancyList.as_view()), path("/", VacancyDetail.as_view()), + path("/close/", VacancyClose.as_view()), + path("/reopen/", VacancyReopen.as_view()), path("/responses/", VacancyResponseList.as_view()), path("responses//", VacancyResponseDetail.as_view()), path("responses/self", UserVacancyResponses.as_view()), diff --git a/vacancy/views.py b/vacancy/views.py index 2fe403f8..f7c53262 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,34 +1,46 @@ +from django.db import transaction from django.db.models import QuerySet +from django.http import Http404 from django_filters import rest_framework as filters from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, mixins, permissions, status +from rest_framework import generics, mixins, permissions, serializers, status from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response -from projects.models import Collaborator, Project from vacancy.filters import VacancyFilter -from vacancy.mapping import CeleryEmailParams, MessageTypeEnum from vacancy.models import Vacancy, VacancyResponse from vacancy.pagination import VacancyPagination from vacancy.permissions import ( - IsProjectLeaderForVacancyResponse, - IsVacancyResponseOwnerOrReadOnly, IsVacancyProjectLeader, ) from vacancy.serializers import ( + VacancyCatalogSerializer, VacancyDetailSerializer, - VacancyResponseAcceptSerializer, - VacancyResponseDetailSerializer, - VacancyResponseDetailReadSerializer, - VacancyResponseFullFileInfoListSerializer, - VacancyResponseListSerializer, ProjectVacancyCreateListSerializer, + VacancyResponseManagerSerializer, + VacancyResponseSelfSerializer, + VacancyResponseWriteSerializer, +) +from vacancy.selectors import ( + can_manage_vacancy, + can_view_vacancy, + get_public_vacancies_queryset, + get_response_queryset, + get_self_response_queryset, + get_vacancy_queryset, +) +from vacancy.response_services import ( + accept_vacancy_response, + close_vacancy, + create_vacancy_response, + delete_vacancy_without_responses, + decline_vacancy_response, + reopen_vacancy, ) from vacancy.services import update_vacancy_skills -from vacancy.tasks import send_email @swagger_auto_schema( @@ -40,29 +52,42 @@ ], ) class VacancyList(generics.ListCreateAPIView): - queryset = Vacancy.objects.get_vacancy_for_list_view() serializer_class = ProjectVacancyCreateListSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly] filter_backends = (filters.DjangoFilterBackend,) filterset_class = VacancyFilter pagination_class = VacancyPagination + def get_queryset(self): + return get_public_vacancies_queryset() + + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyCatalogSerializer + return super().get_serializer_class() + class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = Vacancy.objects.get_vacancy_for_detail_view() + queryset = Vacancy.objects.all() serializer_class = VacancyDetailSerializer permission_classes = [IsVacancyProjectLeader] + def get_object(self): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=self.kwargs["pk"]) + if not can_view_vacancy(self.request.user, vacancy): + raise Http404 + self.check_object_permissions(self.request, vacancy) + return vacancy + def patch(self, request, *args, **kwargs): update_vacancy_skills(request, self.get_object()) return self.partial_update(request, *args, **kwargs) def put(self, request, *args, **kwargs): - """updating the vacancy""" vacancy = self.get_object() if not request.data.get("is_active"): - # automatically declining every vacancy response if the vacancy is not active + # Legacy PUT сохраняет прежнее поведение: закрытие завершает ожидающие отклики. VacancyResponse.objects.filter(vacancy=vacancy, is_approved=None).update( is_approved=False ) @@ -71,163 +96,167 @@ def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): + vacancy = self.get_object() + delete_vacancy_without_responses(vacancy.id, actor=request.user) + return Response(status=status.HTTP_204_NO_CONTENT) -class VacancyResponseList( - mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView -): - permission_classes = [permissions.IsAuthenticatedOrReadOnly] - serializer_class = VacancyResponseListSerializer + +class VacancyResponseList(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyResponseWriteSerializer def get_serializer_class(self): if self.request.method == "GET": - return VacancyResponseFullFileInfoListSerializer + return VacancyResponseManagerSerializer return super().get_serializer_class() def get(self, request, *args, **kwargs): - """retrieve all responses for certain vacancy""" - # note: doesn't raise an error if the vacancy_id passed is non-existent + vacancy = get_object_or_404(Vacancy, pk=self.kwargs["vacancy_id"]) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) return self.list(request, *args, **kwargs) def get_queryset(self): - return VacancyResponse.objects.get_vacancy_response_for_list_view().filter( - vacancy__id=self.kwargs["vacancy_id"] - ) + return get_response_queryset().filter(vacancy_id=self.kwargs["vacancy_id"]) def post(self, request, vacancy_id): - vacancy = get_object_or_404(Vacancy, pk=vacancy_id) - if not vacancy.is_active: - return Response( - "You cannot apply for a closed vacancy", status.HTTP_400_BAD_REQUEST - ) - - try: - request.data["user_id"] = self.request.user.id - request.data["vacancy"] = vacancy_id - except AttributeError: - pass + vacancy = get_object_or_404(get_public_vacancies_queryset(), pk=vacancy_id) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - - vacancy_response = self.create(request, vacancy_id) - - queryset = VacancyResponse.objects.get_vacancy_response_for_email().get( - vacancy__id=self.kwargs["vacancy_id"], user=self.request.user + response = create_vacancy_response( + vacancy_id=vacancy.id, + user=request.user, + validated_data=serializer.validated_data, ) - project = queryset.vacancy.project - - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.RESPONDED.value, - user_id=project.leader.id, - project_name=project.name, - project_id=project.id, - vacancy_role=queryset.vacancy.role, - schema_id=2, - ) + return Response( + VacancyResponseSelfSerializer(response, context={"request": request}).data, + status=status.HTTP_201_CREATED, ) - return vacancy_response - class VacancyResponseDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseDetailSerializer - permission_classes = [IsVacancyResponseOwnerOrReadOnly] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseWriteSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + response = get_object_or_404(get_response_queryset(), pk=self.kwargs["pk"]) + is_owner = response.user_id == self.request.user.id + is_manager = can_manage_vacancy(self.request.user, response.vacancy) + if not (is_owner or is_manager): + raise Http404 + if self.request.method not in permissions.SAFE_METHODS: + if not is_owner: + return self.permission_denied(self.request) + if response.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя изменить.") + return response + + def retrieve(self, request, *args, **kwargs): + response = self.get_object() + serializer_class = ( + VacancyResponseSelfSerializer + if response.user_id == request.user.id + else VacancyResponseManagerSerializer + ) + return Response(serializer_class(response, context={"request": request}).data) - def get_serializer_class(self): - if self.request.method == "GET": - return VacancyResponseDetailReadSerializer - return super().get_serializer_class() + def update(self, request, *args, **kwargs): + visible_instance = self.get_object() + with transaction.atomic(): + instance = VacancyResponse.objects.select_for_update().get( + pk=visible_instance.pk + ) + if instance.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя изменить.") + serializer = self.get_serializer( + instance, + data=request.data, + partial=kwargs.pop("partial", False), + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + VacancyResponseSelfSerializer(instance, context={"request": request}).data, + ) + + def destroy(self, request, *args, **kwargs): + visible_instance = self.get_object() + with transaction.atomic(): + instance = VacancyResponse.objects.select_for_update().get( + pk=visible_instance.pk + ) + if instance.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя отозвать.") + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) class VacancyResponseAccept(generics.GenericAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseAcceptSerializer - permission_classes = [IsProjectLeaderForVacancyResponse] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseManagerSerializer + permission_classes = [permissions.IsAuthenticated] def post(self, request, pk): - """accepting the vacancy""" - vacancy_request = self.get_object() - if vacancy_request.is_approved is not None: - # can't accept a vacancy that's already declined/accepted - return Response(status=status.HTTP_400_BAD_REQUEST) - vacancy_request.is_approved = True - - vacancy = vacancy_request.vacancy - project_add_in: Project = vacancy.project - user_to_add = vacancy_request.user - role_add_as: str = vacancy.role - - # check if this person already has a collaborator role in this project - if Collaborator.objects.filter( - project=project_add_in, user=user_to_add - ).exists(): - return Response( - "You already work for this project, you can't accept a vacancy here", - status=status.HTTP_400_BAD_REQUEST, - ) - - new_collaborator = Collaborator( - user=user_to_add, - project=project_add_in, - role=role_add_as, - ) - - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.ACCEPTED.value, - user_id=user_to_add.id, - project_name=project_add_in.name, - project_id=project_add_in.id, - vacancy_role=role_add_as, - schema_id=2, - ) + vacancy_response = get_object_or_404(get_response_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy_response.vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + accept_vacancy_response(pk, actor=request.user) + accepted = get_response_queryset().get(pk=pk) + return Response( + VacancyResponseManagerSerializer(accepted, context={"request": request}).data ) - # After acceptance, closes the vacancy. - vacancy.is_active = False - vacancy.save() - new_collaborator.save() - vacancy_request.save() - return Response(status=status.HTTP_200_OK) class VacancyResponseDecline(generics.GenericAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseAcceptSerializer - permission_classes = [IsProjectLeaderForVacancyResponse] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseManagerSerializer + permission_classes = [permissions.IsAuthenticated] def post(self, request, pk): - """declining the vacancy""" - vacancy_request = self.get_object() - if vacancy_request.is_approved is not None: - # can't decline a vacancy that's already declined/accepted - return Response(status=status.HTTP_400_BAD_REQUEST) - vacancy_request.is_approved = False - vacancy_request.save() - - project = vacancy_request.vacancy.project - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.REJECTED.value, - user_id=vacancy_request.user.id, - project_name=project.name, - project_id=project.id, - vacancy_role=vacancy_request.vacancy.role, - schema_id=2, - ) + vacancy_response = get_object_or_404(get_response_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy_response.vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + decline_vacancy_response(pk, actor=request.user) + declined = get_response_queryset().get(pk=pk) + return Response( + VacancyResponseManagerSerializer(declined, context={"request": request}).data ) - return Response(status=status.HTTP_200_OK) - class UserVacancyResponses(ListAPIView): - serializer_class = VacancyResponseFullFileInfoListSerializer - permission_classes = [IsVacancyResponseOwnerOrReadOnly] + serializer_class = VacancyResponseSelfSerializer + permission_classes = [permissions.IsAuthenticated] pagination_class = VacancyPagination def get_queryset(self) -> QuerySet[VacancyResponse]: - return ( - VacancyResponse.objects.get_vacancy_response_for_list_view() - .filter(user=self.request.user) - .order_by("datetime_created") - ) + return get_self_response_queryset().filter(user=self.request.user) + + +class VacancyClose(GenericAPIView): + queryset = Vacancy.objects.all() + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyDetailSerializer + + def post(self, request, pk): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + close_vacancy(pk, actor=request.user) + closed = get_vacancy_queryset().get(pk=pk) + return Response(VacancyDetailSerializer(closed).data) + + +class VacancyReopen(GenericAPIView): + queryset = Vacancy.objects.all() + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyDetailSerializer + + def post(self, request, pk): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + reopen_vacancy(pk, actor=request.user) + reopened = get_vacancy_queryset().get(pk=pk) + return Response(VacancyDetailSerializer(reopened).data)