diff --git a/.github/workflows/pilot-package.yml b/.github/workflows/pilot-package.yml new file mode 100644 index 00000000..67e80eff --- /dev/null +++ b/.github/workflows/pilot-package.yml @@ -0,0 +1,67 @@ +name: Build pilot package + +on: + pull_request: + branches: [dev] + paths: + - ".github/workflows/pilot-package.yml" + - "deploy/pilot/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pilot-package-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Build pilot artifact (no deployment) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + path: api + + - name: Read pinned frontend revision + id: frontend + shell: bash + run: | + PILOT_FRONTEND_REF="$(cat api/deploy/pilot/frontend-revision.txt)" + [[ "$PILOT_FRONTEND_REF" =~ ^[a-f0-9]{40}$ ]] + printf 'revision=%s\n' "$PILOT_FRONTEND_REF" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + repository: PROCOLLAB-github/procollab + ref: ${{ steps.frontend.outputs.revision }} + path: frontend + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Build pilot frontend + working-directory: frontend + env: + NG_CLI_ANALYTICS: "false" + NG_BUILD_MAX_WORKERS: "2" + run: ./node_modules/.bin/ng build social_platform --configuration=pilot --output-path=../frontend-build + + - name: Package committed API, frontend and templates without secrets + run: python3 api/deploy/pilot/package.py --api api --frontend frontend-build/browser --target bundle/procollab-pilot + + - uses: actions/upload-artifact@v4 + with: + name: procollab-pilot-candidate + path: bundle/procollab-pilot.tar.gz + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/pilot-runtime.yml b/.github/workflows/pilot-runtime.yml new file mode 100644 index 00000000..35f0c5f4 --- /dev/null +++ b/.github/workflows/pilot-runtime.yml @@ -0,0 +1,89 @@ +name: Build pilot runtime image + +on: + pull_request: + branches: [dev] + paths: + - ".github/workflows/pilot-runtime.yml" + - "procollab/settings_pilot_inline.py" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pilot-runtime-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + runtime: + name: Export pilot runtime image (no deployment) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build from tracked source without checkout credentials + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/pilot-source" runtime-artifact + git archive HEAD | tar -x -C "$RUNNER_TEMP/pilot-source" + PILOT_IMAGE="procollab-white-label-pilot-build:$(git rev-parse --short=12 HEAD)" + docker build --label "org.opencontainers.image.revision=$(git rev-parse HEAD)" --tag "$PILOT_IMAGE" "$RUNNER_TEMP/pilot-source" + printf 'PILOT_IMAGE=%s\n' "$PILOT_IMAGE" >> "$GITHUB_ENV" + + - name: Check isolated inline profile + run: | + docker run --rm \ + -e DJANGO_SETTINGS_MODULE=procollab.settings_pilot_inline \ + -e DJANGO_SECRET_KEY=ci-only-profile-check \ + -e DATABASE_NAME=procollab_white_label_pilot_ci \ + -e DATABASE_HOST=db \ + -e WHITE_LABEL_ENABLED=True \ + -e WHITE_LABEL_PROGRAM_ID=1 \ + -e WHITE_LABEL_BRAND_NAME=Pilot \ + -e WHITE_LABEL_FRONTEND_URL=https://pilot.procollab.ru \ + "$PILOT_IMAGE" python manage.py check + + - name: Export streaming rootfs with provenance and checksum + shell: bash + run: | + PILOT_EXPORT_CONTAINER="$(docker create "$PILOT_IMAGE")" + docker export "$PILOT_EXPORT_CONTAINER" | gzip -1 > runtime-artifact/pilot-api-rootfs.tar.gz + docker rm "$PILOT_EXPORT_CONTAINER" + docker image inspect "$PILOT_IMAGE" > "$RUNNER_TEMP/pilot-image-inspect.json" + python3 - <<'PYINFO' + import hashlib, json, os + from pathlib import Path + import subprocess + info=json.loads(Path(os.environ['RUNNER_TEMP'],'pilot-image-inspect.json').read_text())[0] + archive=Path('runtime-artifact/pilot-api-rootfs.tar.gz') + metadata={ + 'format':'docker-export-v1', + 'image':os.environ['PILOT_IMAGE'].replace('-pilot-build:', '-pilot-api:'), 'image_size_bytes':info['Size'], + 'architecture':info['Architecture'], 'config':info['Config'], + 'api_revision':subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), + 'archive_sha256':hashlib.file_digest(archive.open('rb'),'sha256').hexdigest(), + } + Path('runtime-artifact/runtime.json').write_text(json.dumps(metadata,indent=2)+'\n') + PYINFO + (cd runtime-artifact && sha256sum pilot-api-rootfs.tar.gz runtime.json > SHA256SUMS) + + - name: Verify streaming import restores image configuration + run: | + python3 deploy/pilot/runtime_import.py --runtime runtime-artifact + PILOT_IMPORTED_IMAGE="$(python3 -c 'import json;print(json.load(open("runtime-artifact/runtime.json"))["image"])')" + docker run --rm \ + -e DJANGO_SETTINGS_MODULE=procollab.settings_ci \ + -e DJANGO_SECRET_KEY=ci-only-import-check \ + "$PILOT_IMPORTED_IMAGE" python manage.py check + + - uses: actions/upload-artifact@v4 + with: + name: procollab-pilot-runtime + path: runtime-artifact/ + compression-level: 0 + if-no-files-found: error + retention-days: 7 diff --git a/core/apps.py b/core/apps.py index c0ce093b..4397821d 100644 --- a/core/apps.py +++ b/core/apps.py @@ -4,3 +4,6 @@ class CoreConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "core" + + def ready(self): + from procollab import white_label # noqa: F401 diff --git a/courses/admin_config/helpers.py b/courses/admin_config/helpers.py index 06cf7fd2..c8f3814a 100644 --- a/courses/admin_config/helpers.py +++ b/courses/admin_config/helpers.py @@ -1,7 +1,7 @@ from django import forms from files.models import UserFile -from files.service import CDN, SelectelSwiftStorage +from files.service import CDN, configured_storage from courses.models.file_validation import looks_like_image_file @@ -20,7 +20,7 @@ def validate_image_upload( class UserFileUploadAdminMixin: - cdn = CDN(storage=SelectelSwiftStorage()) + cdn = CDN(storage=configured_storage()) def create_user_file(self, request, uploaded_file): info = self.cdn.upload(uploaded_file, request.user, quality=100) diff --git a/deploy/pilot/README.md b/deploy/pilot/README.md new file mode 100644 index 00000000..73e6cdda --- /dev/null +++ b/deploy/pilot/README.md @@ -0,0 +1,504 @@ +# Пилот «Сознание права» + +Изолированное демонстрационное окружение до подтверждения клиента. Квиз не входит. +Пакет не меняет DNS, nginx хоста, существующий dev/prod или их данные. + +`pilot.procollab.ru` уже имеет A `5.188.81.217`; NS домена — REG.RU. +HTTPS пока возвращает обычный Procollab. + +## Существующий сервер: сначала обследование + +По выводу владельца от 11 ноября на сервере уже работают +`procollab-white-label-pilot-web-1` на `127.0.0.1:18318` и отдельный +`procollab-white-label-pilot-db-1`. Dev API использует порты 8000/8001. +Свободно 6.2 GiB диска, доступно 2.1 GiB RAM, swap отсутствует. +**На этом сервере не запускать второй compose project из этого пакета.** +Подготовленный ниже полный стек предназначен для нового окружения; его +preflight требует 8 GiB диска и 2560 MiB доступной RAM и здесь не пройдет. + +Подтверждены compose `/opt/procollab-white-label-pilot/compose.yml`, образ +`procollab-white-label-pilot-base:20261109`, профиль `pilot_hosted_settings` и +bind mount `/opt/procollab-white-label-pilot/backend:/procollab`. +Uploads (`/var/lib/pilot`), static и logs используют собственные named volumes. +Сравнение исходников подтвердило 272 отличающихся и 110 отсутствующих runtime +файлов; в старой БД не применены 48 миграций текущей базы. Native registration +там отсутствует. В БД четыре существующих аккаунта не с `@example.test`; это +не основание считать их тестовыми или удалять. Прямой upgrade старой схемы не +выполняется. + +Новый закрытый пилот v2: отдельные web и PostgreSQL 15, своя сеть и новые файлы +в `/opt/procollab-white-label-pilot-v2`. API слушает только `127.0.0.1:18319`, +DB не публикует порты. Старые web/DB/compose/backend/файлы остаются для отката. +Лимиты web — 768 MiB / 0.5 CPU, PG — 256 MiB / 0.25 CPU, shared_buffers 64 MiB, +max_connections 40, work_mem 1 MiB. App role не superuser. Для демо используются +локальные cache/channel layers и синхронные Celery tasks; Redis/worker не нужны. + +`hosted.py` жестко проверяет hostname, root, имена/labels старых контейнеров, +сети и принадлежность routes.inc только домену pilot. Запрещает чужие ресурсы, +ссылки за пределы v2, несовпадающие artifacts и перезапись активного релиза. +Перед запуском сохраняет запас памяти 768 MiB и диска 2 GiB плюс бюджет PG. +Новые контейнеры получают повышенный OOM score, отдельные pids/log limits. +Основные процессы/настройки Docker, dev/prod, DNS и сертификаты не меняются. +Проверка dev требует работающие `api-web-1`, `api-redis-1`, `api_celery` и HTTP 200 +от `dev.procollab.ru`. Сравнительный React не является зависимостью пилота: +состояние `api-react-*` не блокирует этапы и не изменяется установщиком. + +## Существующий сервер: этапы v2 + +1. Скачать `procollab-pilot-candidate.zip` и `procollab-pilot-runtime.zip` из + успешных workflow одного commit. Перенести их в `/root` по SSH. Runtime + содержит compressed rootfs и public image config, без секретов. +2. Проверенным `unpack_candidate.py` распаковать только candidate ZIP в новый + каталог. Runtime ZIP не распаковывается: rootfs читается из него потоком. +3. Из каталога `procollab-pilot` выполнить только проверку: + +```sh +python3 hosted.py check --bundle . --runtime /root/procollab-pilot-runtime.zip +``` + +`check` читает ресурсы/конфигурацию и проверяет hashes. Он не создает контейнеры, +DB, пользователей или nginx-конфигурацию. В JSON есть план ресурсов и dev HTTP. +Далее отдельные этапы, после успешного результата предыдущего: + +```sh +python3 hosted.py stage --bundle . --runtime /root/procollab-pilot-runtime.zip +python3 hosted.py switch +python3 hosted.py status +``` + +`stage` создает только v2, делает private backup старой БД/compose/routes, +проверяет читаемость dump через `pg_restore --list`. Импортирует проверенный +rootfs потоком (до 8 MiB/s), сохраняя запас диска; конфигурация ENV/CMD/WORKDIR +восстанавливается и проверена в CI. Новый PG запускается на закрепленном digest, +миграции/seed выполняются только в новой пустой БД `procollab_white_label_pilot_v2`. +Временный API проходит instance/native registration/login smoke. Домен остается +на старом пилоте. Новый пароль DB/Django/демо генерируется только на сервере. + +`switch` сохраняет старый pilot routes.inc, применяет новый include и один +собственный maps-файл, выполняет `nginx -t` и graceful reload. Проверяет закрытый +вход, branded metadata по HTTPS и прежнюю доступность dev. При ошибке возвращает +старые routes и reload. Измененные другим оператором файлы не перезаписывает. +Ни один старый контейнер не останавливается. Все старые DB/volume остаются. + +Maps-файл размещается в `/etc/nginx/sites-enabled/00-procollab-pilot-v2-maps.conf`. +Требуется существующий HTTP include `sites-enabled/*` или `sites-enabled/*.conf`; +основной `nginx.conf` не редактируется. Временные файлы скрыты от include glob. +Если bundle уже скачан, исправленный `hosted.py` можно запускать из отдельного +каталога вместе с `configure.py`, `runtime_import.py` и двумя `hosted-*.template` +из bundle. Передавать исходный bundle через `--bundle`: его manifest и runtime +остаются прежними. Все дальнейшие команды выполнять этим же `hosted.py`. + +Ручной откат маршрутов: + +```sh +python3 hosted.py rollback +``` + +Он возвращает старый пилот, не удаляя новые DB/контейнеры/данные. Каталог старого +пилота сохраняется для отката и ACME. Прежний `deploy.sh` относится к старой копии; +для v2 используется `hosted.py`. Данные входа — только на сервере в +`/opt/procollab-white-label-pilot-v2/access.txt`, root-only. В чат их не отправлять. +Это подготовка демо; нагрузка 100 участников, реальная почта и приемка клиента +проверяются отдельно. + +Прямой публичный SSH из Codex Cloud в этой среде недоступен; нужен настроенный +VPN/Tailscale либо выполнение подготовленных команд владельцем в SSH-терминале. +Добавление публичного ключа само по себе не создает сетевой маршрут. + +### Исправление приглашений для аккаунтов без ФИО + +Новый candidate-пакет содержит `upgrade_invites.py`. Он принимает только активный +hosted-v2 на известном сервере, исходные hashes кода/зависимостей и прежний index. +Runtime image повторно не переносится: четыре проверенных Python-файла подключаются +readonly к новому web-контейнеру с тем же образом, DB, env, сетью и лимитами. +До замены сохраняются dump БД v2 и изменяемые файлы frontend. Останавливается +только прежний web v2, который сохраняется для отката; PostgreSQL, старый пилот, +dev, nginx, DNS и сертификаты не меняются. Миграции и повторный seed не запускаются. + +После распаковки нового candidate в отдельный каталог: + +```sh +python3 upgrade_invites.py check --bundle . +python3 upgrade_invites.py apply --bundle . +``` + +Перед публикацией нового index проверяются вход, точный поиск почты в программе +и отсутствие такого поиска у гостя. Затем проверяются опубликованный index и dev. +При ошибке возвращаются прежний web и frontend, сохраняя текущие данные БД. +Ручной откат: `python3 upgrade_invites.py rollback` из того же каталога. + +## Процентные веса в действующем v2 + +`upgrade_weights.py` обновляет известный v2 после исправления приглашений +`f7f800e15085555300419d87308b6fdf02e3a945`. Fingerprints 20 изменяемых runtime +файлов и защищенных зависимостей/профилей/приглашений заданы в `weights_base.json`. +Чужие source overlays и неожиданные mounts отклоняются до замены контейнера. + +Переиспользуются текущий image, `.env`, PostgreSQL, media, logs и лимиты ресурсов. +Существующие четыре readonly-файла приглашений остаются подключенными. Перед +заменой web сохраняются проверенный dump БД и изменяемые frontend-файлы; все +изменения относятся к `/opt/procollab-white-label-pilot-v2`. Nginx, старый пилот, +dev, React, DNS и сертификаты не редактируются и не перезапускаются. + +После распаковки проверенного candidate в новый каталог: + +```sh +python3 upgrade_weights.py check --bundle . +python3 upgrade_weights.py apply --bundle . +python3 upgrade_weights.py status +``` + +`check` только читает состояние. `apply` отдельным ограниченным контейнером +проверяет полный migration plan и разрешает только две добавочные миграции: +`partner_programs.0044_partnerprogram_evaluation_scoring_mode` и +`project_rates.0012_criteria_weight_percent_and_more`. Прочие pending migrations, +обратный план и операции удаления блокируют установку. Повтор после отката +допускает уже примененные миграции. SQL lock timeout — 10 секунд, statement +timeout — 120 секунд. При прерывании migrator останавливается только после +проверки его identity/image/метки текущего шага. + +Данные и прежний расчет сохраняются. У нового режима есть постоянный DB default +`legacy`, чтобы прежний ORM продолжал создавать программы при откате. Повторный +seed, удаление таблиц и автоматическое включение весов не выполняются. Нужную +сумму 100% организатор задает после установки в «Оценивание → Критерии». + +Перед публикацией index проверяются регистрация, вход, поиск приглашений, +закрытость поиска от гостя и новый criteria API. Затем проверяются index по HTTPS, +неизменность nginx и ID/image/StartedAt защищенных контейнеров, доступность dev. +На время замены web пилот может кратко не отвечать; миграции выполняются до нее. + +При ошибке возвращаются прежние web и frontend. БД из dump автоматически не +восстанавливается: новые пользовательские записи и добавочная схема сохраняются. +Журналы и record находятся в private backup; состояние — `weights-upgrade.json`. +Ручной откат из того же пакета: + +```sh +python3 upgrade_weights.py rollback +``` + +Если проценты уже включены хотя бы в одной программе, откат на старый API +блокируется: он иначе интерпретирует исходные баллы. В этом случае нужна версия, +которая понимает веса; данные не удаляются. История `invite-upgrade.json` при +откате весов не перезаписывается. + +Проверено локально: 266 API tests вместе с white label/invites; после добавления +DB default повторно 235 профильных tests и migration regression. В отдельном +PostgreSQL точный скрипт применил ровно две миграции поверх реального старого +исходного кода, сохранил оценки 8/10 и прошел повтор с пустым планом. Старый ORM +после обновления создает новую legacy-программу. Проверены возвращение старого +web/frontend реальными disposable Docker-контейнерами и сохранение invite record. +Pilot Angular build с реальным локальным API прошел browser flow: веса 40/35/25, +эксперт 71,5, второй 88, итог 79,75, locked UI, Excel, desktop/mobile. Все внешние +сервисы в этом прогоне блокировались. Фактическая установка и smoke на сервере +фиксируются отдельно после выполнения команд владельцем. + +### Проверка установленной версии 12 ноября 2026 + +Владелец выполнил check/apply/status на `procollab-dev`: установлен API/source +`76570824e596ba06b196171f6cf8be38e5a452d1`, frontend +`971e52fb1f199ee5c38e2fb42f06ff1946a261a7`. Получены `phase=active`, +`candidate_running=true`, `image_reused=true`, сохранена БД +`procollab_white_label_pilot_v2`, `nginx_changed=false`, `dev_http=200`. +Резервная копия: `backup/weights-ce58ef27f6ae` внутри v2. + +Независимая проверка HTTPS подтвердила hash опубликованного index, instance и +короткую регистрацию, критерии с новыми полями, results contract, 403 для обычного +участника/эксперта в organizer criteria, scoped поиск приглашений и отсутствие +раскрытия почт гостю. Сданный QA-проект и оба прежних PDF доступны; dev отвечает 200. Внешний gate по-прежнему возвращает 401 без авторизации. + +В реальном браузере проверены desktop/mobile 390 px, поле веса и невозможность +сохранить сумму 40% вместо 100%. Изменения делались только в несохраненном +черновике: после reload критерии/revision прежние, PUT настроек не отправлялся. +Открываются результаты и новый пакетный редактор Django admin, включая новый +template; ошибок страницы нет. TLS-проверка включена. Прокси-CA тестовой среды +доверен только отдельному временному браузеру. + +Это 11 успешных HTTP-проверок и 7 browser checks. Реальный расчет с сохранением +процентных оценок проверялся ранее на изолированных данных: 71,5 / 88 → 79,75. +На живом пилоте оставлен `legacy`: сейчас есть только комментарий, числовые +критерии и конкретные проценты мероприятия еще не заданы. Живые оценки ради +проверки не создавались. Реальный SMTP, нагрузка 100 участников и приемка клиента +остаются отдельными этапами. + +Временный установочный ZIP удален через API его QA-владельца после проверки +SHA-256: DELETE 204, последующее чтение 404. Распакованный релиз и backup не +затрагивались; отдельно подтверждены сохранность команды, сданного проекта и +двух PDF, доступность dev. Пароли и сессии проверки не входят в отчет/Git. + +### Только интерфейс авторизации поверх установленного пилота + +`upgrade_frontend.py` предназначен только для backend/source `7657082` и +frontend `971e52fb` → `75f41a5d` из Angular PR #540. Он устраняет переход +после выхода на общий экран `/auth/login`, используя существующую клиентскую +форму. Вход сразу открывает программу или безопасную защищенную ссылку. +Новые макеты, API, миграции и настройки весов не входят в это обновление. + +Отдельный `package_frontend.py` сравнивает проверенную предыдущую сборку с +новой pilot-сборкой и упаковывает только измененные статические файлы. Пакет +не содержит API runtime, БД, секретов или образов. Его ZIP совместим с +`unpack_candidate.py`: внутри один `procollab-pilot.tar.gz`. + +Установщик проверяет hostname/root, активный v2 и запись установленного +обновления весов, образ/ID контейнера, исходные hashes всех frontend файлов, +отсутствие ссылок, manifest пакета и запас диска 2 GiB. Существующие проверки +dev требуют живые `api-web-1`, `api-redis-1`, `api_celery` и HTTPS 200. +Состояние React не является зависимостью. До и после публикации сравниваются +ID/время старта/настройки контейнеров dev и обоих пилотов, readonly overlays, +настройки пилота и вся конфигурация nginx. Проверки API используют только GET. + +Сначала сохраняется private backup изменяемых файлов, затем новые assets, +последним атомарно заменяется index. Старые chunks остаются для открытых +вкладок. Проверяются опубликованный index по HTTPS, прежний API, dev и +неизменность защищенных ресурсов. При ошибке старый frontend восстанавливается +из проверенной копии; новые chunks не удаляются. БД, контейнеры, backend, +`.env`, `state.json`, nginx и его reload не меняются. + +После проверки SHA-256 и распаковки владелец выполняет в каталоге пакета: + +```sh +python3 upgrade_frontend.py check --bundle . +python3 upgrade_frontend.py apply --bundle . +python3 upgrade_frontend.py status +``` + +Повторный apply установленной версии только проверяет status. Ручной откат: +`python3 upgrade_frontend.py rollback`. Запись `frontend-upgrade.json` и backup +остаются в v2; данные/история предыдущих обновлений не переписываются. +Сначала откатывается этот frontend, если впоследствии понадобится отдельный +откат backend весов. Не запускать повторно `upgrade_weights.py apply` ради +изменения авторизации. + +Проверка подготовки: 21 тест установщика/пакета плюс 20 существующих проверок +hosted/unpack/package — всего 41 PASS, scoped Black/Flake8. В том числе реальная локальная +HTTP-публикация, повтор после отката и принудительный 502 после замены index: +старый index восстановлен, dev-check остается 200, рестарты/команды записи в +Docker не вызываются. Для Angular ранее прошли 48 целевых тестов, обе сборки и +10 браузерных проверок с настоящим API пилота. Выполнение на сервере владельцем +и проверка уже опубликованного auth frontend фиксируются отдельно после установки. + +Первый запуск владельцем остановился после preflight и вернул прежний UI +(`backup/auth-b7b30cc30442`). Независимые GET подтвердили старый index по SHA-256, +dev/pilot API HTTP 200 и отсутствие новых JS assets. Исходный exception был +скрыт общим сообщением первой версии; точная причина на сервере не записана. +Локально на реальном Docker воспроизведен дефект этой проверки: порядок Mounts +при повторном inspect одного контейнера меняется, и hash массива ложно сообщает +об изменении конфигурации. В первом прогоне 12 inspect дали 9 разных hashes +без перезапуска. С исправленным `configuration_fingerprint` второй прогон дал +8 разных исходных hashes и один стабильный исправленный; ID/StartedAt прежние. + +Теперь только Mounts сортируются перед fingerprint; все их поля, Config, +HostConfig, ресурсы, исходники и nginx продолжают сравниваться полностью. +Порядок Env не нормализуется. При повторе после старого отката дополнительно +проверяются прежние ID/image/StartedAt, защищенные файлы и nginx. Новая попытка +получает новый полный snapshot и новый backup; прежний backup сохраняется. +Ошибки сохраняют этап и причину в private `failure.json` и выводят причину; +неподтвержденный rollback больше не описывается как успешный. + +Существующий проверенный ZIP и его manifest не изменяются. Для повтора +исправленный `upgrade_frontend.py` запускается из отдельного каталога tools +с тремя прежними helper-модулями и `--bundle` на распакованный пакет. Сам +исправленный установщик проверяется отдельным SHA-256. Это проверено тестом +неизменности исходного bundle; повторная отправка frontend не нужна. + +Повтор владельцем с `2796b99` завершился успешно: frontend `75f41a5d`, +phase active, dev HTTP 200, контейнеры не перезапускались, БД и nginx не менялись. +Новый backup — `backup/auth-4cf721bc7c02`; предыдущий backup сохранен. + +Независимая приемка опубликованной версии: 8 HTTP и 11 Chromium checks PASS. +SHA-256 index и трех измененных JS соответствует сборке. Браузер использовал +настоящие HTTPS assets/API без локальной подмены; пройдены вход → программа → +reload → выход → повторный вход, клиентская форма на mobile 390, recovery, +старые ссылки и protected returnUrl организатора. Page errors и посторонних +mutations нет; только 6 POST входа/выхода собственных QA-сессий, письма не отправлялись. +Сохранены тестовая команда, сданный проект и оба прежних PDF побайтно, критерии +и режим оценки не менялись. Dev повторно ответил 200. + +После приемки временный auth ZIP удален через API своего QA-владельца +(204 → 404). Распакованный пакет, tools и backup остались. Три cleanup checks +подтвердили сохранность команды/заявки/PDF, опубликованного index и dev. +Данные проверки и скриншоты находятся вне Git; пароли/токены не сохраняются. + +## Общий рейтинг: точечное обновление действующего v2 + +`package_overall.py` / `upgrade_overall.py` предназначены только для известного +`/opt/procollab-white-label-pilot-v2`: backend 7657082 после весов/приглашений и +auth frontend 75f41a5. Цель — runtime 3c21fbc и проверенная Angular 3241381e. +Квиз не устанавливается; результат команды складывается из кейса 100 и будущего +квиза 100. Отсутствующий этап остается ожидающим, баллы не подставляются. + +После фиксации установщика в Git, в API checkout: + +```sh +python3 deploy/pilot/package_overall.py \ + --api /workspace/pilot-api \ + --frontend /workspace/overall-rating-qa-20261112/build-pilot/browser \ + --output /workspace/work/overall-release +``` + +Установщик проверяет точные hashes исходной и целевой сборок, runtime/старых +overlays, records, образ, env, лимиты, сеть/БД, dev и nginx. Пакет содержит +11 runtime файлов, только измененную статику и helpers. Он распаковывается +`unpack_candidate.py` в новый приватный каталог; перед распаковкой оператор +сверяет SHA256 ZIP и самого unpacker из закрепленного Git commit. + +В распакованном `procollab-pilot`, от root на известном dev-сервере: + +```sh +python3 -B upgrade_overall.py check --bundle "$PWD" +python3 -B upgrade_overall.py apply --bundle "$PWD" +python3 -B upgrade_overall.py status +``` + +`check` не публикует файлы и не меняет схему: запускает отдельный временный +migration probe с read-only транзакциями. `apply` сначала создает проверенный +pg_dump и backup статики/env в `backup/overall-`, затем допускает только +CreateModel 0045 (либо пустой план после отката) и штатную metadata прав новой +модели. Меняется только web v2 на loopback 18319, с тем же образом и ресурсами. +Dev, старый пилот и PostgreSQL сохраняются; nginx не перезагружается. После API, +Excel и проверки сохранности кейсов публикуется index. `phase: active` вместе +с успешным `status` подтверждает завершение; повторный `apply` не пересоздает web. + +При ошибке или прерывании выполняется откат web/статики. Для отдельного отката +того же установленного шага: + +```sh +python3 -B upgrade_overall.py rollback +python3 -B upgrade_overall.py status +``` + +Откат сохраняет добавочную 0045 и текущие данные, не запускает pg_restore/drop, +не отключает процентные критерии. Прежний backend 7657082 совместим с сохраненной +таблицей. ID/labels/config и hashes backup проверяются до действий; при внешнем +дрейфе скрипт останавливается с путем backup. `phase: prepared` требует rollback +этим же установщиком перед повтором. Ручная подмена records/hashes и запуск +старого `upgrade_weights.py` для рейтинга не подходят. + +Регрессия: `python3 -B -m unittest scripts.tests.test_pilot_overall_upgrade +scripts.tests.test_pilot_frontend_upgrade scripts.tests.test_pilot_weights_upgrade +scripts.tests.test_pilot_invite_upgrade scripts.tests.test_pilot_package +scripts.tests.test_pilot_unpack`. Реальные PostgreSQL/Docker proofs и ограничения +описаны в `docs/overall-case-quiz-ranking.md`, D01–D07. Сборка/проверки локально +не подтверждают установку: после SSH вывода нужны HTTPS и браузерный smoke пилота. + +### Приемка установленного общего рейтинга — 13 ноября 2026 + +Владелец выполнил tooling `7a8e6c6`: runtime `3c21fbc`, frontend `3241381e`, +apply/status active, БД сохранена, nginx не менялся, dev 200. Backup: +`/opt/procollab-white-label-pilot-v2/backup/overall-d572cb63a116`. +Пройдены 10 HTTPS/API, 11 Chromium и 3 cleanup проверки; проверены все пять +измененных frontend файлов, права, API/admin Excel, старая команда/сдача/PDF, +критерии и dev. Временный ZIP удален через QA owner API (204 → 404). + +Программа сохраняет legacy с единственным текстовым «Комментарий». В UI видна +подсказка настроить числовые критерии с весами 100%, Excel в Angular отключен; +итогов/мест без этапов нет. Настройки и тестовые баллы на пилоте не создавались. +Далее нужны согласованные критерии/веса и настоящий квиз после его дизайна. +Подробная приемка и непроверенное: [spec](../../docs/overall-case-quiz-ranking.md). + +## Сборка полного пакета для нового стенда + +Workflow `Build pilot package` создает artifact `procollab-pilot-candidate` +на GitHub без деплоя/SSH и без секретов. Он берет Angular из публичного +репозитория `PROCOLLAB-github/procollab` на точном commit из +`frontend-revision.txt`, API — из проверяемого checkout. Artifact хранится +7 дней; публичность репозитория не делает демо доступным без его входа. +Это исходники и сборка для подготовки rollout, не команда установки второго +стека на существующий сервер. + +В Angular checkout: + +```sh +NG_CLI_ANALYTICS=false NG_BUILD_MAX_WORKERS=2 ./node_modules/.bin/ng build social_platform --configuration=pilot --output-path=/workspace/work/pilot-frontend +``` + +В API checkout, после tests/review и фиксации файлов в git: + +```sh +python3 deploy/pilot/package.py --api /workspace/api --frontend /workspace/work/pilot-frontend/browser --target /workspace/work/procollab-pilot +``` + +Архив содержит API source, проверенную pilot сборку, конфигурационные шаблоны и +официальные assets. Секреты в архив не входят, создаются на целевом сервере. +Пакет отклоняет обычную сборку, направленную в основной API. + +## Новый сервер: доступ и запуск полного стека + +Нужен SSH-пользователь с Docker и правом проверить/установить отдельный nginx +vhost. Доступ ко всему аккаунту Selectel не обязателен. SSH key и секреты +передаются через настройки среды, а не в чат. + +Распаковать архив в новый каталог, например `/srv/procollab-pilot`. +Перед стартом выполнить read-only проверку: + +```sh +cd /srv/procollab-pilot +python3 preflight.py +python3 configure.py +./pilotctl check +./pilotctl build +./pilotctl start +./pilotctl status +``` + +`configure.py` создает новые Django/DB/демо секреты и вход `pilot` перед просмотром +стенда. Локальные `.env`, `nginx.conf`, `access.htpasswd`, `access.txt` имеют права 0600. Данные входа смотреть в `access.txt` на сервере; не публиковать и не коммитить. +Повторная конфигурация ничего не перезаписывает. Начальные пользователи: +`admin@example.test`, `organizer@example.test`, `expert@example.test`, +`leader@example.test`, `member@example.test`. Общий пароль этих синтетических +аккаунтов также находится в `access.txt`, отдельно от пароля внешнего входа. + +Compose project `procollab-pilot` создает собственные network, PostgreSQL и Redis +volumes. Только gateway слушает loopback `127.0.0.1:18081`. Web/DB/Redis наружу +не публикуются. Файлы, статика и письма находятся в локальном `data`, логи — `logs`. +Загрузка файлов использует отдельный local storage, без общего Selectel bucket. +Сборка образа выполняется отдельно до запуска; `start` не запускает сборку. +При сборке на другом сервере можно перенести образ через `docker save/load`. +Лимиты контейнеров суммарно составляют 1856 MiB и 1.45 CPU; они не гарантируют +достаточность ресурсов соседним приложениям. На занятом dev-сервере этот стек +не устанавливается вместо обновления уже существующего пилота. + +Daphne запускается с `--root-path /backend`, nginx сохраняет полный путь: +это сохраняет префикс в формах и ссылках Django admin. Изменение относится только +к pilot compose, обычная команда API не меняется. + +## Новый сервер: HTTPS и nginx + +Проверить действующий vhost и путь к сертификату для `pilot.procollab.ru`. +`host-nginx.conf.example` — готовый отдельный server block для проксирования +на loopback 18081. Сохранить прежний vhost перед заменой. Если подходящий +сертификат уже есть, использовать его подтвержденный путь; иначе выпустить +сертификат только для pilot, не меняя сертификаты соседних доменов. + +Применение после проверки владельцем сервера: + +```sh +sudo nginx -t +sudo systemctl reload nginx +``` + +Проверить: без внешнего входа и UI, и `/backend/instance/` возвращают 401. +После входа видны «Сознание права», клиентский логотип и favicon. Стандартный +адрес регистрации ведет в программу, Django admin — `/backend/admin/`. +JWT и cookie внешнего входа независимы. Cookies пилота не заменяют основную сессию. + +## Почта и реальные участники + +По умолчанию письма сохраняются в `data/mail`, наружу не отправляются. Signup +разрешает только `@example.test`; это подготовка демо, не прием реальных заявок. +Ссылка подтверждения берется из сохраненного письма владельцем стенда. + +Сервис PROCOLLAB можно подключить после проверки действующего подтвержденного +отправителя: `PILOT_EMAIL_DELIVERY=unisender`, `EMAIL_USER` и ключ Unisender в +секретах `.env`. Перед реальным мероприятием нужны отдельная настройка режима +реальных участников, согласованные документы/доступ к данным и проверка доставки. +Не копировать общий `.env` dev/prod в пилот. + +## Остановка и откат + +```sh +./pilotctl stop +``` + +Команда останавливает только procollab-pilot, не удаляет volumes или данные. +Для отката маршрутизации вернуть сохраненный vhost pilot, выполнить `nginx -t` +и reload. Удаление БД/volumes требует отдельного решения владельца. diff --git a/deploy/pilot/brand/favicon.svg b/deploy/pilot/brand/favicon.svg new file mode 100644 index 00000000..5aa4188f --- /dev/null +++ b/deploy/pilot/brand/favicon.svg @@ -0,0 +1 @@ + diff --git a/deploy/pilot/brand/logo.png b/deploy/pilot/brand/logo.png new file mode 100644 index 00000000..03b53d15 Binary files /dev/null and b/deploy/pilot/brand/logo.png differ diff --git a/deploy/pilot/brand/source.json b/deploy/pilot/brand/source.json new file mode 100644 index 00000000..fcafd87c --- /dev/null +++ b/deploy/pilot/brand/source.json @@ -0,0 +1,19 @@ +{ + "retrieved_at": "2026-11-10", + "site": "https://сознаниеправа.рф/", + "name": "Сознание права", + "logo.png": { + "source": "https://static.tildacdn.com/tild6161-3031-4834-b834-326236393535/__logo.png", + "bytes": 10770, + "sha256": "0313170f21fdcfd4506d47e371a4c0e4eee36100bfbc5009f108b0584866ec29", + "content_type": "image/png" + }, + "favicon.svg": { + "source": "https://static.tildacdn.com/tild6232-6630-4330-a538-353265646464/favicon.svg", + "bytes": 34291, + "sha256": "2459bf8a6a8bbb07422e350f26f02d5e84047f821b0af27f02572083685f1644", + "content_type": "image/svg+xml", + "stored_sha256": "40ad75eab7f2279ef6fdc31dd08a05ec8b49806efb4b827608f71c69bf4af6a1", + "normalization": "XML whitespace at end of file normalized; visual content unchanged" + } +} diff --git a/deploy/pilot/compose.yml b/deploy/pilot/compose.yml new file mode 100644 index 00000000..5f55a122 --- /dev/null +++ b/deploy/pilot/compose.yml @@ -0,0 +1,92 @@ +name: procollab-pilot + +services: + db: + image: postgres:15-alpine + restart: unless-stopped + mem_limit: 384m + cpus: 0.25 + environment: + POSTGRES_DB: ${DATABASE_NAME} + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + volumes: + - pilot-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + mem_limit: 128m + cpus: 0.10 + volumes: + - pilot-redis:/data + + web: + build: + context: ./api + image: procollab-pilot-api:local + command: ["daphne", "-b", "0.0.0.0", "-p", "8000", "--root-path", "/backend", "procollab.asgi:application"] + restart: unless-stopped + mem_limit: 768m + cpus: 0.75 + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: procollab.settings_pilot + DEBUG: "False" + DATABASE_HOST: db + DATABASE_PORT: "5432" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + volumes: + - ./data:/procollab/pilot-data + - ./logs:/procollab/log + + worker: + image: procollab-pilot-api:local + restart: unless-stopped + mem_limit: 512m + cpus: 0.25 + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: procollab.settings_pilot + DEBUG: "False" + DATABASE_HOST: db + DATABASE_PORT: "5432" + command: ["celery", "-A", "procollab", "worker", "--loglevel=INFO", "--concurrency=1"] + depends_on: + - web + volumes: + - ./data:/procollab/pilot-data + - ./logs:/procollab/log + + gateway: + image: nginx:1.28-alpine + restart: unless-stopped + mem_limit: 64m + cpus: 0.10 + command: + - sh + - -c + - cp /run/pilot.htpasswd /etc/nginx/pilot.htpasswd && chown nginx:nginx /etc/nginx/pilot.htpasswd && chmod 400 /etc/nginx/pilot.htpasswd && exec nginx -g 'daemon off;' + ports: + - "127.0.0.1:18081:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./access.htpasswd:/run/pilot.htpasswd:ro + - ./frontend:/srv/pilot/frontend:ro + - ./brand:/srv/pilot/brand:ro + - ./data:/srv/pilot/data:ro + depends_on: + - web + +volumes: + pilot-db: + pilot-redis: diff --git a/deploy/pilot/configure.py b/deploy/pilot/configure.py new file mode 100644 index 00000000..dc12f399 --- /dev/null +++ b/deploy/pilot/configure.py @@ -0,0 +1,109 @@ +"""Создать новые локальные настройки пилота. Ничего не меняет в nginx хоста.""" + +import argparse +import sys +import os +from pathlib import Path +import secrets +import shutil +import subprocess +from urllib.parse import urlsplit + + +def configure(root: Path, origin: str = "https://pilot.procollab.ru") -> None: + parsed = urlsplit(origin) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + raise ValueError("Нужен HTTPS origin без пути и учетных данных.") + root.mkdir(parents=True, exist_ok=True) + names = (".env", "nginx.conf", "access.htpasswd", "access.txt") + if any((root / name).exists() for name in names): + raise FileExistsError( + "Настройки уже существуют; повторная подготовка их не перезаписывает." + ) + if not shutil.which("openssl"): + raise RuntimeError("Для пароля демо нужен openssl.") + gate_password = secrets.token_urlsafe(24) + account_password = secrets.token_urlsafe(24) + hashed = subprocess.run( + ["openssl", "passwd", "-6", "-stdin"], + input=gate_password + "\n", + text=True, + capture_output=True, + check=True, + ).stdout.strip() + gate_token = secrets.token_hex(32) + template = (root / "nginx.conf.template").read_text() + env = { + "DJANGO_SECRET_KEY": secrets.token_urlsafe(48), + "DATABASE_NAME": "procollab_white_label_pilot", + "DATABASE_USER": "procollab_pilot", + "DATABASE_PASSWORD": secrets.token_urlsafe(32), + "WHITE_LABEL_ENABLED": "True", + "WHITE_LABEL_PROGRAM_ID": "1", + "WHITE_LABEL_BRAND_NAME": "Сознание права", + "WHITE_LABEL_FRONTEND_URL": origin, + "WHITE_LABEL_API_HOST": parsed.hostname, + "WHITE_LABEL_LOGO_URL": origin + "/brand/logo.png", + "WHITE_LABEL_FAVICON_URL": origin + "/brand/favicon.svg", + "WHITE_LABEL_LOGO_BACKGROUND": "dark", + "EXPERT_WORKSPACE_FRONTEND_URL": origin, + "PILOT_ACCOUNT_PASSWORD": account_password, + "PILOT_EMAIL_DELIVERY": "file", + "EMAIL_USER": "pilot@example.test", + "AUTOPOSTING_ON": "False", + } + files = { + ".env": "".join(f"{key}={value}\n" for key, value in env.items()), + "nginx.conf": template.replace("@@GATE_TOKEN@@", gate_token), + "access.htpasswd": "pilot:" + hashed + "\n", + "access.txt": ( + "Только для владельца стенда. Не публиковать и не добавлять в git.\n" + f"Демо: {origin}\nВнешний вход: pilot\nПароль входа: {gate_password}\n" + f"Пароль синтетических аккаунтов example.test: {account_password}\n" + "Аккаунты: admin, organizer, expert, leader, member @example.test\n" + "Админка: /backend/admin/\nПисьма сохраняются в data/mail, наружу не отправляются.\n" + ), + } + created = [] + try: + for name, contents in files.items(): + path = root / name + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + created.append(path) + with os.fdopen(fd, "w") as stream: + stream.write(contents) + except Exception: + for path in created: + path.unlink(missing_ok=True) + raise + # nginx читает только публичные файлы. Не зависим от umask владельца + # архива и не открываем каталог с письмами и логами. + for folder, mode in ( + ("data", 0o711), + ("data/media", 0o755), + ("data/static", 0o755), + ("data/mail", 0o700), + ("logs", 0o700), + ): + path = root / folder + path.mkdir(parents=True, exist_ok=True) + path.chmod(mode) + sys.stdout.write( + "Настройки созданы. Данные входа — в локальном access.txt с правами 0600.\n" + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--origin", default="https://pilot.procollab.ru") + args = parser.parse_args() + configure(args.root, args.origin) diff --git a/deploy/pilot/confirm_qa.py b/deploy/pilot/confirm_qa.py new file mode 100644 index 00000000..7d624f99 --- /dev/null +++ b/deploy/pilot/confirm_qa.py @@ -0,0 +1,222 @@ +"""Подтвердить только два новых QA-аккаунта через письма локального пилота.""" + +import argparse +import base64 +from email import policy +from email.parser import BytesParser +from email.utils import getaddresses +import html +from http.cookiejar import CookieJar +import json +import os +from pathlib import Path +import re +import socket +import subprocess +import sys +from urllib.error import HTTPError +from urllib.parse import parse_qs, urlsplit +from urllib.request import HTTPCookieProcessor, HTTPRedirectHandler, Request, build_opener + + +ROOT = Path("/opt/procollab-white-label-pilot-v2") +ORIGIN = "https://pilot.procollab.ru" +WEB = "procollab-white-label-pilot-v2-web" +QA_EMAIL = re.compile(r"qa-\d{13}-[a-f0-9]{6}-(?:leader|member)@example\.test") + + +class Refused(RuntimeError): + pass + + +def require(value, message): + if not value: + raise Refused(message) + + +def validate_emails(emails): + require( + 1 <= len(emails) <= 2 + and len(set(emails)) == len(emails) + and all(QA_EMAIL.fullmatch(value) for value in emails), + "Укажите один или два адреса текущего QA-прогона с доменом example.test.", + ) + runs = {email.rsplit("-", 1)[0] for email in emails} + require(len(runs) == 1, "Аккаунты относятся к разным QA-прогонам.") + + +def confirmation_from_mail(directory, recipient): + for path in sorted( + directory.glob("*.log"), key=lambda item: item.stat().st_mtime, reverse=True + ): + require(not path.is_symlink(), "Ссылка в каталоге тестовых писем запрещена.") + if path.stat().st_size > 2 * 2**20: + continue + message = BytesParser(policy=policy.default).parsebytes(path.read_bytes()) + targets = { + address.lower() for _, address in getaddresses(message.get_all("To", [])) + } + if recipient not in targets: + continue + for part in message.walk(): + if part.get_content_type() not in ("text/plain", "text/html"): + continue + body = html.unescape(part.get_content()) + for url in re.findall(r"https://[^\s<>\"']+", body): + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.netloc != "pilot.procollab.ru" + or parsed.path != "/program-registration/1" + ): + continue + token = parse_qs(parsed.fragment).get("confirmation", []) + if len(token) == 1: + return token[0] + raise Refused("Не найдено локальное письмо подтверждения для " + recipient) + + +class ScopedRedirect(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, newurl): + require( + urlsplit(newurl).scheme == "https" + and urlsplit(newurl).netloc == "pilot.procollab.ru", + "Перенаправление за пределы пилота запрещено.", + ) + return super().redirect_request(request, fp, code, message, headers, newurl) + + +def request(opener, path, *, gate, data=None, access=None): + require(path.startswith("/backend/") and "?" not in path, "Неожиданный путь API.") + headers = {"Content-Type": "application/json"} + headers["Authorization"] = ( + "Bearer " + access + if access + else "Basic " + base64.b64encode(("pilot:" + gate).encode()).decode() + ) + body = None if data is None else json.dumps(data).encode() + try: + response = opener.open( + Request(ORIGIN + path, data=body, headers=headers), timeout=30 + ) + except HTTPError as error: + response = error + with response: + return response.status, json.loads(response.read(2 * 2**20)) + + +def confirm(emails): + validate_emails(emails) + require( + os.geteuid() == 0 and socket.gethostname() == "procollab-dev", + "Только root на известном procollab-dev.", + ) + require(ROOT.resolve() == ROOT, "Неожиданный путь пилота.") + state = json.loads((ROOT / "state.json").read_text()) + require( + state.get("identity") == "hosted-v2" and state.get("phase") == "active", + "Нужен активный hosted-v2.", + ) + environment = dict( + line.split("=", 1) + for line in (ROOT / ".env").read_text().splitlines() + if "=" in line + ) + require( + environment.get("DATABASE_NAME") == "procollab_white_label_pilot_v2" + and environment.get("PILOT_EMAIL_DELIVERY") == "file", + "Нужна отдельная QA-БД и файловая почта.", + ) + docker_environment = os.environ.copy() + for key in ( + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + ): + docker_environment.pop(key, None) + result = subprocess.run( + ["docker", "--host=unix:///var/run/docker.sock", "container", "inspect", WEB], + capture_output=True, + text=True, + check=True, + env=docker_environment, + ) + container = json.loads(result.stdout)[0] + require( + (container["Config"].get("Labels") or {}).get("io.procollab.pilot.instance") + == "hosted-v2", + "Чужой контейнер.", + ) + require(container["State"]["Running"], "Пилот не запущен.") + values = dict( + value.split("=", 1) + for value in container["Config"].get("Env", []) + if "=" in value + ) + require( + values.get("DATABASE_NAME") == environment["DATABASE_NAME"], + "Контейнер использует другую БД.", + ) + gate = (ROOT / "access.txt").read_text().split("Пароль входа: ", 1)[1].splitlines()[0] + require(len(gate) >= 16, "Не найден пароль закрытого входа.") + mail = ROOT / "data/mail" + require(mail.resolve() == mail, "Неожиданный каталог писем.") + # Сначала проверяем письма для всех адресов, затем меняем состояние аккаунтов. + tokens = {email: confirmation_from_mail(mail, email) for email in emails} + opener = build_opener(ScopedRedirect(), HTTPCookieProcessor(CookieJar())) + code, instance = request(opener, "/backend/instance/", gate=gate) + require( + code == 200 + and instance.get("enabled") + and instance.get("demo_mode") + and instance.get("program_id") == 1, + "Это не демонстрационный экземпляр программы 1.", + ) + checks = [] + for email in emails: + code, result = request( + opener, + "/backend/programs/1/registration/confirm/", + gate=gate, + data={"token": tokens[email]}, + ) + if code == 409 and result.get("code") == "confirmation_used": + code, result = request( + opener, + "/backend/api/token/", + gate=gate, + data={"email": email, "password": environment["PILOT_ACCOUNT_PASSWORD"]}, + ) + require( + code == 200 and result.get("access"), + "Подтверждение QA-аккаунта не прошло: HTTP " + str(code), + ) + code, registration = request( + opener, + "/backend/programs/1/registration/", + gate=gate, + access=result["access"], + ) + require( + code == 200 + and registration.get("authenticated") + and registration.get("enrolled"), + "Автоматическое участие в программе не подтверждено.", + ) + checks.append({"email": email, "confirmation_http": 200, "enrolled": True}) + return {"program_id": 1, "checks": checks, "external_email_sent": False} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--email", action="append", required=True) + arguments = parser.parse_args() + try: + sys.stdout.write( + json.dumps(confirm(arguments.email), ensure_ascii=False, indent=2) + "\n" + ) + except (Refused, OSError, ValueError, subprocess.SubprocessError) as error: + sys.stderr.write("ОСТАНОВЛЕНО: " + str(error) + "\n") + raise SystemExit(1) diff --git a/deploy/pilot/frontend-revision.txt b/deploy/pilot/frontend-revision.txt new file mode 100644 index 00000000..26c9f33e --- /dev/null +++ b/deploy/pilot/frontend-revision.txt @@ -0,0 +1 @@ +3241381ea2d2ba9fc9bc5139df543c7a0730814c diff --git a/deploy/pilot/host-nginx.conf.example b/deploy/pilot/host-nginx.conf.example new file mode 100644 index 00000000..01b3c7eb --- /dev/null +++ b/deploy/pilot/host-nginx.conf.example @@ -0,0 +1,29 @@ +# Установить только после проверки текущего vhost pilot.procollab.ru. +# Путь к уже действующему сертификату нужно подтвердить на сервере. +server { + listen 80; + server_name pilot.procollab.ru; + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl; + server_name pilot.procollab.ru; + ssl_certificate /etc/letsencrypt/live/pilot.procollab.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pilot.procollab.ru/privkey.pem; + client_max_body_size 100m; + location / { + proxy_pass http://127.0.0.1:18081; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 600s; + } +} diff --git a/deploy/pilot/hosted-maps.conf.template b/deploy/pilot/hosted-maps.conf.template new file mode 100644 index 00000000..e4fb6e58 --- /dev/null +++ b/deploy/pilot/hosted-maps.conf.template @@ -0,0 +1,12 @@ +map $cookie_procollab_pilot_gate $ppv2_realm { + default "Pilot"; + "@@TOKEN@@" off; +} +map $status $ppv2_gate_cookie { + default ""; + ~^2[0-9][0-9]$ "procollab_pilot_gate=@@TOKEN@@; Path=/; Max-Age=14400; Secure; HttpOnly; SameSite=Strict"; +} +map $http_authorization $ppv2_authorization { + default ""; + "~^Bearer " $http_authorization; +} diff --git a/deploy/pilot/hosted-routes.inc.template b/deploy/pilot/hosted-routes.inc.template new file mode 100644 index 00000000..8cb7b66f --- /dev/null +++ b/deploy/pilot/hosted-routes.inc.template @@ -0,0 +1,40 @@ +root @@ROOT@@/frontend; +index index.html; +server_tokens off; +client_max_body_size 100m; +auth_basic $ppv2_realm; +auth_basic_user_file @@ROOT@@/access.htpasswd; +add_header Set-Cookie $ppv2_gate_cookie; +add_header X-Robots-Tag "noindex, nofollow" always; +add_header X-Content-Type-Options nosniff always; +add_header Cache-Control "private, no-store" always; + +location = /admin { return 302 /backend/admin/; } +location = /admin/ { return 302 /backend/admin/; } +location /backend/media/ { + alias @@ROOT@@/data/media/; + add_header Content-Disposition attachment; + add_header X-Content-Type-Options nosniff always; +} +location /backend/static/ { alias @@ROOT@@/data/static/; } +location /backend/ { + proxy_pass http://127.0.0.1:18319; + proxy_set_header Daphne-Root-Path /backend; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization $ppv2_authorization; + proxy_read_timeout 120s; +} +location /ws { + proxy_pass http://127.0.0.1:18319; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $ppv2_authorization; + proxy_read_timeout 600s; +} +location /brand/ { alias @@ROOT@@/brand/; } +location / { try_files $uri $uri/ /index.html; } diff --git a/deploy/pilot/hosted.py b/deploy/pilot/hosted.py new file mode 100644 index 00000000..6b47de52 --- /dev/null +++ b/deploy/pilot/hosted.py @@ -0,0 +1,938 @@ +"""Этапы обновления только известного пилота. Старые DB/compose/web не изменяются.""" + +import argparse +import base64 +from contextlib import contextmanager +import fcntl +import grp +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import re +import secrets +import shlex +import shutil +import socket +import subprocess +import sys +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +sys.dont_write_bytecode = True +from runtime_import import ( # noqa: E402 + Refused, + RuntimeArchive, + docker, + docker_json, + existing_image, + import_image, +) + +OLD = Path("/opt/procollab-white-label-pilot") +NEW = Path("/opt/procollab-white-label-pilot-v2") +PROJECT = "procollab-white-label-pilot" +OLD_WEB = PROJECT + "-web-1" +DB = PROJECT + "-db-1" +WEB = "procollab-white-label-pilot-v2-web" +PG = "procollab-white-label-pilot-v2-db" +NETWORK = "procollab-white-label-pilot-v2" +PG_IMAGE = ( + "postgres@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984" +) +DB_NAME = "procollab_white_label_pilot_v2" +DB_ROLE = "procollab_pilot_v2" +ROUTES = OLD / "nginx/routes.inc" +MAPS = Path("/etc/nginx/sites-enabled/00-procollab-pilot-v2-maps.conf") +ORIGIN = "https://pilot.procollab.ru" +LABEL = "io.procollab.pilot.instance" +IDENTITY = "hosted-v2" +HERE = Path(__file__).resolve().parent + + +def require(condition, reason): + if not condition: + raise Refused(reason) + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def private_write(path, contents): + path = Path(path) + # sites-enabled/* не должен подхватить временный конфиг при чужом reload. + temporary = path.with_name("." + path.name + ".tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "w") as stream: + stream.write(contents) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def state_read(): + require(NEW.resolve() == NEW, "Каталог v2 является ссылкой.") + value = json.loads((NEW / "state.json").read_text()) + require( + value.get("identity") == IDENTITY, "Каталог v2 не принадлежит этому установщику." + ) + return value + + +def state_write(state): + private_write(NEW / "state.json", json.dumps(state, ensure_ascii=False, indent=2)) + + +def environment(): + return dict( + line.split("=", 1) + for line in (NEW / ".env").read_text().splitlines() + if "=" in line + ) + + +def owned_path(path): + path = Path(path) + require( + not path.is_symlink() and path.resolve().is_relative_to(NEW), + "Путь v2 ведет за пределы нового стенда.", + ) + return path + + +def inspect(name): + return docker_json("container", "inspect", name)[0] + + +def candidate(): + result = docker("container", "inspect", WEB, capture_output=True, text=True) + if result.returncode: + return None + container = json.loads(result.stdout)[0] + require( + (container["Config"].get("Labels") or {}).get(LABEL) == IDENTITY, + "Имя v2 занято чужим контейнером.", + ) + ports = container["HostConfig"].get("PortBindings") or {} + require( + ports == {"8000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18319"}]}, + "Кандидат использует неожиданный порт.", + ) + require( + set(container["NetworkSettings"]["Networks"]) == {NETWORK}, + "Кандидат подключен к другой сети.", + ) + values = dict(v.split("=", 1) for v in container["Config"].get("Env", []) if "=" in v) + require( + values.get("DATABASE_NAME") == DB_NAME and values.get("DATABASE_USER") == DB_ROLE, + "Кандидат использует другую DB/роль.", + ) + require( + values.get("DJANGO_SETTINGS_MODULE") == "procollab.settings_pilot_inline", + "Кандидат использует другой профиль.", + ) + return container + + +def http_includes(config): + """Только активные include непосредственно внутри http, без комментариев.""" + lexer = shlex.shlex(config, posix=True, punctuation_chars="{};") + lexer.whitespace_split = True + lexer.commenters = "#" + blocks, statement, result = [], [], [] + for raw in lexer: + tokens = list(raw) if raw and all(c in "{};" for c in raw) else [raw] + for token in tokens: + if token == "{": + blocks.append(statement[0] if statement else "") + statement = [] + elif token == "}": + require(blocks, "Неверный контекст nginx.") + blocks.pop() + statement = [] + elif token == ";": + if ( + blocks == ["http"] + and len(statement) == 2 + and statement[0] == "include" + ): + result.append(statement[1]) + statement = [] + else: + statement.append(token) + require(not blocks, "Незакрытый блок nginx.") + return result + + +def validate_maps_include(config): + require( + any( + pattern in http_includes(config) + for pattern in ( + "/etc/nginx/sites-enabled/*", + "/etc/nginx/sites-enabled/*.conf", + ) + ), + "Нет HTTP include sites-enabled для файла maps; основной nginx не изменяется.", + ) + + +def nginx_group(config): + user = re.search(r"(?m)^\s*user\s+(\S+?)(?:\s+(\S+?))?;", config) + if user: + return ( + grp.getgrnam(user.group(2)).gr_gid + if user.group(2) + else pwd.getpwnam(user.group(1)).pw_gid + ) + # При отсутствии user используются параметры сборки nginx, не догадка о группе. + compiled = subprocess.run(["nginx", "-V"], capture_output=True, text=True, check=True) + flags = shlex.split(compiled.stdout + "\n" + compiled.stderr) + defaults = dict( + value.split("=", 1) + for value in flags + if value.startswith(("--user=", "--group=")) + ) + require("--user" in defaults, "Не удалось определить пользователя nginx.") + return ( + grp.getgrnam(defaults["--group"]).gr_gid + if "--group" in defaults + else pwd.getpwnam(defaults["--user"]).pw_gid + ) + + +def scope(): + require( + os.geteuid() == 0 and socket.gethostname() == "procollab-dev", + "Только root на известном procollab-dev.", + ) + require( + OLD.resolve() == OLD and ROUTES.resolve().is_relative_to(OLD), + "Неожиданный путь старого пилота.", + ) + for name in (OLD_WEB, DB): + container = inspect(name) + labels = container["Config"].get("Labels") or {} + require( + labels.get("com.docker.compose.project") == PROJECT, + "Контейнер относится к другому проекту.", + ) + require( + labels.get("com.docker.compose.project.working_dir") == str(OLD), + "Неожиданный compose directory.", + ) + require( + container["State"]["Running"], "Старый пилот/DB должен оставаться работающим." + ) + web = inspect(OLD_WEB) + bindings = web["NetworkSettings"]["Ports"].get("8000/tcp") or [] + require( + {(b["HostIp"], b["HostPort"]) for b in bindings} == {("127.0.0.1", "18318")}, + "Неожиданный порт старого пилота.", + ) + db = inspect(DB) + names = [] + for name in db["NetworkSettings"]["Networks"]: + network = docker_json("network", "inspect", name)[0] + if (network.get("Labels") or {}).get("com.docker.compose.project") == PROJECT: + members = {v["Name"] for v in network.get("Containers", {}).values()} + require( + members <= {OLD_WEB, DB}, + "В сети пилота есть посторонние контейнеры.", + ) + names.append(name) + require(len(names) == 1, "Нужна одна отдельная сеть пилота.") + result = subprocess.run(["nginx", "-T"], capture_output=True, text=True, check=True) + config = result.stdout + if not MAPS.exists(): + require("$ppv2_" not in config, "Префикс переменных nginx v2 уже используется.") + validate_maps_include(Path("/etc/nginx/nginx.conf").read_text()) + domains = [] + for match in re.finditer(r"(?m)^\s*server\s*\{", config): + level, end = 1, match.end() + while level and end < len(config): + level += (config[end] == "{") - (config[end] == "}") + end += 1 + start = match.start() + block = config[start:end] + if "include " + str(ROUTES) + ";" in block: + found = re.search(r"server_name\s+([^;]+);", block) + require( + found and found.group(1).split() == ["pilot.procollab.ru"], + "routes.inc используется другим доменом.", + ) + domains.append(found.group(1)) + require(domains, "Не найдены vhost пилота.") + return {"old_network": names[0], "nginx_gid": nginx_group(config)} + + +def health(url, *, headers=None, body=None): + request = Request(url, data=body, headers=headers or {}) + try: + with urlopen(request, timeout=30) as response: + return response.status, dict(response.headers), response.read(2 * 2**20) + except HTTPError as error: + return error.code, dict(error.headers), error.read(2048) + + +def dev_health(): + # Защищаем основной dev; сравнительный React не является зависимостью пилота. + for name in ( + "api-web-1", + "api-redis-1", + "api_celery", + ): + container = inspect(name) + require( + container["State"]["Running"], + "Существующий dev-контейнер не работает: " + name, + ) + require( + container["State"].get("Health", {}).get("Status") != "unhealthy", + "Существующий dev-контейнер unhealthy: " + name, + ) + code, _, _ = health("https://dev.procollab.ru/") + require( + code == 200, "Dev не прошел предварительную HTTP-проверку; изменения остановлены." + ) + return code + + +def readiness(runtime): + target = scope() + info = docker_json("info", "--format", "{{json .}}") + require(info["Driver"] == "overlay2", "Неожиданный Docker storage driver.") + architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get( + info["Architecture"], info["Architecture"] + ) + require( + architecture == runtime.info["architecture"], + "Архитектура runtime не подходит серверу.", + ) + require( + shutil.which("openssl"), + "Нужен установленный openssl; системные пакеты автоматически не меняются.", + ) + current = candidate() + running = bool(current and current["State"]["Running"]) + if not current: + with socket.socket() as probe: + try: + probe.bind(("127.0.0.1", 18319)) + except OSError: + raise Refused("Порт 18319 занят другим процессом.") + memory = dict( + line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() + ) + available = int(memory["MemAvailable"].split()[0]) * 1024 + pg_check = docker("container", "inspect", PG, capture_output=True, text=True) + pg_running = False + if pg_check.returncode == 0: + pg = json.loads(pg_check.stdout)[0] + require( + (pg["Config"].get("Labels") or {}).get(LABEL) == IDENTITY, + "Имя PostgreSQL v2 занято.", + ) + pg_running = pg["State"]["Running"] + required_memory = 768 + (0 if running else 768) + (0 if pg_running else 256) + require( + available >= required_memory * 2**20, + "Недостаточно RAM с запасом для dev.", + ) + present = existing_image(runtime) + required = ( + 2 * 2**30 + 512 * 2**20 + (0 if present else runtime.info["image_size_bytes"]) + ) + free = shutil.disk_usage(info["DockerRootDir"]).free + require(free >= required, "Недостаточно диска: сохраняется минимум 2 GiB для dev.") + + target.update( + { + "free_disk_gib": round(free / 2**30, 2), + "available_ram_mib": round(available / 2**20), + "dev_http": dev_health(), + "changes_applied": False, + "new_containers": [WEB, PG], + "new_network": NETWORK, + "new_database": DB_NAME, + "limits_mib": {"web": 768, "postgres": 256}, + } + ) + return target + + +def verify_bundle(bundle): + bundle = Path(bundle).resolve() + manifest = json.loads((bundle / "manifest.json").read_text()) + require( + re.fullmatch(r"[a-f0-9]{40}", manifest.get("api_revision", "")), + "Нет revision пакета.", + ) + for relative, expected in manifest["files"].items(): + path = bundle / relative + require( + path.resolve().is_relative_to(bundle) and not path.is_symlink(), + "Небезопасный путь пакета.", + ) + require(digest(path) == expected, "Checksum пакета не совпадает: " + relative) + for path in bundle.rglob("*"): + require(not path.is_symlink(), "Ссылка в пакете запрещена.") + if path.is_file() and path.name != "manifest.json": + require( + str(path.relative_to(bundle)) in manifest["files"], + "Лишний файл в пакете: " + str(path.relative_to(bundle)), + ) + require( + "white-label-pilot-v1" in (bundle / "frontend/index.html").read_text(), + "Это не pilot frontend.", + ) + return manifest + + +def verify_compatibility(bundle, runtime): + manifest = verify_bundle(bundle) + require( + manifest["api_revision"] == runtime.info["api_revision"], + "Source и runtime взяты из разных сборок. Нужны artifacts одного commit.", + ) + return manifest + + +def database_sql(env): + require( + env["DATABASE_NAME"] == DB_NAME and env["DATABASE_USER"] == DB_ROLE, + "Неверная новая DB/роль.", + ) + password = env["DATABASE_PASSWORD"].replace("'", "''") + return ( + f'CREATE ROLE "{DB_ROLE}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE ' + f"NOREPLICATION PASSWORD '{password}';\n" + f'ALTER DATABASE "{DB_NAME}" OWNER TO "{DB_ROLE}";\n' + f'REVOKE ALL ON DATABASE "{DB_NAME}" FROM PUBLIC;\n' + f'ALTER SCHEMA public OWNER TO "{DB_ROLE}";\n' + ) + + +def prepare_database(): + result = docker("network", "inspect", NETWORK, capture_output=True, text=True) + if result.returncode: + require( + docker( + "network", + "create", + "--label", + LABEL + "=" + IDENTITY, + NETWORK, + capture_output=True, + ).returncode + == 0, + "Новая сеть не создана.", + ) + else: + network = json.loads(result.stdout)[0] + require( + (network.get("Labels") or {}).get(LABEL) == IDENTITY, "Имя новой сети занято." + ) + require( + {c["Name"] for c in network.get("Containers", {}).values()} + <= {WEB, PG, WEB + "-prepare"}, + "В новой сети есть посторонние контейнеры.", + ) + private = owned_path(NEW / "pg-private") + private.mkdir(mode=0o700, exist_ok=True) + data = owned_path(private / "data") + data.mkdir(mode=0o700, exist_ok=True) + if not (private / "postgres.env").exists(): + private_write( + private / "postgres.env", + f"POSTGRES_USER=pilot_owner\nPOSTGRES_DB={DB_NAME}\nPOSTGRES_PASSWORD={secrets.token_urlsafe(32)}\n", + ) + private_write(private / "init.sql", database_sql(environment())) + os.chown(private / "init.sql", 0, 70) + (private / "init.sql").chmod(0o640) + result = docker("container", "inspect", PG, capture_output=True, text=True) + if result.returncode: + require( + docker("pull", PG_IMAGE, capture_output=True, timeout=180).returncode == 0, + "PostgreSQL image не получен.", + ) + result = docker( + "run", + "-d", + "--name", + PG, + "--restart", + "unless-stopped", + "--network", + NETWORK, + "--network-alias", + "db", + "--memory", + "256m", + "--memory-swap", + "256m", + "--cpus", + "0.25", + "--pids-limit", + "128", + "--oom-score-adj", + "500", + "--label", + LABEL + "=" + IDENTITY, + "--env-file", + str(private / "postgres.env"), + "--mount", + f"type=bind,src={data},dst=/var/lib/postgresql/data", + "--mount", + f"type=bind,src={private/'init.sql'},dst=/docker-entrypoint-initdb.d/10-pilot.sql,readonly", + "--log-driver", + "json-file", + "--log-opt", + "max-size=10m", + "--log-opt", + "max-file=3", + PG_IMAGE, + "postgres", + "-c", + "shared_buffers=64MB", + "-c", + "max_connections=40", + "-c", + "work_mem=1MB", + capture_output=True, + ) + require(result.returncode == 0, "Новый PostgreSQL не запущен.") + else: + pg = json.loads(result.stdout)[0] + require( + (pg["Config"].get("Labels") or {}).get(LABEL) == IDENTITY, + "Имя нового PostgreSQL занято.", + ) + require( + pg["Config"]["Image"] == PG_IMAGE, "PostgreSQL v2 использует другой образ." + ) + require( + not pg["HostConfig"].get("PortBindings"), + "У PostgreSQL v2 опубликованы порты.", + ) + if not pg["State"]["Running"]: + require( + docker("start", PG, capture_output=True).returncode == 0, + "PostgreSQL v2 не запущен.", + ) + for _ in range(45): + check = docker( + "exec", + PG, + "pg_isready", + "-h", + "127.0.0.1", + "-U", + "pilot_owner", + "-d", + DB_NAME, + capture_output=True, + ) + if check.returncode == 0: + return NETWORK + time.sleep(1) + raise Refused( + "Новый PostgreSQL не прошел readiness; старый пилот продолжает работать." + ) + + +def backup(): + target = NEW / "backup" + target.mkdir(mode=0o700, exist_ok=True) + for source, name in ((ROUTES, "routes.inc"), (OLD / "compose.yml", "compose.yml")): + if not (target / name).exists(): + private_write(target / name, source.read_text()) + dump = target / "white_label_pilot.dump" + if not dump.exists(): + temporary = target / "database.tmp" + command = ( + 'export PGPASSWORD="${POSTGRES_PASSWORD:-}"; ' + 'exec pg_dump -U "${POSTGRES_USER:-postgres}" -Fc -d white_label_pilot' + ) + with temporary.open("wb") as stream: + result = docker( + "exec", + DB, + "sh", + "-ec", + command, + stdout=stream, + stderr=subprocess.PIPE, + timeout=180, + ) + require( + result.returncode == 0 and temporary.stat().st_size > 0, + "Резервная копия DB не создана.", + ) + os.replace(temporary, dump) + with dump.open("rb") as stream: + check = docker( + "exec", + "-i", + DB, + "pg_restore", + "--list", + stdin=stream, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=60, + ) + require(check.returncode == 0, "Архив DB не прошел pg_restore --list.") + + +def container_args(image, network): + return [ + "--network", + network, + "--memory", + "768m", + "--memory-swap", + "768m", + "--cpus", + "0.5", + "--pids-limit", + "256", + "--oom-score-adj", + "500", + "--label", + LABEL + "=" + IDENTITY, + "--env-file", + str(NEW / ".env"), + "--mount", + f"type=bind,src={NEW / 'data'},dst=/procollab/pilot-data", + "--mount", + f"type=bind,src={NEW / 'logs'},dst=/procollab/log", + "--log-driver", + "json-file", + "--log-opt", + "max-size=10m", + "--log-opt", + "max-file=3", + image, + ] + + +def candidate_health(): + headers = {"Host": "pilot.procollab.ru", "X-Forwarded-Proto": "https"} + code, _, body = health("http://127.0.0.1:18319/backend/instance/", headers=headers) + require( + code == 200 and json.loads(body).get("enabled") is True, + "Новая версия не прошла instance smoke.", + ) + code, _, body = health( + "http://127.0.0.1:18319/backend/programs/1/registration/", headers=headers + ) + require( + code == 200 and json.loads(body).get("account_mode") == "email_password", + "Новая регистрация не прошла smoke.", + ) + env = environment() + headers["Content-Type"] = "application/json" + payload = json.dumps( + {"email": "leader@example.test", "password": env["PILOT_ACCOUNT_PASSWORD"]} + ).encode() + code, _, _ = health( + "http://127.0.0.1:18319/backend/api/token/", headers=headers, body=payload + ) + require(code == 200, "Вход синтетического участника не прошел smoke.") + + +def stage(bundle, runtime): + manifest = verify_compatibility(bundle, runtime) + target = readiness(runtime) + runtime.verify() + if NEW.exists(): + require(not NEW.is_symlink(), "Каталог v2 является ссылкой.") + state = state_read() + require( + state["release"] == manifest["api_revision"] + and state["rootfs_sha256"] == runtime.info["archive_sha256"], + "В v2 уже другая сборка; перезапись запрещена.", + ) + require( + state["phase"] != "active", "v2 уже опубликован; stage не перезаписывает его." + ) + else: + require(not MAPS.exists(), "Конфигурация maps v2 уже существует без состояния.") + NEW.mkdir(mode=0o700) + state = { + "identity": IDENTITY, + "release": manifest["api_revision"], + "rootfs_sha256": runtime.info["archive_sha256"], + "gate_token": secrets.token_hex(16), + "original_routes_sha256": digest(ROUTES), + "phase": "initialized", + } + state_write(state) + require( + all(not path.is_symlink() for path in NEW.rglob("*")), + "В каталоге v2 обнаружена ссылка; подготовка остановлена.", + ) + for relative in ( + "data", + "data/media", + "data/static", + "data/mail", + "logs", + "backup", + "frontend", + "brand", + "pg-private", + "pg-private/data", + ".env", + "access.htpasswd", + ): + owned_path(NEW / relative) + if not (NEW / ".env").exists(): + shutil.copy2(Path(bundle) / "nginx.conf.template", NEW / "nginx.conf.template") + spec = importlib.util.spec_from_file_location( + "pilot_configure", HERE / "configure.py" + ) + config = importlib.util.module_from_spec(spec) + spec.loader.exec_module(config) + config.configure(NEW, ORIGIN) + env = environment() + env.update( + DATABASE_NAME=DB_NAME, + DATABASE_USER=DB_ROLE, + DATABASE_HOST="db", + DATABASE_PORT="5432", + DJANGO_SETTINGS_MODULE="procollab.settings_pilot_inline", + DEBUG="False", + ) + private_write( + NEW / ".env", "".join(f"{key}={value}\n" for key, value in env.items()) + ) + NEW.chmod(0o711) + for name in ("frontend", "brand"): + shutil.copytree(Path(bundle) / name, NEW / name, dirs_exist_ok=True) + for path in [NEW / name, *(NEW / name).rglob("*")]: + path.chmod(0o755 if path.is_dir() else 0o644) + os.chown(NEW / "access.htpasswd", 0, target["nginx_gid"]) + (NEW / "access.htpasswd").chmod(0o640) + backup() + image_id = import_image(runtime, log=NEW / "logs/runtime-import.log") + network = prepare_database() + if state["phase"] not in ("staged", "rolled_back"): + command = ( + "python manage.py check && python manage.py migrate --noinput && " + "python manage.py collectstatic --noinput && " + "python manage.py prepare_white_label_pilot" + ) + with (NEW / "logs/prepare.log").open("ab") as log: + result = docker( + "run", + "--rm", + "--name", + WEB + "-prepare", + *container_args(image_id, network), + "sh", + "-ec", + command, + stdout=log, + stderr=log, + timeout=900, + ) + require( + result.returncode == 0, + "Подготовка новой DB не завершилась; см. logs/prepare.log. Старый пилот работает.", + ) + current = candidate() + if current: + require(current["Image"] == image_id, "Кандидат использует другой образ.") + if not current["State"]["Running"]: + require( + docker("start", WEB, capture_output=True).returncode == 0, + "Не удалось запустить кандидата.", + ) + else: + result = docker( + "run", + "-d", + "--name", + WEB, + "--restart", + "unless-stopped", + "-p", + "127.0.0.1:18319:8000", + *container_args(image_id, network), + "daphne", + "-b", + "0.0.0.0", + "-p", + "8000", + "--root-path", + "/backend", + "procollab.asgi:application", + capture_output=True, + ) + require(result.returncode == 0, "Кандидат не запущен. Старый пилот работает.") + for attempt in range(20): + try: + candidate_health() + break + except Exception: + if attempt == 19: + raise Refused("Кандидат не прошел smoke; домен не переключался.") + time.sleep(1) + state.update(phase="staged", image_id=image_id) + state_write(state) + dev_health() + return { + "phase": "staged", + "candidate_port": 18319, + "new_database": DB_NAME, + "old_database": "white_label_pilot", + "domain_switched": False, + "backup": str(NEW / "backup"), + } + + +def rollback_files(state): + require( + digest(ROUTES) in (state["new_routes_sha256"], state["original_routes_sha256"]), + "routes.inc изменен снаружи; автоматическая перезапись запрещена.", + ) + if MAPS.exists(): + require(digest(MAPS) == state["new_maps_sha256"], "Файл maps изменен снаружи.") + if digest(ROUTES) != state["original_routes_sha256"]: + private_write(ROUTES, (NEW / "backup/routes.inc").read_text()) + if MAPS.exists(): + MAPS.unlink() + subprocess.run(["nginx", "-t"], check=True, capture_output=True) + subprocess.run(["nginx", "-s", "reload"], check=True, capture_output=True) + state["phase"] = "rolled_back" + state_write(state) + + +def switch(): + scope() + state = state_read() + require(state["phase"] in ("staged", "rolled_back"), "Сначала нужен успешный stage.") + require( + digest(ROUTES) == state["original_routes_sha256"] and not MAPS.exists(), + "Nginx пилота изменился после stage; переключение остановлено.", + ) + dev_health() + current = candidate() + require( + current and current["State"]["Running"] and current["Image"] == state["image_id"], + "Кандидат изменился после stage.", + ) + candidate_health() + maps = ( + (HERE / "hosted-maps.conf.template") + .read_text() + .replace("@@TOKEN@@", state["gate_token"]) + ) + routes = ( + (HERE / "hosted-routes.inc.template").read_text().replace("@@ROOT@@", str(NEW)) + ) + state.update( + new_maps_sha256=hashlib.sha256(maps.encode()).hexdigest(), + new_routes_sha256=hashlib.sha256(routes.encode()).hexdigest(), + ) + state_write(state) + try: + private_write(MAPS, maps) + private_write(ROUTES, routes) + subprocess.run(["nginx", "-t"], check=True, capture_output=True) + subprocess.run(["nginx", "-s", "reload"], check=True, capture_output=True) + time.sleep(1) + code, _, _ = health(ORIGIN + "/") + require(code == 401, "Домен не прошел проверку закрытого входа.") + password = ( + (NEW / "access.txt").read_text().split("Пароль входа: ", 1)[1].splitlines()[0] + ) + headers = { + "Authorization": "Basic " + + base64.b64encode(("pilot:" + password).encode()).decode() + } + code, _, body = health(ORIGIN + "/backend/instance/", headers=headers) + require( + code == 200 and json.loads(body).get("brand_name") == "Сознание права", + "Публичный instance smoke не прошел.", + ) + dev_health() + except BaseException: + rollback_files(state) + raise Refused( + "Переключение не прошло проверку; маршруты старого пилота восстановлены." + ) + state["phase"] = "active" + state_write(state) + return { + "phase": "active", + "origin": ORIGIN, + "dev_http": 200, + "old_web_preserved": True, + "credentials": str(NEW / "access.txt"), + } + + +@contextmanager +def locked(): + with Path("/run/lock/procollab-pilot-v2.lock").open("w") as stream: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + + +if __name__ == "__main__": + os.umask(0o077) + parser = argparse.ArgumentParser() + parser.add_argument( + "command", choices=("check", "stage", "switch", "rollback", "status") + ) + parser.add_argument("--bundle", type=Path) + parser.add_argument("--runtime", type=Path) + args = parser.parse_args() + try: + if args.command in ("check", "stage"): + require( + args.bundle is not None and args.runtime is not None, + "Укажите --bundle и --runtime.", + ) + runtime = RuntimeArchive(args.runtime) + if args.command == "check": + verify_compatibility(args.bundle, runtime) + result = readiness(runtime) + runtime.verify() + else: + with locked(): + result = stage(args.bundle, runtime) + elif args.command == "switch": + with locked(): + result = switch() + elif args.command == "rollback": + with locked(): + scope() + state = state_read() + require(state["phase"] == "active", "Нет активного переключения v2.") + rollback_files(state) + result = {"phase": "rolled_back", "dev_http": dev_health()} + else: + scope() + state = state_read() + result = { + "phase": state["phase"], + "new_database": DB_NAME, + "candidate_running": bool( + candidate() and candidate()["State"]["Running"] + ), + } + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") + except (Refused, subprocess.SubprocessError, OSError, ValueError) as error: + sys.stderr.write("ОСТАНОВЛЕНО: " + str(error) + "\n") + raise SystemExit(1) diff --git a/deploy/pilot/inspect_existing.py b/deploy/pilot/inspect_existing.py new file mode 100644 index 00000000..a9bdae07 --- /dev/null +++ b/deploy/pilot/inspect_existing.py @@ -0,0 +1,146 @@ +"""Read-only обследование существующего пилота. Секреты и исходники не выводятся.""" + +import ast +import hashlib +import io +import json +from pathlib import Path +import re +import subprocess +import tarfile +from urllib.request import urlopen + +BASE_REVISION = "b4575a0770c0515a8099cd3920416a7cbcf953bf" +ROOT = Path("/opt/procollab-white-label-pilot") +WEB = "procollab-white-label-pilot-web-1" + + +def docker(*args): + return subprocess.check_output( + ["docker", "--host=unix:///var/run/docker.sock", *args], text=True, timeout=30 + ) + + +def safe_routes(path): + result = [] + for line in path.read_text().splitlines(): + if re.match( + r"\s*(location|root|alias|proxy_pass|auth_basic|auth_basic_user_file|try_files|rewrite)\b", + line, + ): + value = line.split("{", 1)[0].split(";", 1)[0].strip() + result.append(re.sub(r"(://)[^/\s]*@", r"\1<скрыто>@", value)) + return result + + +def main(): + backend = ROOT / "backend" + if not (backend / "pilot_hosted_settings.py").is_file(): + raise SystemExit("Ожидался существующий pilot_hosted_settings.py. Изменений нет.") + profile = ast.parse((backend / "pilot_hosted_settings.py").read_text()) + report = { + "backend": str(backend), + "reference_api": BASE_REVISION, + "profile_imports": [], + "profile_definitions": [], + "profile_assignments": [], + "root_entries": sorted(p.name for p in ROOT.iterdir()), + } + for node in profile.body: + if isinstance(node, ast.ImportFrom): + report["profile_imports"].append(node.module) + elif isinstance(node, ast.Import): + report["profile_imports"].extend(alias.name for alias in node.names) + elif isinstance(node, (ast.FunctionDef, ast.ClassDef)): + report["profile_definitions"].append(node.name) + elif isinstance(node, ast.Assign): + report["profile_assignments"].extend(ast.unparse(t) for t in node.targets) + + # Читаем неизменяемую публичную reference revision в памяти, не распаковываем. + url = f"https://codeload.github.com/PROCOLLAB-github/api/tar.gz/{BASE_REVISION}" + with urlopen(url, timeout=30) as response: + source = response.read(64 * 1024 * 1024 + 1) + if len(source) > 64 * 1024 * 1024: + raise SystemExit("Reference archive слишком велик. Изменений нет.") + changed, missing, migrations = [], [], [] + with tarfile.open(fileobj=io.BytesIO(source), mode="r:gz") as archive: + for member in archive: + relative = member.name.partition("/")[2] + if not member.isfile() or not relative: + continue + parts = Path(relative).parts + if ( + len(parts) == 3 + and parts[1] == "migrations" + and parts[2].endswith(".py") + and parts[2] != "__init__.py" + ): + migrations.append((parts[0], parts[2][:-3])) + if not ( + relative.endswith(".py") or relative in ("pyproject.toml", "poetry.lock") + ): + continue + if ( + "/tests/" in relative + or relative.startswith(("scripts/", "deploy/")) + or "/migrations/" in relative + ): + continue + current = backend / relative + if not current.is_file(): + missing.append(relative) + elif ( + hashlib.sha256(current.read_bytes()).digest() + != hashlib.sha256(archive.extractfile(member).read()).digest() + ): + changed.append(relative) + report["reference_missing_count"] = len(missing) + report["reference_changed_count"] = len(changed) + report["reference_missing"] = missing[:40] + report["reference_changed"] = changed[:40] + report["routes"] = safe_routes(ROOT / "nginx/routes.inc") + + probe = """ +import json +import django +django.setup() +from django.apps import apps +from django.db import connection +from django.db.migrations.recorder import MigrationRecorder +from django.contrib.auth import get_user_model +result = {"applied": list(MigrationRecorder.Migration.objects.values_list("app", "name"))} +users = get_user_model().objects +result["user_count"] = users.count() +result["non_synthetic_user_count"] = users.exclude(email__iendswith="@example.test").count() +try: + model = apps.get_model("partner_programs", "ProgramRegistration") + result["registration_versions"] = list(model.objects.values("program_id", "published_version")) +except Exception: + result["registration_versions"] = "not_available" +with connection.cursor() as cursor: + cursor.execute("SELECT pg_database_size(current_database())") + result["database_size_bytes"] = cursor.fetchone()[0] +print(json.dumps(result)) +""" + database = json.loads(docker("exec", WEB, "python", "-c", probe)) + applied = {tuple(pair) for pair in database.pop("applied")} + outstanding = sorted(set(migrations) - applied) + database["reference_unapplied_count"] = len(outstanding) + database["reference_unapplied_migrations"] = outstanding[:30] + report["database"] = database + info = json.loads(docker("info", "--format", "{{json .}}")) + report["docker_storage_driver"] = info["Driver"] + report["image_size_bytes"] = int( + docker( + "image", + "inspect", + "--format", + "{{.Size}}", + "procollab-white-label-pilot-base:20261109", + ) + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/deploy/pilot/nginx.conf.template b/deploy/pilot/nginx.conf.template new file mode 100644 index 00000000..b1874c8e --- /dev/null +++ b/deploy/pilot/nginx.conf.template @@ -0,0 +1,67 @@ +# Этот файл обслуживает только отдельный compose project procollab-pilot. +map_hash_bucket_size 128; +map $cookie_procollab_pilot_gate $pilot_realm { + default "Pilot"; + "@@GATE_TOKEN@@" off; +} + +# Автоматический redirect /backend -> /backend/ возможен до auth_basic. +# Допуск выдаем только после успешного ответа защищенного content handler. +map $status $pilot_gate_cookie { + default ""; + ~^2[0-9][0-9]$ "procollab_pilot_gate=@@GATE_TOKEN@@; Path=/; Max-Age=14400; Secure; HttpOnly; SameSite=Strict"; +} + +# Пароль внешнего демо-входа не передается в Django BasicAuthentication. +map $http_authorization $pilot_api_authorization { + default ""; + "~^Bearer " $http_authorization; +} + +server { + listen 80; + server_name _; + root /srv/pilot/frontend; + index index.html; + server_tokens off; + client_max_body_size 100m; + auth_basic $pilot_realm; + auth_basic_user_file /etc/nginx/pilot.htpasswd; + add_header Set-Cookie $pilot_gate_cookie; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header X-Content-Type-Options nosniff always; + + location /backend/media/ { + alias /srv/pilot/data/media/; + add_header Content-Disposition attachment; + add_header X-Content-Type-Options nosniff always; + } + location /backend/static/ { + alias /srv/pilot/data/static/; + } + location /backend/ { + # ASGI получает полный path; Daphne/Django выделяют root_path /backend. + proxy_pass http://web:8000; + proxy_set_header Daphne-Root-Path /backend; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization $pilot_api_authorization; + proxy_read_timeout 120s; + } + location /ws { + proxy_pass http://web:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_read_timeout 600s; + } + location /brand/ { + alias /srv/pilot/brand/; + } + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/pilot/overall_base.json b/deploy/pilot/overall_base.json new file mode 100644 index 00000000..91c8edc4 --- /dev/null +++ b/deploy/pilot/overall_base.json @@ -0,0 +1,1741 @@ +{ + "base_backend_revision": "76570824e596ba06b196171f6cf8be38e5a452d1", + "base_frontend_revision": "75f41a5d9a008fb97418eb50724b3b99fc8349b7", + "backend_revision": "3c21fbc3094e9be77756b51bfe04aea0c5a8f84b", + "frontend_revision": "3241381ea2d2ba9fc9bc5139df543c7a0730814c", + "runtime_hashes": { + "chats/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chats/admin.py": "0c0bc9842622bfd9bdd520e97cf6b00919e7ea3e1882de6e467f9793b5d9b2fa", + "chats/apps.py": "55aabb371802d3839ca3cd424d23f8639450c1f693b7dccc17d0947ba887447c", + "chats/consumers/__init__.py": "3fef090fea84ba00d715ca6d9c0bf334ff6c35e2d6281d05bdf008fcdd7c3ee1", + "chats/consumers/chat.py": "c3dd5096e48dea613751ef46187d7ff30e0c8f482501cdaa940e3c73cbdd4761", + "chats/consumers/event_types/DirectEvent.py": "e6cd4ae8d5486d94bb276ce8d329e6ccd6263fe9a3883950fba3f82c4abf3299", + "chats/consumers/event_types/ProjectEvent.py": "6acb168ef0f5ac7c7e447ec20e2382d74901bc80bf5c05d2e2507df31340bbe2", + "chats/consumers/event_types/__init__.py": "43e83e5f8fc8a0236db17acfb4baa258875830300d76e433dc2130828cee05e2", + "chats/consumers/notification.py": "f0f085a89f20ed7f1347956859ee60c86a19aaf60060cc84ee2a106303fb6c8f", + "chats/exceptions.py": "cc72863489da06b9f2fab5ca820837e50e882259583f122b16abcd4da6a29701", + "chats/managers.py": "f8fdda3a0307ec05b931baac43952c3a4f3c0b32c8eb4a1273033633ea835cb5", + "chats/migrations/0001_initial.py": "266786c4b2f2e1624ae5d7b620a86cc2fd106b7d8d6ceeeede6e40762b9d2a3f", + "chats/migrations/0002_directchatmessage_is_deleted_and_more.py": "865bfc7e4fca97c859250363cb3bf5e0450b28a91d925ccf998df6f775ecaa81", + "chats/migrations/0003_reply_to_constaint.py": "48b8903aa34fa82d36478544d298a40f2d8181b961b98ee498d3750cba2190d7", + "chats/migrations/0004_directchatmessage_file_url_and_more.py": "7b0533167c8e1dcbd86193534f0e51ad7d10ed05d44063315b1eef20188ccf2a", + "chats/migrations/0004_directchatmessage_is_edited_and_more.py": "06537dded3c6c8ed60fe24b7715a569af323bd8d9c3571fc2b5cd3738de52bf8", + "chats/migrations/0005_chatattachment.py": "2a443da8cf84ce4c6b6a6f5c49c9af9b522ff9d72bea19a5a4209173242a8111", + "chats/migrations/0006_merge_20230323_0027.py": "b8f2b373fa15763ade4391f858901852e92c6970b2f3b4d9acd0b3033dd6af89", + "chats/migrations/0007_remove_directchatmessage_file_url_and_more.py": "629ae3ab855a66dd91b44246bd72b46a4ae97f866ceb924e1300adbf2672d2f1", + "chats/migrations/0008_remove_directchatmessage_file_url_and_more.py": "03ede9a502a2665c05ddc95d167dbdeb75052c6f899a7cfec2570d34ce88936b", + "chats/migrations/0009_filetomessage.py": "b6350f322538547ffabd55c04fd38437bc597e1144b521db0dad723c6d758566", + "chats/migrations/0010_alter_filetomessage_file.py": "973af5ec9cbf38516c9d6f7799bc69bf17ebefee472c159f6ec083d3552041ac", + "chats/migrations/0011_alter_filetomessage_direct_message_and_more.py": "fcf14e842302596ed0517ffa961fb297b202f0e0517ab394d32cf24370203527", + "chats/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chats/models.py": "8da1b8bc346ca9d1cb9f51069f81fd391652656992084fa9461e0ebb22e2af88", + "chats/pagination.py": "98c0ffc58e5776684ddfa5d3d80ca628a5ba0b978ca0b15bf3e2fafc379d515e", + "chats/permissions.py": "c5c82e27b26eaadf411170ea7a30371dc4de46a4e02436d75ce2583028ff6793", + "chats/routing.py": "e3b7d2ee650d6110426c127fb1b4e2c8b71806cf0cfe9bfd0aa34ffeaa6d78ec", + "chats/serializers.py": "99393a5e9f80efe33242c489c9a504ec5ca9fcf735e0502e60ad8fda6a0f1c5c", + "chats/urls.py": "b94828429b65b66241adfbe4043174ce26c4eaaef635a1c0495b91d5a219f0c4", + "chats/utils.py": "8d43b710159eed24074e8421d9c4e4425d2f6efb5b6557b4ad1e059c26ef3f5a", + "chats/views.py": "446f94a30e7bd1e743b9d939001371263eb8b028c0e18a923d00cc3a52da2d19", + "chats/websockets_settings.py": "3bd2b88ad308032babaa615ab1476f346e24ad9822f1c6dc872d2660a76af26a", + "core/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "core/admin.py": "b5fa2048d8d6760e2e473ec28634b0765c7fe335a312bb35f867f9a7a5070b80", + "core/admin_language.py": "214dfd7d341698d234699f2af52035342efa523cdcd521b629c31d3724eda783", + "core/apps.py": "98173fdcfe801f35fc342c26346dabef55a5e76dfd1922a0eca68995d6f244d8", + "core/auth/__init__.py": "d22fb4055b527b53db3d6afdfec1ba9943b36f76f2e982c4340e77fe4d1ac6ca", + "core/auth/middleware.py": "8b31d4b539035e95c8df27fc34c2a26921fac4f5b4846b4463a0f6b7c30c9fab", + "core/constants.py": "bb2a42dc44d36c72f62c7f6161cd8eb6e7964a3649a85687fcedd540517fbf83", + "core/fields.py": "836800ef84ca56d782401e7a28ac32533a98390062ca9a1080f1d3a3490e2d3a", + "core/filters.py": "037b783de2b720c99901aa7e1c753985cf3b5558f295678a5b07cd38079df0ed", + "core/log/middleware.py": "7e1c432dcd71fb9351a70ae70e81ff069caac64aff1e2725099e9bbece54c003", + "core/log/utils.py": "b35cd78d1e154cf97a3ea3fa951dc576803644edaa5d4043a9a3d575434d29c9", + "core/migrations/0001_initial.py": "26a41e90d86d0a4b06afc51ba24503d89cf3438e30afa2462f7a2d7df492a984", + "core/migrations/0002_view.py": "b49a31ffe8b53c28f970aec7a14ecb584b76773016264b5816ce81c29bca3532", + "core/migrations/0003_link.py": "0165230cf2af7cae943a206841119c4d2c98103c4488b608a8a0cf9ab9c4a23b", + "core/migrations/0004_alter_link_unique_together.py": "e382f02c516c528fb5be71b25390f4da0691ee8c4f68ad0f0dcc0fc087c6bcae", + "core/migrations/0005_skill_userskilltag_skilltoobject.py": "fad304b81b493b5b7297a861b7101f53914650b2e6d7c94b193616bfb9b8bbcf", + "core/migrations/0006_auto_20231216_1518.py": "713f15861b0472741056c8e944d22bce17d5de76035eccb3826f2e2816ecc4ad", + "core/migrations/0007_specializationcategory_specialization.py": "d6b8c71da244306f3a5568083315649403f98489c0a8e099fa1a1e7d1dcf9360", + "core/migrations/0008_auto_20231223_1331.py": "9b9eef6d6467bd61b0125e1ee7d6b87f90ef584c3b7154adc31baafe9dacd3ea", + "core/migrations/0009_skillcategory_specializationtoobject_and_more.py": "72a3ed5307f0ba27c4e0c1e47ec98a493930732db9442b0e9ebd874ad2d51882", + "core/migrations/0010_alter_skill_options_alter_skillcategory_options_and_more.py": "75316a1effaf6d9c98c0e9b196e2e94fd01c7735cf666ce44ee91946fad3a163", + "core/migrations/0011_auto_20240128_2214.py": "86ecd1b7d3fb9b38a868ea23f617a2991d6d323efbcaeff89c222c5263d798c7", + "core/migrations/0012_alter_specialization_options_and_more.py": "c44b6e8fa98a01193883e27c296fe06e0e18d58b4187cf4e1e5d33efda716905", + "core/migrations/0013_add_skills_from_dataset.py": "cdba38a9687878599b8292db2a5b575e3e32b8c2425c09be07f303e4958f4cce", + "core/migrations/0014_migrate_key_skills_to_skills.py": "b02f10fdad993d7ba993aa2d7548109f88dd138f007f513870a01b4b1d137ba4", + "core/migrations/0015_alter_skill_options_alter_skilltoobject_options.py": "fd71c46f4df355e71a9d313939521169a90c7e9baff61ecb1ae2f9c8adae7440", + "core/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "core/models.py": "b132304291655687ff83862f7238525a6638409225e00c87668f3abad24424a1", + "core/notifications.py": "6afbeef737ba6fd7f743fb90fa28847a599bce44c0c60c7c8e56ea686d6de666", + "core/pagination.py": "cd674d8e47a614183ba96f0546b36c7aae88ca193ad2ee37a5b65f27463adf9f", + "core/permissions.py": "09361f2e3fec2c5f60bc4fd0e2a8bebe43d166384f18148f71377d0b7ac2f4a5", + "core/serializers.py": "bde13f22c5d771bcf025ee6fb02ba435692d3fb36ed9be1f6c155a9e88074189", + "core/services.py": "8a5340aa5dfaf29d54a4bc60a5ab127e2a71271a3215ffac3b98f71d91cb91fb", + "core/urls.py": "0aa977d2af448d02b10fb3896a8ad193b149ef1e08ff631d197bd1f74d02698f", + "core/utils.py": "3b0922f56aa852f76db3a493751943cae89639bbd59cd55b3d01426f2a916ef2", + "core/views.py": "0d2a4d20f7c8d617b3697537da20619589b889c23f976b5a0ee164acedbf66f4", + "courses/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "courses/admin.py": "cc1f7cf77984a96aa425e1ed08c78602c6323cd49f5bdfec560e922f7acd6f40", + "courses/admin_config/__init__.py": "167c2c9f16b04fd763811ceda1b00f0ee16f50ddf785199401249fc2d715fa57", + "courses/admin_config/answers.py": "d92b9e8fc7ecfcdf1edc5a21e19e22cd24114b05a96c02588eaf975dce0b0175", + "courses/admin_config/content.py": "90a14bcdc0e7c64234ea0787939e9581696eebe1c6460c0c1f76252a7c1831ae", + "courses/admin_config/forms.py": "131161bba281ca22a0f5f1947c04b0a4da5c0c31eadcbf35e1d97c6eb7e05031", + "courses/admin_config/helpers.py": "12b36589759e415e99558df09e93db9ceb9fccf40065c3fd6944e701da326bab", + "courses/admin_config/inlines.py": "aaabaf1af91a765fc756828cbde35e9fef4f6a44b9827d4464ccf80b6cee0d8b", + "courses/admin_config/progress.py": "82fecc28d63d7ef4a1ee31cedf501dfb7688db25c36a7fb74d111eec002419f0", + "courses/admin_config/site.py": "94fc8c428403f452475ca0871d03a2bc9a49392eaabe696f9a28ad0cb3ad5b46", + "courses/api/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "courses/api/response.py": "ecf3669fa5720e6e014e006648168490aa29d5a0ac4d7b5e26fc8cd05865e2c9", + "courses/api/serializers/__init__.py": "b3f5d7c64d98433ddbe06b2804378998126f1986f2329da00240bc7c8183b90e", + "courses/api/serializers/common.py": "2af2df0c61604814f074dcb412a5f6ba2824166a7b8de2eefd92ef4e230e5ee8", + "courses/api/serializers/course.py": "e297e31210bbcd3797928ed7f8286f7cb112432b6d35078d57ae696b7dbc34e3", + "courses/api/serializers/lesson.py": "784be3792b48312986cb1db3b1ff545e36f45ea0cce0e5fcd75b4cc4dbb983c6", + "courses/api/serializers/structure.py": "1c0ff5d42b9742b8530a969cc10588780f559918994fa93dab04e7f6543bc215", + "courses/api/serializers/task_answer.py": "a93f101a7ecb0df345b961c27c94e8becae9dfc0696f0a2025c5d45fce534eb1", + "courses/api/views/__init__.py": "b03af6e5959b56e37f1e9cda8d8ff8f5b0ac71c03345f3871f2075a96da60743", + "courses/api/views/base.py": "693a3c2f6c7201ff3ae2150a6cbd2f9292d4279a28f1c96e92c12e5b0d1c3697", + "courses/api/views/course_read.py": "79b3b2cfa074a20318d051cb2739608979bdec10e90c8f64fcf0965a8a96c80c", + "courses/api/views/lesson_read.py": "e83fff66d0a17c8a44c6ec0f844598fc00fc119bfc537ded2a252fb5922dd755", + "courses/api/views/task_actions.py": "b3852582fc402c09e50e2cddec2fbccccbdcc4246ac390569c1c1cd8d1ea618e", + "courses/api/views/visit.py": "ded9e51360f551762ee8859c8259ba532dee402633d0416c6c6b24f68ef5e12d", + "courses/apps.py": "fcac72fd92aad209f12026ff0d8a2836596f38a49b5f36872586b13fa652f0c6", + "courses/migrations/0001_initial.py": "db8869464782520cfefada4ca053fb1b39707ed25a990e483452cbeac458b538", + "courses/migrations/0002_course_courses_program_members_requires_program_and_more.py": "bbe0760a88e33191943027654d73dbb2f704065cdd08562570765d909cd0ad8b", + "courses/migrations/0003_coursemodule_and_more.py": "8d0599d7abac3d71ca8299c6b488d6211e211de74e937adb363838f715cdba00", + "courses/migrations/0004_courselesson_and_more.py": "2d94431fdb7c6743a1be170c06be99980c0a1beab495774f837acd63666e2290", + "courses/migrations/0005_coursetask_coursetaskoption_usertaskanswer_and_more.py": "993e125267b6219ee78031a46e5dff288294c45bab609d8b919c4557f87f3ce9", + "courses/migrations/0006_usercourseprogress_userlessonprogress_and_more.py": "40db48044065badd3bc5c8084d48110e861c055f358128b9593a80a732ee424c", + "courses/migrations/0007_coursetask_answer_title.py": "a665144a8df807f99e944086e11b4dd2f4fbe50ce48ab84ce73e2c4369dd6f48", + "courses/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "courses/models/__init__.py": "86c8a1458be3fe56ccc060b0f48fe945e92dba21c451b1ac989c535e0e90988b", + "courses/models/answers.py": "01db61908a006327bd09068fe98f05195194ccc3f29fe6fd54ee33e1e62e0f4f", + "courses/models/choices.py": "f0edcf46911c45c64b0462d73f4aea1bb8a5fb1b608f526df95f129c6f2549b9", + "courses/models/constants.py": "b624212bbd967bcb6ca9a3da03bea0d981ae7ed40879a865b8ac4f858d0175f7", + "courses/models/content.py": "e438cc5ea2344376035245aa247b60bb73fe27802c664ffad2105f72afac00c1", + "courses/models/course.py": "96d496e6dd531600214d646e526113b2222f243089d945dcce9bd8db67c6cee7", + "courses/models/file_validation.py": "bc48bb350d42453e6e723868081c8f2f4e676377e0182e3e9d6fa4594fef0c9e", + "courses/models/progress.py": "91161bbf228f113a5b36b5efc1f345daf9ba459c1ca1811ee0568dd5f9050913", + "courses/queries/__init__.py": "4bc92587725a34889aa570f25e7b44125d959e3c22ecf1cb5baa63c0c049b160", + "courses/queries/course_detail.py": "f8457df7d462f642264deee83779a31a52394242b21df6d4754ecf9aa99637c7", + "courses/queries/course_list.py": "d55b916d46d3a3430205966f9f934eec343e3368e27407dda4265da279e25743", + "courses/queries/lesson_detail.py": "aca0089fca9a0e8cd9b0c13190359e806746a3630b47b0ce7bf37f6def895eeb", + "courses/queries/structure.py": "d8b3dca09d287cc843e0a3a102290886dafef22003c12df0dcaefd7fbef529a6", + "courses/serializers.py": "0f5f1d144a0eefd2e556cde68d660d5b55c71edd2843fbfd9e362b941102232f", + "courses/services/__init__.py": "5d6d9129811c8b39e8db114e8bedc86550ad1a67e6e647659d8cf4b442e19c96", + "courses/services/access.py": "de55ad1eb9f301039b57e49b863e556f228f59f29cd0c11be093dc34e94e1232", + "courses/services/answers.py": "7dd8eb43a1f70bc1f4346f263bf9503fbfc7518b466f2e25aa4b5005645126b3", + "courses/services/export_course_results.py": "5fadf4efe031a844fb3446d3f5e39f273b3738faa748ada7667c30beea648fc0", + "courses/services/learning_flow.py": "23b358399b6c1ba8a07243c82e6a3e37c7f6d4f53cf1aebb025ea7dd60cdc045", + "courses/services/progress.py": "bf5157b711e8428e60223257797d5cf393e014cbf74582c3b5b82c2bd7237f4e", + "courses/services/querysets.py": "d04462c8cf4100029a77ea4a72a084098f827679df5c5355c70abea9623a3caf", + "courses/urls.py": "6d0b4616ce100d87d46e8c2ff3f7889f306a14e5dadd4690297afe3b1050a7dd", + "courses/views.py": "01322942a1d9f45fc6fc9b1f3c7a55b7054283f1ea65110e1515a470a477e082", + "events/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "events/admin.py": "a6d8bf8bf968746b274c38f71fced299e8afd349244d03f70dfff21e1d4fa8d4", + "events/apps.py": "9503fe322e415c6c85e3b8be80fd2b6f67a9a896ba7574e02544389a8c1c802e", + "events/constants.py": "7a141e9d460edfafa060471cd5ba3a06f4472c4700bebced46e5944549b50030", + "events/filters.py": "0ff9bac534222119be41ac7df29ee31412fd5fedfcf424d70ef7c1f86b40b993", + "events/helpers.py": "a1d978ff949c4c0272582e2aa3523567b020e2121928a8386f30db5c7348a2a5", + "events/managers.py": "8df7dd9c94ef31b778d779d8d2037b492442898404c6510e9e84b752bf4e9774", + "events/migrations/0001_initial.py": "84821334bad83fa71e8883228febdf2ae408bbf866c94d91a86a8d883f9b616a", + "events/migrations/0002_event_tg_message_id.py": "3e6e4272d6228db3fb5e07cb10d0a326584fd5ae007727c4d3cbd3622d0ec8b7", + "events/migrations/0003_alter_event_tg_message_id.py": "3ca3b92d498f91327275fd480b2d113cfd6d4e51b11729238753f378afbeecd9", + "events/migrations/0004_alter_event_tg_message_id.py": "848eb8729b3ec2e03a7d0e5b2b2454c27408b7f69a23b735695b6437d4a1454f", + "events/migrations/0005_alter_event_tg_message_id.py": "4b6f0945b34c1d5bee93344dbce16e232b4114f026413808d9706c5726f616e2", + "events/migrations/0005_auto_20230323_0032.py": "ffee5104c3e89a538fe127a53e3471985dd03078670e45329a9d09d29d900f4a", + "events/migrations/0006_event_event_type_event_favorites_event_likes_and_more.py": "89093ef4b440b71925838f7d8524c48948abf5148685d38c1eb17b4295a83d1a", + "events/migrations/0007_event_tags.py": "0f1a379d9b76bf5ba944214f3f7a9bae32a7c7553b8d0891d9bc342c9e0990c0", + "events/migrations/0008_merge_0005_alter_event_tg_message_id_0007_event_tags.py": "3622af5115edc36bbcd229d4dcc1af2a2bf20075eabf433a8931d45cca972569", + "events/migrations/0009_alter_event_datetime_created_and_more.py": "5ac9132e141bb6680b757c545b5a5ec758e384fad6c97a2e3d8cca1217fbcb26", + "events/migrations/0010_alter_event_likes_alter_event_short_text_and_more.py": "6693b5a2e9b12af1373b3362564fb44c8705f095938fb11fbf597e4e92498b0b", + "events/migrations/0011_remove_event_likes_likesonevent.py": "1308d0835430dce4f73732168bafb991b5d957f228f3defc37b40b1e99e37f2f", + "events/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "events/models.py": "b1cf381ebce69ea0fc12188aaf292fbfcc58f2b323c624dcc893b59ebcb95293", + "events/serializers.py": "95d0e0ad40e52fe06608f0c7f4194cb6e895276e31aa1ff1a6a4d32c2ce4696e", + "events/signals.py": "b9ad504ec69cad2ffe5c33b64d71330cb50acbceff514afc1de072f7e634db58", + "events/urls.py": "89606374e62c975bbe892a443c1ff260c105daf78dde055614396aeeba307fd2", + "events/views.py": "35d3cc33e592bf1c7a3d332750ce1dc0f8acc825338dd85b7e139fb08df55628", + "feed/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "feed/apps.py": "4263ec779ed0d869acaa66db084bc829a4a870cf70b646d16086aeb0769bc93d", + "feed/constants.py": "93e1ef78f9d156a4238c9c99b75ace78d9b61ff2f98b9066561ee527e097791a", + "feed/mapping.py": "2dff16730ece681e4cd75335a581c55d2534aa48324c676746c1418fe483af2d", + "feed/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "feed/pagination.py": "bf900b4fc1cfcaa3191010a4a82dce10d892ce1d43ad70b98622eefa5dd31106", + "feed/serializers.py": "50312a2302a1b36f34ee2ce4630c92cd2af985bbb63cb48905e0aec8927c0003", + "feed/services.py": "bcdcc91a712487c94c7ddfeadb6ee632a1d2a658b17c54f309ba5d5b8514cdcc", + "feed/signals.py": "2a1e23488c2a7be1bf2f0652511700983e433f4e8c97034d194bf6ea937dacb4", + "feed/urls.py": "c0c91c33992cd8d90bd37eb193b2a008182966fcf78803e2a353973013e384b6", + "feed/views.py": "271b93161191c9383975548e79add0734ab52c84f2cb0be415ecbdf087a8ef2d", + "files/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "files/admin.py": "feeafa4987f49718eb87ecb9e1e6bf4fad09494c3338df9eec928e8ee16f5a44", + "files/apps.py": "d07216a71931bdd9d697d783b156640e65808d4efc0016e3fc0cf648b4b9e293", + "files/constants.py": "895a432fed5169254613da1f781c563b324dd5925b4ef3cc22a6889337f4da05", + "files/content.py": "384adcc4eeb786840c44f982adeb37f746b509ccfdb34896cbb60edf457c6665", + "files/exceptions.py": "d85d6ce288405b6fb290050cc64743d9c3bccf8afadc0ec148adf219b3c65542", + "files/helpers.py": "eb9f7cfff2aa519765932b9d365394600ff9b24922c9b2f829b89c24d9174305", + "files/migrations/0001_initial.py": "edda80a7d85f469d6190c79345cdd9dd00b6b0c41c3ad2c86cd65879e7b1a09c", + "files/migrations/0002_remove_userfile_id_alter_userfile_link.py": "9c66fb7f751f418c309234d8c410a6f1cde2b6843227feb88a36268d428eb169", + "files/migrations/0003_userfile_extension_userfile_name_userfile_size.py": "dbe69bc83d0c64a9c3ce677e1057394b3225363ff3062f1b1e0d3d5c4f67240c", + "files/migrations/0004_userfile_mime_type.py": "681ac2172fcb8d77dd71e7f62364d54e7e0e2653cf8ab3eb5ef0cb2813fc1045", + "files/migrations/0005_alter_userfile_options.py": "40631809b06b2f53d8d68a26df4fbb53a5db167d9bb478300c04629aa0f2d434", + "files/migrations/0006_alter_userfile_options.py": "9ec39daa5776d1f517aa7660e47c23fda3735ad17efcc9545c043edafff18491", + "files/migrations/0007_auto_20230929_1727.py": "463d3b80bb74e70c4134f6c37d9db7ea3d8f1beffc4a9f634e78cf97be07f346", + "files/migrations/0008_userfile_detected_mime_type.py": "549b3199ef6383f21ea4b71a4edb787e48dc1dc351891f2007b000ab84808fc4", + "files/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "files/models.py": "31b4ec6686b64198061ceb2dd2c2fbe95258f29b044175f42668601bc5a9d92c", + "files/serializers.py": "a4d10998cf5100dbb40ca5db89bcb0889a95c591cf832d524f59361ec7e17097", + "files/service.py": "affe7aecb19dc40e75e4b0382861fc432dffe1adceb2b6e89253b9774f16bba6", + "files/typings.py": "4093ced4a5c65007f63bdf73dcfd31487f3f092fd576c8a1d0c52d19b752f35e", + "files/urls.py": "34b9527ec61eb3088ba5d24e7c2941733d3405360c7b418514935be7c71dfc77", + "files/views.py": "ea0bf99adc18aac5814d80033f6d0a5ca75a87b639ee4336170210e78ab346c1", + "industries/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "industries/admin.py": "4b3412701d727554658eea7c703808b96f88b5daa1fe0aff0a64a05bc4112ba5", + "industries/apps.py": "2c5cd939be28ab1ec079e356412d23811011bf34da172b7cf7a8202f9a491046", + "industries/migrations/0001_initial.py": "862e8e00bbe5e3c97f45db19863ae2f94d3da9ea24084e2065325c182f2d6b7a", + "industries/migrations/0002_alter_industry_options.py": "ef17a45bb5b9d6a09b6524dc694b4bb0ac34ad3aa242d96640861228b63bd706", + "industries/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "industries/models.py": "3c41087ce93248fc8a3e3160cff1bd734bc062a8e30e777af9f362a3013b7d4a", + "industries/serializers.py": "bd6ef8aa368295b205aa0cc474f3af3346cce2e8e6043f0d4e17675003ed962b", + "industries/urls.py": "7633f297eccc684caeb3b8a57076da2b8af0cc9984f07a745780a0e8fc22d777", + "industries/views.py": "90f919c8088c42a6e2cd45f9da2f4f27d6f90af496462577846900f83395e9a2", + "invites/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "invites/admin.py": "c4bf0ce7f0fdb62aa22048ba83719e9e253b8476db088233d3e838ef4e56387e", + "invites/apps.py": "1c297d706191b154c26513f9c2f447bc1e22b6ca616ebca41f4596383f669758", + "invites/filters.py": "39068fd4cb9c0dd19ed74f8c3dfb41cc7d83aeb57344461c979a8a55044dffe4", + "invites/managers.py": "1058bff289f1d51b2e99fc79dcd47ab52afa77100c11834fc53374a2c8e9be7b", + "invites/migrations/0001_initial.py": "b1982667fb4f941857f00099bd36b453c6ecac74c9a95b80fe8129ea7692d19c", + "invites/migrations/0002_invite_specialization.py": "d493697a0d211fdd5d92e61fe23fa565b47cdc9e678a3cf3622279f6323f094b", + "invites/migrations/0003_legacy_pending_invite_constraint.py": "577d04ae8dae3a76352dcf83e5569047af6bbf35b05dc6e27bc0d1598f377606", + "invites/migrations/0004_legacy_project_team_policy.py": "1401e09d318dcc15841d33913e4b9d0b56e1354c4ffeefec6bd500bb6909d16d", + "invites/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "invites/models.py": "cc1a3c7ad71badc332bf507bb08d778550a55e258e663dabaf33ad9e05fc720b", + "invites/permissions.py": "11d9c951d056ac2f646619174967050a323c10c7a0cfedf23c557c0f76b38b42", + "invites/querysets.py": "e8b1f52e8bdf9b17da500aa474397ccf97bca0779936c2e69c254c53c9a91189", + "invites/serializers.py": "823a113b6469f435ce7730346b24c0c9060a4384ae84d94ad91a6ba6cf908045", + "invites/urls.py": "d7e2590b8d7e76cce91d51705806d9da5c3aa14d1191b198eb3d3f808268560d", + "invites/views.py": "f96c9caf88247409793125e874bd05b2f7e6f2fc10b28bdb0511008cb9ee250f", + "mailing/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "mailing/admin.py": "bccb32485b7a4a647fcc49b45212ee61911098203a976cbd7c5a3b42240fde42", + "mailing/apps.py": "6051a0b63cd1183803f704ce9aacf03b888155a5fcee2b8ef035a7a1c3e446b1", + "mailing/constants.py": "eba6e33be9abfc7dcf6ba600fab55897d4f8924710e719485b02d943f4dd179b", + "mailing/migrations/0001_initial.py": "5c4eef7118bc779e5833f4331580784192fe07690273f51968e4c90e50ece027", + "mailing/migrations/0002_alter_mailingschema_schema.py": "341392685652f7e44f15c98b53eeb554ce99e900f0b3968c86231f6c4efeb72f", + "mailing/migrations/0003_alter_mailingschema_schema.py": "7ec54675f3699411748372cce0c6bdcf8752c2c50e05fc86d53bbb0998617e76", + "mailing/migrations/0004_alter_mailingschema_schema.py": "bbd913cc206b9d93136edb19a169beab5a45c4cabc513826fc1dcb8e7757b82f", + "mailing/migrations/0005_alter_mailingschema_name.py": "2ea78706fd87a82cbf436ece1a687db8077780055a3cce4fe886d837f881329e", + "mailing/migrations/0006_alter_mailingschema_schema_and_more.py": "0bab99e81025747204d7312e0f38c111c80d3dfc6a7e38c54a809a83152c7425", + "mailing/migrations/0007_alter_mailingschema_options_and_more.py": "ec35f654dbec5fdad630892014c81b92fd57f06db43867e9ed9a33aaab1f3ce9", + "mailing/migrations/0008_mailing_scenario_log.py": "6a39154c134f265125f39323b9684827abf3f1a6919cbb4730c2c288f10d38bd", + "mailing/migrations/0009_rename_mailing_ma_scenari_73b1f9_idx_mailing_mai_scenari_eed98a_idx_and_more.py": "c2754a5bcacc5e66509e3ab432b31bd2a08100b3b0dfc1de65c6728c16f7bc8f", + "mailing/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "mailing/models.py": "7562d565d456e8ff9ea8d256de0c616ac74b3cbe06bd538284faa70dd9776fa0", + "mailing/rendering.py": "770078685505d8acf4cc50fb006e4fd01393e15a371bce97a3df26d981f16213", + "mailing/scenarios.py": "be923efb6097f04ac1f3b0d6f26697a65efdc8285bbf6392001dbb14be843bbf", + "mailing/tasks.py": "5fe2e8bbaac16997d3db7006b7124d5fa5a4469b6d6b6ab206952b12e6f384c1", + "mailing/typing.py": "205da847b3b21df3bdb114d8a3b1a83090035017bcc93a4b591a270f1886128b", + "mailing/urls.py": "b0c95e92adb4607415645472f3f3e52a3d861ca2a860dd9ed3091c27fe7bfff6", + "mailing/utils.py": "c1e1483bf4d3ea6cb38d5ee999786fd8f68946c40816557ef8e23cfd06af654b", + "mailing/views.py": "b0d4c5f20deaae96adbeb6f1dba1890cbb30197b667f5135b0ea7c5dea1dd119", + "manage.py": "fd9654cc7f5610e1a87357fc0fe43d69d7266749ccc90281ee8ef1dd937755a0", + "metrics/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "metrics/apps.py": "929975848227a68900788977c80dd05a01568c80bf351c230e1353cbdeb979d4", + "metrics/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "metrics/services.py": "c5add680c412f904a66d594dd8bd723fa71c46c39f96a5a6a6e0961578efcb01", + "metrics/urls.py": "4561da81521ad39fbeaf4e4caee8f8fefd8c7af880028c4b0450a305fef992a5", + "metrics/views.py": "a02bd076131ad637b4d2080c9c26a5188ef5fedd319b3e2887ec5d0a64a7f334", + "news/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "news/admin.py": "43ad80c410a02a0cff4e8d59de81f66d224333961dfcfc8c3bdfe0f256155aa3", + "news/apps.py": "069c91a5d64aad793a86a14ec9b2cd7f3ec430e0b454d81b42cd7a1ac8a5c6f7", + "news/filters.py": "e6b7ed37a28db71dddb38892b6be55cce0c10e38e71b85705ff87ae97f8e3ff7", + "news/managers.py": "61f79c024e75287f6e90202fbd721014c97670eabcba53f2dc57dffbb69f79a5", + "news/mapping.py": "c438adfd557b55c9cbcc4d51a271aa6cce8f54f0ab92761f1874d76569d31b8d", + "news/migrations/0001_initial.py": "5b4e51c6eec0a0a2002708c485402cbecd1583756f70b5f026f6e86dfe10e400", + "news/migrations/0002_newstag_news_tags.py": "dca945c67fb26d2bba474d890775640b32b711bd5d92c8ad8b088e80194fbd3b", + "news/migrations/0003_alter_news_tags_alter_newstag_description.py": "6cf25b04f6e5cae5e2542214e88e2ccaad12c65c69af17e20f499929c85b7ed3", + "news/migrations/0004_alter_news_tags.py": "10d73010670e6f8e7b49ce8753821d4223213565e4db293d0510d34e19298a0e", + "news/migrations/0005_news_content_type_news_object_id.py": "9db7c8bf570172b32fb11cdd6b1516762dc87b6bee2d21f29158a10e282b2436", + "news/migrations/0006_alter_news_options_remove_news_cover_url_and_more.py": "e6356764a4183220150b949a48655fd2002ee202e83ae5476387f9603c310c1a", + "news/migrations/0007_project_news_2_news_news.py": "14dd28117c4d6a1cd173f974328406352d56b23f3f7506c4a9b958f446398ba6", + "news/migrations/0008_alter_news_datetime_created.py": "9cc2d4bc651bcc49d36d3eafb2bf4776840ecab2706c034998b0f3acfd20a4f6", + "news/migrations/0009_news_pin.py": "e4af2cb7e2f9e4f64692955a31466abf890b2f43807bc529ddf3d810ab27aa77", + "news/migrations/0010_news_audience_newscomment.py": "ceb9e88634396e6f523a22903764b42bfb3ad112c76474b66833db5be1efbb76", + "news/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "news/models.py": "918f93de257dc9ad4d4b1c5187390446434d7294ef7a195fb9d030a6897b8b60", + "news/pagination.py": "1b5ae7ea32116689786b3489e8c21b008d63f60264c90eef5e4beffe17690e3c", + "news/permissions.py": "54bab1574cc593661da1f0b3c1bc95296ced67e926b693db5e10819e127ad797", + "news/querysets.py": "740005ebeb193ed9f81901dca1f59f26f5907c03c6b59bd14a7ac4e077312491", + "news/serializers.py": "7887cdedd5707646380bfe4724124ea27cd1186090a740196978c0b2e1ef2eba", + "news/services.py": "65218cf370aebc9101dd8347f9a1806c2602cc5d530623b4250004adb2c8bcec", + "news/views.py": "f6ff8b6112d2a79e82d3c61c15a87d1e0632b8fef3088e3e38b09d36da090049", + "partner_programs/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "partner_programs/admin.py": "b00e5bbbd729d476cecdcc81d0d83c2ee940d3a8d3e69149f1a87782ab411b28", + "partner_programs/admin_certificate_fonts.py": "a91120eb25141bb647abc9b0dbce57bc18a3bce0aa187925de1bf66640535729", + "partner_programs/admin_criteria.py": "dd0bf4ccd0a5d7c1ca6529d8dc0a6ee7d1c8e4b16662acca22b6a16551166297", + "partner_programs/admin_overall_results.py": null, + "partner_programs/admin_publication.py": "a57ea6f7498b3df5385262b4def780adbcd4a6d365f51467aa4905295032a2fb", + "partner_programs/admin_registration.py": "735684a0e59cba2fc20d5be21578f665f09ffa5cbeaac63efdada97daf2ddfec", + "partner_programs/apps.py": "c7376e8f050e426650388de33ddcf5d625f65ea08f7474bfc83afc762c0e3051", + "partner_programs/attention_models.py": "8f1f162bae51f8b1b3459c5b96efa9b2ce89f03086be56bef714d91fab800e42", + "partner_programs/attention_views.py": "5d3f69f60cb2ec656b03c1f831e74f7b969a9037d946ebd003e2f0bf0e1f4d66", + "partner_programs/certificate_models.py": "cd26cf9ed558eedeb6002ee204515d96cc09e333731169b5c5db4ae63c9a3a39", + "partner_programs/certificate_views.py": "7a3f3bdcbcc6be7efc2b617fe393b644505755076ebee5a22ab09aabd501567b", + "partner_programs/constants.py": "6817136a3ddcb2353a0e019aa95452d58de1d02f1b4c2a614eada1b6a840090b", + "partner_programs/forms.py": "6d7b1bb58a1a7b84d899c116a628197d118838da47ac03435d8279d1a760f840", + "partner_programs/helpers.py": "32574d57dd914c0692dfa029ab9240606ed436171e9e5aefd0de190a3626a5fc", + "partner_programs/management/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "partner_programs/management/commands/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "partner_programs/management/commands/prepare_white_label_pilot.py": "9119ccd9b6fdfa805675d6062aba58bfe9d6dd673e8285af512c5b1e3ab3149f", + "partner_programs/management/commands/seed_white_label_demo.py": "315485eede015e8fc055ddbd02335e6c0a74b075db78da425d41bfc550eeb4cc", + "partner_programs/management/commands/set_solution_creation.py": "85542885bfedbbeccd0010d7cbce413ccfb7de5d509e52c9558c5206faabd11a", + "partner_programs/management_views.py": "1962e2d5100e30a86e21e940a2919c6f4f3044ed2b9fdd2198f332dcfa8b46ab", + "partner_programs/migrations/0001_initial.py": "fdece50ed163e2218266b80e7a9798fcc9e235ba72dde8d36d88413da97342fc", + "partner_programs/migrations/0002_alter_partnerprogram_options_and_more.py": "e01079d6f52fae9b43f352acc2699bc1054ae5dd527a3d8b5277fe85959e23d5", + "partner_programs/migrations/0003_partnerprogramuserprofile_project_and_more.py": "c79a9b0d550a38bd6df01b024f90e178791ff3a258e1cfd0eb53ee77b5140421", + "partner_programs/migrations/0004_auto_20231230_0002.py": "50bed222cc172cab1881d776624b34fdd8953f401e8fa62bf765173f59afc933", + "partner_programs/migrations/0005_partnerprogram_draft.py": "23b7ecf3cc66c74a60a19a5a2995994a0ca7e7e5ded47a9da1a653832cf97330", + "partner_programs/migrations/0006_partnerprogram_projects_availability.py": "f25d59ff1a09bf96b6b975c300c9d92678dbd46c281687c26c257becf856c419", + "partner_programs/migrations/0007_partnerprogrammaterial.py": "4fe80d4de11f2009828aa0308fde15ea41f30c7e14724d324fba2c81cc5da3c8", + "partner_programs/migrations/0008_partnerprogram_managers_and_more.py": "4b4dbf2bffff72532fde6bbaae888e8f5ec16aacf1cf18589d8109234fccf8a3", + "partner_programs/migrations/0009_partnerprogramfield_partnerprogramproject_and_more.py": "3a426d84d608c9e96203ace5a3e9928d14e60d17459d287c6b2d97df68490307", + "partner_programs/migrations/0010_alter_partnerprogramfield_options_and_more.py": "99cf939ed193497cf754a59d5f79b19869d80613691fce83d4ffc868df3f2f5e", + "partner_programs/migrations/0011_partnerprogram_is_competitive_and_more.py": "818e1b8657fb8f3d0601536fae85ac5ab4f13ebf6c02c0e66389d131db45dc8d", + "partner_programs/migrations/0012_partnerprogram_registration_link.py": "8ce2696cbfa1ae01a35fde44cb94eff43946356d5d9f7fd69e96dd7555aeaef8", + "partner_programs/migrations/0013_partnerprogram_max_project_rates.py": "db52fee8d2b55ed8fe3c041a497d76a534acc159cc99d57daab32fe5e148ebce", + "partner_programs/migrations/0014_partnerprogram_datetime_evaluation_ends_and_more.py": "7037560b09257b4bb30329b8782db1304e03b7b5548c379ac38ceb204e246d20", + "partner_programs/migrations/0015_partnerprogram_publish_projects_after_finish.py": "3a2f26ee75fcfad8e89c1457d8e8f109eddb406b4458942abc7551e07fda7c63", + "partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py": "7f4c2ddfdeda33b99093a5de5cb51e2d993938302363949782d2d2f143e0bcc9", + "partner_programs/migrations/0017_alter_partnerprogramproject_options.py": "c18f540590d5f11c6431c0c6a9ece04161c36d53b1ef998b1b68071b2340abf6", + "partner_programs/migrations/0018_partnerprogramuserprofile_welcome_acknowledged_at.py": "cf5eb0f604b5a4870e2d5b5ffd46917de35d54d50ce2a486c8f67357de19cadb", + "partner_programs/migrations/0019_legacy_project_team_policy.py": "989ff9a120602e3b3358dcf0b50a2457cffa94724c018462d3ad31d1162ae810", + "partner_programs/migrations/0020_partnerprogram_image_framing.py": "f55fadc0b08a6e8e8cc532b4007cacd1a8d1ef977e8041276077a9e19f2983d3", + "partner_programs/migrations/0021_evaluation_criteria_lock.py": "17a273ea6d9427172109710ef7edcbf1fb55e18a54ff764bba846aebf67a0e36", + "partner_programs/migrations/0022_partnerprogram_required_evaluators.py": "3d25eaa9853c2407680e2771fc8376342886cb3111c2cd5b9d6a6d37548537a3", + "partner_programs/migrations/0023_organizerinvitation_and_more.py": "62cddf022860fbc13fc241eba6f0e0ba3a2808864d92584272cb1f03350ba7a4", + "partner_programs/migrations/0024_partnerprogramuserprofile_registration_schema_and_more.py": "2078b4094c5c3f7b4b55d11434b5f8395e609285171082cf617f5ec4805dbeae", + "partner_programs/migrations/0025_registration_connection_details.py": "e1353572a2acbefd3caf59a4a5d92c671a660236ea26576ab72d9f2650483704", + "partner_programs/migrations/0026_interface_copy.py": "650dc9f0c1b9efd06620bc761709802e72c4f8ed636df826cfe5515de2cf781b", + "partner_programs/migrations/0027_registration_request_notifications.py": "509e438b7a1b9a25e28f144dfc58adb08c297a26765b9efe0f10cfcaaa642a32", + "partner_programs/migrations/0028_programattentionreminderbatch_and_more.py": "1ad05218fd2c3ba15681e7b26aac0a7c9fc5ded9593269a6356cde2beffec2e3", + "partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py": "f009d8e4bd9336a1c5eaac26b64ff7b5581b9fef90dbaae9e38de5fc967b0e47", + "partner_programs/migrations/0030_program_solution_foundation.py": "fdb2b9d89f5758df0bfdf0ddf1a78f5d0c5c98d309f7beeb913a02bda0a2aa9b", + "partner_programs/migrations/0031_programcertificate_rendered_batch.py": "2fe9364b189b5f7db866d67a77eb3d171a131f250215a621ac7bb280ec005e66", + "partner_programs/migrations/0031_solution_field_options.py": "615ab165e9ad29c7adcb161a5b67ad4bdf2ef5332f85261b6a25ac39c72fecd5", + "partner_programs/migrations/0032_alter_programsolution_options.py": "daabc62c0c2595ae7d3a04ccde79f5942b597db2623b6813fe8fc948421b4dee", + "partner_programs/migrations/0033_solution_publication.py": "1724fb7a93f9d1cff87e635a632ad9ffcee7f06541c4b9ae122cad3483347f23", + "partner_programs/migrations/0034_solution_creation_switch.py": "c41268f008f33a6721c577f31bd8c3405f1095c0a3e937aedbdd3b771b280da9", + "partner_programs/migrations/0035_solution_administrative_history.py": "f5469f416cd351d2a80d44264090c7e1de24ae4293617e0c893361732098b0d8", + "partner_programs/migrations/0036_solution_member_admin_history.py": "59e493a8c769b8014ee25003a91b66e95ce00f79f80de8b171fe685b9e39a802", + "partner_programs/migrations/0037_solution_archive_retention.py": "3e5f92253435277705ceab5a69e1c25e894153d56eb484d1df51eaadfd7d75db", + "partner_programs/migrations/0038_solution_registration_removal_audit.py": "a55958f8662cfb5b3d3fb55cfe5ee76d0ee6ff5d83595e206dcc84ac77a2017e", + "partner_programs/migrations/0039_merge_solution_certificate_branches.py": "f9908fb8f247f677a64a3aa4929ffae6d16f7ce372d4d10d64166b9001caa100", + "partner_programs/migrations/0040_program_creation_publication.py": "d97879d6faeed5ef6c3a4bf0accc4889a5efba5a5448577f7864395d3e963865", + "partner_programs/migrations/0041_registration_admin_russian.py": "9cdfe957699d7dcfffecc61d692807d3a1644767b30e85d1a9abc7db473eac88", + "partner_programs/migrations/0042_partnerprogram_tag_optional.py": "b0531346e2313b37ddee2379be5c9d0e1256520e96f1ef0fbce848a3adbedd02", + "partner_programs/migrations/0043_project_copy_form.py": "aafd619b4861bf16444ada5dc013fb5222373ab6c378daab3bdcfd6742ebf1bd", + "partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py": "db6b47e216700c1b4070af96b1dbb8ee02f1d945174e4c82aaf3487493410b6a", + "partner_programs/migrations/0045_program_quiz_outcome.py": null, + "partner_programs/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "partner_programs/models.py": "0cc8fd8ee60c7d71e7d7889a06ba03ac36d143d2de50dddbeef69d18e58c7d6f", + "partner_programs/organizer_views.py": "8cf0464c7932588ade572ec9ae7250909426c1a0cf17c83b1d6bd6775d7b1a75", + "partner_programs/overall_models.py": null, + "partner_programs/overall_views.py": null, + "partner_programs/pagination.py": "cf16d2964fb9d32f02290fb8fb62346d8430754cc350403872cb2fe066028938", + "partner_programs/permissions.py": "f3884ed7db79ed04d0cc2688e6edd18dea4d62458c65d0aae38ca856951ef57a", + "partner_programs/program_dates.py": "ab7a6a04e94a75ee3ea3380ce43d1356a058b4f79e9c3254401f9095e8d4281c", + "partner_programs/program_launch_views.py": "c2d9a70d4bf06b31fe4284b0a27ff2e25809ca50fe6a7b1a412d1782f8f7ec8b", + "partner_programs/project_form.py": "41a8e07d67be03ac9f11f51afc64b4c3e6d6d8d8ac415951b3e4d057d78bbcd8", + "partner_programs/published_projects_api.py": "5754c0cb180190638657cde8e388773c3f0aa9e824f8f29560f6a2129872f6ef", + "partner_programs/registration_models.py": "8ff843d131d037b4be7d3bb8993a475b8bdcb05d49dec4fe0f958644b8885cac", + "partner_programs/registration_regions.py": "664daf908e263ab7c9695b91fbf3e0a39025840625bfd4bc6a8c25dfc60d2c71", + "partner_programs/registration_views.py": "69f5467ef117dadfae9d1ed1f87a9ef394345b91d6ede696fce5a24815361633", + "partner_programs/report_views.py": "73bb7ec02b570595d96f94f2de4b599617d4b68ca6d6ba4985fd7fb3fe10fc6f", + "partner_programs/results_views.py": "8023419560b6f686a807996fac896ea563abe3d72eb7143e3cc4b7daa8c18585", + "partner_programs/selectors.py": "a7b8b81b9f7577790037b400cd79c10faa31a13850d4fc845a026f2d87ce4964", + "partner_programs/serializers/__init__.py": "42af2c18b7a8990e4c539d6558236f800234ad1d58d75868b3fbee3113a77db6", + "partner_programs/serializers/analytics.py": "1e778a4bff6777b66593733ebe9c4018614221d22a9279cabb8528b7d8339053", + "partner_programs/serializers/attention.py": "579a5e4d25bed9c94d315faddb28d03784083813c368f6e8f4884e0146f566ff", + "partner_programs/serializers/attention_actions.py": "ade28e0e89c3db256c0a626c11c2d5840b32cfaa66a52c02e3fddbd3043a7043", + "partner_programs/serializers/fields.py": "d7d65d34325fd3733fb5fe796ab2e81825a4e2bfa817c6227a7c83267ce04f2a", + "partner_programs/serializers/management.py": "0498b58f8d1ebbdbab9657656247e9f4d310b9bb4bb6c3756e9277aa0faefd73", + "partner_programs/serializers/programs.py": "3b237519ac6605a755396a125109792f1ad28bb6b73ea71fb9aaecd7de0a994c", + "partner_programs/serializers/project_case_drilldown.py": "9fd9bc7f6b6c1458cc5d951cd09e3b6d28cfc0a2604083885d8a2677f21c63c6", + "partner_programs/serializers/role_analytics.py": "3c57289be93f10ae399b2d4796bedd5c80147cbd9df9ee2d073adae2456e838e", + "partner_programs/services/__init__.py": "5060cc1d3edfda779cc64967f78929243be7c53419aa37a6fcf0f683809ebc8d", + "partner_programs/services/analytics.py": "56d4979cc4359ec9de8350ad0292703be46d25ed1980346aba716a92183ebe5e", + "partner_programs/services/assignment_analytics.py": "5ab80e8116907478be38e89e4254d2ea1ab129d00917b138e22b8569168f4f30", + "partner_programs/services/attention_actions.py": "e0b89aacafc9fa8dc7e5643c475f24a70a561308f79d4039f9a881a293ad29ea", + "partner_programs/services/case_analytics.py": "e17bd25af16ee59dd29ba96cca6952faa2c06279c377a887dd263088515ab21d", + "partner_programs/services/case_fields.py": "9057af5d80c785c91f87b9932d49e00466e1315cacba1353a03712f7faf52200", + "partner_programs/services/certificates.py": "6f3404f47ebc953b8ed70d976c7875b20852011f62d7d2c7568009c98e195380", + "partner_programs/services/exports.py": "3f0074e3903ac66e4f6845948e2102324437c425e452cba886ae5116fde00775", + "partner_programs/services/field_values.py": "530d97a3943416d5dabce50eacfd684b5e12a149b7d9bd6d5532774ea1a2505d", + "partner_programs/services/management.py": "ade587ff69d68dfd1c947cc01555d9c242b07471ef29aaa0a4d6f1f6819da33c", + "partner_programs/services/management_criteria.py": "60bd5c8b4ce7910d411a4266082f3ef11beb4f3b88226741767ddee5fb1f6b5f", + "partner_programs/services/management_form.py": "ad8b34b217dd79a0eb857388871cb488584c701b5ec7e8f868374b89cb66b741", + "partner_programs/services/management_project_actions.py": "af63527bd821479a0cf5c43db488917b40acedad0663d76f46e309c03f3d9f60", + "partner_programs/services/management_projects_excel.py": "0d848a662db3ff8d2a8ac875ad62a2efc919cb15d23687b982fe0dc66cea0db9", + "partner_programs/services/native_registration.py": "cdf55431307d508b2dc0cbbdc905e50e3be0504b4b2165634530c35bd2603178", + "partner_programs/services/organizers.py": "52a7991a6064602127559dee55c430b5b622894c354604c9af92ccd11340e1c6", + "partner_programs/services/overall_results.py": null, + "partner_programs/services/overall_results_excel.py": null, + "partner_programs/services/participant_removal.py": "62e2a2b2206f5a16acd9ed4b0c6598d37e66054d91f60a377ec3fa0db66ac1fe", + "partner_programs/services/participants_excel.py": "3da492485939e66d807802f59fb492f2357aa1968b093e93547c5ca84080f3e4", + "partner_programs/services/program_launch.py": "76b9824500c3996bbc50eaf0002f7ce68a182fbe4b04c09e9599af203a694bbd", + "partner_programs/services/program_report.py": "e63a0bb58ff6b7d6a91e7820304332a5a4d9746c45c9608598d8d5fcf92bbe65", + "partner_programs/services/project_apply.py": "fee1e75e4c26916a6d348b8196d19060a714f2cfc8ef2905023830203b8f86c3", + "partner_programs/services/project_case_drilldown.py": "d5f8069faad7a35c361fa6e327d6e7575c330138846f996d5c4b41d77160852e", + "partner_programs/services/project_fields.py": "6dd01e469076d963d703eabd0e123c155c9ccf945fdcfe9b90cb004636a21907", + "partner_programs/services/project_filters.py": "097b5430cd31edb54bd85a289d4b648c0efba20f7ca946900fb3ae5967dc7b39", + "partner_programs/services/publishing.py": "9d0df8afb5b9a4d0442cd03436c69a2d29a30c4ecb09498f8ac7af37da0c00a5", + "partner_programs/services/quiz_outcomes.py": null, + "partner_programs/services/registration.py": "7720285221762751c07362f479ad91cc78d424259495f36b1f8a589b214fb840", + "partner_programs/services/registration_notifications.py": "d4245841b3398bb4eaa7bcc14e4b81ad9484346a0ddc25d928a45b6a0d5685d8", + "partner_programs/services/results.py": "2af4b8dfb5e0dbae6963095c8e910faa9d4c129b4b16542eea5ffd3b726c6a8c", + "partner_programs/services/results_excel.py": "c5f523aee172125cd8a306d00b5d46d5b9c30023d2fc377948ad8f31323c9720", + "partner_programs/services/role_analytics.py": "2f19aa650d149c52ee04968ba3f98dfa9e1fd55f70bea0664114398e8c423af2", + "partner_programs/services/solution_analytics.py": "2bee83a4b784fcdd4caae55288d7272dca026be7e8c290acd325ac85e19ef472", + "partner_programs/services/solution_archive_actions.py": "7571e0dff1b6bbcabccc69336c23e9de377ff6d31e9ed094542499991bebd7e5", + "partner_programs/services/solution_attention.py": "df5fb2f786f0b2a80a2872c53ce7ceced635105f6d9a71099a972e53ad3b4b3c", + "partner_programs/services/solution_case_actions.py": "dde07d2fc33e8d2fa6c28eef77f0162603f2a9b835ee9d6fa84e65bd1e2d364d", + "partner_programs/services/solution_case_analytics.py": "d6074c6b68d526adf443bd01ac68a0dca70a3bcde8fbe054c7b0e2a1569fd161", + "partner_programs/services/solution_member_actions.py": "573679c07be01575d355f2556c1880ab3ebc0289c757b1b4910070cfed3ac1b3", + "partner_programs/services/solution_program_report.py": "db1287dcb2711dcb5d0a5effc76950144910085fa7a2ac91510c9c939746faea", + "partner_programs/services/solution_publication.py": "48f21b691c2d880d30bac0b18d08b15545edf9e07967ea717eeb7f2e2f4451bf", + "partner_programs/services/solution_registration_actions.py": "799756778ac9b50c20ad106eb3863a3d455ed372b42e35be7f5bc667ba1fddbd", + "partner_programs/services/solution_role_analytics.py": "25008ae0381d927655c29331be67424040c013dc829d7297547052e773946f3b", + "partner_programs/services/solution_submission_actions.py": "7c629683b99fcfcfbba47db4aba3d1a801f5a8bbdb3daa3ea4834b01694ab41b", + "partner_programs/solution_action_api.py": "1d5310ff5149f1ad1232517923730483d7ebd13f6afc7e5f69140c84c30a18b4", + "partner_programs/solution_admin.py": "e0bcacb374c18a4e495f5b4425e471483ec593f24844470f590bd4d5d00cf093", + "partner_programs/solution_analytics_api.py": "3f9c57e6a24486d16733206b9536286fc178096ceeae0cf5ef006d81bec5c792", + "partner_programs/solution_answer_api.py": "4e9c620c78563fd416d701991ec6c6450ac7573d1e0b64be8cbcd1f76549d543", + "partner_programs/solution_answers.py": "cf088fedc7882e3cd4cb029bc3e9bb0bb056f6c8df30c3109f567b6d38a90e45", + "partner_programs/solution_api.py": "76ee565ddb9bf44656e159b9c209480b94628f2ad4881eec5670d7df17e03cbd", + "partner_programs/solution_archive_action_api.py": "d7e4cd44a30a23df3b12419774e27e9c1bc56fe0b9d751da531c1571c26ff0fd", + "partner_programs/solution_attention_api.py": "82433b3eb5e5336c9a02d8b34ad46654be56b42ad7d2ac6797f580f75fdce638", + "partner_programs/solution_case_action_api.py": "6048400a6f2de3e6d520e8a8653995496e1719a7760c454975378f23379ed700", + "partner_programs/solution_errors.py": "e7ad418fbaeb55edf32e41d4e6e5dede2fe9d1c98270f67cabcfc787e96f102e", + "partner_programs/solution_file_signals.py": "af7ebe245acdb1216c03f7be597de2daa55067369a62d81fd0eff0a4d31ff919", + "partner_programs/solution_files.py": "9d76e69bbdec292d44a75fb21d0f2dbe182079b769be2a427c75bc262970c9b1", + "partner_programs/solution_form_api.py": "5f00ab6886bd59003872f8ef69e8c20ca05a46c5ee70f702bca03929c00d0a64", + "partner_programs/solution_forms.py": "d405d2251289d1784c02cab557fea9776ccefbf9e16adc61001a50bd2d2114b9", + "partner_programs/solution_history_api.py": "7417e9ede5889cfbac2ddc7be0167445b522d56a1fdba290286f624d5014c95f", + "partner_programs/solution_invitation_api.py": "32e436b9ca4fe045866a523a4886b291513e46b4e5715ab28bd3cd093a7773e6", + "partner_programs/solution_invitation_urls.py": "a455bc0fff7b6ca5f35af75f1169fffc599b9dd4fbc129bae247a6630a6e1174", + "partner_programs/solution_invitations.py": "b15e67348f6367aa62fa8ad37c4498346a2fd8075cbbf20de647e74f11d1eba5", + "partner_programs/solution_lifecycle_signals.py": "ab586c9344d83296679fabb36b7cd67ad048eecb8d8b6e6ba7abd8a548a6ce34", + "partner_programs/solution_member_action_api.py": "37bffab4c21bf654dfaf69d6778e6c767cd72c3ce0d251ace3b37c6b3b6fec23", + "partner_programs/solution_models.py": "af30b98eb2bda77bea9ffbc5c4e33e2f2479d9c2bc9e6c301013416efc762803", + "partner_programs/solution_participants.py": "085578ea902f5c50f4f8d2dce589cee3bbe8caa7570f471239488d1f930c347d", + "partner_programs/solution_publication_api.py": "257e5120da1f8e12d59478e40d85f9095f0d28f92d42a9d6013bdc45d9e1b83e", + "partner_programs/solution_registration_action_api.py": "809d706802f2b090a4e871de284ea5fb813af0dfee9ab9c5faa07650efcf9e10", + "partner_programs/solution_schema.py": "ca1ca0e8456f5f160a3e6c8a0ab7e7e828513e3f6033e66e0ebc9d6f5e1dfc31", + "partner_programs/solution_submission.py": "01518f2cd0cf6ec40de2b392f51bfc01d0da599680a5c7219f5ff69f1465a393", + "partner_programs/solution_submission_api.py": "5e76823718d23c5cb861c2b7e82e1f9c6bd764cb905fd943b2bd10c3a0193a29", + "partner_programs/solution_team.py": "5a1abd184887b82ff249be5f3ac102145cdabdf744aaeb5123f5bd2f34c7802b", + "partner_programs/tasks.py": "80225d1fef17116aec52f6d254bd61cedf8b855f8143eb20f7250a4eb145e42a", + "partner_programs/urls.py": "703c76b0e39528b2eec50762e18e4090e627e3ede789faa7e90059e2f42a9a60", + "partner_programs/utils.py": "9d68034323522462c6283d4dca3162396c661a3f8b3c6ae438b371e40bd8aedb", + "partner_programs/views.py": "f117f736246ee3f40aaa969178eba977e37f4b3d4715a0651ca5d583f75f7bbe", + "partner_programs/widget_views.py": "c5d39c1bdb88abb753b324e0b2f6f3f0f52b731202b91d432cfa979e36beac5d", + "poetry.lock": "fd66c215bc4484d4056bdf9aaa1f31867eda35ccbc2a5e50e223491ecadd3f67", + "procollab/__init__.py": "d54716f9a61b2f21e2ed17aa518a56416a84f935dd5614f7ca38fddff85381a3", + "procollab/asgi.py": "cbf17d858466fddedcfb8b4b5928b283f7195f382ea0314567029564c4a74f3f", + "procollab/celery.py": "78a33c776c53c2ece523ea2af775f3919e6c8c52696c53aa7360cc940486a7b2", + "procollab/settings.py": "decec46a0b99e5d3781284bf4189e75451f155246685afbcf0dc15fe3f0b1366", + "procollab/settings_ci.py": "e65b72f648f939f610a0107a5955d93ba333a06f2cbd1cc6a34322de2702d896", + "procollab/settings_pilot.py": "9de066782c3ff888f5be3dde0b3f3500377caecf8a1c7a941d1d23646b8038bb", + "procollab/settings_pilot_inline.py": "6a311d48ee93d7978a6d4e68e5dcf5919023d096b56ee453f67850752ef27366", + "procollab/urls.py": "dcb6dd15a5f0cf75dcf40315cd8a41a7881da1d5e1c8d24647430a06279c7c8a", + "procollab/websocket_routing.py": "ce9888178f905bf334479d489e0fb8df1f7ce45176c1ccf952ef22f63a17e3a8", + "procollab/white_label.py": "23235bf15e5d7574f86663271b62d0bb739fb4e7e64a3cb366c0d2c2e3488d16", + "procollab/wsgi.py": "26b60f737521c0ebad2299584d6e0c285142c1eed12434034a15a9ff5d649773", + "project_rates/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "project_rates/admin.py": "ce734fcaa87d73ee64c6a5091a49c3eace4ba9e25d50c906745a9c46bcb28d91", + "project_rates/allocation_alternatives.py": "2cab82ea8a22bf708f912b86fafb0dd9d8390339f2f4e1f21551c44838fab20d", + "project_rates/apps.py": "0b2723a5fb86a26c9aa2454547b30a1ff654dfabaccf32c1b2394aa8d3afa473", + "project_rates/constants.py": "4d439be5d73a61ad931c49abc7a3b6093d3801e83b760207d992392a3fddf3a3", + "project_rates/criteria_policy.py": "787e5a834379be53ffea55685a3852b614941c507e52ce1bb83fce442cfa1f53", + "project_rates/evaluation_math.py": "5f24d54ab17732fa3b0d182f624183bead972528b3c6993221aabfd1120d6cf7", + "project_rates/expert_cabinet.py": "efda79cea9993102d12263771a8fb199e7e2370eb3d68d6484e5866f44e20dc7", + "project_rates/migrations/0001_initial.py": "a9657d9c1a2ab9d113318c752407f4c5fea680b83d9c6931bf2e83b0c9d87eac", + "project_rates/migrations/0002_remove_projectscore_comment.py": "2e448251a60a0742fc150299cd6f70a4ae56f8b0f9047c77cdf5aa9c45308eb6", + "project_rates/migrations/0003_projectexpertassignment.py": "3dfccad5537c8a5254541bbdc9c8563fdada23915373990d221f79c96dbd3042", + "project_rates/migrations/0004_expertinvitation_and_more.py": "25892aafae0a403322d46419845a13cefafb3236d283a2fdf774631cac04edbf", + "project_rates/migrations/0005_expertcasescope_and_more.py": "892e4538b7e72e5501dfbfaefc0a2a012fe5c1721515d6c36256899dd003528b", + "project_rates/migrations/0006_expertjoinednotification_and_more.py": "fc2ad1aa30842c40891529bb8ccff0e26d2785ad9cb249c30368cffea38302af", + "project_rates/migrations/0007_expertinvitation_case_scope.py": "531a767e1648578f4cd61255a1ffbe12162c19e44d850ddfe7b22c542ea0d277", + "project_rates/migrations/0008_projectscore_value_1000.py": "4c07fcd93bfd965660c9b134f1d74c0667ff472ea37e9c444156219ab2d4d3c9", + "project_rates/migrations/0008_solutionexpertassignment_solutionscore_and_more.py": "1caebda43b5e59140847088332fd317d69060eb61060eb74a93e8b6b4d2ac923", + "project_rates/migrations/0009_alter_solutionexpertassignment_options_and_more.py": "8a671c889aa469b428b6efd4f93b4fc344ec5cd471631c9970b33f010a7469c0", + "project_rates/migrations/0010_merge_solution_scoring.py": "0b5c437a7c47b9474e53887aadcab930e1eb04e964b0cabe68d39bd3431cb3fa", + "project_rates/migrations/0011_solution_score_value_1000.py": "9c360e26fab621001b164b89a96b033b29bc3bd5311af246812bf34e54f1738d", + "project_rates/migrations/0012_criteria_weight_percent_and_more.py": "4ba37e7564ddbcbcf1462f83cbe4fa89e48796c389c3864264ffbc3b164b756b", + "project_rates/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "project_rates/models.py": "c90e156050d8613c0111c8ea7cc9ad310286e89157926ae2f15b5c51a7020e6b", + "project_rates/notifications.py": "91df0805243ed7dfa3dd276756b7dafebd16dffc1b8c92b19f0d6321cbc69f5f", + "project_rates/pagination.py": "6db5c2885da71076002058d78b6875f54eae55e8d4f93f8a35203c184fbb633c", + "project_rates/scoring.py": "a9ee1e4e6b43b0b61f1bb2ddb06de00377b3cffb42f4cb28102fa06e8e27d9f5", + "project_rates/serializers.py": "1bb3f748465ae9772c1e02e2952204b409df6f27e706aff75a0afaa6fcdc4b67", + "project_rates/services.py": "d332563dc237bf0bac66a25e74bb364dc9e0e93934b3f61348b7a027948cc12c", + "project_rates/signals.py": "c02bd96acb08702da4feb8a43690adb300d542b97bcca847c2d6f01b97537e4b", + "project_rates/solution_admin.py": "f9e04e68d402d6b9c7a039796953072544a33020028b09b63f1c9d3e1887d17f", + "project_rates/solution_allocation.py": "e3f1948804bdd45b2022cba1f758f9a2893750458228d47f8adbbbb59cd9f075", + "project_rates/solution_assignments.py": "54bea9618b9f068b197177a4f2aa98e65706cf42c2c6ddf9d88c28db47809dbb", + "project_rates/solution_cabinet.py": "c3593ea3bfc1ab06c8351fe908927c88d0bbffed5ec0dd081f0ec4dba9f46a58", + "project_rates/solution_cabinet_api.py": "7ef02446ccb454ad2fa519868ad5f30e770ce8dc78da304202a6b91e401348ac", + "project_rates/solution_evaluation.py": "641502069985f2575afe7863025404b316fe21d67bb3974be4befb3f83012e6f", + "project_rates/solution_evaluation_api.py": "5cd6a28ea0aacca4fa2564807c4299554793cbf704f6c47a607241b6a5232799", + "project_rates/solution_models.py": "4360ad8cd3f27937decf23f5d8991ba843916bea6b6ee02a705050a95f87b997", + "project_rates/solution_notifications.py": "49b8e349f9d65f5ca3ace1773a8b7562d965ad92fcc76921009118df389ab419", + "project_rates/solution_results.py": "d48d98020caf9d182c4de0fd4a1393bf8831b5a480593cde48cfcc72ee9aa3cd", + "project_rates/tasks.py": "d283a2d3fa94cf1ea135b434f9f0da86fb11b718705286687e6f2239a26b672e", + "project_rates/typing.py": "a464740f1c77ee79b57dca1bc48f472244426c1c1512ea8cb0b88f256b9c5809", + "project_rates/urls.py": "60207592c7f48df80ab08640d5902ac459a94f7a0a6a094e3683f98583feeb0a", + "project_rates/validators.py": "aca17430d73b9497d69c4dd40e601748457a603d742b5b6286cff59d288992c3", + "project_rates/views.py": "233ecc84daa2a27446e49a16637adfda652448db618a13f1d31459e9fa629a8b", + "project_rates/workspace.py": "a6b34b4ff5f4f060d8180f169a62ace46487c1fe5e0c7ba135f69a9c942a5a04", + "project_rates/workspace_urls.py": "d1a13178c7725b23883018f9066b89e995d8c9c09d04b05a3fcbcc070fe73fa9", + "project_rates/workspace_views.py": "6610d169ccc0b93fc39897e28ec08f2e9abc78ad59605ee699c74da1fc22b175", + "projects/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "projects/admin.py": "70f09edecbdc08c390bc9b101a5072d90f1b224b171e92ef9b6a0299df91e8e8", + "projects/apps.py": "60adbac81a61be3186f10dbb143154b4d9107ddf61f0c819489f1b69ed6a2d63", + "projects/cover_reset.py": "e4f4f70c1eed5efcc0ccf1b53bf2fc7eb6daf48ef84121f49c5fcb4a4b5a07ce", + "projects/exceptions.py": "04a12bd179c04344be7b14d38bd370f47c5741c680aca1f1bba3dc13304fd7cf", + "projects/filters.py": "19d97f9e1f3e637d769c7a4cfb4c2218d6828c2acf48de03b866875f8d970259", + "projects/helpers.py": "edb7a9b039f3352596718d8125c801e1269c69999bc1f3f284c433edecca34f1", + "projects/managers.py": "2f43cf48e23431ac7a1d7ad2f25422ddebba7dde7be98d4e4fca62988a6596e2", + "projects/migrations/0001_initial.py": "73e5a38c1605df7985640a1adb42832f04a3947d84d9a31d06938a351c0f3c06", + "projects/migrations/0002_initial.py": "218cb568f2d8320e503337a6f23a05b5b312d414be57c1b71144a7c2ac84bf32", + "projects/migrations/0003_alter_project_collaborators.py": "c6ddbca64ad8765909edc1e7e56e801e5302f05b3d6a27634d253031ab5c5f22", + "projects/migrations/0004_collaborator.py": "2799892fa322a0c687d4f5a1af055cfddee8892c4aac916e05812089d479c359", + "projects/migrations/0005_alter_project_collaborators_and_more.py": "2174b1ab8c23ef41c662b76232f52864809e0de2e3f64faa71de62057a6bb9c1", + "projects/migrations/0006_alter_project_name_alter_project_step.py": "2e0c5835796b8c6d30e71e19d246e39770f0b1c68011ccab3c038d2622b276db", + "projects/migrations/0007_remove_collaborator_unique_collaorator_and_more.py": "6d6401fe03ba541280036fc2596439b3f20898c8dc823b678ea8f2292e9ec530", + "projects/migrations/0008_alter_project_description_and_more.py": "a55605fc0d20225c61676561830828415292207c33a7d00d7938932124911870", + "projects/migrations/0009_remove_project_short_description.py": "e332b9f0a76e555af62b264d4e76b7b2f72ef28ffda9dc68d501cc446d4d4ab9", + "projects/migrations/0010_alter_collaborator_user.py": "37bfd43e45a9f3b80e8a54b9ccd52bded23bcc5e84fd5c904b3fbc65bdad10cf", + "projects/migrations/0011_project_views_count.py": "3bc5f03d9180697baf473b0335e4d57c6ef9e406c666a8e589879818c87998c0", + "projects/migrations/0012_projectlink.py": "d35585ccc5e5e3aa30e5f73831ce93b4afea2b64a233b26014980fcd6bb794c3", + "projects/migrations/0013_projectnews.py": "da13cbbcfd7f262d517ef41c27a623899cb1ac118fb79348f31ff08f9b69292c", + "projects/migrations/0014_alter_projectnews_options_and_more.py": "c777c849b7340c8bbe13d9272298ba00311361df737f6bef242e119fe56b1094", + "projects/migrations/0015_projectnews_files.py": "dcda12ba33f6552eb8375f8a3235dfd89ce72d92baf6aa0c7cab9a06e91ec73b", + "projects/migrations/0016_alter_projectnews_files.py": "709450e0d85d10c13fab059f209dfad2bd45b2046a8c2eb3d044f633e601b75a", + "projects/migrations/0017_remove_project_views_count.py": "91f076fa9bdf6dabc372ceab80f7f922a712a58c4cf9faaea6d455b08be9bf38", + "projects/migrations/0018_alter_projectnews_options.py": "7535e37a88e4b88d536fdd47eab6ab9cbdc6ddab746b05b690dcf3096a537a10", + "projects/migrations/0019_alter_project_options_project_hidden_score.py": "c04628afa3af9711839c297348f3cb0e325510bc04f54bfc993e20745a27585e", + "projects/migrations/0020_project_cover_defaultprojectcover.py": "86f96df19fb8900a9f7c3a48fa46968ee0bd7915f5f19f17dd9d96bfaf9af7e0", + "projects/migrations/0021_project_subscribers.py": "7b728b86eefc5156debd3f6d0962c7423ebca1ed12c0f6f8ddddedc7bb629195", + "projects/migrations/0022_alter_project_options_project_is_company.py": "ab7a16edb408ca1bf658c7cf732b17250fd2f5044cb1193bf77b0e1cc8486862", + "projects/migrations/0023_project_cover_image_address.py": "aa50cc512e2d639dae0fdfcb05bf9bdf1ed94e0b35167c1559a800dfabcef190", + "projects/migrations/0024_project_actuality_project_direction_project_goal_and_more.py": "53f5a86cfd791478f4e08c4d1d06b6935f5b4be0df69f1746823adde95e7c36a", + "projects/migrations/0025_remove_project_specialty.py": "217cb6873be1e35d06bbf523856d33d85c61acc5c87cf99b2778a407dcca00c4", + "projects/migrations/0026_collaborator_specialization.py": "9e9d4f5f0f12586f72b2ce03e184f84cc16d8edf7b21c93b1fb0f0b43da9afac", + "projects/migrations/0027_alter_defaultprojectcover_datetime_created_and_more.py": "5ca297d92803ffdde7132cf38aa7ce954fff65b508bc1d7e54ac2f20807e4d7c", + "projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py": "70b638fcf7675c8a963e62e6d7a93772beb1632048d5ea442b9eb22553c5bc2e", + "projects/migrations/0029_projectgoal.py": "dafc3c1e87164fff189ae4043d7749dc6e025f46c142e6a1aeafe331df41a972", + "projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py": "f07e2d87983943127d563f775f37eecfdb7a0ddc709e3cacbe351d5a27802f22", + "projects/migrations/0031_project_is_public.py": "d2e15c49fc527f5ca37f711c71fba373b76c2004dd11d442bf6154c14c043c40", + "projects/migrations/0032_hide_program_projects.py": "35288cf55f0e19b2bf430cc71ceba01d0a2468c315430653dbd0e7b7f5d56f91", + "projects/migrations/0033_delete_projectnews.py": "5372131e006984dbfa93edd13a9699ff8885ee717f4f1f1b37ffc5c284df1e53", + "projects/migrations/0034_default_project_name.py": "9216daa8ca38016b266fb9afc43e9b70ac06efab11ca697c7920cbf8bf0e6855", + "projects/migrations/0035_interface_copy.py": "96a9f12df0bd3038f25a0a06e47c5b6af20ff8874182a81b8e8dea44b1c55d21", + "projects/migrations/0036_neutral_default_images.py": "fe146e760442b5cfa4f70c5d0f78cc8880c9e86fe57de3caa67bca4967e65f15", + "projects/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "projects/models.py": "6eb0dc87373b7caef154ae66797e0d4075a27590da9c09d3dfa414656af348ea", + "projects/names.py": "897b8321486a48dbdd49997d03d544185a0b20a7c141c66e2f3e695a669aacec", + "projects/pagination.py": "e8396e0fa4ad16a06c367bd291165d06838dc151f15287f87d8425febb06632c", + "projects/permissions.py": "92dd6bffadff49ac229156e747d3d103de10d044d0e6c8040bb01b3fc3785004", + "projects/serializers.py": "e04027ea88d163009a9f3d26d823489577b35af184b06d92db3a32c7a591742a", + "projects/signals.py": "8fe37036def53de94fe95db73866f7e4175490829065fd9d6794f37153bcf9ca", + "projects/team_admin.py": "5556377b39dad7439480bd90987a4c4ab7208ccef9bc4780acc99099f95658a3", + "projects/team_errors.py": "d5b85032ae46b93548c1221b3dd0c35def4a21f307a0906453863be05bc9d00b", + "projects/team_policy.py": "6ca592d10e6f5e5903b41c905cb081c1518f9fd2845fb3681c3f48df0915140b", + "projects/team_service.py": "c8d950a2b43e86452447d4e711c8acd97d570d87cd183ec98362367c2306d00d", + "projects/urls.py": "9bc997ebec0a0c2791661ae415dc4ec2ca16093f4af5cb6196eb19a439886136", + "projects/validators.py": "d9af5231fa2786e245d3f7c1404d910ef934c5b815c165d5b29a64744b9077d0", + "projects/views.py": "921929c9ec76701c4652ba2166e1203f95eb7c284fd2d91eb6e300bbdaa00f71", + "pyproject.toml": "0d9e46cce81c1fd211ae90c73394119d8bb070885f34d9ee2b64d49c6472ffe7", + "templates/admin/base_site.html": "e7424211d644dc29bc102990760bbd8b77d3a6e0de6ba1e87eb08630c344e3d3", + "templates/admin/change_list.html": "859569efbf61236f8a6a8752ab3bbe38ae166ecfa5e6c5bd4b3ce6d211939976", + "templates/courses/admin/course_change_form.html": "d59c97b79386f470c2408f5251c4279496c7ea3a274e30ce9b001b80fd4fa50b", + "templates/email/attention_reminder.html": "c5dfc029e6fa31e6a1315b308856e216dce3262b7426c314a05a4784cc7d33b4", + "templates/email/attention_reminder.txt": "18010d27284fea270a5bb01597723d2b8daff28ef73cd5d4e82db65b0a7132ef", + "templates/email/confirm-email.html": "6bbf4bf1f9dd2978c20449cc8ae0d4f6a31b6710a3172fcad3798169a088bee6", + "templates/email/confirm-email.txt": "e05edfd10fa157ecd25f7a1b6e3254e582997cfe5d12491fe9e898dc837f3eef", + "templates/email/confirm-emial.txt": "33d85d94c11671e94a3cf5c08bab2ab5d62576fc10076987e616b1460c71badc", + "templates/email/email_cv.html": "6fdf53218eec65e5ba05cbb55d03ce69a32c49f86611c539e117ba25a8145fca", + "templates/email/expert_invitation.html": "60c1491d2893162eb226e1a8da18f39b1895037497a223de06b04b9af4ea8bed", + "templates/email/expert_invitation.txt": "03db5838e4161ebe3f8f3e21845948e5ee440e826ea289b46b54802c2f70de1f", + "templates/email/expert_joined.html": "49d6eeba950ddf8b9ceb410940c8487f2101970e4d31f44baf3c250794f617bc", + "templates/email/expert_joined.txt": "32307a9f460a97a0e1f0918732e52eb5f7b099980957f5614496ce2f27833510", + "templates/email/generic-template-0.html": "7fc655a7cc1f236f36206768889be94f136a616192d9a06b5b930c35af0b288e", + "templates/email/organizer_invitation.html": "91d703f77d3e67b168d03e1b205c746d56a117bdb55be5ff26c456dff067b945", + "templates/email/organizer_invitation.txt": "0aabb36a4e419ec2de02c28649fe388a90f7738327ae77b9cbe853cb817f0b42", + "templates/email/password_reset_email.html": "0e8c3024234590dc7da06f764a3df1830c5f8ad567cd12e31e5a313b9a97f3f8", + "templates/email/password_reset_email.txt": "f7d7e12cbfd681d1c6ddb5219dfa6ed8404561f8021e84375ff0ff1dd857456e", + "templates/email/program_registration.html": "0bd2b6c7b0b91fb18e1b015b52762b9e28baf47853efc94f7afc460946ded161", + "templates/email/program_registration.txt": "26b2c9a10d551d12bf454a8e622716046c89c718d2d1dd7c1d05f426e2b12986", + "templates/email/registration_connection_request.html": "ebe4e74a4b3e8da1ed7227df41e61216d34c77a9934c620a57d21cc98b63a64f", + "templates/email/registration_connection_request.txt": "7da6242d5241c815c46b3665142c62247c4305e278c3f4aa4f7ae9a4fd779907", + "templates/email/reset-password.html": "e9990ce9e117bdd2eccf3a1acddaf9f53e013cedbe961549b7f88ed8c12262b1", + "templates/email/reset-password.txt": "aed17eff7a1afe244e49429d6cbc4d737cbe9c9f52485a9988e58d413612d608", + "templates/email/verification-succeed.html": "40569780db6fd42d6b688b87430d65f859db8935f905c11da2bb325791ba2a4d", + "templates/email/verification-succeed.txt": "8edb1eacc55571cf18a63c45b78d37b41a0ed63cd55ca75806fc5382f86f3c8d", + "templates/email/white_label_password_reset.html": "ec5feaf71afdb198c335bdc90d13a1939b582bb4d39f7dbf9d4efc17aa5386a8", + "templates/email/white_label_password_reset.txt": "ced417dfc746dcacd4b8c3860c3007c3849aaad8a99599c4c214430ccbec567d", + "templates/mailing/mail_schema.html": "0e32dbb7d842457a8a25bdaaf5ba5b4b118df90fccfce408e53875843ac037f1", + "templates/partner_programs/admin/certificate_fonts.html": "8c3e70e3b67f130a3778b8d5e882118036b0aafd6df4b305b255c6c915964c07", + "templates/partner_programs/admin/evaluation_criteria.html": "8a0e9ddb826ce0c79dcc741fbb956579d49ebfa318d3d71820930aeb19d072c1", + "templates/partner_programs/admin/overall_results.html": null, + "templates/partner_programs/admin/program_manager_change_form.html": "e8f425ceed868cadf141bea9472a545b0487a3b16b1e0791bc96a911f373d61c", + "templates/partner_programs/admin/programs_change_form.html": "a7f158bdbd6dbe87c886bc4cab2526868340796f0234348125b1d0f1123d0e98", + "templates/partner_programs/admin/registration.html": "024140391c55c7a4da98a092bdc0094c52b3bf3e8238c79a61bf8ecb91dc6f22", + "templates/partner_programs/admin/registration_connection.html": "a29b8997feeaeae887d76b6bbda3dd6eed952adcd4e675b6622b373458faee8c", + "templates/partner_programs/admin/registration_field.html": "50e70bc3ca5034301094b5b3ce1128e1fe1a6b80457f14a86b43bdf964076952", + "templates/partner_programs/admin/unpublish.html": "3d33bdc6bc71fce9554dd234371a9305bc15588b78f1055ab76b913de249d9c8", + "templates/users/admin/users_change_form.html": "7ea753a30e1d0aded1499edd4e6c863480227aba089dc1dc5a5e245ee26c80f0", + "templates/users/admin/users_change_list.html": "95e4cfda78841102a3f7cc869572e7151be6b6382d2449b460af9ab43f280c78", + "templates/users/user_CV/user_CV_template_v2.html": "f618d3c28519a27f367e28d75738419c6625e582d5773b8bb886c259d42c35f6", + "templates/vacancies/vacancies_change_list.html": "3cbb1c9056be7c90d3e242bbeee62608f51f57259d45bec126eca1788ec60ba3", + "users/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "users/admin.py": "8eecd8840ee5f8261d0f932b8341e50aea8f84a41f69b9ba7df51483a31bf44a", + "users/admin_forms.py": "c391ada0644544ba529b5da176e14295cbcc6ec8defa4881eb8ad229d7d421fb", + "users/apps.py": "a142d76f30146f035a8d0e19fff8d48bfb07175b448858f77c3e190c11673c9d", + "users/authentication.py": "e5ab27f863e79ab1945dded6e574ca33f0507140a73771365579f19e7d83b517", + "users/constants.py": "78d620c208f3a2937720ccf07df9617bb54d144bc637447165c31f85ff293451", + "users/filters.py": "0807e0b38b86cdbef47770ed3d0d0ee4846d453e279e356fa67d3027f0514063", + "users/helpers.py": "dd3abfdfe1735ecfa1993e12d416dfb0f70344f00e2386647ee70f03e6a27e2c", + "users/management/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "users/management/commands/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "users/management/commands/import_universities.py": "a15d572d528ffbc883037e5691d3676242284f9c0732d24cdb7fbabcb3a672b0", + "users/managers.py": "625451a6005c6028b28bdc58887b605f0384dcfef2e2fed64702358d14e5485e", + "users/migrations/0001_initial.py": "26627607c2c02179e725239adb16d53eba9357c18182ad90ea2e9a215537beec", + "users/migrations/0002_alter_customuser_avatar.py": "bba2f96cf4f326ea24b02f8379db54d4fafd6bcfcd8484479a7093d01255a10d", + "users/migrations/0003_usertype.py": "e21690278d3c6cc674dfdf2576f8dc404d6280f5294f07c0c048b9bae34805aa", + "users/migrations/0004_remove_customuser_key_skills_and_more.py": "a72de60999d7d37a345a19da747c398365c176e31d48cd3a451b7dbdc2dd060e", + "users/migrations/0005_alter_customuser_user_type.py": "f8e4dc2a7ae221246002ef5e2560ef10b314832ab4a1126fa6a4b4c02210197c", + "users/migrations/0006_alter_member_useful_to_project_mentor_investor_and_more.py": "92c035d8c5c10a3ac18ca6c920320b212fe5d8a529a0f08a1efe6fd6299084e1", + "users/migrations/0007_alter_customuser_user_type.py": "80fc2f2059ab8f3a2a090a41e29f1c399e4db994e89f771322439169a2b26077", + "users/migrations/0008_alter_customuser_user_type_delete_usertype.py": "d35ab7f4add2a8d968802bd20550e78ffdea20db75bf2060078ea9dbf15ff866", + "users/migrations/0009_alter_member_user.py": "34e7eeb43952be539e9cf2abc075ed843e571da82c20e1b0ec5406883b60c835", + "users/migrations/0010_alter_expert_user_alter_investor_user_and_more.py": "ffa8b1af5e6d70a4b8d63e0979c0041961c6c8f7076eb84b69bb09b80e8421e3", + "users/migrations/0011_expert_additional_role_investor_additional_role_and_more.py": "608b589dbdc0d0f3eff8b9762534c99ec21ca948e4a87072cb6b89c32c89470d", + "users/migrations/0012_remove_member_speciality_remove_mentor_job_and_more.py": "b42f1901c358325a7823eafc80628cc6336310ab4b88f76afe50a450f3bb92d1", + "users/migrations/0013_rename_additional_role_expert_first_additional_role_and_more.py": "a373c4c1868b4991f4da4f4eec641e7c85a25e048a93a083d0de42eac0ae9bad", + "users/migrations/0014_alter_customuser_birthday.py": "61aa63742f6304ad19ab9b85d3daeb13ad8027b039bb9fab9be5f8e17710baeb", + "users/migrations/0015_customuser_speciality.py": "09ae6f01de456c9592f6ba7edf3a53a1d0b31b3a4ea942668900515b591d6344", + "users/migrations/0016_userachievement.py": "9f418da7098f0c8f64d04f7d518d4257f9ed4659be862530c5f76d1f3d7fae0b", + "users/migrations/0017_alter_customuser_city_alter_customuser_organization_and_more.py": "380beb8a54ddd487fd0f737bfa940f20e450c24f7c9bff4d62f3210501ec039b", + "users/migrations/0018_alter_customuser_about_me.py": "5f3258d6c19a377b22ee6fbb179ab569caaf51f8e01c1a8aa7c5ed55938269dc", + "users/migrations/0019_alter_userachievement_user.py": "3043e46aa1dc3762b289608541bc3e64c14098a4e0acb7ee50affeef16fc2e9c", + "users/migrations/0020_remove_member_key_skills_customuser_key_skills.py": "67bc8e79b76e3be01c0c7840e9715e7c90ee20889371cf2974a73443d08ea92d", + "users/migrations/0021_alter_customuser_key_skills.py": "1aaae401335f4ee0d609c00501af31f1e0ab657517991819e28f0e27ac50ef01", + "users/migrations/0022_alter_customuser_user_type_and_more.py": "ed9afeaea648fb8cb9ad285717f64e5b669f98ceb6e2c7afedd53b706c5c81d1", + "users/migrations/0023_alter_customuser_options_alter_customuser_birthday_and_more.py": "2619ad4d503cc37bea145d79ba37a3e8ba0cc83d8c1696503267b9185660c4fb", + "users/migrations/0024_alter_customuser_birthday.py": "41e2fa170ac6fda44b36279c62b717a9118204cd7de38f657027202bcdd22d0d", + "users/migrations/0025_mentor_preferred_industries.py": "7058e76caf87b1502a61269d2fcbe7c4fbd007c4e580b6a46673b52c7106684e", + "users/migrations/0026_remove_member_preferred_industries_and_more.py": "9eaddb52bbd2910ea2b16600e59f0b3dfc084128d3405b2fea3d9f988fbc43fd", + "users/migrations/0027_likesonproject.py": "bf5e3b47d8fda37bad8fdd431bd1863595b4e88f2bd8c10d0c20d37cf00de8d3", + "users/migrations/0028_rename_like_likesonproject_is_liked.py": "e0d39ac87684347a9abb9a3241cf79142c81a9202f3ebbaff4ae8b363bc68a59", + "users/migrations/0029_customuser_onboarding_stage.py": "e3263fc4ffb3309502c7386f06bddfa553ccbef331d479a2bd4c028c5131c26b", + "users/migrations/0030_alter_customuser_onboarding_stage.py": "4ceca36e3cb1e2e0193277ec47d84e9245bc53650805c1980b40626c3d7c68fc", + "users/migrations/0031_alter_customuser_onboarding_stage.py": "da47d24f570b1c73b5f97871d810f882ab7fcd2f9a6a7ffeba14135829c81ccf", + "users/migrations/0032_alter_customuser_onboarding_stage.py": "3e9ddfeee9d0a0fe6c86a530cbb20cbddb75cad94af5db2c5bc41387bfeb766d", + "users/migrations/0033_customuser_verification_date_and_more.py": "c315ef7f6ae9909911f29ce80c83c093d52cb202c1db930d2f2c1bc8d20b523d", + "users/migrations/0034_alter_customuser_onboarding_stage.py": "5aaff1f7cc7645e84906e40764747742341cf66351fb04dc46aa7a3e4ce190cf", + "users/migrations/0035_alter_customuser_onboarding_stage.py": "67b428307eb3fa2e17c92294bac5c99cbc0ae9384e2ee5596832534b485b338a", + "users/migrations/0036_userlink.py": "928a025632cbc57a96360e445834f1b06059abc68f4a5b243cda57388ac9173c", + "users/migrations/0037_auto_20230713_0023.py": "3b3a2d5cf17949260ffae56626093835242b695d34ce2000b2e49a3dd86a6275", + "users/migrations/0038_alter_customuser_options_customuser_ordering_score.py": "c114060683d3eb0ebd64823d686c08fd1766acbeb2e0a7631b071717e7172c5f", + "users/migrations/0039_alter_customuser_first_name_and_more.py": "c3feb45055a211fd02eea8a419d5561356491dd969beb6f1df710d5329e8c869", + "users/migrations/0040_alter_customuser_user_type_and_more.py": "8dd0e0871c1fa9b0f60df68e41c06e1831ec8b71a698308006416d5c5f220400", + "users/migrations/0041_alter_customuser_options.py": "7016bc5e489018a8d04706b4098e0c00c01dc4dc02941e5b81585ed34d513283", + "users/migrations/0042_alter_expert_options_alter_investor_options_and_more.py": "5abf17f540867357bf112d9f1345a4e800bdb8bfa6dff59464dee1d7f2a2983b", + "users/migrations/0043_customuser_v2_speciality.py": "e54f83bf7f1b31134276dcae922885582ed44cc567d22bb351d01aeed33ecb3c", + "users/migrations/0044_auto_20240128_2236.py": "32caa62befc51e9ee4d6a31bdec6a34c3fb09747a6fbcf43310daea0a14401cc", + "users/migrations/0045_alter_customuser_v2_speciality.py": "336bf39bf746d77fb61f78b8dfa90c13fcd352d7049c5de160e55f3ebe97286f", + "users/migrations/0046_expert_programs.py": "9551fb76532d07b8ee36189dc0b0b35381e8bdbb03ab02080bc0acf0d615d65e", + "users/migrations/0047_auto_20240301_1531.py": "fb66b94686e323e5f768cef58b0f6b519472b5dfad0bf9082675587689495d50", + "users/migrations/0048_customuser_dataset_migration_applied.py": "c58f766ece3c4150e79aa873eeafeda0c5578398f7a04c5f93db9d18138b15c4", + "users/migrations/0049_alter_customuser_key_skills_and_more.py": "1c90bee2cf94ed94d8eaecec50f6cd6465f15d58ce270182688881adb5cd5f43", + "users/migrations/0050_userskillconfirmation_and_more.py": "5386540e7fcb8deb340906ffd722069db1dd3d63d86af393d7c175cc6a5b3032", + "users/migrations/0051_alter_usereducation_options_customuser_phone_number_and_more.py": "cc193861b5d90f08a83ff838595fad923e37ac2f269f46abd06c8c54a5ba1b3c", + "users/migrations/0052_remove_customuser_organization.py": "9037f51a3381ef114ed95a077a30aa75192b6d40c6437f32f08c9d1edd276e27", + "users/migrations/0053_customuser_is_mospolytech_student_and_more.py": "e94e4f1ea432009ee2337230d3edfa8e25b0f7ae1334e23c7f310ebf6d84e5e8", + "users/migrations/0054_alter_customuser_first_name_and_more.py": "d06c7fd06d6f43b0f26d3416abe4ec5a1c34e0a4a341a663f8f92be65513082a", + "users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py": "761cd07a3a23e1ffd15b60a0ae3fceed9c78ded0219edc8f28585cc58f8478e3", + "users/migrations/0056_userachievementfile_userachievement_year_and_more.py": "d2ac39c749c38a092cba2b9a1b61267573c9df46b00c6afd107c27e30a1af1f3", + "users/migrations/0057_alter_usereducation_description_and_more.py": "9030a3c513db46abca22135d0fc7247866f10357f2dd20537fa1e05874059b40", + "users/migrations/0058_userachievement_files_and_more.py": "b65b9a14c055da43243d4acbbd424d187f509ebb9eda140141d718186f2421b8", + "users/migrations/0059_customuser_last_activity.py": "864f751a79c1ea53fdefeedf8b3fc14650964ed4f3df9b248ca4a86406759a6d", + "users/migrations/0060_alter_userachievement_year.py": "077a588263132c3a5d7723a9e49402d3bebf66a81f7c2803f0850f2e7d5d7592", + "users/migrations/0061_customuser_modal_acknowledgements.py": "a3232e80cd3cc97596bc817229a00d41f6e2b6c2ca823fd6a74081f3e01367ba", + "users/migrations/0062_university_directory.py": "3436c96e25643be8c0bbcc0fe2b330e99f111811a515dfcd4bd12f680e214708", + "users/migrations/0063_seed_university_directory.py": "64885ac35c0d5e03420020a03ea9f89520909643e8c8f5127d5a89525f711722", + "users/migrations/0064_expand_university_directory.py": "36d6cc02acd6957c3f2a6e3192f71f620e2066448b926d201ed5306312322939", + "users/migrations/0065_alter_customuser_birthday.py": "0ff0af6ea95f47738f48148421d9aab69e96a14cb4c6233e79d7f69d0477d364", + "users/migrations/0066_alter_userachievement_files.py": "f670725bc474e967d5abc87350a9eb25b4207d4e7a9ca9403043dd085e1e5e91", + "users/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "users/models.py": "ab366848614ebaf17f4345e1adfdb9d4f516cd293f5fd9efbe3c20d95aa79b24", + "users/pagination.py": "c069f5794db97c83b71223cb0a943629fffa842c7516a05fefd511c98309ec62", + "users/permissions.py": "5a87c751f610769f228dc10b2df68707cd0af0e39aeb0f74be84a8dd5baf5593", + "users/schema.py": "43180a61674336d60432fae7f186eccf5122941979e6b0350b1a2faa8597573b", + "users/serializers.py": "e43e9ec47e70d47b01df5750128258f1a70167ab0fc91b13a50be7ca16cdcfb7", + "users/services/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "users/services/cv_data_prepare.py": "ac9d9666955794ca2f941463dfdbd4cf22e90fc59ebce31f2b9606e33ee3186c", + "users/services/member_statistics.py": "d41a22e586c674c648a374318be57deb4cf1fc7ff4c861d9000920790ee8adf5", + "users/services/users_activity.py": "3be0505b7a86d119313217b09e85dcce4efe3b21587fe7d86f302634963d367c", + "users/signals.py": "38ecb23da8540b6109f7d35fa0aa1a0969c8c4a85ced1f5cd9a4360838b6b3be", + "users/tasks.py": "737ee785af132d18e0e2d9056e2009adb385211a0250a2a5d252eddf7bfb5e49", + "users/typing.py": "e7fd65fec3f99315bca61ff693e43a73b1b5792ee798264b16c12a362bc7166e", + "users/universities.py": "40b88c16a10a04f39f087689aed29c0366cee0b14f2f6c1f8ec6448948286c2b", + "users/urls.py": "cc36b56a6192b05650592b1f835b3baefd064e4dd66ce75245a818e7991eea7a", + "users/utils.py": "e5762828bac37d4ad3b4fd108877e058ed8bb9e76e830ac75ce73c1c744ab784", + "users/validators.py": "76ba4c1982ad569c578025d04ba79a71b8e9865fec364c1655afa81bf052d82a", + "users/views.py": "59e3af1f80c0d31f5c4cf86d4966d19c637786305a055c6cc57c8089f49e9c17", + "users/views_universities.py": "39c0def013fa92ff5c2663a90cded8230b8b0153bdf501fd7c14e50f27b88255", + "users/white_label_candidates.py": "6995344df5ae466f194125f04c3152ba78a6a40819239754b9c416877fcd8831", + "vacancy/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "vacancy/admin.py": "0e7fe6e548acdd6b8551af9573246c27d4f5674c449a4a12c9e6c7be26ec38bd", + "vacancy/apps.py": "ea160c47982f0a06da1f88eccebf0c496c1d879edab4eaeb4613033daf0ca991", + "vacancy/constants.py": "12dcc7e6b398f7eb2227e0be069dda22ef9d281fd702095087ccd693e6fbeb55", + "vacancy/filters.py": "54ffb93e1178e5c1651e69c42b276cee0ff0e5fbfb2e91b4b2f1a90dc4a3b607", + "vacancy/lifecycle.py": "4d81241bf33218bd327a78ff04d20e8ab831d43149cb02bf1f78a62e44f5e173", + "vacancy/managers.py": "75f9c946aed53d20ba206165624c97a10af9794f5c366ed09d8d2fc584c49966", + "vacancy/mapping.py": "990e4049f92c9b78682b0e43cf076fa3815936e25d72c87848c7da7de8bc6329", + "vacancy/migrations/0001_initial.py": "af6f2a7afe4b70e5a995783da9165f282fc5d5ebfc430c6d85c1328d4278df7f", + "vacancy/migrations/0002_rename_required_skills_vacancy_required_skills_old.py": "937f742e25606126ae9c0799b5780a3ab2eec6f602b32c502f1105ed8674dd46", + "vacancy/migrations/0003_migrate_old_required_skills_to_new.py": "f02085b3c5e9066bac202edd00c133d302adc74b717354feb0560b993164a148", + "vacancy/migrations/0004_vacancyresponse_accompanying_file.py": "a8768e6a2bb0ee10a450776fba118d5f676d8c1c21ae2e0e31120132b799bbc7", + "vacancy/migrations/0005_remove_vacancy_required_skills_old.py": "3770e21db22fed25253e0d5fc646eb23f5a0f08df2c5499dc5085a80c4b48d79", + "vacancy/migrations/0006_vacancy_datetime_closed.py": "caa296c8b6a525aab1a082c0ae23f11243b80c51bb61dfc4520131df09c5f6cf", + "vacancy/migrations/0007_vacancy_required_experience_vacancy_work_format_and_more.py": "7c3b322e3a448525ea56280800521c98e841eab5a6615ed1e7a2254afcc44f2b", + "vacancy/migrations/0008_vacancy_salary_alter_vacancy_required_experience_and_more.py": "d98d55695939883f0b2a3e15c6fb895f8ac222c412ea89829101a7a7490f8de6", + "vacancy/migrations/0009_vacancy_specialization.py": "38a82d644a9bafebfa8898cd77c803ece8fc956f75bc91ec8d4f6ce51e64d5fd", + "vacancy/migrations/0010_vacancy_city.py": "86978843665de1f80e11edee3b7f0da1dcbd44e2514b1d6a826beb4de85b1958", + "vacancy/migrations/0011_vacancy_resume_on_publish.py": "49304300ed5089ede9fb4d33649c7c63f13f7b75ffe548a16c2601eb8d38bb1f", + "vacancy/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "vacancy/models.py": "0d5a998f53be3850386d75cd75955b686ce577d458fa294636e37e8f0ac067ae", + "vacancy/pagination.py": "d8e88ea8c1d9ff3214db824a45008acb9ab26d071ed9918f996d8e30565b99c3", + "vacancy/permissions.py": "9ac1fa62feac27ab0f6a5a276afa6848e6d69498d660866c6cdb1c1b713e2f89", + "vacancy/response_services.py": "cb18f45359aaaece74df1f94acbc3379be1719a4a12300935c574b36d76f4739", + "vacancy/selectors.py": "4e523f644e83056bba97f3e7ed3ab39208204899221d3bdadf9697fa0be18d54", + "vacancy/serializers.py": "8747b3eecfb49ed378a17f24b02a2ac4607389022c7feedb9b00bbad8f09c7c5", + "vacancy/services.py": "cf351dc9cedec74605c16a6b6ebe5a82719b7af5cac787e9bb1d4c068263507d", + "vacancy/tasks.py": "f121467dbca4e425271c91d999a812811ea377ba877557c800e707f2ef91583e", + "vacancy/urls.py": "4d857bfe94c57a98da832911091c3c2d3bbd84ef270356d4bc7dacec7094edf7", + "vacancy/views.py": "637bbf61f966b1e8dc6da8821d5e9aebf2c2fa52b2ef0d7aea68964939cfcc03" + }, + "target_hashes": { + "partner_programs/admin.py": "ea42600ed500595135125d8130769d51fb7e71fdd8e068d31b93f270f3a241af", + "partner_programs/admin_overall_results.py": "54ff457667c9d631f68f17f9e398f25ea336b2ff39049edf11400904cdba9fa1", + "partner_programs/migrations/0045_program_quiz_outcome.py": "c27e4cf64368ecf26d9b903ff5bcceb3aae95801c5ef1f4117f391ecf1b08104", + "partner_programs/models.py": "427ae09130ca0af085944268792098c849572c9daba8a7570ea45193d932a364", + "partner_programs/overall_models.py": "a431416849f3fd0b71e3566b924023b45095391747e435b1374e406592be7393", + "partner_programs/overall_views.py": "93fe9f47f4e9c24bcc578854c52095c7dcc714058b65fb4a634e66fdc3b49298", + "partner_programs/services/overall_results.py": "c5cbbc2bbb52c52c391012f8eb931cf4cf46b24b88c189e6fd426f3e2a24aa70", + "partner_programs/services/overall_results_excel.py": "56c2410707489f941a5abe0ef2c9bd74852cc5b8197a7190a65a5eed48ff9cfe", + "partner_programs/services/quiz_outcomes.py": "9eca47bc7f0f0b1f513896617f4247e916ae7580238a0df6ad0f0d5a158837ee", + "partner_programs/urls.py": "45e57f2b7442c04f2ffddc93817286d983a7a95a86c15fee1652f485d77249f7", + "templates/partner_programs/admin/overall_results.html": "7635892a20c5c75f07f0d29b6a2e3fea9a08e049230526308764a874c059bfda" + }, + "overlays": [ + "partner_programs/admin.py", + "partner_programs/admin_criteria.py", + "partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py", + "partner_programs/models.py", + "partner_programs/services/management_criteria.py", + "partner_programs/services/results.py", + "partner_programs/services/results_excel.py", + "project_rates/admin.py", + "project_rates/criteria_policy.py", + "project_rates/evaluation_math.py", + "project_rates/migrations/0012_criteria_weight_percent_and_more.py", + "project_rates/models.py", + "project_rates/scoring.py", + "project_rates/serializers.py", + "project_rates/services.py", + "project_rates/solution_evaluation.py", + "project_rates/solution_models.py", + "project_rates/solution_results.py", + "project_rates/typing.py", + "templates/partner_programs/admin/evaluation_criteria.html", + "users/filters.py", + "users/serializers.py", + "users/views.py", + "users/white_label_candidates.py" + ], + "frontend_files": { + "3rdpartylicenses.txt": "10f5f544ec3d71c40c93b66fcf9ba0fb9c0f484b443e5dc1487e5acb3bb294cd", + "assets/downloads/auth/shared/privacy_policy_2022.docx": "118f33f152970e85bc893ca75afd62883a83a1d3de17f356b9f9e6f2945eb8bb", + "assets/downloads/subscription-plans/shared/Договор Оферты.pdf": "44841192cfc06b08acf8340e13b68f26fe312d1aa75a00230df5bbf76ac9b926", + "assets/font/Mont-Black.eot": "b5b32b3ece5fd612d52631066e308ea0733636bf3a6cca8066cbd5d309a1ad06", + "assets/font/Mont-Black.ttf": "00c2fc2b91fc6ff250a2f91a67e6eb11c27b093c2bc75661942fd5653b8ceba5", + "assets/font/Mont-Black.woff": "f02577df1049b69199e39f1dac8e086d46521e805eac9731aa578958439258cd", + "assets/font/Mont-Black.woff2": "a94582ba690d74b4e0e9272a4d72cad170e513ed9198ca042ed6475d71c1f8ce", + "assets/font/Mont-BlackItalic.eot": "76137797d8e7bd43d87ab4d326655897fd1d819139e02e919ea2fc5c094446cf", + "assets/font/Mont-BlackItalic.ttf": "44819196479a62c322e67b419739f239cd236e5c4a16a113b3c6ffb563808fb3", + "assets/font/Mont-BlackItalic.woff": "91f00316e0262c203e3159e2a4be2ca11464cc7a59b68b92474d9476bf72eb64", + "assets/font/Mont-BlackItalic.woff2": "cf9408b2915a7e8c74c8aef4d5f5556cca048c79a123313b3f2742f47fee3dcb", + "assets/font/Mont-Bold.eot": "673fcac77187f1dad0d5974ddeb4471bb7ca6370da242f86a28e596127f9a117", + "assets/font/Mont-Bold.ttf": "0371bbcb8df8f597af0237e13a9db8a04892649fdfd595c283719d3aa56018a2", + "assets/font/Mont-Bold.woff": "e3bb59a5d49ed3ee29c92ae13c4c4c390eb4ddcad55fb72f698742a600efb0c5", + "assets/font/Mont-Bold.woff2": "141fa4004c0413987f1638c827f2faba62b54bd1f618796c49a5cf8f5ac7c4bd", + "assets/font/Mont-BoldItalic.eot": "6e94762bdbb16001cee4954cfa4e229cadc77350e22fa4f62d9711178d4cc38c", + "assets/font/Mont-BoldItalic.ttf": "a150c940551debedc346b39f8a248c528b884ecce261a97472e83153eaf16154", + "assets/font/Mont-BoldItalic.woff": "b677650c8b2cf320fe66855c6160fa53a065fe10c869c0696935b4124a475e27", + "assets/font/Mont-BoldItalic.woff2": "48f860daed27879358252afdfc6b3cda688470477e0da7079c80fc7b193ad7d7", + "assets/font/Mont-ExtraLight.eot": "fdd7ee32e0a22d1d47e5d1f713b72e8435513042fd3e470a16df1fe310512119", + "assets/font/Mont-ExtraLight.ttf": "51593eb80d7ae71e001321212015ec726253c411f80c0dd09435e674f5c01bde", + "assets/font/Mont-ExtraLight.woff": "d0ce6409f197d19065f0c86c025ec87c99a45d3add7cb4959c53f6206a16dab2", + "assets/font/Mont-ExtraLight.woff2": "e5b5fee9a6c36d70e7c994b0a48d82d883baeac1795a48b7dd27282da1587d2c", + "assets/font/Mont-ExtraLightItalic.eot": "1237a7d4d7921f4c65028cd85294361ef599027ea0689876b8bc5ba1388b5fc1", + "assets/font/Mont-ExtraLightItalic.ttf": "33b75c709fab9e7dea5a6b57c3525e8e1defb82d6714b072cf25ba152019406a", + "assets/font/Mont-ExtraLightItalic.woff": "0f3ede3403a6f4ed01245f190e4830392010a7a1fd2660df11fa2ac30c97eaf3", + "assets/font/Mont-ExtraLightItalic.woff2": "eee60ca3d948e08df506d12a28751ebad3e9704af50a8f39eb8acca82d76abc8", + "assets/font/Mont-Heavy.eot": "615c73226cfcd0704155428e00a65b969f971bbd90dee352adc54ae15cb02eba", + "assets/font/Mont-Heavy.ttf": "24fa6e8d4038a37a4a79ec087cd4b61a392474891e1158837537d7f9f4e8e101", + "assets/font/Mont-Heavy.woff": "860084fae53b928995b4809cbf63ffb08b9db77040fcb0762dcf7d1feef6c2d1", + "assets/font/Mont-Heavy.woff2": "f59d60e6232576f751dc60eb2e660d4b8287464f130bd77b3b2eaba1a5671390", + "assets/font/Mont-HeavyItalic.eot": "a538b9678e183a205f76fa4d3b59de00b1ccbf132d41e65cf78e324e38b16b54", + "assets/font/Mont-HeavyItalic.ttf": "23115190cf8514ffa96986108818a89ebf6f00c2031cb976cdee1b84a10479ca", + "assets/font/Mont-HeavyItalic.woff": "ccf64e0bfa463de88abcb2151071da1bb5b5323594eb78cd18b71a8cafb1ee71", + "assets/font/Mont-HeavyItalic.woff2": "2d043a8ba71ab28e1785ae49ba985975bd0b18a6db0f7c0b9f8c36b18e72aca0", + "assets/font/Mont-Light.eot": "20afea63f0d8d8bd9b4772d47713502a75217c27f002888d0dd799866fc5ece4", + "assets/font/Mont-Light.ttf": "038d167769ba21ab4b3880b6a84575e1c5134b69632404f47b1e734912adb613", + "assets/font/Mont-Light.woff": "34a9566626c3d1b89f5d7cdef04a6bb6f64515755d403775aace1af808b96f30", + "assets/font/Mont-Light.woff2": "db1925fcd80019c78b84ba15e7644fcddd447073818b88ea907347758380c37f", + "assets/font/Mont-LightItalic.eot": "443ac126d18c64c7e81c7fdf1ce7e35968d5b32af55760249fd17f5b6ff9cc19", + "assets/font/Mont-LightItalic.ttf": "517c8307e4a7770bc966485f2d7d969b2ab9a136b2972198062e5d4c6d50a5bd", + "assets/font/Mont-LightItalic.woff": "914c8514e3894d6035098030cc249e5f64026adc3f3a7f216b7c910c828e9301", + "assets/font/Mont-LightItalic.woff2": "6c0d81865be35458e30eef22616a4b7c96bc1946b31fdf5201037e315cdc3d7b", + "assets/font/Mont-Regular.eot": "65a09751a7a289a0bfa64284a040a148b71b23ae8ef85e261eeb89ddc6d5116d", + "assets/font/Mont-Regular.ttf": "0bb5e1336fbeb2c78e049cf3a21101289c7b6c906d7335a5859acedfb8aa5875", + "assets/font/Mont-Regular.woff": "29c81b198136bf8a929f52133314319cb6d8375a526da5c97197df1960ceebb3", + "assets/font/Mont-Regular.woff2": "e01372b13985c09f2a168382ad044312db914801a595b4306abd7d686b0cc64b", + "assets/font/Mont-RegularItalic.eot": "6529a6d919ef17fce494b2d2bf02467c0b3412dfd65a82d81289cf0c8a95a8f9", + "assets/font/Mont-RegularItalic.ttf": "207550eaab2b7778409ea7cc52a560a9fad67abaa310c8a5bbe043e2066d3427", + "assets/font/Mont-RegularItalic.woff": "9b4bd95152b25732581590cdb33b595639fe76de1f89eb52d4838888de492f24", + "assets/font/Mont-RegularItalic.woff2": "1f17226d2391542d53063104a35af2dcb81f359bd3f9504be0630514548fc26e", + "assets/font/Mont-SemiBold.eot": "d0c7662470a8da9c1dc4aab63a44ff7ae9bee2aef4ab87d0efd91fef1c6ea1ef", + "assets/font/Mont-SemiBold.ttf": "a0d30d71b1ed99f8ff6cd6e5a83b93dc832e4a00f6cf402f587351c1ba8f7dc2", + "assets/font/Mont-SemiBold.woff": "1d01cc3f8fafbed1582b3182c40eafa607fb6670a179f23116d8eed2e8907481", + "assets/font/Mont-SemiBold.woff2": "ea0681c9068fb473d3606e012e76728eb44b2e04c276266894aeca5fc1934b52", + "assets/font/Mont-SemiBoldItalic.eot": "c7130fc00335029f97ce29e2ef0ba8278f9928f2e0e94fc329eeb5b434382b6e", + "assets/font/Mont-SemiBoldItalic.ttf": "d41d3c156ecc3fb13c05ac8a5134847c0256a1aec9c9009be20864637a5accaf", + "assets/font/Mont-SemiBoldItalic.woff": "6cc4a96ab2ad4df23a05f33d2ba02102ba50804293dbe38aeaf3a98acdc1edc8", + "assets/font/Mont-SemiBoldItalic.woff2": "689bcc5350fbdf6186ef0cfdc9f66c5849b2f763f25c084ce1cf4a0a3cba4f41", + "assets/font/Mont-Thin.eot": "2a78e0788c50d1d1abcbb9143bb9454280d8cabfdd8b126bb66bf33dce923d74", + "assets/font/Mont-Thin.ttf": "82fba7ea5cbfda83867df7bea68da990caccb92745988e69a529412f1729aba7", + "assets/font/Mont-Thin.woff": "ee1781a5334f694b984e94870f3693705dc9f6de2bf9ea9cc09ec546cdf3a24f", + "assets/font/Mont-Thin.woff2": "fcfaf54ddf82e941398b75638a23a8318658ac8f1e09c1c954788ae2840acbd9", + "assets/font/Mont-ThinItalic.eot": "8a24a0f0ffdf67b114b114fd888e143369b3217c01fe112274a315fdaaceb8a1", + "assets/font/Mont-ThinItalic.ttf": "2774c696a2fd490a27c4a151d0c5a00e8439bf4483c0878f5380b5447cd52337", + "assets/font/Mont-ThinItalic.woff": "c7328fe67271bfc102091615989f9e303f6e6312879e8d4572b963ee3df09cba", + "assets/font/Mont-ThinItalic.woff2": "ea3e03419814ee43bb95f38636d1da5df78c3a7aa26233080890bc27bf6dd197", + "assets/font/certificates/Lobster-OFL.txt": "88aece7d90f2bb7049719f11619a560af22af3451af141f12aa4f46bb157a99b", + "assets/font/certificates/Lobster-Regular.woff2": "fc34318ab076213055f3efef3c6f59b295db3b3d32ad98afc77691b9d2f7dbff", + "assets/font/certificates/PTSerif-Bold.woff2": "1086afee8bbda29eac51ecf3f236567d2eb93bb820a676530a3af887cff6785a", + "assets/font/certificates/PTSerif-OFL.txt": "ddf311c28ddf5a5ad9747649837346b67bed9d356789c3072bb27dbce49e514d", + "assets/font/certificates/PTSerif-Regular.woff2": "651c35f6cb50a1f31146912c8d083e0c15de77b016939490647bf2914d3ad169", + "assets/font/certificates/README.md": "f7a4378954e2c848e91be4d2677b2d7540a5e2ef45aeeb7e67760c4aaaad8bf4", + "assets/icons/svg/academic-hat.svg": "f1ebd7747a9acff64071d9dcc5fc896f273e018fadc1885af63e48781dddcf8e", + "assets/icons/svg/accent-error.svg": "d4f6fdbeb37ab1fb4043dcafaebdfd5403acf951ed1761605f579c47817b59b6", + "assets/icons/svg/achievements.svg": "dbad646f847267d07cdc33d5fd90f5e8c0d89fae386e72ae493415060ca0b839", + "assets/icons/svg/add-person.svg": "d355b7508a87b8ba4ceab4916841158adfcc4aa95f3e66f67341a2920c557147", + "assets/icons/svg/additional.svg": "161ae69208e7defa01d965305616f82318df58a2307dbca76cddc82a077c78b1", + "assets/icons/svg/analytics-submitted.svg": "1db03b920ec67c47263fa07bffe6471a1a7df4ac341e877e40434160c9736a0b", + "assets/icons/svg/analytics-team.svg": "f9a36444820d11623d44112c65f36be39ddaea41cde7bd624bc9ecfeb6981f66", + "assets/icons/svg/anchor.svg": "ede46ef85c113f496cb00f80ec93ade7ba7dae5b7653405ef9d22f5a2e542edb", + "assets/icons/svg/arrow-down.svg": "49eeb7d46c46fdaa41558d878c384177abb91cdef2a7f81f1f6bc2243e257675", + "assets/icons/svg/arrow-no-body.svg": "ba8c40f6beb8fbd7cff0e73a208d64f838bd1009a3c58ca589366f9c39eb75eb", + "assets/icons/svg/arrow-wide.svg": "f4ca69889fdccc34455730e59d524e35d32e00eb5e331919598a9d75a64b40f2", + "assets/icons/svg/arrowright.svg": "456b4b302a2b57d42dba35d5362ebe34621da91f7f3a1c059aae28810e7b1635", + "assets/icons/svg/attach.svg": "2cc77358759878120340c760ae22f5ff093795aec9622a6b1fe5a0eabf43950a", + "assets/icons/svg/basket.svg": "0675d68c761f8401c2b8354f1e6ee1165676ceae351141b24d8ea28a59c960fb", + "assets/icons/svg/bell.svg": "7486b0ca74c9c8d0ad63343b523358e258878bc04bdf3745b091a95f8dbdeb0a", + "assets/icons/svg/book.svg": "04fa7f5bd0d7bdeccff8d2f582da16125023562f11dbf9bc17d1b4119593dfc9", + "assets/icons/svg/box.svg": "3ab37dd4d3c5e5567f59a6cc813213241364c2a5255fd750f26182ab0998da27", + "assets/icons/svg/calendar.svg": "e0923360b7b7d7364e7e9382438abb5f498a494263c4ba3b91180ee08ac44900", + "assets/icons/svg/chats.svg": "93e3cf266bb089bddbb68b8101bb0756bfb4af8d88971cb7e20e0456db94ca4f", + "assets/icons/svg/check.svg": "ce333739db97ed9e527902ca57163c4c76fdbd95603880faa7f004029a7fe6e9", + "assets/icons/svg/chevron-down.svg": "554c4ada5fc10b47c3d4047b74815f0e6c8fb4cc6ed572bf2d71995f72bce18b", + "assets/icons/svg/circle-check.svg": "28de43f86c7eda529b0a8f8751e4caa905a290474dae23ce4b8dc6a288c308d0", + "assets/icons/svg/command.svg": "10bc2a8c7bdf9105262074d4f33756bb392d7593e2b80d78976ccf2d6a86c820", + "assets/icons/svg/comment.svg": "366d6d091c2dad0bdd034bb7aaf1f7e4b2ff3bd0659b0abbc623694d3680e985", + "assets/icons/svg/contacts.svg": "d47a809d207f7448a8d36f625f77f57c05ef0adf9997ecb2acb06cb45998b66c", + "assets/icons/svg/cross.svg": "152c67d622cf3ab3a9f994256a700423751c28eaff42b3b8268e4fc6ab119ad4", + "assets/icons/svg/deadline.svg": "0d3af503613f36874a66e31755b3cdd9f18b0a51925d0f1698cc607bde09cb12", + "assets/icons/svg/dots.svg": "5c5eede4d415a546635f202961be20ef768d55b2f7f2527b5702b6872875e2e3", + "assets/icons/svg/download.svg": "92bd50debd8d4d3763e6dfffc6701880637290b9deb7ce737fda0ec9ff960c61", + "assets/icons/svg/edit-pen-old.svg": "233413531f966eea74739c0b3f92501a96a4a8c12e14c7dcdeadba29771110de", + "assets/icons/svg/edit-pen.svg": "a56f452804324800883db0ff86fcadfe31de47927ef1d7c0de4797fcb1a5c0c0", + "assets/icons/svg/efficiency.svg": "36c3c58f6c12a0356c9427d964da25be83152efa34ac5773282ca1bf98c4ac8a", + "assets/icons/svg/empty-chat.svg": "fb661e453bba7b8435d9d71e7f9491b326871f4583920333aa98829ce0e19f4a", + "assets/icons/svg/empty-mail.svg": "ab03db717e863e84dd63787f2da65bc72775ab55d3f121d0a5000a8048346f01", + "assets/icons/svg/error.svg": "a6a53258a37230b39ce3fa281d53c0d75a20ea5f4ba1a7f1925a3847484a3af2", + "assets/icons/svg/eye-off.svg": "ce6ec04e8df239e45d0955a9a9955f30f1e8858298bba8abf7f5162e07a3a11a", + "assets/icons/svg/eye.svg": "2ad0716fbbbf3d103a5953566285388b5b961ebed48f888cce5903cf42541890", + "assets/icons/svg/favourites.svg": "204a514e8b9a5c16f420278de96a37985a9a3d86234d3d0f7e7b8a671407dfdc", + "assets/icons/svg/feed.svg": "196190a4036844581e4fbc460287503ecefcd6d2e4834897dc8506f12fc28ef7", + "assets/icons/svg/file-success.svg": "83fac88da530cb668913bf49cc9a8c64acab44c4e068a37e9837a2b6d7d887df", + "assets/icons/svg/file.svg": "f2e8476e0cff4d5022374f3ffa7c6bdbc280f2ae30c9956ae348b75dfc9b70bc", + "assets/icons/svg/file_type/file_arch.svg": "49d92d3d28de322ac1065c0a28118c68bea8806e746bab6e0c7e0d0da4af3d73", + "assets/icons/svg/file_type/file_csv.svg": "9e482a0593fc1a61cd5f5818e6bb031b052dc97e9f1243170d0e66949d74b165", + "assets/icons/svg/file_type/file_doc.svg": "93a76aae04df76f3a6601f529fefbadecf0ea89c9f1153920abf7c14a4b7b109", + "assets/icons/svg/file_type/file_file.svg": "b14487171ccfaadfea8077c097a22c542bb597ed4ec3e6ff2f6592b204bdff7c", + "assets/icons/svg/file_type/file_image.svg": "e2134bf78d5f1036d2d027a1fa5ba385308e3cffb3f7debff16ff6612cb849ac", + "assets/icons/svg/file_type/file_jpeg.svg": "9f2674e29995a43faaeaade75795f4d6061c1cba57633899dd3f90c4538e748a", + "assets/icons/svg/file_type/file_mp3.svg": "30aa65519e07eae115d12f1cf99ec8ca04befabac2ff4faa27738c81e7b01e5e", + "assets/icons/svg/file_type/file_mp4.svg": "bf912f0607a5e5ec08ff0ef3bfcf2a296bd8c6e2ee9234c885281dac7b4cee6e", + "assets/icons/svg/file_type/file_pdf.svg": "28543c7002a8311268bda12068d3f69a06a08d3b9c825ac67dd4787b940d4b2d", + "assets/icons/svg/file_type/file_png.svg": "1c3034acb155c51c6ce4a421ce018a345e7d0b68d219c8eb52d110406d6ca239", + "assets/icons/svg/file_type/file_ppt.svg": "f5f9e4ed33311799584150c3b82a570f8c3d0ac63b9032d87bdfcfda0112d361", + "assets/icons/svg/file_type/file_svg.svg": "1887164be84471f8247ea58d2d30d23adafda7360d02dc4c81bd0110454abf7a", + "assets/icons/svg/file_type/file_txt.svg": "0f7d964ee2fcc756f61386d0f740e32cdd1466dbfb340f0404508b4c84940110", + "assets/icons/svg/file_type/file_video.svg": "a0ea9fe468ef9fdcf0b89c525af5cada2248ab75aeacc3a81c2ec73ed065b7e9", + "assets/icons/svg/file_type/file_webp.svg": "5ec61e22750c70c172b049a29cb84463379aa8128807697edab0f55b12683ed0", + "assets/icons/svg/file_type/file_xls.svg": "0c26bdd828ab9e3c93c5e2d5fe5d6a88a08bc48b1936cdcb7aafa1ae79444a93", + "assets/icons/svg/file_type/file_xlsx.svg": "20678983d227cce74dd1806aa365d5573167e7676575d36b9774b4cc88fa13db", + "assets/icons/svg/filter.svg": "9b4588c7f90dd2173745fb3cad2dfdcb71cc2a5e905d7a4d0e780115b4784b50", + "assets/icons/svg/folder.svg": "8d7b3c5ab1593147c3576cdbe021c6c73c53f1a2277b32c0a3c51cf8ff3ee8ef", + "assets/icons/svg/folders.svg": "3585b3e2628b0dea0734559d3800b0e926ff3309edc1da00f62a86d8849ecebf", + "assets/icons/svg/geo-point.svg": "51b44a9fa6311ce2d1df7064cda7ac67cd95bae0027861eb2dc20876486af6ac", + "assets/icons/svg/goal.svg": "fad71af7723de718741e066cf46dfb385b458c66e68638db08bc957ae434620c", + "assets/icons/svg/graph.svg": "c66a8fc70250f40a2e4b10083b95157c7cd0e9d21a2c9a607637b800a5eb33a2", + "assets/icons/svg/hand.svg": "0e5b636068142e82129c9e3b940264a9ffadbe6d192b9c300df5363457ddd3a0", + "assets/icons/svg/hashtag.svg": "b93d4a6eba052d1090e68fd51af782e1728edebcd21fb0f0e43961b93293cb23", + "assets/icons/svg/hint.svg": "32b28951a20cd04a5bc6f32540c5733a9088ec584a9d77acbb26bc0af492be04", + "assets/icons/svg/in-search.svg": "5fd713d56abe2005572de9d95a3d0a7ca6d05986522dfce076cac7f1ca24a0d0", + "assets/icons/svg/inline-check.svg": "74207853c976459d20f4c671e30121821efab14173fa3f1539e42bd1e0450a1a", + "assets/icons/svg/key.svg": "16e97b4f373d7842a2f8481c6b7299168b8b8503d1b6ed611c41e0640ce4c2b1", + "assets/icons/svg/label.svg": "8f1beefe4e15c35de5957d441825912c124087db7ecf610703c6a24975e8c498", + "assets/icons/svg/left-arrow.svg": "22847871b6f9a61db97665c02517e348900161eb441029564f38b64629bfe11b", + "assets/icons/svg/like.svg": "4c3c0f91a9db66677f63954eff25e79f01e0edb61f87e139340fc1b718f13480", + "assets/icons/svg/link.svg": "d4148452325a700f8f89e33658026e12c05a43840ed3107944481df52a9f2aed", + "assets/icons/svg/lock.svg": "2197b49564f06c7fe61ec608e51c2b006dd19393ba8d1f5b692f28fcb6a1aef4", + "assets/icons/svg/logout.svg": "f1f828ac9e48229e8377ffb0580b642a3c48db49e300f0cc088cb4e6da9c2925", + "assets/icons/svg/logout2.svg": "1df1f3c12c3d512bd3f5afef561ccb1b531327fcdd4d178f1776d5fe89b46672", + "assets/icons/svg/logout3.svg": "7def497eb56975029b74e2c77eea3a9f5d9f7a142a48aaf307d91e920f585841", + "assets/icons/svg/mail.svg": "31b773ed34d5c0b9eb38aaa064a903cd5b6e87511242d3386ad851456404ab90", + "assets/icons/svg/main.svg": "1b2835e0aced47f201cdabdc20f8662f60c509ec2386b5c8bb9c85cfaa8f7bb2", + "assets/icons/svg/medal-outlined.svg": "4c899e6dde73ffcf9ea8f9994f7d502c24af4485aec01b92efa6d625acf4478d", + "assets/icons/svg/medal.svg": "5d3008426cbf165b305f48cb59af7c7403eb2b03e6968587ec5b5334c9e61ad5", + "assets/icons/svg/menu-burger.svg": "314a0227ddf3e45e42d90cc098052b7151016de978fe784eeaa1d5053c65ad0d", + "assets/icons/svg/menu-cross.svg": "701dacbfbbbd2c1be6900b9d74f5673163829aaa385c9c40af29e82c03f87d5b", + "assets/icons/svg/message-inline.svg": "d286efac700d14a9fee64c75f5b841c943a7640e031e01659a8573948b33137b", + "assets/icons/svg/message.svg": "4d2d03a4e97763a433237cabd5c08d5156ede936508445fd516519cecef48fab", + "assets/icons/svg/pen.svg": "2059d5a8343f0f1884e530541a4bc056c34aa8edbfe528fff037a28c116073c1", + "assets/icons/svg/people-bold.svg": "cd1c22feb545f0ad5aaa49b6a43f9256288ab3ff72a94e255015f8b99dac0748", + "assets/icons/svg/people-filled.svg": "c012e876e9c494b93161e21dcfe288c71b83d7f9846a2371b100c6d410ac158c", + "assets/icons/svg/people.svg": "3cbc1d9a91fc27ca4ea78a343ea1bf2ec9617dc42f862b045abba2239515328c", + "assets/icons/svg/person.svg": "b4ba6185224800a65f48eca9b9eaca42364cdb7eb739b672e837b2fde643d269", + "assets/icons/svg/phone.svg": "60f7c7a24f2e80cf045083936a38a550eee5d7b7836bfa21d54f0903081a4a86", + "assets/icons/svg/pin.svg": "34e104fc5757baa38b091b24746cdc9f524832269ee5dc3c211919380908d926", + "assets/icons/svg/plus.svg": "6925b0744f9e7397d5d9cbbefca2847a47a48b3ce08ede616fe8abe33d9222ed", + "assets/icons/svg/procollab.svg": "84f9ff167518519dde9617f66f55f3ceca5adaa76f7d6fa585102251e15d5246", + "assets/icons/svg/program.svg": "d3c92ac8f0eee090f8bc27c2954528d07b7bf941ada11c6ae66ed9cb77c8c5fa", + "assets/icons/svg/projects.svg": "952c4e13a26c9542305e85ddb667c11debab9fb88b29303b5e73b92220b3b2a1", + "assets/icons/svg/reload.svg": "7cd1a0d913e38512355fa8e6e3426648b1bdab2a3d47067335309535c075ebef", + "assets/icons/svg/reply.svg": "b7b394021871bfb3dc2e00c8096f117ac6dfff26537facab589ccc0a07a88a00", + "assets/icons/svg/rocket.svg": "7dbe8dce52dcbdebefba4d8987f3ad95be8817d058440f9c624da4e65956ba31", + "assets/icons/svg/sad-smile.svg": "ecf9ef575748efaf95f329b2f4f0c59adcb98471282a208f5a168f80b46f9417", + "assets/icons/svg/search-sidebar.svg": "f960a9b0a14fa09ad3463ac5a087049c969a5c666fb7ae10c6b6a90a1a6e0bba", + "assets/icons/svg/search.svg": "d7125923fc66715d8af7c0551d61fb66b55c05b9c4d4fc5e7ea1e2f62e3614ac", + "assets/icons/svg/send.svg": "59b6e42e21cf9f6fcce1dfa787ac5431588723cb7e8693453b4b87c58f6cd565", + "assets/icons/svg/settings.svg": "e29c4787d45db5ba6c033abdd818423e490340751e133389453e847c89c4bd0a", + "assets/icons/svg/share.svg": "9939332c05851c02ed89197b4f4de5761ead92f64c8fdc2ddb639991371aa151", + "assets/icons/svg/slide.svg": "1ba844f7c6cfdb3646a23ecf8ff23635a1c91b8c736a009c7ceef8f353c4afe1", + "assets/icons/svg/smile.svg": "36c4ae7c54a39805db20120631ed9e98add02488477e3c27132dd42a107001f5", + "assets/icons/svg/sort-activity-asc.svg": "e2f9df22ddba3b62c6c189d978b94a7e899df05fac90f7ccb03a6b83a1b6197d", + "assets/icons/svg/sort-activity-desc.svg": "b1ea812b9ecd25c580b8deea2dae3216c249381dea56ab1e27d325d5b93f2a3a", + "assets/icons/svg/spinner.svg": "cc37435f77ed822e08974859a38296c9231f9add395ae14a69b70e77be65e6d0", + "assets/icons/svg/squiz.svg": "ba5c66687d62fa6ed9cd8395e42ff2edcac05b4d589e03d02d2cbb3d6b513326", + "assets/icons/svg/star.svg": "5b2470751733a5636dac71bb0cff5e66f2ebc0568985b14decc8b336a5763232", + "assets/icons/svg/straight-face.svg": "d76f651ec215d5c6b3d3ea1fea5367fb81e7349b1143844f112fa3c9d3e018b5", + "assets/icons/svg/subscribe-badge.svg": "db376b0c72ec1caab67e02e08860c428af0486db43af6ef8ba0b8b27833ad257", + "assets/icons/svg/suitcase.svg": "e083977965847931a469becea90cbc878ef2cc84417765755894be5b04b136a5", + "assets/icons/svg/task.svg": "2d052d18d9edd4be95485d7a68edc7363de5c8130c21ea52f540ff95dca99128", + "assets/icons/svg/team.svg": "c905d74f382bab4d827e6860b5ca66ce25cdaa0f38ec5c25e2577c890432e146", + "assets/icons/svg/telegram.svg": "53fa13e1aab8bb0824743992176f02d44a6e408b50c135f4b34763b960576200", + "assets/icons/svg/trajectories.svg": "26b16cb4ceff596802f4a48dd28bce24ebfd81cbca00f7475133ec11ea5bb972", + "assets/icons/svg/triangle.svg": "4fab4877237d59a2324325848ce6f3578d6103bf84391dbc118da9b1073272ba", + "assets/icons/svg/two-people.svg": "3e9c9b3b6dcccaf7d4d8df0960561ecac6cef666ab97048c7e2ee8d4ec0396ff", + "assets/icons/svg/unsubscribe-badge.svg": "283fdc3bfc21f8caa05be97989f3e6f57ce9cf46aebdcf3479db9b694b27ab3a", + "assets/icons/svg/upload.svg": "3f5147e64c3e20666463d749856217533cd3d51e8f0880881c358b0a479c6c4d", + "assets/icons/svg/vacancies.svg": "8a26779972824e3473ac8bd648ee0d0eac879609d29a443bf338803f610906e7", + "assets/icons/svg/views.svg": "166f0df2d1bc12b235d43780c3ce349276c54cb49829fa8178abe901add41878", + "assets/icons/svg/vk.svg": "b19cc5a2ba05f1a79ecfb71209def0cce2746e391c340142945267e90cbc8c7c", + "assets/icons/svg/work.svg": "1a6dcee48be22025dd3b31e2f5b4d72909db934875f91b8fb4af0e8e74327ab0", + "assets/icons/svg/world-wide.svg": "61d5c0c1cd6a9e4255c5f16305cfcf1e77510a636ababca19361ab93db18253c", + "assets/icons/symbol/svg/sprite.css.svg": "2398de44438322c9f644db072d284b0da70f59db344b67657e8b7d086289f065", + "assets/images/auth/bg.svg": "1742940a95019408d290411e9c8af8d34f3aa7b6a32002f2fd21a3fb16eec864", + "assets/images/auth/email.svg": "3e767501b1ae34792a0f2b77097274d15b1307ffd5a8069846502e469b066387", + "assets/images/auth/login-img.png": "d0cc9a1442ca9414c03171e4faa15704bd54b8382ff1f3ff8551c32f804b532d", + "assets/images/courses/character.svg": "e24d5466be290e4d389f4655d05977baf5ebfbdff3711739261d95230576506d", + "assets/images/courses/complete.svg": "ccac92979a7ef5fe117bd0d6beb76d771179deb426a066d61a0e1f5882c2299c", + "assets/images/error/not-found/not-found.svg": "3e0cd151353b9b59f719f5913cbc2245bec31380894b97aeb49707b0562f1d5c", + "assets/images/office/feed/vacancy-card/vacancies-card-background.svg": "cc414473ebf0bcbc21f34a4d96f8a2a33c0bbc554c25d91b684f77538228cb8b", + "assets/images/office/onboarding/laptop-guy.svg": "80e685cde0ed1cd501854226471b7f052a3853a30a591683da551bd9bd7392ec", + "assets/images/office/profile/detail/cover.png": "a3c4bc34bdc819e4d54fde81adb21b3f5c818e6befc294152ffb582b8b0da081", + "assets/images/office/shared/backgorund-image-rocket-main.svg": "91f539d9e5fbf92e074d58d42fd18d2252be31d35b5399ba1a619402e5d4fd1a", + "assets/images/office/shared/fulfill-profile.svg": "212deb59494338b341e8b982e9a68741b8ba8d9b4be11c90a0d9ef5816aa7703", + "assets/images/office/shared/lightbulb.png": "38d20e1c2da1819d02f6897e6ff58d5182e0a2711da205c27a7919cf9ac38f11", + "assets/images/office/shared/wait.png": "67ace598f1a2bdc018d0b0206ff2a8b64a8811363409db817647f1f4ae8786d7", + "assets/images/office/vacancy/send/bg.svg": "7e6313a0d71cd424058cbae011ec359f88ba0c959088e5a0958955544331dc3b", + "assets/images/profile/achievements.svg": "1f97590ddf694fdb638ac8a861750aee94f2d809f9fb24591ee5260ba4567a2e", + "assets/images/profile/education.svg": "73fd9ada024e5244165a2f9083502474b4e184d8dc198f7f987630ec10e13544", + "assets/images/profile/experience.svg": "1896343d5bd8bffd66b273c0f82f37cf3706a96603d3684d20f7172446ef1878", + "assets/images/profile/main.svg": "dc4f95deaa2a0bccc5457398b06459ed18634483e6d6d8da4736ca31ba328987", + "assets/images/profile/profile-unfill.png": "b8fb0522f208016f5470d3a8b84ec4df4a1dfca0570fd681598280737bb6b339", + "assets/images/profile/skills.svg": "8cad15072e1a6eef16746454c4b734a8914ba21d327094a62c2707a0d57d84db", + "assets/images/projects/chat/drop.png": "3c391516a0e9e4d7f158fdee55d8aac9fa2643595ae6da6784111baf6e6064ea", + "assets/images/projects/detail/bid.svg": "812b503a8942d1476d118b8d68053a039fe69b33e0d31a6bd01dda9f1aa9c723", + "assets/images/projects/detail/end.svg": "e626f72b2865d28e076e668f8b0fa7ed4d0c66949c3bdc6225b57a42584fec8d", + "assets/images/projects/detail/leaveProject.svg": "d73ed1886be77e0c87991d61d6bf40cae95a980e238b60cf920b0c5f18703921", + "assets/images/projects/edit/achievements.svg": "e6aff1fd50768e33e0ada60c438bb5e645243535314eccd60a2ea27a8414ac9a", + "assets/images/projects/edit/additional.svg": "f74863c03f7c2fb3d64be40b587193d536d0780dbe4f890cc787c97a43698c94", + "assets/images/projects/edit/contacts.svg": "cd7b3033bf14752b6771af72f2b28e22634940813d8fcc37fe6a96d798653ddc", + "assets/images/projects/edit/main.svg": "eb60565d900e1fd273d8c5f04f0bfcec3bfacf421ecfd6e936bd2d887771c994", + "assets/images/projects/edit/members.svg": "48b2357701b2f103c5d8fa748add76cf8b22868a7971f053d4c14b8c92d61fd2", + "assets/images/projects/edit/team.svg": "e393e1c1faaf9edd7165d5cba5f2a2c7cbaf438e196145eac24378d5efc18a17", + "assets/images/projects/edit/vacancies.svg": "d12b2b96e47c274ff4810de9cc932481b7e92d24de10f7ab52ad43e8798ad62b", + "assets/images/projects/responses/no-responses.svg": "2b2618dee8ae440425f98cf68bde0e09acdea89156ffe98c4b1b094a437377c4", + "assets/images/projects/shared/add-project.svg": "2bce2161c3ed8eac0c8635d68c4ffd2f157c0e61a8d60b3be2dbbbdf52ef36a1", + "assets/images/projects/shared/arrow.svg": "89355315b93a1741ee0589197ca24b7e48a264669395e001cdc2de3921050616", + "assets/images/projects/shared/divider.png": "6b8724221c992006abaef1dbcbc4838d32b636333da9547aa29fa1b8ffd8e8a3", + "assets/images/projects/shared/edit_disable.svg": "b6f8eaa9a8a695e5555dbf212c4edbd062fa0914c06df32cc45452ac2b28fced", + "assets/images/projects/shared/empty-subscriptions.svg": "dccfa9316470cf98c7207471c08c60d12c5bec31826a860e6e92cff18366f6df", + "assets/images/projects/shared/idea.svg": "20194052c463205dd2b3f4c3556e5a8163c0c00f4644dfe80aea23093c5b24af", + "assets/images/projects/shared/pdf.svg": "9fa65e707e5ffed90bad2d90c13726e48393e689bed771f4b5abb590dba0a89d", + "assets/images/results-arrow-down.svg": "4efd8b675cca99aea02bc1910e6c372df125e389c2eeb9c2695d7a7b01eb3f0e", + "assets/images/shared/avatar-placeholder.svg": "75468bd50ae9eb762dd0cdeb9ce8a77c6af20dd92e277d1ed359cc1d5428c630", + "assets/images/shared/logo.svg": "e3e10725df88b75c81067ae59efa51a725f4740dbe4a96c3f29b8e028962495b", + "assets/images/vacancies/detail/success.png": "9db26e2476cb338b1e806cf31b106b0dc292eb07233cb2d1d9de28dc32dfb574", + "assets/project-defaults/avatars/avatar-01.jpg": "22ee8f8eb3b69d8e86513d3c4490ead66385ece56835fe6c05d9867c6fbc1330", + "assets/project-defaults/avatars/avatar-02.jpg": "f41b1e52ff57e9bd606f868d93a06300680585b00d7797cb6e9c5c2cae702f22", + "assets/project-defaults/avatars/avatar-03.jpg": "fa1c5c497a1b549c2d49fda4d90d491d9ca1a152615d33a323d6cb1319a2df65", + "assets/project-defaults/avatars/avatar-04.jpg": "b3a64637363e12037b62001151f8c6f32c4e654b045d5195c26872aef2b04f0b", + "assets/project-defaults/avatars/avatar-05.jpg": "8e8ccd59fb4352c22495d91a834487a003f08ce24864f78bb203c921ce0c5dc8", + "assets/project-defaults/avatars/avatar-06.jpg": "4d001075bc4dc0a8f1990c811b4fced86a6b4fd8af5c814a23e1e9ef59b18107", + "assets/project-defaults/avatars/avatar-07.jpg": "f2efab2a53c64312d55879b4d3b65029515ff644404429416d8ef9925325c921", + "assets/project-defaults/avatars/avatar-08.jpg": "c0c2a9c16b1063728ed951c1037c48eb71ecfc9a3e31bbccdf45953160df35da", + "assets/project-defaults/avatars/avatar-09.jpg": "7a4234ed7901452aa52b8e439459da9801aec94d71165220ea7a5a37b6fc3e19", + "assets/project-defaults/avatars/avatar-10.jpg": "098a1c839374d6bddd532481826397470e67cf781e3285acd7f956c4213a68d5", + "assets/project-defaults/avatars/avatar-11.jpg": "94d90291b9ecc2bb39ec54bb1a5f192367cbd04fa6cfcc72e13111d3b702529b", + "assets/project-defaults/avatars/avatar-12.jpg": "f20f0a7f2660751b6f943dd9e0eda55841f5532c0afa561dd451beb3a5cac61f", + "assets/project-defaults/covers/cover-01.jpg": "23b695ac039285cf38626c95bdcf1e844f9860941c43f38341812855d7c0fa0d", + "assets/project-defaults/covers/cover-02.jpg": "b83db9664485763969f6f33f7b752007e9fb067f13ef921bd41803180c1d4392", + "assets/project-defaults/covers/cover-03.jpg": "09a68044e39185c30906a7da3b01e5f67800b718900a154e22c521dbb127a6a9", + "assets/project-defaults/covers/cover-04.jpg": "9370d7b308bb046cf92324ce7c5be937da19b132349aaa7e07cabca2c6f0471f", + "assets/project-defaults/covers/cover-05.jpg": "34ca5d7f6c68e39d8aaf5d9a26bc4f0750c4e010d11a57912a65b4f0e7c4c84e", + "assets/project-defaults/covers/cover-06.jpg": "7bfca4ba4405c1377e65d6ab6247fb76ac9aef4904b3c84c6c12a40111c49ec0", + "assets/project-defaults/covers/cover-07.jpg": "ac146b5fc6ada40755343ac381512afc30c97995c1688b18167eb2f24c1bb4f6", + "assets/project-defaults/covers/cover-08.jpg": "e184342cf712f9e85bf68e74861f5d7494a9f887fd54f396d862b393b94c34c9", + "assets/project-defaults/covers/cover-09.jpg": "78a03679718d355959c052c026a848b57e1eecb44e9aa3234935eec4a225cfd8", + "assets/project-defaults/covers/cover-10.jpg": "78dfa58bad451b6b783dbccfb2ebf8e5e8ac336d3bae3dbdcf3d86405e934e87", + "assets/project-defaults/covers/cover-11.jpg": "4783dc7487a4e281adcce244bad84dd1dbb86ebd2d1aed85fc702592d3eea6e2", + "assets/project-defaults/covers/cover-12.jpg": "fada1af94e378608d706978959bf0760f0afd25c1841a252937b1dcd08de57cd", + "chunk-23ZU6LRG.js": "0f525f4981593bba207b0c9dd26ea9913436764224cd8301016653db18da558d", + "chunk-2DR6JBGZ.js": "27500a658cc7a28d96b8a632ef26e93389a6645455762c97e354493a63a86084", + "chunk-2EXXHI2N.js": "1fd3b5574d51721db14b1ea5162dba1a160badaf9829586fd82699f1d5c01e90", + "chunk-2F3XFNHK.js": "ab6bd380ea9bcc7e6221deb3bb2cbc5eb1abc4af26bb7652a5cc4593875c95bd", + "chunk-2PU4LROP.js": "8c8b02efd22830d93c30104e8ba6ae70b73abe07a9500a6d879d7b6c6a3f3acd", + "chunk-2S2ZLXKM.js": "e016ca0eb340d39b495c828c6bd2574738173c3f73a49129bd4dc16d9dc4486c", + "chunk-2Y3K7AYQ.js": "6cd368169658e6afe7bf0497708d909e2e9421020c7b7dba3bb8fe6ebf448dc4", + "chunk-34ZEFFCD.js": "dc5e14c696fccc4e36fe495ff28f44ec22f35420650408e5337f9a22a8427484", + "chunk-35OL7QEH.js": "b305640806e84826abcee9bea785bd0966ee7ba0877477d376b22c49061a3d1c", + "chunk-3IWBPXER.js": "c0f62a825c68d1af6995fb8496ccbf04d1416560a6385cb27f7eeba4bdbc2c53", + "chunk-3JFQI7S2.js": "98bd099fd0a582f9e8707bc48ccd728f0b47416101982b83559ffdedb6be70e6", + "chunk-3VON52XT.js": "9048867e7cfe45dc62c2fa62ae7f3ce45b07e7264c734c52d9c3319ad686d881", + "chunk-46TFGTY5.js": "264eadeceecbb48615dfa877e0a52c4e3695e81077820d318913ebadf7d6dc81", + "chunk-4CQUPGHG.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-4SDEMIFW.js": "7d88c5c27a5eb6d0bc4391a9c3a54ede849000b09cdcd9533b8e3a75f0bbf92a", + "chunk-4UBYHQCH.js": "75e7b821db517e0bec5b749aafb2b5d8f182e4a284e36f345c15bf19466bd233", + "chunk-5GESWIST.js": "1902fd1296acc16baacab1ebf005d002211b5178fe5d19dc82499784e6fb3b3a", + "chunk-5I5Z7NGP.js": "d7b6b440ba5534fe64d41c37749e3b1f8cf81d89f69b528ab61ab5d6790c18ec", + "chunk-5JGME6PW.js": "2986a3a84405f3243da13ee3fa749de6bc0caf176056330690c17bab068fe23d", + "chunk-5TFI2INI.js": "6d6ad5223eb4bf383621d2f853a5405c7678b55c3fa37ba69c4d0a8594201c7f", + "chunk-6C6ZZN2Y.js": "d2e9d5250b8ee241789ca6e63636ce74725b1349c4a8587591b53c93a7333983", + "chunk-6EDX4YDV.js": "9fa3493844368ef524bdab5518df6aa886f801edbba99f514875f1d114df03ca", + "chunk-6KPOXAX2.js": "5fd883040ec4d09bb63689b62bcb37cbc946036b9fc8ccbc946c03879a52feae", + "chunk-6MDSHYKU.js": "591172e750cf13111927b0938e36e07a5980efd9317fe509f708d7af33ee32c0", + "chunk-6QUBAG2Z.js": "08425cc32471f96fda4368fc2fea9b4bac4a4612ae4290cb85347055d00c5c80", + "chunk-727DOWQN.js": "c8a90d26f6510f5681b64a386149e0e6f6f2108402d5769bcd9c10662566ce5e", + "chunk-72TIMBJX.js": "d629dfbf356ad035593eaac5557b36e72dd2268c84fed2cddf7cd009ca32665b", + "chunk-773KAP3G.js": "40e566cd21439fdf51f7739229c3beb9a95b63f79eededa740f70f89adcff4f3", + "chunk-7IWCUP2L.js": "f5984ec34309445a0c57612c807b1b08ebd3bb2a0af6438be873735ba0f7b923", + "chunk-7IWSYPRG.js": "1f14a5a71612025c94ca04fa5896dff138104b10341db741dfe9c4ba0db769d4", + "chunk-7SM2N75H.js": "153c09b915dd414421750dd76850019cbe65b88c0652efbcfb2e5b707ea81c4a", + "chunk-7XFZHB6P.js": "d1d251805e07014b3e2b42dc43451c4f40d5fd73c100179c7e3b7e190be1028f", + "chunk-AAG2V534.js": "91ad0c74e45a6c8d1e519236d3a1e355dc4ddb2fdc50299647feac0096cb8d5e", + "chunk-AFTDGM6F.js": "587fe72e2710ea627e3e1ad4b03f90cb08320bb1cb75e68d34c65ee9cdd1fd47", + "chunk-AG4NU3VM.js": "580404b89799aea212ed32ed53f7d72314ec1ccea96781b08c2099c856b7afb3", + "chunk-AGJGLD7O.js": "37ac596efbf975669926cc79ef5908768bbcb434da095da1fef16a2f99fffe96", + "chunk-AJVO3555.js": "ff32996437249f529f7df979dc9bf40a875e10c5ab850db2222bfdfccecc48cb", + "chunk-ASTRD3NW.js": "1deba24e94a39c513b607c2228ea1d5f4c593bc161e6eed7bbe6754ea8a87272", + "chunk-AVTNL7KT.js": "068e2a2e91de9521b40081c83e8a723b9a739f4ddba53fccd4356c2b2672c5dd", + "chunk-AXCE7O65.js": "1d0463d518e79bbb10ea0111ff9f9e087c1f7ae440a8a9f02b673276f6236fdc", + "chunk-B652S3Z5.js": "f6e43a5eaab4180a0b51eedfdd6e81fbcf7b272d9310d8b2e738bfbb3f7adf5b", + "chunk-B7XGAJUT.js": "47c244d2310f9c23dd970fcd05f8be2c2bd179369df26f46cb442ba1f9127a6a", + "chunk-BBGD2IZW.js": "205975d5e0289678750d5404a0e938465cc2ef69dc569757eed9639f9b52cab1", + "chunk-BFWRZFE5.js": "40a6b57a6bffe59d5f590bb6d82006a52f5ac17eac77671e62fa41d481ecf099", + "chunk-BXKTACNF.js": "e15083ed45f459a9ead4fbb1ef45e54624485802a19a3cc84d3612d2f9b5a2da", + "chunk-BZK2ABAM.js": "c5e4a81324c544e779ae784f11948ba2fe03b127fb1d7e62886199963234f0f4", + "chunk-C4IVCFKG.js": "99661a008a655e91aeb55d7219936a888f07488905701d0130d53b1dc6f53844", + "chunk-C5LRIWGW.js": "fa6f848fd6b95655b8910ab58a6a25338dd79409a92577788f531e7290cb8520", + "chunk-CCWZ2MKX.js": "c4d0c01302f4f58b136b2588406a8368740b4798a96d03b9c413631a78c48262", + "chunk-CD7NUIUC.js": "4f84528cf0a1b545384e6ac7fafd5730416c038ff5a2ac87693367385ae7ccab", + "chunk-CFO3IHXO.js": "23c8d4da8c501a6907a8d4162096e72152072902900e8d26bb5df0eaca757625", + "chunk-CGVRDMBB.js": "189e62356cc24c6ec317f91815944b87dcdc3b6ab900ee29c4dc036a70bc46db", + "chunk-CHCMB7XV.js": "b186527ea25112d7db8680c451aec67a3846e2b5a0ac8b0eed2b682caa5385b8", + "chunk-CPAL4Y5W.js": "6e5762e246892c7031ea1f8427d639c992ed1e9070749e7f2b7c993e4605504c", + "chunk-CRTRRB5R.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-CRUTENDI.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-D7NIGB2U.js": "fc24549cc1422bb5368bd52fe8fdfa699d2296179cb1aa28aa711fd6ca0ce4dc", + "chunk-DBIRNJYH.js": "38bad91a785728b64cf9db0e659e9eba937675d932b0e8b6159003ae2320d785", + "chunk-DBUMCIGV.js": "6d5c95e0f69527a0e350606ca394d511e10de8fd478a1aa0fd88c74fd823274f", + "chunk-E4FZ2M7F.js": "d94ee416e317e0bb8180e561c3e2961654eda44ae5fe880859cb85948752451d", + "chunk-ECA4OUX3.js": "17bb8e7609ebd305d51c09981154600aa9410a5e157132533b4432fd3b009ed8", + "chunk-ELQM7IWY.js": "5899940d127a5f64fe0e2ed15ba2c8aef1b14c8003630dec00100c27ddb0cc9e", + "chunk-F7NYZONF.js": "b84ef3505f4bb855a7b5e6575403dc4490ce374b91efd3589daeda9c2b26d875", + "chunk-FE3JRQRM.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-FEJN5J3H.js": "e977c5e342dc4ab60b1a9a9ea96a3ca36382f147ceb9d7478d50ee66f35e5c20", + "chunk-FI4DW6KU.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-FIOLKH3U.js": "fd10cd3846722620e2b300ae4b2c375d6237b0b2361141bfcfca44cfcfc3fe86", + "chunk-FJG36B46.js": "ebb0ba7da3e13c1cee3ec5b6b7440eca906f8e7ed93dd3c03035c237954c41f2", + "chunk-FJQYBBFI.js": "3ebedab7b8d1f55fb1f61135dd92c5e8d9c8f21dc5aa71f16c12ef3fca0bc38b", + "chunk-FS7XQWRS.js": "c039faddd0498428d37819a134c68897e3363dc6908bec080f117298c430d1db", + "chunk-FWASO57A.js": "4b0ff823cf2b128d9cef0d1d019baa0f06e92bb5d7bc31e0890c47b9c260793c", + "chunk-G356IHLY.js": "236379d56085114d87f5a368a2c2b02867b8e7cee9b0e8fa37c18ce8ca876e77", + "chunk-G35EE43Y.js": "5a224827ebc125abb0280b3fa9e19b38fb0d2e2bc4bebe7dfd368fb88a02cce7", + "chunk-G4LSIWJC.js": "4c22381fef36e7360b3160a209dd65db03dca0db281c390ecc3bb48ec5b33b33", + "chunk-G7NJ3H3J.js": "3ac4851878ed9dba1d882097432d3ac7a162846263ecab9cb3768791f535d1d5", + "chunk-GADCCZK4.js": "45ee9ba4589dfc3b2a2d3f2a31b330f2bb6a323d547ff2b60be7f4e520c16667", + "chunk-GAELZNYR.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-GCGAQL3Q.js": "dc8abc419297a4ecf7e6b354fc95c45676c0ea06f3306a7d636d0cc0ff5fdeaa", + "chunk-GDMD5RKS.js": "219c04b6a66e74c8c28f31901fa54656a1b8a0ee5bd1b8158f5bae52da53a472", + "chunk-GEVGGPFQ.js": "9fc1c7d3c28bbe6e669c45b982a4259d6c8fef5376bb6f02f989b1e296f88e63", + "chunk-GJQGMQFG.js": "917a776a46d737c8ef07ab6d975ec7aea152fe078a2ac13c1b774322905ff82e", + "chunk-GMU36VBY.js": "aaec987342240a9553b330eac2d60ca5269896138301315894b92acc12fc338a", + "chunk-H3KC6E57.js": "ecbc1f4df8a191c06d1d5f10b725efb6d38c1959e0f841cd376bfdec23ee556a", + "chunk-H6VPACU7.js": "22f3ec9d92f5fb298e556fd7eb1c90dc463278a296db84d41bbaf75c6f7f0276", + "chunk-H7XPQTTW.js": "5e3c9ff4626be81066828160bab67b47e47387d8972ed61857cdbb2f4e34a5b8", + "chunk-HD3G6DRK.js": "be2e30d4c172b036b02c9e8eafea1da703d5fa468180abd9501665b7de80e622", + "chunk-HEM7ZCBJ.js": "3df5a992491aaadaab46497aac1fb43205182b57ba7c1cec87a94dc692aab0d0", + "chunk-HJMFHTRN.js": "b9e89de3fc602bb6006744916c8ba4a0323aa911def2b9947888f9aef52aa9cb", + "chunk-HKGPFLNK.js": "d77ae9158001c862d9c0b33bcb3e168b9b0468855f8f913baede35b03e656b63", + "chunk-HQSQT6Y3.js": "66a8b6c355187f975bbe2c2c0a5b9dc34e24d2df3d7fadc5acd6d9a6211b9a2f", + "chunk-HQYFZJM5.js": "70ac1746e90911fced1a458a34dcafaa843f9d3d3f005637f44f5e87bc3c86c6", + "chunk-HS2FTI3F.js": "d8c467f4c61bba733a9ec24dc985bfcd449571921beaadeb9aac8ccdd085f8e2", + "chunk-HVOESLA7.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-HWSXNT2V.js": "96f9fe5d70d812d830df8e30bb8a540a5a3f50174f3a6cf423bf957c126e2762", + "chunk-IC666LKR.js": "27f41d3d72ec2fa56ff19da35f3abfbdc8d67595037afb9330c74d764d166e7b", + "chunk-IFWEWPH7.js": "3fe8e0452a17e52a5cfaf7fc1a49c1b22d2258624baf94e1f94e294dcfd805c2", + "chunk-INY6GFMR.js": "7075fa433b168698c01fad8df54436c6ac1ed03654622c57d76dd2fb003afbba", + "chunk-IWKJLZKB.js": "0bcf789662606a3745d358ae28b3434dbbfd859d103bfbd5d2034e009a88b0d8", + "chunk-JEE4N3TS.js": "deea940fab041e9d3d62b9f45410f13a10c988765e21aea72b5e7cd38be2793d", + "chunk-JHUOFMVX.js": "93cf59f592b50d077bb76d75e308574e695f5e4bedde71eb83bd14ef664a0643", + "chunk-JJKMKSAK.js": "d16c7ec05d6529407cb95715c39712888ab848aaa5bff90d5b5000fc2157a49b", + "chunk-JNNOEYKZ.js": "028c6760b064b7329921dc0f03b5155651dc6b78782c4a301a0f2ed1fad67103", + "chunk-JZ7TUIPA.js": "0dc05155bbec9190fa6e62138e4950b1ac9f8a483d42178e9d4a113584670fb8", + "chunk-KEPWQ2XV.js": "394e306a00b5670e2087c2ea4709784259906e1fecd76112757c958c426510d8", + "chunk-KKEW2G7D.js": "04229a56e68a783c72199e832ae46524584d83aedc478d30389e6da4a6b9728c", + "chunk-KTRLVWC3.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-LFUHDYRM.js": "19d548e2010f55979d69c732483385959fd6d4dfe1d6a781b8960d570d6c253f", + "chunk-LPIRHTIZ.js": "6b1bdd5cb4b0e697c98a18dbcbcee87ea407b48d369d37624e01e20c46825ff7", + "chunk-LYOKFRGF.js": "d93bdde0b755b03e0ba30490700d787476d28ec520e2f46262ae31fbd3bd68cf", + "chunk-M7RNECXK.js": "872426135097f84d84f1616f02a1ad5328c1203b91df8de63dab5708d2d52d64", + "chunk-MS2BK2XC.js": "6cbf3792209b2213d939391b0e410a28e3cc4cebe9e3bca5b0f3c1372617848b", + "chunk-MYXNY2BK.js": "b2d7f3c67e485753fd8d6c6d8249a98a2123b9565f7e5cd4e7f7ee0be8141a1a", + "chunk-NCHAAQO5.js": "394e306a00b5670e2087c2ea4709784259906e1fecd76112757c958c426510d8", + "chunk-NCSQLE2V.js": "a003b99f67176ece40876da30e522ad18d03d602f0f0fdae61261128c4c47096", + "chunk-NHSQNPYR.js": "480d39d73924a277ba5e2f36bfdbc6cffa19677bf6ace704db7979b81130d048", + "chunk-NYBBQCI3.js": "d5d053058a5599f8f40cc56c1750a9484603f00ae80b17e0a3fa02622e99a642", + "chunk-OBWORE6U.js": "775899b419110aa5892f9903152c9e2bf4c6d2bd15b5acd04660e2835604ff15", + "chunk-OGETYNF2.js": "a1a6006046a428b84f23f989a4183f2bdc54cafb24bb27354bc6dd916e3d65bb", + "chunk-OQDADP67.js": "a7f77ab681c58262f8acf0f156ca93e1e890ad25550de966cedcc0cc18d2e43c", + "chunk-OQVLWJOA.js": "3dc7f8509c44eadd5d4123c4c39857cb347da1bc650e74d44db8b49963ab6225", + "chunk-ORNIXTHJ.js": "a3c8cb0719ed5b06146795def50533f434fb0dac942f71081558aa11b71d7311", + "chunk-ORTPMUFN.js": "9f37c8abc58494ebda1355db7367941b22a8fd304a934f0e83b9f62ba501a7be", + "chunk-P3NGBUQA.js": "aca29975f7599a12a6e87a1ee1abb1388d4daf78b97f4ec58c098d26831c1ee1", + "chunk-P6L436IK.js": "9839ca739b2c54e816b9633911441f79abec7a38d3067fc05607bda57d21933d", + "chunk-P7ZNJWYH.js": "c45eefe91aadcd29d7f73475deefeeb1dab78968e48e05c8c2dcd2ca0baa67eb", + "chunk-PDSEBV4I.js": "4481b7cb927c6673086c01303764961a4b6c1ed60e92ae68159052cc52e56798", + "chunk-PKT6RRYE.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-PKUAPQMU.js": "842777ee341c6bda8a48d372e20c0b9c00a437131f9ac29d4883543bb072fcef", + "chunk-PNXDAMJM.js": "f47f2fb188bc043c9dc22ab9d79f1cabbb7792dc31ac239e3ce0850479d4c9a5", + "chunk-PPZSTFUD.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-PY5GJ2VA.js": "52e045b30c61e59aa87bae1339cc6518faacc24650406bbe23cf6c04099c9418", + "chunk-PYP35JQ4.js": "aa23b006ca87cdb1a9a6fcdcec42a2237e498eb4703762b9058642f980b5b1bf", + "chunk-QBVTQ7AJ.js": "cd37b2dfb166b6168b949212ac408fcc009a6bae7593d04ebedd85e0827a0cc4", + "chunk-QN2LPFZ5.js": "09586a3bef9ad5489a73e1c64f87c25460ce3d46e31f23b3cf983e7716de2c98", + "chunk-QO53FM57.js": "d74a026d65a8b66efb0f0d19fd86b1497472f109825a58c0cda1fb293460b892", + "chunk-QRYA5QYO.js": "5f49fabbb442b7832050c0365e56c1c3e31f73c109c4cc78f6c59dc8c839bae5", + "chunk-QYUSC46N.js": "e2dbe4f8b0ed5f723145bba340bba4596c4e2363105845ae1575ced5cb0b35c5", + "chunk-R53SC6YT.js": "b82916433319b31fdbc99db0e8e50fb4b39271b0b4f5a5c85f0d71b350816719", + "chunk-RMP32DNY.js": "88772ab3aa50aeb7a44f879b3f45c903249e264918996fd608db0abe0c339b60", + "chunk-RWAASGF5.js": "f99b753164d815e2bc01723aef1be1e6a305bd8435aba053b75baa9d91dace4c", + "chunk-RZO4MDZY.js": "db17023632edb28cb75e864ef53154229c4f35e6796622408a953dd04a2fc378", + "chunk-S7EUWH5W.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-SK7GH6NG.js": "efe4689540db404a319f809266a94fc46e5081f664a7ebe8e4ef71cee13fb86a", + "chunk-SKQTAURN.js": "b6b3e4abc99ed23a7001a07483cab238c61701feb74ff2b2c6375699db727134", + "chunk-SOCVDKX6.js": "f669ff7a13ef4ee36979bebf274d96494cab4c05f8028b710466ae7c9580670d", + "chunk-SORXG3WM.js": "e2a55bcf58bf09daaa773a7283f8d1c769c77124e0a9016f316250db6d984d2f", + "chunk-STDX2QZ3.js": "d665e49ad1f84f94d810cb0a7df99c34bc77175fd5c7e67fe3cccd5e329cfb52", + "chunk-SVFKPVOL.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-SXCMSJUF.js": "f65b6c88a67e05086296b020519c47b4e6ffaba9da5cb6380d8b93718579d8b7", + "chunk-SZNKQ2ZO.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-T3WSVEY4.js": "e0937e421a9c41e0b1eb52bc2f70113b42c35a54adb6d8c9334960dbf07730dc", + "chunk-THPFTLNX.js": "c7403c041d81ac7c1dfbc1f5c83dc1fa4ab2bd12e16ffa06e06700f6746968ba", + "chunk-TMHENYE3.js": "498284fb350ffb4015261fd83ae271346c2ba815b896762fb86371c3b4c40285", + "chunk-TPT6UZI3.js": "810623ef7a81170b0cb475c1bd6065bd27522d9c3cae6e2e39715b0486911b09", + "chunk-TTZ4IF2R.js": "f3909b470b8a3ed0da59715e7f94e2accb367391739aef39c388c1c289bfe6f9", + "chunk-TXI6IPIG.js": "e1b85d0b7e4ec3c559244b1205e5b0f908eccaedf645042d1599b3a9862e0054", + "chunk-U7QOMTFH.js": "e9fea77b67eadc08f1c01224b36937264479b06ae7535c86bcfb1f0dd403994c", + "chunk-UGSVHFE4.js": "7bde07914e176039c9187392b1f91f5ae8ee206837da18998f0d8d0431a46a2e", + "chunk-UK5UKT3V.js": "e294567d2f611b83656c9fd259db9455ab0ae2f6785827021bf1577a9a70aabf", + "chunk-UMKP35NR.js": "c45afc670af5b3a726e92484c29a04c9456d5f8aaf63afc0cb981ae8ebf35d42", + "chunk-UNENCFKP.js": "f5985dc82846e809af07cceb7d3e0672ab42e19ea443c6118f3465a205665c6f", + "chunk-UTWQFQVE.js": "a9ba2b643673aa2b235bf044f7755820ca25c049bbd65326662d00a3e893b63f", + "chunk-UYXSSEQD.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-V3TCMJP5.js": "be7aafe88e586815e4b15007f956a654683b34da2b62af65b73aef3f53ba7256", + "chunk-VOSPIT4N.js": "0b9907b7c4c20fecc25fb722f5131dce336e6b2f6d8bb970c37476e4feed6918", + "chunk-W2ZPBHP5.js": "fb7050759eb8424d30a05bed67978166425e4a6f25db69fa9663259a6b3a4f94", + "chunk-W5H3AEFL.js": "2346bab21f9c87892d75374d89bd01f0dc08fd8e08a7f9497de318e025273711", + "chunk-WJX7WGYX.js": "23953466611d8b4f3c0084582cf7306d37158c8a02e2fe50061ef4b1a5296008", + "chunk-WP6O5FVQ.js": "97332f1e90f70d69748751042c53a49c5765c767e139412bd34446cc322f0bb5", + "chunk-WUKL3F6L.js": "35914c719162f0b5794cb187393caa58edd59ba99f07f8598df3b1ba15b213d7", + "chunk-WXLNKHDH.js": "5ead8ea7ec1f7269fab0cdcfe63f154f8f4d0116eca9274dd262b24c6ba333f9", + "chunk-X4I2SBJL.js": "45cdb22871ca8bbc88da655b34bd8a53a221a66358ea46d3bb58f96220baea5e", + "chunk-X4ROVHIJ.js": "d0f65acd761fdd0b0b3f410bca3b868ff99148facbfa8eafe12a5706e0fde7bf", + "chunk-X7V5FF7M.js": "64116af3ad88688d620dc94372364919e7e35f123fabab3545a77b767c09edfc", + "chunk-XCNR4QBB.js": "e196821a94ef94635bc4e700ea6c04582b07cdf4e17b11e8103835f0f63c4a86", + "chunk-XFSBP2WD.js": "79b1b57396a677a69c4ecd00c7e25b0d3d597c0f586ae6fb22ec83b8230e4c20", + "chunk-XGJ4GZN2.js": "93a9636ffb9348f1f81b18ca0ea23fa7f055d1c9e701ff63b10e7224513f6865", + "chunk-XH3LC4HK.js": "4a9f9da9d57132843d2134b9555ac0e04bddf1b3941f32601a8dcb9667d5de36", + "chunk-XRW266TM.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-XU2CZSOY.js": "35189ce3d8c6eb9fd90214f569e4c331aefe3b2bb52b1df7404007bd9f75a315", + "chunk-Y7FCNQPS.js": "6f61f1572147587befe70f5c37f2c3f250629a174dde7f95d5bbfa79f3d48c59", + "chunk-YGCGOQUU.js": "fff054f0afa1f8e28b76d759af4a96c73e7f76da84a10fcd9f5a60b8f62753a5", + "chunk-YI52ARKQ.js": "c80e354624b5b1625edbbb8b516135a0a0bdffc32fa11e677f809590833b0c27", + "chunk-YKFEDNBV.js": "ee06bebe3254e1e1fd744b2a1e41e1308522afbf170ad2ac5a620ef054d071cb", + "chunk-YLGWU3K5.js": "a5139a6ed303dafd5946aeea1681ecf99924473b92cc79da0c793867ab1ad7ac", + "chunk-YOBIHSQW.js": "538f1412dbf2f2f1d7c26b1ab31423b220246b4ce19eff13d48032217b002e7e", + "chunk-ZE7ENFWL.js": "0618ca406bc37688b707dd92e834f425c54f9a63b9f1f6345a00971a3eb428ab", + "chunk-ZGLHH6B5.js": "f8a2f8cce5a674ce7c1f5eefd18b0a7ed34c75e136389506b81c44da26d3fea3", + "chunk-ZGTQ4DFJ.js": "18d25169cd898879a7c85ad3ff4a76617beb8ffd863c3d31b082ac26e0ce8c5d", + "chunk-ZWVXAKCW.js": "1c81193f001123175bb37bb439632f67167e317853d1c4537aa312dbcc612cf3", + "chunk-ZX65CFM5.js": "4912d99b6bd7767e4a78d733e155d8660e504842ac1ac56233ed6ef82aba2e22", + "favicon.ico": "76c12efffa19c76cef57fd7a58f65cebd22df8d8a0e9ac7237c58234cf7fd57d", + "index.html": "fca978ed608a5ef48b7b145160a6377001054477dd9ee52e90991f3d7829f4b4", + "main-XJROBMBW.js": "769ff03a74b688d98e8a90be1e248ef0bc575fdb62820d6cd1b56caea06bb516", + "media/Mont-Black-CKKAF2O3.eot": "b5b32b3ece5fd612d52631066e308ea0733636bf3a6cca8066cbd5d309a1ad06", + "media/Mont-Black-O4OJXHSC.ttf": "00c2fc2b91fc6ff250a2f91a67e6eb11c27b093c2bc75661942fd5653b8ceba5", + "media/Mont-Black-UEG4C46Y.woff": "f02577df1049b69199e39f1dac8e086d46521e805eac9731aa578958439258cd", + "media/Mont-Black-ZXUYXEJ6.woff2": "a94582ba690d74b4e0e9272a4d72cad170e513ed9198ca042ed6475d71c1f8ce", + "media/Mont-Bold-7L6HWBWW.woff2": "141fa4004c0413987f1638c827f2faba62b54bd1f618796c49a5cf8f5ac7c4bd", + "media/Mont-Bold-A3OSQYTY.woff": "e3bb59a5d49ed3ee29c92ae13c4c4c390eb4ddcad55fb72f698742a600efb0c5", + "media/Mont-Bold-JCFB53E2.eot": "673fcac77187f1dad0d5974ddeb4471bb7ca6370da242f86a28e596127f9a117", + "media/Mont-Bold-VNDQNQDW.ttf": "0371bbcb8df8f597af0237e13a9db8a04892649fdfd595c283719d3aa56018a2", + "media/Mont-Heavy-CZVFDIWI.eot": "615c73226cfcd0704155428e00a65b969f971bbd90dee352adc54ae15cb02eba", + "media/Mont-Heavy-JVDMIVZJ.ttf": "24fa6e8d4038a37a4a79ec087cd4b61a392474891e1158837537d7f9f4e8e101", + "media/Mont-Heavy-SAJ5B6FS.woff2": "f59d60e6232576f751dc60eb2e660d4b8287464f130bd77b3b2eaba1a5671390", + "media/Mont-Heavy-WMPUKQNL.woff": "860084fae53b928995b4809cbf63ffb08b9db77040fcb0762dcf7d1feef6c2d1", + "media/Mont-Regular-5LPDNI7X.ttf": "0bb5e1336fbeb2c78e049cf3a21101289c7b6c906d7335a5859acedfb8aa5875", + "media/Mont-Regular-RSRKQCMK.eot": "65a09751a7a289a0bfa64284a040a148b71b23ae8ef85e261eeb89ddc6d5116d", + "media/Mont-Regular-T6L2NNOO.woff": "29c81b198136bf8a929f52133314319cb6d8375a526da5c97197df1960ceebb3", + "media/Mont-Regular-UA5RCVYG.woff2": "e01372b13985c09f2a168382ad044312db914801a595b4306abd7d686b0cc64b", + "media/Mont-SemiBold-7XOUXTAI.woff2": "ea0681c9068fb473d3606e012e76728eb44b2e04c276266894aeca5fc1934b52", + "media/Mont-SemiBold-BZSF3DKB.eot": "d0c7662470a8da9c1dc4aab63a44ff7ae9bee2aef4ab87d0efd91fef1c6ea1ef", + "media/Mont-SemiBold-M6WLAC2K.ttf": "a0d30d71b1ed99f8ff6cd6e5a83b93dc832e4a00f6cf402f587351c1ba8f7dc2", + "media/Mont-SemiBold-WNUGA77E.woff": "1d01cc3f8fafbed1582b3182c40eafa607fb6670a179f23116d8eed2e8907481", + "prerendered-routes.json": "a79ddcc8a115190e9c19479ed36914741a7d1db74f0a1153529efc80725505fe", + "styles-PHA4KBVL.css": "47fb81fe384508ffaade6d3372a03f2d8fb6b9cc86924f41ec33bc7d9085b862" + }, + "target_frontend_files": { + "assets/downloads/auth/shared/privacy_policy_2022.docx": "118f33f152970e85bc893ca75afd62883a83a1d3de17f356b9f9e6f2945eb8bb", + "assets/downloads/subscription-plans/shared/Договор Оферты.pdf": "44841192cfc06b08acf8340e13b68f26fe312d1aa75a00230df5bbf76ac9b926", + "assets/font/Mont-Black.eot": "b5b32b3ece5fd612d52631066e308ea0733636bf3a6cca8066cbd5d309a1ad06", + "assets/font/Mont-Black.ttf": "00c2fc2b91fc6ff250a2f91a67e6eb11c27b093c2bc75661942fd5653b8ceba5", + "assets/font/Mont-Black.woff": "f02577df1049b69199e39f1dac8e086d46521e805eac9731aa578958439258cd", + "assets/font/Mont-Black.woff2": "a94582ba690d74b4e0e9272a4d72cad170e513ed9198ca042ed6475d71c1f8ce", + "assets/font/Mont-BlackItalic.eot": "76137797d8e7bd43d87ab4d326655897fd1d819139e02e919ea2fc5c094446cf", + "assets/font/Mont-BlackItalic.ttf": "44819196479a62c322e67b419739f239cd236e5c4a16a113b3c6ffb563808fb3", + "assets/font/Mont-BlackItalic.woff": "91f00316e0262c203e3159e2a4be2ca11464cc7a59b68b92474d9476bf72eb64", + "assets/font/Mont-BlackItalic.woff2": "cf9408b2915a7e8c74c8aef4d5f5556cca048c79a123313b3f2742f47fee3dcb", + "assets/font/Mont-Bold.eot": "673fcac77187f1dad0d5974ddeb4471bb7ca6370da242f86a28e596127f9a117", + "assets/font/Mont-Bold.ttf": "0371bbcb8df8f597af0237e13a9db8a04892649fdfd595c283719d3aa56018a2", + "assets/font/Mont-Bold.woff": "e3bb59a5d49ed3ee29c92ae13c4c4c390eb4ddcad55fb72f698742a600efb0c5", + "assets/font/Mont-Bold.woff2": "141fa4004c0413987f1638c827f2faba62b54bd1f618796c49a5cf8f5ac7c4bd", + "assets/font/Mont-BoldItalic.eot": "6e94762bdbb16001cee4954cfa4e229cadc77350e22fa4f62d9711178d4cc38c", + "assets/font/Mont-BoldItalic.ttf": "a150c940551debedc346b39f8a248c528b884ecce261a97472e83153eaf16154", + "assets/font/Mont-BoldItalic.woff": "b677650c8b2cf320fe66855c6160fa53a065fe10c869c0696935b4124a475e27", + "assets/font/Mont-BoldItalic.woff2": "48f860daed27879358252afdfc6b3cda688470477e0da7079c80fc7b193ad7d7", + "assets/font/Mont-ExtraLight.eot": "fdd7ee32e0a22d1d47e5d1f713b72e8435513042fd3e470a16df1fe310512119", + "assets/font/Mont-ExtraLight.ttf": "51593eb80d7ae71e001321212015ec726253c411f80c0dd09435e674f5c01bde", + "assets/font/Mont-ExtraLight.woff": "d0ce6409f197d19065f0c86c025ec87c99a45d3add7cb4959c53f6206a16dab2", + "assets/font/Mont-ExtraLight.woff2": "e5b5fee9a6c36d70e7c994b0a48d82d883baeac1795a48b7dd27282da1587d2c", + "assets/font/Mont-ExtraLightItalic.eot": "1237a7d4d7921f4c65028cd85294361ef599027ea0689876b8bc5ba1388b5fc1", + "assets/font/Mont-ExtraLightItalic.ttf": "33b75c709fab9e7dea5a6b57c3525e8e1defb82d6714b072cf25ba152019406a", + "assets/font/Mont-ExtraLightItalic.woff": "0f3ede3403a6f4ed01245f190e4830392010a7a1fd2660df11fa2ac30c97eaf3", + "assets/font/Mont-ExtraLightItalic.woff2": "eee60ca3d948e08df506d12a28751ebad3e9704af50a8f39eb8acca82d76abc8", + "assets/font/Mont-Heavy.eot": "615c73226cfcd0704155428e00a65b969f971bbd90dee352adc54ae15cb02eba", + "assets/font/Mont-Heavy.ttf": "24fa6e8d4038a37a4a79ec087cd4b61a392474891e1158837537d7f9f4e8e101", + "assets/font/Mont-Heavy.woff": "860084fae53b928995b4809cbf63ffb08b9db77040fcb0762dcf7d1feef6c2d1", + "assets/font/Mont-Heavy.woff2": "f59d60e6232576f751dc60eb2e660d4b8287464f130bd77b3b2eaba1a5671390", + "assets/font/Mont-HeavyItalic.eot": "a538b9678e183a205f76fa4d3b59de00b1ccbf132d41e65cf78e324e38b16b54", + "assets/font/Mont-HeavyItalic.ttf": "23115190cf8514ffa96986108818a89ebf6f00c2031cb976cdee1b84a10479ca", + "assets/font/Mont-HeavyItalic.woff": "ccf64e0bfa463de88abcb2151071da1bb5b5323594eb78cd18b71a8cafb1ee71", + "assets/font/Mont-HeavyItalic.woff2": "2d043a8ba71ab28e1785ae49ba985975bd0b18a6db0f7c0b9f8c36b18e72aca0", + "assets/font/Mont-Light.eot": "20afea63f0d8d8bd9b4772d47713502a75217c27f002888d0dd799866fc5ece4", + "assets/font/Mont-Light.ttf": "038d167769ba21ab4b3880b6a84575e1c5134b69632404f47b1e734912adb613", + "assets/font/Mont-Light.woff": "34a9566626c3d1b89f5d7cdef04a6bb6f64515755d403775aace1af808b96f30", + "assets/font/Mont-Light.woff2": "db1925fcd80019c78b84ba15e7644fcddd447073818b88ea907347758380c37f", + "assets/font/Mont-LightItalic.eot": "443ac126d18c64c7e81c7fdf1ce7e35968d5b32af55760249fd17f5b6ff9cc19", + "assets/font/Mont-LightItalic.ttf": "517c8307e4a7770bc966485f2d7d969b2ab9a136b2972198062e5d4c6d50a5bd", + "assets/font/Mont-LightItalic.woff": "914c8514e3894d6035098030cc249e5f64026adc3f3a7f216b7c910c828e9301", + "assets/font/Mont-LightItalic.woff2": "6c0d81865be35458e30eef22616a4b7c96bc1946b31fdf5201037e315cdc3d7b", + "assets/font/Mont-Regular.eot": "65a09751a7a289a0bfa64284a040a148b71b23ae8ef85e261eeb89ddc6d5116d", + "assets/font/Mont-Regular.ttf": "0bb5e1336fbeb2c78e049cf3a21101289c7b6c906d7335a5859acedfb8aa5875", + "assets/font/Mont-Regular.woff": "29c81b198136bf8a929f52133314319cb6d8375a526da5c97197df1960ceebb3", + "assets/font/Mont-Regular.woff2": "e01372b13985c09f2a168382ad044312db914801a595b4306abd7d686b0cc64b", + "assets/font/Mont-RegularItalic.eot": "6529a6d919ef17fce494b2d2bf02467c0b3412dfd65a82d81289cf0c8a95a8f9", + "assets/font/Mont-RegularItalic.ttf": "207550eaab2b7778409ea7cc52a560a9fad67abaa310c8a5bbe043e2066d3427", + "assets/font/Mont-RegularItalic.woff": "9b4bd95152b25732581590cdb33b595639fe76de1f89eb52d4838888de492f24", + "assets/font/Mont-RegularItalic.woff2": "1f17226d2391542d53063104a35af2dcb81f359bd3f9504be0630514548fc26e", + "assets/font/Mont-SemiBold.eot": "d0c7662470a8da9c1dc4aab63a44ff7ae9bee2aef4ab87d0efd91fef1c6ea1ef", + "assets/font/Mont-SemiBold.ttf": "a0d30d71b1ed99f8ff6cd6e5a83b93dc832e4a00f6cf402f587351c1ba8f7dc2", + "assets/font/Mont-SemiBold.woff": "1d01cc3f8fafbed1582b3182c40eafa607fb6670a179f23116d8eed2e8907481", + "assets/font/Mont-SemiBold.woff2": "ea0681c9068fb473d3606e012e76728eb44b2e04c276266894aeca5fc1934b52", + "assets/font/Mont-SemiBoldItalic.eot": "c7130fc00335029f97ce29e2ef0ba8278f9928f2e0e94fc329eeb5b434382b6e", + "assets/font/Mont-SemiBoldItalic.ttf": "d41d3c156ecc3fb13c05ac8a5134847c0256a1aec9c9009be20864637a5accaf", + "assets/font/Mont-SemiBoldItalic.woff": "6cc4a96ab2ad4df23a05f33d2ba02102ba50804293dbe38aeaf3a98acdc1edc8", + "assets/font/Mont-SemiBoldItalic.woff2": "689bcc5350fbdf6186ef0cfdc9f66c5849b2f763f25c084ce1cf4a0a3cba4f41", + "assets/font/Mont-Thin.eot": "2a78e0788c50d1d1abcbb9143bb9454280d8cabfdd8b126bb66bf33dce923d74", + "assets/font/Mont-Thin.ttf": "82fba7ea5cbfda83867df7bea68da990caccb92745988e69a529412f1729aba7", + "assets/font/Mont-Thin.woff": "ee1781a5334f694b984e94870f3693705dc9f6de2bf9ea9cc09ec546cdf3a24f", + "assets/font/Mont-Thin.woff2": "fcfaf54ddf82e941398b75638a23a8318658ac8f1e09c1c954788ae2840acbd9", + "assets/font/Mont-ThinItalic.eot": "8a24a0f0ffdf67b114b114fd888e143369b3217c01fe112274a315fdaaceb8a1", + "assets/font/Mont-ThinItalic.ttf": "2774c696a2fd490a27c4a151d0c5a00e8439bf4483c0878f5380b5447cd52337", + "assets/font/Mont-ThinItalic.woff": "c7328fe67271bfc102091615989f9e303f6e6312879e8d4572b963ee3df09cba", + "assets/font/Mont-ThinItalic.woff2": "ea3e03419814ee43bb95f38636d1da5df78c3a7aa26233080890bc27bf6dd197", + "assets/font/certificates/Lobster-OFL.txt": "88aece7d90f2bb7049719f11619a560af22af3451af141f12aa4f46bb157a99b", + "assets/font/certificates/Lobster-Regular.woff2": "fc34318ab076213055f3efef3c6f59b295db3b3d32ad98afc77691b9d2f7dbff", + "assets/font/certificates/PTSerif-Bold.woff2": "1086afee8bbda29eac51ecf3f236567d2eb93bb820a676530a3af887cff6785a", + "assets/font/certificates/PTSerif-OFL.txt": "ddf311c28ddf5a5ad9747649837346b67bed9d356789c3072bb27dbce49e514d", + "assets/font/certificates/PTSerif-Regular.woff2": "651c35f6cb50a1f31146912c8d083e0c15de77b016939490647bf2914d3ad169", + "assets/font/certificates/README.md": "f7a4378954e2c848e91be4d2677b2d7540a5e2ef45aeeb7e67760c4aaaad8bf4", + "assets/icons/svg/academic-hat.svg": "f1ebd7747a9acff64071d9dcc5fc896f273e018fadc1885af63e48781dddcf8e", + "assets/icons/svg/accent-error.svg": "d4f6fdbeb37ab1fb4043dcafaebdfd5403acf951ed1761605f579c47817b59b6", + "assets/icons/svg/achievements.svg": "dbad646f847267d07cdc33d5fd90f5e8c0d89fae386e72ae493415060ca0b839", + "assets/icons/svg/add-person.svg": "d355b7508a87b8ba4ceab4916841158adfcc4aa95f3e66f67341a2920c557147", + "assets/icons/svg/additional.svg": "161ae69208e7defa01d965305616f82318df58a2307dbca76cddc82a077c78b1", + "assets/icons/svg/analytics-submitted.svg": "1db03b920ec67c47263fa07bffe6471a1a7df4ac341e877e40434160c9736a0b", + "assets/icons/svg/analytics-team.svg": "f9a36444820d11623d44112c65f36be39ddaea41cde7bd624bc9ecfeb6981f66", + "assets/icons/svg/anchor.svg": "ede46ef85c113f496cb00f80ec93ade7ba7dae5b7653405ef9d22f5a2e542edb", + "assets/icons/svg/arrow-down.svg": "49eeb7d46c46fdaa41558d878c384177abb91cdef2a7f81f1f6bc2243e257675", + "assets/icons/svg/arrow-no-body.svg": "ba8c40f6beb8fbd7cff0e73a208d64f838bd1009a3c58ca589366f9c39eb75eb", + "assets/icons/svg/arrow-wide.svg": "f4ca69889fdccc34455730e59d524e35d32e00eb5e331919598a9d75a64b40f2", + "assets/icons/svg/arrowright.svg": "456b4b302a2b57d42dba35d5362ebe34621da91f7f3a1c059aae28810e7b1635", + "assets/icons/svg/attach.svg": "2cc77358759878120340c760ae22f5ff093795aec9622a6b1fe5a0eabf43950a", + "assets/icons/svg/basket.svg": "0675d68c761f8401c2b8354f1e6ee1165676ceae351141b24d8ea28a59c960fb", + "assets/icons/svg/bell.svg": "7486b0ca74c9c8d0ad63343b523358e258878bc04bdf3745b091a95f8dbdeb0a", + "assets/icons/svg/book.svg": "04fa7f5bd0d7bdeccff8d2f582da16125023562f11dbf9bc17d1b4119593dfc9", + "assets/icons/svg/box.svg": "3ab37dd4d3c5e5567f59a6cc813213241364c2a5255fd750f26182ab0998da27", + "assets/icons/svg/calendar.svg": "e0923360b7b7d7364e7e9382438abb5f498a494263c4ba3b91180ee08ac44900", + "assets/icons/svg/chats.svg": "93e3cf266bb089bddbb68b8101bb0756bfb4af8d88971cb7e20e0456db94ca4f", + "assets/icons/svg/check.svg": "ce333739db97ed9e527902ca57163c4c76fdbd95603880faa7f004029a7fe6e9", + "assets/icons/svg/chevron-down.svg": "554c4ada5fc10b47c3d4047b74815f0e6c8fb4cc6ed572bf2d71995f72bce18b", + "assets/icons/svg/circle-check.svg": "28de43f86c7eda529b0a8f8751e4caa905a290474dae23ce4b8dc6a288c308d0", + "assets/icons/svg/command.svg": "10bc2a8c7bdf9105262074d4f33756bb392d7593e2b80d78976ccf2d6a86c820", + "assets/icons/svg/comment.svg": "366d6d091c2dad0bdd034bb7aaf1f7e4b2ff3bd0659b0abbc623694d3680e985", + "assets/icons/svg/contacts.svg": "d47a809d207f7448a8d36f625f77f57c05ef0adf9997ecb2acb06cb45998b66c", + "assets/icons/svg/cross.svg": "152c67d622cf3ab3a9f994256a700423751c28eaff42b3b8268e4fc6ab119ad4", + "assets/icons/svg/deadline.svg": "0d3af503613f36874a66e31755b3cdd9f18b0a51925d0f1698cc607bde09cb12", + "assets/icons/svg/dots.svg": "5c5eede4d415a546635f202961be20ef768d55b2f7f2527b5702b6872875e2e3", + "assets/icons/svg/download.svg": "92bd50debd8d4d3763e6dfffc6701880637290b9deb7ce737fda0ec9ff960c61", + "assets/icons/svg/edit-pen-old.svg": "233413531f966eea74739c0b3f92501a96a4a8c12e14c7dcdeadba29771110de", + "assets/icons/svg/edit-pen.svg": "a56f452804324800883db0ff86fcadfe31de47927ef1d7c0de4797fcb1a5c0c0", + "assets/icons/svg/efficiency.svg": "36c3c58f6c12a0356c9427d964da25be83152efa34ac5773282ca1bf98c4ac8a", + "assets/icons/svg/empty-chat.svg": "fb661e453bba7b8435d9d71e7f9491b326871f4583920333aa98829ce0e19f4a", + "assets/icons/svg/empty-mail.svg": "ab03db717e863e84dd63787f2da65bc72775ab55d3f121d0a5000a8048346f01", + "assets/icons/svg/error.svg": "a6a53258a37230b39ce3fa281d53c0d75a20ea5f4ba1a7f1925a3847484a3af2", + "assets/icons/svg/eye-off.svg": "ce6ec04e8df239e45d0955a9a9955f30f1e8858298bba8abf7f5162e07a3a11a", + "assets/icons/svg/eye.svg": "2ad0716fbbbf3d103a5953566285388b5b961ebed48f888cce5903cf42541890", + "assets/icons/svg/favourites.svg": "204a514e8b9a5c16f420278de96a37985a9a3d86234d3d0f7e7b8a671407dfdc", + "assets/icons/svg/feed.svg": "196190a4036844581e4fbc460287503ecefcd6d2e4834897dc8506f12fc28ef7", + "assets/icons/svg/file-success.svg": "83fac88da530cb668913bf49cc9a8c64acab44c4e068a37e9837a2b6d7d887df", + "assets/icons/svg/file.svg": "f2e8476e0cff4d5022374f3ffa7c6bdbc280f2ae30c9956ae348b75dfc9b70bc", + "assets/icons/svg/file_type/file_arch.svg": "49d92d3d28de322ac1065c0a28118c68bea8806e746bab6e0c7e0d0da4af3d73", + "assets/icons/svg/file_type/file_csv.svg": "9e482a0593fc1a61cd5f5818e6bb031b052dc97e9f1243170d0e66949d74b165", + "assets/icons/svg/file_type/file_doc.svg": "93a76aae04df76f3a6601f529fefbadecf0ea89c9f1153920abf7c14a4b7b109", + "assets/icons/svg/file_type/file_file.svg": "b14487171ccfaadfea8077c097a22c542bb597ed4ec3e6ff2f6592b204bdff7c", + "assets/icons/svg/file_type/file_image.svg": "e2134bf78d5f1036d2d027a1fa5ba385308e3cffb3f7debff16ff6612cb849ac", + "assets/icons/svg/file_type/file_jpeg.svg": "9f2674e29995a43faaeaade75795f4d6061c1cba57633899dd3f90c4538e748a", + "assets/icons/svg/file_type/file_mp3.svg": "30aa65519e07eae115d12f1cf99ec8ca04befabac2ff4faa27738c81e7b01e5e", + "assets/icons/svg/file_type/file_mp4.svg": "bf912f0607a5e5ec08ff0ef3bfcf2a296bd8c6e2ee9234c885281dac7b4cee6e", + "assets/icons/svg/file_type/file_pdf.svg": "28543c7002a8311268bda12068d3f69a06a08d3b9c825ac67dd4787b940d4b2d", + "assets/icons/svg/file_type/file_png.svg": "1c3034acb155c51c6ce4a421ce018a345e7d0b68d219c8eb52d110406d6ca239", + "assets/icons/svg/file_type/file_ppt.svg": "f5f9e4ed33311799584150c3b82a570f8c3d0ac63b9032d87bdfcfda0112d361", + "assets/icons/svg/file_type/file_svg.svg": "1887164be84471f8247ea58d2d30d23adafda7360d02dc4c81bd0110454abf7a", + "assets/icons/svg/file_type/file_txt.svg": "0f7d964ee2fcc756f61386d0f740e32cdd1466dbfb340f0404508b4c84940110", + "assets/icons/svg/file_type/file_video.svg": "a0ea9fe468ef9fdcf0b89c525af5cada2248ab75aeacc3a81c2ec73ed065b7e9", + "assets/icons/svg/file_type/file_webp.svg": "5ec61e22750c70c172b049a29cb84463379aa8128807697edab0f55b12683ed0", + "assets/icons/svg/file_type/file_xls.svg": "0c26bdd828ab9e3c93c5e2d5fe5d6a88a08bc48b1936cdcb7aafa1ae79444a93", + "assets/icons/svg/file_type/file_xlsx.svg": "20678983d227cce74dd1806aa365d5573167e7676575d36b9774b4cc88fa13db", + "assets/icons/svg/filter.svg": "9b4588c7f90dd2173745fb3cad2dfdcb71cc2a5e905d7a4d0e780115b4784b50", + "assets/icons/svg/folder.svg": "8d7b3c5ab1593147c3576cdbe021c6c73c53f1a2277b32c0a3c51cf8ff3ee8ef", + "assets/icons/svg/folders.svg": "3585b3e2628b0dea0734559d3800b0e926ff3309edc1da00f62a86d8849ecebf", + "assets/icons/svg/geo-point.svg": "51b44a9fa6311ce2d1df7064cda7ac67cd95bae0027861eb2dc20876486af6ac", + "assets/icons/svg/goal.svg": "fad71af7723de718741e066cf46dfb385b458c66e68638db08bc957ae434620c", + "assets/icons/svg/graph.svg": "c66a8fc70250f40a2e4b10083b95157c7cd0e9d21a2c9a607637b800a5eb33a2", + "assets/icons/svg/hand.svg": "0e5b636068142e82129c9e3b940264a9ffadbe6d192b9c300df5363457ddd3a0", + "assets/icons/svg/hashtag.svg": "b93d4a6eba052d1090e68fd51af782e1728edebcd21fb0f0e43961b93293cb23", + "assets/icons/svg/hint.svg": "32b28951a20cd04a5bc6f32540c5733a9088ec584a9d77acbb26bc0af492be04", + "assets/icons/svg/in-search.svg": "5fd713d56abe2005572de9d95a3d0a7ca6d05986522dfce076cac7f1ca24a0d0", + "assets/icons/svg/inline-check.svg": "74207853c976459d20f4c671e30121821efab14173fa3f1539e42bd1e0450a1a", + "assets/icons/svg/key.svg": "16e97b4f373d7842a2f8481c6b7299168b8b8503d1b6ed611c41e0640ce4c2b1", + "assets/icons/svg/label.svg": "8f1beefe4e15c35de5957d441825912c124087db7ecf610703c6a24975e8c498", + "assets/icons/svg/left-arrow.svg": "22847871b6f9a61db97665c02517e348900161eb441029564f38b64629bfe11b", + "assets/icons/svg/like.svg": "4c3c0f91a9db66677f63954eff25e79f01e0edb61f87e139340fc1b718f13480", + "assets/icons/svg/link.svg": "d4148452325a700f8f89e33658026e12c05a43840ed3107944481df52a9f2aed", + "assets/icons/svg/lock.svg": "2197b49564f06c7fe61ec608e51c2b006dd19393ba8d1f5b692f28fcb6a1aef4", + "assets/icons/svg/logout.svg": "f1f828ac9e48229e8377ffb0580b642a3c48db49e300f0cc088cb4e6da9c2925", + "assets/icons/svg/logout2.svg": "1df1f3c12c3d512bd3f5afef561ccb1b531327fcdd4d178f1776d5fe89b46672", + "assets/icons/svg/logout3.svg": "7def497eb56975029b74e2c77eea3a9f5d9f7a142a48aaf307d91e920f585841", + "assets/icons/svg/mail.svg": "31b773ed34d5c0b9eb38aaa064a903cd5b6e87511242d3386ad851456404ab90", + "assets/icons/svg/main.svg": "1b2835e0aced47f201cdabdc20f8662f60c509ec2386b5c8bb9c85cfaa8f7bb2", + "assets/icons/svg/medal-outlined.svg": "4c899e6dde73ffcf9ea8f9994f7d502c24af4485aec01b92efa6d625acf4478d", + "assets/icons/svg/medal.svg": "5d3008426cbf165b305f48cb59af7c7403eb2b03e6968587ec5b5334c9e61ad5", + "assets/icons/svg/menu-burger.svg": "314a0227ddf3e45e42d90cc098052b7151016de978fe784eeaa1d5053c65ad0d", + "assets/icons/svg/menu-cross.svg": "701dacbfbbbd2c1be6900b9d74f5673163829aaa385c9c40af29e82c03f87d5b", + "assets/icons/svg/message-inline.svg": "d286efac700d14a9fee64c75f5b841c943a7640e031e01659a8573948b33137b", + "assets/icons/svg/message.svg": "4d2d03a4e97763a433237cabd5c08d5156ede936508445fd516519cecef48fab", + "assets/icons/svg/pen.svg": "2059d5a8343f0f1884e530541a4bc056c34aa8edbfe528fff037a28c116073c1", + "assets/icons/svg/people-bold.svg": "cd1c22feb545f0ad5aaa49b6a43f9256288ab3ff72a94e255015f8b99dac0748", + "assets/icons/svg/people-filled.svg": "c012e876e9c494b93161e21dcfe288c71b83d7f9846a2371b100c6d410ac158c", + "assets/icons/svg/people.svg": "3cbc1d9a91fc27ca4ea78a343ea1bf2ec9617dc42f862b045abba2239515328c", + "assets/icons/svg/person.svg": "b4ba6185224800a65f48eca9b9eaca42364cdb7eb739b672e837b2fde643d269", + "assets/icons/svg/phone.svg": "60f7c7a24f2e80cf045083936a38a550eee5d7b7836bfa21d54f0903081a4a86", + "assets/icons/svg/pin.svg": "34e104fc5757baa38b091b24746cdc9f524832269ee5dc3c211919380908d926", + "assets/icons/svg/plus.svg": "6925b0744f9e7397d5d9cbbefca2847a47a48b3ce08ede616fe8abe33d9222ed", + "assets/icons/svg/procollab.svg": "84f9ff167518519dde9617f66f55f3ceca5adaa76f7d6fa585102251e15d5246", + "assets/icons/svg/program.svg": "d3c92ac8f0eee090f8bc27c2954528d07b7bf941ada11c6ae66ed9cb77c8c5fa", + "assets/icons/svg/projects.svg": "952c4e13a26c9542305e85ddb667c11debab9fb88b29303b5e73b92220b3b2a1", + "assets/icons/svg/reload.svg": "7cd1a0d913e38512355fa8e6e3426648b1bdab2a3d47067335309535c075ebef", + "assets/icons/svg/reply.svg": "b7b394021871bfb3dc2e00c8096f117ac6dfff26537facab589ccc0a07a88a00", + "assets/icons/svg/rocket.svg": "7dbe8dce52dcbdebefba4d8987f3ad95be8817d058440f9c624da4e65956ba31", + "assets/icons/svg/sad-smile.svg": "ecf9ef575748efaf95f329b2f4f0c59adcb98471282a208f5a168f80b46f9417", + "assets/icons/svg/search-sidebar.svg": "f960a9b0a14fa09ad3463ac5a087049c969a5c666fb7ae10c6b6a90a1a6e0bba", + "assets/icons/svg/search.svg": "d7125923fc66715d8af7c0551d61fb66b55c05b9c4d4fc5e7ea1e2f62e3614ac", + "assets/icons/svg/send.svg": "59b6e42e21cf9f6fcce1dfa787ac5431588723cb7e8693453b4b87c58f6cd565", + "assets/icons/svg/settings.svg": "e29c4787d45db5ba6c033abdd818423e490340751e133389453e847c89c4bd0a", + "assets/icons/svg/share.svg": "9939332c05851c02ed89197b4f4de5761ead92f64c8fdc2ddb639991371aa151", + "assets/icons/svg/slide.svg": "1ba844f7c6cfdb3646a23ecf8ff23635a1c91b8c736a009c7ceef8f353c4afe1", + "assets/icons/svg/smile.svg": "36c4ae7c54a39805db20120631ed9e98add02488477e3c27132dd42a107001f5", + "assets/icons/svg/sort-activity-asc.svg": "e2f9df22ddba3b62c6c189d978b94a7e899df05fac90f7ccb03a6b83a1b6197d", + "assets/icons/svg/sort-activity-desc.svg": "b1ea812b9ecd25c580b8deea2dae3216c249381dea56ab1e27d325d5b93f2a3a", + "assets/icons/svg/spinner.svg": "cc37435f77ed822e08974859a38296c9231f9add395ae14a69b70e77be65e6d0", + "assets/icons/svg/squiz.svg": "ba5c66687d62fa6ed9cd8395e42ff2edcac05b4d589e03d02d2cbb3d6b513326", + "assets/icons/svg/star.svg": "5b2470751733a5636dac71bb0cff5e66f2ebc0568985b14decc8b336a5763232", + "assets/icons/svg/straight-face.svg": "d76f651ec215d5c6b3d3ea1fea5367fb81e7349b1143844f112fa3c9d3e018b5", + "assets/icons/svg/subscribe-badge.svg": "db376b0c72ec1caab67e02e08860c428af0486db43af6ef8ba0b8b27833ad257", + "assets/icons/svg/suitcase.svg": "e083977965847931a469becea90cbc878ef2cc84417765755894be5b04b136a5", + "assets/icons/svg/task.svg": "2d052d18d9edd4be95485d7a68edc7363de5c8130c21ea52f540ff95dca99128", + "assets/icons/svg/team.svg": "c905d74f382bab4d827e6860b5ca66ce25cdaa0f38ec5c25e2577c890432e146", + "assets/icons/svg/telegram.svg": "53fa13e1aab8bb0824743992176f02d44a6e408b50c135f4b34763b960576200", + "assets/icons/svg/trajectories.svg": "26b16cb4ceff596802f4a48dd28bce24ebfd81cbca00f7475133ec11ea5bb972", + "assets/icons/svg/triangle.svg": "4fab4877237d59a2324325848ce6f3578d6103bf84391dbc118da9b1073272ba", + "assets/icons/svg/two-people.svg": "3e9c9b3b6dcccaf7d4d8df0960561ecac6cef666ab97048c7e2ee8d4ec0396ff", + "assets/icons/svg/unsubscribe-badge.svg": "283fdc3bfc21f8caa05be97989f3e6f57ce9cf46aebdcf3479db9b694b27ab3a", + "assets/icons/svg/upload.svg": "3f5147e64c3e20666463d749856217533cd3d51e8f0880881c358b0a479c6c4d", + "assets/icons/svg/vacancies.svg": "8a26779972824e3473ac8bd648ee0d0eac879609d29a443bf338803f610906e7", + "assets/icons/svg/views.svg": "166f0df2d1bc12b235d43780c3ce349276c54cb49829fa8178abe901add41878", + "assets/icons/svg/vk.svg": "b19cc5a2ba05f1a79ecfb71209def0cce2746e391c340142945267e90cbc8c7c", + "assets/icons/svg/work.svg": "1a6dcee48be22025dd3b31e2f5b4d72909db934875f91b8fb4af0e8e74327ab0", + "assets/icons/svg/world-wide.svg": "61d5c0c1cd6a9e4255c5f16305cfcf1e77510a636ababca19361ab93db18253c", + "assets/icons/symbol/svg/sprite.css.svg": "2398de44438322c9f644db072d284b0da70f59db344b67657e8b7d086289f065", + "assets/images/auth/bg.svg": "1742940a95019408d290411e9c8af8d34f3aa7b6a32002f2fd21a3fb16eec864", + "assets/images/auth/email.svg": "3e767501b1ae34792a0f2b77097274d15b1307ffd5a8069846502e469b066387", + "assets/images/auth/login-img.png": "d0cc9a1442ca9414c03171e4faa15704bd54b8382ff1f3ff8551c32f804b532d", + "assets/images/courses/character.svg": "e24d5466be290e4d389f4655d05977baf5ebfbdff3711739261d95230576506d", + "assets/images/courses/complete.svg": "ccac92979a7ef5fe117bd0d6beb76d771179deb426a066d61a0e1f5882c2299c", + "assets/images/error/not-found/not-found.svg": "3e0cd151353b9b59f719f5913cbc2245bec31380894b97aeb49707b0562f1d5c", + "assets/images/office/feed/vacancy-card/vacancies-card-background.svg": "cc414473ebf0bcbc21f34a4d96f8a2a33c0bbc554c25d91b684f77538228cb8b", + "assets/images/office/onboarding/laptop-guy.svg": "80e685cde0ed1cd501854226471b7f052a3853a30a591683da551bd9bd7392ec", + "assets/images/office/profile/detail/cover.png": "a3c4bc34bdc819e4d54fde81adb21b3f5c818e6befc294152ffb582b8b0da081", + "assets/images/office/shared/backgorund-image-rocket-main.svg": "91f539d9e5fbf92e074d58d42fd18d2252be31d35b5399ba1a619402e5d4fd1a", + "assets/images/office/shared/fulfill-profile.svg": "212deb59494338b341e8b982e9a68741b8ba8d9b4be11c90a0d9ef5816aa7703", + "assets/images/office/shared/lightbulb.png": "38d20e1c2da1819d02f6897e6ff58d5182e0a2711da205c27a7919cf9ac38f11", + "assets/images/office/shared/wait.png": "67ace598f1a2bdc018d0b0206ff2a8b64a8811363409db817647f1f4ae8786d7", + "assets/images/office/vacancy/send/bg.svg": "7e6313a0d71cd424058cbae011ec359f88ba0c959088e5a0958955544331dc3b", + "assets/images/profile/achievements.svg": "1f97590ddf694fdb638ac8a861750aee94f2d809f9fb24591ee5260ba4567a2e", + "assets/images/profile/education.svg": "73fd9ada024e5244165a2f9083502474b4e184d8dc198f7f987630ec10e13544", + "assets/images/profile/experience.svg": "1896343d5bd8bffd66b273c0f82f37cf3706a96603d3684d20f7172446ef1878", + "assets/images/profile/main.svg": "dc4f95deaa2a0bccc5457398b06459ed18634483e6d6d8da4736ca31ba328987", + "assets/images/profile/profile-unfill.png": "b8fb0522f208016f5470d3a8b84ec4df4a1dfca0570fd681598280737bb6b339", + "assets/images/profile/skills.svg": "8cad15072e1a6eef16746454c4b734a8914ba21d327094a62c2707a0d57d84db", + "assets/images/projects/chat/drop.png": "3c391516a0e9e4d7f158fdee55d8aac9fa2643595ae6da6784111baf6e6064ea", + "assets/images/projects/detail/bid.svg": "812b503a8942d1476d118b8d68053a039fe69b33e0d31a6bd01dda9f1aa9c723", + "assets/images/projects/detail/end.svg": "e626f72b2865d28e076e668f8b0fa7ed4d0c66949c3bdc6225b57a42584fec8d", + "assets/images/projects/detail/leaveProject.svg": "d73ed1886be77e0c87991d61d6bf40cae95a980e238b60cf920b0c5f18703921", + "assets/images/projects/edit/achievements.svg": "e6aff1fd50768e33e0ada60c438bb5e645243535314eccd60a2ea27a8414ac9a", + "assets/images/projects/edit/additional.svg": "f74863c03f7c2fb3d64be40b587193d536d0780dbe4f890cc787c97a43698c94", + "assets/images/projects/edit/contacts.svg": "cd7b3033bf14752b6771af72f2b28e22634940813d8fcc37fe6a96d798653ddc", + "assets/images/projects/edit/main.svg": "eb60565d900e1fd273d8c5f04f0bfcec3bfacf421ecfd6e936bd2d887771c994", + "assets/images/projects/edit/members.svg": "48b2357701b2f103c5d8fa748add76cf8b22868a7971f053d4c14b8c92d61fd2", + "assets/images/projects/edit/team.svg": "e393e1c1faaf9edd7165d5cba5f2a2c7cbaf438e196145eac24378d5efc18a17", + "assets/images/projects/edit/vacancies.svg": "d12b2b96e47c274ff4810de9cc932481b7e92d24de10f7ab52ad43e8798ad62b", + "assets/images/projects/responses/no-responses.svg": "2b2618dee8ae440425f98cf68bde0e09acdea89156ffe98c4b1b094a437377c4", + "assets/images/projects/shared/add-project.svg": "2bce2161c3ed8eac0c8635d68c4ffd2f157c0e61a8d60b3be2dbbbdf52ef36a1", + "assets/images/projects/shared/arrow.svg": "89355315b93a1741ee0589197ca24b7e48a264669395e001cdc2de3921050616", + "assets/images/projects/shared/divider.png": "6b8724221c992006abaef1dbcbc4838d32b636333da9547aa29fa1b8ffd8e8a3", + "assets/images/projects/shared/edit_disable.svg": "b6f8eaa9a8a695e5555dbf212c4edbd062fa0914c06df32cc45452ac2b28fced", + "assets/images/projects/shared/empty-subscriptions.svg": "dccfa9316470cf98c7207471c08c60d12c5bec31826a860e6e92cff18366f6df", + "assets/images/projects/shared/idea.svg": "20194052c463205dd2b3f4c3556e5a8163c0c00f4644dfe80aea23093c5b24af", + "assets/images/projects/shared/pdf.svg": "9fa65e707e5ffed90bad2d90c13726e48393e689bed771f4b5abb590dba0a89d", + "assets/images/results-arrow-down.svg": "4efd8b675cca99aea02bc1910e6c372df125e389c2eeb9c2695d7a7b01eb3f0e", + "assets/images/shared/avatar-placeholder.svg": "75468bd50ae9eb762dd0cdeb9ce8a77c6af20dd92e277d1ed359cc1d5428c630", + "assets/images/shared/logo.svg": "e3e10725df88b75c81067ae59efa51a725f4740dbe4a96c3f29b8e028962495b", + "assets/images/vacancies/detail/success.png": "9db26e2476cb338b1e806cf31b106b0dc292eb07233cb2d1d9de28dc32dfb574", + "assets/project-defaults/avatars/avatar-01.jpg": "22ee8f8eb3b69d8e86513d3c4490ead66385ece56835fe6c05d9867c6fbc1330", + "assets/project-defaults/avatars/avatar-02.jpg": "f41b1e52ff57e9bd606f868d93a06300680585b00d7797cb6e9c5c2cae702f22", + "assets/project-defaults/avatars/avatar-03.jpg": "fa1c5c497a1b549c2d49fda4d90d491d9ca1a152615d33a323d6cb1319a2df65", + "assets/project-defaults/avatars/avatar-04.jpg": "b3a64637363e12037b62001151f8c6f32c4e654b045d5195c26872aef2b04f0b", + "assets/project-defaults/avatars/avatar-05.jpg": "8e8ccd59fb4352c22495d91a834487a003f08ce24864f78bb203c921ce0c5dc8", + "assets/project-defaults/avatars/avatar-06.jpg": "4d001075bc4dc0a8f1990c811b4fced86a6b4fd8af5c814a23e1e9ef59b18107", + "assets/project-defaults/avatars/avatar-07.jpg": "f2efab2a53c64312d55879b4d3b65029515ff644404429416d8ef9925325c921", + "assets/project-defaults/avatars/avatar-08.jpg": "c0c2a9c16b1063728ed951c1037c48eb71ecfc9a3e31bbccdf45953160df35da", + "assets/project-defaults/avatars/avatar-09.jpg": "7a4234ed7901452aa52b8e439459da9801aec94d71165220ea7a5a37b6fc3e19", + "assets/project-defaults/avatars/avatar-10.jpg": "098a1c839374d6bddd532481826397470e67cf781e3285acd7f956c4213a68d5", + "assets/project-defaults/avatars/avatar-11.jpg": "94d90291b9ecc2bb39ec54bb1a5f192367cbd04fa6cfcc72e13111d3b702529b", + "assets/project-defaults/avatars/avatar-12.jpg": "f20f0a7f2660751b6f943dd9e0eda55841f5532c0afa561dd451beb3a5cac61f", + "assets/project-defaults/covers/cover-01.jpg": "23b695ac039285cf38626c95bdcf1e844f9860941c43f38341812855d7c0fa0d", + "assets/project-defaults/covers/cover-02.jpg": "b83db9664485763969f6f33f7b752007e9fb067f13ef921bd41803180c1d4392", + "assets/project-defaults/covers/cover-03.jpg": "09a68044e39185c30906a7da3b01e5f67800b718900a154e22c521dbb127a6a9", + "assets/project-defaults/covers/cover-04.jpg": "9370d7b308bb046cf92324ce7c5be937da19b132349aaa7e07cabca2c6f0471f", + "assets/project-defaults/covers/cover-05.jpg": "34ca5d7f6c68e39d8aaf5d9a26bc4f0750c4e010d11a57912a65b4f0e7c4c84e", + "assets/project-defaults/covers/cover-06.jpg": "7bfca4ba4405c1377e65d6ab6247fb76ac9aef4904b3c84c6c12a40111c49ec0", + "assets/project-defaults/covers/cover-07.jpg": "ac146b5fc6ada40755343ac381512afc30c97995c1688b18167eb2f24c1bb4f6", + "assets/project-defaults/covers/cover-08.jpg": "e184342cf712f9e85bf68e74861f5d7494a9f887fd54f396d862b393b94c34c9", + "assets/project-defaults/covers/cover-09.jpg": "78a03679718d355959c052c026a848b57e1eecb44e9aa3234935eec4a225cfd8", + "assets/project-defaults/covers/cover-10.jpg": "78dfa58bad451b6b783dbccfb2ebf8e5e8ac336d3bae3dbdcf3d86405e934e87", + "assets/project-defaults/covers/cover-11.jpg": "4783dc7487a4e281adcce244bad84dd1dbb86ebd2d1aed85fc702592d3eea6e2", + "assets/project-defaults/covers/cover-12.jpg": "fada1af94e378608d706978959bf0760f0afd25c1841a252937b1dcd08de57cd", + "chunk-23ZU6LRG.js": "0f525f4981593bba207b0c9dd26ea9913436764224cd8301016653db18da558d", + "chunk-2DR6JBGZ.js": "27500a658cc7a28d96b8a632ef26e93389a6645455762c97e354493a63a86084", + "chunk-2EXXHI2N.js": "1fd3b5574d51721db14b1ea5162dba1a160badaf9829586fd82699f1d5c01e90", + "chunk-2F3XFNHK.js": "ab6bd380ea9bcc7e6221deb3bb2cbc5eb1abc4af26bb7652a5cc4593875c95bd", + "chunk-2PU4LROP.js": "8c8b02efd22830d93c30104e8ba6ae70b73abe07a9500a6d879d7b6c6a3f3acd", + "chunk-2S2ZLXKM.js": "e016ca0eb340d39b495c828c6bd2574738173c3f73a49129bd4dc16d9dc4486c", + "chunk-2Y3K7AYQ.js": "6cd368169658e6afe7bf0497708d909e2e9421020c7b7dba3bb8fe6ebf448dc4", + "chunk-34ZEFFCD.js": "dc5e14c696fccc4e36fe495ff28f44ec22f35420650408e5337f9a22a8427484", + "chunk-35OL7QEH.js": "b305640806e84826abcee9bea785bd0966ee7ba0877477d376b22c49061a3d1c", + "chunk-3IWBPXER.js": "c0f62a825c68d1af6995fb8496ccbf04d1416560a6385cb27f7eeba4bdbc2c53", + "chunk-3JFQI7S2.js": "98bd099fd0a582f9e8707bc48ccd728f0b47416101982b83559ffdedb6be70e6", + "chunk-3VON52XT.js": "9048867e7cfe45dc62c2fa62ae7f3ce45b07e7264c734c52d9c3319ad686d881", + "chunk-46TFGTY5.js": "264eadeceecbb48615dfa877e0a52c4e3695e81077820d318913ebadf7d6dc81", + "chunk-4CQUPGHG.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-4SDEMIFW.js": "7d88c5c27a5eb6d0bc4391a9c3a54ede849000b09cdcd9533b8e3a75f0bbf92a", + "chunk-4UBYHQCH.js": "75e7b821db517e0bec5b749aafb2b5d8f182e4a284e36f345c15bf19466bd233", + "chunk-5GESWIST.js": "1902fd1296acc16baacab1ebf005d002211b5178fe5d19dc82499784e6fb3b3a", + "chunk-5I5Z7NGP.js": "d7b6b440ba5534fe64d41c37749e3b1f8cf81d89f69b528ab61ab5d6790c18ec", + "chunk-5JGME6PW.js": "2986a3a84405f3243da13ee3fa749de6bc0caf176056330690c17bab068fe23d", + "chunk-5TFI2INI.js": "6d6ad5223eb4bf383621d2f853a5405c7678b55c3fa37ba69c4d0a8594201c7f", + "chunk-6C6ZZN2Y.js": "d2e9d5250b8ee241789ca6e63636ce74725b1349c4a8587591b53c93a7333983", + "chunk-6EDX4YDV.js": "9fa3493844368ef524bdab5518df6aa886f801edbba99f514875f1d114df03ca", + "chunk-6KPOXAX2.js": "5fd883040ec4d09bb63689b62bcb37cbc946036b9fc8ccbc946c03879a52feae", + "chunk-6MDSHYKU.js": "591172e750cf13111927b0938e36e07a5980efd9317fe509f708d7af33ee32c0", + "chunk-6QUBAG2Z.js": "08425cc32471f96fda4368fc2fea9b4bac4a4612ae4290cb85347055d00c5c80", + "chunk-727DOWQN.js": "c8a90d26f6510f5681b64a386149e0e6f6f2108402d5769bcd9c10662566ce5e", + "chunk-72TIMBJX.js": "d629dfbf356ad035593eaac5557b36e72dd2268c84fed2cddf7cd009ca32665b", + "chunk-773KAP3G.js": "40e566cd21439fdf51f7739229c3beb9a95b63f79eededa740f70f89adcff4f3", + "chunk-7IWCUP2L.js": "f5984ec34309445a0c57612c807b1b08ebd3bb2a0af6438be873735ba0f7b923", + "chunk-7IWSYPRG.js": "1f14a5a71612025c94ca04fa5896dff138104b10341db741dfe9c4ba0db769d4", + "chunk-7SM2N75H.js": "153c09b915dd414421750dd76850019cbe65b88c0652efbcfb2e5b707ea81c4a", + "chunk-7XFZHB6P.js": "d1d251805e07014b3e2b42dc43451c4f40d5fd73c100179c7e3b7e190be1028f", + "chunk-AAG2V534.js": "91ad0c74e45a6c8d1e519236d3a1e355dc4ddb2fdc50299647feac0096cb8d5e", + "chunk-AFTDGM6F.js": "587fe72e2710ea627e3e1ad4b03f90cb08320bb1cb75e68d34c65ee9cdd1fd47", + "chunk-AG4NU3VM.js": "580404b89799aea212ed32ed53f7d72314ec1ccea96781b08c2099c856b7afb3", + "chunk-AGJGLD7O.js": "37ac596efbf975669926cc79ef5908768bbcb434da095da1fef16a2f99fffe96", + "chunk-AJVO3555.js": "ff32996437249f529f7df979dc9bf40a875e10c5ab850db2222bfdfccecc48cb", + "chunk-ASTRD3NW.js": "1deba24e94a39c513b607c2228ea1d5f4c593bc161e6eed7bbe6754ea8a87272", + "chunk-AVTNL7KT.js": "068e2a2e91de9521b40081c83e8a723b9a739f4ddba53fccd4356c2b2672c5dd", + "chunk-AXCE7O65.js": "1d0463d518e79bbb10ea0111ff9f9e087c1f7ae440a8a9f02b673276f6236fdc", + "chunk-B652S3Z5.js": "f6e43a5eaab4180a0b51eedfdd6e81fbcf7b272d9310d8b2e738bfbb3f7adf5b", + "chunk-B7XGAJUT.js": "47c244d2310f9c23dd970fcd05f8be2c2bd179369df26f46cb442ba1f9127a6a", + "chunk-BBGD2IZW.js": "205975d5e0289678750d5404a0e938465cc2ef69dc569757eed9639f9b52cab1", + "chunk-BFWRZFE5.js": "40a6b57a6bffe59d5f590bb6d82006a52f5ac17eac77671e62fa41d481ecf099", + "chunk-BXKTACNF.js": "e15083ed45f459a9ead4fbb1ef45e54624485802a19a3cc84d3612d2f9b5a2da", + "chunk-BZK2ABAM.js": "c5e4a81324c544e779ae784f11948ba2fe03b127fb1d7e62886199963234f0f4", + "chunk-C36HC2F3.js": "aca1123a243a7cf275b2cb3c24c6a5637c9fa768666b15063278644451785265", + "chunk-C4IVCFKG.js": "99661a008a655e91aeb55d7219936a888f07488905701d0130d53b1dc6f53844", + "chunk-C5LRIWGW.js": "fa6f848fd6b95655b8910ab58a6a25338dd79409a92577788f531e7290cb8520", + "chunk-CCWZ2MKX.js": "c4d0c01302f4f58b136b2588406a8368740b4798a96d03b9c413631a78c48262", + "chunk-CD7NUIUC.js": "4f84528cf0a1b545384e6ac7fafd5730416c038ff5a2ac87693367385ae7ccab", + "chunk-CFO3IHXO.js": "23c8d4da8c501a6907a8d4162096e72152072902900e8d26bb5df0eaca757625", + "chunk-CGVRDMBB.js": "189e62356cc24c6ec317f91815944b87dcdc3b6ab900ee29c4dc036a70bc46db", + "chunk-CHCMB7XV.js": "b186527ea25112d7db8680c451aec67a3846e2b5a0ac8b0eed2b682caa5385b8", + "chunk-CPAL4Y5W.js": "6e5762e246892c7031ea1f8427d639c992ed1e9070749e7f2b7c993e4605504c", + "chunk-CRTRRB5R.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-CRUTENDI.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-D7NIGB2U.js": "fc24549cc1422bb5368bd52fe8fdfa699d2296179cb1aa28aa711fd6ca0ce4dc", + "chunk-DBIRNJYH.js": "38bad91a785728b64cf9db0e659e9eba937675d932b0e8b6159003ae2320d785", + "chunk-DBUMCIGV.js": "6d5c95e0f69527a0e350606ca394d511e10de8fd478a1aa0fd88c74fd823274f", + "chunk-E4FZ2M7F.js": "d94ee416e317e0bb8180e561c3e2961654eda44ae5fe880859cb85948752451d", + "chunk-ECA4OUX3.js": "17bb8e7609ebd305d51c09981154600aa9410a5e157132533b4432fd3b009ed8", + "chunk-ELQM7IWY.js": "5899940d127a5f64fe0e2ed15ba2c8aef1b14c8003630dec00100c27ddb0cc9e", + "chunk-F7NYZONF.js": "b84ef3505f4bb855a7b5e6575403dc4490ce374b91efd3589daeda9c2b26d875", + "chunk-FE3JRQRM.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-FEJN5J3H.js": "e977c5e342dc4ab60b1a9a9ea96a3ca36382f147ceb9d7478d50ee66f35e5c20", + "chunk-FI4DW6KU.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-FIOLKH3U.js": "fd10cd3846722620e2b300ae4b2c375d6237b0b2361141bfcfca44cfcfc3fe86", + "chunk-FJG36B46.js": "ebb0ba7da3e13c1cee3ec5b6b7440eca906f8e7ed93dd3c03035c237954c41f2", + "chunk-FJQYBBFI.js": "3ebedab7b8d1f55fb1f61135dd92c5e8d9c8f21dc5aa71f16c12ef3fca0bc38b", + "chunk-FS7XQWRS.js": "c039faddd0498428d37819a134c68897e3363dc6908bec080f117298c430d1db", + "chunk-FWASO57A.js": "4b0ff823cf2b128d9cef0d1d019baa0f06e92bb5d7bc31e0890c47b9c260793c", + "chunk-G356IHLY.js": "236379d56085114d87f5a368a2c2b02867b8e7cee9b0e8fa37c18ce8ca876e77", + "chunk-G35EE43Y.js": "5a224827ebc125abb0280b3fa9e19b38fb0d2e2bc4bebe7dfd368fb88a02cce7", + "chunk-G4LSIWJC.js": "4c22381fef36e7360b3160a209dd65db03dca0db281c390ecc3bb48ec5b33b33", + "chunk-G7NJ3H3J.js": "3ac4851878ed9dba1d882097432d3ac7a162846263ecab9cb3768791f535d1d5", + "chunk-GADCCZK4.js": "45ee9ba4589dfc3b2a2d3f2a31b330f2bb6a323d547ff2b60be7f4e520c16667", + "chunk-GAELZNYR.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-GCGAQL3Q.js": "dc8abc419297a4ecf7e6b354fc95c45676c0ea06f3306a7d636d0cc0ff5fdeaa", + "chunk-GDMD5RKS.js": "219c04b6a66e74c8c28f31901fa54656a1b8a0ee5bd1b8158f5bae52da53a472", + "chunk-GEVGGPFQ.js": "9fc1c7d3c28bbe6e669c45b982a4259d6c8fef5376bb6f02f989b1e296f88e63", + "chunk-GJQGMQFG.js": "917a776a46d737c8ef07ab6d975ec7aea152fe078a2ac13c1b774322905ff82e", + "chunk-GMU36VBY.js": "aaec987342240a9553b330eac2d60ca5269896138301315894b92acc12fc338a", + "chunk-H3KC6E57.js": "ecbc1f4df8a191c06d1d5f10b725efb6d38c1959e0f841cd376bfdec23ee556a", + "chunk-H6VPACU7.js": "22f3ec9d92f5fb298e556fd7eb1c90dc463278a296db84d41bbaf75c6f7f0276", + "chunk-H7XPQTTW.js": "5e3c9ff4626be81066828160bab67b47e47387d8972ed61857cdbb2f4e34a5b8", + "chunk-HD3G6DRK.js": "be2e30d4c172b036b02c9e8eafea1da703d5fa468180abd9501665b7de80e622", + "chunk-HEM7ZCBJ.js": "3df5a992491aaadaab46497aac1fb43205182b57ba7c1cec87a94dc692aab0d0", + "chunk-HJMFHTRN.js": "b9e89de3fc602bb6006744916c8ba4a0323aa911def2b9947888f9aef52aa9cb", + "chunk-HKGPFLNK.js": "d77ae9158001c862d9c0b33bcb3e168b9b0468855f8f913baede35b03e656b63", + "chunk-HQSQT6Y3.js": "66a8b6c355187f975bbe2c2c0a5b9dc34e24d2df3d7fadc5acd6d9a6211b9a2f", + "chunk-HQYFZJM5.js": "70ac1746e90911fced1a458a34dcafaa843f9d3d3f005637f44f5e87bc3c86c6", + "chunk-HS2FTI3F.js": "d8c467f4c61bba733a9ec24dc985bfcd449571921beaadeb9aac8ccdd085f8e2", + "chunk-HVOESLA7.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-HWSXNT2V.js": "96f9fe5d70d812d830df8e30bb8a540a5a3f50174f3a6cf423bf957c126e2762", + "chunk-IC666LKR.js": "27f41d3d72ec2fa56ff19da35f3abfbdc8d67595037afb9330c74d764d166e7b", + "chunk-IFWEWPH7.js": "3fe8e0452a17e52a5cfaf7fc1a49c1b22d2258624baf94e1f94e294dcfd805c2", + "chunk-INY6GFMR.js": "7075fa433b168698c01fad8df54436c6ac1ed03654622c57d76dd2fb003afbba", + "chunk-IWKJLZKB.js": "0bcf789662606a3745d358ae28b3434dbbfd859d103bfbd5d2034e009a88b0d8", + "chunk-JEE4N3TS.js": "deea940fab041e9d3d62b9f45410f13a10c988765e21aea72b5e7cd38be2793d", + "chunk-JHUOFMVX.js": "93cf59f592b50d077bb76d75e308574e695f5e4bedde71eb83bd14ef664a0643", + "chunk-JJKMKSAK.js": "d16c7ec05d6529407cb95715c39712888ab848aaa5bff90d5b5000fc2157a49b", + "chunk-JNNOEYKZ.js": "028c6760b064b7329921dc0f03b5155651dc6b78782c4a301a0f2ed1fad67103", + "chunk-JZ7TUIPA.js": "0dc05155bbec9190fa6e62138e4950b1ac9f8a483d42178e9d4a113584670fb8", + "chunk-KEPWQ2XV.js": "394e306a00b5670e2087c2ea4709784259906e1fecd76112757c958c426510d8", + "chunk-KKEW2G7D.js": "04229a56e68a783c72199e832ae46524584d83aedc478d30389e6da4a6b9728c", + "chunk-KTRLVWC3.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-LFUHDYRM.js": "19d548e2010f55979d69c732483385959fd6d4dfe1d6a781b8960d570d6c253f", + "chunk-LPIRHTIZ.js": "6b1bdd5cb4b0e697c98a18dbcbcee87ea407b48d369d37624e01e20c46825ff7", + "chunk-LYOKFRGF.js": "d93bdde0b755b03e0ba30490700d787476d28ec520e2f46262ae31fbd3bd68cf", + "chunk-M7RNECXK.js": "872426135097f84d84f1616f02a1ad5328c1203b91df8de63dab5708d2d52d64", + "chunk-MS2BK2XC.js": "6cbf3792209b2213d939391b0e410a28e3cc4cebe9e3bca5b0f3c1372617848b", + "chunk-MY3TAGP5.js": "cd5a646d1990c18d968e91a63374f7fb900839df3c6f8ba8d681af115cdf6b5a", + "chunk-MYXNY2BK.js": "b2d7f3c67e485753fd8d6c6d8249a98a2123b9565f7e5cd4e7f7ee0be8141a1a", + "chunk-NCHAAQO5.js": "394e306a00b5670e2087c2ea4709784259906e1fecd76112757c958c426510d8", + "chunk-NCSQLE2V.js": "a003b99f67176ece40876da30e522ad18d03d602f0f0fdae61261128c4c47096", + "chunk-NHSQNPYR.js": "480d39d73924a277ba5e2f36bfdbc6cffa19677bf6ace704db7979b81130d048", + "chunk-NYBBQCI3.js": "d5d053058a5599f8f40cc56c1750a9484603f00ae80b17e0a3fa02622e99a642", + "chunk-OBWORE6U.js": "775899b419110aa5892f9903152c9e2bf4c6d2bd15b5acd04660e2835604ff15", + "chunk-OGETYNF2.js": "a1a6006046a428b84f23f989a4183f2bdc54cafb24bb27354bc6dd916e3d65bb", + "chunk-OQDADP67.js": "a7f77ab681c58262f8acf0f156ca93e1e890ad25550de966cedcc0cc18d2e43c", + "chunk-OQVLWJOA.js": "3dc7f8509c44eadd5d4123c4c39857cb347da1bc650e74d44db8b49963ab6225", + "chunk-ORNIXTHJ.js": "a3c8cb0719ed5b06146795def50533f434fb0dac942f71081558aa11b71d7311", + "chunk-ORTPMUFN.js": "9f37c8abc58494ebda1355db7367941b22a8fd304a934f0e83b9f62ba501a7be", + "chunk-P6L436IK.js": "9839ca739b2c54e816b9633911441f79abec7a38d3067fc05607bda57d21933d", + "chunk-P7ZNJWYH.js": "c45eefe91aadcd29d7f73475deefeeb1dab78968e48e05c8c2dcd2ca0baa67eb", + "chunk-PKT6RRYE.js": "cc2c07ae3583607d281861f0b356ac0f967fbcbc6fdd4463cdc6f7cfd9380e23", + "chunk-PKUAPQMU.js": "842777ee341c6bda8a48d372e20c0b9c00a437131f9ac29d4883543bb072fcef", + "chunk-PNXDAMJM.js": "f47f2fb188bc043c9dc22ab9d79f1cabbb7792dc31ac239e3ce0850479d4c9a5", + "chunk-PPZSTFUD.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-PY5GJ2VA.js": "52e045b30c61e59aa87bae1339cc6518faacc24650406bbe23cf6c04099c9418", + "chunk-PYP35JQ4.js": "aa23b006ca87cdb1a9a6fcdcec42a2237e498eb4703762b9058642f980b5b1bf", + "chunk-QBVTQ7AJ.js": "cd37b2dfb166b6168b949212ac408fcc009a6bae7593d04ebedd85e0827a0cc4", + "chunk-QN2LPFZ5.js": "09586a3bef9ad5489a73e1c64f87c25460ce3d46e31f23b3cf983e7716de2c98", + "chunk-QO53FM57.js": "d74a026d65a8b66efb0f0d19fd86b1497472f109825a58c0cda1fb293460b892", + "chunk-QRYA5QYO.js": "5f49fabbb442b7832050c0365e56c1c3e31f73c109c4cc78f6c59dc8c839bae5", + "chunk-QYUSC46N.js": "e2dbe4f8b0ed5f723145bba340bba4596c4e2363105845ae1575ced5cb0b35c5", + "chunk-R53SC6YT.js": "b82916433319b31fdbc99db0e8e50fb4b39271b0b4f5a5c85f0d71b350816719", + "chunk-RMP32DNY.js": "88772ab3aa50aeb7a44f879b3f45c903249e264918996fd608db0abe0c339b60", + "chunk-RWAASGF5.js": "f99b753164d815e2bc01723aef1be1e6a305bd8435aba053b75baa9d91dace4c", + "chunk-RZO4MDZY.js": "db17023632edb28cb75e864ef53154229c4f35e6796622408a953dd04a2fc378", + "chunk-S7EUWH5W.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk-SK7GH6NG.js": "efe4689540db404a319f809266a94fc46e5081f664a7ebe8e4ef71cee13fb86a", + "chunk-SKQTAURN.js": "b6b3e4abc99ed23a7001a07483cab238c61701feb74ff2b2c6375699db727134", + "chunk-SORXG3WM.js": "e2a55bcf58bf09daaa773a7283f8d1c769c77124e0a9016f316250db6d984d2f", + "chunk-STDX2QZ3.js": "d665e49ad1f84f94d810cb0a7df99c34bc77175fd5c7e67fe3cccd5e329cfb52", + "chunk-SVFKPVOL.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-SXCMSJUF.js": "f65b6c88a67e05086296b020519c47b4e6ffaba9da5cb6380d8b93718579d8b7", + "chunk-SZNKQ2ZO.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-T3WSVEY4.js": "e0937e421a9c41e0b1eb52bc2f70113b42c35a54adb6d8c9334960dbf07730dc", + "chunk-THPFTLNX.js": "c7403c041d81ac7c1dfbc1f5c83dc1fa4ab2bd12e16ffa06e06700f6746968ba", + "chunk-TMHENYE3.js": "498284fb350ffb4015261fd83ae271346c2ba815b896762fb86371c3b4c40285", + "chunk-TPT6UZI3.js": "810623ef7a81170b0cb475c1bd6065bd27522d9c3cae6e2e39715b0486911b09", + "chunk-TTZ4IF2R.js": "f3909b470b8a3ed0da59715e7f94e2accb367391739aef39c388c1c289bfe6f9", + "chunk-TXI6IPIG.js": "e1b85d0b7e4ec3c559244b1205e5b0f908eccaedf645042d1599b3a9862e0054", + "chunk-U7QOMTFH.js": "e9fea77b67eadc08f1c01224b36937264479b06ae7535c86bcfb1f0dd403994c", + "chunk-UGSVHFE4.js": "7bde07914e176039c9187392b1f91f5ae8ee206837da18998f0d8d0431a46a2e", + "chunk-UK5UKT3V.js": "e294567d2f611b83656c9fd259db9455ab0ae2f6785827021bf1577a9a70aabf", + "chunk-UMKP35NR.js": "c45afc670af5b3a726e92484c29a04c9456d5f8aaf63afc0cb981ae8ebf35d42", + "chunk-UNENCFKP.js": "f5985dc82846e809af07cceb7d3e0672ab42e19ea443c6118f3465a205665c6f", + "chunk-UTWQFQVE.js": "a9ba2b643673aa2b235bf044f7755820ca25c049bbd65326662d00a3e893b63f", + "chunk-UYXSSEQD.js": "b99bb629f1aa0970e8cd0fc9a3557bdc3e601a250f5ca3962ffa20999000bd30", + "chunk-V3TCMJP5.js": "be7aafe88e586815e4b15007f956a654683b34da2b62af65b73aef3f53ba7256", + "chunk-VOSPIT4N.js": "0b9907b7c4c20fecc25fb722f5131dce336e6b2f6d8bb970c37476e4feed6918", + "chunk-W2ZPBHP5.js": "fb7050759eb8424d30a05bed67978166425e4a6f25db69fa9663259a6b3a4f94", + "chunk-W5H3AEFL.js": "2346bab21f9c87892d75374d89bd01f0dc08fd8e08a7f9497de318e025273711", + "chunk-WJX7WGYX.js": "23953466611d8b4f3c0084582cf7306d37158c8a02e2fe50061ef4b1a5296008", + "chunk-WP6O5FVQ.js": "97332f1e90f70d69748751042c53a49c5765c767e139412bd34446cc322f0bb5", + "chunk-WUKL3F6L.js": "35914c719162f0b5794cb187393caa58edd59ba99f07f8598df3b1ba15b213d7", + "chunk-WXLNKHDH.js": "5ead8ea7ec1f7269fab0cdcfe63f154f8f4d0116eca9274dd262b24c6ba333f9", + "chunk-X4I2SBJL.js": "45cdb22871ca8bbc88da655b34bd8a53a221a66358ea46d3bb58f96220baea5e", + "chunk-X4ROVHIJ.js": "d0f65acd761fdd0b0b3f410bca3b868ff99148facbfa8eafe12a5706e0fde7bf", + "chunk-X7V5FF7M.js": "64116af3ad88688d620dc94372364919e7e35f123fabab3545a77b767c09edfc", + "chunk-XCNR4QBB.js": "e196821a94ef94635bc4e700ea6c04582b07cdf4e17b11e8103835f0f63c4a86", + "chunk-XFSBP2WD.js": "79b1b57396a677a69c4ecd00c7e25b0d3d597c0f586ae6fb22ec83b8230e4c20", + "chunk-XGJ4GZN2.js": "93a9636ffb9348f1f81b18ca0ea23fa7f055d1c9e701ff63b10e7224513f6865", + "chunk-XH3LC4HK.js": "4a9f9da9d57132843d2134b9555ac0e04bddf1b3941f32601a8dcb9667d5de36", + "chunk-XRW266TM.js": "b7a8e853fbd64d9c5ce733e139ff2ec1ebbb8a8c99fb1087f56d1e941d50e474", + "chunk-XU2CZSOY.js": "35189ce3d8c6eb9fd90214f569e4c331aefe3b2bb52b1df7404007bd9f75a315", + "chunk-Y3DPWBHM.js": "e9f59bcf53fdd1d2670db07b25aecb23db99f2dfd5b9cb3185666f08989ff955", + "chunk-Y7FCNQPS.js": "6f61f1572147587befe70f5c37f2c3f250629a174dde7f95d5bbfa79f3d48c59", + "chunk-YGCGOQUU.js": "fff054f0afa1f8e28b76d759af4a96c73e7f76da84a10fcd9f5a60b8f62753a5", + "chunk-YI52ARKQ.js": "c80e354624b5b1625edbbb8b516135a0a0bdffc32fa11e677f809590833b0c27", + "chunk-YKFEDNBV.js": "ee06bebe3254e1e1fd744b2a1e41e1308522afbf170ad2ac5a620ef054d071cb", + "chunk-YLGWU3K5.js": "a5139a6ed303dafd5946aeea1681ecf99924473b92cc79da0c793867ab1ad7ac", + "chunk-YOBIHSQW.js": "538f1412dbf2f2f1d7c26b1ab31423b220246b4ce19eff13d48032217b002e7e", + "chunk-ZE7ENFWL.js": "0618ca406bc37688b707dd92e834f425c54f9a63b9f1f6345a00971a3eb428ab", + "chunk-ZGLHH6B5.js": "f8a2f8cce5a674ce7c1f5eefd18b0a7ed34c75e136389506b81c44da26d3fea3", + "chunk-ZGTQ4DFJ.js": "18d25169cd898879a7c85ad3ff4a76617beb8ffd863c3d31b082ac26e0ce8c5d", + "chunk-ZWVXAKCW.js": "1c81193f001123175bb37bb439632f67167e317853d1c4537aa312dbcc612cf3", + "chunk-ZX65CFM5.js": "4912d99b6bd7767e4a78d733e155d8660e504842ac1ac56233ed6ef82aba2e22", + "favicon.ico": "76c12efffa19c76cef57fd7a58f65cebd22df8d8a0e9ac7237c58234cf7fd57d", + "index.html": "e050fd8b5ca22fda82d8b8cc8c10f4a81710c8462fb3bb454e397143b4bf5ecd", + "main-UFORBZO6.js": "d09c58649f45dec75022daf394dc77908d2b64d6761e63f79b81b24e0a86136e", + "media/Mont-Black-CKKAF2O3.eot": "b5b32b3ece5fd612d52631066e308ea0733636bf3a6cca8066cbd5d309a1ad06", + "media/Mont-Black-O4OJXHSC.ttf": "00c2fc2b91fc6ff250a2f91a67e6eb11c27b093c2bc75661942fd5653b8ceba5", + "media/Mont-Black-UEG4C46Y.woff": "f02577df1049b69199e39f1dac8e086d46521e805eac9731aa578958439258cd", + "media/Mont-Black-ZXUYXEJ6.woff2": "a94582ba690d74b4e0e9272a4d72cad170e513ed9198ca042ed6475d71c1f8ce", + "media/Mont-Bold-7L6HWBWW.woff2": "141fa4004c0413987f1638c827f2faba62b54bd1f618796c49a5cf8f5ac7c4bd", + "media/Mont-Bold-A3OSQYTY.woff": "e3bb59a5d49ed3ee29c92ae13c4c4c390eb4ddcad55fb72f698742a600efb0c5", + "media/Mont-Bold-JCFB53E2.eot": "673fcac77187f1dad0d5974ddeb4471bb7ca6370da242f86a28e596127f9a117", + "media/Mont-Bold-VNDQNQDW.ttf": "0371bbcb8df8f597af0237e13a9db8a04892649fdfd595c283719d3aa56018a2", + "media/Mont-Heavy-CZVFDIWI.eot": "615c73226cfcd0704155428e00a65b969f971bbd90dee352adc54ae15cb02eba", + "media/Mont-Heavy-JVDMIVZJ.ttf": "24fa6e8d4038a37a4a79ec087cd4b61a392474891e1158837537d7f9f4e8e101", + "media/Mont-Heavy-SAJ5B6FS.woff2": "f59d60e6232576f751dc60eb2e660d4b8287464f130bd77b3b2eaba1a5671390", + "media/Mont-Heavy-WMPUKQNL.woff": "860084fae53b928995b4809cbf63ffb08b9db77040fcb0762dcf7d1feef6c2d1", + "media/Mont-Regular-5LPDNI7X.ttf": "0bb5e1336fbeb2c78e049cf3a21101289c7b6c906d7335a5859acedfb8aa5875", + "media/Mont-Regular-RSRKQCMK.eot": "65a09751a7a289a0bfa64284a040a148b71b23ae8ef85e261eeb89ddc6d5116d", + "media/Mont-Regular-T6L2NNOO.woff": "29c81b198136bf8a929f52133314319cb6d8375a526da5c97197df1960ceebb3", + "media/Mont-Regular-UA5RCVYG.woff2": "e01372b13985c09f2a168382ad044312db914801a595b4306abd7d686b0cc64b", + "media/Mont-SemiBold-7XOUXTAI.woff2": "ea0681c9068fb473d3606e012e76728eb44b2e04c276266894aeca5fc1934b52", + "media/Mont-SemiBold-BZSF3DKB.eot": "d0c7662470a8da9c1dc4aab63a44ff7ae9bee2aef4ab87d0efd91fef1c6ea1ef", + "media/Mont-SemiBold-M6WLAC2K.ttf": "a0d30d71b1ed99f8ff6cd6e5a83b93dc832e4a00f6cf402f587351c1ba8f7dc2", + "media/Mont-SemiBold-WNUGA77E.woff": "1d01cc3f8fafbed1582b3182c40eafa607fb6670a179f23116d8eed2e8907481", + "styles-PHA4KBVL.css": "47fb81fe384508ffaade6d3372a03f2d8fb6b9cc86924f41ec33bc7d9085b862" + } +} diff --git a/deploy/pilot/package.py b/deploy/pilot/package.py new file mode 100644 index 00000000..70f98d48 --- /dev/null +++ b/deploy/pilot/package.py @@ -0,0 +1,99 @@ +"""Собрать переносимый пилот из отслеживаемого API и pilot Angular build.""" + +import argparse +import sys +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tarfile + + +def package(api: Path, frontend: Path, target: Path) -> Path: + if target.exists(): + raise FileExistsError("Каталог пакета уже существует.") + if subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=no"], cwd=api + ).strip(): + raise ValueError("Сначала проверьте и зафиксируйте изменения API в git.") + if not (frontend / "index.html").is_file(): + raise ValueError("Не найдена Angular сборка.") + if "white-label-pilot-v1" not in (frontend / "index.html").read_text(): + raise ValueError( + "Нужна специальная pilot сборка, обычная сборка использует основной API." + ) + source = Path(__file__).resolve().parent + target.mkdir(parents=True) + for name in ( + "compose.yml", + "nginx.conf.template", + "host-nginx.conf.example", + "configure.py", + "pilotctl", + "preflight.py", + "hosted.py", + "upgrade_invites.py", + "upgrade_weights.py", + "weights_base.json", + "runtime_import.py", + "hosted-maps.conf.template", + "hosted-routes.inc.template", + "README.md", + ): + shutil.copy2(source / name, target / name) + (target / "pilotctl").chmod(0o750) + shutil.copytree(frontend, target / "frontend") + shutil.copytree(source / "brand", target / "brand") + # В Cloud build assets могут иметь 0600/0700. Nginx worker читает только + # эти публичные каталоги; секреты и API source здесь не затрагиваются. + for public in (target / "frontend", target / "brand"): + public.chmod(0o755) + for path in public.rglob("*"): + path.chmod(0o755 if path.is_dir() else 0o644) + tracked = ( + subprocess.check_output( + ["git", "ls-files", "-z"], + cwd=api, + ) + .decode() + .split("\0") + ) + if "procollab/settings_pilot.py" not in tracked: + raise ValueError("Сначала добавьте и проверьте API pilot profile в git.") + for relative in filter(None, tracked): + path = api / relative + if path.is_symlink(): + raise ValueError(f"Символическая ссылка не входит в пакет: {relative}") + destination = target / "api" / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + revision = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=api, text=True + ).strip() + manifest = {"api_revision": revision, "files": {}} + for path in sorted(target.rglob("*")): + if path.is_file(): + manifest["files"][str(path.relative_to(target))] = hashlib.sha256( + path.read_bytes() + ).hexdigest() + (target / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + ) + archive = target.with_suffix(".tar.gz") + with tarfile.open(archive, "x:gz") as tar: + tar.add(target, arcname=target.name) + archive.chmod(0o600) + return archive + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--api", type=Path, required=True) + parser.add_argument("--frontend", type=Path, required=True) + parser.add_argument("--target", type=Path, required=True) + args = parser.parse_args() + sys.stdout.write( + str(package(args.api.resolve(), args.frontend.resolve(), args.target.resolve())) + + "\n" + ) diff --git a/deploy/pilot/package_frontend.py b/deploy/pilot/package_frontend.py new file mode 100644 index 00000000..4e9b6e91 --- /dev/null +++ b/deploy/pilot/package_frontend.py @@ -0,0 +1,91 @@ +"""Пакет только измененной статики пилота поверх известной предыдущей сборки.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import tarfile +import zipfile + +import upgrade_frontend as upgrade + + +def package(frontend, base_manifest, output): + upgrade.require(not output.exists(), "Каталог пакета уже существует.") + base = json.loads(base_manifest.read_text()) + upgrade.require( + base.get("api_revision") == upgrade.BACKEND, "Неизвестный исходный API." + ) + before = { + p.removeprefix("frontend/"): h + for p, h in base["files"].items() + if p.startswith("frontend/") + } + upgrade.require( + before.get("index.html") == upgrade.BASE_INDEX, "Неизвестный исходный index." + ) + after = {} + for path in sorted(frontend.rglob("*")): + upgrade.require(not path.is_symlink(), "Ссылка в сборке запрещена.") + if path.is_file(): + after[str(path.relative_to(frontend))] = upgrade.hosted.digest(path) + upgrade.hashes(before) + upgrade.hashes(after) + output.mkdir(mode=0o700, parents=True) + bundle = output / "procollab-pilot" + bundle.mkdir(mode=0o755) + for relative in upgrade.HELPERS: + shutil.copyfile(Path(__file__).parent / relative, bundle / relative) + for relative, digest in after.items(): + if before.get(relative) != digest: + target = bundle / "frontend" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(frontend / relative, target) + manifest = { + "format": upgrade.IDENTITY, + "backend_revision": upgrade.BACKEND, + "base_frontend_revision": upgrade.PREVIOUS, + "frontend_revision": upgrade.REVISION, + "base_files": before, + "new_files": after, + "files": { + str(path.relative_to(bundle)): upgrade.hosted.digest(path) + for path in sorted(bundle.rglob("*")) + if path.is_file() + }, + } + (bundle / "frontend-manifest.json").write_text(json.dumps(manifest, indent=2)) + upgrade.verify_bundle(bundle) + tar = output / "procollab-pilot.tar.gz" + with tarfile.open(tar, "w:gz") as archive: + archive.add(bundle, arcname="procollab-pilot") + destination = output / ("procollab-pilot-auth-" + upgrade.REVISION[:7] + ".zip") + with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_STORED) as archive: + archive.write(tar, arcname=tar.name) + return { + "archive": str(destination), + "bytes": destination.stat().st_size, + "sha256": hashlib.sha256(destination.read_bytes()).hexdigest(), + "frontend_revision": upgrade.REVISION, + "backend_revision": upgrade.BACKEND, + "changed_files": len(manifest["files"]) - len(upgrade.HELPERS), + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--frontend", type=Path, required=True) + parser.add_argument("--base-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + print( # noqa: T201 + json.dumps( + package( + args.frontend.resolve(), + args.base_manifest.resolve(), + args.output.resolve(), + ), + indent=2, + ) + ) # noqa: T201 diff --git a/deploy/pilot/package_overall.py b/deploy/pilot/package_overall.py new file mode 100644 index 00000000..5e519838 --- /dev/null +++ b/deploy/pilot/package_overall.py @@ -0,0 +1,126 @@ +"""Небольшой пакет общего рейтинга для существующего пилота после auth 75f41a5.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tarfile +import zipfile + +import upgrade_overall as upgrade + +SOURCE = Path(__file__).resolve().parent + + +def tooling_revision(): + repo = SOURCE.parents[1] + require = upgrade.require + require( + not subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=no"], cwd=repo + ).strip(), + "Сначала проверьте и зафиксируйте изменения установщика.", + ) + subprocess.run( + [ + "git", + "ls-files", + "--error-unmatch", + *["deploy/pilot/" + name for name in upgrade.HELPERS], + "deploy/pilot/package_overall.py", + ], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + ) + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repo, text=True + ).strip() + + +def package(api, frontend, output): + upgrade.require(not output.exists(), "Каталог пакета уже существует.") + revision = tooling_revision() + for name, digest in upgrade.BASE["target_hashes"].items(): + source = api / name + upgrade.require( + source.is_file() + and not source.is_symlink() + and upgrade.hosted.digest(source) == digest, + "Неизвестный runtime файл: " + name, + ) + after = {} + for path in sorted(frontend.rglob("*")): + upgrade.require(not path.is_symlink(), "Ссылка в frontend build.") + if path.is_file(): + after[str(path.relative_to(frontend))] = upgrade.hosted.digest(path) + upgrade.auth.hashes(after) + output.mkdir(mode=0o700, parents=True) + bundle = output / "procollab-pilot" + bundle.mkdir(mode=0o755) + for name in upgrade.HELPERS: + if name == "overall_base.json": + (bundle / name).write_text( + json.dumps(upgrade.BASE, ensure_ascii=False, indent=2) + "\n" + ) + else: + shutil.copyfile(SOURCE / name, bundle / name) + for name in upgrade.PATCH_FILES: + target = bundle / "api" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(api / name, target) + for name, digest in after.items(): + if upgrade.BASE["frontend_files"].get(name) != digest: + target = bundle / "frontend" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(frontend / name, target) + manifest = { + "format": upgrade.IDENTITY, + "base_backend_revision": upgrade.BASE["base_backend_revision"], + "base_frontend_revision": upgrade.BASE["base_frontend_revision"], + "backend_revision": upgrade.BASE["backend_revision"], + "frontend_revision": upgrade.BASE["frontend_revision"], + "tooling_revision": revision, + "new_files": after, + "files": { + str(path.relative_to(bundle)): upgrade.hosted.digest(path) + for path in sorted(bundle.rglob("*")) + if path.is_file() + }, + } + (bundle / "overall-manifest.json").write_text(json.dumps(manifest, indent=2)) + upgrade.verify_bundle(bundle) + tar = output / "procollab-pilot.tar.gz" + with tarfile.open(tar, "x:gz") as archive: + archive.add(bundle, arcname="procollab-pilot") + destination = output / ("procollab-pilot-overall-" + revision[:7] + ".zip") + with zipfile.ZipFile(destination, "x", compression=zipfile.ZIP_STORED) as archive: + archive.write(tar, arcname=tar.name) + destination.chmod(0o600) + return { + "archive": str(destination), + "bytes": destination.stat().st_size, + "sha256": hashlib.sha256(destination.read_bytes()).hexdigest(), + "backend_revision": upgrade.BASE["backend_revision"], + "frontend_revision": upgrade.BASE["frontend_revision"], + "tooling_revision": revision, + "api_files": len(upgrade.PATCH_FILES), + "changed_frontend_files": sum( + upgrade.BASE["frontend_files"].get(name) != digest + for name, digest in after.items() + ), + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--api", type=Path, required=True) + parser.add_argument("--frontend", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = package(args.api.resolve(), args.frontend.resolve(), args.output.resolve()) + import sys + + sys.stdout.write(json.dumps(result, indent=2) + "\n") diff --git a/deploy/pilot/pilotctl b/deploy/pilot/pilotctl new file mode 100755 index 00000000..2025b622 --- /dev/null +++ b/deploy/pilot/pilotctl @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "$0")" +docker_cli=(env -u DOCKER_HOST -u DOCKER_CONTEXT -u DOCKER_TLS -u DOCKER_TLS_VERIFY -u DOCKER_CERT_PATH docker --host=unix:///var/run/docker.sock) +compose=("${docker_cli[@]}" compose --project-name procollab-pilot --env-file .env -f compose.yml) +case "${1:-check}" in + init) python3 configure.py ;; + check) + test -s .env && test -s nginx.conf && test -s access.htpasswd + test -s frontend/index.html + "${compose[@]}" config --quiet + ;; + build) + "$0" check + python3 preflight.py --check + "${compose[@]}" build web + ;; + start) + "$0" check + python3 preflight.py --check + "${docker_cli[@]}" image inspect procollab-pilot-api:local >/dev/null + "${compose[@]}" up -d db redis + "${compose[@]}" run --rm web sh -ec 'python manage.py migrate --noinput && python manage.py collectstatic --noinput && python manage.py prepare_white_label_pilot' + "${compose[@]}" up -d web worker gateway + "${compose[@]}" exec -T gateway nginx -t + ;; + status) "${compose[@]}" ps ;; + stop) "${compose[@]}" stop ;; + *) printf 'Использование: ./pilotctl init|check|build|start|status|stop\n' >&2; exit 2 ;; +esac diff --git a/deploy/pilot/preflight.py b/deploy/pilot/preflight.py new file mode 100644 index 00000000..9e8bda7f --- /dev/null +++ b/deploy/pilot/preflight.py @@ -0,0 +1,116 @@ +"""Read-only проверка сервера перед установкой нового пилота.""" + +from pathlib import Path +import shutil +import argparse +import os +import sys +import socket +import subprocess + + +def report(*parts): + sys.stdout.write(" ".join(str(part) for part in parts) + "\n") + + +def run(argv): + environment = os.environ.copy() + if argv[0] == "docker": + for name in ( + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + ): + environment.pop(name, None) + argv = ["docker", "--host=unix:///var/run/docker.sock", *argv[1:]] + result = subprocess.run( + argv, text=True, capture_output=True, timeout=20, env=environment + ) + return result.returncode, result.stdout + + +def main(check=False): + failures = [] + report("DNS pilot.procollab.ru:", socket.gethostbyname("pilot.procollab.ru")) + for command in ("docker", "nginx", "openssl", "certbot"): + report(command + ":", shutil.which(command) or "не найден") + free_gib = shutil.disk_usage(Path.cwd()).free / 2**30 + report("Свободно на диске, GiB:", round(free_gib, 1)) + if free_gib < 8: + failures.append("Нужно минимум 8 GiB свободного диска перед запуском.") + memory = {} + for line in Path("/proc/meminfo").read_text().splitlines(): + key, value = line.split(":", 1) + memory[key] = int(value.strip().split()[0]) + available_mib = memory["MemAvailable"] / 1024 + report("Доступная память, MiB:", round(available_mib)) + if available_mib < 2560: + failures.append("Нужно минимум 2560 MiB доступной памяти с запасом для dev.") + own_gateway = False + if shutil.which("docker"): + code, output = run(["docker", "ps", "--format", "{{.Names}} {{.Ports}}"]) + report( + "Существующие контейнеры:\n" + output + if code == 0 + else "Нет доступа к локальному Docker" + ) + if code: + failures.append("Нет доступа к локальному Docker.") + if "procollab-white-label-pilot-" in output: + failures.append( + "Уже есть hosted pilot: второй полный стек не устанавливается." + ) + code, own = run( + [ + "docker", + "ps", + "--filter", + "label=com.docker.compose.project=procollab-pilot", + "--filter", + "label=com.docker.compose.service=gateway", + "--format", + "{{.Ports}}", + ] + ) + own_gateway = code == 0 and "127.0.0.1:18081->80/tcp" in own + else: + failures.append("Docker не установлен.") + with socket.socket() as probe: + try: + probe.bind(("127.0.0.1", 18081)) + report("Порт 18081: свободен") + except OSError: + report( + "Порт 18081:", + "занят этим пилотом" if own_gateway else "занят другим процессом", + ) + if not own_gateway: + failures.append("Порт 18081 занят другим процессом.") + if shutil.which("nginx"): + code, output = run(["nginx", "-T"]) + if code and shutil.which("sudo"): + code, output = run(["sudo", "-n", "nginx", "-T"]) + # Конфигурация может содержать пароли; выводим только server_name. + matches = [ + line.strip() + for line in output.splitlines() + if line.strip().startswith("server_name ") and "pilot.procollab.ru" in line + ] + report( + "Текущие server_name pilot:\n" + "\n".join(matches) + if code == 0 + else "Для nginx -T нужен доступ владельца конфигурации" + ) + for failure in failures: + report("НЕ ГОТОВО:", failure) + report("Preflight завершен без изменений на сервере.") + return 1 if check and failures else 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + raise SystemExit(main(arguments.check)) diff --git a/deploy/pilot/runtime_import.py b/deploy/pilot/runtime_import.py new file mode 100644 index 00000000..1bea7924 --- /dev/null +++ b/deploy/pilot/runtime_import.py @@ -0,0 +1,220 @@ +"""Поточный импорт rootfs: без второй распаковки многослойного docker save.""" + +import argparse +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time +import zipfile + + +class Refused(RuntimeError): + pass + + +def docker(*args, **kwargs): + environment = os.environ.copy() + for key in ( + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + ): + environment.pop(key, None) + return subprocess.run( + ["docker", "--host=unix:///var/run/docker.sock", *args], + env=environment, + **kwargs, + ) + + +def docker_json(*args): + result = docker(*args, capture_output=True, text=True, timeout=30) + if result.returncode: + raise Refused("Локальный Docker не выполнил проверку.") + return json.loads(result.stdout) + + +class RuntimeArchive: + def __init__(self, path): + self.path = Path(path) + with self.open("runtime.json") as stream: + self.info = json.load(stream) + meta = self.info + if ( + meta.get("format") != "docker-export-v1" + or not re.fullmatch(r"[a-f0-9]{40}", meta.get("api_revision", "")) + or not re.fullmatch(r"[a-f0-9]{64}", meta.get("archive_sha256", "")) + or not re.fullmatch( + r"procollab-white-label-pilot-api:[a-f0-9]{12}", meta.get("image", "") + ) + or type(meta.get("image_size_bytes")) is not int + or meta["image_size_bytes"] <= 0 + ): + raise Refused("Некорректные метаданные runtime artifact.") + + @contextmanager + def open(self, member): + if member not in ("runtime.json", "pilot-api-rootfs.tar.gz"): + raise Refused("Неизвестный файл runtime.") + if self.path.is_dir(): + if member == "runtime.json" and (self.path / member).stat().st_size > 2**20: + raise Refused("Метаданные runtime превышают допустимый размер.") + with (self.path / member).open("rb") as stream: + yield stream + else: + with zipfile.ZipFile(self.path) as archive: + if ( + member == "runtime.json" + and archive.getinfo(member).file_size > 2**20 + ): + raise Refused("Метаданные runtime превышают допустимый размер.") + with archive.open(member) as stream: + yield stream + + def verify(self): + digest = hashlib.sha256() + with self.open("pilot-api-rootfs.tar.gz") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != self.info["archive_sha256"]: + raise Refused("Checksum runtime не совпадает. Импорт не выполнялся.") + + +def changes(meta): + config = meta["config"] + if config.get("Volumes") or config.get("Healthcheck"): + raise Refused("Этот exporter не поддерживает VOLUME/HEALTHCHECK.") + result = [] + for value in config.get("Env") or []: + key, separator, contents = value.partition("=") + if ( + not separator + or not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", key) + or any(c in contents for c in "\n\r\x00$`") + ): + raise Refused("Неподдерживаемое значение ENV образа.") + result.append("ENV " + key + "=" + json.dumps(contents)) + for name, value in ( + ("WORKDIR", config.get("WorkingDir")), + ("USER", config.get("User")), + ): + if value: + if not re.fullmatch(r"[A-Za-z0-9_/:.\-]+", value): + raise Refused("Неподдерживаемая конфигурация образа.") + result.append(name + " " + value) + for name in ("Entrypoint", "Cmd"): + value = config.get(name) + if value: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise Refused("Неверная команда образа.") + result.append(name.upper() + " " + json.dumps(value)) + result.extend( + [ + 'LABEL org.opencontainers.image.revision="' + meta["api_revision"] + '"', + 'LABEL io.procollab.pilot.rootfs-sha256="' + meta["archive_sha256"] + '"', + ] + ) + return result + + +def existing_image(runtime): + result = docker( + "image", "inspect", runtime.info["image"], capture_output=True, text=True + ) + if result.returncode: + return None + image = json.loads(result.stdout)[0] + labels = image["Config"].get("Labels") or {} + if labels.get("io.procollab.pilot.rootfs-sha256") != runtime.info["archive_sha256"]: + raise Refused("Тег образа уже занят другой сборкой; он не перезаписывается.") + return image["Id"] + + +def import_image(runtime, *, log, throttle=True): + present = existing_image(runtime) + if present: + return present + runtime.verify() + info = docker_json("info", "--format", "{{json .}}") + architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get( + info["Architecture"], info["Architecture"] + ) + if runtime.info["architecture"] != architecture: + raise Refused("Архитектура образа не соответствует серверу.") + if ( + shutil.disk_usage(info["DockerRootDir"]).free + < runtime.info["image_size_bytes"] + 2 * 2**30 + ): + raise Refused("Для импорта не хватает диска с запасом 2 GiB для dev.") + args = [] + for change in changes(runtime.info): + args.extend(["--change", change]) + environment = { + k: v + for k, v in os.environ.items() + if k + not in { + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + } + } + with runtime.open("pilot-api-rootfs.tar.gz") as stream, Path(log).open( + "wb" + ) as errors: + process = subprocess.Popen( + [ + "docker", + "--host=unix:///var/run/docker.sock", + "import", + *args, + "-", + runtime.info["image"], + ], + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=errors, + ) + started, copied = time.monotonic(), 0 + try: + for chunk in iter(lambda: stream.read(512 * 1024), b""): + process.stdin.write(chunk) + copied += len(chunk) + if throttle: + delay = copied / (8 * 2**20) - (time.monotonic() - started) + if delay > 0: + time.sleep(min(delay, 0.1)) + process.stdin.close() + output = process.stdout.read() + if process.wait(timeout=600) or not re.fullmatch( + rb"sha256:[a-f0-9]{64}\s*", output + ): + raise Refused( + "Импорт не завершился; подробности в локальном runtime-import.log." + ) + except BaseException: + process.kill() + process.wait() + raise + return existing_image(runtime) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", type=Path, required=True) + arguments = parser.parse_args() + image_id = import_image( + RuntimeArchive(arguments.runtime), log="runtime-import.log", throttle=False + ) + sys.stdout.write("Runtime image imported: " + image_id + "\n") diff --git a/deploy/pilot/unpack_candidate.py b/deploy/pilot/unpack_candidate.py new file mode 100644 index 00000000..a755b5ce --- /dev/null +++ b/deploy/pilot/unpack_candidate.py @@ -0,0 +1,53 @@ +"""Распаковать только source/frontend artifact в новый каталог владельца.""" + +import argparse +from pathlib import Path, PurePosixPath +import shutil +import tarfile +import zipfile + + +def unpack(source, destination): + destination = Path(destination) + if destination.exists(): + raise FileExistsError("Каталог уже существует; перезапись запрещена.") + destination.mkdir(mode=0o700, parents=True) + total = 0 + with zipfile.ZipFile(source) as archive: + if archive.namelist() != ["procollab-pilot.tar.gz"]: + raise ValueError("Нужен artifact procollab-pilot-candidate.") + with archive.open("procollab-pilot.tar.gz") as stream: + with tarfile.open(fileobj=stream, mode="r|gz") as contents: + for member in contents: + path = PurePosixPath(member.name) + if ( + path.is_absolute() + or ".." in path.parts + or not path.parts + or path.parts[0] != "procollab-pilot" + ): + raise ValueError("Небезопасный путь архива.") + target = destination.joinpath(*path.parts) + if member.isdir(): + target.mkdir(mode=0o755, parents=True, exist_ok=True) + elif member.isfile(): + total += member.size + if total > 256 * 2**20 or member.size > 64 * 2**20: + raise ValueError("Пакет превышает допустимый размер.") + target.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + with target.open("xb") as output, contents.extractfile( + member + ) as data: + shutil.copyfileobj(data, output, length=1024 * 1024) + target.chmod(member.mode & 0o755) + else: + raise ValueError("Ссылки и специальные файлы запрещены.") + return destination / "procollab-pilot" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--zip", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + args = parser.parse_args() + print(unpack(args.zip, args.destination)) # noqa: T201 diff --git a/deploy/pilot/upgrade_frontend.py b/deploy/pilot/upgrade_frontend.py new file mode 100644 index 00000000..2d84611b --- /dev/null +++ b/deploy/pilot/upgrade_frontend.py @@ -0,0 +1,514 @@ +"""Заменить только статические файлы авторизации известного пилота, без рестартов.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import sys +import uuid + +sys.dont_write_bytecode = True +import hosted # noqa: E402 +import upgrade_invites as files # noqa: E402 + +ROOT = hosted.NEW +RECORD = ROOT / "frontend-upgrade.json" +IDENTITY = "hosted-v2-auth-frontend" +BACKEND = "76570824e596ba06b196171f6cf8be38e5a452d1" +PREVIOUS = "971e52fb1f199ee5c38e2fb42f06ff1946a261a7" +REVISION = "75f41a5d9a008fb97418eb50724b3b99fc8349b7" +BASE_INDEX = "8052cbdedfc44d328fe9d06d90ff1f135cd17506df461e59d582d8d68b73707c" +HELPERS = {"upgrade_frontend.py", "hosted.py", "runtime_import.py", "upgrade_invites.py"} +SNAPSHOT_FORMAT = "canonical-mounts-v1" +require = hosted.require + + +def relative_name(value): + require(isinstance(value, str), "Неизвестное имя файла.") + path = PurePosixPath(value) + require( + str(path) == value + and bool(path.parts) + and not path.is_absolute() + and all(part not in ("", ".", "..") for part in path.parts) + and not re.search(r"[\\\x00-\x1f\x7f]", value), + "Небезопасное имя файла.", + ) + return value + + +def frontend_path(relative): + relative_name(relative) + target = files.frontend_path(relative) + require( + all( + not parent.is_symlink() + for parent in target.parents + if parent.is_relative_to(ROOT / "frontend") + ), + "Ссылки внутри frontend запрещены.", + ) + return target + + +def hashes(value): + require(isinstance(value, dict) and value, "Пустой список файлов.") + for name, digest in value.items(): + relative_name(name) + require( + isinstance(digest, str) and re.fullmatch(r"[a-f0-9]{64}", digest), + "Некорректный checksum.", + ) + return value + + +def verify_bundle(bundle): + require( + bundle.resolve() == bundle and not bundle.is_symlink(), "Ссылка вместо пакета." + ) + manifest = json.loads((bundle / "frontend-manifest.json").read_text()) + require( + manifest.get("format") == IDENTITY + and manifest.get("backend_revision") == BACKEND + and manifest.get("base_frontend_revision") == PREVIOUS + and manifest.get("frontend_revision") == REVISION, + "Пакет предназначен для другой версии пилота.", + ) + before = hashes(manifest["base_files"]) + after = hashes(manifest["new_files"]) + require(before.get("index.html") == BASE_INDEX, "Неизвестная исходная сборка.") + require( + after.get("index.html") and after["index.html"] != BASE_INDEX, + "Нет нового index сборки.", + ) + expected = hashes(manifest["files"]) + changed = {"frontend/" + p for p, h in after.items() if before.get(p) != h} + require(set(expected) == HELPERS | changed, "В пакете есть посторонние файлы.") + actual = set() + for path in bundle.rglob("*"): + require(not path.is_symlink(), "Ссылки в пакете запрещены.") + if path.is_file(): + actual.add(str(path.relative_to(bundle))) + require( + actual == set(expected) | {"frontend-manifest.json"}, "Состав пакета изменен." + ) + for relative, digest in expected.items(): + require(hosted.digest(bundle / relative) == digest, "Поврежден файл: " + relative) + if relative.startswith("frontend/"): + require(after[relative[9:]] == digest, "Несовпадающие checksums frontend.") + index = (bundle / "frontend/index.html").read_text() + require( + 'content="white-label-pilot-v1"' in index + and 'content="noindex, nofollow"' in index, + "Нужна специальная pilot сборка.", + ) + return manifest + + +def configuration_fingerprint(container): + # Docker собирает Mounts из map: порядок записей может меняться между inspect. + config = {key: container[key] for key in ("Config", "HostConfig")} + config["Mounts"] = sorted( + container["Mounts"], key=lambda mount: json.dumps(mount, sort_keys=True) + ) + return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest() + + +def protected_snapshot(): + names = ( + "api-web-1", + "api-redis-1", + "api_celery", + hosted.OLD_WEB, + hosted.DB, + hosted.WEB, + hosted.PG, + ) + result = {"format": SNAPSHOT_FORMAT, "containers": {}, "files": {}} + for name in names: + container = hosted.inspect(name) + require(container["State"]["Running"], "Контейнер не работает: " + name) + result["containers"][name] = [ + container["Id"], + container["Image"], + container["State"]["StartedAt"], + configuration_fingerprint(container), + ] + paths = [ + ROOT / n for n in ("state.json", ".env", "access.txt", "weights-upgrade.json") + ] + paths.extend((hosted.ROUTES, hosted.MAPS)) + for mount in hosted.inspect(hosted.WEB)["Mounts"]: + if mount["Type"] == "bind" and not mount["RW"]: + paths.append(hosted.owned_path(mount["Source"])) + for path in paths: + require(path.is_file() and not path.is_symlink(), "Неизвестный защищенный файл.") + result["files"][str(path)] = hosted.digest(path) + config = subprocess.run(["nginx", "-T"], capture_output=True, check=True) + result["nginx"] = hashlib.sha256(config.stdout).hexdigest() + return result + + +def verify_protected(record): + current = protected_snapshot() + previous = record["protected"] + containers = sorted( + name + for name in set(current["containers"]) | set(previous["containers"]) + if current["containers"].get(name) != previous["containers"].get(name) + ) + paths = sorted( + name + for name in set(current["files"]) | set(previous["files"]) + if current["files"].get(name) != previous["files"].get(name) + ) + require( + current == previous, + "Изменились защищенные ресурсы: контейнеры=" + + ",".join(containers) + + "; файлы=" + + ",".join(paths) + + "; nginx=" + + str(current["nginx"] != previous["nginx"]), + ) + + +def api_smoke(): + for url, key, value in ( + ("/backend/instance/", "enabled", True), + ("/backend/programs/1/registration/", "account_mode", "email_password"), + ): + code, _, body = hosted.health( + "http://127.0.0.1:18319" + url, + headers={"Host": "pilot.procollab.ru", "X-Forwarded-Proto": "https"}, + ) + require( + code == 200 and json.loads(body).get(key) == value, + "API пилота не прошел GET-проверку.", + ) + + +def verify_live(expected): + password = ( + (ROOT / "access.txt").read_text().split("Пароль входа: ", 1)[1].splitlines()[0] + ) + code, _, body = hosted.health( + hosted.ORIGIN + "/", + headers={ + "Authorization": "Basic " + + base64.b64encode(("pilot:" + password).encode()).decode(), + "Cache-Control": "no-cache", + }, + ) + require( + code == 200 and hashlib.sha256(body).hexdigest() == expected, + "HTTPS отдает другой frontend.", + ) + + +def save_record(record): + value = json.dumps(record, ensure_ascii=False, indent=2) + hosted.private_write(Path(record["backup"]) / "record.json", value) + hosted.private_write(RECORD, value) + + +def load_record(): + require( + RECORD.is_file() and not RECORD.is_symlink(), + "Нет известного состояния обновления.", + ) + record = json.loads(RECORD.read_text()) + require( + record.get("identity") == IDENTITY + and record.get("release") == REVISION + and record.get("phase") in ("prepared", "active", "rolled_back"), + "Неизвестное состояние frontend.", + ) + backup = Path(record["backup"]) + require( + backup.parent == ROOT / "backup" + and re.fullmatch(r"auth-[a-f0-9]{12}", backup.name) + and (ROOT / "backup").resolve() == ROOT / "backup" + and hosted.owned_path(backup) == backup, + "Чужая резервная копия.", + ) + hashes(record["new_files"]) + for relative, item in record["frontend"].items(): + relative_name(relative) + require( + re.fullmatch(r"[a-f0-9]{64}", item["after"]), "Неверный checksum frontend." + ) + require( + item["before"] is None or re.fullmatch(r"[a-f0-9]{64}", item["before"]), + "Неверный checksum backup.", + ) + require("index.html" in record["frontend"], "В записи нет index.") + return record + + +def check(bundle): + hosted.scope() + state = hosted.state_read() + require( + state.get("phase") == "active" + and state.get("app_patch_revision") == BACKEND + and state.get("weights_revision") == BACKEND, + "Нужен действующий пилот с установленными весами 7657082.", + ) + previous = None + if RECORD.exists(): + previous = load_record() + require( + previous["phase"] == "rolled_back", + "Уже есть обновление; сначала проверьте status.", + ) + current = hosted.candidate() + weights = json.loads((ROOT / "weights-upgrade.json").read_text()) + require( + current + and current["State"]["Running"] + and current["Image"] == state["image_id"] + and weights.get("identity") == "hosted-v2-weights-upgrade" + and weights.get("phase") == "active" + and weights.get("release") == BACKEND + and weights.get("candidate_id") == current["Id"], + "Не подтвержден действующий backend пилота.", + ) + require( + hosted.digest(hosted.ROUTES) == state["new_routes_sha256"] + and hosted.digest(hosted.MAPS) == state["new_maps_sha256"], + "Маршруты пилота изменены снаружи.", + ) + manifest = verify_bundle(bundle) + require( + (ROOT / "backup").resolve() == ROOT / "backup", "Каталог backup является ссылкой." + ) + for relative, digest in manifest["base_files"].items(): + target = frontend_path(relative) + require( + target.is_file() and hosted.digest(target) == digest, + "Установленный frontend изменен: " + relative, + ) + for relative, digest in manifest["new_files"].items(): + target = frontend_path(relative) + if relative not in manifest["base_files"] and target.exists(): + require( + target.is_file() and hosted.digest(target) == digest, + "Новый путь уже занят другим файлом.", + ) + size = sum( + (bundle / p).stat().st_size + for p in manifest["files"] + if p.startswith("frontend/") + ) + require( + shutil.disk_usage(ROOT).free >= 2 * 2**30 + 2 * size, + "Недостаточно диска с запасом 2 GiB для dev.", + ) + hosted.dev_health() + api_smoke() + protected = protected_snapshot() + if previous: + # Подтверждаем сохранность ресурсов после предыдущей неудачной попытки. + # Старый hash Config нельзя нормализовать задним числом без исходного Mounts. + old = previous["protected"] + require( + old["files"] == protected["files"] and old["nginx"] == protected["nginx"], + "Защищенные файлы изменились после предыдущей попытки.", + ) + require( + set(old["containers"]) == set(protected["containers"]) + and all( + old["containers"][name][:3] == identity[:3] + for name, identity in protected["containers"].items() + ), + "Контейнеры изменились после предыдущей попытки.", + ) + if old.get("format") == SNAPSHOT_FORMAT: + require(old == protected, "Конфигурация изменилась после предыдущей попытки.") + return manifest, protected + + +def rollback(record): + # Не удаляем новые chunks: уже открытая вкладка может еще запросить их. + backup = Path(record["backup"]) + for relative, item in record["frontend"].items(): + target = frontend_path(relative) + current = hosted.digest(target) if target.is_file() else None + require( + current in (item["before"], item["after"]), + "Frontend изменен снаружи; откат остановлен.", + ) + if item["before"]: + saved = hosted.owned_path(backup / "frontend" / relative) + require( + saved.is_file() and hosted.digest(saved) == item["before"], + "Резервная копия повреждена.", + ) + for relative in sorted(record["frontend"], key=lambda p: p == "index.html"): + if record["frontend"][relative]["before"]: + files.copy_atomic(backup / "frontend" / relative, frontend_path(relative)) + record["phase"] = "rolled_back" + save_record(record) + verify_live(record["frontend"]["index.html"]["before"]) + + +def status(): + hosted.scope() + record = load_record() + verify_protected(record) + hosted.dev_health() + api_smoke() + if record["phase"] == "active": + for relative, digest in record["new_files"].items(): + require( + hosted.digest(frontend_path(relative)) == digest, + "Frontend изменен снаружи.", + ) + verify_live(record["new_files"]["index.html"]) + else: + verify_live(record["frontend"]["index.html"]["before"]) + return { + "phase": record["phase"], + "frontend_revision": REVISION if record["phase"] == "active" else PREVIOUS, + "dev_http": 200, + "containers_restarted": False, + "database_changed": False, + "nginx_changed": False, + "backup": record["backup"], + } + + +def apply(bundle): + if RECORD.exists() and load_record()["phase"] == "active": + return status() + manifest, protected = check(bundle) + backup = hosted.owned_path(ROOT / "backup" / ("auth-" + uuid.uuid4().hex[:12])) + backup.mkdir(mode=0o700) + record = { + "identity": IDENTITY, + "release": REVISION, + "phase": "prepared", + "backup": str(backup), + "protected": protected, + "frontend": {}, + "new_files": manifest["new_files"], + } + for relative, after in manifest["new_files"].items(): + target = frontend_path(relative) + before = hosted.digest(target) if target.is_file() else None + if before != after: + record["frontend"][relative] = {"before": before, "after": after} + if before: + saved = backup / "frontend" / relative + files.copy_atomic(target, saved, mode=0o600) + require( + hosted.digest(saved) == before, "Резервная копия не прошла проверку." + ) + save_record(record) + print( # noqa: T201 + "Проверки пройдены. Резервная копия интерфейса сохранена; публикую только статику пилота.", + flush=True, + ) + stage = "protected_before_publish" + try: + verify_protected(record) + for relative in sorted(record["frontend"], key=lambda p: p == "index.html"): + stage = "publish:" + relative + source = bundle / "frontend" / relative + require( + hosted.digest(source) == record["frontend"][relative]["after"], + "Пакет изменен во время установки.", + ) + target = frontend_path(relative) + before = hosted.digest(target) if target.is_file() else None + require( + before == record["frontend"][relative]["before"], + "Frontend изменен во время установки.", + ) + files.copy_atomic(source, target) + stage = "published_https" + verify_live(manifest["new_files"]["index.html"]) + stage = "pilot_api" + api_smoke() + stage = "protected_after_publish" + verify_protected(record) + stage = "dev_http_after_publish" + hosted.dev_health() + stage = "save_active_state" + record["phase"] = "active" + save_record(record) + except BaseException as error: + failure = { + "stage": stage, + "error_type": type(error).__name__, + "reason": str(error), + } + record["failure"] = failure + try: + hosted.private_write( + backup / "failure.json", json.dumps(failure, ensure_ascii=False, indent=2) + ) + except OSError: + pass + try: + rollback(record) + except BaseException as rollback_error: + raise hosted.Refused( + "Обновление остановлено на этапе " + + stage + + ": " + + str(error) + + ". Откат не подтвержден: " + + str(rollback_error) + + ". Backup: " + + str(backup) + ) from error + raise hosted.Refused( + "Обновление остановлено на этапе " + + stage + + ": " + + str(error) + + ". Прежний интерфейс пилота восстановлен. Backup: " + + str(backup) + ) from error + return status() + + +if __name__ == "__main__": + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "apply", "rollback", "status")) + parser.add_argument("--bundle", type=Path) + args = parser.parse_args() + try: + with hosted.locked(): + if args.command in ("check", "apply"): + require(args.bundle is not None, "Нужен --bundle.") + if args.command == "apply": + result = apply(args.bundle.resolve()) + else: + manifest, _ = check(args.bundle.resolve()) + result = { + "changes_applied": False, + "target_frontend_revision": REVISION, + "dev_http": 200, + "containers_restarted": False, + "database_changed": False, + "nginx_changed": False, + "changed_files": len(manifest["files"]) - len(HELPERS), + } + else: + if args.command == "rollback": + hosted.scope() + rollback(load_record()) + result = status() + print(json.dumps(result, ensure_ascii=False, indent=2)) # noqa: T201 + except Exception as error: + print("ОСТАНОВЛЕНО: " + str(error), file=sys.stderr) # noqa: T201 + raise SystemExit(1) diff --git a/deploy/pilot/upgrade_invites.py b/deploy/pilot/upgrade_invites.py new file mode 100644 index 00000000..bd3bde9d --- /dev/null +++ b/deploy/pilot/upgrade_invites.py @@ -0,0 +1,549 @@ +"""Обновить только hosted-v2: тот же image/DB, четыре readonly Python файла и UI.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys +import time +from urllib.parse import urlencode +import uuid + +sys.dont_write_bytecode = True +import hosted # noqa: E402 + +ROOT = hosted.NEW +BASELINE = Path("/root/procollab-pilot-upload-83ca9a9/procollab-pilot") +RECORD = ROOT / "invite-upgrade.json" +REVISION_LABEL = "io.procollab.pilot.app-revision" +PATCH_FILES = ( + "users/filters.py", + "users/serializers.py", + "users/views.py", + "users/white_label_candidates.py", +) +BASE_HASHES = { + "users/filters.py": "11bc0c29e9fe9c30730fd9007d0e2b8817df4fc1eb18f654400a0ea2e382175f", + "users/serializers.py": "53de881f56ef3f773784188eef6cf91f035c85f1a2bb7bcc21947d8a64c2f291", + "users/views.py": "5f4d729d212a4ee48fe78cb940c6b3bbc142db9dcd0a147d5b7995289c43676c", + "pyproject.toml": "0d9e46cce81c1fd211ae90c73394119d8bb070885f34d9ee2b64d49c6472ffe7", + "poetry.lock": "fd66c215bc4484d4056bdf9aaa1f31867eda35ccbc2a5e50e223491ecadd3f67", + "procollab/settings_pilot.py": "9de066782c3ff888f5be3dde0b3f3500377caecf8a1c7a941d1d23646b8038bb", + "procollab/settings_pilot_inline.py": "6a311d48ee93d7978a6d4e68e5dcf5919023d096b56ee453f67850752ef27366", + "procollab/white_label.py": "23235bf15e5d7574f86663271b62d0bb739fb4e7e64a3cb366c0d2c2e3488d16", +} +require = hosted.require + + +def copy_atomic(source, destination, mode=0o644): + missing = [] + parent = destination.parent + while not parent.exists(): + missing.append(parent) + parent = parent.parent + public = destination.resolve().is_relative_to(ROOT / "frontend") + for parent in reversed(missing): + parent.mkdir(mode=0o755 if public else 0o700) + parent.chmod(0o755 if public else 0o700) + temporary = destination.with_name("." + destination.name + ".invite-next") + descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode) + try: + with os.fdopen(descriptor, "wb") as output, source.open("rb") as input_file: + shutil.copyfileobj(input_file, output) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(mode) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + + +def frontend_path(relative): + root = ROOT / "frontend" + destination = root / relative + require( + root.resolve() == root and destination.resolve().is_relative_to(root), + "Путь выходит за пределы frontend пилота.", + ) + require(not destination.is_symlink(), "Ссылка вместо frontend файла запрещена.") + return destination + + +def validate_backend(hashes, manifest): + require( + all(hashes.get(name) == expected for name, expected in BASE_HASHES.items()), + "Код или зависимости установленного пилота изменены; обновление остановлено.", + ) + require( + hashes.get("users/white_label_candidates.py") is None, + "Модуль поиска уже установлен вне этого обновления.", + ) + for name, expected in BASE_HASHES.items(): + if name not in PATCH_FILES: + require( + manifest["files"].get("api/" + name) == expected, + "Пакет требует другие зависимости или настройки.", + ) + require( + all("api/" + name in manifest["files"] for name in PATCH_FILES), + "В пакете отсутствует исправление приглашений.", + ) + return {name: manifest["files"]["api/" + name] for name in PATCH_FILES} + + +def save_record(record): + value = json.dumps(record, ensure_ascii=False, indent=2) + hosted.private_write(Path(record["backup"]) / "record.json", value) + hosted.private_write(RECORD, value) + + +def load_record(): + value = json.loads(RECORD.read_text()) + require( + value.get("identity") == "hosted-v2-invite-upgrade", "Чужое состояние обновления." + ) + require( + re.fullmatch(r"[a-f0-9]{40}", value.get("release", "")), + "Неизвестная revision обновления.", + ) + require( + re.fullmatch( + re.escape(hosted.WEB) + r"-before-[a-f0-9]{12}", value.get("old_name", "") + ), + "Чужое имя сохраненного контейнера.", + ) + backup = Path(value["backup"]) + require( + backup.resolve().is_relative_to(ROOT / "backup") and not backup.is_symlink(), + "Чужой каталог резервной копии.", + ) + return value + + +def check(bundle): + hosted.scope() + state = hosted.state_read() + require(state["phase"] == "active", "Нужен активный hosted-v2.") + require( + not state.get("app_patch_revision"), "Пилот уже содержит другой source overlay." + ) + if RECORD.exists(): + require( + load_record()["phase"] == "rolled_back", + "Есть активное или незавершенное обновление; сначала проверьте его состояние/rollback.", + ) + current = hosted.candidate() + require( + current and current["State"]["Running"] and current["Image"] == state["image_id"], + "Работающий пилот не соответствует состоянию.", + ) + expected_mounts = { + (str(ROOT / "data"), "/procollab/pilot-data"), + (str(ROOT / "logs"), "/procollab/log"), + } + require( + {(mount["Source"], mount["Destination"]) for mount in current["Mounts"]} + == expected_mounts, + "В пилоте есть другие mounts; они не заменяются.", + ) + network = hosted.docker_json("network", "inspect", hosted.NETWORK)[0] + require( + (network.get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and {item["Name"] for item in network.get("Containers", {}).values()} + <= {hosted.WEB, hosted.PG}, + "В сети пилота есть посторонние ресурсы.", + ) + require( + hosted.digest(hosted.ROUTES) == state["new_routes_sha256"] + and hosted.digest(hosted.MAPS) == state["new_maps_sha256"], + "Маршруты пилота изменены снаружи.", + ) + old = hosted.verify_bundle(BASELINE) + require( + old["api_revision"] == state["release"], + "Исходный пакет не соответствует работающему пилоту.", + ) + manifest = hosted.verify_bundle(bundle) + script = ( + "import hashlib,json;from pathlib import Path;names=" + + json.dumps([*BASE_HASHES, "users/white_label_candidates.py"]) + + ";print(json.dumps({n:hashlib.sha256(Path(n).read_bytes()).hexdigest() " + "if Path(n).is_file() else None for n in names}))" + ) + result = hosted.docker( + "exec", + hosted.WEB, + "python", + "-c", + script, + capture_output=True, + text=True, + check=True, + ) + hashes = validate_backend(json.loads(result.stdout), manifest) + require( + hosted.digest(frontend_path("index.html")) == old["files"]["frontend/index.html"], + "Index установленного пилота изменен снаружи.", + ) + memory = dict( + line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() + ) + require( + int(memory["MemAvailable"].split()[0]) >= 1536 * 1024, + "Недостаточно RAM с запасом для dev.", + ) + require( + shutil.disk_usage(ROOT).free >= 2 * 2**30 + 192 * 2**20, + "Недостаточно диска с запасом 2 GiB для dev.", + ) + hosted.dev_health() + hosted.candidate_health() + return state, current, manifest, hashes + + +def backup_database(backup): + pg = hosted.inspect(hosted.PG) + require( + (pg["Config"].get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and pg["State"]["Running"], + "Чужой или остановленный PostgreSQL пилота.", + ) + temporary = backup / "database.tmp" + with temporary.open("xb") as output: + result = hosted.docker( + "exec", + hosted.PG, + "sh", + "-ec", + 'export PGPASSWORD="${POSTGRES_PASSWORD:-}"; ' + "exec pg_dump -U pilot_owner -Fc -d procollab_white_label_pilot_v2", + stdout=output, + stderr=hosted.subprocess.PIPE, + timeout=180, + ) + require( + result.returncode == 0 and temporary.stat().st_size > 0, + "Не создана резервная копия БД v2.", + ) + with temporary.open("rb") as input_file: + result = hosted.docker( + "exec", + "-i", + hosted.PG, + "pg_restore", + "--list", + stdin=input_file, + stdout=hosted.subprocess.DEVNULL, + stderr=hosted.subprocess.PIPE, + timeout=60, + ) + require(result.returncode == 0, "Резервная копия v2 не прошла проверку.") + temporary.rename(backup / "database.dump") + + +def rollback(record, *, record_writer=None): + require(record["phase"] in ("prepared", "active"), "Нет обновления для отката.") + backup = Path(record["backup"]) + # Все внешние изменения проверяются до остановки контейнера или записи файлов. + for relative, expected in record["frontend"].items(): + target = frontend_path(relative) + value = hosted.digest(target) if target.exists() else None + require( + value in (expected["before"], expected["after"]), + "Frontend изменен снаружи; откат остановлен.", + ) + if expected["before"]: + saved = backup / "frontend" / relative + require( + saved.is_file() and hosted.digest(saved) == expected["before"], + "Резервная копия frontend изменена; откат остановлен.", + ) + result = hosted.docker( + "container", "inspect", hosted.WEB, capture_output=True, text=True + ) + current = json.loads(result.stdout)[0] if result.returncode == 0 else None + if current: + require( + (current["Config"].get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY, + "Имя пилота занято чужим проектом.", + ) + if current and current["Id"] != record["old_container_id"]: + labels = current["Config"].get("Labels") or {} + require( + labels.get(REVISION_LABEL) == record["release"] + and labels.get(hosted.LABEL) == hosted.IDENTITY, + "Имя пилота занято другим контейнером.", + ) + require( + not record.get("candidate_id") or current["Id"] == record["candidate_id"], + "Кандидат изменен снаружи.", + ) + old = current + if not current or current["Id"] != record["old_container_id"]: + old = hosted.inspect(record["old_name"]) + require(old["Id"] == record["old_container_id"], "Прежний контейнер изменен.") + require( + (old["Config"].get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and old["Image"] == record["previous_state"]["image_id"], + "Сохраненный контейнер не принадлежит пилоту.", + ) + if current and current["Id"] != record["old_container_id"]: + if current["State"]["Running"]: + require( + hosted.docker( + "stop", "--time", "20", hosted.WEB, capture_output=True, timeout=45 + ).returncode + == 0, + "Кандидат не остановлен.", + ) + require( + hosted.docker( + "rename", + hosted.WEB, + hosted.WEB + "-failed-" + current["Id"][:12], + capture_output=True, + ).returncode + == 0, + "Кандидат не сохранен для анализа.", + ) + current = None + if not current: + require( + hosted.docker( + "rename", record["old_name"], hosted.WEB, capture_output=True + ).returncode + == 0, + "Не восстановлено имя прежнего пилота.", + ) + current = old + if not current["State"]["Running"]: + require( + hosted.docker("start", hosted.WEB, capture_output=True).returncode == 0, + "Прежний пилот не запущен.", + ) + for relative, expected in record["frontend"].items(): + if expected["before"]: + copy_atomic(backup / "frontend" / relative, frontend_path(relative)) + hosted.state_write(record["previous_state"]) + record["phase"] = "rolled_back" + (record_writer or save_record)(record) + return { + "phase": "rolled_back", + "database_restored": False, + "data_preserved": True, + "dev_http": hosted.dev_health(), + } + + +def email_smoke(): + headers = { + "Host": "pilot.procollab.ru", + "X-Forwarded-Proto": "https", + "Content-Type": "application/json", + } + code, _, body = hosted.health( + "http://127.0.0.1:18319/backend/api/token/", + headers=headers, + body=json.dumps( + { + "email": "leader@example.test", + "password": hosted.environment()["PILOT_ACCOUNT_PASSWORD"], + } + ).encode(), + ) + require(code == 200, "Вход после обновления не прошел smoke.") + headers["Authorization"] = "Bearer " + json.loads(body)["access"] + path = "http://127.0.0.1:18319/backend/auth/public-users/?" + urlencode( + {"fullname": "member@example.test", "partner_program": 1, "user_type": 1} + ) + code, _, body = hosted.health(path, headers=headers) + require( + code == 200 + and [row.get("email") for row in json.loads(body)["results"]] + == ["member@example.test"], + "Поиск почты после обновления не прошел smoke.", + ) + headers.pop("Authorization") + code, _, body = hosted.health(path, headers=headers) + require( + code == 200 and json.loads(body)["count"] == 0, + "Публичный поиск раскрывает почту.", + ) + + +def apply(bundle): + state, current, manifest, hashes = check(bundle) + release = manifest["api_revision"] + identifier = uuid.uuid4().hex[:12] + backup = ROOT / "backup" / ("invite-" + identifier) + backup.mkdir(mode=0o700) + sources = ROOT / "releases" / ("invite-" + release) / "backend" + for relative in PATCH_FILES: + target = hosted.owned_path(sources / relative) + copy_atomic(bundle / "api" / relative, target) + record = { + "identity": "hosted-v2-invite-upgrade", + "release": release, + "phase": "prepared", + "backup": str(backup), + "old_container_id": current["Id"], + "old_name": hosted.WEB + "-before-" + identifier, + "previous_state": state, + "frontend": {}, + } + for source in sorted((bundle / "frontend").rglob("*")): + if not source.is_file(): + continue + relative = str(source.relative_to(bundle / "frontend")) + target = frontend_path(relative) + before = hosted.digest(target) if target.exists() else None + after = hosted.digest(source) + if before != after: + record["frontend"][relative] = {"before": before, "after": after} + if before: + copy_atomic(target, backup / "frontend" / relative, mode=0o600) + backup_database(backup) + save_record(record) + try: + require( + hosted.docker( + "rename", hosted.WEB, record["old_name"], capture_output=True + ).returncode + == 0, + "Прежний пилот не сохранен.", + ) + require( + hosted.docker( + "stop", + "--time", + "20", + record["old_name"], + capture_output=True, + timeout=45, + ).returncode + == 0, + "Прежний пилот не остановлен.", + ) + mounts = [] + for relative in PATCH_FILES: + mounts.extend( + [ + "--mount", + f"type=bind,src={sources/relative},dst=/procollab/{relative},readonly", + ] + ) + args = hosted.container_args(state["image_id"], hosted.NETWORK) + result = hosted.docker( + "run", + "-d", + "--name", + hosted.WEB, + "--restart", + "unless-stopped", + "-p", + "127.0.0.1:18319:8000", + "--label", + REVISION_LABEL + "=" + release, + *mounts, + *args, + "daphne", + "-b", + "0.0.0.0", + "-p", + "8000", + "--root-path", + "/backend", + "procollab.asgi:application", + capture_output=True, + text=True, + ) + require(result.returncode == 0, "Обновленный пилот не запущен.") + record["candidate_id"] = result.stdout.strip() + save_record(record) + for attempt in range(30): + try: + hosted.candidate_health() + break + except Exception: + if attempt == 29: + raise + time.sleep(1) + email_smoke() + for relative in record["frontend"]: + if relative != "index.html": + copy_atomic(bundle / "frontend" / relative, frontend_path(relative)) + copy_atomic(bundle / "frontend/index.html", frontend_path("index.html")) + password = ( + (ROOT / "access.txt") + .read_text() + .split("Пароль входа: ", 1)[1] + .splitlines()[0] + ) + code, _, body = hosted.health( + hosted.ORIGIN + "/", + headers={ + "Authorization": "Basic " + + base64.b64encode(("pilot:" + password).encode()).decode() + }, + ) + require( + code == 200 + and hashlib.sha256(body).hexdigest() + == manifest["files"]["frontend/index.html"], + "Опубликованный frontend не прошел проверку.", + ) + hosted.dev_health() + state = {**state, "app_patch_revision": release, "app_patch_files": hashes} + hosted.state_write(state) + record["phase"] = "active" + save_record(record) + except BaseException: + rollback(record) + raise hosted.Refused("Обновление не прошло проверку; прежний пилот восстановлен.") + return { + "phase": "active", + "app_patch_revision": release, + "image_reused": True, + "database_preserved": hosted.DB_NAME, + "nginx_changed": False, + "dev_http": 200, + "backup": str(backup), + } + + +if __name__ == "__main__": + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "apply", "rollback")) + parser.add_argument("--bundle", type=Path) + arguments = parser.parse_args() + try: + with hosted.locked(): + if arguments.command == "rollback": + hosted.scope() + result = rollback(load_record()) + else: + require(arguments.bundle is not None, "Укажите --bundle.") + if arguments.command == "check": + _, _, manifest, _ = check(arguments.bundle.resolve()) + result = { + "changes_applied": False, + "target_revision": manifest["api_revision"], + "image_reused": True, + "dev_http": 200, + } + else: + result = apply(arguments.bundle.resolve()) + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") + except ( + hosted.Refused, + OSError, + ValueError, + hosted.subprocess.SubprocessError, + ) as error: + sys.stderr.write("ОСТАНОВЛЕНО: " + str(error) + "\n") + raise SystemExit(1) diff --git a/deploy/pilot/upgrade_overall.py b/deploy/pilot/upgrade_overall.py new file mode 100644 index 00000000..40411dad --- /dev/null +++ b/deploy/pilot/upgrade_overall.py @@ -0,0 +1,939 @@ +"""Общий рейтинг только для известного пилота после весов и исправления входа.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys +import time +import uuid + +sys.dont_write_bytecode = True +import hosted # noqa: E402 +import upgrade_frontend as auth # noqa: E402 +import upgrade_invites as files # noqa: E402 + +ROOT = hosted.NEW +RECORD = ROOT / "overall-upgrade.json" +BASE = json.loads((Path(__file__).parent / "overall_base.json").read_text()) +IDENTITY = "hosted-v2-overall-upgrade" +STEP_LABEL = "io.procollab.pilot.overall.step" +PATCH_FILES = tuple(BASE["target_hashes"]) +HELPERS = { + "upgrade_overall.py", + "overall_base.json", + "hosted.py", + "runtime_import.py", + "upgrade_invites.py", + "upgrade_frontend.py", +} +MIGRATION = ("partner_programs", "0045_program_quiz_outcome") +OUTSIDE = ("api-web-1", "api-redis-1", "api_celery", hosted.OLD_WEB, hosted.DB, hosted.PG) +require = hosted.require + + +def progress(message): + sys.stdout.write(message + "\n") + sys.stdout.flush() + + +def container_identity(container): + return [ + container["Id"], + container["Image"], + container["State"]["StartedAt"], + auth.configuration_fingerprint(container), + ] + + +def outside_snapshot(current): + containers = {} + for name in OUTSIDE: + container = hosted.inspect(name) + require( + container["State"]["Running"], "Не работает защищенный контейнер: " + name + ) + containers[name] = container_identity(container) + paths = [ + ROOT / name + for name in ( + ".env", + "access.txt", + "invite-upgrade.json", + "weights-upgrade.json", + "frontend-upgrade.json", + ) + ] + paths.extend((hosted.ROUTES, hosted.MAPS)) + paths.extend( + hosted.owned_path(mount["Source"]) + for mount in current["Mounts"] + if mount["Type"] == "bind" and not mount["RW"] + ) + protected = {} + for path in paths: + require(path.is_file() and not path.is_symlink(), "Неизвестный защищенный файл.") + protected[str(path)] = hosted.digest(path) + nginx = hosted.subprocess.run(["nginx", "-T"], capture_output=True, check=True) + return { + "containers": containers, + "files": protected, + "nginx": hashlib.sha256(nginx.stdout).hexdigest(), + } + + +def verify_outside(record): + old = hosted.inspect(record["old_container_id"]) + require( + outside_snapshot(old) == record["outside"], + "Конфигурация dev/БД/старого пилота или защищенные файлы изменились.", + ) + return hosted.dev_health() + + +def runtime_fingerprint(container): + script = ( + "import hashlib,json,sys;from pathlib import Path;root=Path('/procollab');" + "names=json.load(sys.stdin);print(json.dumps({n:" + "hashlib.sha256((root/n).read_bytes()).hexdigest() " + "if (root/n).is_file() and not (root/n).is_symlink() else None for n in names}))" + ) + result = hosted.docker( + "exec", + "-i", + container["Id"], + "python", + "-B", + "-c", + script, + input=json.dumps(list(BASE["runtime_hashes"])), + capture_output=True, + text=True, + timeout=45, + ) + require(result.returncode == 0, "Не прочитан код работающего пилота.") + return json.loads(result.stdout) + + +def verify_runtime(container, *, updated): + expected = {**BASE["runtime_hashes"], **(BASE["target_hashes"] if updated else {})} + actual = runtime_fingerprint(container) + for name, value in expected.items(): + require(actual.get(name) == value, "Неизвестный код пилота: " + name) + + +def verify_mounts(current): + mounts = {mount["Destination"]: mount for mount in current["Mounts"]} + expected = {"/procollab/pilot-data": ROOT / "data", "/procollab/log": ROOT / "logs"} + require( + len(mounts) == len(current["Mounts"]) + and set(mounts) + == set(expected) | {"/procollab/" + name for name in BASE["overlays"]}, + "Неизвестные mounts пилота.", + ) + for destination, source in expected.items(): + require( + mounts[destination]["Type"] == "bind" + and mounts[destination]["Source"] == str(source) + and mounts[destination]["RW"], + "Изменен каталог данных пилота.", + ) + for name in BASE["overlays"]: + mount = mounts["/procollab/" + name] + source = hosted.owned_path(mount["Source"]) + require( + mount["Type"] == "bind" + and not mount["RW"] + and source.is_file() + and hosted.digest(source) == BASE["runtime_hashes"][name], + "Изменено прежнее исправление: " + name, + ) + + +def verify_bundle(bundle): + require( + bundle.resolve() == bundle and not bundle.is_symlink(), "Ссылка вместо пакета." + ) + manifest = json.loads((bundle / "overall-manifest.json").read_text()) + require( + manifest.get("format") == IDENTITY + and manifest.get("backend_revision") == BASE["backend_revision"] + and manifest.get("frontend_revision") == BASE["frontend_revision"] + and manifest.get("base_backend_revision") == BASE["base_backend_revision"] + and manifest.get("base_frontend_revision") == BASE["base_frontend_revision"], + "Пакет для другой версии пилота.", + ) + require( + re.fullmatch(r"[a-f0-9]{40}", manifest.get("tooling_revision", "")), + "Нет revision установщика.", + ) + after = auth.hashes(manifest["new_files"]) + require( + after == BASE["target_frontend_files"], + "Нужна точная проверенная сборка интерфейса.", + ) + require( + json.loads((bundle / "overall_base.json").read_text()) == BASE, + "Разные базовые fingerprints пакета.", + ) + require( + after.get("index.html") + and after["index.html"] != BASE["frontend_files"]["index.html"], + "Нет нового index.", + ) + expected = auth.hashes(manifest["files"]) + changed = { + "frontend/" + name + for name, digest in after.items() + if BASE["frontend_files"].get(name) != digest + } + require( + set(expected) == HELPERS | {"api/" + name for name in PATCH_FILES} | changed, + "В пакете есть посторонние файлы.", + ) + actual = set() + for path in bundle.rglob("*"): + require(not path.is_symlink(), "Ссылка внутри пакета.") + if path.is_file(): + actual.add(str(path.relative_to(bundle))) + require(actual == set(expected) | {"overall-manifest.json"}, "Состав пакета изменен.") + for name, digest in expected.items(): + require(hosted.digest(bundle / name) == digest, "Поврежден файл пакета: " + name) + if name.startswith("frontend/"): + require(after[name[9:]] == digest, "Разные checksums frontend.") + for name, digest in BASE["target_hashes"].items(): + require(expected["api/" + name] == digest, "Неизвестный целевой код: " + name) + index = (bundle / "frontend/index.html").read_text() + require( + 'content="white-label-pilot-v1"' in index + and 'content="noindex, nofollow"' in index, + "Нужна специальная pilot сборка.", + ) + return manifest + + +def save_record(record): + value = json.dumps(record, ensure_ascii=False, indent=2) + hosted.private_write(Path(record["backup"]) / "record.json", value) + hosted.private_write(RECORD, value) + + +def load_record(): + require( + RECORD.is_file() and not RECORD.is_symlink(), + "Нет известного обновления рейтинга.", + ) + record = json.loads(RECORD.read_text()) + require( + record.get("identity") == IDENTITY + and record.get("release") == BASE["backend_revision"] + and record.get("frontend_revision") == BASE["frontend_revision"] + and record.get("phase") in ("prepared", "active", "rolled_back"), + "Чужое состояние обновления.", + ) + require( + re.fullmatch(r"[a-f0-9]{12}", record.get("step", "")), + "Неизвестный шаг обновления.", + ) + require( + record.get("old_name") == hosted.WEB + "-before-" + record["step"], + "Чужое имя прежнего web.", + ) + backup = Path(record["backup"]) + require( + backup.parent == ROOT / "backup" + and backup.name == "overall-" + record["step"] + and hosted.owned_path(backup) == backup + and (ROOT / "backup").resolve() == ROOT / "backup", + "Чужой backup.", + ) + require( + re.fullmatch(r"[a-f0-9]{64}", record.get("old_container_id", "")), + "Нет ID прежнего web.", + ) + if record.get("candidate_id"): + require( + re.fullmatch(r"[a-f0-9]{64}", record["candidate_id"]), + "Неизвестный ID кандидата.", + ) + auth.hashes(record["new_files"]) + for name, item in record["frontend"].items(): + auth.relative_name(name) + require( + re.fullmatch(r"[a-f0-9]{64}", item["after"]) + and (item["before"] is None or re.fullmatch(r"[a-f0-9]{64}", item["before"])), + "Повреждено описание frontend backup.", + ) + return record + + +def overlay_args(current, sources): + result = [] + replaced = {"/procollab/" + name for name in PATCH_FILES} + for mount in current["Mounts"]: + if mount["Destination"] in replaced | {"/procollab/pilot-data", "/procollab/log"}: + continue + require( + mount["Type"] == "bind" and not mount["RW"], + "Неизвестный дополнительный mount.", + ) + result.extend( + ( + "--mount", + f"type=bind,src={mount['Source']},dst={mount['Destination']},readonly", + ) + ) + for name in PATCH_FILES: + result.extend( + ("--mount", f"type=bind,src={sources/name},dst=/procollab/{name},readonly") + ) + return result + + +def app_args(current, environment_file=None): + result = hosted.container_args(current["Image"], hosted.NETWORK) + if environment_file is not None: + result[result.index("--env-file") + 1] = str(hosted.owned_path(environment_file)) + return result + + +def migration_script(apply): + return "\n".join( + [ + "import django,json;django.setup()", + "from django.db import connection", + "from django.db.migrations.executor import MigrationExecutor", + "db=connection.settings_dict", + f"if not (db['NAME']=={hosted.DB_NAME!r} and db['USER']=={hosted.DB_ROLE!r} " + "and db['HOST']=='db'): raise RuntimeError('Unexpected pilot database')", + "with connection.cursor() as cursor:", + " cursor.execute(\"SET lock_timeout = '10s'\")", + " cursor.execute(\"SET statement_timeout = '120s'\")", + " cursor.execute('SET default_transaction_read_only = on')" + if not apply + else " pass", + "executor=MigrationExecutor(connection)", + "targets=executor.loader.graph.leaf_nodes()", + "plan=executor.migration_plan(targets)", + "if not all(not backwards and " + f"(m.app_label,m.name)=={MIGRATION!r} for m,backwards in plan): " + "raise RuntimeError('Unexpected migration plan')", + "if not all(len(m.operations)==1 and " + "type(m.operations[0]).__name__=='CreateModel' and " + "m.operations[0].name=='ProgramQuizOutcome' for m,_ in plan): " + "raise RuntimeError('Unexpected migration operation')", + "executor.migrate(targets)" if apply else "# read-only plan", + "from django.apps import apps" if apply else "# no permission writes", + "from django.contrib.auth.management import create_permissions" + if apply + else "# read-only metadata", + "create_permissions(apps.get_app_config('partner_programs'), verbosity=0, using=connection.alias)" + if apply + else "# no metadata changes", + "print(json.dumps({'applied':" + + str(apply) + + ",'plan':[[m.app_label,m.name] for m,_ in plan]}))", + ] + ) + + +def stop_migrator(step, image): + name = hosted.WEB + "-overall-migrate" + result = hosted.docker("inspect", name, capture_output=True, text=True, timeout=15) + if result.returncode: + return + container = json.loads(result.stdout)[0] + labels = container["Config"].get("Labels") or {} + require( + container["Image"] == image + and labels.get(hosted.LABEL) == hosted.IDENTITY + and labels.get(STEP_LABEL) == step, + "Имя migrator занято чужим контейнером.", + ) + hosted.docker( + "stop", + "--time", + "5", + container["Id"], + capture_output=True, + timeout=15, + check=True, + ) + hosted.docker( + "rm", "-f", container["Id"], capture_output=True, timeout=15, check=True + ) + + +def migrate(current, sources, *, apply, step, environment_file=None, log=None): + output = ( + {"capture_output": True, "text": True} + if log is None + else {"stdout": log, "stderr": hosted.subprocess.STDOUT} + ) + try: + result = hosted.docker( + "run", + "--rm", + "--name", + hosted.WEB + "-overall-migrate", + "--label", + STEP_LABEL + "=" + step, + *overlay_args(current, sources), + *app_args(current, environment_file), + "python", + "-B", + "-c", + migration_script(apply), + timeout=180, + **output, + ) + except BaseException: + stop_migrator(step, current["Image"]) + raise + require( + result.returncode == 0, + "План/миграция рейтинга не прошли проверку; подробности в локальном журнале.", + ) + return json.loads(result.stdout.splitlines()[-1]) if log is None else None + + +def case_snapshot(container): + script = "\n".join( + [ + "import django,hashlib,json;django.setup()", + "from partner_programs.models import PartnerProgram,PartnerProgramProject", + "from partner_programs.services.results import results_data", + "from users.models import CustomUser", + "program=PartnerProgram.objects.get(pk=1)", + "data=results_data(program,{'search':'','case_scope':'all','scope':'all','order':'desc','status':'all'})", + "data.pop('generated_at')", + "data['submitted']=list(PartnerProgramProject.objects.filter(partner_program=program)" + ".order_by('pk').values_list('pk','project_id','submitted'))", + "data['users_count']=CustomUser.objects.count()", + "print(json.dumps({'sha256':hashlib.sha256(json.dumps(data,sort_keys=True,default=str)" + ".encode()).hexdigest(),'scoring_mode':program.evaluation_scoring_mode," + "'team_count':len(data['submitted'])}))", + ] + ) + result = hosted.docker( + "exec", + container["Id"], + "python", + "-B", + "-c", + script, + capture_output=True, + text=True, + timeout=45, + ) + require(result.returncode == 0, "Не проверена сохранность оценок кейса.") + return json.loads(result.stdout.splitlines()[-1]) + + +def check(bundle): + hosted.scope() + manifest = verify_bundle(bundle) + state = hosted.state_read() + require( + state["phase"] == "active" + and state.get("app_patch_revision") == BASE["base_backend_revision"] + and state.get("weights_revision") == BASE["base_backend_revision"], + "Нужна известная версия пилота после весов.", + ) + if RECORD.exists(): + previous = load_record() + require( + previous["phase"] == "rolled_back", + "Есть активное/незавершенное обновление; используйте status или rollback.", + ) + verify_outside(previous) + current = hosted.candidate() + require( + current and current["State"]["Running"] and current["Image"] == state["image_id"], + "Работающий пилот не соответствует состоянию.", + ) + verify_mounts(current) + weight = json.loads((ROOT / "weights-upgrade.json").read_text()) + require( + weight.get("identity") == "hosted-v2-weights-upgrade" + and weight.get("phase") == "active" + and weight.get("release") == BASE["base_backend_revision"] + and weight.get("candidate_id") == current["Id"], + "Не подтверждена установленная версия весов.", + ) + old_auth = auth.load_record() + require( + old_auth["phase"] == "active" + and old_auth["release"] == BASE["base_frontend_revision"] + and old_auth["new_files"] == BASE["frontend_files"] + and old_auth["protected"].get("format") == auth.SNAPSHOT_FORMAT, + "Не подтверждено исправление входа.", + ) + expected = old_auth["protected"]["containers"][hosted.WEB] + require( + current["Id"] == expected[0] + and current["Image"] == expected[1] + and auth.configuration_fingerprint(current) == expected[3], + "Конфигурация web изменилась после исправления входа.", + ) + for name in (".env", "state.json", "access.txt", "weights-upgrade.json"): + path = ROOT / name + require( + hosted.digest(path) == old_auth["protected"]["files"].get(str(path)), + "Изменен защищенный файл: " + name, + ) + network = hosted.docker_json("network", "inspect", hosted.NETWORK)[0] + require( + (network.get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and {value["Name"] for value in network.get("Containers", {}).values()} + <= {hosted.WEB, hosted.PG}, + "В сети пилота есть посторонние контейнеры.", + ) + require( + hosted.digest(hosted.ROUTES) == state["new_routes_sha256"] + and hosted.digest(hosted.MAPS) == state["new_maps_sha256"], + "Маршруты пилота изменены.", + ) + verify_runtime(current, updated=False) + for name, digest in BASE["frontend_files"].items(): + target = auth.frontend_path(name) + require( + target.is_file() and hosted.digest(target) == digest, + "Frontend изменен: " + name, + ) + for name, digest in manifest["new_files"].items(): + target = auth.frontend_path(name) + if name not in BASE["frontend_files"] and target.exists(): + require( + target.is_file() and hosted.digest(target) == digest, + "Новый путь занят другим файлом.", + ) + memory = dict( + line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() + ) + require( + int(memory["MemAvailable"].split()[0]) >= 1536 * 1024, + "Недостаточно RAM с запасом для dev.", + ) + size = sum((bundle / name).stat().st_size for name in manifest["files"]) + require( + shutil.disk_usage(ROOT).free >= 2 * 2**30 + 2 * size + 128 * 2**20, + "Недостаточно диска с запасом для dev.", + ) + hosted.dev_health() + auth.api_smoke() + auth.verify_live(BASE["frontend_files"]["index.html"]) + plan = migrate(current, bundle / "api", apply=False, step=uuid.uuid4().hex[:12]) + if RECORD.exists(): + require( + container_identity(current) == previous["restored_web"], + "Прежний web изменен после отката.", + ) + return state, current, manifest, plan["plan"] + + +def preserved_configuration(before, after): + require(after["Image"] == before["Image"], "Образ web изменился.") + + def normalized(container): + config = dict(container["Config"]) + for name in ("Hostname", "Image"): + config.pop(name, None) + # Docker CLI proxy defaults can reorder unique env keys between runs. + # Duplicate keys retain list order because their effective value may differ. + environment = config.get("Env") or [] + pairs = [value.split("=", 1) for value in environment] + if all(len(pair) == 2 for pair in pairs) and len({p[0] for p in pairs}) == len( + pairs + ): + config["Env"] = dict(pairs) + config["Labels"] = { + name: value + for name, value in (config.get("Labels") or {}).items() + if name not in (files.REVISION_LABEL, STEP_LABEL) + } + host = { + name: value + for name, value in container["HostConfig"].items() + if name != "Mounts" + } + return {"Config": config, "HostConfig": host} + + require( + normalized(before) == normalized(after), "Изменились env/лимиты/настройки web." + ) + + +def verify_replacement_mounts(before, after, sources): + def items(container): + result = { + mount["Destination"]: (mount["Type"], mount["Source"], mount["RW"]) + for mount in container["Mounts"] + } + require(len(result) == len(container["Mounts"]), "Повторяющийся mount.") + return result + + expected = items(before) + expected.update( + { + "/procollab/" + name: ("bind", str(sources / name), False) + for name in PATCH_FILES + } + ) + require(items(after) == expected, "Изменились данные/logs/readonly overlays web.") + + +def smoke(): + hosted.candidate_health() + files.email_smoke() + headers = { + "Host": "pilot.procollab.ru", + "X-Forwarded-Proto": "https", + "Content-Type": "application/json", + } + code, _, body = hosted.health( + "http://127.0.0.1:18319/backend/api/token/", + headers=headers, + body=json.dumps( + { + "email": "organizer@example.test", + "password": hosted.environment()["PILOT_ACCOUNT_PASSWORD"], + } + ).encode(), + ) + require(code == 200, "Не прошел вход организатора.") + headers["Authorization"] = "Bearer " + json.loads(body)["access"] + url = "http://127.0.0.1:18319/backend/programs/1/management/overall-results/" + code, _, body = hosted.health(url, headers=headers) + data = json.loads(body) if code == 200 else {} + require( + code == 200 + and data.get("formula") == "case_plus_quiz" + and (data.get("case_maximum"), data.get("quiz_maximum"), data.get("maximum")) + == (100, 100, 200), + "Не прошел API общего рейтинга.", + ) + code, _, body = hosted.health(url + "export/", headers=headers) + require( + code == 200 and body.startswith(b"PK\x03\x04"), "Не прошла выгрузка рейтинга." + ) + headers.pop("Authorization") + code, _, _ = hosted.health(url, headers=headers) + require(code in (401, 403), "Общий рейтинг открыт гостю.") + + +def wait_for_web(): + for attempt in range(30): + try: + auth.api_smoke() + return + except Exception: + if attempt == 29: + raise + time.sleep(1) + + +def rollback(record): + if record["phase"] == "rolled_back": + return status() + verify_outside(record) + stop_migrator(record["step"], record["previous_state"]["image_id"]) + old = hosted.inspect(record["old_container_id"]) + require( + auth.configuration_fingerprint(old) == record["old_web"][3] + and old["Image"] == record["old_web"][1], + "Прежний web изменился; откат остановлен.", + ) + probe = hosted.docker( + "container", "inspect", hosted.WEB, capture_output=True, text=True + ) + current = json.loads(probe.stdout)[0] if probe.returncode == 0 else None + if current and current["Id"] != record["old_container_id"]: + labels = current["Config"].get("Labels") or {} + require( + labels.get(STEP_LABEL) == record["step"] + and ( + not record.get("new_web") + or container_identity(current) == record["new_web"] + ), + "Текущий web не принадлежит этому шагу или изменился.", + ) + + def write_rollback(value): + value["restored_web"] = container_identity(hosted.inspect(hosted.WEB)) + save_record(value) + + result = files.rollback(record, record_writer=write_rollback) + restored = hosted.candidate() + require(restored["Id"] == record["old_container_id"], "Не восстановлен прежний web.") + verify_outside(record) + record["restored_web"] = container_identity(restored) + save_record(record) + auth.verify_live(BASE["frontend_files"]["index.html"]) + return {**result, "schema_retained": True, "nginx_changed": False} + + +def status(): + hosted.scope() + record = load_record() + verify_outside(record) + current = hosted.candidate() + if record["phase"] == "prepared": + return { + "phase": "prepared", + "candidate_running": bool(current and current["State"]["Running"]), + "required_next": "rollback", + } + require(current and current["State"]["Running"], "Web пилота не работает.") + if record["phase"] == "active": + require( + container_identity(current) == record["new_web"], "Обновленный web изменился." + ) + require(hosted.state_read() == record["new_state"], "State пилота изменился.") + verify_runtime(current, updated=True) + for name, digest in record["new_files"].items(): + require( + hosted.digest(auth.frontend_path(name)) == digest, + "Frontend изменился: " + name, + ) + auth.verify_live(record["new_files"]["index.html"]) + smoke() + elif record["phase"] == "rolled_back": + require( + container_identity(current) == record["restored_web"], + "Прежний web изменился после отката.", + ) + require( + hosted.state_read() == record["previous_state"], + "State после отката изменился.", + ) + verify_runtime(current, updated=False) + auth.verify_live(BASE["frontend_files"]["index.html"]) + return { + "phase": record["phase"], + "overall_revision": record["release"], + "frontend_revision": record["frontend_revision"] + if record["phase"] == "active" + else BASE["base_frontend_revision"], + "candidate_running": True, + "dev_http": 200, + "database_preserved": hosted.DB_NAME, + "nginx_changed": False, + "backup": record["backup"], + } + + +def apply(bundle): + if RECORD.exists() and load_record()["phase"] == "active": + verify_bundle(bundle) + return status() + state, current, manifest, _plan = check(bundle) + progress("Проверки пройдены. Сохраняю БД, интерфейс и настройки пилота.") + step = uuid.uuid4().hex[:12] + backup = hosted.owned_path(ROOT / "backup" / ("overall-" + step)) + backup.mkdir(mode=0o700) + sources = hosted.owned_path( + ROOT / "releases" / ("overall-" + BASE["backend_revision"]) / "backend" + ) + for name in PATCH_FILES: + target = hosted.owned_path(sources / name) + if target.exists(): + require( + hosted.digest(target) == BASE["target_hashes"][name], + "Целевой release изменен.", + ) + else: + files.copy_atomic(bundle / "api" / name, target) + record = { + "identity": IDENTITY, + "release": BASE["backend_revision"], + "frontend_revision": BASE["frontend_revision"], + "step": step, + "phase": "prepared", + "backup": str(backup), + "old_container_id": current["Id"], + "old_name": hosted.WEB + "-before-" + step, + "old_web": container_identity(current), + "previous_state": state, + "outside": outside_snapshot(current), + "frontend": {}, + "new_files": manifest["new_files"], + "case_before": case_snapshot(current), + } + for name in sorted(manifest["new_files"], key=lambda value: value == "index.html"): + target = auth.frontend_path(name) + before = hosted.digest(target) if target.exists() else None + after = manifest["new_files"][name] + if before != after: + record["frontend"][name] = {"before": before, "after": after} + if before: + files.copy_atomic(target, backup / "frontend" / name, mode=0o600) + files.copy_atomic(ROOT / ".env", backup / "environment.env", mode=0o600) + files.backup_database(backup) + save_record(record) + try: + verify_outside(record) + progress( + "Резервная копия проверена. Применяю только добавочную миграцию 0045.", + ) + with (backup / "migrations.log").open("ab") as log: + migrate( + current, + sources, + apply=False, + step=step, + environment_file=backup / "environment.env", + log=log, + ) + migrate( + current, + sources, + apply=True, + step=step, + environment_file=backup / "environment.env", + log=log, + ) + verify_outside(record) + progress("Миграция завершена. Заменяю только web-контейнер пилота.") + require( + hosted.docker( + "rename", hosted.WEB, record["old_name"], capture_output=True + ).returncode + == 0, + "Прежний web не сохранен.", + ) + require( + hosted.docker( + "stop", + "--time", + "20", + record["old_name"], + capture_output=True, + timeout=45, + ).returncode + == 0, + "Прежний web не остановлен.", + ) + result = hosted.docker( + "run", + "-d", + "--name", + hosted.WEB, + "--restart", + "unless-stopped", + "-p", + "127.0.0.1:18319:8000", + "--label", + files.REVISION_LABEL + "=" + record["release"], + "--label", + STEP_LABEL + "=" + step, + *overlay_args(current, sources), + *app_args(current, backup / "environment.env"), + "daphne", + "-b", + "0.0.0.0", + "-p", + "8000", + "--root-path", + "/backend", + "procollab.asgi:application", + capture_output=True, + text=True, + ) + require(result.returncode == 0, "Новый web не запущен.") + record["candidate_id"] = result.stdout.strip() + save_record(record) + candidate = hosted.candidate() + preserved_configuration(current, candidate) + verify_replacement_mounts(current, candidate, sources) + record["new_web"] = container_identity(candidate) + save_record(record) + wait_for_web() + verify_runtime(candidate, updated=True) + smoke() + require( + case_snapshot(candidate) == record["case_before"], + "Оценки, команды или участники изменились; прежний интерфейс сохраняется.", + ) + verify_outside(record) + progress( + "API, регистрация, приглашения, Excel и прежние оценки проверены. Публикую интерфейс.", + ) + for name in record["frontend"]: + if name != "index.html": + files.copy_atomic(bundle / "frontend" / name, auth.frontend_path(name)) + files.copy_atomic( + bundle / "frontend/index.html", auth.frontend_path("index.html") + ) + auth.verify_live(manifest["new_files"]["index.html"]) + verify_outside(record) + new_state = { + **state, + "app_patch_revision": record["release"], + "app_patch_files": { + **state.get("app_patch_files", {}), + **BASE["target_hashes"], + }, + "overall_revision": record["release"], + "overall_frontend_revision": record["frontend_revision"], + } + hosted.state_write(new_state) + record.update(phase="active", new_state=new_state) + save_record(record) + return status() + except BaseException as error: + try: + hosted.private_write( + backup / "failure.txt", type(error).__name__ + ": " + str(error) + ) + except OSError: + pass + try: + rollback(record) + except BaseException as rollback_error: + raise hosted.Refused( + "Обновление остановлено; автоматический откат не завершен. Не меняйте БД/контейнеры вручную. Backup: " + + str(backup) + + ". Причина: " + + str(rollback_error) + ) from error + raise hosted.Refused( + "Обновление не прошло проверку; прежние web/UI восстановлены, данные и добавочная схема сохранены. Backup: " + + str(backup) + ) from error + + +if __name__ == "__main__": + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "apply", "rollback", "status")) + parser.add_argument("--bundle", type=Path) + args = parser.parse_args() + try: + with hosted.locked(): + if args.command == "status": + result = status() + elif args.command == "rollback": + hosted.scope() + result = rollback(load_record()) + else: + require(args.bundle is not None, "Укажите --bundle.") + bundle = args.bundle.resolve() + if args.command == "apply": + result = apply(bundle) + else: + _, _, _, plan = check(bundle) + result = { + "changes_applied": False, + "backend_revision": BASE["backend_revision"], + "frontend_revision": BASE["frontend_revision"], + "migration_count": len(plan), + "image_reused": True, + "dev_http": 200, + } + except (hosted.Refused, OSError, ValueError) as error: + sys.exit("ОСТАНОВЛЕНО: " + str(error)) + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") diff --git a/deploy/pilot/upgrade_weights.py b/deploy/pilot/upgrade_weights.py new file mode 100644 index 00000000..251ae6e2 --- /dev/null +++ b/deploy/pilot/upgrade_weights.py @@ -0,0 +1,585 @@ +"""Процентные веса только для действующего hosted-v2, без замены DB/образа/nginx.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys +import time +import uuid + +sys.dont_write_bytecode = True +import hosted # noqa: E402 +import upgrade_invites as files # noqa: E402 + +ROOT = hosted.NEW +RECORD = ROOT / "weights-upgrade.json" +BASE = json.loads((Path(__file__).parent / "weights_base.json").read_text()) +PATCH_FILES = tuple(BASE["patches"]) +IDENTITY = "hosted-v2-weights-upgrade" +MIGRATIONS = { + ("partner_programs", "0044_partnerprogram_evaluation_scoring_mode"), + ("project_rates", "0012_criteria_weight_percent_and_more"), +} +require = hosted.require + + +def save_record(record): + text = json.dumps(record, ensure_ascii=False, indent=2) + hosted.private_write(Path(record["backup"]) / "record.json", text) + hosted.private_write(RECORD, text) + + +def load_record(): + record = json.loads(RECORD.read_text()) + require(record.get("identity") == IDENTITY, "Чужое состояние обновления весов.") + require( + re.fullmatch(r"[a-f0-9]{40}", record.get("release", "")), + "Неизвестная revision весов.", + ) + require( + re.fullmatch( + re.escape(hosted.WEB) + r"-before-[a-f0-9]{12}", record.get("old_name", "") + ), + "Чужое имя прежнего контейнера.", + ) + backup = Path(record["backup"]) + require( + backup.resolve().is_relative_to(ROOT / "backup") and not backup.is_symlink(), + "Чужой каталог резервной копии.", + ) + return record + + +def validate_backend(hashes, manifest): + for name, expected in {**BASE["patches"], **BASE["protected"]}.items(): + require(hashes.get(name) == expected, "Код пилота изменен: " + name) + for name, expected in BASE["protected"].items(): + require( + manifest["files"].get("api/" + name) == expected, + "Пакет меняет зависимости, профиль или приглашения: " + name, + ) + require( + all("api/" + name in manifest["files"] for name in PATCH_FILES), + "В пакете отсутствуют файлы весов.", + ) + return {name: manifest["files"]["api/" + name] for name in PATCH_FILES} + + +def current_fingerprint(): + names = [*BASE["patches"], *BASE["protected"]] + script = ( + "import hashlib,json;from pathlib import Path;names=" + + json.dumps(names) + + ";print(json.dumps({n:hashlib.sha256(Path(n).read_bytes()).hexdigest() " + "if Path(n).is_file() else None for n in names}))" + ) + result = hosted.docker( + "exec", + hosted.WEB, + "python", + "-c", + script, + capture_output=True, + text=True, + timeout=30, + ) + require(result.returncode == 0, "Не прочитан fingerprint работающего пилота.") + return json.loads(result.stdout) + + +def verify_mounts(current): + mounts = {m["Destination"]: m for m in current["Mounts"]} + normal = { + "/procollab/pilot-data": ROOT / "data", + "/procollab/log": ROOT / "logs", + } + require( + set(mounts) == set(normal) | {"/procollab/" + n for n in files.PATCH_FILES}, + "Неожиданные mounts; существующий контейнер не заменяется.", + ) + for destination, source in normal.items(): + require( + mounts[destination]["Source"] == str(source) and mounts[destination]["RW"], + "Изменен каталог данных пилота.", + ) + for relative in files.PATCH_FILES: + mount = mounts["/procollab/" + relative] + path = hosted.owned_path(mount["Source"]) + require( + mount["Type"] == "bind" + and not mount["RW"] + and path.is_file() + and hosted.digest(path) == BASE["protected"][relative], + "Изменено установленное исправление приглашений.", + ) + + +def dev_snapshot(): + result = {} + for name in ("api-web-1", "api-redis-1", "api_celery", hosted.OLD_WEB, hosted.DB): + container = hosted.inspect(name) + result[name] = [ + container["Id"], + container["Image"], + container["State"]["StartedAt"], + ] + return result + + +def verify_outside(record): + require( + dev_snapshot() == record["outside"], "Состояние dev/старого пилота изменилось." + ) + require( + hosted.digest(hosted.ROUTES) == record["routes"] + and hosted.digest(hosted.MAPS) == record["maps"], + "Маршруты nginx изменились снаружи.", + ) + return hosted.dev_health() + + +def check(bundle): + hosted.scope() + state = hosted.state_read() + require(state["phase"] == "active", "Нужен действующий hosted-v2.") + require( + state.get("app_patch_revision") == BASE["base_app_revision"], + "Нужна известная версия пилота с исправлением приглашений.", + ) + if RECORD.exists(): + require( + load_record()["phase"] == "rolled_back", + "Есть незавершенное/активное обновление весов.", + ) + current = hosted.candidate() + require( + current and current["State"]["Running"] and current["Image"] == state["image_id"], + "Работающий пилот не соответствует состоянию.", + ) + verify_mounts(current) + old = files.load_record() + require( + old["phase"] == "active" + and old["release"] == BASE["base_app_revision"] + and old.get("candidate_id") == current["Id"], + "Не подтверждено установленное исправление приглашений.", + ) + require( + hosted.digest(files.frontend_path("index.html")) + == old["frontend"]["index.html"]["after"], + "Frontend пилота изменен снаружи.", + ) + network = hosted.docker_json("network", "inspect", hosted.NETWORK)[0] + require( + (network.get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and {c["Name"] for c in network.get("Containers", {}).values()} + <= {hosted.WEB, hosted.PG}, + "В сети пилота есть посторонние контейнеры.", + ) + require( + hosted.digest(hosted.ROUTES) == state["new_routes_sha256"] + and hosted.digest(hosted.MAPS) == state["new_maps_sha256"], + "Маршруты пилота изменены снаружи.", + ) + manifest = hosted.verify_bundle(bundle) + hashes = validate_backend(current_fingerprint(), manifest) + memory = dict( + line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() + ) + require( + int(memory["MemAvailable"].split()[0]) >= 1536 * 1024, + "Недостаточно RAM с запасом для dev.", + ) + require( + shutil.disk_usage(ROOT).free >= 2 * 2**30 + 256 * 2**20, + "Недостаточно диска с запасом 2 GiB для dev.", + ) + hosted.dev_health() + hosted.candidate_health() + return state, current, manifest, hashes + + +def overlay_args(current, sources): + mounts = [] + for mount in current["Mounts"]: + if mount["Destination"] in ("/procollab/pilot-data", "/procollab/log"): + continue + mounts.extend( + [ + "--mount", + f"type=bind,src={mount['Source']},dst={mount['Destination']},readonly", + ] + ) + for relative in PATCH_FILES: + mounts.extend( + [ + "--mount", + f"type=bind,src={sources/relative},dst=/procollab/{relative},readonly", + ] + ) + return mounts + + +def migration_script(*, apply): + # План строится для всех leaves: любой посторонний pending migration блокирует + # обновление до записи в DB. Старые scores/пользователи не пересоздаются. + return "\n".join( + [ + "import django,json; django.setup()", + "from django.db import connection", + "from django.db.migrations.executor import MigrationExecutor", + "db=connection.settings_dict", + f"if not (db['NAME']=={hosted.DB_NAME!r} and db['USER']=={hosted.DB_ROLE!r} and db['HOST']=='db'): raise RuntimeError('Unexpected pilot database')", + "with connection.cursor() as cursor:", + " cursor.execute(\"SET lock_timeout = '10s'\")", + " cursor.execute(\"SET statement_timeout = '120s'\")", + "executor=MigrationExecutor(connection)", + "targets=executor.loader.graph.leaf_nodes()", + "plan=executor.migration_plan(targets)", + f"allowed={MIGRATIONS!r}", + "if not all(not backwards and (m.app_label,m.name) in allowed for m,backwards in plan): raise RuntimeError('Unexpected migration plan')", + "if not all(type(op).__name__ in ('AddField','AddConstraint') for m,_ in plan for op in m.operations): raise RuntimeError('Non-additive migration')", + "executor.migrate(targets)" if apply else "# read-only plan", + "print(json.dumps({'applied': " + + str(apply) + + ", 'plan': [[m.app_label,m.name] for m,_ in plan]}))", + ] + ) + + +def migrate(current, sources, *, apply, log): + name = hosted.WEB + "-weights-migrate" + step_label = "io.procollab.pilot.weights.step" + step = sources.parent.name + try: + result = hosted.docker( + "run", + "--rm", + "--name", + name, + "--label", + step_label + "=" + step, + *overlay_args(current, sources), + *hosted.container_args(current["Image"], hosted.NETWORK), + "python", + "-c", + migration_script(apply=apply), + stdout=log, + stderr=hosted.subprocess.STDOUT, + timeout=180, + ) + except BaseException: + # Завершение docker CLI само по себе не останавливает контейнер. При + # timeout/Ctrl-C останавливаем только созданный этим шагом migrator. + probe = hosted.docker("inspect", name, capture_output=True, text=True, timeout=15) + if probe.returncode == 0: + container = json.loads(probe.stdout)[0] + labels = container["Config"].get("Labels") or {} + require( + container["Image"] == current["Image"] + and labels.get(hosted.LABEL) == hosted.IDENTITY + and labels.get(step_label) == step, + "Имя migrator занято другим контейнером; он не изменяется.", + ) + hosted.docker( + "stop", "--time", "5", container["Id"], capture_output=True, timeout=15 + ) + hosted.docker("rm", "-f", container["Id"], capture_output=True, timeout=15) + raise + require( + result.returncode == 0, + "Проверка/миграции весов не прошли; подробности в локальном журнале.", + ) + + +def weighted_program_count(): + pg = hosted.inspect(hosted.PG) + require( + (pg["Config"].get("Labels") or {}).get(hosted.LABEL) == hosted.IDENTITY + and pg["State"]["Running"], + "Чужой или остановленный PostgreSQL пилота.", + ) + + def scalar(sql): + result = hosted.docker( + "exec", + hosted.PG, + "sh", + "-ec", + 'export PGPASSWORD="${POSTGRES_PASSWORD:-}"; exec psql -X -v ON_ERROR_STOP=1 -U pilot_owner -d ' + + hosted.DB_NAME + + ' -At -c "$1"', + "pilot-weights", + sql, + capture_output=True, + text=True, + timeout=30, + ) + require(result.returncode == 0, "Не проверено состояние весов в БД пилота.") + return int(result.stdout.strip()) + + exists = scalar( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='partner_programs_partnerprogram' AND column_name='evaluation_scoring_mode'" + ) + return ( + scalar( + "SELECT COUNT(*) FROM partner_programs_partnerprogram WHERE evaluation_scoring_mode='weighted_percent'" + ) + if exists + else 0 + ) + + +def rollback(record): + require( + weighted_program_count() == 0, + "Веса уже включены: прежний API их не понимает. Нужен откат на совместимую версию, данные сохраняются.", + ) + result = files.rollback(record, record_writer=save_record) + result.update(schema_retained=True, nginx_changed=False) + return result + + +def smoke(): + hosted.candidate_health() + files.email_smoke() + headers = { + "Host": "pilot.procollab.ru", + "X-Forwarded-Proto": "https", + "Content-Type": "application/json", + } + code, _, body = hosted.health( + "http://127.0.0.1:18319/backend/api/token/", + headers=headers, + body=json.dumps( + { + "email": "organizer@example.test", + "password": hosted.environment()["PILOT_ACCOUNT_PASSWORD"], + } + ).encode(), + ) + require(code == 200, "Вход организатора не прошел проверку.") + headers["Authorization"] = "Bearer " + json.loads(body)["access"] + code, _, body = hosted.health( + "http://127.0.0.1:18319/backend/programs/1/management/criteria/", headers=headers + ) + data = json.loads(body) if code == 200 else {} + require( + code == 200 + and data.get("scoring_mode") == "legacy" + and all("weight_percent" in c for c in data.get("criteria", [])), + "Новый API критериев не прошел проверку; прежний расчет должен сохраняться.", + ) + + +def apply(bundle): + state, current, manifest, hashes = check(bundle) + print("Проверки пройдены. Сохраняю резервную копию БД и frontend пилота.", flush=True) + identifier = uuid.uuid4().hex[:12] + backup = hosted.owned_path(ROOT / "backup" / ("weights-" + identifier)) + backup.mkdir(mode=0o700) + sources = hosted.owned_path( + ROOT / "releases" / ("weights-" + manifest["api_revision"]) / "backend" + ) + for relative in PATCH_FILES: + files.copy_atomic( + bundle / "api" / relative, hosted.owned_path(sources / relative) + ) + record = { + "identity": IDENTITY, + "release": manifest["api_revision"], + "phase": "prepared", + "backup": str(backup), + "old_container_id": current["Id"], + "old_name": hosted.WEB + "-before-" + identifier, + "previous_state": state, + "frontend": {}, + "outside": dev_snapshot(), + "routes": hosted.digest(hosted.ROUTES), + "maps": hosted.digest(hosted.MAPS), + } + for source in sorted((bundle / "frontend").rglob("*")): + if source.is_file(): + relative = str(source.relative_to(bundle / "frontend")) + target = files.frontend_path(relative) + before = hosted.digest(target) if target.exists() else None + after = hosted.digest(source) + if before != after: + record["frontend"][relative] = {"before": before, "after": after} + if before: + files.copy_atomic(target, backup / "frontend" / relative, mode=0o600) + files.backup_database(backup) + save_record(record) + print( + "Резервная копия проверена. Проверяю план и применяю две добавочные миграции.", + flush=True, + ) + try: + with (backup / "migrations.log").open("ab") as log: + migrate(current, sources, apply=False, log=log) + migrate(current, sources, apply=True, log=log) + print("Миграции завершены. Заменяю только web-контейнер пилота.", flush=True) + require( + hosted.docker( + "rename", hosted.WEB, record["old_name"], capture_output=True + ).returncode + == 0, + "Прежний web не сохранен.", + ) + require( + hosted.docker( + "stop", + "--time", + "20", + record["old_name"], + capture_output=True, + timeout=45, + ).returncode + == 0, + "Прежний web не остановлен.", + ) + result = hosted.docker( + "run", + "-d", + "--name", + hosted.WEB, + "--restart", + "unless-stopped", + "-p", + "127.0.0.1:18319:8000", + "--label", + files.REVISION_LABEL + "=" + record["release"], + *overlay_args(current, sources), + *hosted.container_args(current["Image"], hosted.NETWORK), + "daphne", + "-b", + "0.0.0.0", + "-p", + "8000", + "--root-path", + "/backend", + "procollab.asgi:application", + capture_output=True, + text=True, + ) + require(result.returncode == 0, "Обновленный web не запущен.") + record["candidate_id"] = result.stdout.strip() + save_record(record) + deadline = time.monotonic() + 60 + for attempt in range(30): + try: + smoke() + break + except Exception: + if attempt == 29 or time.monotonic() >= deadline: + raise + time.sleep(1) + print( + "Вход, регистрация, приглашения и API критериев проверены. Публикую интерфейс.", + flush=True, + ) + for relative in record["frontend"]: + if relative != "index.html": + files.copy_atomic( + bundle / "frontend" / relative, files.frontend_path(relative) + ) + files.copy_atomic( + bundle / "frontend/index.html", files.frontend_path("index.html") + ) + password = ( + (ROOT / "access.txt") + .read_text() + .split("Пароль входа: ", 1)[1] + .splitlines()[0] + ) + code, _, body = hosted.health( + hosted.ORIGIN + "/", + headers={ + "Authorization": "Basic " + + base64.b64encode(("pilot:" + password).encode()).decode() + }, + ) + require( + code == 200 + and hashlib.sha256(body).hexdigest() + == manifest["files"]["frontend/index.html"], + "Опубликованный frontend не прошел проверку.", + ) + verify_outside(record) + hosted.state_write( + { + **state, + "app_patch_revision": record["release"], + "app_patch_files": {**state.get("app_patch_files", {}), **hashes}, + "weights_revision": record["release"], + } + ) + record["phase"] = "active" + save_record(record) + except BaseException as error: + try: + hosted.private_write( + backup / "failure.txt", type(error).__name__ + ": " + str(error) + ) + except OSError: + pass + rollback(record) + raise hosted.Refused( + "Обновление не прошло проверку; прежний web/UI восстановлены, данные и добавочная схема сохранены. Журнал: " + + str(backup) + ) + return { + "phase": "active", + "weights_revision": record["release"], + "image_reused": True, + "database_preserved": hosted.DB_NAME, + "nginx_changed": False, + "dev_http": 200, + "backup": str(backup), + "weights_enabled": False, + } + + +if __name__ == "__main__": + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "apply", "rollback", "status")) + parser.add_argument("--bundle", type=Path) + args = parser.parse_args() + try: + with hosted.locked(): + if args.command in ("rollback", "status"): + hosted.scope() + record = load_record() + if args.command == "rollback": + result = rollback(record) + else: + current = hosted.candidate() + result = { + "phase": record["phase"], + "weights_revision": record["release"], + "candidate_running": bool( + current and current["State"]["Running"] + ), + } + else: + require(args.bundle is not None, "Укажите --bundle.") + if args.command == "apply": + result = apply(args.bundle.resolve()) + else: + _, _, manifest, _ = check(args.bundle.resolve()) + result = { + "changes_applied": False, + "target_revision": manifest["api_revision"], + "image_reused": True, + "migration_count": 2, + "dev_http": 200, + } + except (hosted.Refused, OSError, ValueError) as error: + sys.exit("ОСТАНОВЛЕНО: " + str(error)) + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") diff --git a/deploy/pilot/weights_base.json b/deploy/pilot/weights_base.json new file mode 100644 index 00000000..b8425a83 --- /dev/null +++ b/deploy/pilot/weights_base.json @@ -0,0 +1,37 @@ +{ + "base_source_revision": "95f8375e40f2263b6c1eb727f8ade2e2e95fa403", + "base_app_revision": "f7f800e15085555300419d87308b6fdf02e3a945", + "patches": { + "partner_programs/admin.py": "9f734b5b1f1baf894b9fa7addf4e17fb5aaebde291f4094108886a44f42aedbf", + "partner_programs/admin_criteria.py": null, + "partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py": null, + "partner_programs/models.py": "9328da5c4704866dd0489fcde9809977dc6da05513039667ec13eddd0ea91145", + "partner_programs/services/management_criteria.py": "0d60a976f75aab4540ff4b0412936462b37c3bdcf989d2dd84ce56d6f9e1fab1", + "partner_programs/services/results.py": "7b448cc40b950c6c822a61ee6bd364c57c1f4cd019f8d4c31f757c02fd8979aa", + "partner_programs/services/results_excel.py": "d91a1ea6945f197b199a226632d637a19a0b92a3b19ff1f948877ba5a54e100e", + "project_rates/admin.py": "8dea9a74b9513e2fb3a8046aba6362f8fe6b4398f23eda494f6e8c759667ad2c", + "project_rates/criteria_policy.py": "1a6fdd73da2c8aa689f307ff243162b030386698980070bb955c18bcad366662", + "project_rates/evaluation_math.py": "3e591cb9522e373a4f0b6ec090c094ca5bb680d458f3156843449593725ba292", + "project_rates/migrations/0012_criteria_weight_percent_and_more.py": null, + "project_rates/models.py": "d0f8b39197c7803b77ebd94bf33f85c0f8f1c8c880e84d8a6decf9dbfe89a900", + "project_rates/scoring.py": null, + "project_rates/serializers.py": "9f9d39351070789964e4f3008c6ce13f7c0aa27598e643b6c697d4009703e8d7", + "project_rates/services.py": "7e11cc291b5bc48068a0db31961e6bdcb6b8b99c436d69196181844023bb2a7b", + "project_rates/solution_evaluation.py": "7caad9c059426a707739a0db88dd26c8ae7818ca1a659f61fb6e03b6a552df01", + "project_rates/solution_models.py": "18d1425aab284df38dda08e11df8b6f646db40fcb7b1507f6ef35f80ba970776", + "project_rates/solution_results.py": "2459a41e7b4ce98295367a252ebcca6f49315eb1c6a80fe1a2cf477cc6d9db69", + "project_rates/typing.py": "75e26f1c6b1d0c4919641e9367504ed7a360a21da624541d03b931eaed4ea6aa", + "templates/partner_programs/admin/evaluation_criteria.html": null + }, + "protected": { + "pyproject.toml": "0d9e46cce81c1fd211ae90c73394119d8bb070885f34d9ee2b64d49c6472ffe7", + "poetry.lock": "fd66c215bc4484d4056bdf9aaa1f31867eda35ccbc2a5e50e223491ecadd3f67", + "procollab/settings_pilot.py": "9de066782c3ff888f5be3dde0b3f3500377caecf8a1c7a941d1d23646b8038bb", + "procollab/settings_pilot_inline.py": "6a311d48ee93d7978a6d4e68e5dcf5919023d096b56ee453f67850752ef27366", + "procollab/white_label.py": "23235bf15e5d7574f86663271b62d0bb739fb4e7e64a3cb366c0d2c2e3488d16", + "users/filters.py": "0807e0b38b86cdbef47770ed3d0d0ee4846d453e279e356fa67d3027f0514063", + "users/serializers.py": "e43e9ec47e70d47b01df5750128258f1a70167ab0fc91b13a50be7ca16cdcfb7", + "users/views.py": "59e3af1f80c0d31f5c4cf86d4966d19c637786305a055c6cc57c8089f49e9c17", + "users/white_label_candidates.py": "6995344df5ae466f194125f04c3152ba78a6a40819239754b9c416877fcd8831" + } +} diff --git a/deploy/white-label.env.example b/deploy/white-label.env.example new file mode 100644 index 00000000..344661a3 --- /dev/null +++ b/deploy/white-label.env.example @@ -0,0 +1,20 @@ +# Шаблон одного отдельного экземпляра. Значения ниже вымышленные. +# Подключение к БД, файлы и SMTP настраиваются отдельно для экземпляра. +WHITE_LABEL_ENABLED=True +WHITE_LABEL_PROGRAM_ID=1 +WHITE_LABEL_BRAND_NAME=Кейс-чемпионат клиента +WHITE_LABEL_LOGO_URL=https://championship.example.test/logo.svg +WHITE_LABEL_FAVICON_URL= +WHITE_LABEL_LOGO_BACKGROUND=transparent +WHITE_LABEL_FRONTEND_URL=https://championship.example.test +WHITE_LABEL_API_HOST=api.championship.example.test +# Сессионные cookies интерфейса принадлежат текущему frontend-host. +# Демо-наполнение не разрешается в клиентской/боевой среде. +WHITE_LABEL_DEMO_ALLOWED=False +EXPERT_WORKSPACE_FRONTEND_URL=https://championship.example.test +DATABASE_NAME=client_championship +DATABASE_USER=client_championship +DATABASE_PASSWORD=replace-with-instance-password +DATABASE_HOST=replace-with-instance-database-host +DATABASE_PORT=5432 +EMAIL_USER=no-reply@championship.example.test diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 6629b7e5..25cebdf6 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -1,5 +1,7 @@ +Изменение 1.22.0: optional weight_percent у критериев, scoring_mode у результатов и contributions у строки решения. Режим weighted_percent нормализует каждый числовой критерий по его шкале и процентному весу, максимум равен 100; legacy сохраняет прежнюю сумму. Старые payload оценок, required поля, URL и примеры совместимы. Веса и режим входят в revision и фиксируются первой оценкой; правила Project и ProgramSolution используют общую формулу. Для внедрения нужны добавочные миграции программы/критерия и backend до Angular. + # C00 Контракт решения в программе Изменение 1.21.0: публичные GET охватывают уже сданные конкурсные решения КЧ/хакатона после окончания программы и установки marker существующим publisher. Неконкурсные сохраненные материалы остаются без сдачи. DTO/156 schemas/59 operations сохраняются;6 новых независимых сценариев, итого305. Временный сценарий published_solution_list_competitive_pending_rule намеренно меняется404→200 с пустой страницей: весь конкурсный список больше не закрыт из-за отдельного вопроса о draft. Остальные298 старых сценариев структурно неизменны. Несданные конкурсные работы и архивы не выдаются, окончательная eligibility draft все еще ожидает ответа. Нового Angular UI, схемы БД или Project нет. diff --git a/docs/contracts/program-solutions-v1/examples.json b/docs/contracts/program-solutions-v1/examples.json index e0d4745c..02eb5ff3 100644 --- a/docs/contracts/program-solutions-v1/examples.json +++ b/docs/contracts/program-solutions-v1/examples.json @@ -1,5 +1,5 @@ { - "contract_version": "1.21.0", + "contract_version": "1.22.0", "description": "Независимые примеры, не последовательность операций; фиктивные данные.", "scenarios": [ { @@ -12645,6 +12645,393 @@ "errors": [], "meta": {} } + }, + { + "name": "solution_results_weighted_percent", + "operation_id": "getSolutionResults", + "status": 200, + "response": { + "program_id": 12, + "program_name": "Кейс-чемпионат PROCOLLAB", + "generated_at": "2026-10-24T20:00:00Z", + "required_evaluators": 2, + "maximum": 100, + "has_numeric_criteria": true, + "case_options": [ + "Цифровой сервис" + ], + "groups": [ + { + "case_name": "Цифровой сервис", + "solutions": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "completed": 1, + "required": 2, + "evaluation_complete": false, + "ready": false, + "score": 54, + "place": null, + "averages": { + "21": 0, + "22": 4.5 + }, + "experts": [ + { + "user_id": 91, + "name": "Эксперт программы", + "email": "expert@local.example", + "avatar": null, + "complete": true, + "total": 54, + "values": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + } + ], + "contributions": { + "21": 0, + "22": 54 + } + } + ] + } + ], + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "weight_percent": null + } + ], + "scoring_mode": "weighted_percent" + } + }, + { + "name": "solution_rating_weighted_percent", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5", + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False", + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + "weight_percent": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } } ] } diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index 02f502a0..34f3b203 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -3,7 +3,7 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "PROCOLLAB Program Solutions C00", - "version": "1.21.0", + "version": "1.22.0", "description": "Контракт решений в программах для реализации и моков BE01–BE09. API base URL задается окружением. snake_case на проводе. Старые API не меняются." }, "servers": [ @@ -11549,6 +11549,15 @@ ], "maxLength": 1000, "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12279,6 +12288,15 @@ "number", "null" ] + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12396,6 +12414,12 @@ "items": { "$ref": "#/components/schemas/ResultExpert" } + }, + "contributions": { + "type": "object", + "additionalProperties": { + "type": "number" + } } }, "required": [ @@ -12481,6 +12505,13 @@ "items": { "$ref": "#/components/schemas/ResultCriterion" } + }, + "scoring_mode": { + "type": "string", + "enum": [ + "legacy", + "weighted_percent" + ] } }, "required": [ diff --git a/docs/overall-case-quiz-ranking.md b/docs/overall-case-quiz-ranking.md new file mode 100644 index 00000000..84034cdc --- /dev/null +++ b/docs/overall-case-quiz-ranking.md @@ -0,0 +1,342 @@ +# Общий рейтинг кейс-чемпионата + +## Требование и текущий контур + +Организатору нужен один итог команды: оценка кейса от 0 до 100 плюс результат +квиза от 0 до 100. Максимум — 200. Пользователь 12 ноября подтвердил простое +сложение и доступ только организатору. Отдельных долей этапов 70/30 нет. +Проценты критериев остаются внутренней частью оценки кейса. + +База реализации — ветка пилота API, runtime до изменений `7657082`. +`partner_programs/services/results.py:results_data` уже считает экспертные +оценки и готовность сданного Project; `project_rates/evaluation_math.py` +содержит общий расчет и места 1, 1, 3. Проект связан с программой через +`PartnerProgramProject`, уникальный по программе и проекту. Это существующая +команда чемпионата; Application/Team/Submission и ProgramSolution сюда не входят. +Квиз с командной попыткой пока не реализован и остается отдельной задачей +после согласования дизайна. Курсовые ответы не являются результатом этого квиза. + +## Расчет и состояния + +- `итог = кейс + квиз`, без изменения масштаба и без дополнительных коэффициентов. +- Кейс использует готовый `weighted_percent`: официальные баллы из существующего + расчета, округленные до сотых. Нужны сданный проект и требуемое число полных + экспертных оценок. Предварительный балл кейса можно видеть отдельно. +- Legacy-режим не превращается автоматически в шкалу 0–100. До настройки весов + новый рейтинг явно сообщает, что расчет кейса не настроен; прежний рейтинг + кейсов продолжает работать по старому контракту. +- Квиз передает окончательный нормализованный балл 0–100 с точностью до сотых. + Правильность, частичные ответы, скорость и нормализация относятся к модулю + квиза. Общий рейтинг не пересчитывает ответы и время. +- Пока любой этап не завершен, итог и место — `null`. Отсутствующий балл + не заменяется нулем; настоящий ноль является завершенным результатом. +- Все команды программы видны одной таблицей, включая еще не сдавших кейс. + Общие места считаются по всем готовым командам программы, до поиска/фильтров; + равные итоги делят место: 1, 1, 3. Имена/ID задают только стабильный порядок строк. +- Состав команды и правила сдачи/приглашений сохраняются. Результат относится + к паре программа × проект, а не к лидеру или отдельному участнику. + +## Результат квиза и границы записи + +Новая добавочная `ProgramQuizOutcome` хранит программу, проект, уникальный +серверный идентификатор попытки, балл и время получения результата. Пара +программа × проект уникальна. Результат сохраняется при удалении и повторном +создании связи участия: это не дает вторую попытку того же проекта. Удаление +самого проекта/программы использует обычный cascade. + +Внутренний сервис `record_quiz_outcome` вызывается будущим серверным модулем +квиза после завершения попытки. Он проверяет текущую связь с программой, +использует существующий порядок блокировок `lock_team`, принимает только +конечные баллы 0–100 и глобальный непрозрачный идентификатор попытки. Повтор +того же результата идемпотентен; другая попытка/другой балл конфликтуют. +Одна попытка не может использоваться для другой команды. + +Публичного POST/PUT для загрузки баллов нет. Участник, эксперт и организатор +не задают балл квиза вручную. В Django admin результат доступен только для +чтения. Исправление/пересчет завершенной попытки требует отдельного явного +контракта и в эту задачу не входит. + +## API и права + +Новые organizer endpoints: + +- `GET /programs/{id}/management/overall-results/` — общий расчет и строки. +- `GET /programs/{id}/management/overall-results/export/` — тот же расчет в XLSX. + +Права берутся из существующего `managed_program`: manager своей программы либо +staff. Гость/участник/эксперт/чужой manager не получают данные. Ответы private, +no-store. Django admin дополнительно проверяет модельные права программы. +Новые действия чтения/экспорта доступны и через карточку программы в admin, +с общей бизнес-логикой. Остальные endpoints/поля не меняются. + +Фильтры совместимы по смыслу с `ResultsQuery`: поиск, кейс, complete/pending, +порядок, scope экспорта current/all. Невалидный фильтр — 400; неизвестная +программа — 404; недостаточные права — 403; неподдерживаемый контур решения +или неконкурсная программа — 409. Нельзя смешать Project и ProgramSolution. + +Ответ содержит программу, дату расчета, максимумы 100/100/200, признак настройки +кейса, число готовых команд и `projects`. Строка содержит ID связи и проекта, +название команды, кейс, баллы/готовность каждого этапа, счетчик экспертов, +общий итог, место и явный статус ожидания. Персональные оценки/почты экспертов +и идентификатор попытки в этот ответ не включаются. + +## Интерфейс и выгрузка + +Существующий экран результатов кейса сохраняется. Для Angular готовится +дополнение существующего раздела оценивания: общий рейтинг с колонками места, +команды, кейса, кейса из 100, квиза из 100, итога из 200 и состояния. Нужны +loading/empty/error/no results, ожидание этапов и корректный настоящий ноль. +Новый Angular UI требует отдельного конкретного дизайн-превью и подтверждения; +до этого независимы серверный расчет, API, admin и XLSX. Экран квиза не меняется. + +XLSX использует единый серверный snapshot: итоговая формула только для готовых +строк, неподготовленные итоги/места пустые. Названия записываются как текст, +чтобы исключить исполнение формул из пользовательских строк. Arial 10, +белый фон, жирная только первая строка, закреплена только шапка, без закрепления +столбцов и служебных листов. + +## Acceptance criteria и план проверки + +- O01: готовые этапы 80 и 90 дают 170 из 200; 100+100 дают 200; 0+0 дают 0 + и участвуют в рейтинге. Проверки математики/API/XLSX. +- O02: нет квиза, не сдан кейс или не хватает полной экспертной оценки — + итог/место пусты, отдельные готовые баллы сохранены. Legacy не нормализуется + скрыто. Проверки API с реальными данными и регрессия обычных результатов. +- O03: проект в двух программах не смешивает результаты; повторное участие + того же проекта сохраняет единственную попытку. Проверки сервиса и БД. +- O04: повтор записи той же попытки/балла не меняет запись; конфликтующий + повтор и повтор чужой попытки отклоняются. Диапазон/NaN/Infinity/лишняя точность + отклоняются; уникальность и диапазон проверяются PostgreSQL. +- O05: manager своей программы/staff читают; остальные роли, гость и чужой + manager не получают API/export/admin. Прямые HTTP/negative tests. +- O06: равные итоги делят место, поиск/фильтр не меняют глобальные места; + ожидающие команды не получают место. Tests на нескольких кейсах/командах. +- O07: XLSX повторяет баллы/места/статусы API, считает простую сумму готовых + этапов и сохраняет настоящий ноль. Проверки книги/формул/инъекций/оформления. +- O08: Django admin и API используют один расчет; admin не разрешает ручное + изменение результата квиза. Tests прав, GET/POST и отображения. +- O09: добавляется только новая таблица. Миграция не меняет существующие + программы/оценки/участников; прежние API/расчет/веса проходят регрессию. +- O10: число запросов не растет на один запрос для каждой команды; конкурентная + запись одной попытки не дает дубликат. Query-count и PostgreSQL transaction tests. +- O11 (Angular): после подтверждения конкретного экрана организатор видит новый + общий рейтинг по серверному контракту; loading/empty/no results/error/pending/ + zero, mobile, keyboard и прямой переход проверены в браузере. До подтверждения + это отдельный ожидающий этап, а не готовый runtime UI. + +Порядок реализации: schema/model и внутренняя запись результата → общий +read-service → API/export/admin → PostgreSQL/migration/regression → review/PR. +Angular дизайн готовится по существующим результатам и PROCOLLAB controls; +реализация нового UI идет после его подтверждения. До готового квиза реальные +строки будут ожидать его результат; тестовые баллы проверяются только на +изолированной БД, в живой пилот не записываются. Dev/prod не обновляются. + +## Проверки и requirement audit — 12 ноября 2026 + +Серверная часть проверена на отдельном локальном PostgreSQL 15 / Django 5.2.18. +Команда регрессии: + +```text +manage.py test partner_programs.tests.test_overall_results + partner_programs.tests.test_quiz_outcome_races + partner_programs.tests.test_management_results_api + partner_programs.tests.test_management_criteria_api + project_rates.tests.test_weighted_criteria + project_rates.tests.test_weighted_integration --noinput --keepdb +``` + +Результат: **77 tests, OK**. После уточнений admin-фильтров и отображения нуля +отдельно повторены обе затронутые admin-проверки. Scoped Black/Flake8, +`makemigrations --check --dry-run` и `git diff --check` прошли. + +Проверка миграции выполнена на свежей изолированной БД: применены прежние +leaf migrations с partner_programs 0044, созданы программа, сданный проект, +критерий и реальные экспертные оценки. Следующий план содержит ровно 0045; +после нее таблица результатов квиза пустая, прежний `results_data` +совпадает по данным (кроме generated_at), число программ/оценок и сдача прежние. +SQL содержит только CREATE TABLE, ее constraints/FK/indexes; существующие +таблицы не обновляются. + +Настоящий локальный HTTP/API и Chromium с синтетическими данными: 6 сценариев +прошли — token login и сохраненные 200/0/pending, запрет чужого/гостевого +доступа и публичной записи, XLSX, admin login/рейтинг, фильтр с сохранением +глобального места и скачиванием выбранной строки, readonly результат квиза. +Ошибок JS и запросов на внешние origin нет. Это не приемка живого пилота. + +- O01 — PASS: `overall_results_data`, API fixtures 200/170/0/68.33 и XLSX. +- O02 — PASS: неполная экспертная оценка, отсутствие квиза, несданный кейс, + legacy без скрытой нормализации; старые case results прошли регрессию. +- O03 — PASS: отдельные программы одного проекта и unlink/rejoin без сброса + `ProgramQuizOutcome`; тесты сервиса/БД. +- O04 — PASS: сервисная валидация/идемпотентность и реальные PostgreSQL + range/unique constraints; конкурирующие записи проверены двумя соединениями. +- O05 — PASS: API/export отрицательные role tests и реальный HTTP 401/403; + admin требует staff и модельные права, дополнительно общий program policy. +- O06 — PASS: общие места при равенстве между кейсами, фильтры/поиск/порядок, + отсутствие места у ожидающих; Chromium проверил сохранение места после поиска. +- O07 — PASS: типизированные XLSX-ячейки, формулы/суммы, ноль, места, пустые + незавершенные итоги, защита от формул в именах и Arial 10/A2 без заливок. +- O08 — PASS: admin использует тот же service/export, доступны GET-фильтры; + POST/добавление/редактирование результата запрещены, ошибки фильтров text/plain + защищают от отраженного HTML. Tests и реальный browser path. +- O09 — PASS: отдельный migration proof и регрессия существующих результатов + и процентных критериев. Живые БД не изменялись. +- O10 — PASS: bounded query-count и PostgreSQL TransactionTestCase на одной + команде/разных программах с общим source attempt ID. +- O11 — PASS: владелец подтвердил экран ответом «да норм» 12 ноября. + Angular #540 реализует organizer-only рейтинг по этому API: 108 профильных + tests, после уточнения shared select повторены 72 component/management tests; + pilot/production builds и 13 Chromium сценариев с настоящей локальной БД/API + прошли. Проверены 200/ноль/ожидание, query/reload, Excel, ошибки/повтор, + desktop/mobile/keyboard, запрет чужого доступа и старые результаты кейса. + Подробный audit: Angular `docs/feature-specs/overall-case-quiz-ranking.md`. + +Review: новых BLOCKER/MAJOR в серверной части не осталось. В проверке исправлены +отраженный HTML ошибки admin и разница scope чтения между API/admin. Сервер и +Angular экран реализованы и проверены локально. Backend PostgreSQL CI, Docker +image, pilot package и runtime image для runtime commit 515862c прошли. +Этап реализации 12 ноября не включал установку; приемка пилота 13 ноября ниже. + +На этапе реализации не проверялись установка на pilot/dev/prod, интеграция с еще +не реализованным квизом и native Excel пересчет формул. Формулы, входные числа +и совпадение суммы с API проверены программно; книга просит пересчет при открытии. +Нагрузка 100 участников/боевой SMTP/приемка клиента в этот прогон не входят. + +## Порядок обновления + +Совместимый backend → одна добавочная 0045 → согласованный Angular экран. +Полный новый pilot package закреплен на проверенном Angular +`3241381ea2d2ba9fc9bc5139df543c7a0730814c`. Эта версия опубликована на пилоте +13 ноября отдельным установщиком; само изменение pin установку не выполняет. +Исторический `upgrade_weights.py` разрешает только две миграции весов и не +является установщиком общего рейтинга. Отдельные `package_overall.py` и +`upgrade_overall.py` готовят точечный пакет поверх известного source/UI с +проверками и rollback; команды описаны в `deploy/pilot/README.md`. +Приемка отдельной установки зафиксирована ниже. Не следует подменять старые +fingerprints/whitelist вручную или запускать повторно установщик весов. + +При откате приложения новую таблицу сохраняют; автоматического удаления +результатов или восстановления всей БД поверх текущих данных нет. Существующие +регистрация, приглашения, критерии, оценки и данные команды не пересоздаются. + +## Обновление действующего пилота — требования к установщику + +Владелец разрешил подготовку/установку отдельного обновления пилота после +реализации экрана. База: backend 7657082 с установленными весами/приглашениями, +frontend 75f41a5. Цель: проверенный runtime 3c21fbc и Angular 3241381e. +Квиз не устанавливается. Существующие данные/настройки критериев сохраняются. + +- D01: check сверяет известные state/records, runtime и frontend hashes, + readonly overlays, образ, env, ресурсы, отдельную сеть/БД и nginx. Дрейф + останавливает обновление; check не применяет миграции и не публикует файлы. +- D02: пакет содержит только 11 runtime overlay файлов, измененные frontend + assets и проверенные helpers; исходные код/сборки привязаны к точным revisions. +- D03: до миграции создаются и проверяются pg_dump и резервная копия изменяемой + статики. Допускаются только новая CreateModel 0045 либо пустой план после + предыдущего отката. Другие pending/reverse/destructive операции запрещены. + После нее создается штатная metadata прав новой модели через Django + create_permissions для partner_programs; действующие права не отзываются. +- D04: сохранены image, DB/роль/данные, env, лимиты, все не заменяемые overlays. + Заменяется только web пилота на 18319. Dev, старый пилот и PostgreSQL не + перезапускаются; nginx не меняется. Перед публикацией index выполняется smoke. +- D05: ошибка/прерывание вызывает откат web/UI с сохранением добавочной схемы + и текущих данных. Ни pg_restore поверх текущих данных, ни удаление таблицы, + ни откат процентных критериев не выполняются. Контроль дрейфа действует и + перед откатом; чужие контейнеры/измененные backup не перезаписываются. +- D06: status и повторный apply подтверждают фактические ID/config/source/UI; + повтор после завершенного отката допустим только при сохранности окружения. +- D07: на отдельном PostgreSQL проверяется миграция с сохранением исходных + кейсов/оценок; на disposable Docker проверяется возврат старого контейнера + и frontend. Пакет распаковывается тем же production unpacker. + +Доступа SSH в текущей Cloud-среде нет; финальный запуск на известном сервере +выполняет владелец одним проверенным блоком команд. Это ограничение доступа, +а не дополнительное согласование уже разрешенного обновления. До его вывода +установка и серверный smoke не объявляются выполненными. + +## Проверки установщика и requirement audit D01–D07 до установки + +Checked by source: `upgrade_overall.py`, `package_overall.py`, неизмененные +общие функции backup/rollback и `overall_base.json`. В fingerprint закреплены +720 runtime путей, 24 прежних readonly overlays, 11 целевых runtime файлов, +полные наборы прежней и проверенной целевой frontend сборок. Порядок уникальных +env ключей при замене контейнера несущественен; значения и порядок повторяющихся +ключей сохраняются. Fingerprint уже работающего контейнера остается строгим. + +Checked by tests: **68 tests, OK** — новый lifecycle/migration suite и регрессия +auth/weights/invites/package/unpack. Отдельный PostgreSQL 15 с несуперпользовательской +ролью приложения подтвердил readonly probe, только 0045, четыре стандартных +права новой модели, сохранение кейса 85/100 и безопасный повтор с пустым планом. +Настоящий старый runtime 7657082 продолжает рассчитывать 85/100 при сохраненной +0045. В disposable Docker реально проверены новые mount/config и возврат +прежнего ID/конфигурации web, frontend и HTTP 200 после отказа кандидата; +независимые sentinel и PostgreSQL сохранили ID/настройки. Docker proof использует +локальный nginx вместо полного live Django image; это проверка механики Docker, +а не запуск боевого приложения. Fixtures и credentials находятся вне Git. + +- D01 — PASS: readonly check, неизвестный source/UI/overlay/record и чужие + ресурсы останавливают процесс; runtime hashes и resources проверены тестами. +- D02 — PASS: whitelist/checksums, точные target build hashes, extra/wrong build + rejection и roundtrip package → настоящий production unpacker. +- D03 — PASS: backup до записи, отрицательные migration plans/DB/role/host, + настоящая 0045 и штатные permissions на PostgreSQL. +- D04 — PASS локально: сохранение image/env/лимитов/data и invite mounts, + однозначные назначения mounts, smoke до index, неизменность case snapshot; + реальный Docker config proof. Живой запуск еще не выполнялся. +- D05 — PASS: migration/smoke/publication failure, KeyboardInterrupt, ошибка + записи журнала; защищенный откат без pg_restore/drop и с прежними весами. + Реальный Docker вернул старый контейнер/UI; чужой drift запрещает mutations. +- D06 — PASS: повторный apply без redeploy, API health в status, известные + ID/config/state/source/UI и повтор после завершенного rollback. +- D07 — PASS: отдельные настоящие PostgreSQL/Docker proofs и unpack roundtrip. + +Review: найденная в реальном Docker перестановка уникальных proxy env ключей +учтена только в сравнении замены; тесты защищают значения, дубликаты и строгий +fingerprint текущего контейнера. Новых BLOCKER/MAJOR не осталось. + +До установки были проверены только HTTPS baseline прежнего index 75f41a5, +программа 1, отдельный gate, вход/выход своего QA-аккаунта и dev HTTP 200. +Фактическая установка и приемка нового интерфейса зафиксированы ниже. + +## Установка и приемка пилота — 13 ноября 2026 + +Владелец выполнил закрепленный tooling `7a8e6c6` на `procollab-dev`. +Предварительная проверка разрешила одну миграцию; apply и повторный status +вернули `phase: active`, runtime `3c21fbc`, frontend `3241381e`, прежнюю БД +`procollab_white_label_pilot_v2`, `nginx_changed: false`, `dev_http: 200`. +Backup: `/opt/procollab-white-label-pilot-v2/backup/overall-d572cb63a116`. +Эти серверные факты подтверждены выводом владельца; SSH из Cloud не выполнялся. + +Checked by HTTPS/API: **10 checks PASS**. Все пять измененных frontend файлов +совпали по SHA256 с проверенной сборкой. Организатор читает общий рейтинг +100 + 100, поиск/статусы и полную/отфильтрованную XLSX-выборки; no-store сохранен. +Гость, участник и эксперт не получают рейтинг/export (шесть отрицательных GET). +Прежняя команда, сдача и оба PDF сохранены, критерии и старый case API прежние. +Dev отвечает 200 отдельным запросом без передачи учетных данных пилота. + +Checked in browser: **11 Chromium сценариев PASS** на опубликованном HTTPS UI +и настоящем API, без подмены ответов/сборки. Вход по защищенной ссылке, +organizer-only экран, URL/reload поиска и статуса, 1440/390 без горизонтального +overflow, навигация в прежние результаты/критерии, ошибка чтения и повтор, +выход на ту же клиентскую форму и отказ участнику. Настоящие Django admin +login/общий рейтинг/Excel и пустой readonly список итогов квиза также проверены; +книга из admin совпала с API по ячейкам. Ошибок JS: 0. Пять попыток загрузки +существующей внешней картинки-заглушки блокировались; внешние запросы не +доставлялись. Допускались только POST входа/выхода; настройки и баллы не менялись. + +Cleanup: **3 checks PASS**. Удален только временный ZIP через API QA-владельца +(204, повторное скачивание 404); распакованный release и backup не затрагивались. +Повторно подтверждены команда/сдача/PDF, критерии, API, опубликованный index и dev. +Временные учетные данные/браузерные артефакты не входят в Git. + +Текущая программа все еще `legacy`: имеется только текстовый критерий +«Комментарий», числовых критериев/весов нет. Общий рейтинг корректно показывает +«Настройте оценку кейса из 100» и ссылку к критериям, Angular Excel отключен; +API/admin XLSX оставляют итог/место пустыми. Это сохраненная настройка, +а не ошибка установки. Для рабочего расчета организатор должен согласовать +числовые критерии и веса с суммой 100%; тестовые оценки не создавались. + +Итог: установка и приемка текущего состояния пилота PASS. Не проверены настоящий +квиз и боевые завершенные итоги на пилоте, native Excel пересчет, нагрузка 100, +SMTP и приемка клиента. Полные 200/0/pending и гонки подтверждены прежними +локальными PostgreSQL/browser тестами. Dev/prod не обновлялись. diff --git a/docs/weighted-case-criteria.md b/docs/weighted-case-criteria.md new file mode 100644 index 00000000..4ed034f1 --- /dev/null +++ b/docs/weighted-case-criteria.md @@ -0,0 +1,174 @@ +# Процентные веса критериев кейса + +Согласовано 11 ноября 2026: пользователь выбрал проценты с общей суммой 100% +и итог кейса 0–100. Эта задача не включает квиз и смешанный рейтинг квиза/кейса. + +## Исходное поведение и границы + +API base: `b4575a0770c0515a8099cd3920416a7cbcf953bf`, Angular base: +`936dd7e1043f07c4a25dcd819ae7d5523644724f`, ветки от dev. Работа выполняется +отдельно от white label PR и опубликованного пилота; серверы не изменяются. + +`Criteria` сейчас содержит тип и шкалу, без веса. Общая +`project_rates/evaluation_math.py:evaluation_totals` суммирует числовые критерии +полной оценки и усредняет суммы экспертов. Ее используют результаты Project и +ProgramSolution. Результаты и XLSX используют общий snapshot, но XLSX также +содержит собственные формулы SUM/AVERAGEIF. Число полных оценок для допуска в +рейтинг, места при равенстве, сроки и object permissions уже определены кодом. +`criteria_policy` фиксирует критерии после первой сохраненной оценки. + +Организатор редактирует критерии в существующем разделе управления и Django +admin. Эксперт выставляет исходные баллы; участник не задает веса. Права остаются +на сервере и ограничены той же программой. Angular получает веса из API. + +## Правила расчета + +- Существующие программы сохраняют режим `legacy`: результаты не пересчитываются. +- Режим `weighted_percent` выбирается организатором до первой сохраненной оценки. +- Каждый числовой критерий имеет вес больше 0 и не больше 100%, до двух знаков + после запятой. Общая сумма числовых весов должна быть ровно 100%. +- Шкала каждого взвешенного критерия конечна, максимум строго больше минимума. + Нормализованный вклад: `(оценка − минимум) / (максимум − минимум) × вес`. +- Минимум шкалы дает нулевой вклад, максимум — полный вес. Например, 8 из 10 + при шкале 0–10 и весе 40% дают 32 балла из 40. +- Комментарии и «да/нет» не имеют числового веса. Их прежние правила заполнения + и завершенности не меняются. +- Итог эксперта — сумма вкладов. Итог команды — среднее полных оценок экспертов. + Все эксперты равноправны; неполная оценка не превращается в ноль. +- Расчет выполняется Decimal без промежуточного округления вкладов. Финальный + балл округляется до двух знаков HALF_UP; действующие правила мест сохраняются. +- Сумма весов, шкалы, режим и веса блокируются после первой сохраненной оценки, + включая частичную. Проверка и сохранение используют общий program lock. +- В XLSX исходные баллы, веса и итог должны воспроизводить расчет API. + +## Контракт и совместимость + +В `GET/PUT /programs/:id/management/criteria/` добавляются `scoring_mode` +(`legacy|weighted_percent`) и nullable `weight_percent` у критерия. Старый PUT +без новых полей сохраняет текущий режим и веса существующих критериев. В новом +режиме сохранение неполного/невалидного набора возвращает 400 атомарно. +Существующие 401/403/404 и 409 `stale_criteria|criteria_locked` сохраняются. +Режим и веса входят в revision: устаревшая экспертная форма не пишет оценки +по измененным правилам. Payload исходных экспертных баллов сохраняется. + +Результаты Project и ProgramSolution добавляют режим, веса и вклады, сохраняя +исходные averages. Для нового режима maximum=100. Общая формула используется +обоими контурами; сущности и их права не объединяются. Отдельный React +Submission/Evaluation API не меняется. + +Нужны две добавочные миграции: режим в PartnerProgram с default legacy и nullable +Decimal-вес в Criteria. Старые записи остаются без весов. Backend выкладывается +перед Angular; миграции и установка на pilot выполняются отдельным контролируемым +этапом после verification. Автоматического изменения dev/prod нет. + +## UI и админка + +Расширяется существующая форма критериев: выбор способа расчета, поле «Вес, %» +у числового критерия и сумма весов с ошибкой при отклонении от 100%. Строка +критерия и форма эксперта показывают вес, результаты — формулу и вклад. Сохраняются +текущие карточки, модальное окно, Mont, controls и responsive layout; нового +экрана или изменения структуры навигации нет. Это ограниченное расширение +существующих полей, без отдельного нового визуального дизайна. + +В Django admin требуется эквивалентное атомарное редактирование всего набора +через тот же service, а не обход проверки суммы отдельными записями. + +## Acceptance criteria + +- W01: legacy программа с прежними оценками сохраняет прежние баллы, максимум, + места и XLSX. Проверка регрессионными тестами обеих моделей результатов. +- W02: manager своей программы сохраняет веса 40/35/25 и режим weighted_percent; + повторный GET возвращает те же числа. Проверка API и БД PostgreSQL. +- W03: неверная сумма, ноль/отрицательное/NaN/Infinity, больше двух знаков, + нечисловой взвешенный критерий и вырожденная шкала отклоняются без частичных + изменений. Проверка API/model/admin tests. +- W04: чужая программа/роль/гость не могут изменить веса; старый revision и первая + сохраненная оценка дают прежний 409. Проверка прямых HTTP-запросов и гонки. +- W05: на разных шкалах вклад нормализуется, максимум=100, минимум=0; неполные + оценки не входят в среднее, эксперты равноправны. Проверка Decimal примерами. +- W06: после первой оценки режим/веса нельзя изменить через API, model и admin; + экспертный POST проверяет актуальный revision. Проверка PostgreSQL tests. +- W07: Project и ProgramSolution с одинаковыми данными получают одинаковые + итоги, вклады и места; экспортные формулы воспроизводят результат. +- W08: Angular позволяет настроить веса, показывает их сумму и ошибки, сохраняет + черновик при 409 и блокирует форму после фиксации. Старый API без новых ключей + работает как legacy. Проверка component/adapter tests. +- W09: эксперт видит вес без ручного умножения; результаты показывают корректную + формулу/максимум. Проверка тестами и браузером desktop/mobile, клавиатурой. +- W10: миграции применяются к отдельной тестовой PostgreSQL; проверки/build и + итоговый review подтверждены до PR. Реальные серверы не затрагиваются. + +## Порядок реализации + +Backend-first: models/migrations → общий расчет и policy → атомарная настройка +API/admin → результаты/XLSX → API/locking/regression tests. Затем Angular +domain/adapter → существующие формы/результаты → tests/build/browser → review +обоих PR. По завершении фиксируются фактические проверки и ограничения. + +## Проверка реализации 11 ноября 2026 + +Checked by source: общий расчет используется результатами Project и ProgramSolution, +а API организатора и пакетный редактор Django admin вызывают один service. Права +организатора прежние: manager своей программы или staff; Django admin дополнительно +проверяет модельные view/change permissions. Веса и режим входят в revision. +Удаление ограничено проверенными ID; после program lock повторно проверяется +принадлежность изменяемого критерия и критерия сохраняемой оценки. + +Checked by tests: на отдельном PostgreSQL прошли 235 тестов `project_rates`, +`partner_programs.tests.test_management_criteria_api`, `test_management_results_api`, +`test_management_api`. Включены миграция туда/обратно с прежними оценками, +паритет Project/ProgramSolution, гонка первой оценки и изменения весов, удаление +замененного набора и перенос критерия до program lock. Негативные роли отдельно +перепроверены прямыми HTTP-запросами. Black и Python lint E/F/W прошли; +`makemigrations --check --dry-run` не обнаружил изменений. + +Checked by build: Angular production build и 57 тестов в семи файлах прошли, +включая adapters, общий expert form, обе модели результатов, criteria editor и +solution evaluation contract. C00 1.22.0: 156 schemas, 59 operations, 307 scenarios; +валидатор OpenAPI и TypeScript contract check прошли. Добавления схем и примеров +совпадают в двух repos; прежнее различие описаний published-solutions GET вне +этой задачи сохранено. Проверки форматирования и ESLint ограничены измененными +файлами; общерепозиторные автоисправления не запускались. + +Checked in browser: production Angular build с настоящим изолированным Django API, +синтетическими аккаунтами и заблокированными внешними сервисами. Организатор +сохранил 40/35/25 через форму; до распределения 100% сохранение недоступно. +Эксперт через форму сохранил 8/7/6: 71,5 балла. Второй эксперт отправил 10/8/8 +через обычный API: 88 баллов. Рейтинг показал 79,75 из 100 и первое место; +формула показала вклады 36 / 26,25 / 17,5. Первая оценка заблокировала настройки. +Desktop 1440 px и mobile 390 px проверены; форма и объяснение без горизонтального +скролла. Enter открывает объяснение, Escape закрывает; числовое поле preview +сохраняет доступное имя критерия. Ошибок страницы не было. Проверены текущие +Mont/components/layout; новых CSS, декоративных анимаций и UI-библиотек нет. + +Скачанный через API XLSX пересчитан LibreOffice: итоги экспертов 71,5 и 88, +рейтинг 79,75, веса 40/35/25. Это проверка вычисленных значений, а не только текста +формул. Тесты также проверяют метаданные, заморозку строки и Arial 10 нового листа. + +### Acceptance audit + +| AC | Evidence | Verdict | +| --- | ----------------------------------------------------------------------------------- | ------- | +| W01 | Legacy math/results/XLSX regression tests и migration roundtrip | PASS | +| W02 | PostgreSQL API roundtrip и сохранение 40/35/25 в браузере | PASS | +| W03 | Невалидные веса/шкалы, atomicity, model и admin tests | PASS | +| W04 | Guest/member/foreign manager/expert HTTP checks, stale revision и PostgreSQL race | PASS | +| W05 | Разные шкалы, границы 0/100, частичные оценки и Decimal examples | PASS | +| W06 | API/model/admin lock tests, повторная проверка родителя, locked UI | PASS | +| W07 | Реальные Project/ProgramSolution scores и schema validation; пересчет XLSX | PASS | +| W08 | Component/adapter tests: сумма, 409 draft, old API, float weight; browser save/lock | PASS | +| W09 | Expert form и results tests; browser desktop/mobile/keyboard | PASS | +| W10 | Изолированный PostgreSQL, migration roundtrip, build/checks и итоговый review | PASS | + +Итог review: actionable BLOCKER/MAJOR/MINOR findings не осталось. Найденные гонки +исправлены и проверены; основной flow, совместимость и границы роли подтверждены. + +Not checked: установка на pilot/dev/prod, работа реальной почты, приемка клиента +и нагрузка на 100 участников. Они не являются частью этой локальной проверки +весов. Квиз и смешивание квиза с кейсом не реализовывались. Логи, скриншоты и +синтетические данные QA остаются вне Git; секреты в PR не входят. + +Проверка установки на пилот выявила требование к откату: у режима также задан +`db_default="legacy"`. Исходный ORM без нового поля после миграции продолжает +создавать программы. Регрессия проверена исторической моделью в PostgreSQL и +реальным старым исходным кодом; добавочные колонки при откате не удаляются. diff --git a/docs/white-label-foundation.md b/docs/white-label-foundation.md new file mode 100644 index 00000000..f44adb09 --- /dev/null +++ b/docs/white-label-foundation.md @@ -0,0 +1,339 @@ +# Основа отдельного экземпляра и короткой регистрации + +## Цель и границы + +Один кейс-чемпионат в отдельном экземпляре с собственной БД, брендом и адресом +клиента. Первый выпуск этого блока: серверная конфигурация экземпляра, короткая +регистрация и письма, воспроизводимое наполнение изолированного тестового стенда. +Квиз, оценки и рейтинг в этот блок не входят: дизайн квиза еще не согласован. + +Для приглашения участника без ФИО существующий поиск также принимает точную +полную почту. Это расширение действует только в настроенной white label программе +для ее участника, организатора или администратора. Ответ содержит почту только +совпавшего участника этой программы и помечается `private, no-store`; публичная +выдача, поиск по имени и обычный режим сохраняют прежний состав полей. +Профиль не дополняется вымышленными именами или датой рождения. В окне приглашения +используется реальная почта; в строке команды без ФИО — идентификатор участника. +В связанном frontend PR существующая форма получает вариант конфигурации: +скрываются ФИО/дата рождения, используются имя/логотип из ответа API. Существующие +компоненты, оформление, состояния и подтверждение почты используются повторно. + +База: `api/dev` `b4575a0770c0515a8099cd3920416a7cbcf953bf`. +Существующие сценарии: `partner_programs/registration_views.py`, +`services/native_registration.py`, `registration_models.py`, `users/signals.py`. +Angular потребляет `/programs/{id}/registration/` через +`ProgramRegistrationHttpAdapter` и `PublicRegistrationComponent`. + +## Текущее и целевое поведение + +Встроенная регистрация уже подтверждает почту, создает участие в программе и +ставит `onboarding_stage=None`, сохраняя глобальную модерацию профиля. Ее форма +требует ФИО и дату рождения, а письма используют бренд и домены PROCOLLAB. + +Включенный white label сокращает данные нового аккаунта до почты и пароля для +конкретной настроенной программы. Повтор пароля и существующие согласия остаются. +Имя, фамилия и дата рождения не выдумываются: пустые имена и `birthday=None` +поддерживаются текущей моделью. Возраст подтверждается существующим согласием; +переданная дата рождения также проходит прежнюю проверку. Подтверждение почты, +неизменность существующего аккаунта, версия анкеты и дедлайны сохраняются. + +Настройки экземпляра задаются окружением, без миграции или копирования чужой БД: +`WHITE_LABEL_ENABLED`, `WHITE_LABEL_PROGRAM_ID`, `WHITE_LABEL_BRAND_NAME`, +`WHITE_LABEL_LOGO_URL`, `WHITE_LABEL_FRONTEND_URL`, `WHITE_LABEL_API_HOST`. +По умолчанию режим выключен. Публичная конфигурация +не содержит секретов. Адреса писем берутся из доверенной конфигурации сервера. + +## Контракт и доступ + +- Новый публичный `GET /instance/`: `{enabled: false}` в обычном режиме; в white + label также `program_id`, `brand_name`, `logo_url`, `frontend_url`, + `registration_url`, `program_url`. Ответ не кешируется. +- `GET /programs/{id}/registration/` дополнительно возвращает + `account_mode: "email_password"` и `branding: {name, logo_url}` только для + программы экземпляра. Обычный ответ сохраняет прежние ключи. +- `POST /programs/{id}/registration/signup/` сохраняет старый контракт. В white + label для настроенной программы `first_name`, `last_name`, `birthday` становятся + необязательными. Почта, пароль/повтор, `accept_policy`, `accept_age`, версия и + ответы анкеты проверяются как прежде. Другие программы не получают послабления. +- Создание: 201, ошибки данных: 400, существующая почта/конфликт: 409, скрытая + программа: 404. До доказательства почты нет JWT и участия. Подтверждение + возвращает JWT в теле и автоматически создает участие ровно один раз. +- Подтверждение и восстановление пароля используют домен и бренд экземпляра. + Обычные письма и формы в выключенном режиме сохраняют прежнее поведение. +- Создание/изменение программы и ролей сохраняет действующие серверные права. + Общая бизнес-логика регистрации используется повторно. + +## Критерии приемки и проверки + +| ID | Предусловие, действие и наблюдаемый результат | Проверка | +| --- | --- | --- | +| WL01 | Режим выключен: обычная регистрация требует прежние данные; `/instance/` сообщает выключенный режим | API и существующие тесты регистрации | +| WL02 | Режим включен: публичная конфигурация содержит только бренд, адреса и ID программы | API, проверка точных ключей | +| WL03 | Гость регистрируется в настроенной программе без ФИО/даты: 201, неактивный аккаунт, `birthday=None`, онбординг пропущен, участие пока не создано | API и БД | +| WL04 | Участник подтверждает почту: получает JWT и одно участие; повтор подтверждения/анкеты не создает дубль | API и БД | +| WL05 | Существующая почта: 409 без изменения профиля/пароля; другой ID программы сохраняет полный контракт | Негативные API-тесты | +| WL06 | Неверные пароли, согласия, версия, закрытая регистрация отклоняются без частичных данных | Негативные API-тесты | +| WL07 | Письма подтверждения/сброса содержат бренд и домен клиента, обычный режим сохраняет прежние письма | Email backend в памяти | +| WL08 | Некорректная конфигурация не проходит `manage.py check`; домены не принимаются из запроса | System checks и API | +| WL09 | Команда демо-наполнения работает только на явно разрешенной локальной демо-БД, создает роли/команду/программу без дублей | PostgreSQL, повтор команды | +| WL10 | Клиентская форма скрывает ФИО/дату и не отправляет их; пароль, согласия и поля анкеты обязательны как прежде | Angular component tests, build, браузер | +| WL11 | Форма показывает имя/логотип клиента без бренда платформы; обычная форма сохраняет прежние поля и бренд | Angular component tests, браузер desktop/mobile | +| WL12 | Production cookies на домене клиента принадлежат текущему host; на доменах PROCOLLAB сохраняется общий cookie domain и разделение dev/prod ключей | TokenService tests | + +## Порядок реализации и внедрения + +Конфигурация/проверки и API → расширение существующей регистрации и писем → +защищенное демо-наполнение → PostgreSQL-проверки и регрессия → review/PR в `dev`. +Frontend использует добавочный контракт в существующем варианте формы. +Порядок frontend: domain model → текущая форма и +валидация → TokenService для домена клиента → component/cookie tests → build и +браузер. Изменения макета и новый экран не требуются. Selectel, публичный DNS, +платные ресурсы, production merge и +deploy не выполняются этим блоком. Клиентские домен/логотип пока заменены +вымышленными значениями в локальной конфигурации. + +Доступ после мероприятия остается без заранее заданного срока отключения. +Период проведения в коммерческом предложении: один месяц, 100 участников. +Схема БД не меняется; удаления/переноса существующих данных нет. + +## Проверка первого блока, 10 ноября 2026 + +Проверки выполнены на отдельной локальной PostgreSQL 15 и production Angular +сборке. API адреса Angular перенаправлялись в браузерном тесте на локальный API; +внешние запросы блокировались. Реальная доставка SMTP и публичный DNS не проверены. + +- Django `check`, `makemigrations --check --dry-run`, scoped Black/flake8 и + `git diff --check`: пройдены, новых миграций нет. +- 117 API-тестов: white label, native registration, редактор регистрации в + админке, уведомления, auth/signals/activity, экспертная админка: пройдены. +- HTTP: signup 201 → письмо с брендом/адресом экземпляра → confirm 200 → login + 200 → участие в программе 200. Повторная отправка анкеты не создает дубль. +- Демо-БД создана с нуля существующими миграциями; наполнение и его повтор + проверены. В браузере вошли администратор и организатор Django admin. +- Angular: component/HTTP adapter/token/guard/interceptor tests и production + build; точный состав и результаты находятся в связанном frontend PR. +- Браузер Chromium, 1440×1000 и 390×844: короткая форма, отправка, письмо, + подтверждение, открытие программы, пропуск онбординга и обновление сессии. + На телефоне: нет горизонтального скролла, доступен фокус с клавиатуры, + проверены ошибка обязательных данных, вход и восстановление пароля. + +WL01–WL09 подтверждены API-тестами и указанными HTTP/БД-проверками. +WL10–WL12 подтверждены связанными Angular-тестами, сборкой и браузером. +Права управления программой не менялись; существующие проверки 403/404 прошли. + +Этот блок не означает готовность всего клиентского white label: еще нужны +брендирование общего кабинета/метаданных/оставшихся писем, документы клиента, +настройка frontend API/WebSocket адресов для его сборки, отдельные ресурсы и +почтовый отправитель, HTTPS/DNS и приемка клиентского стенда. Сейчас в браузере +использовались вымышленный бренд и локальная почта, кабинет сохраняет свой текущий +бренд. Демо-учетки запрещены на клиентской БД. + +## Второй блок: закрытый пилот «Сознание права» + +Запрос от 10 ноября: подготовить демо до решения клиента, на +`https://pilot.procollab.ru`, с названием и логотипом с `https://сознаниеправа.рф/`. +Почтовый транспорт для будущей отправки — действующий сервис PROCOLLAB. +Для подготовки используются вымышленные аккаунты и отдельные данные. + +Наблюдение 10 ноября: A `pilot.procollab.ru` и `dev.procollab.ru` = +`5.188.81.217`; NS домена `ns1.reg.ru`, `ns2.reg.ru`; HTTPS пилота возвращает +обычный Procollab. Вывод владельца от 11 ноября подтверждает существующий пилот: +web `procollab-white-label-pilot-web-1` слушает loopback 18318, отдельный db +healthy; dev API использует 8000/8001. Доступно 2.1 GiB RAM и 6.2 GiB диска, +swap нет. Второй полный compose project на этом сервере не запускаем. +Compose найден в `/opt/procollab-white-label-pilot/compose.yml`, API подключен +из `/opt/procollab-white-label-pilot/backend`, профиль `pilot_hosted_settings`, +образ `procollab-white-label-pilot-base:20261109`; uploads/static/logs отдельные. +Обновляем этот пилот после проверки runtime settings/vhost и резервной копии, +сохраняя DB volume. Совместимость профиля еще не подтверждена. +Пароли не менялись и не требуют изменений. DNS менять +сейчас не требуется. Публичный SSH из Cloud без VPN/Tailscale недоступен; +владелец выполняет диагностику в своем SSH-терминале. Доступ к GitHub есть. + +Сохраняются модели Project/Collaborator, права программы, регистрация с +подтверждением почты, сроки и все сценарии оценивания. Квиз остается вне scope. +UI сохраняет существующие экраны и DS: заменяются брендовые слоты, подписи и +метаданные, без нового макета. Белый официальный PNG логотипа показывается на +темной подложке только по настройке экземпляра. + +Порядок: добавочная metadata API и профиль pilot → pilot build Angular, +изолированные cookie keys, загрузка instance config и существующие брендовые +слоты → отдельный compose/nginx/подготовка архива → tests/build/browser/review. +Сначала контракт API, затем его Angular consumer. Миграций нет. + +- P01: обычные dev/prod сборки и письма сохраняют поведение; pilot build + обращается только в `/backend` и свой `/ws`, обычные сервисы не используются. +- P02: production cookies пилота имеют отдельные имена и host scope; cookies + основной платформы не читаются, не меняются и не удаляются. +- P03: `/instance/` добавляет необязательные favicon URL и фон логотипа; + конфигурация проверяется, обычный контракт остается совместимым. +- P04: название/логотип в существующих регистрации, входе, кабинете и Django + admin, заголовок вкладки и favicon берутся из экземпляра. Отсутствующие данные + пилота показывают понятную ошибку, а не переход на обычную платформу. +- P05: собственные DB/media/static/mail, Django secret и loopback binding; + меняются только ресурсы пилота. Полный compose для нового окружения имеет + отдельные project/network/Redis и лимиты ресурсов. На текущем сервере нужен + план обновления существующего пилота 18318 после обследования, без второго стека. +- P06: до входа в закрытое демо и UI, и API отвечают 401; после входа сохраняется + JWT Authorization. Cookie допуска демо Secure/HttpOnly/host-only и случайный. +- P07: подготовка создает новые секреты локально с правами 0600, не выводит их; + повтор не перезаписывает настройки/данные. Демо содержит только example.test; + отправка наружу по умолчанию выключена, письма сохраняются отдельно. +- P08: подготовлены переносимый пакет и команды для нового окружения; + preflight блокирует новый стек при недостатке RAM/диска. Для существующего + сервера команды обновления/backup/rollback зависят от еще не полученной + конфигурации. Реальный сервер/DNS не меняются без целевого доступа. + Настройка сервера проверяется отдельно от локальных tests/build/browser. + +Проверки: unit/integration для metadata/cookies/config/границ seed, compose и +nginx syntax/routes, настоящий локальный API/PostgreSQL и Chromium desktop/mobile. +Нагрузку на 100 участников и реальный SMTP проверяем перед запуском мероприятия. + +### Проверка второго блока, 11 ноября 2026 + +Локальный Docker использует Django 5.2.18 из release Dockerfile, PostgreSQL 15, +nginx и loopback HTTPS с собственным тестовым сертификатом. Внешние запросы в +браузере блокируются. Проверена специальная pilot сборка и обычная production +сборка Angular, новых миграций нет. + +- 131 API/helper tests: white label, native/admin registration, организаторы, + case scope, уведомления, обложки, local storage, configure/preflight/package. +- Еще 68 tests файлов/новостей/решений/архивов и 17 tests моделей контента курсов; + три local-storage tests повторяются в первом и втором запуске. Существующий + интерфейс `FileView.cdn` сохранен для связанных consumers. +- Angular: 64 tests конфигурации, регистрации, кабинета, входа, приглашений, + sidebar, токенов и guard; отдельно 13 bearer interceptor tests. Scoped + ESLint/Prettier/Stylelint и Black/flake8 прошли. +- Chromium 1440×1000 и 390×844 dark: signup → локальное письмо → confirm → + программа без онбординга → reload; brand/title/favicon, host-only pilot + cookies, загрузка/скачивание/удаление файла. Обычные cookies не изменились. +- Вход администратора и организатора в branded Django admin через `/backend`. + Ошибка `/instance/` показывает нейтральное сообщение; повтор восстанавливает + клиентскую регистрацию. Консольных JS ошибок в основном сценарии нет. +- До авторизации UI/API закрыты; автоматический redirect `/backend` не выдает + cookie допуска. Проверены права файлов пакета и отсутствие перезаписи секретов, + блокировка второго стека при недостатке RAM/диска и отказ обычной frontend сборке. + +Nginx syntax и compose config проверены. Celery обрабатывал локальные письма +процессом внутри web-контейнера; отдельный worker-контейнер в Cloud не подтвержден +из-за нехватки диска при его создании. Это ограничение проверки полного стека +для нового сервера. Существующий пилот с web/db обновляется отдельным планом. + +Не проверены: обновление и smoke на реальном сервере, его текущий runtime +profile/vhost, реальная доставка писем, нагрузка 100 участников и документы +клиента. Серверная диагностика продолжается; DNS и стенд удаленно не изменялись. + + +### Уточнение rollout по фактическому серверу + +Исходный pilot существенно старше `dev`: Django 4.2.11, 272 отличающихся/110 +отсутствующих файлов, 48 непримененных миграций; native registration отсутствует. +Существующие четыре аккаунта не с example.test, данные сохраняются целиком. +Прямой upgrade/очистка старой БД не выполняется. + +Подготовлена компактная v2 из web + собственного PostgreSQL 15 с новой пустой +БД, своей сетью и data в `/opt/procollab-white-label-pilot-v2`. Старый проект +остается работающим. Новый API — loopback 18319. Inline profile не использует +Redis/worker; web ограничен 768 MiB / 0.5 CPU, PG 256 MiB / 0.25 CPU, с запасом +768 MiB RAM / 2 GiB диска для dev. Приложение не использует DB superuser. + +Дополнительные критерии: + +- H01: check/stage проверяют известные host/project/labels/network/routes; чужие + ресурсы, некорректные пути/ссылки и разные revisions artifacts отклоняются. +- H02: старая DB/compose/backend/контейнеры не меняются; backup локальный private, + новые migrations/seed выполняются только в новой DB. +- H03: запуск ограничен cgroups и resource checks; образ собирается вне dev, + импортируется потоком с проверкой SHA, без распаковки docker-save во временный + многослойный архив. ENV/CMD/WORKDIR и source revision сохраняются. +- H04: stage проверяет новый API/native registration/login до переключения домена. +- H05: switch затрагивает только pilot routes и собственный maps, требует nginx + syntax check, dev/public smoke; ошибка откатывает маршруты, чужие правки + сохраняются. Нет глобального restart/down/prune/reboot. +- H06: чистая DB и private secrets создаются локально; наружу email не отправляется, + demo ограничен example.test. Старые accounts и новая DB сохраняются при rollback. + +Проверено локально: inline HTTPS desktop/mobile flow на свежей DB с отдельным +несуперпользователем; все миграции + seed; официальный pinned PG init с private +SQL и non-superuser ролью; host-style nginx template и auth gate; негативные +helper tests и rollback при ошибке nginx. Runtime export/import с восстановлением +image config проверен реальным Docker локально на маленьком fixture и в CI на +полном release image. Реальный server check/stage/switch еще не выполнялись. + +### Текущее состояние и проверка приглашений, 11 ноября 2026 + +Владелец выполнил check/stage/switch на сервере: hosted-v2 активен на +`pilot.procollab.ru`, dev вернул HTTP 200, прежний pilot сохранен. Новая БД +изолирована; это snapshot кода на базе dev, без копирования данных dev/prod. + +На опубликованном стенде Chromium проверил внешний вход, вход пяти ролей, +права управления программой, короткую регистрацию desktop/mobile и Django admin. +Два новых QA-аккаунта зарегистрированы без ФИО/даты; владелец подтвердил их через +обычный API по локальным письмам, оба автоматически вступили в программу. +Новый участник вошел без онбординга и создал проект через интерфейс. + +Обнаружен воспроизводимый blocker: поиск приглашения по полной почте возвращал +пустой результат для аккаунта без имени. Установлено исправление существующего +контракта `GET /auth/public-users/?fullname=&partner_program=`: + +- I01: авторизованный участник или организатор программы экземпляра может найти + ее участника по полной почте без учета регистра; ответ дает реальную почту, + не изменяет профиль, не требует вымышленного имени/даты. +- I02: гость, пользователь другой программы, частичная почта и отсутствие + program scope не получают приватное поле почты; обычный поиск/режим сохраняется. +- I03: окно приглашения показывает почту при пустом имени; строка команды + использует идентификатор, отсутствующая дата не превращается в возраст. +- I04: обновление сохраняет текущие БД/файлы/секреты и образ; меняет только + web v2 и его frontend. Старый web сохраняется, ошибка вызывает откат. + +Checked by source: scope, существующие filters/permissions, отсутствие миграций, +порядок rollout API + UI, изоляция контейнеров/портов. Checked by tests: 87 Django +tests на PostgreSQL 15 / Django 5.2.18, включая приватность поиска; 81 Angular +tests приглашений, регистрации и токенов; scoped lint/format и solution contract. +Checked by build: специальная pilot и обычная production Angular сборки. +Checked in browser: перечисленные исходные live-сценарии, воспроизведение blocker +и повторная проверка после его исправления. +Механизм отката проверен на отдельных локальных Docker-контейнерах с реальным +восстановлением прежнего контейнера, HTTP 200 и старого index; dev health в этом +локальном fixture заменен заглушкой, реальный сервер он не затрагивает. + +Владелец установил пакет из CI run `43073600357` с app revision +`f7f800e15085555300419d87308b6fdf02e3a945`: active, image_reused=true, +database_preserved=`procollab_white_label_pilot_v2`, nginx_changed=false, +dev_http=200. БД и загруженные до обновления файлы сохранились. Повторный перенос +runtime, миграции, новый seed и изменение dev/prod не выполнялись. + +На опубликованном pilot проверены I01/I03: новый лидер нашел нового участника +по полной почте, отправил приглашение через UI (201), участник принял его с +телефона (200), состав команды содержит обоих участников. Чужой аккаунт не может +принять приглашение (403), гость не получает результат поиска почты. Mobile +390×844 показывает почту без горизонтального скролла и вымышленного возраста. + +Пустая демопрограмма штатно отклонила сдачу без настроенных кейсов (400). Через +существующий organizer API с revision check в нее добавлены явно тестовый кейс +и поле файла; прежние настройки формы сохранены для проверки, ограничения +команды не менялись. Это тестовое наполнение, не согласованные материалы клиента. +Затем лидер выбрал кейс, загрузил PDF через UI (201, скачивание побайтно совпало) +и сдал проект через подтверждение (200). Участник команды не может сдать проект +вместо лидера (403). Organizer видит submitted=true. После сдачи редактор +перенаправляет на просмотр, удаление участника возвращает 409/team_frozen, +изменение полей и повторная сдача возвращают 400; состав, кейс и файл сохранены. +Финальная mobile-страница не имеет горизонтального скролла и page errors. + +Также пройден полный mobile password recovery нового QA-участника: запрос и +установка через UI (200), возврат ко входу в программу, старый пароль (401), +новый (200), повтор использованной ссылки (404). Исходный QA-пароль восстановлен +через обычный API. Письма остаются в file backend; ключ прочитан только для +собственного synthetic fixture через авторизованный admin, без вывода секретов. + +PostgreSQL CI и Docker image build текущего исправления прошли в run +`43073600194`; candidate из run `43073600357` проверен по SHA256 и каждому +manifest hash. I04 подтвержден также отчетом установки владельца и сохранением +данных на опубликованном pilot. Промежуточные ошибки автоматизации (порядок +закрытия модальных окон, ожидание disabled вместо штатного redirect и неверное +ожидание запрета редактирования черновика соавтором) исправлены в QA-прогоне; +существующие правила совместного редактирования не менялись. + +Not checked: реальные SMTP-письма, нагрузка на 100 участников, оценивание с +клиентскими критериями/весами и приемка клиента. Эти этапы остаются подготовкой +к мероприятию. Квиз не переносится до согласования дизайна. diff --git a/files/admin.py b/files/admin.py index 39e14de1..59a66a1a 100644 --- a/files/admin.py +++ b/files/admin.py @@ -6,7 +6,7 @@ from django.forms import ModelForm, FileField from django.db.models import QuerySet -from files.service import CDN, SelectelSwiftStorage +from files.service import CDN, configured_storage from files.models import UserFile @@ -58,7 +58,7 @@ def get_deleted_objects(self, objs, request): date_hierarchy = "datetime_uploaded" ordering = ("-datetime_uploaded",) - cdn = CDN(storage=SelectelSwiftStorage()) + cdn = CDN(storage=configured_storage()) @admin.display(empty_value="Empty filename") def filename(self, obj): diff --git a/files/service.py b/files/service.py index 1304af6c..95dd26f0 100644 --- a/files/service.py +++ b/files/service.py @@ -1,9 +1,13 @@ import time +from pathlib import PurePosixPath +from urllib.parse import unquote, urlsplit +from uuid import uuid4 from abc import ABC, abstractmethod import requests from django.conf import settings from django.contrib.auth import get_user_model +from django.core.files.storage import FileSystemStorage from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile from requests import Response @@ -138,6 +142,50 @@ def _get_auth_token(): return response.headers["x-subject-token"] +class PilotFileStorage(Storage): + """Файлы изолированного пилота не используют общий контейнер Selectel.""" + + def __init__(self): + self.storage = FileSystemStorage() + + def upload(self, file: File, user: User) -> FileInfo: + suffix = f".{file.extension}" if file.extension else "" + name = self.storage.save(f"uploads/{user.pk}/{uuid4().hex}{suffix}", file.buffer) + return FileInfo( + url=settings.WHITE_LABEL_FRONTEND_URL.rstrip("/") + self.storage.url(name), + name=file.name, + extension=file.extension, + mime_type=file.content_type, + size=file.size, + detected_mime_type=file.detected_mime_type, + ) + + def delete(self, url: str) -> Response: + origin = settings.WHITE_LABEL_FRONTEND_URL.rstrip("/") + prefix = origin + settings.MEDIA_URL + if not url.startswith(prefix): + raise ValueError("Файл не принадлежит хранилищу пилота.") + offset = len(settings.MEDIA_URL) + name = unquote(urlsplit(url).path[offset:]) + if ( + not name + or PurePosixPath(name).is_absolute() + or ".." in PurePosixPath(name).parts + or "\\" in name + ): + raise ValueError("Некорректный путь файла.") + self.storage.delete(name) + response = Response() + response.status_code = 204 + return response + + +def configured_storage() -> Storage: + if getattr(settings, "PILOT_LOCAL_FILES", False): + return PilotFileStorage() + return SelectelSwiftStorage() + + class CDN: def __init__(self, storage: Storage) -> None: self.storage = storage diff --git a/files/test_pilot_storage.py b/files/test_pilot_storage.py new file mode 100644 index 00000000..5db95a17 --- /dev/null +++ b/files/test_pilot_storage.py @@ -0,0 +1,87 @@ +"""Пилот пишет и удаляет только собственные файлы без вызовов Selectel.""" + +from tempfile import TemporaryDirectory +from unittest.mock import patch +from urllib.parse import urlencode + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from files.models import UserFile +from files.service import CDN, configured_storage +from partner_programs.tests.helpers import create_user + + +@override_settings( + PILOT_LOCAL_FILES=True, + WHITE_LABEL_FRONTEND_URL="https://pilot.example.test", + MEDIA_URL="/backend/media/", +) +class PilotStorageTests(TestCase): + def setUp(self): + self.directory = TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.media = override_settings(MEDIA_ROOT=self.directory.name) + self.media.enable() + self.addCleanup(self.media.disable) + # В приложении адаптер выбирается при запуске. В тестовом процессе + # URLconf уже загружен до override_settings, поэтому повторяем этот выбор. + storage = patch("files.views.FileView.cdn", CDN(storage=configured_storage())) + storage.start() + self.addCleanup(storage.stop) + self.user = create_user(is_active=True) + self.client = APIClient() + self.client.force_authenticate(self.user) + + @patch("files.service.requests.put") + @patch("files.service.requests.post") + def test_upload_and_delete_use_local_instance_storage(self, post, put): + upload = SimpleUploadedFile( + "case.txt", b"synthetic case document", content_type="text/plain" + ) + response = self.client.post( + "/files/?preserve_original=true", {"file": upload}, format="multipart" + ) + self.assertEqual(response.status_code, 201, response.data) + url = response.data["url"] + self.assertTrue( + url.startswith("https://pilot.example.test/backend/media/uploads/") + ) + self.assertTrue(UserFile.objects.filter(link=url, user=self.user).exists()) + from pathlib import Path + + files = [p for p in Path(self.directory.name).rglob("*") if p.is_file()] + self.assertEqual(len(files), 1) + self.assertEqual(files[0].read_bytes(), b"synthetic case document") + deleted = self.client.delete("/files/?" + urlencode({"link": url})) + self.assertEqual(deleted.status_code, 204) + self.assertFalse(files[0].exists()) + self.assertFalse(UserFile.objects.filter(link=url).exists()) + post.assert_not_called() + put.assert_not_called() + + def test_other_owner_cannot_delete_file(self): + other = create_user(is_active=True) + row = UserFile.objects.create( + user=other, + link="https://pilot.example.test/backend/media/uploads/other.txt", + name="case", + extension="txt", + size=10, + ) + response = self.client.delete("/files/?" + urlencode({"link": row.link})) + self.assertEqual(response.status_code, 403) + self.assertTrue(UserFile.objects.filter(pk=row.pk).exists()) + + def test_delete_rejects_external_urls_and_traversal(self): + from files.service import PilotFileStorage + + storage = PilotFileStorage() + for url in ( + "https://outside.example.test/file", + "https://pilot.example.test/backend/media/%2e%2e/secret", + "https://pilot.example.test/backend/media/%2fetc/passwd", + ): + with self.subTest(url=url), self.assertRaises(ValueError): + storage.delete(url) diff --git a/files/views.py b/files/views.py index 776ab8a5..aeab2e2a 100644 --- a/files/views.py +++ b/files/views.py @@ -4,7 +4,7 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from files.service import CDN, SelectelSwiftStorage +from files.service import CDN, configured_storage from files.models import UserFile from files.serializers import UserFileSerializer @@ -14,7 +14,7 @@ class FileView(generics.GenericAPIView): serializer_class = UserFileSerializer queryset = UserFile.objects.all() - cdn = CDN(storage=SelectelSwiftStorage()) + cdn = CDN(storage=configured_storage()) @transaction.atomic def post(self, request): diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 77633333..8cd360da 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -31,6 +31,8 @@ from partner_programs.admin_registration import ProgramRegistrationAdminMixin from partner_programs.admin_publication import ProgramPublicationAdminMixin from partner_programs.admin_certificate_fonts import ProgramCertificateFontsAdminMixin +from partner_programs.admin_criteria import ProgramCriteriaAdminMixin +from partner_programs.admin_overall_results import ProgramOverallResultsAdminMixin from partner_programs.program_dates import ( DATE_FIELDS, program_date_errors, @@ -54,6 +56,8 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin( + ProgramOverallResultsAdminMixin, + ProgramCriteriaAdminMixin, ProgramPublicationAdminMixin, ProgramRegistrationAdminMixin, ProgramCertificateFontsAdminMixin, @@ -121,7 +125,12 @@ class Meta: autocomplete_fields = ("managers",) date_hierarchy = "datetime_started" - readonly_fields = ("datetime_created", "datetime_updated") + readonly_fields = ( + "datetime_created", + "datetime_updated", + "evaluation_criteria_link", + "overall_results_link", + ) fieldsets = ( ( None, @@ -139,6 +148,8 @@ class Meta: "publish_projects_after_finish", "max_project_rates", "is_distributed_evaluation", + "evaluation_criteria_link", + "overall_results_link", "draft", ( "datetime_started", diff --git a/partner_programs/admin_criteria.py b/partner_programs/admin_criteria.py new file mode 100644 index 00000000..e7c40c5f --- /dev/null +++ b/partner_programs/admin_criteria.py @@ -0,0 +1,158 @@ +"""Атомарная настройка критериев и весов через общий organizer service.""" + +from decimal import Decimal + +from django import forms +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.forms import formset_factory +from django.http import Http404, HttpResponseRedirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html +from rest_framework.exceptions import APIException + +from partner_programs.admin_registration import error_text +from partner_programs.services.management import managed_program +from partner_programs.services.management_criteria import criteria_data, save_criteria +from project_rates.scoring import SCORING_MODES + + +class ScoringForm(forms.Form): + scoring_mode = forms.ChoiceField(label="Способ расчета", choices=SCORING_MODES) + expected_updated_at = forms.DateTimeField(widget=forms.HiddenInput) + expected_revision = forms.CharField(widget=forms.HiddenInput) + + +class CriterionForm(forms.Form): + id = forms.IntegerField(required=False, widget=forms.HiddenInput) + name = forms.CharField(label="Название", max_length=50) + description = forms.CharField( + label="Описание", required=False, widget=forms.Textarea(attrs={"rows": 2}) + ) + type = forms.ChoiceField( + label="Тип ответа", + choices=( + ("int", "Целые баллы"), + ("float", "Дробные баллы"), + ("bool", "Да / нет"), + ("str", "Текстовый комментарий"), + ), + initial="int", + ) + min_value = forms.FloatField(label="Минимум", required=False) + max_value = forms.FloatField(label="Максимум", required=False) + weight_percent = forms.DecimalField( + label="Вес, %", + required=False, + max_digits=5, + decimal_places=2, + min_value=Decimal("0.01"), + max_value=Decimal("100"), + ) + + +CriteriaFormSet = formset_factory( + CriterionForm, extra=3, can_delete=True, max_num=100, validate_max=True +) + + +class ProgramCriteriaAdminMixin: + def get_urls(self): + return [ + path( + "/evaluation-criteria/", + self.admin_site.admin_view(self.evaluation_criteria_view), + name="partner_programs_partnerprogram_evaluation_criteria", + ) + ] + super().get_urls() + + def evaluation_criteria_link(self, obj): + if not obj or not obj.pk: + return "Сначала сохраните программу." + url = reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", args=[obj.pk] + ) + return format_html('Критерии, веса и способ расчета', url) + + evaluation_criteria_link.short_description = "Оценивание" + + def evaluation_criteria_view(self, request, program_id): + program = self.get_queryset(request).filter(pk=program_id).first() + if program is None: + raise Http404 + if not self.has_view_or_change_permission(request, program): + raise PermissionDenied + program = managed_program(program_id, request.user) + snapshot = criteria_data(program) + initial = dict( + scoring_mode=snapshot["scoring_mode"], + expected_updated_at=snapshot["updated_at"], + expected_revision=snapshot["revision"], + ) + form = ScoringForm( + request.POST if request.method == "POST" else None, initial=initial + ) + rows = CriteriaFormSet( + request.POST if request.method == "POST" else None, + initial=snapshot["criteria"], + prefix="criteria", + ) + status, error = 200, None + if request.method == "POST": + if not self.has_change_permission(request, program): + raise PermissionDenied + if form.is_valid() and rows.is_valid(): + criteria = [ + { + key: value + for key, value in row.cleaned_data.items() + if key != "DELETE" + } + for row in rows + if row.cleaned_data and not row.cleaned_data.get("DELETE") + ] + for row in criteria: + if row["type"] not in ("int", "float"): + row["min_value"] = row["max_value"] = None + if row.get("id"): + original = next( + (c for c in snapshot["criteria"] if c["id"] == row["id"]), + None, + ) + if ( + original + and original["description"] is None + and row["description"] == "" + ): + row["description"] = None + try: + save_criteria( + program_id, + request.user, + {**form.cleaned_data, "criteria": criteria}, + ) + except APIException as exception: + error, status = error_text(exception.detail), exception.status_code + else: + messages.success(request, "Критерии и веса сохранены.") + return HttpResponseRedirect(request.path) + else: + status = 400 + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "original": program, + "title": "Критерии и веса программы", + "scoring_form": form, + "criteria_formset": rows, + "locked": snapshot["locked"], + "can_change": self.has_change_permission(request, program), + "error": error, + } + return TemplateResponse( + request, + "partner_programs/admin/evaluation_criteria.html", + context, + status=status, + ) diff --git a/partner_programs/admin_overall_results.py b/partner_programs/admin_overall_results.py new file mode 100644 index 00000000..9a15fcf1 --- /dev/null +++ b/partner_programs/admin_overall_results.py @@ -0,0 +1,142 @@ +"""Чтение и экспорт общего рейтинга в admin через тот же organizer service.""" + +from django.contrib import admin +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpResponse, HttpResponseNotAllowed +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html +from rest_framework.exceptions import APIException + +from partner_programs.admin_registration import error_text +from partner_programs.overall_models import ProgramQuizOutcome +from partner_programs.services.management import managed_program +from partner_programs.services.overall_results import STATUS_LABELS, overall_results_data +from partner_programs.services.overall_results_excel import export_overall_results +from partner_programs.services.results import ResultsQuery + + +class ProgramOverallResultsAdminMixin: + def get_urls(self): + return [ + path( + "/overall-results/", + self.admin_site.admin_view(self.overall_results_view), + name="partner_programs_partnerprogram_overall_results", + ), + path( + "/overall-results/export/", + self.admin_site.admin_view(self.overall_results_export_view), + name="partner_programs_partnerprogram_overall_results_export", + ), + ] + super().get_urls() + + def overall_results_link(self, obj): + if not obj or not obj.pk: + return "Сначала сохраните программу." + return format_html( + 'Кейс + квиз, итог из 200', + reverse( + "admin:partner_programs_partnerprogram_overall_results", args=[obj.pk] + ), + ) + + overall_results_link.short_description = "Общий рейтинг" + + def _overall_snapshot(self, request, program_id, *, export=False): + program = self.get_queryset(request).filter(pk=program_id).first() + if program is None: + raise Http404 + if not self.has_view_or_change_permission(request, program): + raise PermissionDenied + program = managed_program(program_id, request.user) + query = ResultsQuery(data=request.GET) + query.is_valid(raise_exception=True) + values = dict(query.validated_data) + if not export: + values["scope"] = "current" + return ( + program, + overall_results_data(program, values), + values, + ) + + def overall_results_view(self, request, program_id): + if request.method not in ("GET", "HEAD"): + return HttpResponseNotAllowed(("GET", "HEAD")) + try: + program, snapshot, query = self._overall_snapshot(request, program_id) + except APIException as error: + return HttpResponse( + error_text(error.detail), + status=error.status_code, + content_type="text/plain; charset=utf-8", + ) + rows = [ + {**row, "status_label": STATUS_LABELS[row["status"]]} + for row in snapshot["projects"] + ] + response = TemplateResponse( + request, + "partner_programs/admin/overall_results.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "original": program, + "title": "Общий рейтинг программы", + "snapshot": snapshot, + "query": query, + "rows": rows, + "export_url": reverse( + "admin:partner_programs_partnerprogram_overall_results_export", + args=[program.pk], + ) + + ("?" + request.GET.urlencode() if request.GET else ""), + "criteria_url": reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", + args=[program.pk], + ), + }, + ) + response["Cache-Control"] = "private, no-store" + return response + + def overall_results_export_view(self, request, program_id): + if request.method not in ("GET", "HEAD"): + return HttpResponseNotAllowed(("GET", "HEAD")) + try: + program, snapshot, _query = self._overall_snapshot( + request, program_id, export=True + ) + except APIException as error: + return HttpResponse( + error_text(error.detail), + status=error.status_code, + content_type="text/plain; charset=utf-8", + ) + response = HttpResponse( + export_overall_results(snapshot), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{program.pk}-overall-results.xlsx"' + response["Cache-Control"] = "private, no-store" + return response + + +@admin.register(ProgramQuizOutcome) +class ProgramQuizOutcomeAdmin(admin.ModelAdmin): + list_display = ("program", "project", "score", "recorded_at") + readonly_fields = ("program", "project", "source_attempt_id", "score", "recorded_at") + search_fields = ("program__name", "project__name") + list_select_related = ("program", "project") + + 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 diff --git a/partner_programs/management/commands/prepare_white_label_pilot.py b/partner_programs/management/commands/prepare_white_label_pilot.py new file mode 100644 index 00000000..1ba75e5b --- /dev/null +++ b/partner_programs/management/commands/prepare_white_label_pilot.py @@ -0,0 +1,17 @@ +"""Подготовить только изолированное демо с синтетическими участниками.""" + +import os + +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + def handle(self, *args, **options): + if not getattr(settings, "PILOT_LOCAL_FILES", False): + raise CommandError("Нужен изолированный профиль procollab.settings_pilot.") + password = os.environ.get("PILOT_ACCOUNT_PASSWORD", "") + if len(password) < 16: + raise CommandError("Задайте PILOT_ACCOUNT_PASSWORD длиной от 16 символов.") + call_command("seed_white_label_demo", password=password, stdout=self.stdout) diff --git a/partner_programs/management/commands/seed_white_label_demo.py b/partner_programs/management/commands/seed_white_label_demo.py new file mode 100644 index 00000000..22d76dd0 --- /dev/null +++ b/partner_programs/management/commands/seed_white_label_demo.py @@ -0,0 +1,162 @@ +"""Повторяемое наполнение только явно разрешенной локальной демо-БД.""" + +import json +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.models import Permission +from django.core.management.base import BaseCommand, CommandError, no_translations +from django.core.management.color import no_style +from django.db import connection, transaction +from django.utils import timezone + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, + ProgramRegistration, +) +from partner_programs.services.native_registration import create_enrollment, save_settings +from procollab.white_label import get_config +from projects.models import Collaborator, Project +from users.models import CustomUser + + +class Command(BaseCommand): + help = "Создать вымышленный КЧ и роли в отдельной локальной white label БД." + + def add_arguments(self, parser): + parser.add_argument("--password", default="WhiteLabel-demo-2026!") + + @no_translations + @transaction.atomic + def handle(self, *args, **options): + database = connection.settings_dict + if not ( + getattr(settings, "WHITE_LABEL_DEMO_ALLOWED", False) + and connection.vendor == "postgresql" + and ( + database["HOST"] in ("127.0.0.1", "localhost", "::1") + or ( + database["HOST"] == "db" + and getattr(settings, "WHITE_LABEL_DEMO_CONTAINER_HOST", None) == "db" + and database["NAME"].startswith("procollab_white_label_pilot") + ) + ) + and database["NAME"].startswith( + ("procollab_white_label", "test_procollab_white_label") + ) + ): + raise CommandError( + "Демо разрешено только с WHITE_LABEL_DEMO_ALLOWED=True " + "на отдельной локальной PostgreSQL БД procollab_white_label*." + ) + config = get_config() + if not config.enabled: + raise CommandError("Включите и настройте WHITE_LABEL_ENABLED.") + if CustomUser.objects.exclude(email__endswith="@example.test").exists(): + raise CommandError( + "БД содержит учетные записи вне вымышленного example.test." + ) + if PartnerProgram.objects.exclude(tag="white-label-demo").exists(): + raise CommandError("БД содержит программы вне этого демо.") + + now = timezone.now() + roles = {} + for role, name in ( + ("admin", "Администратор"), + ("organizer", "Организатор"), + ("expert", "Эксперт"), + ("leader", "Лидер"), + ("member", "Участник"), + ): + user, created = CustomUser.objects.get_or_create( + email=f"{role}@example.test", + defaults={ + "first_name": name, + "last_name": "Демонстрационный", + "birthday": "2000-01-01", + "is_active": True, + "is_staff": role in ("admin", "organizer"), + "is_superuser": role == "admin", + "onboarding_stage": None, + "user_type": CustomUser.EXPERT + if role == "expert" + else CustomUser.MEMBER, + }, + ) + if created: + user.set_password(options["password"]) + user.save(update_fields=["password"]) + roles[role] = user + + program, created = PartnerProgram.objects.get_or_create( + pk=config.program_id, + defaults={ + "name": f"Демо кейс-чемпионата «{config.brand_name}»", + "tag": "white-label-demo", + "city": "Тестовая среда", + "description": "Вымышленные данные для проверки отдельного экземпляра.", + "draft": False, + "is_competitive": True, + "program_format": "case_championship", + "datetime_started": now, + "datetime_finished": now + timedelta(days=30), + "datetime_registration_ends": now + timedelta(days=30), + }, + ) + if program.tag != "white-label-demo": + raise CommandError("ID настроенной программы занят другими данными.") + if created: + with connection.cursor() as cursor: + for statement in connection.ops.sequence_reset_sql( + no_style(), [PartnerProgram] + ): + cursor.execute(statement) + program.managers.add(roles["organizer"]) + roles["expert"].expert.programs.add(program) + roles["organizer"].user_permissions.add( + *Permission.objects.filter( + content_type__app_label__in=("partner_programs", "project_rates"), + ) + ) + + schema = {"mode": "native", "fields": []} + if not ProgramRegistration.objects.filter(program=program).exists(): + save_settings( + program.pk, + roles["organizer"], + {"revision": 0, "draft": schema}, + publish=True, + ) + for role in ("leader", "member"): + create_enrollment(program, roles[role], {}, schema) + leader = roles["leader"] + project, _ = Project.objects.get_or_create( + leader=leader, + name="Демонстрационная команда", + defaults={"description": "Вымышленная команда", "draft": False}, + ) + for role in ("leader", "member"): + Collaborator.objects.get_or_create(project=project, user=roles[role]) + link, _ = PartnerProgramProject.objects.get_or_create( + partner_program=program, project=project + ) + PartnerProgramUserProfile.objects.filter( + partner_program=program, user__in=[roles["leader"], roles["member"]] + ).update(project=project) + + self.stdout.write( + json.dumps( + { + "program_id": program.pk, + "project_id": project.pk, + "program_project_id": link.pk, + "accounts": {role: user.email for role, user in roles.items()}, + "registration_url": ( + f"{config.frontend_url}/program-registration/{program.pk}" + ), + }, + ensure_ascii=False, + ) + ) diff --git a/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py b/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py new file mode 100644 index 00000000..7d879308 --- /dev/null +++ b/partner_programs/migrations/0044_partnerprogram_evaluation_scoring_mode.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.18 on 2026-11-11 19:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0043_project_copy_form"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="evaluation_scoring_mode", + field=models.CharField( + choices=[ + ("legacy", "Сумма исходных баллов"), + ("weighted_percent", "Веса, итог от 0 до 100"), + ], + default="legacy", + db_default="legacy", + max_length=20, + verbose_name="Способ расчета оценки", + ), + ), + ] diff --git a/partner_programs/migrations/0045_program_quiz_outcome.py b/partner_programs/migrations/0045_program_quiz_outcome.py new file mode 100644 index 00000000..c2dcca24 --- /dev/null +++ b/partner_programs/migrations/0045_program_quiz_outcome.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.18 on 2026-11-12 15:52 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0044_partnerprogram_evaluation_scoring_mode"), + ("projects", "0036_neutral_default_images"), + ] + + operations = [ + migrations.CreateModel( + name="ProgramQuizOutcome", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "source_attempt_id", + models.CharField( + max_length=128, + unique=True, + verbose_name="Идентификатор завершенной попытки", + ), + ), + ( + "score", + models.DecimalField( + decimal_places=2, + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")), + django.core.validators.MaxValueValidator(Decimal("100")), + ], + verbose_name="Балл квиза из 100", + ), + ), + ( + "recorded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Результат получен" + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="quiz_outcomes", + to="partner_programs.partnerprogram", + verbose_name="Программа", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="quiz_outcomes", + to="projects.project", + verbose_name="Команда (проект)", + ), + ), + ], + options={ + "verbose_name": "Результат командного квиза", + "verbose_name_plural": "Результаты командных квизов", + "constraints": [ + models.UniqueConstraint( + fields=("program", "project"), name="quiz_outcome_one_per_team" + ), + models.CheckConstraint( + condition=models.Q(("score__gte", 0), ("score__lte", 100)), + name="quiz_outcome_score_0_100", + ), + ], + }, + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 19f1df93..1247c3b6 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -11,6 +11,7 @@ ProgramCertificateConfig, ) from partner_programs.constants import get_default_data_schema +from partner_programs.overall_models import ProgramQuizOutcome # noqa: F401 from partner_programs.solution_models import ( # noqa: F401 ProgramSolution, ProgramSolutionMember, @@ -213,6 +214,16 @@ class PartnerProgram(models.Model): ) evaluation_criteria_locked_at = models.DateTimeField(null=True, editable=False) evaluation_criteria_version = models.PositiveIntegerField(default=0, editable=False) + evaluation_scoring_mode = models.CharField( + max_length=20, + default="legacy", + db_default="legacy", + choices=( + ("legacy", "Сумма исходных баллов"), + ("weighted_percent", "Веса, итог от 0 до 100"), + ), + verbose_name="Способ расчета оценки", + ) data_schema = models.JSONField( verbose_name="Схема данных в формате JSON", help_text="Ключи - имена полей, значения - тип поля ввода", @@ -301,6 +312,7 @@ class PartnerProgram(models.Model): def clean(self): super().clean() self.validate_solution_template_change() + self.validate_scoring_mode_change() from partner_programs.project_form import PROJECT_SECTIONS if not isinstance(self.project_sections, dict) or any( @@ -340,14 +352,44 @@ def validate_solution_template_change(self, previous=None): {"solution_template": "После появления ответов шаблон нельзя менять."} ) + def validate_scoring_mode_change(self, previous=None): + from project_rates.criteria_policy import locked + from project_rates.scoring import LEGACY, validate_configuration + + if not self.pk: + if self.evaluation_scoring_mode != LEGACY: + raise ValidationError( + { + "evaluation_scoring_mode": "Сначала создайте программу и настройте критерии." + } + ) + return + if previous is None: + previous = type(self).objects.filter(pk=self.pk).first() + if previous and previous.evaluation_scoring_mode != self.evaluation_scoring_mode: + if locked(previous): + raise ValidationError( + { + "evaluation_scoring_mode": "После первой оценки способ расчета нельзя менять." + } + ) + validate_configuration(self) + def save(self, *args, **kwargs): fields = kwargs.get("update_fields") - if self.pk and (fields is None or "solution_template" in fields): + if not self.pk: + self.validate_scoring_mode_change() + if self.pk and ( + fields is None + or {"solution_template", "evaluation_scoring_mode"}.intersection(fields) + ): with transaction.atomic(): previous = ( type(self).objects.select_for_update().filter(pk=self.pk).first() ) self.validate_solution_template_change(previous) + if fields is None or "evaluation_scoring_mode" in fields: + self.validate_scoring_mode_change(previous) # Обычный save устаревшей карточки не отменяет операторский откат. # Служебный запуск меняется явным update_fields под program lock. if previous and ( diff --git a/partner_programs/overall_models.py b/partner_programs/overall_models.py new file mode 100644 index 00000000..b51beced --- /dev/null +++ b/partner_programs/overall_models.py @@ -0,0 +1,118 @@ +"""Окончательный нормализованный результат одного квиза команды в программе.""" + +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from project_rates.scoring import decimal_number + + +def normalized_stage_score(value): + try: + number = decimal_number(value) + if not 0 <= number <= 100 or number != number.quantize(Decimal("0.01")): + raise ValueError() + return number.quantize(Decimal("0.01")) + except (ValueError, ArithmeticError) as error: + raise ValidationError( + "Укажите балл этапа от 0 до 100, до двух знаков после запятой." + ) from error + + +class QuizOutcomeQuerySet(models.QuerySet): + def update(self, **kwargs): + raise ValidationError("Завершенный результат квиза нельзя изменять.") + + def bulk_update(self, objs, fields, batch_size=None): + raise ValidationError("Завершенный результат квиза нельзя изменять.") + + def bulk_create(self, objs, **kwargs): + raise ValidationError( + "Используйте серверную запись результата завершенной попытки." + ) + + +class ProgramQuizOutcome(models.Model): + program = models.ForeignKey( + "partner_programs.PartnerProgram", + on_delete=models.CASCADE, + related_name="quiz_outcomes", + verbose_name="Программа", + ) + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="quiz_outcomes", + verbose_name="Команда (проект)", + ) + source_attempt_id = models.CharField( + max_length=128, + unique=True, + verbose_name="Идентификатор завершенной попытки", + ) + score = models.DecimalField( + max_digits=5, + decimal_places=2, + verbose_name="Балл квиза из 100", + validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("100"))], + ) + recorded_at = models.DateTimeField( + auto_now_add=True, verbose_name="Результат получен" + ) + objects = QuizOutcomeQuerySet.as_manager() + + class Meta: + verbose_name = "Результат командного квиза" + verbose_name_plural = "Результаты командных квизов" + constraints = [ + models.UniqueConstraint( + fields=("program", "project"), name="quiz_outcome_one_per_team" + ), + models.CheckConstraint( + condition=models.Q(score__gte=0, score__lte=100), + name="quiz_outcome_score_0_100", + ), + ] + + def __str__(self): + return f"{self.program} / {self.project}: {self.score}" + + def clean(self): + super().clean() + self.score = normalized_stage_score(self.score) + if ( + not self.source_attempt_id + or self.source_attempt_id != self.source_attempt_id.strip() + ): + raise ValidationError( + { + "source_attempt_id": "Укажите непустой идентификатор без пробелов по краям." + } + ) + if not self.pk: + from partner_programs.models import PartnerProgramProject + + if not PartnerProgramProject.objects.filter( + partner_program_id=self.program_id, + project_id=self.project_id, + ).exists(): + raise ValidationError("Команда не зарегистрирована в этой программе.") + if self.pk: + previous = type(self).objects.filter(pk=self.pk).first() + if previous and any( + getattr(previous, name) != getattr(self, name) + for name in ( + "program_id", + "project_id", + "source_attempt_id", + "score", + "recorded_at", + ) + ): + raise ValidationError("Завершенный результат квиза нельзя изменять.") + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/partner_programs/overall_views.py b/partner_programs/overall_views.py new file mode 100644 index 00000000..072482f9 --- /dev/null +++ b/partner_programs/overall_views.py @@ -0,0 +1,35 @@ +"""Общий рейтинг доступен только через существующие права организатора.""" + +from django.http import HttpResponse +from rest_framework.response import Response + +from partner_programs.management_views import ManagementBase +from partner_programs.services.management import managed_program +from partner_programs.services.overall_results import overall_results_data +from partner_programs.services.overall_results_excel import export_overall_results +from partner_programs.services.results import ResultsQuery + + +class ManagementOverallResultsView(ManagementBase): + def get(self, request, pk): + program = managed_program(pk, request.user) + query = ResultsQuery(data=request.query_params) + query.is_valid(raise_exception=True) + values = {**query.validated_data, "scope": "current"} + return Response(overall_results_data(program, values)) + + +class ManagementOverallResultsExportView(ManagementBase): + def get(self, request, pk): + program = managed_program(pk, request.user) + query = ResultsQuery(data=request.query_params) + query.is_valid(raise_exception=True) + snapshot = overall_results_data(program, query.validated_data) + response = HttpResponse( + export_overall_results(snapshot), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{pk}-overall-results.xlsx"' + return response diff --git a/partner_programs/registration_views.py b/partner_programs/registration_views.py index 067768ff..95cb71d8 100644 --- a/partner_programs/registration_views.py +++ b/partner_programs/registration_views.py @@ -15,6 +15,7 @@ from partner_programs.services import native_registration as service from project_rates.workspace_views import PrivateView from users.models import CustomUser +from procollab.white_label import is_white_label_program class RegistrationInput(StrictFieldsMixin, serializers.Serializer): @@ -57,9 +58,9 @@ def validate(self, data): raise serializers.ValidationError( {"accept_policy": "Подтвердите согласие с условиями платформы."} ) - if ( - not data["accept_age"] - or (timezone.now().date() - data["birthday"]).days < 14 * 365 + if not data["accept_age"] or ( + data["birthday"] is not None + and (timezone.now().date() - data["birthday"]).days < 14 * 365 ): raise serializers.ValidationError( {"birthday": "Для регистрации участнику должно быть не менее 14 лет."} @@ -80,6 +81,39 @@ def validate(self, data): return data +class WhiteLabelSignupWrite(SignupWrite): + def validate_email(self, value): + from django.conf import settings + + domain = getattr(settings, "WHITE_LABEL_DEMO_EMAIL_DOMAIN", "") + if domain and value.rsplit("@", 1)[-1].lower() != domain: + raise serializers.ValidationError( + "В демо используйте адрес с доменом example.test." + ) + return value + + first_name = serializers.CharField( + max_length=255, + validators=CustomUser._meta.get_field("first_name").validators, + required=False, + allow_blank=True, + default="", + ) + last_name = serializers.CharField( + max_length=255, + validators=CustomUser._meta.get_field("last_name").validators, + required=False, + allow_blank=True, + default="", + ) + birthday = serializers.DateField( + validators=CustomUser._meta.get_field("birthday").validators, + required=False, + allow_null=True, + default=None, + ) + + class TokenWrite(RegistrationInput): token = serializers.CharField(max_length=2000) @@ -173,9 +207,8 @@ def post(self, request, pk, action): raise serializers.ValidationError( "Для своего аккаунта отправьте только анкету." ) - return Response( - service.signup(pk, validated(SignupWrite, request.data)), status=201 - ) + form = WhiteLabelSignupWrite if is_white_label_program(pk) else SignupWrite + return Response(service.signup(pk, validated(form, request.data)), status=201) if action in ("confirm", "resend"): data = validated(TokenWrite, request.data) return Response(getattr(service, action)(pk, data["token"])) diff --git a/partner_programs/services/management_criteria.py b/partner_programs/services/management_criteria.py index 387ecfa7..5d2708a9 100644 --- a/partner_programs/services/management_criteria.py +++ b/partner_programs/services/management_criteria.py @@ -1,6 +1,8 @@ """Atomic editing of the shared program criteria, before the first score.""" import math +from decimal import Decimal +from types import SimpleNamespace from django.db import transaction from django.utils import timezone @@ -11,6 +13,8 @@ from partner_programs.services.management import managed_program from project_rates.criteria_policy import CriteriaConflict, definitions, locked, revision from project_rates.models import Criteria +from project_rates.scoring import SCORING_MODES, validate_configuration +from django.core.exceptions import ValidationError as ModelValidationError SUPPORTED_TYPES = ("int", "bool", "str") @@ -24,12 +28,22 @@ class CriterionWrite(StrictFieldsMixin, serializers.Serializer): type = serializers.ChoiceField(choices=(*SUPPORTED_TYPES, "float")) min_value = serializers.FloatField(allow_null=True) max_value = serializers.FloatField(allow_null=True) + weight_percent = serializers.DecimalField( + max_digits=5, + decimal_places=2, + min_value=Decimal("0.01"), + max_value=Decimal("100"), + required=False, + allow_null=True, + coerce_to_string=False, + ) class CriteriaWrite(StrictFieldsMixin, serializers.Serializer): expected_updated_at = serializers.DateTimeField() expected_revision = serializers.CharField(max_length=64) criteria = CriterionWrite(many=True) + scoring_mode = serializers.ChoiceField(choices=SCORING_MODES, required=False) def validate_criteria(self, rows): ids = [r["id"] for r in rows if r.get("id") is not None] @@ -48,6 +62,7 @@ def criteria_data(program): # Historical scores have no timestamps; do not present the backfill date as first score. "supported_types": SUPPORTED_TYPES, "criteria": definitions(program), + "scoring_mode": program.evaluation_scoring_mode, } @@ -69,7 +84,28 @@ def save_criteria(program_id, user, payload): "stale_criteria", "Настройки изменены. Обновите действующие критерии." ) existing = {c.pk: c for c in program.criterias.all()} + mode = data.get("scoring_mode", program.evaluation_scoring_mode) + candidates = [] + for row in data["criteria"]: + old = existing.get(row.get("id")) + row.setdefault("weight_percent", old.weight_percent if old else None) + candidates.append(SimpleNamespace(**row)) + try: + from project_rates.scoring import validate_weight + + for candidate in candidates: + validate_weight(candidate) + validate_configuration(SimpleNamespace(evaluation_scoring_mode=mode), candidates) + except (ModelValidationError, ValueError) as error: + raise serializers.ValidationError( + {"criteria": getattr(error, "messages", [str(error)])} + ) from error + # Режим и весь набор видны другим транзакциям только после commit. Все записи + # оценок используют тот же program lock; частичное состояние недоступно. + PartnerProgram.objects.filter(pk=program.pk).update(evaluation_scoring_mode=mode) + program.evaluation_scoring_mode = mode keep = [] + created = [] for row in data["criteria"]: pk = row.pop("id", None) old = existing.get(pk) @@ -77,7 +113,11 @@ def save_criteria(program_id, user, payload): raise serializers.ValidationError( {"criteria": "Критерий не принадлежит этой программе."} ) - unchanged = old and all(getattr(old, key) == value for key, value in row.items()) + unchanged = old and all( + getattr(old, key) == value + for key, value in row.items() + if key != "weight_percent" + ) if not unchanged: if row["type"] not in SUPPORTED_TYPES: raise serializers.ValidationError( @@ -105,12 +145,18 @@ def save_criteria(program_id, user, payload): Criteria.objects.filter(pk=pk).update(**row) keep.append(pk) else: - keep.append(Criteria.objects.create(partner_program=program, **row).pk) + criterion = Criteria(partner_program=program, **row) + criterion.full_clean() + created.append(criterion) + Criteria.objects.bulk_create(created) + keep.extend(criterion.pk for criterion in created) program.criterias.exclude(pk__in=keep).delete() program.evaluation_criteria_version += 1 + program.evaluation_scoring_mode = mode program.datetime_updated = timezone.now() PartnerProgram.objects.filter(pk=program.pk).update( evaluation_criteria_version=program.evaluation_criteria_version, datetime_updated=program.datetime_updated, + evaluation_scoring_mode=mode, ) return criteria_data(program) diff --git a/partner_programs/services/native_registration.py b/partner_programs/services/native_registration.py index 6224094a..9f9c3eb0 100644 --- a/partner_programs/services/native_registration.py +++ b/partner_programs/services/native_registration.py @@ -25,6 +25,11 @@ from partner_programs.services.management import managed_program from project_rates.workspace import WorkspaceConflict from users.models import CustomUser, University +from procollab.white_label import ( + frontend_url as instance_frontend_url, + get_config as get_instance_config, + is_white_label_program, +) SALT = "program-native-confirm-v1" RECEIPT_SALT = "program-native-resend-v1" @@ -33,7 +38,7 @@ def frontend_url(): - return settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + return instance_frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) def config_for(program): @@ -253,7 +258,7 @@ def public_data(program, user): partner_program=program, user=user ).exists() ) - return { + data = { "id": program.pk, "name": program.name, "version": config.published_version, @@ -264,6 +269,15 @@ def public_data(program, user): "program_url": f"/office/program/{program.pk}", "registration_ends": program.datetime_registration_ends, } + if is_white_label_program(program.pk): + data["account_mode"] = "email_password" + brand = get_instance_config() + data["branding"] = { + "name": brand.brand_name, + "logo_url": brand.logo_url, + "logo_background": brand.logo_background, + } + return data def validate_answers(schema, answers): diff --git a/partner_programs/services/overall_results.py b/partner_programs/services/overall_results.py new file mode 100644 index 00000000..6a2365d9 --- /dev/null +++ b/partner_programs/services/overall_results.py @@ -0,0 +1,160 @@ +"""Общий командный рейтинг: официальный кейс из 100 плюс завершенный квиз из 100.""" + +from decimal import Decimal + +from django.utils import timezone +from rest_framework.exceptions import APIException, ValidationError + +from partner_programs.overall_models import ProgramQuizOutcome, normalized_stage_score +from partner_programs.services.case_analytics import project_case_links +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.results import results_data +from project_rates.evaluation_math import assign_places +from project_rates.scoring import WEIGHTED + +STATUS_LABELS = { + "configuration_required": "Не настроены веса критериев кейса", + "pending_both": "Ожидает кейс и квиз", + "pending_case": "Ожидает кейс", + "pending_quiz": "Ожидает квиз", + "complete": "В рейтинге", +} + + +class OverallResultsUnavailable(APIException): + status_code = 409 + default_code = "overall_results_unavailable" + default_detail = "Общий рейтинг доступен для конкурсной программы с проектами." + + +def overall_results_data(program, query): + if not program.is_competitive or program.solution_storage_mode != "legacy_project": + raise OverallResultsUnavailable() + # Оценки кейса считаем тем же сервисом, с теми же экспертами и округлением. + case_data = results_data( + program, + { + "search": "", + "case_scope": "all", + "scope": "all", + "order": "desc", + "status": "all", + }, + ) + case_rows = { + row["id"]: row for group in case_data["groups"] for row in group["projects"] + } + configured = program.evaluation_scoring_mode == WEIGHTED + links = list( + project_case_links(program, field=get_program_case_field(program)) + .select_related("project") + .order_by("pk") + ) + quizzes = dict( + ProgramQuizOutcome.objects.filter( + program=program, + project_id__in=[link.project_id for link in links], + ).values_list("project_id", "score") + ) + rows = [] + for link in links: + case = case_rows.get(link.pk) + case_score = ( + normalized_stage_score(case["score"]) + if configured and case and case["score"] is not None + else None + ) + case_complete = bool( + configured and case and case["ready"] and case_score is not None + ) + quiz_score = quizzes.get(link.project_id) + quiz_complete = quiz_score is not None + ready = case_complete and quiz_complete + case_status = ( + "not_configured" + if not configured + else "not_submitted" + if not link.submitted + else "complete" + if case_complete + else "pending" + ) + status = ( + "configuration_required" + if not configured + else "complete" + if ready + else "pending_both" + if not case_complete and not quiz_complete + else "pending_case" + if not case_complete + else "pending_quiz" + ) + rows.append( + { + "id": link.pk, + "project_id": link.project_id, + "name": link.project.name, + "case_name": link.case_name, + "submitted": link.submitted, + "case_score": float(case_score) if case_score is not None else None, + "case_complete": case_complete, + "case_status": case_status, + "case_evaluators_completed": case["completed"] if case else 0, + "case_evaluators_required": case_data["required_evaluators"], + "quiz_score": float(quiz_score) if quiz_score is not None else None, + "quiz_complete": quiz_complete, + "score": case_score + quiz_score if ready else None, + "ready": ready, + "status": status, + "place": None, + } + ) + assign_places(rows, "id") + total_count, total_ready = len(rows), sum(row["ready"] for row in rows) + if ( + query["case_scope"] == "selected" + and query.get("case_name") not in case_data["case_options"] + ): + raise ValidationError({"case_name": "Выберите существующий кейс."}) + if query.get("scope", "current") != "all": + if query["case_scope"] == "selected": + rows = [row for row in rows if row["case_name"] == query["case_name"]] + elif query["case_scope"] == "without_case": + rows = [row for row in rows if row["case_name"] is None] + rows = [ + row for row in rows if query.get("search", "").lower() in row["name"].lower() + ] + if query.get("status", "all") != "all": + rows = [ + row for row in rows if row["ready"] == (query["status"] == "complete") + ] + sign = -1 if query.get("order", "desc") == "desc" else 1 + rows.sort( + key=lambda row: ( + not row["ready"], + sign * (row["score"] or Decimal("0")), + row["name"].lower(), + row["id"], + ) + ) + for row in rows: + if row["score"] is not None: + row["score"] = float(row["score"]) + return { + "program_id": program.pk, + "program_name": program.name, + "generated_at": timezone.now(), + "formula": "case_plus_quiz", + "case_maximum": 100, + "quiz_maximum": 100, + "maximum": 200, + "case_scoring_mode": program.evaluation_scoring_mode, + "case_scoring_configured": configured, + "case_options": case_data["case_options"], + "total_count": total_count, + "total_ready_count": total_ready, + "matching_count": len(rows), + "ready_count": sum(row["ready"] for row in rows), + "projects": rows, + } diff --git a/partner_programs/services/overall_results_excel.py b/partner_programs/services/overall_results_excel.py new file mode 100644 index 00000000..cc01ffac --- /dev/null +++ b/partner_programs/services/overall_results_excel.py @@ -0,0 +1,60 @@ +"""Простая XLSX-выгрузка того же общего рейтинга, который отдает API.""" + +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font +from openpyxl.workbook.properties import CalcProperties + +from partner_programs.services.overall_results import STATUS_LABELS +from partner_programs.services.results_excel import text_cell + + +def export_overall_results(snapshot): + workbook = Workbook() + workbook.calculation = CalcProperties(fullCalcOnLoad=True) + sheet = workbook.active + sheet.title = "Общий рейтинг" + sheet.append( + [ + "Место", + "Команда", + "Кейс", + "Кейс из 100", + "Квиз из 100", + "Итог из 200", + "Статус", + ] + ) + for number, project in enumerate(snapshot["projects"], 2): + sheet.cell(number, 1, project["place"]) + text_cell(sheet, number, 2, project["name"]) + text_cell(sheet, number, 3, project["case_name"] or "Без кейса") + for column, key in ((4, "case_score"), (5, "quiz_score")): + if project[key] is not None: + sheet.cell(number, column, project[key]) + if project["ready"]: + sheet.cell(number, 6, f"=ROUND(D{number}+E{number},2)") + text_cell(sheet, number, 7, STATUS_LABELS[project["status"]]) + for row in sheet: + for cell in row: + cell.font = Font(name="Arial", size=10, bold=cell.row == 1) + cell.alignment = Alignment(vertical="top", wrap_text=True) + if cell.row > 1 and cell.column in (4, 5, 6): + cell.number_format = "0.00" + for column, width in ( + ("A", 10), + ("B", 34), + ("C", 26), + ("D", 18), + ("E", 18), + ("F", 18), + ("G", 36), + ): + sheet.column_dimensions[column].width = width + sheet.row_dimensions[1].height = 30 + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + output = BytesIO() + workbook.save(output) + return output.getvalue() diff --git a/partner_programs/services/quiz_outcomes.py b/partner_programs/services/quiz_outcomes.py new file mode 100644 index 00000000..000d135c --- /dev/null +++ b/partner_programs/services/quiz_outcomes.py @@ -0,0 +1,82 @@ +"""Внутренний адаптер будущего квиза; публичных способов ввода балла нет.""" + +from django.core.exceptions import ValidationError as ModelValidationError +from django.db import IntegrityError, transaction +from django.http import Http404 +from rest_framework.exceptions import APIException, ValidationError + +from partner_programs.overall_models import ProgramQuizOutcome, normalized_stage_score +from projects.team_policy import lock_team + + +class QuizOutcomeConflict(APIException): + status_code = 409 + default_code = "quiz_outcome_conflict" + default_detail = "У команды уже есть другой результат завершенного квиза." + + +@transaction.atomic +def record_quiz_outcome(program_id, project_id, source_attempt_id, score): + """Вызывать только серверным модулем после завершения и расчета попытки. + + ID попытки глобально уникален; при локальных ID модуль добавляет свой namespace. + Повтор того же события не меняет балл/время и не создает вторую запись. + """ + try: + value = normalized_stage_score(score) + except ModelValidationError as error: + raise ValidationError({"score": error.messages}) from error + if ( + not isinstance(source_attempt_id, str) + or not source_attempt_id.strip() + or source_attempt_id != source_attempt_id.strip() + or len(source_attempt_id) > 128 + ): + raise ValidationError( + {"source_attempt_id": "Укажите непустой идентификатор до 128 символов."} + ) + context = lock_team(project_id, extra_program_ids=[program_id]) + program = context.programs.get(program_id) + if program is None or not any( + link.partner_program_id == program_id for link in context.links + ): + raise Http404 + if not program.is_competitive or program.solution_storage_mode != "legacy_project": + raise ValidationError( + "Результат квиза поддерживается для конкурсной программы с проектами." + ) + existing = ProgramQuizOutcome.objects.filter( + program=program, project=context.project + ).first() + if existing: + if existing.source_attempt_id == source_attempt_id and existing.score == value: + return existing + raise QuizOutcomeConflict() + if ProgramQuizOutcome.objects.filter(source_attempt_id=source_attempt_id).exists(): + raise QuizOutcomeConflict("Эта попытка уже относится к другой команде.") + outcome = ProgramQuizOutcome( + program=program, + project=context.project, + source_attempt_id=source_attempt_id, + score=value, + ) + try: + with transaction.atomic(): + outcome.save() + except (IntegrityError, ModelValidationError) as error: + if ProgramQuizOutcome.objects.filter( + source_attempt_id=source_attempt_id + ).exists(): + raise QuizOutcomeConflict( + "Эта попытка уже относится к другой команде." + ) from error + if ProgramQuizOutcome.objects.filter( + program=program, project=context.project + ).exists(): + raise QuizOutcomeConflict() from error + if isinstance(error, ModelValidationError): + raise ValidationError( + getattr(error, "message_dict", error.messages) + ) from error + raise + return outcome diff --git a/partner_programs/services/results.py b/partner_programs/services/results.py index b1c06db2..0c16fb68 100644 --- a/partner_programs/services/results.py +++ b/partner_programs/services/results.py @@ -29,6 +29,9 @@ class ResultsQuery(serializers.Serializer): def results_data(program, query, *, project_link_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program, criteria) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] field = get_program_case_field(program) options = field.get_options_list() if field else [] @@ -53,7 +56,9 @@ def results_data(program, query, *, project_link_id=None): ).select_related("expert__user"): people[assignment.project_id][assignment.expert.user_id] = assignment.expert.user required = program.required_evaluators or program.max_project_rates or 1 - maximum = evaluation_totals(criteria, {})["maximum"] + maximum = evaluation_totals( + criteria, {}, scoring_mode=program.evaluation_scoring_mode + )["maximum"] buckets = {name: [] for name in [*options, None]} for link in links: totals = evaluation_totals( @@ -62,6 +67,7 @@ def results_data(program, query, *, project_link_id=None): user_id: values[(link.project_id, user_id)] for user_id in people[link.project_id] }, + scoring_mode=program.evaluation_scoring_mode, ) experts = [] for user_id, user in sorted(people[link.project_id].items()): @@ -99,6 +105,7 @@ def results_data(program, query, *, project_link_id=None): "score": numeric(score), "place": None, "averages": averages, + "contributions": totals["contributions"], "experts": experts, } ) @@ -137,6 +144,7 @@ def results_data(program, query, *, project_link_id=None): return { "program_id": program.pk, "program_name": program.name, + "scoring_mode": program.evaluation_scoring_mode, "generated_at": timezone.now(), "required_evaluators": required, "maximum": numeric(maximum), @@ -151,6 +159,9 @@ def results_data(program, query, *, project_link_id=None): "type": c.type, "min_value": c.min_value, "max_value": c.max_value, + "weight_percent": float(c.weight_percent) + if c.weight_percent is not None + else None, } for c in criteria ], diff --git a/partner_programs/services/results_excel.py b/partner_programs/services/results_excel.py index bacd8c20..28c39734 100644 --- a/partner_programs/services/results_excel.py +++ b/partner_programs/services/results_excel.py @@ -4,10 +4,11 @@ from math import isfinite from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill +from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter from openpyxl.workbook.properties import CalcProperties from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE +from project_rates.scoring import WEIGHTED, decimal_number def text_cell(sheet, row, column, value): @@ -40,6 +41,15 @@ def _export_results(data, *, label, group_key, id_key): criteria = data["criteria"] numeric = [c for c in criteria if c["type"] in ("int", "float")] other = [c for c in criteria if c not in numeric] + weighted = data.get("scoring_mode") == WEIGHTED + definitions = None + if weighted: + definitions = workbook.create_sheet("Критерии") + definitions.append(["Критерий", "Минимум", "Максимум", "Вес, %"]) + for index, criterion in enumerate(numeric, 2): + text_cell(definitions, index, 1, criterion["name"]) + for col, key in enumerate(("min_value", "max_value", "weight_percent"), 2): + definitions.cell(index, col, float(decimal_number(criterion[key]))) rating.append( [ "Место", @@ -61,7 +71,7 @@ def _export_results(data, *, label, group_key, id_key): "Email", "Статус", *[c["name"] for c in numeric], - "Сумма", + "Итог из 100" if weighted else "Сумма", *[c["name"] for c in other], "ID решения" if id_key == "solution_id" else "ID проекта", "ID эксперта", @@ -99,10 +109,18 @@ def _export_results(data, *, label, group_key, id_key): if expert["complete"]: expert_rows.append(row) if numeric: + formula = f"=SUM(F{row}:{get_column_letter(total_col - 1)}{row})" + if weighted: + terms = [ + f"({get_column_letter(col)}{row}-'Критерии'!B{index})" + f"/('Критерии'!C{index}-'Критерии'!B{index})*'Критерии'!D{index}" + for index, col in enumerate(range(6, total_col), 2) + ] + formula = "=SUM(" + ",".join(terms) + ")" experts.cell( row, total_col, - f"=SUM(F{row}:{get_column_letter(total_col - 1)}{row})", + formula, ) for col, criterion in enumerate(other, total_col + 1): value = values.get(criterion["id"]) @@ -154,6 +172,15 @@ def _export_results(data, *, label, group_key, id_key): cell.fill = PatternFill("solid", fgColor="8A63E6") for col in range(1, sheet.max_column + 1): sheet.column_dimensions[get_column_letter(col)].width = 24 + if definitions: + definitions.freeze_panes = "A2" + definitions.auto_filter.ref = definitions.dimensions + for row in definitions: + for cell in row: + cell.font = Font(name="Arial", size=10, bold=cell.row == 1) + cell.alignment = Alignment(vertical="top", wrap_text=True) + for column, width in (("A", 42), ("B", 16), ("C", 16), ("D", 16)): + definitions.column_dimensions[column].width = width output = BytesIO() workbook.save(output) return output.getvalue() diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 18e8c207..1cfb6b10 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -84,6 +84,7 @@ def send_program_registration_confirmation(pending_id, generation): from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from urllib.parse import urlencode + from procollab.white_label import get_config from partner_programs.models import PendingProgramRegistration from partner_programs.services.native_registration import ( confirmation_token, @@ -96,15 +97,17 @@ def send_program_registration_confirmation(pending_id, generation): pending = rows.select_related("program", "user").first() if not pending: return + brand = get_config() context = { "program_name": pending.program.name, "first_name": pending.user.first_name, + "brand_name": brand.brand_name, "confirmation_url": f"{frontend_url()}/program-registration/{pending.program_id}#" + urlencode({"confirmation": confirmation_token(pending)}), } try: message = EmailMultiAlternatives( - "Подтвердите регистрацию в программе | PROCOLLAB", + f"Подтвердите регистрацию в программе | {brand.brand_name}", render_to_string("email/program_registration.txt", context), settings.EMAIL_USER, [pending.user.email], @@ -150,6 +153,7 @@ def publish_finished_program_solutions_task() -> int: @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) def send_organizer_invitation(invitation_id, token): + from procollab.white_label import get_config, frontend_url from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string @@ -166,18 +170,23 @@ def send_organizer_invitation(invitation_id, token): invitation = rows.select_related("partner_program").first() if not invitation: return + brand = get_config() context = { "program_name": invitation.partner_program.name, "email": invitation.email, - "invitation_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + "invitation_url": frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + "/organizer-invitation/" + token, "expires_at": invitation.expires_at, - "logo_url": "https://786505.selcdn.ru/app_files/procollab_logo.png", + "logo_url": brand.logo_url + if brand.enabled + else "https://786505.selcdn.ru/app_files/procollab_logo.png", + "brand_name": brand.brand_name, + "logo_background": brand.logo_background, } try: message = EmailMultiAlternatives( - "Приглашение организатора | PROCOLLAB", + f"Приглашение организатора | {brand.brand_name}", render_to_string("email/organizer_invitation.txt", context), settings.EMAIL_USER, [invitation.email], diff --git a/partner_programs/tests/test_overall_results.py b/partner_programs/tests/test_overall_results.py new file mode 100644 index 00000000..e6c4d428 --- /dev/null +++ b/partner_programs/tests/test_overall_results.py @@ -0,0 +1,543 @@ +from decimal import Decimal +from io import BytesIO + +from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError as ModelValidationError +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + ProgramQuizOutcome, +) +from partner_programs.services.quiz_outcomes import ( + QuizOutcomeConflict, + record_quiz_outcome, +) +from project_rates.models import Criteria, ProjectScore +from project_rates.tests.helpers import ( + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +def weighted_program(**kwargs): + program = create_rate_program(is_competitive=True, required_evaluators=2, **kwargs) + program.criterias.all().delete() + criterion = Criteria.objects.create( + partner_program=program, + name="Оценка кейса", + type="int", + min_value=0, + max_value=10, + weight_percent=100, + ) + program.evaluation_scoring_mode = "weighted_percent" + program.save() + return program, criterion + + +def team(program, *, name="Команда", case_field=None, case_name="Право", submitted=True): + project = create_rate_project(name=name) + project.name = name + project.save() + program.users.add(project.leader, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(program, project) + if case_field: + PartnerProgramFieldValue.objects.create( + program_project=link, field=case_field, value_text=case_name + ) + PartnerProgramProject.objects.filter(pk=link.pk).update(submitted=submitted) + return project, link + + +class OverallResultsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.program, cls.criterion = weighted_program() + cls.manager = create_rate_user() + cls.program.managers.add(cls.manager) + cls.experts = [create_rate_expert(program=cls.program) for _ in range(2)] + cls.case_field = PartnerProgramField.objects.create( + partner_program=cls.program, + name="case", + label="Кейс", + field_type="select", + options="Право|Общество", + is_required=True, + show_filter=True, + ) + cls.projects, cls.links = {}, {} + for name, points, quiz, submitted, case in ( + ("Максимум", [10, 10], 100, True, "Право"), + ("Альфа", [8, 8], 90, True, "Право"), + ("Бета", [9, 9], 80, True, "Общество"), + ("Гамма", [7, 7], 70, True, "Право"), + ("Ноль", [0, 0], 0, True, "Право"), + ("Кейс готов", [10, 10], None, True, "Право"), + ("Квиз готов", [8], 100, True, "Право"), + ("Не сдан", [8, 8], 50, False, "Право"), + ("Ничего", [], None, True, "Право"), + ): + project, link = team( + cls.program, + name=name, + case_field=cls.case_field, + case_name=case, + submitted=submitted, + ) + cls.projects[name], cls.links[name] = project, link + for expert, score in zip(cls.experts, points): + ProjectScore.objects.create( + criteria=cls.criterion, project=project, user=expert, value=str(score) + ) + if quiz is not None: + record_quiz_outcome( + cls.program.pk, project.pk, f"quiz:{project.pk}", quiz + ) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.manager) + + def url(self, export=False, program=None): + return reverse( + "partner_programs:management-overall-results" + ("-export" if export else ""), + kwargs={"pk": (program or self.program).pk}, + ) + + def snapshot(self, **query): + response = self.api.get(self.url(), query) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def rows(self, **query): + return {row["name"]: row for row in self.snapshot(**query)["projects"]} + + def test_sum_200_zero_and_competition_places_across_cases(self): + data = self.snapshot() + self.assertEqual( + (data["case_maximum"], data["quiz_maximum"], data["maximum"]), (100, 100, 200) + ) + self.assertEqual((data["total_count"], data["ready_count"]), (9, 5)) + rows = self.rows() + for name, score, place in ( + ("Максимум", 200, 1), + ("Альфа", 170, 2), + ("Бета", 170, 2), + ("Гамма", 140, 4), + ("Ноль", 0, 5), + ): + with self.subTest(name=name): + self.assertEqual( + (rows[name]["score"], rows[name]["place"], rows[name]["ready"]), + (score, place, True), + ) + self.assertEqual((rows["Ноль"]["case_score"], rows["Ноль"]["quiz_score"]), (0, 0)) + + def test_pending_stages_are_not_zero_or_ranked(self): + rows = self.rows() + for name, status in ( + ("Кейс готов", "pending_quiz"), + ("Квиз готов", "pending_case"), + ("Не сдан", "pending_case"), + ("Ничего", "pending_both"), + ): + self.assertEqual(rows[name]["status"], status) + self.assertIsNone(rows[name]["score"]) + self.assertIsNone(rows[name]["place"]) + self.assertEqual(rows["Кейс готов"]["case_score"], 100) + self.assertEqual(rows["Квиз готов"]["case_score"], 80) + self.assertFalse(rows["Квиз готов"]["case_complete"]) + self.assertEqual(rows["Не сдан"]["case_status"], "not_submitted") + self.assertIsNone(rows["Не сдан"]["case_score"]) + + def test_official_case_score_is_reused_without_different_rounding(self): + case = self.api.get(f"/programs/{self.program.pk}/management/results/").data + expected = {r["id"]: r for g in case["groups"] for r in g["projects"]} + for row in self.snapshot()["projects"]: + if row["submitted"]: + self.assertEqual(row["case_score"], expected[row["id"]]["score"]) + self.assertEqual(row["case_complete"], expected[row["id"]]["ready"]) + + def test_filter_and_search_keep_global_places(self): + rows = self.rows(search="Гамма") + self.assertEqual(set(rows), {"Гамма"}) + self.assertEqual(rows["Гамма"]["place"], 4) + rows = self.rows(case_scope="selected", case_name="Общество") + self.assertEqual(set(rows), {"Бета"}) + self.assertEqual(rows["Бета"]["place"], 2) + self.assertEqual(len(self.rows(status="pending")), 4) + self.assertEqual(len(self.rows(status="complete")), 5) + + def test_sort_ascending_keeps_pending_last_and_places_unchanged(self): + rows = self.snapshot(order="asc")["projects"] + self.assertEqual(rows[0]["name"], "Ноль") + self.assertEqual(rows[0]["place"], 5) + self.assertEqual([r["ready"] for r in rows], [True] * 5 + [False] * 4) + + def test_get_scope_all_cannot_bypass_current_filters(self): + self.assertEqual(set(self.rows(scope="all", search="Гамма")), {"Гамма"}) + + def test_invalid_filters_are_rejected_including_full_export(self): + for export in (False, True): + for query in ( + {"status": "invalid"}, + {"order": "invalid"}, + {"case_scope": "selected", "case_name": "Чужой кейс", "scope": "all"}, + ): + with self.subTest(export=export, query=query): + self.assertEqual( + self.api.get(self.url(export), query).status_code, 400 + ) + + def test_only_own_manager_and_staff_can_read_and_export(self): + member = self.projects["Альфа"].leader + foreign = create_rate_user() + other = create_rate_program() + other.managers.add(foreign) + for actor in (member, self.experts[0], foreign): + self.api.force_authenticate(actor) + for export in (False, True): + self.assertEqual(self.api.get(self.url(export)).status_code, 403) + self.api.force_authenticate(None) + self.assertIn(self.api.get(self.url()).status_code, (401, 403)) + self.manager.is_staff = True + self.manager.save() + self.api.force_authenticate(self.manager) + self.assertEqual(self.api.get(self.url()).status_code, 200) + + def test_unknown_program_and_public_score_writes_are_rejected(self): + self.assertEqual( + self.api.get("/programs/999999/management/overall-results/").status_code, 404 + ) + before = ProgramQuizOutcome.objects.count() + self.assertEqual(self.api.post(self.url(), {"quiz_score": 100}).status_code, 405) + self.assertEqual(self.api.put(self.url(), {"quiz_score": 100}).status_code, 405) + self.assertEqual(ProgramQuizOutcome.objects.count(), before) + + def test_no_expert_personal_data_or_attempt_ids_in_response(self): + data = self.api.get(self.url()) + self.assertIn("no-store", data["Cache-Control"]) + body = data.content.decode() + for key in ('"experts"', '"email"', '"source_attempt_id"'): + self.assertNotIn(key, body) + + def test_legacy_mode_does_not_silently_normalize_case_or_break_existing_rating(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + evaluation_scoring_mode="legacy" + ) + data = self.snapshot() + self.assertFalse(data["case_scoring_configured"]) + self.assertTrue( + all( + p["status"] == "configuration_required" and p["score"] is None + for p in data["projects"] + ) + ) + old = self.api.get(f"/programs/{self.program.pk}/management/results/") + self.assertEqual(old.status_code, 200) + self.assertEqual(old.data["maximum"], 10) + + def test_empty_program_has_explicit_empty_result(self): + program, _ = weighted_program() + program.managers.add(self.manager) + response = self.api.get(self.url(program=program)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["projects"], []) + self.assertEqual(response.data["maximum"], 200) + + def test_unrelated_program_quiz_results_do_not_enter_current_rating(self): + other, _ = weighted_program() + other.users.add( + self.projects["Не сдан"].leader, through_defaults={"partner_program_data": {}} + ) + link_project_to_program(other, self.projects["Не сдан"]) + record_quiz_outcome( + other.pk, self.projects["Не сдан"].pk, "other-program-attempt", 100 + ) + self.assertEqual(self.rows()["Не сдан"]["quiz_score"], 50) + + def test_decimal_quiz_result_is_added_to_official_case_score(self): + project, _ = team(self.program, name="Сотые балла", submitted=True) + for expert, score in zip(self.experts, (3, 4)): + ProjectScore.objects.create( + criteria=self.criterion, project=project, user=expert, value=str(score) + ) + record_quiz_outcome(self.program.pk, project.pk, "decimal-attempt", "33.33") + row = self.rows()["Сотые балла"] + self.assertEqual( + (row["case_score"], row["quiz_score"], row["score"]), (35, 33.33, 68.33) + ) + + def test_unsupported_program_contours_do_not_mix_models(self): + for changes in ({"solution_storage_mode": "solution"}, {"is_competitive": False}): + with self.subTest(changes=changes): + original = {key: getattr(self.program, key) for key in changes} + PartnerProgram.objects.filter(pk=self.program.pk).update(**changes) + self.assertEqual(self.api.get(self.url()).status_code, 409) + PartnerProgram.objects.filter(pk=self.program.pk).update(**original) + + def test_xlsx_has_same_places_values_formulas_and_plain_arial_10(self): + response = self.api.get(self.url(export=True)) + self.assertEqual(response.status_code, 200) + self.assertIn("no-store", response["Cache-Control"]) + book = load_workbook(BytesIO(response.content), data_only=False) + self.assertEqual(book.sheetnames, ["Общий рейтинг"]) + sheet = book.active + self.assertEqual(sheet.freeze_panes, "A2") + expected = self.rows() + for index in range(2, sheet.max_row + 1): + row = expected[sheet.cell(index, 2).value] + self.assertEqual(sheet.cell(index, 1).value, row["place"]) + self.assertEqual(sheet.cell(index, 4).value, row["case_score"]) + self.assertEqual(sheet.cell(index, 5).value, row["quiz_score"]) + self.assertEqual( + sheet.cell(index, 6).value, + f"=ROUND(D{index}+E{index},2)" if row["ready"] else None, + ) + if row["ready"]: + self.assertEqual( + Decimal(str(sheet.cell(index, 4).value)) + + Decimal(str(sheet.cell(index, 5).value)), + Decimal(str(row["score"])), + ) + for cells in sheet: + for cell in cells: + self.assertEqual((cell.font.name, cell.font.sz), ("Arial", 10)) + self.assertEqual(cell.font.bold, cell.row == 1) + self.assertIsNone(cell.fill.patternType) + self.assertIsNone(cell.comment) + + def test_export_scope_full_and_formula_injection_safety(self): + project = self.projects["Альфа"] + project.name = '=HYPERLINK("https://example.test","name")' + project.save() + response = self.api.get(self.url(True), {"search": "Ноль", "scope": "all"}) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.max_row, 10) + cell = next( + row[1] for row in sheet.iter_rows(min_row=2) if row[1].value == project.name + ) + self.assertEqual(cell.data_type, "s") + selected = load_workbook( + BytesIO(self.api.get(self.url(True), {"search": "Ноль"}).content) + ).active + self.assertEqual(selected.max_row, 2) + + def test_queries_do_not_grow_per_team(self): + self.snapshot() + with CaptureQueriesContext(connection) as before: + self.snapshot() + for i in range(12): + team(self.program, name=f"Дополнительная {i}", submitted=False) + with CaptureQueriesContext(connection) as after: + self.snapshot() + self.assertLessEqual(len(after), len(before) + 1) + + def test_admin_uses_same_results_and_refuses_manual_quiz_edit(self): + staff = create_rate_user() + staff.is_staff = True + staff.save() + staff.user_permissions.add( + Permission.objects.get(codename="view_partnerprogram"), + Permission.objects.get(codename="view_programquizoutcome"), + ) + self.client.force_login(staff) + url = reverse( + "admin:partner_programs_partnerprogram_overall_results", + args=[self.program.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.context_data["snapshot"]["projects"], self.snapshot()["projects"] + ) + self.assertContains(response, "Итог из 200") + self.assertContains(response, "0,00") + self.assertContains(response, "1 из 2 оценок") + selected = self.client.get(url, {"search": "Гамма", "scope": "all"}) + self.assertEqual( + [row["name"] for row in selected.context_data["snapshot"]["projects"]], + ["Гамма"], + ) + self.assertEqual(self.client.post(url, {"quiz_score": 100}).status_code, 405) + export = reverse( + "admin:partner_programs_partnerprogram_overall_results_export", + args=[self.program.pk], + ) + self.assertEqual(self.client.get(export).status_code, 200) + outcome = ProgramQuizOutcome.objects.first() + self.assertEqual( + self.client.get( + reverse( + "admin:partner_programs_programquizoutcome_change", args=[outcome.pk] + ) + ).status_code, + 200, + ) + self.assertEqual( + self.client.post( + reverse( + "admin:partner_programs_programquizoutcome_change", args=[outcome.pk] + ), + {"score": 100}, + ).status_code, + 403, + ) + self.assertEqual( + self.client.get( + reverse("admin:partner_programs_programquizoutcome_add") + ).status_code, + 403, + ) + + def test_admin_requires_model_permissions(self): + staff = create_rate_user() + staff.is_staff = True + staff.save() + self.client.force_login(staff) + url = reverse( + "admin:partner_programs_partnerprogram_overall_results", + args=[self.program.pk], + ) + self.assertEqual(self.client.get(url).status_code, 403) + + def test_admin_filter_errors_are_plain_text_not_executable_html(self): + staff = create_rate_user() + staff.is_staff = staff.is_superuser = True + staff.save() + self.client.force_login(staff) + for name in ("overall_results", "overall_results_export"): + url = reverse( + "admin:partner_programs_partnerprogram_" + name, args=[self.program.pk] + ) + response = self.client.get(url, {"status": ""}) + self.assertEqual(response.status_code, 400) + self.assertTrue(response["Content-Type"].startswith("text/plain")) + + +class QuizOutcomeTests(TestCase): + def setUp(self): + self.program, _ = weighted_program() + self.project, self.link = team(self.program, submitted=False) + + def record(self, score="80.25", attempt="stable-attempt"): + return record_quiz_outcome(self.program.pk, self.project.pk, attempt, score) + + def test_idempotent_record_does_not_change_timestamp(self): + first = self.record() + second = self.record(score=Decimal("80.25")) + self.assertEqual((first.pk, first.recorded_at), (second.pk, second.recorded_at)) + self.assertEqual(ProgramQuizOutcome.objects.count(), 1) + + def test_conflicting_attempt_or_score_cannot_replace_result(self): + first = self.record() + for score, attempt in (("81", "stable-attempt"), ("80.25", "another-attempt")): + with self.subTest(score=score, attempt=attempt), self.assertRaises( + QuizOutcomeConflict + ): + self.record(score, attempt) + first.refresh_from_db() + self.assertEqual(first.score, Decimal("80.25")) + + def test_missing_registration_and_foreign_program_are_rejected(self): + other, _ = weighted_program() + from django.http import Http404 + + with self.assertRaises(Http404): + record_quiz_outcome(other.pk, self.project.pk, "foreign-attempt", 100) + self.assertFalse(ProgramQuizOutcome.objects.exists()) + + def test_same_attempt_cannot_be_used_by_another_team(self): + self.record() + project, _ = team(self.program, submitted=False) + with self.assertRaises(QuizOutcomeConflict): + record_quiz_outcome(self.program.pk, project.pk, "stable-attempt", 80.25) + + def test_rejoining_preserves_same_team_result_and_one_attempt(self): + first = self.record() + self.link.delete() + link_project_to_program(self.program, self.project) + self.assertEqual(self.record().pk, first.pk) + with self.assertRaises(QuizOutcomeConflict): + self.record(attempt="retry-after-rejoin") + + def test_invalid_scores_are_rejected_without_creating_records(self): + from rest_framework.exceptions import ValidationError + + for value in ( + None, + True, + "NaN", + "Infinity", + "-Infinity", + "-0.01", + "100.01", + "1.001", + "bad", + ): + with self.subTest(value=value), self.assertRaises(ValidationError): + self.record(score=value) + self.assertFalse(ProgramQuizOutcome.objects.exists()) + + def test_empty_or_overlong_attempt_ids_are_rejected(self): + from rest_framework.exceptions import ValidationError + + for value in (None, "", " ", " x", "x ", "x" * 129): + with self.subTest(value=value), self.assertRaises(ValidationError): + self.record(attempt=value) + + def test_unsupported_program_cannot_accept_quiz_outcome(self): + from rest_framework.exceptions import ValidationError + + for change in ({"solution_storage_mode": "solution"}, {"is_competitive": False}): + original = {key: getattr(self.program, key) for key in change} + PartnerProgram.objects.filter(pk=self.program.pk).update(**change) + with self.assertRaises(ValidationError): + self.record() + PartnerProgram.objects.filter(pk=self.program.pk).update(**original) + self.assertFalse(ProgramQuizOutcome.objects.exists()) + + def test_model_and_queryset_refuse_changes_to_completed_outcome(self): + outcome = self.record() + outcome.score = Decimal("90") + with self.assertRaises(ModelValidationError): + outcome.save() + with self.assertRaises(ModelValidationError): + ProgramQuizOutcome.objects.filter(pk=outcome.pk).update(score=90) + with self.assertRaises(ModelValidationError): + ProgramQuizOutcome.objects.bulk_update([outcome], ["score"]) + + def test_database_range_constraint_is_enforced(self): + outcome = self.record() + for value in ("101", "-0.01", "NaN"): + with self.subTest(value=value), self.assertRaises( + IntegrityError + ), transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute( + "UPDATE partner_programs_programquizoutcome SET score=%s WHERE id=%s", + [value, outcome.pk], + ) + + def test_database_unique_team_constraint_is_enforced(self): + self.record() + with self.assertRaises(IntegrityError), transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute( + "INSERT INTO partner_programs_programquizoutcome " + "(program_id,project_id,source_attempt_id,score,recorded_at) " + "VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", + [self.program.pk, self.project.pk, "different-attempt", 50], + ) diff --git a/partner_programs/tests/test_quiz_outcome_races.py b/partner_programs/tests/test_quiz_outcome_races.py new file mode 100644 index 00000000..750b073c --- /dev/null +++ b/partner_programs/tests/test_quiz_outcome_races.py @@ -0,0 +1,69 @@ +"""Конкурентные завершения попытки на настоящем PostgreSQL.""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +from django.db import connections +from django.test import TransactionTestCase, skipUnlessDBFeature + +from partner_programs.overall_models import ProgramQuizOutcome +from partner_programs.services.quiz_outcomes import ( + QuizOutcomeConflict, + record_quiz_outcome, +) +from partner_programs.tests.test_overall_results import team, weighted_program + + +@skipUnlessDBFeature("has_select_for_update") +class QuizOutcomeRaceTests(TransactionTestCase): + def race(self, calls): + barrier = Barrier(2) + + def worker(args): + connections.close_all() + try: + barrier.wait(timeout=10) + try: + return ("ok", record_quiz_outcome(*args).pk) + except QuizOutcomeConflict: + return ("conflict", None) + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + return list(executor.map(worker, calls)) + + def test_repeated_same_completion_has_one_record(self): + program, _ = weighted_program() + project, _ = team(program, submitted=False) + event = (program.pk, project.pk, "same-finalized-attempt", "88.25") + results = self.race([event, event]) + self.assertEqual(results[0], results[1]) + self.assertEqual(results[0][0], "ok") + self.assertEqual(ProgramQuizOutcome.objects.count(), 1) + + def test_second_attempt_cannot_win_a_race_for_same_team(self): + program, _ = weighted_program() + project, _ = team(program, submitted=False) + results = self.race( + [ + (program.pk, project.pk, "first-attempt", 50), + (program.pk, project.pk, "second-attempt", 100), + ] + ) + self.assertCountEqual([row[0] for row in results], ["ok", "conflict"]) + self.assertEqual(ProgramQuizOutcome.objects.count(), 1) + + def test_same_source_attempt_cannot_be_claimed_in_two_programs(self): + first, _ = weighted_program() + second, _ = weighted_program() + first_team, _ = team(first, submitted=False) + second_team, _ = team(second, submitted=False) + results = self.race( + [ + (first.pk, first_team.pk, "global-attempt", 75), + (second.pk, second_team.pk, "global-attempt", 75), + ] + ) + self.assertCountEqual([row[0] for row in results], ["ok", "conflict"]) + self.assertEqual(ProgramQuizOutcome.objects.count(), 1) diff --git a/partner_programs/tests/test_white_label.py b/partner_programs/tests/test_white_label.py new file mode 100644 index 00000000..93bf8ea5 --- /dev/null +++ b/partner_programs/tests/test_white_label.py @@ -0,0 +1,402 @@ +"""Проверки изолированного режима и сохранения обычной регистрации.""" + +import io +from datetime import timedelta +from unittest.mock import patch + +from django.core import mail +from django.core.cache import cache +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import connection +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramUserProfile, + PendingProgramRegistration, + ProgramRegistration, +) +from partner_programs.services.native_registration import confirmation_token +from partner_programs.tests.helpers import create_partner_program, create_user +from partner_programs.tests.test_native_registration import TEST_REGISTRATION_SETTINGS +from procollab.white_label import check_white_label +from users.models import CustomUser + + +WHITE_LABEL_SETTINGS = { + "WHITE_LABEL_ENABLED": True, + "WHITE_LABEL_PROGRAM_ID": 1, + "WHITE_LABEL_BRAND_NAME": "Кейс-чемпионат клиента", + "WHITE_LABEL_FRONTEND_URL": "https://championship.example.test", + "WHITE_LABEL_LOGO_URL": "https://championship.example.test/logo.svg", + "WHITE_LABEL_FAVICON_URL": "", + "WHITE_LABEL_LOGO_BACKGROUND": "transparent", +} + + +@override_settings(**WHITE_LABEL_SETTINGS) +class WhiteLabelConfigurationTests(SimpleTestCase): + def test_favicon_and_logo_background_validation(self): + for changed in ( + { + "WHITE_LABEL_FAVICON_URL": "https://user:secret@outside.example.test/icon.svg" + }, + {"WHITE_LABEL_FAVICON_URL": "http://outside.example.test/icon.svg"}, + {"WHITE_LABEL_LOGO_BACKGROUND": "url(https://outside.example.test)"}, + ): + with self.subTest(changed=changed), override_settings(**changed): + self.assertTrue(check_white_label(None)) + with override_settings( + WHITE_LABEL_FAVICON_URL="https://client.example.test/icon.svg", + WHITE_LABEL_LOGO_BACKGROUND="dark", + ): + self.assertEqual(check_white_label(None), []) + + def test_valid_configuration(self): + self.assertEqual(check_white_label(None), []) + + def test_invalid_program_and_brand(self): + for key, value in ( + ("WHITE_LABEL_PROGRAM_ID", 0), + ("WHITE_LABEL_PROGRAM_ID", True), + ("WHITE_LABEL_BRAND_NAME", ""), + ("WHITE_LABEL_BRAND_NAME", "Имя\nПодмена"), + ): + with self.subTest(key=key, value=value), override_settings(**{key: value}): + self.assertEqual(check_white_label(None)[0].id, "white_label.E001") + + def test_untrusted_url_components_are_rejected(self): + for url in ( + "http://championship.example.test", + "https://user:pass@championship.example.test", + "https://championship.example.test/path", + "https://championship.example.test?next=https://other.example.test", + "https://championship.example.test#fragment", + "javascript:alert(1)", + ): + with self.subTest(url=url), override_settings(WHITE_LABEL_FRONTEND_URL=url): + self.assertTrue(check_white_label(None)) + + def test_local_development_origin(self): + with override_settings(WHITE_LABEL_FRONTEND_URL="http://localhost:8317"): + self.assertEqual(check_white_label(None), []) + + def test_disabled_mode_ignores_unused_configuration(self): + with override_settings(WHITE_LABEL_ENABLED=False, WHITE_LABEL_PROGRAM_ID=0): + self.assertEqual(check_white_label(None), []) + + def test_logo_credentials_are_not_exposed_as_public_configuration(self): + with override_settings( + WHITE_LABEL_LOGO_URL="https://user:password@championship.example.test/logo.svg" + ): + self.assertTrue(check_white_label(None)) + + +@override_settings(**TEST_REGISTRATION_SETTINGS) +class WhiteLabelRegistrationTests(TestCase): + @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") + def test_role_invitations_use_instance_brand_and_keep_normal_fallback(self): + from partner_programs.models import OrganizerInvitation + from partner_programs.tasks import send_organizer_invitation + from project_rates.models import ExpertInvitation + from project_rates.tasks import send_expert_invitation + from project_rates.workspace import digest + + for model, task, role in ( + (OrganizerInvitation, send_organizer_invitation, "organizer"), + (ExpertInvitation, send_expert_invitation, "expert"), + ): + token = f"synthetic-{role}-invitation-token" + row = model.objects.create( + partner_program=self.program, + email=f"{role}@example.test", + token_digest=digest(token), + expires_at=timezone.now() + timedelta(days=1), + ) + task(row.pk, token) + message = mail.outbox[-1] + self.assertIn(WHITE_LABEL_SETTINGS["WHITE_LABEL_BRAND_NAME"], message.subject) + self.assertIn( + f"https://championship.example.test/{role}-invitation/{token}", + message.body, + ) + self.assertNotIn("PROCOLLAB", message.body + message.alternatives[0][0]) + with override_settings(WHITE_LABEL_ENABLED=False): + task(row.pk, token) + self.assertIn("PROCOLLAB", mail.outbox[-1].subject) + + def test_demo_limits_registration_to_synthetic_email_without_partial_account(self): + with override_settings(WHITE_LABEL_DEMO_EMAIL_DOMAIN="example.test"): + result = self.signup(email="participant@example.com") + self.assertEqual(result.status_code, 400) + self.assertFalse( + CustomUser.objects.filter(email="participant@example.com").exists() + ) + + def test_admin_uses_instance_brand_and_normal_mode_keeps_default(self): + admin = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(admin) + response = self.client.get("/admin/") + self.assertContains(response, "Кейс-чемпионат клиента — администрирование") + self.assertNotContains(response, "| Django site admin") + with override_settings(WHITE_LABEL_ENABLED=False): + normal = self.client.get("/admin/") + self.assertNotContains(normal, "Кейс-чемпионат клиента — администрирование") + + def setUp(self): + cache.clear() + self.addCleanup(cache.clear) + self.client = APIClient() + self.program = create_partner_program() + self.other_program = create_partner_program() + for program in (self.program, self.other_program): + ProgramRegistration.objects.create( + program=program, + draft={"mode": "native", "fields": []}, + published={"mode": "native", "fields": []}, + published_version=1, + ) + self.mode = override_settings( + **{**WHITE_LABEL_SETTINGS, "WHITE_LABEL_PROGRAM_ID": self.program.pk} + ) + self.mode.enable() + self.addCleanup(self.mode.disable) + self.root = f"/programs/{self.program.pk}/registration/" + + def payload(self, **overrides): + data = { + "version": 1, + "answers": {}, + "email": "new-participant@example.test", + "password": "Secure-demo-password-2030", + "repeat_password": "Secure-demo-password-2030", + "accept_policy": True, + "accept_age": True, + } + return {**data, **overrides} + + def signup(self, **overrides): + with self.captureOnCommitCallbacks(execute=True): + return self.client.post( + self.root + "signup/", self.payload(**overrides), format="json" + ) + + def test_public_configuration_has_only_safe_fields(self): + response = self.client.get("/instance/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data), + { + "enabled", + "program_id", + "brand_name", + "logo_url", + "favicon_url", + "logo_background", + "demo_mode", + "frontend_url", + "registration_url", + "program_url", + }, + ) + self.assertEqual(response.data["program_id"], self.program.pk) + self.assertEqual(response["Cache-Control"], "no-store") + self.assertTrue( + response.data["registration_url"].startswith( + "https://championship.example.test/" + ) + ) + + def test_short_mode_is_limited_to_the_configured_program(self): + data = self.client.get(self.root).data + self.assertEqual(data["account_mode"], "email_password") + self.assertEqual( + data["branding"], + { + "name": WHITE_LABEL_SETTINGS["WHITE_LABEL_BRAND_NAME"], + "logo_url": WHITE_LABEL_SETTINGS["WHITE_LABEL_LOGO_URL"], + "logo_background": "transparent", + }, + ) + other = f"/programs/{self.other_program.pk}/registration/" + self.assertNotIn("account_mode", self.client.get(other).data) + self.assertNotIn("branding", self.client.get(other).data) + response = self.client.post(other + "signup/", self.payload(), format="json") + self.assertEqual(response.status_code, 400) + self.assertIn("first_name", response.data) + self.assertIn("last_name", response.data) + self.assertIn("birthday", response.data) + + def test_disabled_mode_keeps_the_original_form(self): + with override_settings(WHITE_LABEL_ENABLED=False): + self.assertEqual(self.client.get("/instance/").data, {"enabled": False}) + self.assertNotIn("account_mode", self.client.get(self.root).data) + self.assertNotIn("branding", self.client.get(self.root).data) + response = self.signup() + self.assertEqual(response.status_code, 400) + self.assertIn("birthday", response.data) + + def test_signup_confirm_and_login_without_fake_profile_data(self): + response = self.signup() + self.assertEqual(response.status_code, 201, response.data) + self.assertNotIn("access", response.data) + pending = PendingProgramRegistration.objects.get() + user = pending.user + self.assertFalse(user.is_active) + self.assertEqual(user.first_name, "") + self.assertEqual(user.last_name, "") + self.assertIsNone(user.birthday) + self.assertIsNone(user.onboarding_stage) + self.assertIsNone(user.verification_date) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + self.assertEqual(len(mail.outbox), 1) + self.assertIn("Кейс-чемпионат клиента", mail.outbox[0].subject) + self.assertIn( + "https://championship.example.test/program-registration/", mail.outbox[0].body + ) + self.assertNotIn("PROCOLLAB", mail.outbox[0].body) + self.assertNotIn("procollab.ru", mail.outbox[0].body) + + token = confirmation_token(pending) + result = self.client.post(self.root + "confirm/", {"token": token}, format="json") + self.assertEqual(result.status_code, 200, result.data) + self.assertTrue(result.data["enrolled"]) + self.assertEqual(PartnerProgramUserProfile.objects.get().user_id, user.pk) + login = self.client.post( + "/api/token/", + {"email": user.email, "password": self.payload()["password"]}, + format="json", + ) + self.assertEqual(login.status_code, 200, login.data) + self.assertIn("access", login.data) + again = self.client.post(self.root + "confirm/", {"token": token}, format="json") + self.assertEqual(again.status_code, 409) + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + login.data["access"]) + for _ in range(2): + enrolled = self.client.post( + self.root, {"version": 1, "answers": {}}, format="json" + ) + self.assertEqual(enrolled.status_code, 200, enrolled.data) + self.assertEqual(PartnerProgramUserProfile.objects.filter(user=user).count(), 1) + + def test_existing_account_is_not_changed(self): + user = create_user(email="known@example.test", first_name="Анна") + before = (user.password, user.first_name, user.onboarding_stage) + response = self.signup(email="KNOWN@example.test") + self.assertEqual(response.status_code, 409, response.data) + user.refresh_from_db() + self.assertEqual((user.password, user.first_name, user.onboarding_stage), before) + self.assertFalse(PendingProgramRegistration.objects.exists()) + + def test_password_consent_and_form_version_checks_are_preserved(self): + for changed in ( + {"repeat_password": "different-password"}, + {"password": "tiny", "repeat_password": "tiny"}, + {"accept_policy": False}, + {"accept_age": False}, + {"birthday": timezone.now().date().isoformat()}, + {"version": 2}, + ): + with self.subTest(changed=changed): + response = self.signup(**changed) + self.assertIn(response.status_code, (400, 409), response.data) + self.assertFalse(PendingProgramRegistration.objects.exists()) + self.assertFalse( + CustomUser.objects.filter(email=self.payload()["email"]).exists() + ) + + def test_closed_registration_does_not_create_an_account(self): + self.program.datetime_registration_ends = timezone.now() - timedelta(seconds=1) + self.program.save(update_fields=["datetime_registration_ends"]) + response = self.signup() + self.assertEqual(response.status_code, 409, response.data) + self.assertFalse(PendingProgramRegistration.objects.exists()) + + def test_password_reset_uses_client_origin_and_brand(self): + user = create_user(email="reset@example.test", is_active=True) + response = self.client.post( + "/auth/reset_password/", + {"email": user.email, "redirect_url": "https://outside.example.test"}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + "https://championship.example.test/auth/reset_password/", mail.outbox[0].body + ) + self.assertIn("Кейс-чемпионат клиента", mail.outbox[0].subject) + self.assertNotIn("procollab", mail.outbox[0].body.lower()) + self.assertNotIn("outside.example.test", mail.outbox[0].body) + + def test_program_password_reset_preserves_return_context(self): + user = create_user(email="program-reset@example.test", is_active=True) + response = self.client.post( + "/auth/reset_password/", + {"email": user.email, "registration_program_id": self.program.pk}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertIn( + "https://championship.example.test/program-password-reset/", + mail.outbox[0].body, + ) + self.assertIn(f"registration_program={self.program.pk}", mail.outbox[0].body) + + def test_normal_password_reset_keeps_its_subject_and_domain(self): + user = create_user(email="normal-reset@example.test", is_active=True) + with override_settings(WHITE_LABEL_ENABLED=False): + response = self.client.post( + "/auth/reset_password/", {"email": user.email}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertIn( + "https://app.procollab.ru/auth/reset_password/", mail.outbox[0].body + ) + self.assertEqual(mail.outbox[0].subject, "Сброс пароля | Procollab") + + +@override_settings(**TEST_REGISTRATION_SETTINGS, **WHITE_LABEL_SETTINGS) +class WhiteLabelDemoTests(TestCase): + def test_demo_is_disabled_by_default(self): + with override_settings(WHITE_LABEL_DEMO_ALLOWED=False): + with self.assertRaises(CommandError): + call_command("seed_white_label_demo", stdout=io.StringIO()) + self.assertFalse(CustomUser.objects.exists()) + + def test_demo_rejects_other_database_names_and_remote_hosts(self): + with override_settings(WHITE_LABEL_DEMO_ALLOWED=True): + for database in ( + {"HOST": "127.0.0.1", "NAME": "customer_database"}, + {"HOST": "database.example.test", "NAME": "procollab_white_label"}, + ): + with self.subTest(database=database), patch.dict( + connection.settings_dict, database + ): + with self.assertRaises(CommandError): + call_command("seed_white_label_demo", stdout=io.StringIO()) + self.assertFalse(CustomUser.objects.exists()) + + def test_repeat_seed_preserves_accounts_and_program(self): + # Меняются только метаданные проверки уже открытого тестового соединения. + # Запросы остаются в транзакции текущей тестовой БД, в том числе в CI. + with override_settings(WHITE_LABEL_DEMO_ALLOWED=True), patch.dict( + connection.settings_dict, + {"HOST": "127.0.0.1", "NAME": "test_procollab_white_label_seed"}, + ): + call_command("seed_white_label_demo", stdout=io.StringIO()) + user = CustomUser.objects.get(email="leader@example.test") + before = user.password + call_command( + "seed_white_label_demo", + password="different-demo-password", + stdout=io.StringIO(), + ) + self.assertEqual(PartnerProgram.objects.count(), 1) + self.assertEqual(CustomUser.objects.count(), 5) + self.assertEqual(PartnerProgramUserProfile.objects.count(), 2) + self.assertEqual(PartnerProgram.objects.get().program_projects.count(), 1) + user.refresh_from_db() + self.assertEqual(user.password, before) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 02721337..05ab4dd5 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -128,6 +128,10 @@ ManagementResultsView, ManagementResultsExportView, ) +from partner_programs.overall_views import ( + ManagementOverallResultsView, + ManagementOverallResultsExportView, +) from partner_programs.certificate_views import ( CertificateSettingsView, @@ -474,6 +478,16 @@ program_view(ManagementResultsExportView), name="management-results-export", ), + path( + "/management/overall-results/", + ManagementOverallResultsView.as_view(), + name="management-overall-results", + ), + path( + "/management/overall-results/export/", + ManagementOverallResultsExportView.as_view(), + name="management-overall-results-export", + ), path( "/management/experts/", program_view(ExpertsView), diff --git a/procollab/settings.py b/procollab/settings.py index 7adc6b7c..ed5e4116 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -3,6 +3,7 @@ import sys from datetime import timedelta from pathlib import Path +from urllib.parse import urlsplit from decouple import config @@ -21,6 +22,18 @@ PROGRAM_SOLUTIONS_ENABLED = config("PROGRAM_SOLUTIONS_ENABLED", default=True, cast=bool) PROJECT_DEFAULT_PACK_URL = config("PROJECT_DEFAULT_PACK_URL", default="") +WHITE_LABEL_ENABLED = config("WHITE_LABEL_ENABLED", default=False, cast=bool) +WHITE_LABEL_PROGRAM_ID = config("WHITE_LABEL_PROGRAM_ID", default=0, cast=int) +WHITE_LABEL_BRAND_NAME = config("WHITE_LABEL_BRAND_NAME", default="", cast=str) +WHITE_LABEL_LOGO_URL = config("WHITE_LABEL_LOGO_URL", default="", cast=str) +WHITE_LABEL_FAVICON_URL = config("WHITE_LABEL_FAVICON_URL", default="", cast=str) +WHITE_LABEL_LOGO_BACKGROUND = config( + "WHITE_LABEL_LOGO_BACKGROUND", default="transparent", cast=str +) +WHITE_LABEL_FRONTEND_URL = config("WHITE_LABEL_FRONTEND_URL", default="", cast=str) +WHITE_LABEL_API_HOST = config("WHITE_LABEL_API_HOST", default="", cast=str) +WHITE_LABEL_DEMO_ALLOWED = config("WHITE_LABEL_DEMO_ALLOWED", default=False, cast=bool) + AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool) TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="", cast=str) @@ -148,6 +161,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "procollab.white_label.template_context", ], }, }, @@ -387,3 +401,15 @@ CELERY_RESULT_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json" CELERY_TIMEZONE = "Europe/Moscow" + +if WHITE_LABEL_ENABLED: + # Настройки доверенных доменов относятся только к этому экземпляру. + instance_origin = WHITE_LABEL_FRONTEND_URL.rstrip("/") + instance_host = urlsplit(instance_origin).hostname + ALLOWED_HOSTS = list(ALLOWED_HOSTS) + for host in (instance_host, WHITE_LABEL_API_HOST): + if host and host not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(host) + CSRF_TRUSTED_ORIGINS = [*CSRF_TRUSTED_ORIGINS, instance_origin] + CORS_ALLOW_ALL_ORIGINS = False + CORS_ALLOWED_ORIGINS = [instance_origin] diff --git a/procollab/settings_pilot.py b/procollab/settings_pilot.py new file mode 100644 index 00000000..be3c2656 --- /dev/null +++ b/procollab/settings_pilot.py @@ -0,0 +1,58 @@ +"""Отдельный демонстрационный экземпляр. Не подключать к основной БД.""" + +from pathlib import Path +from urllib.parse import urlsplit + +from django.core.exceptions import ImproperlyConfigured + +from .settings import * # noqa: F401,F403 +from .settings import DATABASES + +DEBUG = False +if not WHITE_LABEL_ENABLED: # noqa: F405 + raise ImproperlyConfigured("Профиль pilot требует WHITE_LABEL_ENABLED=True.") +if not DATABASES["default"]["NAME"].startswith( + "procollab_white_label_pilot" +): # noqa: F405 + raise ImproperlyConfigured("Pilot требует отдельную БД procollab_white_label_pilot*.") + +host = urlsplit(WHITE_LABEL_FRONTEND_URL).hostname # noqa: F405 +ALLOWED_HOSTS = [host, "localhost", "127.0.0.1", "web"] +CSRF_TRUSTED_ORIGINS = [WHITE_LABEL_FRONTEND_URL] # noqa: F405 +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [WHITE_LABEL_FRONTEND_URL] # noqa: F405 +LANGUAGE_CODE = "ru-ru" +FORCE_SCRIPT_NAME = "/backend" +STATIC_URL = "/backend/static/" +STATIC_ROOT = Path("/procollab/pilot-data/static") +MEDIA_URL = "/backend/media/" +MEDIA_ROOT = Path("/procollab/pilot-data/media") +PILOT_LOCAL_FILES = True +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"}, +} +SESSION_COOKIE_NAME = "pilot_sessionid" +CSRF_COOKIE_NAME = "pilot_csrftoken" +SESSION_COOKIE_DOMAIN = None +CSRF_COOKIE_DOMAIN = None +SESSION_COOKIE_PATH = "/backend/" +CSRF_COOKIE_PATH = "/backend/" +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +AUTOPOSTING_ON = False +WHITE_LABEL_DEMO_ALLOWED = True +WHITE_LABEL_DEMO_EMAIL_DOMAIN = "example.test" +WHITE_LABEL_DEMO_CONTAINER_HOST = "db" +EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" +EMAIL_FILE_PATH = "/procollab/pilot-data/mail" +# Реальная почтовая отправка включается отдельной настройкой после проверки отправителя. +delivery = config("PILOT_EMAIL_DELIVERY", default="file") # noqa: F405 +if delivery not in ("file", "unisender"): + raise ImproperlyConfigured("PILOT_EMAIL_DELIVERY: file или unisender.") +if delivery == "unisender": + if not UNISENDER_GO_API_KEY or not EMAIL_USER: # noqa: F405 + raise ImproperlyConfigured( + "Для доставки нужны подтвержденный EMAIL_USER и ключ сервиса." + ) + EMAIL_BACKEND = "anymail.backends.unisender_go.EmailBackend" diff --git a/procollab/settings_pilot_inline.py b/procollab/settings_pilot_inline.py new file mode 100644 index 00000000..e72bcd79 --- /dev/null +++ b/procollab/settings_pilot_inline.py @@ -0,0 +1,15 @@ +"""Закрытое демо с одним web-процессом, без отдельного Redis и Celery worker.""" + +from .settings_pilot import * # noqa: F401,F403 + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "white-label-pilot-inline", + } +} +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} +CELERY_BROKER_URL = "memory://" +CELERY_RESULT_BACKEND = "cache+memory://" +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True diff --git a/procollab/urls.py b/procollab/urls.py index 1d98f4bf..b1190156 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -12,6 +12,7 @@ ) from users.authentication import ActivityTrackingJWTAuthentication from partner_programs.solution_form_api import SolutionFormTemplatesView +from procollab.white_label import InstanceConfigView schema_view = get_schema_view( openapi.Info( @@ -29,6 +30,7 @@ ) urlpatterns = [ + path("instance/", InstanceConfigView.as_view(), name="instance-config"), path("admin/", admin.site.urls), path("api-auth/", include("rest_framework.urls")), re_path( diff --git a/procollab/white_label.py b/procollab/white_label.py new file mode 100644 index 00000000..52a7cc79 --- /dev/null +++ b/procollab/white_label.py @@ -0,0 +1,128 @@ +"""Конфигурация отдельного клиентского экземпляра без изменения обычного режима.""" + +from dataclasses import dataclass +from urllib.parse import urlsplit + +from django.conf import settings +from django.core.checks import Error, Tags, register +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.validators import URLValidator +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + + +@dataclass(frozen=True) +class WhiteLabelConfig: + enabled: bool = False + program_id: int | None = None + brand_name: str = "PROCOLLAB" + logo_url: str = "" + frontend_url: str = "" + favicon_url: str = "" + logo_background: str = "transparent" + + +def get_config() -> WhiteLabelConfig: + if not getattr(settings, "WHITE_LABEL_ENABLED", False): + return WhiteLabelConfig() + + program_id = getattr(settings, "WHITE_LABEL_PROGRAM_ID", 0) + brand_name = getattr(settings, "WHITE_LABEL_BRAND_NAME", "").strip() + origin = getattr(settings, "WHITE_LABEL_FRONTEND_URL", "").rstrip("/") + logo_url = getattr(settings, "WHITE_LABEL_LOGO_URL", "").strip() + favicon_url = getattr(settings, "WHITE_LABEL_FAVICON_URL", "").strip() + logo_background = getattr(settings, "WHITE_LABEL_LOGO_BACKGROUND", "transparent") + if logo_background not in ("transparent", "dark"): + raise ImproperlyConfigured("WHITE_LABEL_LOGO_BACKGROUND: transparent или dark.") + if isinstance(program_id, bool) or not isinstance(program_id, int) or program_id < 1: + raise ImproperlyConfigured("WHITE_LABEL_PROGRAM_ID: укажите положительный ID.") + if ( + not brand_name + or len(brand_name) > 200 + or "\n" in brand_name + or "\r" in brand_name + ): + raise ImproperlyConfigured( + "WHITE_LABEL_BRAND_NAME: укажите название до 200 знаков." + ) + try: + URLValidator(schemes=["https", "http"])(origin) + parsed = urlsplit(origin) + if ( + parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + or ( + parsed.scheme != "https" + and parsed.hostname not in ("localhost", "127.0.0.1", "::1") + ) + ): + raise ValidationError("origin") + for asset in (logo_url, favicon_url): + if not asset: + continue + URLValidator(schemes=["https"])(asset) + logo = urlsplit(asset) + if logo.username or logo.password: + raise ValidationError("logo credentials") + except (ValidationError, ValueError): + raise ImproperlyConfigured( + "Укажите HTTPS origin в WHITE_LABEL_FRONTEND_URL и HTTPS URL логотипа. " + "HTTP разрешен только для локальной разработки." + ) + return WhiteLabelConfig( + True, program_id, brand_name, logo_url, origin, favicon_url, logo_background + ) + + +def template_context(request): + config = get_config() + return {"instance_brand": config} if config.enabled else {} + + +def is_white_label_program(program_id) -> bool: + config = get_config() + return config.enabled and config.program_id == program_id + + +def frontend_url(default: str) -> str: + config = get_config() + return config.frontend_url if config.enabled else default.rstrip("/") + + +@register(Tags.security) +def check_white_label(app_configs, **kwargs): + try: + get_config() + except ImproperlyConfigured as error: + return [Error(str(error), id="white_label.E001")] + return [] + + +class InstanceConfigView(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + def get(self, request): + config = get_config() + data = {"enabled": config.enabled} + if config.enabled: + data.update( + program_id=config.program_id, + brand_name=config.brand_name, + logo_url=config.logo_url, + favicon_url=config.favicon_url, + logo_background=config.logo_background, + demo_mode=bool(getattr(settings, "WHITE_LABEL_DEMO_EMAIL_DOMAIN", "")), + frontend_url=config.frontend_url, + registration_url=( + f"{config.frontend_url}/program-registration/{config.program_id}" + ), + program_url=f"{config.frontend_url}/office/program/{config.program_id}", + ) + response = Response(data) + response["Cache-Control"] = "no-store" + return response diff --git a/project_rates/admin.py b/project_rates/admin.py index 4b204cbc..ab0ef959 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -27,6 +27,21 @@ def clean(self): raise forms.ValidationError( "После первой оценки критерии программы зафиксированы." ) + if program: + from types import SimpleNamespace + from project_rates.scoring import validate_configuration + + candidate = SimpleNamespace( + **{ + key: data.get(key) + for key in ("type", "min_value", "max_value", "weight_percent") + } + ) + criteria = list(program.criterias.exclude(pk=self.instance.pk)) + [candidate] + try: + validate_configuration(program, criteria) + except DjangoValidationError as error: + raise forms.ValidationError(error.messages) from error return data @@ -34,7 +49,7 @@ def clean(self): @admin.register(Criteria) class CriteriaAdmin(admin.ModelAdmin): form = CriteriaAdminForm - list_display = ("id", "name", "type", "get_program_name") + list_display = ("id", "name", "type", "weight_percent", "get_program_name") list_display_links = ( "id", "name", diff --git a/project_rates/criteria_policy.py b/project_rates/criteria_policy.py index 95857bac..40f7621f 100644 --- a/project_rates/criteria_policy.py +++ b/project_rates/criteria_policy.py @@ -15,16 +15,33 @@ def __init__(self, code, detail): def definitions(program): - return list( + rows = list( program.criterias.order_by("pk").values( - "id", "name", "description", "type", "min_value", "max_value" + "id", + "name", + "description", + "type", + "min_value", + "max_value", + "weight_percent", ) ) + for row in rows: + if row["weight_percent"] is not None: + row["weight_percent"] = float(row["weight_percent"]) + return rows def revision(program): return hashlib.sha256( - json.dumps(definitions(program), ensure_ascii=False, sort_keys=True).encode() + json.dumps( + { + "scoring_mode": program.evaluation_scoring_mode, + "criteria": definitions(program), + }, + ensure_ascii=False, + sort_keys=True, + ).encode() ).hexdigest() @@ -46,3 +63,13 @@ def mark_locked(program): PartnerProgram.objects.filter(pk=program.pk).update( evaluation_criteria_locked_at=program.evaluation_criteria_locked_at ) + + +def require_scoring_configuration(program, criteria=None): + from django.core.exceptions import ValidationError + from project_rates.scoring import validate_configuration + + try: + validate_configuration(program, criteria) + except ValidationError as error: + raise CriteriaConflict("invalid_criteria", " ".join(error.messages)) from error diff --git a/project_rates/evaluation_math.py b/project_rates/evaluation_math.py index 8a9e5422..9aa9e120 100644 --- a/project_rates/evaluation_math.py +++ b/project_rates/evaluation_math.py @@ -3,6 +3,7 @@ from decimal import Decimal, ROUND_HALF_UP from project_rates.validators import ProjectScoreValidator +from project_rates.scoring import LEGACY, WEIGHTED, percentage_criteria, weighted_values def rounded(value): @@ -33,8 +34,48 @@ def complete_values(criteria, values): ) -def evaluation_totals(criteria, values_by_user): +def evaluation_totals(criteria, values_by_user, *, scoring_mode=LEGACY): """Сохраняет порядок округления существующего results_data, включая float totals.""" + if scoring_mode == WEIGHTED: + specifications = percentage_criteria(criteria) + evaluations, exact_totals, contributions = {}, {}, {} + for user_id, values in values_by_user.items(): + complete = complete_values(criteria, values) + parts = weighted_values(specifications, values) if complete else {} + total = sum(parts.values(), Decimal("0")) if complete else None + evaluations[user_id] = dict(complete=complete, total=numeric(total)) + if complete: + exact_totals[user_id] = total + contributions[user_id] = parts + completed = list(exact_totals) + return dict( + evaluations=evaluations, + completed=len(completed), + score=numeric(rounded(sum(exact_totals.values()) / len(completed))) + if completed + else None, + averages={ + str(c.pk): numeric( + rounded( + sum(Decimal(values_by_user[pk][c.pk]) for pk in completed) + / len(completed) + ) + ) + for c, *_ in specifications + if completed + }, + contributions={ + str(c.pk): numeric( + rounded( + sum(contributions[pk][c.pk] for pk in completed) / len(completed) + ) + ) + for c, *_ in specifications + if completed + }, + maximum=100.0, + has_numeric_criteria=True, + ) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] evaluations = {} for user_id, values in values_by_user.items(): @@ -76,6 +117,7 @@ def evaluation_totals(criteria, values_by_user): averages=averages, maximum=numeric(maximum), has_numeric_criteria=bool(numeric_criteria), + contributions={}, ) diff --git a/project_rates/migrations/0012_criteria_weight_percent_and_more.py b/project_rates/migrations/0012_criteria_weight_percent_and_more.py new file mode 100644 index 00000000..954bae2b --- /dev/null +++ b/project_rates/migrations/0012_criteria_weight_percent_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.18 on 2026-11-11 19:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0044_partnerprogram_evaluation_scoring_mode"), + ("project_rates", "0011_solution_score_value_1000"), + ] + + operations = [ + migrations.AddField( + model_name="criteria", + name="weight_percent", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=5, + null=True, + verbose_name="Вес, %", + ), + ), + migrations.AddConstraint( + model_name="criteria", + constraint=models.CheckConstraint( + condition=models.Q( + ("weight_percent__isnull", True), + models.Q( + ("type__in", ("int", "float")), + ("weight_percent__gt", 0), + ("weight_percent__lte", 100), + ), + _connector="OR", + ), + name="criterion_weight_numeric_positive", + ), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 91686007..5bd7d824 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -13,17 +13,30 @@ class CriteriaQuerySet(models.QuerySet): def delete(self): from .criteria_policy import locked + from .scoring import validate_configuration with transaction.atomic(): - ids = self.values_list("partner_program_id", flat=True).distinct() - programs = ( + selected = list(self.values_list("pk", "partner_program_id")) + deleted = [pk for pk, _ in selected] + ids = {program_id for _, program_id in selected} + programs = list( PartnerProgram.objects.select_for_update() .filter(pk__in=ids) .order_by("pk") ) + current = list( + self.filter(pk__in=deleted).values_list("pk", "partner_program_id") + ) + if any(program_id not in ids for _, program_id in current): + raise ValidationError("Критерий изменился. Обновите данные.") + deleted = [pk for pk, _ in current] if any(locked(p) for p in programs): raise ValidationError("После первой оценки критерии нельзя удалять.") - return super().delete() + for program in programs: + validate_configuration( + program, list(program.criterias.exclude(pk__in=deleted)) + ) + return models.QuerySet.delete(self.filter(pk__in=deleted)) class Criteria(models.Model): @@ -56,6 +69,9 @@ class Criteria(models.Model): null=True, blank=True, ) + weight_percent = models.DecimalField( + verbose_name="Вес, %", max_digits=5, decimal_places=2, null=True, blank=True + ) partner_program = models.ForeignKey( PartnerProgram, on_delete=models.CASCADE, @@ -64,8 +80,23 @@ class Criteria(models.Model): objects = CriteriaQuerySet.as_manager() + def clean(self): + super().clean() + from .scoring import validate_weight + + try: + validate_weight(self) + except ValueError as error: + raise ValidationError({"weight_percent": str(error)}) from error + def save(self, *args, **kwargs): from .criteria_policy import locked + from .scoring import validate_weight + + try: + validate_weight(self) + except ValueError as error: + raise ValidationError({"weight_percent": str(error)}) from error with transaction.atomic(): old = type(self).objects.filter(pk=self.pk).first() if self.pk else None @@ -77,6 +108,12 @@ def save(self, *args, **kwargs): .filter(pk__in=ids) .order_by("pk") ) + current = type(self).objects.filter(pk=self.pk).first() if self.pk else None + if (old is not None and current is None) or ( + current is not None and current.partner_program_id not in ids + ): + raise ValidationError("Критерий изменился. Обновите данные.") + old = current changed = old is None or any( getattr(old, field) != getattr(self, field) for field in ( @@ -85,11 +122,20 @@ def save(self, *args, **kwargs): "type", "min_value", "max_value", + "weight_percent", "partner_program_id", ) ) if changed and any(locked(p) for p in programs): raise ValidationError("После первой оценки критерии нельзя менять.") + if changed: + from .scoring import validate_configuration + + for program in programs: + rows = list(program.criterias.exclude(pk=self.pk)) + if program.pk == self.partner_program_id: + rows.append(self) + validate_configuration(program, rows) return super().save(*args, **kwargs) def delete(self, *args, **kwargs): @@ -101,6 +147,16 @@ def __str__(self): class Meta: verbose_name = "Критерий оценки проекта" verbose_name_plural = "Критерии оценки проектов" + constraints = [ + models.CheckConstraint( + condition=models.Q(weight_percent__isnull=True) + | ( + models.Q(type__in=("int", "float")) + & models.Q(weight_percent__gt=0, weight_percent__lte=100) + ), + name="criterion_weight_numeric_positive", + ) + ] class ProjectScore(models.Model): @@ -137,7 +193,12 @@ def save(self, *args, **kwargs): ) with transaction.atomic(): program = PartnerProgram.objects.select_for_update().get(pk=program_id) + from .scoring import validate_configuration + + validate_configuration(program) self.criteria = Criteria.objects.get(pk=self.criteria_id) + if self.criteria.partner_program_id != program.pk: + raise ValidationError("Критерий изменился. Обновите форму оценки.") ProjectScoreValidator.validate( criteria_type=self.criteria.type, value=self.value, diff --git a/project_rates/scoring.py b/project_rates/scoring.py new file mode 100644 index 00000000..0be2af48 --- /dev/null +++ b/project_rates/scoring.py @@ -0,0 +1,73 @@ +"""Процентные веса и нормализация; один контракт для API, моделей и admin.""" + +from decimal import Decimal, InvalidOperation + +from django.core.exceptions import ValidationError + +LEGACY = "legacy" +WEIGHTED = "weighted_percent" +SCORING_MODES = ((LEGACY, "Сумма исходных баллов"), (WEIGHTED, "Веса, итог от 0 до 100")) +NUMERIC_TYPES = ("int", "float") + + +def decimal_number(value): + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ValueError("Укажите конечное число.") from error + if not result.is_finite(): + raise ValueError("Укажите конечное число.") + return result + + +def validate_weight(criterion): + value = getattr(criterion, "weight_percent", None) + if value is None: + return + weight = decimal_number(value) + if criterion.type not in NUMERIC_TYPES: + raise ValueError("Вес можно задать только числовому критерию.") + if not 0 < weight <= 100 or weight != weight.quantize(Decimal("0.01")): + raise ValueError( + "Укажите вес больше 0 и не больше 100%, до двух знаков после запятой." + ) + + +def percentage_criteria(criteria): + specifications = [] + for criterion in criteria: + validate_weight(criterion) + if criterion.type not in NUMERIC_TYPES: + continue + if getattr(criterion, "weight_percent", None) is None: + raise ValueError("Укажите вес каждого числового критерия.") + low = decimal_number(criterion.min_value) + high = decimal_number(criterion.max_value) + if high <= low: + raise ValueError("Для весов нужен конечный максимум строго больше минимума.") + specifications.append( + (criterion, low, high, decimal_number(criterion.weight_percent)) + ) + if not specifications or sum(row[3] for row in specifications) != Decimal("100"): + raise ValueError("Сумма весов числовых критериев должна быть ровно 100%.") + return specifications + + +def validate_configuration(program, criteria=None): + if program.evaluation_scoring_mode == LEGACY: + return + if program.evaluation_scoring_mode != WEIGHTED: + raise ValidationError({"evaluation_scoring_mode": "Выберите способ расчета."}) + try: + percentage_criteria( + list(program.criterias.all()) if criteria is None else criteria + ) + except ValueError as error: + raise ValidationError({"evaluation_scoring_mode": str(error)}) from error + + +def weighted_values(specifications, values): + return { + criterion.pk: (decimal_number(values[criterion.pk]) - low) / (high - low) * weight + for criterion, low, high, weight in specifications + } diff --git a/project_rates/serializers.py b/project_rates/serializers.py index d315339b..883e46c5 100644 --- a/project_rates/serializers.py +++ b/project_rates/serializers.py @@ -35,6 +35,16 @@ def validate(self, data): class CriteriaSerializer(serializers.ModelSerializer): + weight_percent = serializers.SerializerMethodField() + + def get_weight_percent(self, obj): + if ( + obj.partner_program.evaluation_scoring_mode != "weighted_percent" + or obj.weight_percent is None + ): + return None + return float(obj.weight_percent) + class Meta: model = Criteria exclude = ["partner_program"] @@ -105,7 +115,7 @@ def _get_program_scores(self, obj): program_id = self.context["view"].kwargs.get("program_id") return ProjectScore.objects.filter( project=obj, criteria__partner_program_id=program_id - ).select_related("criteria", "user") + ).select_related("criteria__partner_program", "user") def _get_user_scores(self, obj): scores = self._get_program_scores(obj) @@ -124,15 +134,17 @@ def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: **CriteriaSerializer(c).data, **({"value": scores[c.pk].value} if c.pk in scores else {}), } - for c in Criteria.objects.filter(partner_program_id=program_id).order_by( - "pk" - ) + for c in Criteria.objects.filter(partner_program_id=program_id) + .select_related("partner_program") + .order_by("pk") ] if user_scores: serializer = ProjectScoreSerializer(user_scores, many=True) return serializer.data program_id = self.context["view"].kwargs.get("program_id") - criterias = Criteria.objects.filter(partner_program__id=program_id) + criterias = Criteria.objects.filter( + partner_program__id=program_id + ).select_related("partner_program") serializer = CriteriaSerializer(criterias, many=True) return serializer.data diff --git a/project_rates/services.py b/project_rates/services.py index e77bff0d..17a3011c 100644 --- a/project_rates/services.py +++ b/project_rates/services.py @@ -74,7 +74,7 @@ def get_projects_for_rate_queryset( "scores", queryset=ProjectScore.objects.filter( criteria__partner_program=program - ).select_related("user"), + ).select_related("user", "criteria__partner_program"), to_attr="_program_scores", ) @@ -157,12 +157,17 @@ def submit_project_scores(*, user, project_id: int, data) -> None: program = PartnerProgram.objects.select_for_update().get(pk=programs.pop()) Expert.objects.get(user__id=user.id, programs=program) if ( - expected_revision is not None or program.evaluation_criteria_version + expected_revision is not None + or program.evaluation_criteria_version + or program.evaluation_scoring_mode == "weighted_percent" ) and expected_revision != revision(program): raise CriteriaConflict( "criteria_changed", "Критерии изменились. Обновите форму оценки; введенные ответы сохранены в форме.", ) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program) rating_data, criteria_ids, program = _prepare_project_score_data( user=user, project_id=project_id, diff --git a/project_rates/solution_evaluation.py b/project_rates/solution_evaluation.py index 8a348b7a..a4e50ea0 100644 --- a/project_rates/solution_evaluation.py +++ b/project_rates/solution_evaluation.py @@ -179,6 +179,10 @@ def rating_frame(solution, actor): type=c.type, min_value=c.min_value, max_value=c.max_value, + weight_percent=float(c.weight_percent) + if program.evaluation_scoring_mode == "weighted_percent" + and c.weight_percent is not None + else None, value=state["own"].get(c.pk), ) for c in criteria @@ -266,6 +270,9 @@ def rate_solution(*, program_id, solution_id, actor, expected_criteria_revision, "Критерии изменились. Обновите форму оценки.", meta={"current_criteria_revision": actual_revision}, ) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program) criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) definitions = {c.pk: c for c in criteria} state = metrics(program, [solution], actor, criteria)[solution.pk] diff --git a/project_rates/solution_models.py b/project_rates/solution_models.py index e6c77bd3..fd481ced 100644 --- a/project_rates/solution_models.py +++ b/project_rates/solution_models.py @@ -49,6 +49,9 @@ def clean(self): if solution and solution.status != "submitted": raise ValidationError({"solution": "Сначала нужно сдать решение."}) if criterion: + from project_rates.scoring import validate_configuration + + validate_configuration(criterion.partner_program) try: ProjectScoreValidator.validate( criteria_type=criterion.type, diff --git a/project_rates/solution_results.py b/project_rates/solution_results.py index 3d0c36db..1a8e5ca6 100644 --- a/project_rates/solution_results.py +++ b/project_rates/solution_results.py @@ -15,6 +15,9 @@ def results_data(program, query, *, solution_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + from project_rates.criteria_policy import require_scoring_configuration + + require_scoring_configuration(program, criteria) field = get_program_case_field(program) options = field.get_options_list() if field else [] if query["case_scope"] == "selected" and query.get("case_name") not in options: @@ -40,6 +43,7 @@ def results_data(program, query, *, solution_id=None): totals = evaluation_totals( criteria, {user_id: values[(solution.pk, user_id)] for user_id in people[solution.pk]}, + scoring_mode=program.evaluation_scoring_mode, ) experts = [ dict( @@ -77,6 +81,7 @@ def results_data(program, query, *, solution_id=None): score=totals["score"], place=None, averages=totals["averages"], + contributions=totals["contributions"], experts=experts, ) ) @@ -108,10 +113,11 @@ def results_data(program, query, *, solution_id=None): ) if rows: groups.append(dict(case_name=case_name, solutions=rows)) - base = evaluation_totals(criteria, {}) + base = evaluation_totals(criteria, {}, scoring_mode=program.evaluation_scoring_mode) return dict( program_id=program.pk, program_name=program.name, + scoring_mode=program.evaluation_scoring_mode, generated_at=timezone.now(), required_evaluators=required, maximum=base["maximum"], @@ -126,6 +132,9 @@ def results_data(program, query, *, solution_id=None): type=c.type, min_value=c.min_value, max_value=c.max_value, + weight_percent=float(c.weight_percent) + if c.weight_percent is not None + else None, ) for c in criteria ], diff --git a/project_rates/tasks.py b/project_rates/tasks.py index de0c6f23..bb15034d 100644 --- a/project_rates/tasks.py +++ b/project_rates/tasks.py @@ -4,6 +4,7 @@ from django.utils import timezone from procollab.celery import app +from procollab.white_label import get_config, frontend_url @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) @@ -20,18 +21,23 @@ def send_expert_invitation(invitation_id, token): invitation = rows.select_related("partner_program").first() if not invitation: return + brand = get_config() context = { "program_name": invitation.partner_program.name, "email": invitation.email, - "invitation_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + "invitation_url": frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + "/expert-invitation/" + token, "expires_at": invitation.expires_at, - "logo_url": "https://786505.selcdn.ru/app_files/procollab_logo.png", + "logo_url": brand.logo_url + if brand.enabled + else "https://786505.selcdn.ru/app_files/procollab_logo.png", + "brand_name": brand.brand_name, + "logo_background": brand.logo_background, } try: message = EmailMultiAlternatives( - "Приглашение эксперта | PROCOLLAB", + f"Приглашение эксперта | {brand.brand_name}", render_to_string("email/expert_invitation.txt", context), settings.EMAIL_USER, [invitation.email], @@ -67,15 +73,16 @@ def send_expert_joined_notification(notification_id): ).first() if not recipient or not recipient.email: return + brand = get_config() context = { "program_name": program.name, "expert_name": notice.expert_name, "expert_email": notice.expert_email, - "program_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + "program_url": frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + f"/office/program/{program.pk}/management/experts", } message = EmailMultiAlternatives( - "Эксперт принял приглашение | PROCOLLAB", + f"Эксперт принял приглашение | {brand.brand_name}", render_to_string("email/expert_joined.txt", context), settings.EMAIL_USER, [recipient.email], diff --git a/project_rates/tests/test_weighted_criteria.py b/project_rates/tests/test_weighted_criteria.py new file mode 100644 index 00000000..1fdc0527 --- /dev/null +++ b/project_rates/tests/test_weighted_criteria.py @@ -0,0 +1,535 @@ +from decimal import Decimal +from io import BytesIO +from types import SimpleNamespace + +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase, TestCase, TransactionTestCase +from django.urls import reverse +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.services.results_excel import export_results +from project_rates.evaluation_math import evaluation_totals +from project_rates.models import Criteria, ProjectScore +from project_rates.scoring import percentage_criteria +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_user, + create_rate_project, + link_project_to_program, +) + + +class WeightedMathTests(SimpleTestCase): + def criteria(self): + return [ + SimpleNamespace( + pk=i, + type="int", + min_value=0, + max_value=10, + weight_percent=Decimal(str(weight)), + ) + for i, weight in enumerate((40, 35, 25), 1) + ] + + def test_real_percentage_example_complete_experts_and_partial_excluded(self): + result = evaluation_totals( + self.criteria(), + {1: {1: "8", 2: "7", 3: "6"}, 2: {1: "10", 2: "8", 3: "8"}, 3: {1: "10"}}, + scoring_mode="weighted_percent", + ) + self.assertEqual(result["evaluations"][1]["total"], 71.5) + self.assertEqual(result["evaluations"][2]["total"], 88) + self.assertEqual(result["completed"], 2) + self.assertEqual(result["score"], 79.75) + self.assertEqual(result["maximum"], 100) + self.assertEqual(result["contributions"], {"1": 36.0, "2": 26.25, "3": 17.5}) + + def test_different_scales_minima_zero_and_maximum(self): + criteria = self.criteria()[:2] + criteria[0].min_value, criteria[0].max_value = 2, 6 + criteria[1].max_value, criteria[1].weight_percent = 100, Decimal("60") + for values, expected in [ + ({1: "2", 2: "0"}, 0), + ({1: "6", 2: "100"}, 100), + ({1: "4", 2: "50"}, 50), + ]: + with self.subTest(values=values): + self.assertEqual( + evaluation_totals( + criteria, {1: values}, scoring_mode="weighted_percent" + )["score"], + expected, + ) + + def test_non_numeric_values_still_determine_completeness_without_weight(self): + criteria = self.criteria() + [ + SimpleNamespace( + pk=4, type="str", min_value=None, max_value=None, weight_percent=None + ) + ] + result = evaluation_totals( + criteria, + {1: {1: "8", 2: "7", 3: "6"}, 2: {1: "8", 2: "7", 3: "6", 4: ""}}, + scoring_mode="weighted_percent", + ) + self.assertEqual(result["completed"], 1) + self.assertEqual(result["score"], 71.5) + + def test_invalid_configuration_cannot_calculate_a_plausible_ranking(self): + for change in [ + dict(weight_percent=None), + dict(weight_percent=0), + dict(weight_percent=Decimal("0.001")), + dict(max_value=0), + dict(max_value=float("inf")), + dict(weight_percent=Decimal("39.99")), + ]: + criteria = self.criteria() + for key, value in change.items(): + setattr(criteria[0], key, value) + with self.subTest(change=change), self.assertRaises(ValueError): + percentage_criteria(criteria) + + def test_legacy_keeps_raw_scales_even_if_unused_weights_are_present(self): + result = evaluation_totals(self.criteria(), {1: {1: "8", 2: "7", 3: "6"}}) + self.assertEqual(result["score"], 21) + self.assertEqual(result["maximum"], 30) + + +class WeightedCriteriaApiTests(TestCase): + def setUp(self): + self.program = create_rate_program() + self.manager = create_rate_user() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = f"/programs/{self.program.pk}/management/criteria/" + + def payload(self): + snapshot = self.client.get(self.url).data + return { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "scoring_mode": "weighted_percent", + "criteria": [ + dict( + name=f"Критерий {i}", + description="", + type="int", + min_value=0, + max_value=10, + weight_percent=weight, + ) + for i, weight in enumerate((40, 35, 25), 1) + ], + } + + def put(self, data): + return self.client.put(self.url, data, format="json") + + def configure(self): + result = self.put(self.payload()) + self.assertEqual(result.status_code, 200, result.data) + self.program.refresh_from_db() + return result.data + + def test_roundtrip_percentages_and_explicit_mode(self): + data = self.configure() + self.assertEqual(data["scoring_mode"], "weighted_percent") + self.assertEqual([c["weight_percent"] for c in data["criteria"]], [40, 35, 25]) + self.assertEqual(self.program.evaluation_scoring_mode, "weighted_percent") + + def test_invalid_numbers_ranges_sum_and_comment_weight_are_atomic(self): + baseline = list(self.program.criterias.values()) + for field, value in [ + ("weight_percent", 0), + ("weight_percent", -1), + ("weight_percent", 101), + ("weight_percent", "NaN"), + ("weight_percent", "Infinity"), + ("weight_percent", "40.001"), + ("weight_percent", 39.99), + ("max_value", 0), + ]: + payload = self.payload() + payload["criteria"][0][field] = value + with self.subTest(field=field, value=value): + result = self.put(payload) + self.assertEqual(result.status_code, 400, result.data) + self.assertEqual(list(self.program.criterias.values()), baseline) + payload = self.payload() + payload["criteria"][0].update(type="str", min_value=None, max_value=None) + self.assertEqual(self.put(payload).status_code, 400) + + def test_old_client_omissions_preserve_active_mode_and_weights(self): + data = self.configure() + for criterion in data["criteria"]: + criterion.pop("weight_percent") + result = self.put( + { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": data["criteria"], + } + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data["scoring_mode"], "weighted_percent") + self.assertEqual( + [c["weight_percent"] for c in result.data["criteria"]], [40, 35, 25] + ) + + def test_atomic_redistribution_add_remove_and_switch_back_to_legacy(self): + data = self.configure() + remaining = data["criteria"][:2] + remaining[0]["weight_percent"] = 40 + remaining[1]["weight_percent"] = 60 + payload = { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": remaining, + "scoring_mode": "weighted_percent", + } + response = self.put(payload) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.program.criterias.count(), 2) + data = response.data + payload.update( + expected_updated_at=data["updated_at"], + expected_revision=data["revision"], + scoring_mode="legacy", + criteria=[], + ) + response = self.put(payload) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.program.criterias.count(), 0) + + def test_model_and_individual_admin_cannot_make_active_weights_invalid(self): + self.configure() + criterion = self.program.criterias.order_by("pk").first() + criterion.weight_percent = Decimal("41") + with self.assertRaises(ValidationError): + criterion.save() + criterion.refresh_from_db() + with self.assertRaises(ValidationError): + criterion.delete() + from project_rates.admin import CriteriaAdminForm + + form = CriteriaAdminForm( + instance=criterion, + data={ + "partner_program": self.program.pk, + "name": criterion.name, + "description": "", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": "41", + }, + ) + self.assertFalse(form.is_valid()) + + def test_delete_does_not_remove_replacement_criteria_created_before_parent_lock(self): + from unittest.mock import patch + + from partner_programs.services.management_criteria import save_criteria + from project_rates.models import CriteriaQuerySet + + self.configure() + original = CriteriaQuerySet.values_list + replace = True + + def values_list(queryset, *fields, **kwargs): + nonlocal replace + rows = original(queryset, *fields, **kwargs) + if replace and fields == ("pk", "partner_program_id"): + selected = list(rows) + replace = False + payload = self.payload() + payload["criteria"] = [dict(payload["criteria"][0], weight_percent=100)] + save_criteria(self.program.pk, self.manager, payload) + return selected + return rows + + with patch.object(CriteriaQuerySet, "values_list", values_list): + deleted, _ = self.program.criterias.all().delete() + self.assertEqual(deleted, 0) + self.assertEqual(self.program.criterias.get().weight_percent, Decimal("100")) + + def test_score_rejects_criterion_moved_before_parent_lock(self): + from unittest.mock import patch + + from project_rates.models import CriteriaQuerySet + + target = create_rate_program() + criterion = self.program.criterias.get(type="str") + project = create_rate_project() + original = CriteriaQuerySet.get + move = True + + def get(queryset, *args, **kwargs): + nonlocal move + result = original(queryset, *args, **kwargs) + if move and queryset._fields == ("partner_program_id",): + move = False + criterion.partner_program = target + criterion.save() + return result + + with patch.object(CriteriaQuerySet, "get", get): + with self.assertRaisesMessage(ValidationError, "Критерий изменился"): + ProjectScore.objects.create( + criteria=criterion, project=project, user=self.manager, value="Оценка" + ) + criterion.refresh_from_db() + self.assertEqual(criterion.partner_program_id, target.pk) + self.assertFalse(ProjectScore.objects.filter(criteria=criterion).exists()) + + def test_expert_response_exposes_only_active_weights_and_stale_form_cannot_save(self): + from unittest.mock import patch + from project_rates.tests.helpers import create_rate_expert + + snapshot = self.configure() + expert = create_rate_expert(program=self.program) + project = create_rate_project() + link_project_to_program(self.program, project) + self.client.force_authenticate(expert) + response = self.client.get(f"/rate-project/{self.program.pk}") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + [c["weight_percent"] for c in response.data["results"][0]["criterias"]], + [40, 35, 25], + ) + self.client.force_authenticate(self.manager) + body = { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "criteria": snapshot["criteria"], + "scoring_mode": "legacy", + } + after = self.put(body) + self.assertEqual(after.status_code, 200, after.data) + self.assertNotEqual(after.data["revision"], snapshot["revision"]) + self.client.force_authenticate(expert) + values = [{"criterion_id": c["id"], "value": "8"} for c in snapshot["criteria"]] + with patch("project_rates.services.send_email.delay"): + response = self.client.post( + f"/rate-project/rate/{project.pk}", + {"expected_revision": snapshot["revision"], "scores": values}, + format="json", + ) + self.assertEqual(response.status_code, 409, response.data) + self.assertFalse(ProjectScore.objects.exists()) + response = self.client.get(f"/rate-project/{self.program.pk}") + self.assertTrue( + all( + c["weight_percent"] is None + for c in response.data["results"][0]["criterias"] + ) + ) + + def test_mode_and_weight_changes_change_revision_and_partial_score_freezes_them(self): + data = self.configure() + criterion = self.program.criterias.order_by("pk").first() + project = create_rate_project() + link_project_to_program(self.program, project) + ProjectScore.objects.create( + criteria=criterion, project=project, user=self.manager, value="8" + ) + payload = { + "expected_updated_at": data["updated_at"], + "expected_revision": data["revision"], + "criteria": data["criteria"], + "scoring_mode": "legacy", + } + self.assertEqual(self.put(payload).status_code, 409) + criterion.weight_percent = Decimal("50") + with self.assertRaises(ValidationError): + criterion.save() + self.program.evaluation_scoring_mode = "legacy" + with self.assertRaises(ValidationError): + self.program.save(update_fields=["evaluation_scoring_mode"]) + + def test_guest_foreign_manager_and_expert_cannot_set_weights(self): + from project_rates.tests.helpers import create_rate_expert + + payload = self.payload() + foreign_manager = create_rate_user() + create_rate_program().managers.add(foreign_manager) + expert = create_rate_expert(program=self.program) + for actor, expected in [ + (None, 401), + (create_rate_user(), 403), + (foreign_manager, 403), + (expert, 403), + ]: + self.client.force_authenticate(actor) + self.assertEqual(self.put(payload).status_code, expected) + + def test_admin_editor_uses_the_same_atomic_weight_validation(self): + self.manager.is_staff = self.manager.is_superuser = True + self.manager.save() + self.client.force_authenticate(user=None) + self.client.force_login(self.manager) + url = reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", + args=[self.program.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Сохранить критерии и веса") + from partner_programs.services.management_criteria import criteria_data + + snapshot = criteria_data(self.program) + fields = { + "scoring_mode": "weighted_percent", + "expected_updated_at": snapshot["updated_at"].isoformat(), + "expected_revision": snapshot["revision"], + "criteria-TOTAL_FORMS": "1", + "criteria-INITIAL_FORMS": "0", + "criteria-MIN_NUM_FORMS": "0", + "criteria-MAX_NUM_FORMS": "100", + "criteria-0-name": "Качество", + "criteria-0-type": "int", + "criteria-0-min_value": "0", + "criteria-0-max_value": "10", + "criteria-0-weight_percent": "99", + } + self.assertEqual(self.client.post(url, fields).status_code, 400) + self.assertEqual(self.program.criterias.count(), 1) + fields["criteria-0-weight_percent"] = "100" + self.assertEqual(self.client.post(url, fields).status_code, 302) + self.program.refresh_from_db() + self.assertEqual(self.program.evaluation_scoring_mode, "weighted_percent") + self.assertEqual(self.program.criterias.get().weight_percent, Decimal("100")) + + def test_admin_editor_requires_model_view_and_change_permissions(self): + from django.contrib.auth.models import Permission + + viewer = create_rate_user() + viewer.is_staff = True + viewer.save() + self.client.force_authenticate(user=None) + self.client.force_login(viewer) + url = reverse( + "admin:partner_programs_partnerprogram_evaluation_criteria", + args=[self.program.pk], + ) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual(self.client.post(url, {}).status_code, 403) + viewer.user_permissions.add( + Permission.objects.get(codename="view_partnerprogram") + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'value="Сохранить критерии и веса"') + self.assertEqual(self.client.post(url, {}).status_code, 403) + + def test_excel_exports_editable_weights_and_normalized_total_formula(self): + data = self.configure() + criteria = data["criteria"] + row = dict( + name="Команда", + case_name="Кейс", + completed=1, + required=1, + place=1, + ready=True, + evaluation_complete=True, + project_id=1, + experts=[ + dict( + name="Эксперт", + email="expert@example.test", + complete=True, + user_id=1, + values=[ + dict(criterion_id=c["id"], value=value) + for c, value in zip(criteria, ["8", "7", "6"]) + ], + ) + ], + ) + book = load_workbook( + BytesIO( + export_results( + dict( + scoring_mode="weighted_percent", + criteria=criteria, + groups=[dict(projects=[row])], + ) + ) + ) + ) + self.assertEqual(book.sheetnames, ["Рейтинг", "Оценки экспертов", "Критерии"]) + self.assertEqual(book["Критерии"]["D2"].value, 40) + self.assertEqual(book["Критерии"].freeze_panes, "A2") + self.assertEqual(book["Критерии"]["A1"].font.name, "Arial") + self.assertIn("'Критерии'!D2", book["Оценки экспертов"]["I2"].value) + self.assertIn("AVERAGEIF", book["Рейтинг"]["I2"].value) + + +class WeightedMigrationTests(TransactionTestCase): + def test_existing_scores_and_old_results_keep_their_legacy_mode(self): + from django.db import connection + from django.db.migrations.executor import MigrationExecutor + + program = create_rate_program() + criterion = Criteria.objects.create( + partner_program=program, + name="Качество", + type="int", + min_value=0, + max_value=10, + ) + project = create_rate_project() + user = create_rate_user() + score = ProjectScore.objects.create( + criteria=criterion, project=project, user=user, value="8" + ) + executor = MigrationExecutor(connection) + current = executor.loader.graph.leaf_nodes() + try: + executor.migrate( + [ + ("partner_programs", "0043_project_copy_form"), + ("project_rates", "0011_solution_score_value_1000"), + ] + ) + executor = MigrationExecutor(connection) + executor.migrate(current) + old_apps = executor.loader.project_state( + [ + ("partner_programs", "0043_project_copy_form"), + ("project_rates", "0011_solution_score_value_1000"), + ] + ).apps + old_program = old_apps.get_model("partner_programs", "PartnerProgram") + fields = { + field.attname: getattr(program, field.attname) + for field in old_program._meta.local_fields + if not field.primary_key + } + fields.update(name="Legacy writer", tag="legacy-writer-after-migration") + created = old_program.objects.create(**fields) + from partner_programs.models import PartnerProgram + + self.assertEqual( + PartnerProgram.objects.get(pk=created.pk).evaluation_scoring_mode, + "legacy", + ) + program.refresh_from_db() + criterion.refresh_from_db() + score.refresh_from_db() + self.assertEqual(program.evaluation_scoring_mode, "legacy") + self.assertIsNone(criterion.weight_percent) + self.assertEqual(score.value, "8") + self.assertEqual( + evaluation_totals([criterion], {user.pk: {criterion.pk: score.value}})[ + "score" + ], + 8, + ) + finally: + MigrationExecutor(connection).migrate(current) diff --git a/project_rates/tests/test_weighted_integration.py b/project_rates/tests/test_weighted_integration.py new file mode 100644 index 00000000..545043b4 --- /dev/null +++ b/project_rates/tests/test_weighted_integration.py @@ -0,0 +1,216 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import connections +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from jsonschema import Draft202012Validator + +from partner_programs.models import PartnerProgramProject +from partner_programs.services.management_criteria import criteria_data, save_criteria +from partner_programs.services.results import results_data as project_results +from project_rates.criteria_policy import CriteriaConflict +from project_rates.models import Criteria, CriteriaQuerySet, ProjectScore +from project_rates.services import submit_project_scores +from project_rates.solution_models import SolutionScore +from project_rates.solution_results import results_data as solution_results +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +def weighted_snapshot(program, manager, weights): + snapshot = criteria_data(program) + numbers = iter(weights) + for row in snapshot["criteria"]: + row["weight_percent"] = next(numbers) if row["type"] in ("int", "float") else None + result = save_criteria( + program.pk, + manager, + dict( + expected_updated_at=snapshot["updated_at"], + expected_revision=snapshot["revision"], + scoring_mode="weighted_percent", + criteria=snapshot["criteria"], + ), + ) + program.refresh_from_db() + return result + + +class WeightedResultsParityTests(EvaluationFixture, TestCase): + def test_actual_project_and_solution_scores_share_percentage_ranking_and_contract( + self, + ): + from pathlib import Path + import json + + self.seed_evaluation() + self.program.required_evaluators = 2 + self.program.save() + legacy = create_rate_program(is_competitive=True, required_evaluators=2) + legacy.managers.add(self.manager) + legacy.criterias.all().delete() + old_criteria = [ + Criteria.objects.create( + partner_program=legacy, + name=c.name, + type=c.type, + min_value=c.min_value, + max_value=c.max_value, + ) + for c in self.criteria + ] + weighted_snapshot(self.program, self.manager, [100]) + weighted_snapshot(legacy, self.manager, [100]) + project = create_rate_project(name=self.solution.name) + legacy.users.add(project.leader, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(legacy, project) + PartnerProgramProject.objects.filter(pk=link.pk).update(submitted=True) + for user, points in ((self.expert, "8"), (self.second, "6")): + for old, current, value in zip( + old_criteria, self.criteria, [points, "False", ""] + ): + ProjectScore.objects.create( + project=project, user=user, criteria=old, value=value + ) + SolutionScore.objects.create( + solution=self.solution, user=user, criteria=current, value=value + ) + query = dict(search="", case_scope="all", scope="all", order="desc", status="all") + before, after = project_results(legacy, query), solution_results( + self.program, query + ) + left, right = ( + before["groups"][0]["projects"][0], + after["groups"][0]["solutions"][0], + ) + self.assertEqual((left["score"], right["score"]), (70, 70)) + self.assertEqual((before["maximum"], after["maximum"]), (100, 100)) + self.assertEqual((left["place"], right["place"]), (1, 1)) + self.assertEqual( + list(left["contributions"].values()), list(right["contributions"].values()) + ) + self.assertEqual(list(left["averages"].values()), [7]) + self.assertEqual([e["total"] for e in left["experts"]], [80, 60]) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text() + ) + # DateTime JSON serialization follows the actual REST renderer. + from rest_framework.renderers import JSONRenderer + + Draft202012Validator( + { + "components": spec["components"], + "$ref": "#/components/schemas/SolutionResults", + } + ).validate(json.loads(JSONRenderer().render(after))) + + +class WeightedCriteriaRaceTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + @patch("project_rates.services.send_email.delay") + def test_percentage_change_and_first_partial_score_are_serialized(self, email): + program = create_rate_program() + manager = create_rate_user() + program.managers.add(manager) + expert = create_rate_expert(program=program) + project = create_rate_project() + link_project_to_program(program, project) + first = create_rate_criteria(program, min_value=0, max_value=10) + create_rate_criteria(program, min_value=0, max_value=10) + snapshot = weighted_snapshot(program, manager, [40, 60]) + payload = dict( + expected_updated_at=snapshot["updated_at"], + expected_revision=snapshot["revision"], + scoring_mode="weighted_percent", + criteria=snapshot["criteria"], + ) + for row in payload["criteria"]: + if row["type"] == "int": + row["weight_percent"] = 50 + barrier = Barrier(2) + + def run(action): + connections.close_all() + try: + barrier.wait(timeout=10) + if action == "manager": + save_criteria(program.pk, manager, payload) + else: + submit_project_scores( + user=expert, + project_id=project.pk, + data={ + "expected_revision": snapshot["revision"], + "scores": [{"criterion_id": first.pk, "value": "8"}], + }, + ) + return "ok" + except CriteriaConflict: + return "conflict" + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(run, ["manager", "expert"])) + self.assertCountEqual(results, ["ok", "conflict"]) + first.refresh_from_db() + self.assertEqual( + first.weight_percent, 40 if ProjectScore.objects.exists() else 50 + ) + + +class WeightedModelInterleavingTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_stale_criterion_cannot_move_away_from_a_newly_locked_program(self): + source = create_rate_program() + locked_program = create_rate_program() + target = create_rate_program() + manager = create_rate_user() + stale = source.criterias.get(type="str") + project = create_rate_project() + link_project_to_program(locked_program, project) + stale.partner_program = target + original = CriteriaQuerySet.first + move = True + + def move_and_score(): + connections.close_all() + try: + current = Criteria.objects.get(pk=stale.pk) + current.partner_program = locked_program + current.save() + ProjectScore.objects.create( + criteria=current, project=project, user=manager, value="Оценка" + ) + finally: + connections.close_all() + + def first(queryset): + nonlocal move + old = original(queryset) + if move and old is not None and old.pk == stale.pk: + move = False + # The competing transaction commits before the stale writer locks + # its originally observed parents; its writes must survive rejection. + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(move_and_score).result(timeout=10) + return old + + with patch.object(CriteriaQuerySet, "first", first): + with self.assertRaisesMessage(ValidationError, "Критерий изменился"): + stale.save() + stale.refresh_from_db() + self.assertEqual(stale.partner_program_id, locked_program.pk) + self.assertTrue(ProjectScore.objects.filter(criteria=stale).exists()) diff --git a/project_rates/typing.py b/project_rates/typing.py index 162e25af..70512376 100644 --- a/project_rates/typing.py +++ b/project_rates/typing.py @@ -8,6 +8,7 @@ class CriteriasResponse(TypedDict): type: str min_value: int | float | None max_value: int | float | None + weight_percent: float | None class ProjectScoresResponse(CriteriasResponse): diff --git a/projects/cover_reset.py b/projects/cover_reset.py index c8d6eaa4..5c330355 100644 --- a/projects/cover_reset.py +++ b/projects/cover_reset.py @@ -8,11 +8,11 @@ from rest_framework.exceptions import APIException from files.models import UserFile -from files.service import CDN, SelectelSwiftStorage +from files.service import CDN, configured_storage from projects.models import DefaultProjectAvatar, DefaultProjectCover, Project logger = logging.getLogger(__name__) -cdn = CDN(storage=SelectelSwiftStorage()) +cdn = CDN(storage=configured_storage()) class DefaultCoverUnavailable(APIException): diff --git a/scripts/tests/test_hosted_pilot.py b/scripts/tests/test_hosted_pilot.py new file mode 100644 index 00000000..3ea04020 --- /dev/null +++ b/scripts/tests/test_hosted_pilot.py @@ -0,0 +1,316 @@ +"""Защита dev: чужой host/network, старые DB/роли и ошибочный nginx запрещены.""" + +import glob +import hashlib +import importlib.util +import json +from types import SimpleNamespace +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot" +with patch.object(sys, "path", [str(SOURCE), *sys.path]): + spec = importlib.util.spec_from_file_location("hosted_pilot", SOURCE / "hosted.py") + hosted = importlib.util.module_from_spec(spec) + spec.loader.exec_module(hosted) + + +def sha(value): + return hashlib.sha256(value.encode()).hexdigest() + + +class HostedPilotGuards(unittest.TestCase): + def test_dev_guard_does_not_require_react_containers(self): + containers = { + name: {"State": {"Running": True}} + for name in ("api-web-1", "api-redis-1", "api_celery") + } + with ( + patch.object( + hosted, "inspect", side_effect=containers.__getitem__ + ) as inspect, + patch.object(hosted, "health", return_value=(200, {}, b"")) as health, + ): + self.assertEqual(hosted.dev_health(), 200) + self.assertEqual( + {call.args[0] for call in inspect.call_args_list}, set(containers) + ) + health.assert_called_once_with("https://dev.procollab.ru/") + + def test_dev_guard_still_refuses_stopped_or_unhealthy_main_services(self): + names = ("api-web-1", "api-redis-1", "api_celery") + for name in names: + for bad_state in ( + {"Running": False}, + {"Running": True, "Health": {"Status": "unhealthy"}}, + ): + with self.subTest(name=name, state=bad_state): + containers = {other: {"State": {"Running": True}} for other in names} + containers[name] = {"State": bad_state} + with ( + patch.object( + hosted, "inspect", side_effect=containers.__getitem__ + ), + patch.object(hosted, "health") as health, + ): + with self.assertRaisesRegex(hosted.Refused, name): + hosted.dev_health() + health.assert_not_called() + + def test_dev_guard_still_refuses_failed_public_http(self): + with ( + patch.object(hosted, "inspect", return_value={"State": {"Running": True}}), + patch.object(hosted, "health", return_value=(503, {}, b"")), + ): + with self.assertRaises(hosted.Refused): + hosted.dev_health() + + def test_maps_use_existing_sites_enabled_http_include(self): + config = """events { worker_connections 1024; } +http { + include /etc/nginx/mime.types; + include /etc/nginx/sites-enabled/*; +}""" + hosted.validate_maps_include(config) + self.assertEqual(hosted.MAPS.parent, Path("/etc/nginx/sites-enabled")) + self.assertEqual( + hosted.http_includes(config), + ["/etc/nginx/mime.types", "/etc/nginx/sites-enabled/*"], + ) + hosted.validate_maps_include( + 'http { include "/etc/nginx/sites-enabled/*.conf" ; }' + ) + + def test_comment_or_wrong_context_does_not_enable_maps(self): + for config in ( + "# http { include /etc/nginx/sites-enabled/*; }", + "stream { include /etc/nginx/sites-enabled/*; }", + "http { server { include /etc/nginx/sites-enabled/*; } }", + 'http { log_format test "include /etc/nginx/sites-enabled/*;"; }', + ): + with self.subTest(config=config), self.assertRaises(hosted.Refused): + hosted.validate_maps_include(config) + + def test_compiled_nginx_user_when_user_directive_is_omitted(self): + with ( + patch.object( + hosted.subprocess, + "run", + return_value=SimpleNamespace( + stdout="", + stderr="configure arguments: --user=www-data --group=www-data", + ), + ), + patch.object(hosted.grp, "getgrnam", return_value=SimpleNamespace(gr_gid=33)), + ): + self.assertEqual(hosted.nginx_group("events {} http {}"), 33) + + def test_atomic_write_hides_temporary_nginx_config(self): + with TemporaryDirectory() as temp: + destination = Path(temp) / "pilot.conf" + replace = hosted.os.replace + seen = [] + + def commit(source, target): + seen.append(source.name) + self.assertEqual(glob.glob(str(Path(temp) / "*")), []) + replace(source, target) + + with patch.object(hosted.os, "replace", side_effect=commit): + hosted.private_write(destination, "map config") + self.assertTrue(seen[0].startswith(".")) + self.assertEqual(destination.read_text(), "map config") + + def test_other_host_refused_before_docker_inspection(self): + with ( + patch.object(hosted.os, "geteuid", return_value=0), + patch.object( + hosted.socket, "gethostname", return_value="procollab-production" + ), + patch.object(hosted, "inspect") as inspect, + ): + with self.assertRaises(hosted.Refused): + hosted.scope() + inspect.assert_not_called() + + def test_initialization_sql_targets_only_new_database_and_non_superuser(self): + with self.assertRaises(hosted.Refused): + hosted.database_sql( + {"DATABASE_NAME": "white_label_pilot", "DATABASE_USER": hosted.DB_ROLE} + ) + statement = hosted.database_sql( + { + "DATABASE_NAME": hosted.DB_NAME, + "DATABASE_USER": hosted.DB_ROLE, + "DATABASE_PASSWORD": "synthetic-password", + } + ) + self.assertIn("NOSUPERUSER NOCREATEDB NOCREATEROLE", statement) + self.assertIn('ALTER DATABASE "procollab_white_label_pilot_v2"', statement) + self.assertNotIn('"white_label_pilot"', statement) + self.assertNotIn("ALTER ROLE", statement) + + def test_low_memory_stops_before_import_or_start(self): + runtime = SimpleNamespace( + info={"image_size_bytes": 2**30, "architecture": "amd64"} + ) + with ( + patch.object(hosted, "scope", return_value={}), + patch.object(hosted, "candidate", return_value=None), + patch.object( + hosted, + "docker_json", + return_value={ + "Driver": "overlay2", + "DockerRootDir": "/tmp", + "Architecture": "amd64", + }, + ), + patch.object(hosted, "docker", return_value=SimpleNamespace(returncode=1)), + patch.object(hosted.socket, "socket"), + patch.object( + hosted.Path, "read_text", return_value="MemAvailable: 500000 kB\n" + ), + patch.object(hosted, "existing_image") as image, + ): + with self.assertRaises(hosted.Refused): + hosted.readiness(runtime) + image.assert_not_called() + + def test_existing_network_with_dev_member_is_refused(self): + with ( + patch.object( + hosted, + "docker", + return_value=SimpleNamespace( + returncode=0, + stdout=json.dumps( + [ + { + "Labels": {hosted.LABEL: hosted.IDENTITY}, + "Containers": {"x": {"Name": "api-web-1"}}, + } + ] + ), + ), + ), + patch.object(hosted, "environment") as env, + ): + with self.assertRaises(hosted.Refused): + hosted.prepare_database() + env.assert_not_called() + + def test_mismatched_runtime_and_source_are_refused(self): + with patch.object( + hosted, "verify_bundle", return_value={"api_revision": "a" * 40} + ): + with self.assertRaises(hosted.Refused): + hosted.verify_compatibility( + Path("unused"), SimpleNamespace(info={"api_revision": "b" * 40}) + ) + + def test_symlink_outside_new_directory_is_refused(self): + with TemporaryDirectory() as temp: + root = Path(temp) / "new" + root.mkdir() + outside = Path(temp) / "dev" + outside.mkdir() + (root / "data").symlink_to(outside, target_is_directory=True) + with patch.object(hosted, "NEW", root): + with self.assertRaises(hosted.Refused): + hosted.owned_path(root / "data/media") + self.assertEqual(list(outside.iterdir()), []) + + def test_failed_preflight_creates_no_new_files_or_database(self): + with TemporaryDirectory() as temp: + root = Path(temp) / "new" + with ( + patch.object(hosted, "NEW", root), + patch.object( + hosted, + "verify_compatibility", + return_value={"api_revision": "a" * 40}, + ), + patch.object( + hosted, "readiness", side_effect=hosted.Refused("low resources") + ), + patch.object(hosted, "prepare_database") as sql, + ): + with self.assertRaises(hosted.Refused): + hosted.stage(Path(temp), object()) + self.assertFalse(root.exists()) + sql.assert_not_called() + + def test_rollback_preserves_files_changed_by_another_operator(self): + with TemporaryDirectory() as temp: + root = Path(temp) + routes = root / "routes.inc" + maps = root / "maps.conf" + routes.write_text("new routes") + maps.write_text("foreign change") + state = { + "new_routes_sha256": sha("new routes"), + "original_routes_sha256": sha("old routes"), + "new_maps_sha256": sha("new maps"), + } + with patch.object(hosted, "ROUTES", routes), patch.object( + hosted, "MAPS", maps + ): + with self.assertRaises(hosted.Refused): + hosted.rollback_files(state) + self.assertEqual(routes.read_text(), "new routes") + self.assertEqual(maps.read_text(), "foreign change") + + def test_nginx_failure_restores_only_pilot_routes(self): + with TemporaryDirectory() as temp: + root = Path(temp) + (root / "backup").mkdir() + routes = root / "routes.inc" + maps = root / "maps.conf" + routes.write_text("old routes") + (root / "backup/routes.inc").write_text("old routes") + dev = root / "dev.conf" + dev.write_text("untouched dev") + state = { + "identity": hosted.IDENTITY, + "phase": "staged", + "original_routes_sha256": sha("old routes"), + "image_id": "sha256:test", + "gate_token": "a" * 32, + } + count = 0 + + def nginx(argv, **kwargs): + nonlocal count + count += 1 + if count == 1: + raise subprocess.CalledProcessError(1, argv) + return subprocess.CompletedProcess(argv, 0) + + with ( + patch.object(hosted, "NEW", root), + patch.object(hosted, "ROUTES", routes), + patch.object(hosted, "MAPS", maps), + patch.object(hosted, "scope"), + patch.object(hosted, "state_read", return_value=state), + patch.object( + hosted, + "candidate", + return_value={"State": {"Running": True}, "Image": "sha256:test"}, + ), + patch.object(hosted, "candidate_health"), + patch.object(hosted, "dev_health", return_value=200), + patch.object(hosted.subprocess, "run", side_effect=nginx), + ): + with self.assertRaises(hosted.Refused): + hosted.switch() + self.assertEqual(routes.read_text(), "old routes") + self.assertFalse(maps.exists()) + self.assertEqual(dev.read_text(), "untouched dev") + self.assertEqual( + json.loads((root / "state.json").read_text())["phase"], "rolled_back" + ) diff --git a/scripts/tests/test_pilot_configure.py b/scripts/tests/test_pilot_configure.py new file mode 100644 index 00000000..6e400053 --- /dev/null +++ b/scripts/tests/test_pilot_configure.py @@ -0,0 +1,47 @@ +"""Границы пакета: секреты закрыты, повтор не меняет существующий пилот.""" + +import importlib.util +from pathlib import Path +import shutil +import stat +from tempfile import TemporaryDirectory +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "deploy" / "pilot" +spec = importlib.util.spec_from_file_location("pilot_configure", SOURCE / "configure.py") +configure = importlib.util.module_from_spec(spec) +spec.loader.exec_module(configure) + + +class PilotConfigureTests(unittest.TestCase): + def test_new_secrets_are_private_and_repeat_does_not_overwrite(self): + with TemporaryDirectory() as temp: + root = Path(temp) + shutil.copy2(SOURCE / "nginx.conf.template", root) + configure.configure(root) + before = (root / ".env").read_bytes() + for name in (".env", "nginx.conf", "access.htpasswd", "access.txt"): + self.assertEqual(stat.S_IMODE((root / name).stat().st_mode), 0o600) + for folder, mode in ( + ("data", 0o711), + ("data/media", 0o755), + ("data/static", 0o755), + ("data/mail", 0o700), + ("logs", 0o700), + ): + self.assertEqual(stat.S_IMODE((root / folder).stat().st_mode), mode) + self.assertNotIn("@@GATE_TOKEN@@", (root / "nginx.conf").read_text()) + self.assertNotIn("WhiteLabel-demo-2026!", (root / ".env").read_text()) + with self.assertRaises(FileExistsError): + configure.configure(root) + self.assertEqual((root / ".env").read_bytes(), before) + + def test_external_credentials_and_non_https_origin_rejected(self): + with TemporaryDirectory() as temp: + for origin in ( + "http://pilot.procollab.ru", + "https://user:secret@pilot.procollab.ru", + "https://pilot.procollab.ru/path", + ): + with self.subTest(origin=origin), self.assertRaises(ValueError): + configure.configure(Path(temp), origin) diff --git a/scripts/tests/test_pilot_confirm_qa.py b/scripts/tests/test_pilot_confirm_qa.py new file mode 100644 index 00000000..73728062 --- /dev/null +++ b/scripts/tests/test_pilot_confirm_qa.py @@ -0,0 +1,69 @@ +"""QA confirmation never consumes other users' mail or sends credentials elsewhere.""" + +from email.message import EmailMessage +import importlib.util +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot/confirm_qa.py" +spec = importlib.util.spec_from_file_location("pilot_confirm_qa", SOURCE) +qa = importlib.util.module_from_spec(spec) +spec.loader.exec_module(qa) +LEADER = "qa-1794415505972-2b6c7b-leader@example.test" +MEMBER = "qa-1794415505972-2b6c7b-member@example.test" + + +class PilotQAConfirmation(unittest.TestCase): + def test_only_accounts_of_one_qa_run_are_allowed(self): + qa.validate_emails([LEADER, MEMBER]) + for emails in ( + ["admin@example.test"], + ["user@client.test"], + [LEADER, LEADER], + [LEADER, MEMBER.replace("2b6c7b", "ffffff")], + [], + ): + with self.subTest(emails=emails), self.assertRaises(qa.Refused): + qa.validate_emails(emails) + + def test_reads_multipart_mail_for_exact_recipient(self): + with TemporaryDirectory() as directory: + mail = EmailMessage() + mail["To"] = LEADER + mail.set_content("Подтверждение регистрации") + mail.add_alternative( + 'Подтвердить', + subtype="html", + ) + (Path(directory) / "email.log").write_bytes(mail.as_bytes()) + self.assertEqual( + qa.confirmation_from_mail(Path(directory), LEADER), "signed:proof" + ) + with self.assertRaises(qa.Refused): + qa.confirmation_from_mail(Path(directory), MEMBER) + + def test_rejects_other_domains_programs_and_receipts(self): + for link in ( + "https://pilot.procollab.ru.evil.test/program-registration/1#confirmation=x", + "https://pilot.procollab.ru/program-registration/2#confirmation=x", + "https://pilot.procollab.ru/program-registration/1#receipt=x", + ): + with self.subTest(link=link), TemporaryDirectory() as directory: + mail = EmailMessage() + mail["To"] = LEADER + mail.set_content(link) + (Path(directory) / "email.log").write_bytes(mail.as_bytes()) + with self.assertRaises(qa.Refused): + qa.confirmation_from_mail(Path(directory), LEADER) + + def test_redirect_cannot_send_credentials_outside_pilot(self): + with self.assertRaises(qa.Refused): + qa.ScopedRedirect().redirect_request( + None, None, 302, "", {}, "https://other.test/" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pilot_frontend_upgrade.py b/scripts/tests/test_pilot_frontend_upgrade.py new file mode 100644 index 00000000..2fb21de3 --- /dev/null +++ b/scripts/tests/test_pilot_frontend_upgrade.py @@ -0,0 +1,562 @@ +"""Изоляция dev и сохранность файлов при публикации/откате статики пилота.""" + +import hashlib +import importlib +import json +from pathlib import Path +import shutil +import sys +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot" +with patch.object(sys, "path", [str(SOURCE), *sys.path]): + upgrade = importlib.import_module("upgrade_frontend") + packager = importlib.import_module("package_frontend") + unpacker = importlib.import_module("unpack_candidate") +REAL_SNAPSHOT = upgrade.protected_snapshot +REAL_HEALTH = upgrade.hosted.health + + +class PilotFrontendUpgradeTests(unittest.TestCase): + def setUp(self): + self.directory = Path(self.enterContext(TemporaryDirectory())) + self.root = self.directory / "pilot" + self.root.mkdir() + (self.root / "frontend").mkdir() + (self.root / "backup").mkdir() + (self.root / "access.txt").write_text("Пароль входа: test-gate\n") + self.route = self.root / "routes.inc" + self.maps = self.root / "maps.conf" + self.route.write_text("pilot routes unchanged") + self.maps.write_text("pilot maps unchanged") + for name in (".env", "state.json", "backend.py", "database-sentinel"): + (self.root / name).write_text("unchanged " + name) + self.original = { + "index.html": b"old index", + "main-old.js": b"old chunk retained for open tabs", + "assets/logo.svg": b"unchanged logo", + } + self.new = { + "index.html": b'new index', + "main-new.js": b"new chunk", + "assets/logo.svg": b"unchanged logo", + } + for relative, data in self.original.items(): + destination = self.root / "frontend" / relative + destination.parent.mkdir(exist_ok=True) + destination.write_bytes(data) + self.enterContext(patch.object(upgrade, "ROOT", self.root)) + self.enterContext( + patch.object(upgrade, "RECORD", self.root / "frontend-upgrade.json") + ) + self.enterContext( + patch.object(upgrade, "BASE_INDEX", self.digest(self.original["index.html"])) + ) + self.enterContext(patch.object(upgrade.files, "ROOT", self.root)) + self.enterContext(patch.object(upgrade.hosted, "NEW", self.root)) + self.enterContext(patch.object(upgrade.hosted, "ROUTES", self.route)) + self.enterContext(patch.object(upgrade.hosted, "MAPS", self.maps)) + self.scope = self.enterContext(patch.object(upgrade.hosted, "scope")) + self.dev = self.enterContext(patch.object(upgrade.hosted, "dev_health")) + self.container = { + "Id": "pilot-original-id", + "Image": "original-image", + "State": {"Running": True}, + } + self.enterContext( + patch.object(upgrade.hosted, "candidate", return_value=self.container) + ) + self.state = { + "phase": "active", + "app_patch_revision": upgrade.BACKEND, + "weights_revision": upgrade.BACKEND, + "image_id": "original-image", + "new_routes_sha256": upgrade.hosted.digest(self.route), + "new_maps_sha256": upgrade.hosted.digest(self.maps), + } + self.enterContext( + patch.object(upgrade.hosted, "state_read", return_value=self.state) + ) + (self.root / "weights-upgrade.json").write_text( + json.dumps( + { + "identity": "hosted-v2-weights-upgrade", + "phase": "active", + "release": upgrade.BACKEND, + "candidate_id": self.container["Id"], + } + ) + ) + self.snapshot = { + "format": upgrade.SNAPSHOT_FORMAT, + "containers": { + "dev": ["dev-original-id", "image-id", "original-start", "config-sha"] + }, + "files": {"nginx": "unchanged"}, + "nginx": "original-nginx", + } + self.snapshot_mock = self.enterContext( + patch.object(upgrade, "protected_snapshot", return_value=self.snapshot) + ) + self.health = self.enterContext( + patch.object(upgrade.hosted, "health", side_effect=self.http) + ) + self.docker_mutation = self.enterContext( + patch.object( + upgrade.hosted, + "docker", + side_effect=AssertionError("Docker command forbidden"), + ) + ) + self.bundle = self.directory / "procollab-pilot" + self.bundle.mkdir() + for relative in upgrade.HELPERS: + shutil.copyfile(SOURCE / relative, self.bundle / relative) + for relative, data in self.new.items(): + if data != self.original.get(relative): + destination = self.bundle / "frontend" / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + self.manifest = { + "format": upgrade.IDENTITY, + "backend_revision": upgrade.BACKEND, + "base_frontend_revision": upgrade.PREVIOUS, + "frontend_revision": upgrade.REVISION, + "base_files": {p: self.digest(b) for p, b in self.original.items()}, + "new_files": {p: self.digest(b) for p, b in self.new.items()}, + "files": { + str(p.relative_to(self.bundle)): upgrade.hosted.digest(p) + for p in self.bundle.rglob("*") + if p.is_file() + }, + } + self.write_manifest() + + @staticmethod + def digest(data): + return hashlib.sha256(data).hexdigest() + + def write_manifest(self): + (self.bundle / "frontend-manifest.json").write_text(json.dumps(self.manifest)) + + def http(self, url, **kwargs): + if url.endswith("/backend/instance/"): + return 200, {}, b'{"enabled":true}' + if url.endswith("/backend/programs/1/registration/"): + return 200, {}, b'{"account_mode":"email_password"}' + self.assertEqual(url, upgrade.hosted.ORIGIN + "/") + return 200, {}, (self.root / "frontend/index.html").read_bytes() + + def test_apply_changes_only_static_files_and_publishes_index_last(self): + order = [] + original_copy = upgrade.files.copy_atomic + + def copy(source, destination, mode=0o644): + if destination.is_relative_to(self.root / "frontend"): + order.append(str(destination.relative_to(self.root / "frontend"))) + return original_copy(source, destination, mode) + + with patch.object(upgrade.files, "copy_atomic", side_effect=copy): + result = upgrade.apply(self.bundle) + self.assertEqual(result["phase"], "active") + self.assertFalse(result["containers_restarted"]) + self.assertFalse(result["database_changed"]) + self.assertEqual(order, ["main-new.js", "index.html"]) + self.assertEqual( + (self.root / "frontend/main-old.js").read_bytes(), + self.original["main-old.js"], + ) + self.assertEqual( + (self.root / "frontend/main-new.js").stat().st_mode & 0o777, 0o644 + ) + for name in (".env", "state.json", "backend.py", "database-sentinel"): + self.assertEqual((self.root / name).read_text(), "unchanged " + name) + self.docker_mutation.assert_not_called() + + def test_unhealthy_dev_stops_before_any_backup_or_frontend_write(self): + self.dev.side_effect = upgrade.hosted.Refused("dev unavailable") + with self.assertRaisesRegex(upgrade.hosted.Refused, "dev unavailable"): + upgrade.apply(self.bundle) + self.assertFalse(upgrade.RECORD.exists()) + self.assertEqual(list((self.root / "backup").iterdir()), []) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.original["index.html"] + ) + + def test_failed_postpublish_dev_check_restores_previous_index_without_restarts(self): + self.dev.side_effect = [None, upgrade.hosted.Refused("dev unavailable")] + with self.assertRaisesRegex(upgrade.hosted.Refused, "восстановлен"): + upgrade.apply(self.bundle) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.original["index.html"] + ) + self.assertEqual(upgrade.load_record()["phase"], "rolled_back") + self.assertTrue((self.root / "frontend/main-new.js").exists()) + self.docker_mutation.assert_not_called() + + def test_failed_https_verification_automatically_rolls_back(self): + original_http = self.http + failed = False + + def fail_new_index_once(url, **kwargs): + nonlocal failed + if url == upgrade.hosted.ORIGIN + "/" and not failed: + failed = True + return 502, {}, b"bad gateway" + return original_http(url, **kwargs) + + self.health.side_effect = fail_new_index_once + with self.assertRaisesRegex(upgrade.hosted.Refused, "восстановлен"): + upgrade.apply(self.bundle) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.original["index.html"] + ) + record = upgrade.load_record() + self.assertEqual(record["failure"]["stage"], "published_https") + self.assertIn("HTTPS отдает другой frontend", record["failure"]["reason"]) + saved = Path(record["backup"]) / "failure.json" + self.assertEqual(json.loads(saved.read_text()), record["failure"]) + self.assertEqual(saved.stat().st_mode & 0o777, 0o600) + + def test_baseline_drift_prevents_overwriting_operator_changes(self): + (self.root / "frontend/assets/logo.svg").write_bytes(b"operator logo") + with self.assertRaisesRegex(upgrade.hosted.Refused, "изменен"): + upgrade.apply(self.bundle) + self.assertEqual( + (self.root / "frontend/assets/logo.svg").read_bytes(), b"operator logo" + ) + self.assertFalse(upgrade.RECORD.exists()) + + def test_corrupt_or_extra_bundle_file_rejected(self): + for name in ("frontend/main-new.js", "api.py"): + with self.subTest(name=name): + target = self.bundle / name + before = target.read_bytes() if target.exists() else None + target.write_bytes(b"corrupted") + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_bundle(self.bundle) + if before is None: + target.unlink() + else: + target.write_bytes(before) + + def test_forbidden_paths_and_internal_symlink_rejected(self): + for name in ( + ".", + "../dev/index.html", + "/etc/nginx/nginx.conf", + "x/../a", + "x\\y", + "x\ny", + ): + with self.subTest(name=name), self.assertRaises(upgrade.hosted.Refused): + upgrade.relative_name(name) + link = self.root / "frontend/alias" + link.symlink_to(self.root / "frontend/assets", target_is_directory=True) + with self.assertRaises(upgrade.hosted.Refused): + upgrade.frontend_path("alias/logo.svg") + + def test_second_apply_only_checks_existing_release(self): + upgrade.apply(self.bundle) + backups = list((self.root / "backup").iterdir()) + with patch.object( + upgrade.files, "copy_atomic", side_effect=AssertionError("No repeat write") + ): + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertEqual(list((self.root / "backup").iterdir()), backups) + + def test_manual_rollback_keeps_old_and_new_chunks_for_open_tabs(self): + upgrade.apply(self.bundle) + upgrade.rollback(upgrade.load_record()) + self.assertEqual(upgrade.status()["frontend_revision"], upgrade.PREVIOUS) + self.assertTrue((self.root / "frontend/main-old.js").exists()) + self.assertTrue((self.root / "frontend/main-new.js").exists()) + + def test_rollback_does_not_overwrite_new_external_edits(self): + upgrade.apply(self.bundle) + (self.root / "frontend/index.html").write_bytes(b"external edit") + with self.assertRaisesRegex(upgrade.hosted.Refused, "изменен снаружи"): + upgrade.rollback(upgrade.load_record()) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), b"external edit" + ) + + def test_corrupt_backup_stops_rollback_before_any_static_write(self): + upgrade.apply(self.bundle) + record = upgrade.load_record() + (Path(record["backup"]) / "frontend/index.html").write_bytes(b"corrupt backup") + with self.assertRaisesRegex(upgrade.hosted.Refused, "повреждена"): + upgrade.rollback(record) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.new["index.html"] + ) + + def test_frontend_package_has_only_changed_assets_and_safe_zip_envelope(self): + frontend = self.directory / "compiled" + frontend.mkdir() + for relative, data in self.new.items(): + target = frontend / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + base = self.directory / "base.json" + base.write_text( + json.dumps( + { + "api_revision": upgrade.BACKEND, + "files": { + "frontend/" + p: self.digest(b) for p, b in self.original.items() + }, + } + ) + ) + meta = packager.package(frontend, base, self.directory / "package") + unpacked = unpacker.unpack(Path(meta["archive"]), self.directory / "unpacked") + manifest = upgrade.verify_bundle(unpacked) + self.assertEqual(meta["changed_files"], 2) + self.assertEqual( + set(manifest["files"]), + upgrade.HELPERS | {"frontend/index.html", "frontend/main-new.js"}, + ) + self.assertFalse((unpacked / "api").exists()) + self.assertFalse((unpacked / "frontend/assets/logo.svg").exists()) + + def test_snapshot_detects_external_changes_without_exposing_container_secrets(self): + from types import SimpleNamespace + + container = { + "Id": "original-id", + "Image": "original-image", + "State": {"Running": True, "StartedAt": "original-start"}, + "Config": {"Env": ["PASSWORD=never-export-this"]}, + "HostConfig": {}, + "Mounts": [ + {"Type": "bind", "RW": False, "Source": str(self.root / "backend.py")} + ], + } + with patch.object( + upgrade.hosted, "inspect", return_value=container + ), patch.object( + upgrade.subprocess, + "run", + return_value=SimpleNamespace(stdout=b"nginx original"), + ) as nginx: + first = REAL_SNAPSHOT() + self.assertNotIn("never-export-this", json.dumps(first)) + nginx.assert_called_once_with( + ["nginx", "-T"], capture_output=True, check=True + ) + (self.root / "backend.py").write_text("changed backend") + self.assertNotEqual(first, REAL_SNAPSHOT()) + (self.root / "backend.py").write_text("unchanged backend.py") + container["State"]["StartedAt"] = "external restart" + self.assertNotEqual(first, REAL_SNAPSHOT()) + + def test_symlink_backup_is_rejected_before_writes(self): + (self.root / "backup").rmdir() + (self.root / "backup").symlink_to( + self.root / "frontend", target_is_directory=True + ) + with self.assertRaisesRegex(upgrade.hosted.Refused, "backup является ссылкой"): + upgrade.apply(self.bundle) + self.assertFalse(upgrade.RECORD.exists()) + + def test_fingerprint_normalizes_only_mount_order_without_mutating_inspect(self): + container = { + "Config": {"Env": ["A=first", "A=last"]}, + "HostConfig": {"Memory": 768}, + "Mounts": [ + {"Source": "/b", "Destination": "/b", "Type": "bind", "RW": False}, + {"Source": "/a", "Destination": "/a", "Type": "bind", "RW": True}, + ], + } + before = json.loads(json.dumps(container)) + permuted = json.loads(json.dumps(container)) + permuted["Mounts"].reverse() + self.assertEqual( + upgrade.configuration_fingerprint(container), + upgrade.configuration_fingerprint(permuted), + ) + self.assertEqual(container, before) + permuted["Config"]["Env"].reverse() + self.assertNotEqual( + upgrade.configuration_fingerprint(container), + upgrade.configuration_fingerprint(permuted), + ) + + def test_fingerprint_still_detects_mount_source_destination_mode_and_limits(self): + container = { + "Config": {}, + "HostConfig": {"Memory": 768}, + "Mounts": [ + {"Source": "/a", "Destination": "/a", "Type": "bind", "RW": False} + ], + } + original = upgrade.configuration_fingerprint(container) + for key, value in ( + ("Source", "/dev"), + ("Destination", "/dev"), + ("RW", True), + ("Type", "volume"), + ): + changed = json.loads(json.dumps(container)) + changed["Mounts"][0][key] = value + with self.subTest(key=key): + self.assertNotEqual(original, upgrade.configuration_fingerprint(changed)) + changed = json.loads(json.dumps(container)) + changed["HostConfig"]["Memory"] = 1024 + self.assertNotEqual(original, upgrade.configuration_fingerprint(changed)) + + def test_apply_succeeds_when_inspect_reorders_mounts_between_every_call(self): + from types import SimpleNamespace + + calls = {} + + def inspect(name): + calls[name] = calls.get(name, 0) + 1 + mounts = [ + { + "Source": str(self.root / "backend.py"), + "Destination": "/procollab/backend.py", + "Type": "bind", + "RW": False, + }, + { + "Source": str(self.root / ".env"), + "Destination": "/procollab/.env", + "Type": "bind", + "RW": False, + }, + ] + return { + "Id": name + "-id", + "Image": "original-image", + "State": {"Running": True, "StartedAt": "original-start"}, + "Config": {}, + "HostConfig": {}, + "Mounts": mounts if calls[name] % 2 else list(reversed(mounts)), + } + + self.snapshot_mock.side_effect = REAL_SNAPSHOT + with patch.object(upgrade.hosted, "inspect", side_effect=inspect), patch.object( + upgrade.subprocess, + "run", + return_value=SimpleNamespace(stdout=b"nginx unchanged"), + ): + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertGreater(sum(calls.values()), 20) + self.docker_mutation.assert_not_called() + + def test_retry_accepts_old_rolled_back_record_and_preserves_it_in_backup(self): + upgrade.apply(self.bundle) + upgrade.rollback(upgrade.load_record()) + previous = upgrade.load_record() + previous["protected"].pop("format") + previous["protected"]["containers"]["dev"][3] = "legacy-unsorted-hash" + upgrade.save_record(previous) + previous_path = Path(previous["backup"]) / "record.json" + before = previous_path.read_bytes() + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertEqual(previous_path.read_bytes(), before) + self.assertEqual( + upgrade.load_record()["protected"]["format"], upgrade.SNAPSHOT_FORMAT + ) + + def test_retry_refuses_real_external_restart_since_previous_attempt(self): + upgrade.apply(self.bundle) + upgrade.rollback(upgrade.load_record()) + self.snapshot["containers"]["dev"][2] = "external-restart" + with self.assertRaisesRegex(upgrade.hosted.Refused, "Контейнеры изменились"): + upgrade.apply(self.bundle) + self.assertEqual(upgrade.load_record()["phase"], "rolled_back") + + def test_external_fixed_tool_works_with_immutable_original_bundle(self): + original_helper = self.bundle / "upgrade_frontend.py" + original_helper.write_text("# previous verified helper retained unchanged\n") + self.manifest["files"]["upgrade_frontend.py"] = upgrade.hosted.digest( + original_helper + ) + self.write_manifest() + manifest_before = (self.bundle / "frontend-manifest.json").read_bytes() + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertEqual( + (self.bundle / "frontend-manifest.json").read_bytes(), manifest_before + ) + self.assertEqual( + upgrade.hosted.digest(original_helper), + self.manifest["files"]["upgrade_frontend.py"], + ) + + def test_real_http_publication_retry_and_automatic_rollback_preserve_dev(self): + from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + import threading + from urllib.parse import urlsplit + + frontend = self.root / "frontend" + fail_new_index = [False] + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(frontend), **kwargs) + + def log_message(self, *args): + pass + + def do_GET(self): + if self.path in ( + "/dev", + "/backend/instance/", + "/backend/programs/1/registration/", + ): + body = { + "/dev": b"existing dev remains available", + "/backend/instance/": b'{"enabled":true}', + "/backend/programs/1/registration/": b'{"account_mode":"email_password"}', + }[self.path] + self.send_response(200) + self.end_headers() + self.wfile.write(body) + elif ( + self.path == "/" + and fail_new_index[0] + and (frontend / "index.html").read_bytes().endswith(b"new index") + ): + fail_new_index[0] = False + self.send_error(502) + else: + super().do_GET() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + local = "http://127.0.0.1:" + str(server.server_port) + + def real_http(url, **kwargs): + return REAL_HEALTH(local + urlsplit(url).path, **kwargs) + + def dev_health(): + code, _, body = REAL_HEALTH(local + "/dev") + self.assertEqual((code, body), (200, b"existing dev remains available")) + + try: + self.health.side_effect = real_http + self.dev.side_effect = dev_health + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertEqual(REAL_HEALTH(local + "/")[2], self.new["index.html"]) + upgrade.rollback(upgrade.load_record()) + fail_new_index[0] = True + with self.assertRaisesRegex(upgrade.hosted.Refused, "восстановлен"): + upgrade.apply(self.bundle) + self.assertEqual(REAL_HEALTH(local + "/")[2], self.original["index.html"]) + self.assertEqual(upgrade.load_record()["phase"], "rolled_back") + self.assertEqual(upgrade.status()["dev_http"], 200) + self.docker_mutation.assert_not_called() + finally: + server.shutdown() + thread.join(timeout=2) + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pilot_invite_upgrade.py b/scripts/tests/test_pilot_invite_upgrade.py new file mode 100644 index 00000000..1e91f143 --- /dev/null +++ b/scripts/tests/test_pilot_invite_upgrade.py @@ -0,0 +1,251 @@ +"""Source-only pilot update refuses foreign code and preserves outside edits.""" + +import hashlib +import importlib.util +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import sys +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot" +with patch.object(sys, "path", [str(SOURCE), *sys.path]): + spec = importlib.util.spec_from_file_location( + "pilot_invite_upgrade", SOURCE / "upgrade_invites.py" + ) + upgrade = importlib.util.module_from_spec(spec) + spec.loader.exec_module(upgrade) + + +class PilotInviteUpgradeTests(unittest.TestCase): + def manifest(self): + files = {"api/" + name: value for name, value in upgrade.BASE_HASHES.items()} + for name in upgrade.PATCH_FILES: + files["api/" + name] = "a" * 64 + return {"files": files} + + def test_only_known_base_code_and_dependencies_are_accepted(self): + self.assertEqual( + set(upgrade.validate_backend(upgrade.BASE_HASHES, self.manifest())), + set(upgrade.PATCH_FILES), + ) + altered = {**upgrade.BASE_HASHES, "users/views.py": "b" * 64} + with self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend(altered, self.manifest()) + altered = {**upgrade.BASE_HASHES, "users/white_label_candidates.py": "b" * 64} + with self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend(altered, self.manifest()) + bundle = self.manifest() + bundle["files"]["api/poetry.lock"] = "b" * 64 + with self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend(upgrade.BASE_HASHES, bundle) + + def test_frontend_cannot_escape_into_data_or_follow_external_links(self): + with TemporaryDirectory() as directory, patch.object( + upgrade, "ROOT", Path(directory) + ): + root = Path(directory) + (root / "frontend").mkdir() + (root / "data").mkdir() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.frontend_path("../data/upload.pdf") + (root / "frontend/link").symlink_to(root / "data", target_is_directory=True) + with self.assertRaises(upgrade.hosted.Refused): + upgrade.frontend_path("link/upload.pdf") + + def test_foreign_frontend_changes_stop_rollback_before_container_operations(self): + with TemporaryDirectory() as directory, patch.object( + upgrade, "ROOT", Path(directory) + ): + root = Path(directory) + (root / "frontend").mkdir() + (root / "frontend/index.html").write_text("operator change") + record = { + "phase": "active", + "backup": str(root / "backup"), + "frontend": {"index.html": {"before": "a" * 64, "after": "b" * 64}}, + } + with patch.object(upgrade.hosted, "docker") as docker: + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback(record) + docker.assert_not_called() + + def test_changed_container_cannot_be_stopped_by_rollback(self): + record = { + "phase": "active", + "backup": "/unused", + "frontend": {}, + "old_container_id": "old", + "release": "a" * 40, + "candidate_id": "expected", + } + container = { + "Id": "foreign", + "Config": { + "Labels": { + upgrade.REVISION_LABEL: "a" * 40, + upgrade.hosted.LABEL: upgrade.hosted.IDENTITY, + } + }, + "State": {"Running": True}, + } + with patch.object( + upgrade.hosted, + "docker", + return_value=SimpleNamespace(returncode=0, stdout=json.dumps([container])), + ) as docker: + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback(record) + self.assertEqual(docker.call_count, 1) + + def test_atomic_frontend_write_never_publishes_partial_index(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "new.html" + source.write_bytes(b"complete new index") + target = root / "index.html" + target.write_bytes(b"old index") + replace = upgrade.os.replace + + def commit(temporary, destination): + self.assertEqual(target.read_bytes(), b"old index") + self.assertEqual(temporary.read_bytes(), source.read_bytes()) + replace(temporary, destination) + + with patch.object(upgrade.os, "replace", side_effect=commit): + upgrade.copy_atomic(source, target) + self.assertEqual( + hashlib.sha256(source.read_bytes()).digest(), + hashlib.sha256(target.read_bytes()).digest(), + ) + + def test_missing_frontend_backup_stops_rollback_before_container_operations(self): + with TemporaryDirectory() as directory, patch.object( + upgrade, "ROOT", Path(directory) + ): + root = Path(directory) + (root / "frontend").mkdir() + (root / "frontend/index.html").write_bytes(b"new index") + record = { + "phase": "active", + "backup": str(root / "backup"), + "frontend": { + "index.html": { + "before": hashlib.sha256(b"old index").hexdigest(), + "after": hashlib.sha256(b"new index").hexdigest(), + } + }, + } + with patch.object(upgrade.hosted, "docker") as docker: + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback(record) + docker.assert_not_called() + + def test_changed_saved_container_is_detected_before_stopping_active_web(self): + record = { + "phase": "active", + "backup": "/unused", + "frontend": {}, + "old_container_id": "old", + "old_name": "saved-old", + "release": "a" * 40, + "candidate_id": "new", + "previous_state": {"image_id": "image"}, + } + current = { + "Id": "new", + "Config": { + "Labels": { + upgrade.REVISION_LABEL: "a" * 40, + upgrade.hosted.LABEL: upgrade.hosted.IDENTITY, + } + }, + "State": {"Running": True}, + } + with patch.object( + upgrade.hosted, + "docker", + return_value=SimpleNamespace(returncode=0, stdout=json.dumps([current])), + ) as docker, patch.object( + upgrade.hosted, "inspect", return_value={"Id": "foreign"} + ): + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback(record) + self.assertEqual(docker.call_count, 1) + + def test_new_public_asset_directories_are_readable_under_private_umask(self): + import os + import stat + + with TemporaryDirectory() as directory, patch.object( + upgrade, "ROOT", Path(directory) + ): + root = Path(directory) + source = root / "asset" + source.write_bytes(b"public asset") + target = root / "frontend/assets/new/icon.svg" + previous = os.umask(0o077) + try: + upgrade.copy_atomic(source, target) + finally: + os.umask(previous) + self.assertEqual(stat.S_IMODE(target.parent.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o644) + + def test_rollback_can_restore_old_web_after_new_container_fails_to_start(self): + record = { + "phase": "prepared", + "backup": "/unused", + "frontend": {}, + "old_container_id": "old", + "old_name": "old-backup", + "release": "a" * 40, + "candidate_id": "new", + "previous_state": {"image_id": "image"}, + } + labels = { + upgrade.REVISION_LABEL: "a" * 40, + upgrade.hosted.LABEL: upgrade.hosted.IDENTITY, + } + failed = {"Id": "new", "Config": {"Labels": labels}, "State": {"Running": False}} + old = { + "Id": "old", + "Image": "image", + "Config": {"Labels": labels}, + "State": {"Running": False}, + } + + def docker(*args, **kwargs): + return SimpleNamespace( + returncode=0, + stdout=json.dumps([failed]) + if args[:2] == ("container", "inspect") + else "", + ) + + with patch.object( + upgrade.hosted, "docker", side_effect=docker + ) as calls, patch.object( + upgrade.hosted, "inspect", return_value=old + ), patch.object( + upgrade.hosted, "state_write" + ) as state, patch.object( + upgrade, "save_record" + ), patch.object( + upgrade.hosted, "dev_health", return_value=200 + ): + self.assertEqual(upgrade.rollback(record)["phase"], "rolled_back") + self.assertFalse(any(call.args[0] == "stop" for call in calls.call_args_list)) + self.assertTrue( + any( + call.args == ("start", upgrade.hosted.WEB) + for call in calls.call_args_list + ) + ) + state.assert_called_once_with(record["previous_state"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pilot_overall_upgrade.py b/scripts/tests/test_pilot_overall_upgrade.py new file mode 100644 index 00000000..b2db9551 --- /dev/null +++ b/scripts/tests/test_pilot_overall_upgrade.py @@ -0,0 +1,703 @@ +"""Сохранность пилота при установке и при ошибках миграции/публикации рейтинга.""" + +from copy import deepcopy +import importlib +import json +from pathlib import Path +import shutil +import sys +from tempfile import TemporaryDirectory +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot" +with patch.object(sys, "path", [str(SOURCE), *sys.path]): + upgrade = importlib.import_module("upgrade_overall") + packager = importlib.import_module("package_overall") + unpacker = importlib.import_module("unpack_candidate") +VERIFY_RUNTIME = upgrade.verify_runtime + + +class OverallUpgradeTests(unittest.TestCase): + def setUp(self): + self.directory = Path(self.enterContext(TemporaryDirectory())) + self.root = self.directory / "pilot" + self.root.mkdir() + (self.root / "backup").mkdir() + (self.root / "frontend").mkdir() + (self.root / "data").mkdir() + (self.root / "logs").mkdir() + self.old = { + "index.html": b"old auth index", + "main-old.js": b"old code", + "assets/logo.svg": b"logo", + } + self.new = { + "index.html": b'overall', + "main-new.js": b"new code", + "assets/logo.svg": b"logo", + } + for name, data in self.old.items(): + target = self.root / "frontend" / name + target.parent.mkdir(exist_ok=True) + target.write_bytes(data) + self.base = deepcopy(upgrade.BASE) + self.base["frontend_files"] = { + name: self.hash(data) for name, data in self.old.items() + } + self.base["target_frontend_files"] = { + name: self.hash(data) for name, data in self.new.items() + } + self.base["runtime_hashes"] = {name: None for name in upgrade.PATCH_FILES} + self.base["target_hashes"] = { + name: self.hash(("new " + name).encode()) for name in upgrade.PATCH_FILES + } + self.mounts = [ + { + "Type": "bind", + "Source": str(self.root / "data"), + "Destination": "/procollab/pilot-data", + "RW": True, + }, + { + "Type": "bind", + "Source": str(self.root / "logs"), + "Destination": "/procollab/log", + "RW": True, + }, + ] + for name in self.base["overlays"]: + source = self.root / "releases" / "old" / name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("old " + name) + self.base["runtime_hashes"][name] = upgrade.hosted.digest(source) + self.mounts.append( + { + "Type": "bind", + "Source": str(source), + "Destination": "/procollab/" + name, + "RW": False, + } + ) + self.before = { + "Id": "1" * 64, + "Image": "sha256:" + "2" * 64, + "Config": { + "Hostname": "old-host", + "Image": "old-image-name", + "Env": ["TEST=unchanged", "OTHER=still"], + "Labels": { + upgrade.hosted.LABEL: upgrade.hosted.IDENTITY, + upgrade.files.REVISION_LABEL: self.base["base_backend_revision"], + }, + }, + "HostConfig": {"Mounts": ["old"], "Memory": 768 * 2**20}, + "Mounts": self.mounts, + "State": {"Running": True, "StartedAt": "original-start"}, + } + self.containers = {self.before["Id"]: self.before} + self.names = {upgrade.hosted.WEB: self.before["Id"]} + self.calls = [] + self.state = { + "identity": upgrade.hosted.IDENTITY, + "phase": "active", + "image_id": self.before["Image"], + "app_patch_revision": self.base["base_backend_revision"], + "weights_revision": self.base["base_backend_revision"], + "app_patch_files": {"old-overlay": "kept"}, + } + (self.root / ".env").write_text("TEST=unchanged\n") + (self.root / "access.txt").write_text("Пароль входа: fake-local-gate\n") + (self.root / "data" / "case-sentinel").write_text("existing scores / team / PDF") + self.route, self.maps = self.root / "routes.inc", self.root / "maps.conf" + self.route.write_text("unchanged routes") + self.maps.write_text("unchanged maps") + self.state.update( + new_routes_sha256=upgrade.hosted.digest(self.route), + new_maps_sha256=upgrade.hosted.digest(self.maps), + ) + (self.root / "state.json").write_text( + json.dumps(self.state, ensure_ascii=False, indent=2) + ) + (self.root / "invite-upgrade.json").write_text("old invite record") + (self.root / "weights-upgrade.json").write_text( + json.dumps( + { + "identity": "hosted-v2-weights-upgrade", + "phase": "active", + "release": self.base["base_backend_revision"], + "candidate_id": self.before["Id"], + } + ) + ) + auth_record = { + "identity": upgrade.auth.IDENTITY, + "release": self.base["base_frontend_revision"], + "phase": "active", + "backup": str(self.root / "backup" / ("auth-" + "0" * 12)), + "new_files": self.base["frontend_files"], + "frontend": { + "index.html": { + "before": "0" * 64, + "after": self.base["frontend_files"]["index.html"], + } + }, + "protected": { + "format": upgrade.auth.SNAPSHOT_FORMAT, + "containers": { + upgrade.hosted.WEB: upgrade.container_identity(self.before) + }, + "files": { + str(self.root / name): upgrade.hosted.digest(self.root / name) + for name in ( + ".env", + "state.json", + "access.txt", + "weights-upgrade.json", + ) + }, + }, + } + (self.root / "frontend-upgrade.json").write_text(json.dumps(auth_record)) + self.enterContext(patch.object(upgrade, "BASE", self.base)) + self.enterContext(patch.object(upgrade, "ROOT", self.root)) + self.enterContext( + patch.object(upgrade, "RECORD", self.root / "overall-upgrade.json") + ) + self.enterContext(patch.object(upgrade.hosted, "NEW", self.root)) + self.enterContext(patch.object(upgrade.hosted, "ROUTES", self.route)) + self.enterContext(patch.object(upgrade.hosted, "MAPS", self.maps)) + self.enterContext(patch.object(upgrade.files, "ROOT", self.root)) + self.enterContext(patch.object(upgrade.auth, "ROOT", self.root)) + self.enterContext( + patch.object(upgrade.auth, "RECORD", self.root / "frontend-upgrade.json") + ) + self.enterContext(patch.object(upgrade.hosted, "scope")) + self.dev = self.enterContext( + patch.object(upgrade.hosted, "dev_health", return_value=200) + ) + self.api_smoke = self.enterContext(patch.object(upgrade.auth, "api_smoke")) + self.live = self.enterContext(patch.object(upgrade.auth, "verify_live")) + self.enterContext( + patch.object( + upgrade.hosted, + "candidate", + side_effect=lambda: self.containers.get( + self.names.get(upgrade.hosted.WEB) + ), + ) + ) + self.enterContext( + patch.object(upgrade.hosted, "inspect", side_effect=self.inspect) + ) + self.docker = self.enterContext( + patch.object(upgrade.hosted, "docker", side_effect=self.run_docker) + ) + self.enterContext( + patch.object( + upgrade.hosted, + "docker_json", + return_value=[ + { + "Labels": {upgrade.hosted.LABEL: upgrade.hosted.IDENTITY}, + "Containers": { + "web": {"Name": upgrade.hosted.WEB}, + "pg": {"Name": upgrade.hosted.PG}, + }, + } + ], + ) + ) + self.outside = {"protected": "old dev / PostgreSQL / nginx"} + self.enterContext( + patch.object(upgrade, "outside_snapshot", return_value=self.outside) + ) + self.enterContext( + patch.object( + upgrade, + "case_snapshot", + return_value={ + "sha256": "case-sha", + "scoring_mode": "weighted_percent", + "team_count": 1, + }, + ) + ) + self.verify_runtime = self.enterContext(patch.object(upgrade, "verify_runtime")) + self.migration = self.enterContext( + patch.object( + upgrade, "migrate", return_value={"plan": [list(upgrade.MIGRATION)]} + ) + ) + self.smoke = self.enterContext(patch.object(upgrade, "smoke")) + self.enterContext(patch.object(upgrade, "wait_for_web")) + self.backup = self.enterContext( + patch.object( + upgrade.files, + "backup_database", + side_effect=lambda path: (path / "database.dump").write_bytes( + b"verified synthetic dump" + ), + ) + ) + self.enterContext( + patch.object( + upgrade.shutil, + "disk_usage", + return_value=SimpleNamespace(free=10 * 2**30), + ) + ) + self.bundle = self.directory / "bundle" + self.bundle.mkdir() + for name in upgrade.HELPERS: + if name == "overall_base.json": + (self.bundle / name).write_text(json.dumps(self.base)) + else: + shutil.copyfile(SOURCE / name, self.bundle / name) + for name in upgrade.PATCH_FILES: + target = self.bundle / "api" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("new " + name) + for name, data in self.new.items(): + if data != self.old.get(name): + target = self.bundle / "frontend" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + self.manifest = { + "format": upgrade.IDENTITY, + "backend_revision": self.base["backend_revision"], + "frontend_revision": self.base["frontend_revision"], + "base_backend_revision": self.base["base_backend_revision"], + "base_frontend_revision": self.base["base_frontend_revision"], + "tooling_revision": "3" * 40, + "new_files": {name: self.hash(data) for name, data in self.new.items()}, + "files": { + str(path.relative_to(self.bundle)): upgrade.hosted.digest(path) + for path in self.bundle.rglob("*") + if path.is_file() + }, + } + self.write_manifest() + + @staticmethod + def hash(data): + import hashlib + + return hashlib.sha256(data).hexdigest() + + def write_manifest(self): + (self.bundle / "overall-manifest.json").write_text(json.dumps(self.manifest)) + + def inspect(self, name): + return self.containers[self.names.get(name, name)] + + def run_docker(self, *args, **kwargs): + self.calls.append(args) + result = SimpleNamespace(returncode=0, stdout="", stderr="") + if args[0] == "container" and args[1] == "inspect": + return SimpleNamespace( + returncode=0, stdout=json.dumps([self.inspect(args[2])]) + ) + if args[0] == "inspect": + return SimpleNamespace(returncode=1, stdout="") + if args[0] == "rename": + self.names[args[2]] = self.names.pop(args[1]) + elif args[0] in ("stop", "start"): + container = self.inspect(args[-1]) + container["State"]["Running"] = args[0] == "start" + if args[0] == "start": + container["State"]["StartedAt"] = "restored-start" + elif args[0] == "run": + self.assertIn("-d", args) + container = deepcopy(self.before) + container["Id"] = "4" * 64 + container["Config"]["Hostname"] = "new-host" + container["Config"]["Image"] = self.before["Image"] + for index, value in enumerate(args[:-1]): + if value == "--label": + name, value = args[index + 1].split("=", 1) + container["Config"]["Labels"][name] = value + container["HostConfig"]["Mounts"] = ["new"] + container["Mounts"] = [] + for index, value in enumerate(args[:-1]): + if value == "--mount": + parts = args[index + 1].split(",") + values = dict(part.split("=", 1) for part in parts if "=" in part) + container["Mounts"].append( + { + "Type": "bind", + "Source": values["src"], + "Destination": values["dst"], + "RW": "readonly" not in parts, + } + ) + container["State"] = {"Running": True, "StartedAt": "new-start"} + self.containers[container["Id"]] = container + self.names[upgrade.hosted.WEB] = container["Id"] + result.stdout = container["Id"] + "\n" + else: + self.fail("Unexpected Docker operation: " + repr(args)) + return result + + def assert_data_preserved(self): + self.assertEqual( + (self.root / "data/case-sentinel").read_text(), "existing scores / team / PDF" + ) + self.assertEqual( + (self.root / "invite-upgrade.json").read_text(), "old invite record" + ) + self.assertEqual((self.root / ".env").read_text(), "TEST=unchanged\n") + self.assertEqual(self.route.read_text(), "unchanged routes") + self.assertEqual(self.maps.read_text(), "unchanged maps") + + def test_check_is_read_only_for_code_frontend_state_and_migration(self): + before = { + str(path.relative_to(self.root)): path.read_bytes() + for path in self.root.rglob("*") + if path.is_file() + } + upgrade.check(self.bundle) + after = { + str(path.relative_to(self.root)): path.read_bytes() + for path in self.root.rglob("*") + if path.is_file() + } + self.assertEqual(before, after) + self.assertFalse( + any(call[0] in ("run", "stop", "rename", "start") for call in self.calls) + ) + self.assertFalse(self.migration.call_args.kwargs["apply"]) + + def test_probe_reports_empty_plan_after_retained_schema(self): + self.migration.return_value = {"plan": []} + self.assertEqual(upgrade.check(self.bundle)[3], []) + + def test_runtime_fingerprint_rejects_changed_source_and_accepts_exact_target(self): + with patch.object( + upgrade, "runtime_fingerprint", return_value=self.base["runtime_hashes"] + ): + VERIFY_RUNTIME(self.before, updated=False) + changed = {**self.base["runtime_hashes"], "partner_programs/models.py": "f" * 64} + with patch.object( + upgrade, "runtime_fingerprint", return_value=changed + ), self.assertRaises(upgrade.hosted.Refused): + VERIFY_RUNTIME(self.before, updated=False) + updated = {**self.base["runtime_hashes"], **self.base["target_hashes"]} + with patch.object(upgrade, "runtime_fingerprint", return_value=updated): + VERIFY_RUNTIME(self.before, updated=True) + + def test_bundle_rejects_another_frontend_even_with_valid_file_hashes(self): + self.manifest["new_files"]["index.html"] = "f" * 64 + self.manifest["files"]["frontend/index.html"] = "f" * 64 + self.write_manifest() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_bundle(self.bundle) + + def test_failure_journal_error_does_not_prevent_rollback(self): + write = upgrade.hosted.private_write + + def fail_log(path, value): + if path.name == "failure.txt": + raise OSError("injected log write failure") + return write(path, value) + + with patch.object(upgrade.hosted, "private_write", side_effect=fail_log): + self.assert_smoke_failure_restores(RuntimeError("injected smoke failure")) + + def test_apply_reuses_image_env_data_and_publishes_index_last(self): + writes = [] + copy = upgrade.files.copy_atomic + + def tracked(source, target, mode=0o644): + if target.is_relative_to(self.root / "frontend"): + writes.append(str(target.relative_to(self.root / "frontend"))) + return copy(source, target, mode) + + with patch.object(upgrade.files, "copy_atomic", side_effect=tracked): + result = upgrade.apply(self.bundle) + self.assertEqual(result["phase"], "active") + self.assertEqual(writes[-1], "index.html") + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.new["index.html"] + ) + self.assertEqual( + (self.root / "frontend/main-old.js").read_bytes(), self.old["main-old.js"] + ) + self.assertEqual(self.inspect(upgrade.hosted.WEB)["Image"], self.before["Image"]) + self.assertEqual( + self.inspect(upgrade.hosted.WEB)["Config"]["Env"], + ["TEST=unchanged", "OTHER=still"], + ) + self.assert_data_preserved() + self.assertEqual( + [call.kwargs["apply"] for call in self.migration.call_args_list], + [False, False, True], + ) + self.assertTrue((Path(result["backup"]) / "database.dump").is_file()) + self.assertEqual(self.inspect(self.before["Id"])["State"]["Running"], False) + + def test_failed_migration_rolls_back_without_replacing_web_or_restoring_database( + self, + ): + self.migration.side_effect = [ + {"plan": [list(upgrade.MIGRATION)]}, + None, + RuntimeError("injected DDL failure"), + ] + with self.assertRaises(upgrade.hosted.Refused): + upgrade.apply(self.bundle) + record = upgrade.load_record() + self.assertEqual(record["phase"], "rolled_back") + self.assertEqual(self.inspect(upgrade.hosted.WEB)["Id"], self.before["Id"]) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.old["index.html"] + ) + self.assert_data_preserved() + self.assertFalse(any(call[0] in ("rename", "stop", "run") for call in self.calls)) + + def assert_smoke_failure_restores(self, error): + self.smoke.side_effect = error + with self.assertRaises(upgrade.hosted.Refused): + upgrade.apply(self.bundle) + self.assertEqual(self.inspect(upgrade.hosted.WEB)["Id"], self.before["Id"]) + self.assertTrue(self.inspect(upgrade.hosted.WEB)["State"]["Running"]) + self.assertEqual(upgrade.load_record()["phase"], "rolled_back") + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.old["index.html"] + ) + self.assert_data_preserved() + + def test_smoke_failure_restores_exact_old_web_and_keeps_schema(self): + self.assert_smoke_failure_restores(RuntimeError("smoke failure")) + + def test_interrupt_restores_exact_old_web_and_keeps_schema(self): + self.assert_smoke_failure_restores(KeyboardInterrupt()) + + def test_publication_failure_restores_old_ui_without_deleting_new_chunks(self): + def live(digest): + if digest == self.manifest["new_files"]["index.html"]: + raise RuntimeError("injected HTTPS failure") + + self.live.side_effect = live + with self.assertRaises(upgrade.hosted.Refused): + upgrade.apply(self.bundle) + self.assertEqual( + (self.root / "frontend/index.html").read_bytes(), self.old["index.html"] + ) + self.assertTrue((self.root / "frontend/main-new.js").is_file()) + self.assertEqual(self.inspect(upgrade.hosted.WEB)["Id"], self.before["Id"]) + self.assert_data_preserved() + + def test_repeat_apply_does_not_redeploy_and_rollback_is_compatible_with_weights( + self, + ): + upgrade.apply(self.bundle) + self.calls.clear() + self.assertEqual(upgrade.apply(self.bundle)["phase"], "active") + self.assertEqual(self.calls, []) + record = upgrade.load_record() + result = upgrade.rollback(record) + self.assertTrue(result["schema_retained"]) + self.assertFalse(result["database_restored"]) + self.assertEqual(upgrade.status()["phase"], "rolled_back") + self.assertEqual(upgrade.rollback(upgrade.load_record())["phase"], "rolled_back") + self.assert_data_preserved() + + def test_active_status_checks_overall_api_health(self): + upgrade.apply(self.bundle) + self.smoke.side_effect = upgrade.hosted.Refused("injected unhealthy overall API") + self.calls.clear() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.status() + self.assertEqual(self.calls, []) + + def test_frontend_or_backup_drift_stops_rollback_before_container_mutation(self): + upgrade.apply(self.bundle) + record = upgrade.load_record() + (Path(record["backup"]) / "frontend/index.html").write_bytes(b"unexpected backup") + self.calls.clear() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback(record) + self.assertFalse( + any(call[0] in ("stop", "rename", "start") for call in self.calls) + ) + + def test_changed_old_overlay_or_wrong_frontend_prevents_check(self): + source = Path(self.mounts[2]["Source"]) + source.write_text("unknown code") + with self.assertRaises(upgrade.hosted.Refused): + upgrade.check(self.bundle) + self.migration.assert_not_called() + + def test_bundle_rejects_extra_files_bad_hash_and_wrong_target(self): + (self.bundle / "extra.txt").write_text("unexpected") + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_bundle(self.bundle) + (self.bundle / "extra.txt").unlink() + self.manifest["backend_revision"] = "a" * 40 + self.write_manifest() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_bundle(self.bundle) + self.manifest["backend_revision"] = self.base["backend_revision"] + self.manifest["files"]["frontend/index.html"] = "0" * 64 + self.write_manifest() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_bundle(self.bundle) + + def test_overlay_replaces_existing_destinations_once_and_preserves_invites(self): + args = upgrade.overlay_args(self.before, self.root / "new") + destinations = [ + value.split("dst=", 1)[1].split(",", 1)[0] for value in args[1::2] + ] + self.assertEqual(len(destinations), len(set(destinations))) + self.assertIn("/procollab/users/white_label_candidates.py", destinations) + self.assertEqual(destinations.count("/procollab/partner_programs/models.py"), 1) + self.assertNotIn("/procollab/pilot-data", destinations) + self.assertTrue(all(value.endswith(",readonly") for value in args[1::2])) + + def test_unique_env_order_is_semantically_equal_but_values_are_preserved(self): + after = deepcopy(self.before) + after["Config"]["Env"].reverse() + upgrade.preserved_configuration(self.before, after) + after["Config"]["Env"][0] = "OTHER=changed" + with self.assertRaises(upgrade.hosted.Refused): + upgrade.preserved_configuration(self.before, after) + + def test_duplicate_env_order_and_container_fingerprints_remain_strict(self): + before = deepcopy(self.before) + before["Config"]["Env"] = ["TEST=one", "TEST=two"] + after = deepcopy(before) + after["Config"]["Env"].reverse() + with self.assertRaises(upgrade.hosted.Refused): + upgrade.preserved_configuration(before, after) + self.assertNotEqual( + upgrade.auth.configuration_fingerprint(before), + upgrade.auth.configuration_fingerprint(after), + ) + after = deepcopy(self.before) + after["Config"]["Env"].reverse() + self.assertNotEqual( + upgrade.auth.configuration_fingerprint(self.before), + upgrade.auth.configuration_fingerprint(after), + ) + + def test_limits_and_data_destination_changes_are_detected(self): + after = deepcopy(self.before) + after["HostConfig"]["Memory"] //= 2 + with self.assertRaises(upgrade.hosted.Refused): + upgrade.preserved_configuration(self.before, after) + after = deepcopy(self.before) + after["Mounts"][0]["Source"] = "/unknown" + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_replacement_mounts(self.before, after, self.root / "new") + + def test_package_roundtrip_uses_production_unpacker_and_only_expected_files(self): + frontend = self.directory / "build" + frontend.mkdir() + for name, data in self.new.items(): + target = frontend / name + target.parent.mkdir(exist_ok=True) + target.write_bytes(data) + with patch.object(packager, "tooling_revision", return_value="3" * 40): + result = packager.package( + self.bundle / "api", frontend, self.directory / "release" + ) + destination = self.directory / "unpacked" + unpacker.unpack(Path(result["archive"]), destination) + recovered = destination / "procollab-pilot" + upgrade.verify_bundle(recovered) + self.assertEqual(result["api_files"], 11) + self.assertEqual(result["changed_frontend_files"], 2) + + +class OverallMigrationGuardTests(unittest.TestCase): + def execute(self, plan, *, apply=True, database=None): + django = ModuleType("django") + django.setup = MagicMock() + db = ModuleType("django.db") + db.connection = MagicMock() + db.connection.alias = "default" + db.connection.settings_dict = database or { + "NAME": upgrade.hosted.DB_NAME, + "USER": upgrade.hosted.DB_ROLE, + "HOST": "db", + } + executor = MagicMock() + executor.migration_plan.return_value = plan + module = ModuleType("django.db.migrations.executor") + module.MigrationExecutor = MagicMock(return_value=executor) + apps = ModuleType("django.apps") + apps.apps = MagicMock() + permissions = ModuleType("django.contrib.auth.management") + permissions.create_permissions = MagicMock() + with patch.dict( + sys.modules, + { + "django": django, + "django.db": db, + "django.db.migrations.executor": module, + "django.apps": apps, + "django.contrib.auth.management": permissions, + }, + ): + exec(upgrade.migration_script(apply), {}) + return executor, db.connection, permissions.create_permissions + + def plan(self, *, name="ProgramQuizOutcome", operation="CreateModel"): + op = type(operation, (), {"name": name})() + return [ + ( + SimpleNamespace( + app_label=upgrade.MIGRATION[0], + name=upgrade.MIGRATION[1], + operations=[op], + ), + False, + ) + ] + + def test_only_new_table_migration_applies_and_creates_standard_permissions(self): + executor, _, metadata = self.execute(self.plan()) + executor.migrate.assert_called_once() + metadata.assert_called_once() + + def test_readonly_probe_and_empty_plan_are_safe(self): + executor, connection, metadata = self.execute(self.plan(), apply=False) + executor.migrate.assert_not_called() + metadata.assert_not_called() + self.assertIn( + "SET default_transaction_read_only = on", + [ + call.args[0] + for call in connection.cursor.return_value.__enter__.return_value.execute.call_args_list + ], + ) + self.execute([])[0].migrate.assert_called_once() + + def test_other_reverse_or_destructive_operations_rejected(self): + unrelated = self.plan() + unrelated[0][0].app_label = "users" + for plan in ( + unrelated, + [(self.plan()[0][0], True)], + self.plan(operation="RunPython"), + self.plan(name="OtherModel"), + ): + with self.subTest(plan=plan), self.assertRaises(RuntimeError): + self.execute(plan) + + def test_other_database_or_role_cannot_be_migrated(self): + for change in ( + {"NAME": "production"}, + {"HOST": "5.188.81.217"}, + {"USER": "root"}, + ): + database = { + "NAME": upgrade.hosted.DB_NAME, + "USER": upgrade.hosted.DB_ROLE, + "HOST": "db", + **change, + } + with self.subTest(change=change), self.assertRaises(RuntimeError): + self.execute(self.plan(), database=database) diff --git a/scripts/tests/test_pilot_package.py b/scripts/tests/test_pilot_package.py new file mode 100644 index 00000000..1275666a --- /dev/null +++ b/scripts/tests/test_pilot_package.py @@ -0,0 +1,75 @@ +"""Пакет содержит читаемые nginx assets и соответствует зафиксированной версии API.""" + +import importlib.util +import json +from pathlib import Path +import stat +import subprocess +from tempfile import TemporaryDirectory +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "deploy" / "pilot" +spec = importlib.util.spec_from_file_location("pilot_package", SOURCE / "package.py") +package = importlib.util.module_from_spec(spec) +spec.loader.exec_module(package) + + +class PilotPackageTests(unittest.TestCase): + def prepare(self, root): + api, frontend = root / "api", root / "frontend" + (api / "procollab").mkdir(parents=True) + (api / "procollab/settings_pilot.py").write_text("# synthetic profile\n") + frontend.mkdir(mode=0o700) + (frontend / "index.html").write_text("") + (frontend / "index.html").chmod(0o600) + for args in ( + ["init", "-q"], + ["add", "."], + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "user.name=Pilot Test", + "-c", + "user.email=pilot@example.test", + "commit", + "-qm", + "synthetic fixture", + ], + ): + subprocess.run(["git", *args], cwd=api, check=True, capture_output=True) + (api / ".env").write_text("PRIVATE=must-not-be-packaged\n") + return api, frontend + + def test_public_assets_readable_and_untracked_secrets_excluded(self): + with TemporaryDirectory() as temp: + root = Path(temp) + api, frontend = self.prepare(root) + target = root / "bundle" + archive = package.package(api, frontend, target) + self.assertEqual(stat.S_IMODE(archive.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE((target / "frontend").stat().st_mode), 0o755) + self.assertEqual( + stat.S_IMODE((target / "frontend/index.html").stat().st_mode), 0o644 + ) + self.assertFalse((target / "api/.env").exists()) + manifest = json.loads((target / "manifest.json").read_text()) + self.assertIn( + "procollab/settings_pilot.py", + [name.removeprefix("api/") for name in manifest["files"]], + ) + self.assertEqual(len(manifest["api_revision"]), 40) + self.assertIn("upgrade_weights.py", manifest["files"]) + self.assertIn("weights_base.json", manifest["files"]) + + def test_regular_frontend_and_dirty_api_rejected(self): + with TemporaryDirectory() as temp: + root = Path(temp) + api, frontend = self.prepare(root) + (frontend / "index.html").write_text("ordinary build") + with self.assertRaises(ValueError): + package.package(api, frontend, root / "regular") + (frontend / "index.html").write_text("white-label-pilot-v1") + (api / "procollab/settings_pilot.py").write_text("uncommitted changes") + with self.assertRaises(ValueError): + package.package(api, frontend, root / "dirty") diff --git a/scripts/tests/test_pilot_preflight.py b/scripts/tests/test_pilot_preflight.py new file mode 100644 index 00000000..4b371183 --- /dev/null +++ b/scripts/tests/test_pilot_preflight.py @@ -0,0 +1,74 @@ +"""Не запускаем новый стек при недостатке ресурсов или чужом занятом порте.""" + +import importlib.util +import io +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy" / "pilot" +spec = importlib.util.spec_from_file_location("pilot_preflight", SOURCE / "preflight.py") +preflight = importlib.util.module_from_spec(spec) +spec.loader.exec_module(preflight) + + +class PilotPreflightTests(unittest.TestCase): + def check(self, disk_gib, memory_mib, busy=False, own_gateway=False): + probe = MagicMock() + if busy: + probe.__enter__.return_value.bind.side_effect = OSError("occupied") + + def command(argv): + if "--filter" in argv: + return 0, "127.0.0.1:18081->80/tcp" if own_gateway else "" + return 0, "" + + output = io.StringIO() + with ( + patch.object(preflight.socket, "gethostbyname", return_value="5.188.81.217"), + patch.object(preflight.socket, "socket", return_value=probe), + patch.object(preflight.shutil, "which", return_value="/bin/tool"), + patch.object( + preflight.shutil, + "disk_usage", + return_value=SimpleNamespace(free=disk_gib * 2**30), + ), + patch.object( + preflight.Path, + "read_text", + return_value=f"MemAvailable: {memory_mib * 1024} kB\n", + ), + patch.object(preflight, "run", side_effect=command), + patch.object(preflight.sys, "stdout", output), + ): + result = preflight.main(check=True) + return result, output.getvalue() + + def test_current_dev_resources_block_second_stack(self): + result, output = self.check(disk_gib=6.2, memory_mib=2150) + self.assertEqual(result, 1) + self.assertIn("8 GiB", output) + self.assertIn("2560 MiB", output) + + def test_foreign_port_blocks_but_own_gateway_is_allowed(self): + self.assertEqual(self.check(12, 4096, busy=True)[0], 1) + self.assertEqual(self.check(12, 4096, busy=True, own_gateway=True)[0], 0) + + def test_docker_inspects_local_socket_without_inherited_target(self): + with ( + patch.dict( + preflight.os.environ, + {"DOCKER_HOST": "tcp://foreign:2375", "DOCKER_CONTEXT": "foreign"}, + ), + patch.object( + preflight.subprocess, + "run", + return_value=SimpleNamespace(returncode=0, stdout=""), + ) as run, + ): + preflight.run(["docker", "ps"]) + args, kwargs = run.call_args + self.assertEqual(args[0], ["docker", "--host=unix:///var/run/docker.sock", "ps"]) + self.assertNotIn("DOCKER_HOST", kwargs["env"]) + self.assertNotIn("DOCKER_CONTEXT", kwargs["env"]) diff --git a/scripts/tests/test_pilot_unpack.py b/scripts/tests/test_pilot_unpack.py new file mode 100644 index 00000000..9ad3dd88 --- /dev/null +++ b/scripts/tests/test_pilot_unpack.py @@ -0,0 +1,56 @@ +"""ZIP/TAR загрузки не может перезаписать существующие или внешние файлы.""" + +import importlib.util +import io +from pathlib import Path +import tarfile +from tempfile import TemporaryDirectory +import unittest +import zipfile + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot/unpack_candidate.py" +spec = importlib.util.spec_from_file_location("pilot_unpack", SOURCE) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +def fixture(path, name, link=False): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + entry = tarfile.TarInfo(name) + entry.mode = 0o644 + if link: + entry.type = tarfile.SYMTYPE + entry.linkname = "/etc/passwd" + archive.addfile(entry) + else: + payload = b"synthetic public asset" + entry.size = len(payload) + archive.addfile(entry, io.BytesIO(payload)) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("procollab-pilot.tar.gz", buffer.getvalue()) + + +class UnpackPilotTests(unittest.TestCase): + def test_new_source_directory_and_repeat_guard(self): + with TemporaryDirectory() as temp: + root = Path(temp) + fixture(root / "bundle.zip", "procollab-pilot/frontend/index.html") + output = module.unpack(root / "bundle.zip", root / "unpacked") + self.assertEqual( + (output / "frontend/index.html").read_bytes(), b"synthetic public asset" + ) + with self.assertRaises(FileExistsError): + module.unpack(root / "bundle.zip", root / "unpacked") + + def test_traversal_and_symlink_rejected(self): + for name, link in [ + ("procollab-pilot/../../outside.txt", False), + ("procollab-pilot/key", True), + ]: + with self.subTest(name=name), TemporaryDirectory() as temp: + root = Path(temp) + fixture(root / "bundle.zip", name, link) + with self.assertRaises(ValueError): + module.unpack(root / "bundle.zip", root / "new") + self.assertFalse((root / "outside.txt").exists()) diff --git a/scripts/tests/test_pilot_weights_upgrade.py b/scripts/tests/test_pilot_weights_upgrade.py new file mode 100644 index 00000000..f93eabcd --- /dev/null +++ b/scripts/tests/test_pilot_weights_upgrade.py @@ -0,0 +1,203 @@ +"""Границы source overlay, миграций и отката процентных весов пилота.""" + +import importlib.util +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +SOURCE = Path(__file__).resolve().parents[2] / "deploy/pilot" +with patch.object(sys, "path", [str(SOURCE), *sys.path]): + spec = importlib.util.spec_from_file_location( + "pilot_weights_upgrade", SOURCE / "upgrade_weights.py" + ) + upgrade = importlib.util.module_from_spec(spec) + spec.loader.exec_module(upgrade) + + +class PilotWeightsUpgradeTests(unittest.TestCase): + def manifest(self): + result = {"api/" + p: h for p, h in upgrade.BASE["protected"].items()} + result.update({"api/" + p: "a" * 64 for p in upgrade.PATCH_FILES}) + return {"files": result} + + def test_only_known_current_code_and_unchanged_invites_profile_dependencies(self): + hashes = {**upgrade.BASE["patches"], **upgrade.BASE["protected"]} + result = upgrade.validate_backend(hashes, self.manifest()) + self.assertEqual(set(result), set(upgrade.PATCH_FILES)) + for name in ( + "project_rates/models.py", + "project_rates/scoring.py", + "users/views.py", + ): + with self.subTest(name=name), self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend({**hashes, name: "b" * 64}, self.manifest()) + for name in upgrade.BASE["protected"]: + manifest = self.manifest() + manifest["files"]["api/" + name] = "b" * 64 + with self.subTest(protected=name), self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend(hashes, manifest) + + def test_missing_patch_rejected(self): + manifest = self.manifest() + manifest["files"].pop("api/project_rates/scoring.py") + with self.assertRaises(upgrade.hosted.Refused): + upgrade.validate_backend( + {**upgrade.BASE["patches"], **upgrade.BASE["protected"]}, manifest + ) + + def test_overlay_retains_invite_files_readonly_and_does_not_rebind_data(self): + current = { + "Mounts": [ + { + "Source": str(upgrade.ROOT / "data"), + "Destination": "/procollab/pilot-data", + }, + {"Source": str(upgrade.ROOT / "logs"), "Destination": "/procollab/log"}, + *[ + {"Source": "/known/" + name, "Destination": "/procollab/" + name} + for name in upgrade.files.PATCH_FILES + ], + ] + } + args = upgrade.overlay_args(current, Path("/known/weights")) + self.assertTrue(all(value.endswith(",readonly") for value in args[1::2])) + self.assertTrue( + any( + "dst=/procollab/users/white_label_candidates.py," in value + for value in args + ) + ) + self.assertTrue( + any( + "dst=/procollab/templates/partner_programs/admin/evaluation_criteria.html," + in value + for value in args + ) + ) + self.assertFalse( + any("pilot-data" in value or "dst=/procollab/log," in value for value in args) + ) + + def run_migration(self, plan, *, apply=True): + connection = MagicMock() + connection.settings_dict = { + "NAME": upgrade.hosted.DB_NAME, + "USER": upgrade.hosted.DB_ROLE, + "HOST": "db", + } + executor = MagicMock() + executor.migration_plan.return_value = plan + factory = MagicMock(return_value=executor) + django = ModuleType("django") + django.setup = MagicMock() + db = ModuleType("django.db") + db.connection = connection + module = ModuleType("django.db.migrations.executor") + module.MigrationExecutor = factory + with patch.dict( + sys.modules, + {"django": django, "django.db": db, "django.db.migrations.executor": module}, + ): + exec(upgrade.migration_script(apply=apply), {}) + return executor + + def migrations(self): + operation = type("AddField", (), {})() + return [ + (SimpleNamespace(app_label=app, name=name, operations=[operation]), False) + for app, name in sorted(upgrade.MIGRATIONS) + ] + + def test_only_two_additive_migrations_can_be_applied(self): + executor = self.run_migration(self.migrations()) + executor.migrate.assert_called_once() + + def test_readonly_plan_does_not_execute_migrations_and_retry_accepts_empty_plan(self): + self.run_migration(self.migrations(), apply=False).migrate.assert_not_called() + self.run_migration([]).migrate.assert_called_once() + + def test_unrelated_reverse_and_destructive_migrations_rejected_before_write(self): + unexpected = SimpleNamespace(app_label="users", name="unknown", operations=[]) + destructive = self.migrations() + destructive[0][0].operations = [type("RemoveField", (), {})()] + for plan in [ + self.migrations() + [(unexpected, False)], + [(self.migrations()[0][0], True)], + destructive, + ]: + with self.subTest(plan=plan), self.assertRaises(RuntimeError): + self.run_migration(plan) + + def test_rollback_does_not_drop_schema_restore_database_or_overwrite_invite_record( + self, + ): + record = {"phase": "active"} + with patch.object( + upgrade, "weighted_program_count", return_value=0 + ), patch.object( + upgrade.files, "rollback", return_value={"phase": "rolled_back"} + ) as restore: + result = upgrade.rollback(record) + restore.assert_called_once_with(record, record_writer=upgrade.save_record) + self.assertTrue(result["schema_retained"]) + self.assertFalse(result["nginx_changed"]) + + def test_rollback_refuses_to_reinterpret_active_percentage_scores_with_old_api(self): + with patch.object( + upgrade, "weighted_program_count", return_value=1 + ), patch.object(upgrade.files, "rollback") as restore: + with self.assertRaises(upgrade.hosted.Refused): + upgrade.rollback({"phase": "active"}) + restore.assert_not_called() + + def test_timeout_stops_only_owned_migrator_before_rollback(self): + import json + import subprocess + + migrator = { + "Id": "owned-step", + "Image": "image", + "Config": { + "Labels": { + upgrade.hosted.LABEL: upgrade.hosted.IDENTITY, + "io.procollab.pilot.weights.step": "weights-release", + } + }, + } + response = SimpleNamespace(returncode=0, stdout=json.dumps([migrator])) + with patch.object( + upgrade.hosted, + "docker", + side_effect=[ + subprocess.TimeoutExpired("docker", 180), + response, + SimpleNamespace(returncode=0), + SimpleNamespace(returncode=0), + ], + ) as docker: + with self.assertRaises(subprocess.TimeoutExpired): + upgrade.migrate( + {"Image": "image", "Mounts": []}, + Path("/release/weights-release/backend"), + apply=True, + log=None, + ) + self.assertEqual( + docker.call_args_list[-2].args, ("stop", "--time", "5", "owned-step") + ) + self.assertEqual(docker.call_args_list[-1].args, ("rm", "-f", "owned-step")) + + def test_changed_dev_or_nginx_is_reported_without_mutation(self): + record = {"outside": {"original": True}} + with patch.object( + upgrade, "dev_snapshot", return_value={"other": True} + ), patch.object(upgrade.hosted, "docker") as docker: + with self.assertRaises(upgrade.hosted.Refused): + upgrade.verify_outside(record) + docker.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 00000000..3018b566 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,8 @@ +{% extends "admin/base_site.html" %} +{% block title %}{% if instance_brand %}{{ title }} | {{ instance_brand.brand_name }}{% else %}{{ block.super }}{% endif %}{% endblock %} +{% block branding %} +{% if instance_brand %} + +{% else %}{{ block.super }}{% endif %} +{% endblock %} +{% block extrahead %}{{ block.super }}{% if instance_brand.favicon_url %}{% endif %}{% endblock %} diff --git a/templates/email/expert_invitation.html b/templates/email/expert_invitation.html index 64d75d60..b186ed74 100644 --- a/templates/email/expert_invitation.html +++ b/templates/email/expert_invitation.html @@ -3,7 +3,7 @@
-PROCOLLAB +{% if logo_url %}{{ brand_name|default:'PROCOLLAB' }}{% else %}{{ brand_name }}{% endif %}

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

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

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

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

diff --git a/templates/email/expert_invitation.txt b/templates/email/expert_invitation.txt index 7f2a0c01..52ad88ad 100644 --- a/templates/email/expert_invitation.txt +++ b/templates/email/expert_invitation.txt @@ -1,6 +1,6 @@ Здравствуйте! -Вас пригласили стать экспертом программы «{{ program_name }}» на PROCOLLAB. +Вас пригласили стать экспертом программы «{{ program_name }}» на {{ brand_name|default:"PROCOLLAB" }}. Приглашение для {{ email }}. После принятия организатор сможет назначить вам проекты на оценку. Принять приглашение: {{ invitation_url }} diff --git a/templates/email/organizer_invitation.html b/templates/email/organizer_invitation.html index 2744e769..9bfe3779 100644 --- a/templates/email/organizer_invitation.html +++ b/templates/email/organizer_invitation.html @@ -3,9 +3,9 @@
-PROCOLLAB +{% if logo_url %}{{ brand_name|default:'PROCOLLAB' }}{% else %}{{ brand_name }}{% endif %}

Вас пригласили стать организатором

-

Программа «{{ program_name }}» на PROCOLLAB.

+

Программа «{{ program_name }}» на {{ brand_name|default:"PROCOLLAB" }}.

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

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

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

diff --git a/templates/email/organizer_invitation.txt b/templates/email/organizer_invitation.txt index 9473d9de..47c69f23 100644 --- a/templates/email/organizer_invitation.txt +++ b/templates/email/organizer_invitation.txt @@ -1,6 +1,6 @@ Здравствуйте! -Вас пригласили стать организатором программы «{{ program_name }}» на PROCOLLAB. +Вас пригласили стать организатором программы «{{ program_name }}» на {{ brand_name|default:"PROCOLLAB" }}. Приглашение для {{ email }}. После принятия вы сможете управлять этой программой и приглашать коллег. Принять приглашение: {{ invitation_url }} diff --git a/templates/email/program_registration.html b/templates/email/program_registration.html index 54c1e676..7058e4fa 100644 --- a/templates/email/program_registration.html +++ b/templates/email/program_registration.html @@ -1,7 +1,7 @@
-

Подтвердите регистрацию

Здравствуйте, {{ first_name }}!

+

Подтвердите регистрацию

Здравствуйте{% if first_name %}, {{ first_name }}{% endif %}!

Вы заполнили анкету программы «{{ program_name }}». Ответы сохранены.

Подтвердить почту и перейти к программе

Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.

-

Команда PROCOLLAB

+

Команда {{ brand_name|default:"PROCOLLAB" }}

diff --git a/templates/email/program_registration.txt b/templates/email/program_registration.txt index 6c1b7a95..10169f93 100644 --- a/templates/email/program_registration.txt +++ b/templates/email/program_registration.txt @@ -1,4 +1,4 @@ -Здравствуйте, {{ first_name }}! +Здравствуйте{% if first_name %}, {{ first_name }}{% endif %}! Подтвердите почту для участия в программе «{{ program_name }}»: {{ confirmation_url }} @@ -6,4 +6,4 @@ Анкета уже сохранена. После подтверждения откроется программа. Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо. -Команда PROCOLLAB +Команда {{ brand_name|default:"PROCOLLAB" }} diff --git a/templates/email/white_label_password_reset.html b/templates/email/white_label_password_reset.html new file mode 100644 index 00000000..fe4c76f9 --- /dev/null +++ b/templates/email/white_label_password_reset.html @@ -0,0 +1,11 @@ + + + +

Восстановление пароля

+

Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}!

+

Для смены пароля перейдите по ссылке:

+

Установить новый пароль

+

Если вы не запрашивали смену пароля, проигнорируйте это письмо.

+

Команда {{ brand_name }}

+ + diff --git a/templates/email/white_label_password_reset.txt b/templates/email/white_label_password_reset.txt new file mode 100644 index 00000000..35c3d9d0 --- /dev/null +++ b/templates/email/white_label_password_reset.txt @@ -0,0 +1,8 @@ +Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}! + +Для смены пароля перейдите по ссылке: +{{ reset_password_url }} + +Если вы не запрашивали смену пароля, проигнорируйте это письмо. + +Команда {{ brand_name }} diff --git a/templates/partner_programs/admin/evaluation_criteria.html b/templates/partner_programs/admin/evaluation_criteria.html new file mode 100644 index 00000000..a3f08deb --- /dev/null +++ b/templates/partner_programs/admin/evaluation_criteria.html @@ -0,0 +1,24 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

{{ original.name }}

+

Вес каждого числового критерия задается в процентах. В режиме весов сумма должна быть ровно 100%; итог кейса — от 0 до 100.

+

Минимум шкалы дает нулевой вклад, максимум — полный вес. Оценки экспертов учитываются одинаково.

+{% if locked %}

Первая оценка уже сохранена. Критерии, веса и способ расчета зафиксированы.

{% endif %} +{% if error %}{% endif %} +
{% csrf_token %} +{{ scoring_form.non_field_errors }} +{{ scoring_form.expected_updated_at }}{{ scoring_form.expected_revision }} +
+
{{ scoring_form.scoring_mode.errors }}{{ scoring_form.scoring_mode.label_tag }}{{ scoring_form.scoring_mode }}
+ {{ criteria_formset.management_form }}{{ criteria_formset.non_form_errors }} + {% for form in criteria_formset %} +
+

Критерий {{ forloop.counter }}

{{ form.non_field_errors }}{{ form.id }} + {% for field in form.visible_fields %}
{{ field.errors }}{{ field.label_tag }}{{ field }}
{% endfor %} +
+ {% endfor %} +
+{% if not locked and can_change %}
{% endif %} +
+

Вернуться к программе

+{% endblock %} diff --git a/templates/partner_programs/admin/overall_results.html b/templates/partner_programs/admin/overall_results.html new file mode 100644 index 00000000..c3dea566 --- /dev/null +++ b/templates/partner_programs/admin/overall_results.html @@ -0,0 +1,56 @@ +{% extends "admin/base_site.html" %} +{% load l10n %} +{% block content %} +

{{ original.name }}

+

Кейс до 100 баллов + квиз до 100 баллов. Общий итог до 200.

+

Место получают команды, завершившие оба этапа. При одинаковом итоге команды делят место.

+{% if not snapshot.case_scoring_configured %} +

Для оценки кейса из 100 нужны веса критериев. Общий итог пока не рассчитывается.

+{% endif %} +
+

+ + + + + + + {% if snapshot.case_options %} + {% endif %} + + + + Сбросить фильтры +

+
+

Готовы к общему рейтингу: {{ snapshot.ready_count }} из {{ snapshot.matching_count }}.

+

Скачать общий рейтинг в Excel

+
+ + + + + {% for row in rows %} + + + + + {% empty %}{% endfor %} + +
Общий рейтинг команд
МестоКомандаКейсКейс из 100Квиз из 100Итог из 200Статус
{{ row.place|default_if_none:"" }}{{ row.name }}{{ row.case_name|default_if_none:"Без кейса" }}{{ row.case_score|default_if_none:""|floatformat:2 }}{{ row.quiz_score|default_if_none:""|floatformat:2 }}{{ row.score|default_if_none:""|floatformat:2 }}{{ row.status_label }}{% if row.case_status == "pending" %}: {{ row.case_evaluators_completed }} из {{ row.case_evaluators_required }} оценок{% endif %}
Команды пока не добавлены или не подходят под фильтры.
+
+

Вернуться к программе

+{% endblock %} diff --git a/users/filters.py b/users/filters.py index d3401224..b47d3299 100644 --- a/users/filters.py +++ b/users/filters.py @@ -8,6 +8,7 @@ from core.models import Specialization from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from users.utils import filter_age +from users.white_label_candidates import email_candidate_scope User = get_user_model() MIN_AGE_VALUE = 0 @@ -91,8 +92,7 @@ def fullname_literal_pattern(value): for c in value ) - @classmethod - def filter_by_fullname(cls, queryset, name, value): + def filter_by_fullname(self, queryset, name, value): """Ищет фрагменты имени и фамилии совместно, также в обратном порядке. split нормализует пробелы; пустая строка не фильтрует. Для составных @@ -100,6 +100,12 @@ def filter_by_fullname(cls, queryset, name, value): «Иван Иванов» не должен находить Петра Иванова по одной фамилии. Вся фильтрация выполняется в SQL до пагинации. """ + scope = email_candidate_scope(self.request) + if scope: + return queryset.filter( + email__iexact=scope.email, + partner_program_profiles__partner_program_id=scope.program_id, + ).distinct() words = value.split() if not words: return queryset @@ -115,14 +121,14 @@ def filter_by_fullname(cls, queryset, name, value): if len(" ".join(words)) > name_limit: return queryset.none() if len(words) == 1: - pattern = cls.fullname_literal_pattern(words[0]) + pattern = self.fullname_literal_pattern(words[0]) return queryset.filter( Q(first_name__regex=pattern) | Q(last_name__regex=pattern) ) predicate = Q() for boundary in range(1, len(words)): - first = cls.fullname_literal_pattern(" ".join(words[:boundary])) - last = cls.fullname_literal_pattern(" ".join(words[boundary:])) + first = self.fullname_literal_pattern(" ".join(words[:boundary])) + last = self.fullname_literal_pattern(" ".join(words[boundary:])) predicate |= (Q(first_name__regex=first) & Q(last_name__regex=last)) | ( Q(first_name__regex=last) & Q(last_name__regex=first) ) diff --git a/users/serializers.py b/users/serializers.py index 9f9f0e98..59456a77 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -916,6 +916,12 @@ class PublicUserSerializer(serializers.ModelSerializer): skills = serializers.SerializerMethodField() is_online = serializers.SerializerMethodField() + def get_fields(self): + fields = super().get_fields() + if self.context.get("white_label_email_candidate"): + fields["email"] = serializers.EmailField(read_only=True) + return fields + def get_skills(self, user: CustomUser) -> list: """Возвращает список навыков без поля approves""" skills = [] diff --git a/users/signals.py b/users/signals.py index b50f47c8..bf478cc6 100644 --- a/users/signals.py +++ b/users/signals.py @@ -7,6 +7,7 @@ from django_rest_passwordreset.signals import reset_password_token_created from users.models import CustomUser, Expert, Investor, Member, Mentor +from procollab.white_label import frontend_url, get_config @receiver(post_save, sender=CustomUser) @@ -46,8 +47,10 @@ def update_migration(): @receiver(reset_password_token_created) def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): + brand = get_config() reset_password_url = ( - f"https://app.procollab.ru/auth/reset_password/?token={reset_password_token.key}" + f"{frontend_url('https://app.procollab.ru')}/auth/reset_password/" + f"?token={reset_password_token.key}" ) registration_program = getattr(getattr(instance, "request", None), "data", {}).get( "registration_program_id" @@ -62,7 +65,7 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, registration_config__published__mode="native", ).exists(): reset_password_url = ( - settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + "/program-password-reset/?" + urlencode( { @@ -87,7 +90,7 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, invitation = None if invitation and invitation.email == reset_password_token.user.email.lower(): reset_password_url = ( - settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + "/expert-password-reset/?" + urlencode( { @@ -114,7 +117,7 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, and invitation.email.lower() == reset_password_token.user.email.lower() ): reset_password_url = ( - settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + frontend_url(settings.EXPERT_WORKSPACE_FRONTEND_URL) + "/organizer-password-reset/?" + urlencode( { @@ -127,13 +130,17 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, "user": reset_password_token.user, "email": reset_password_token.user.email, "reset_password_url": reset_password_url, + "brand_name": brand.brand_name, } - email_html_message = render_to_string("email/password_reset_email.html", context) - email_plaintext_message = render_to_string("email/password_reset_email.txt", context) + template = "white_label_password_reset" if brand.enabled else "password_reset_email" + email_html_message = render_to_string(f"email/{template}.html", context) + email_plaintext_message = render_to_string(f"email/{template}.txt", context) msg = EmailMultiAlternatives( - "Сброс пароля | Procollab", + f"Сброс пароля | {brand.brand_name}" + if brand.enabled + else "Сброс пароля | Procollab", email_plaintext_message, settings.EMAIL_USER, [reset_password_token.user.email], diff --git a/users/tests/test_white_label_candidates.py b/users/tests/test_white_label_candidates.py new file mode 100644 index 00000000..67553f79 --- /dev/null +++ b/users/tests/test_white_label_candidates.py @@ -0,0 +1,115 @@ +"""Email-only participants are discoverable only by an authorized program peer.""" + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from users.tests.helpers import add_user_to_program, build_partner_program, build_user + + +class WhiteLabelCandidateSearchTests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = build_partner_program() + self.other = build_partner_program(name="Другая программа") + self.leader = build_user(email="leader@example.test") + self.member = build_user( + email="member@example.test", + first_name="", + last_name="", + birthday=None, + onboarding_stage=None, + ) + self.outsider = build_user(email="outsider@example.test") + add_user_to_program(self.leader, self.program) + add_user_to_program(self.member, self.program) + add_user_to_program(self.outsider, self.other) + self.mode = override_settings( + WHITE_LABEL_ENABLED=True, + WHITE_LABEL_PROGRAM_ID=self.program.pk, + WHITE_LABEL_BRAND_NAME="Чемпионат", + WHITE_LABEL_FRONTEND_URL="https://championship.example.test", + WHITE_LABEL_LOGO_URL="", + WHITE_LABEL_FAVICON_URL="", + WHITE_LABEL_LOGO_BACKGROUND="transparent", + ) + self.mode.enable() + self.addCleanup(self.mode.disable) + self.client.force_authenticate(self.leader) + + def search(self, **overrides): + query = { + "fullname": self.member.email, + "partner_program": self.program.pk, + "user_type": 1, + **overrides, + } + return self.client.get("/auth/public-users/", query) + + def test_exact_email_finds_peer_without_inventing_profile_fields(self): + response = self.search(fullname=" MEMBER@EXAMPLE.TEST ") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + row = response.data["results"][0] + self.assertEqual(row["id"], self.member.pk) + self.assertEqual(row["email"], self.member.email) + self.assertEqual(row["firstName"], "") + self.assertEqual(row["lastName"], "") + self.assertIsNone(row["birthday"]) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertIn("Authorization", response["Vary"]) + + def test_anonymous_and_foreign_actor_cannot_search_email(self): + for actor in (None, self.outsider): + with self.subTest(actor=getattr(actor, "pk", None)): + self.client.force_authenticate(actor) + response = self.search() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + def test_foreign_candidate_and_program_are_not_disclosed(self): + self.assertEqual(self.search(fullname=self.outsider.email).data["count"], 0) + self.assertEqual( + self.search(partner_program=self.other.pk, fullname=self.outsider.email).data[ + "count" + ], + 0, + ) + + def test_missing_program_and_partial_email_do_not_enable_lookup(self): + for query in ( + {"fullname": self.member.email}, + {"fullname": "member@", "partner_program": self.program.pk}, + ): + response = self.client.get("/auth/public-users/", query) + self.assertEqual(response.data["count"], 0) + + def test_named_search_and_public_list_do_not_expose_email(self): + for actor in (None, self.leader): + self.client.force_authenticate(actor) + for query in ( + {}, + {"fullname": self.leader.first_name, "partner_program": self.program.pk}, + ): + response = self.client.get("/auth/public-users/", query) + self.assertTrue(response.data["results"]) + for row in response.data["results"]: + self.assertNotIn("email", row) + + def test_disabled_white_label_preserves_original_contract(self): + with override_settings(WHITE_LABEL_ENABLED=False): + self.assertEqual(self.search().data["count"], 0) + response = self.search(fullname=self.leader.first_name) + self.assertTrue(response.data["results"]) + self.assertNotIn("email", response.data["results"][0]) + + def test_program_manager_can_find_own_participant(self): + manager = build_user(email="manager@example.test") + self.program.managers.add(manager) + self.client.force_authenticate(manager) + self.assertEqual(self.search().data["count"], 1) + + def test_other_filters_remain_effective(self): + self.assertEqual(self.search(user_type=2).data["count"], 0) + self.member.is_active = False + self.member.save(update_fields=["is_active"]) + self.assertEqual(self.search().data["count"], 0) diff --git a/users/views.py b/users/views.py index 0afc8231..1f495323 100644 --- a/users/views.py +++ b/users/views.py @@ -12,6 +12,7 @@ from django.shortcuts import get_object_or_404, redirect from django.template.loader import render_to_string from django.utils import timezone +from django.utils.cache import patch_vary_headers from django_filters import rest_framework as filters from drf_yasg.utils import swagger_auto_schema from rest_framework import exceptions, permissions, status @@ -49,6 +50,7 @@ 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 +from users.white_label_candidates import email_candidate_scope from users.serializers import ( AchievementDetailSerializer, AchievementListSerializer, @@ -691,6 +693,18 @@ class PublicUserListView(ListAPIView): filterset_class = UserFilter permission_classes = [AllowAny] + def get_serializer_context(self): + context = super().get_serializer_context() + context["white_label_email_candidate"] = bool(email_candidate_scope(self.request)) + return context + + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + if email_candidate_scope(request): + response["Cache-Control"] = "private, no-store" + patch_vary_headers(response, ("Authorization", "Cookie")) + return response + def get_queryset(self): """Оптимизация запросов для навыков и категорий""" return ( diff --git a/users/white_label_candidates.py b/users/white_label_candidates.py new file mode 100644 index 00000000..cad4732d --- /dev/null +++ b/users/white_label_candidates.py @@ -0,0 +1,46 @@ +"""Точный поиск почты внутри программы без расширения публичного каталога.""" + +from dataclasses import dataclass + +from django.core.exceptions import ValidationError +from django.core.validators import validate_email + +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from procollab.white_label import is_white_label_program + + +@dataclass(frozen=True) +class EmailCandidateScope: + program_id: int + email: str + + +def email_candidate_scope(request): + if request is None: + return None + if hasattr(request, "_white_label_email_candidate_scope"): + return request._white_label_email_candidate_scope + request._white_label_email_candidate_scope = None + user = request.user + if not user.is_authenticated: + return None + email = request.query_params.get("fullname", "").strip() + try: + program_id = int(request.query_params.get("partner_program", "")) + validate_email(email) + except (ValueError, TypeError, ValidationError): + return None + if not is_white_label_program(program_id): + return None + allowed = ( + user.is_superuser + or PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, user=user + ).exists() + or PartnerProgram.objects.filter(pk=program_id, managers=user).exists() + ) + if not allowed: + return None + scope = EmailCandidateScope(program_id, email) + request._white_label_email_candidate_scope = scope + return scope