From 4543ab24cead452ecb9f9f334ec09b806bf3c425 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 11 Sep 2026 01:24:01 +0300 Subject: [PATCH 1/2] Quarantine next-gen API surface in production --- .env.example | 2 + .github/workflows/backend-postgres-ci.yml | 11 ++ .github/workflows/ci.yml | 14 ++ .github/workflows/release-ci.yml | 1 + .gitignore | 1 + docs/backend-react-dev-autodeploy.md | 26 ++- docs/react-dev-backend.md | 15 +- feed/urls.py | 57 ++++--- invites/tests/test_invite_api.py | 13 +- invites/tests/test_invite_decisions.py | 21 ++- invites/tests/test_invite_detail_access.py | 9 +- invites/views.py | 18 ++- notifications/events.py | 30 +++- .../tests/test_surface_quarantine.py | 101 ++++++++++++ partner_programs/admin.py | 40 +++-- partner_programs/urls.py | 75 +++++---- procollab/settings.py | 14 +- procollab/tests/test_nextgen_surface.py | 151 ++++++++++++++++++ procollab/urls.py | 54 ++++--- projects/urls.py | 149 ++++++++--------- scripts/deploy_react_dev.sh | 80 ++++++++++ users/urls.py | 26 +-- 22 files changed, 703 insertions(+), 205 deletions(-) create mode 100644 notifications/tests/test_surface_quarantine.py create mode 100644 procollab/tests/test_nextgen_surface.py diff --git a/.env.example b/.env.example index 813d4d12..bbf638b3 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ EXTRA_CSRF_TRUSTED_ORIGINS= # Пример для react-dev: # EXTRA_ALLOWED_HOSTS=api-react-dev.procollab.ru,react-dev.procollab.ru # EXTRA_CSRF_TRUSTED_ORIGINS=https://api-react-dev.procollab.ru,https://react-dev.procollab.ru +# NEXTGEN_SURFACE_ENABLED=True +NEXTGEN_SURFACE_ENABLED=False EMAIL_USER= EMAIL_BACKEND=anymail.backends.unisender_go.EmailBackend diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index eced28d2..8f5aea53 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -50,6 +50,7 @@ jobs: DATABASE_HOST: 127.0.0.1 DATABASE_PORT: "5432" ALLOW_REACT_DEV_DEMO_SEED: "False" + NEXTGEN_SURFACE_ENABLED: "True" AUTOPOSTING_ON: "False" steps: @@ -104,6 +105,16 @@ jobs: - name: Lint CI settings run: poetry run flake8 procollab/settings_ci.py + - name: Run production surface quarantine tests + env: + NEXTGEN_SURFACE_ENABLED: "False" + run: | + poetry run python manage.py test \ + procollab.tests.test_nextgen_surface \ + notifications.tests.test_surface_quarantine \ + notifications.tests.test_notifications_api \ + --verbosity 2 + - name: Run PostgreSQL locking and constraint tests run: | poetry run python manage.py test \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa92e86d..c752551d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,9 +60,23 @@ jobs: - name: Install dependencies run: poetry install + - name: Run production surface quarantine tests + run: >- + poetry run python manage.py test + procollab.tests.test_nextgen_surface + notifications.tests.test_surface_quarantine + notifications.tests.test_notifications_api + --verbosity=2 + env: + DEBUG: True + DJANGO_SETTINGS_MODULE: procollab.settings + NEXTGEN_SURFACE_ENABLED: False + PYTHONUNBUFFERED: 1 + - name: Run tests run: poetry run python manage.py test --verbosity=2 env: DEBUG: True DJANGO_SETTINGS_MODULE: procollab.settings + NEXTGEN_SURFACE_ENABLED: True PYTHONUNBUFFERED: 1 diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index a6381219..47adddc1 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -87,6 +87,7 @@ jobs: run: poetry run python manage.py test env: DEBUG: True + NEXTGEN_SURFACE_ENABLED: True build: name: Build Image diff --git a/.gitignore b/.gitignore index 5ee01578..cf9c54d3 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ celerybeat.pid # Environments .env .venv +/.react-dev-runtime/ env/ venv/ ENV/ diff --git a/docs/backend-react-dev-autodeploy.md b/docs/backend-react-dev-autodeploy.md index 34cf9c13..0f9c88a1 100644 --- a/docs/backend-react-dev-autodeploy.md +++ b/docs/backend-react-dev-autodeploy.md @@ -89,20 +89,30 @@ Compose-команда собирается как Bash array без `eval`. Д containers проверяется наличие точных сервисов `web`, `celerys` и `redis`. Иное имя Celery service не угадывается: deploy завершается с явной ошибкой. +Script атомарно генерирует ignored override +`.react-dev-runtime/nextgen-surface.compose.yml`, который передаёт +`NEXTGEN_SURFACE_ENABLED=True` только в `web` и `celerys`. Override добавляется к +Compose config files ровно один раз, а итоговое значение обоих services +проверяется до смены repository revision, миграций и перезапуска. Поэтому +React-dev сохраняет будущий API surface, даже если setting отсутствует в +серверном `.env`; production deploy этот override не использует. + ## Порядок deploy 1. Получение deployment lock через `flock`. 2. Проверка repository, origin, git state и stale deploy. -3. Сохранение предыдущих SHA, container IDs, image IDs и image references. -4. Сборка backend image в GitHub Actions и публикация в GHCR по SHA-тегу. -5. Загрузка image на React-dev по immutable digest и проверка revision label. -6. Переназначение существующих Compose image references без server-side build. -7. `python manage.py check` во временном container нового `web` image без TTY +3. Подключение React-dev Compose override и проверка + `NEXTGEN_SURFACE_ENABLED=True` для `web` и `celerys`. +4. Сохранение предыдущих SHA, container IDs, image IDs и image references. +5. Сборка backend image в GitHub Actions и публикация в GHCR по SHA-тегу. +6. Загрузка image на React-dev по immutable digest и проверка revision label. +7. Переназначение существующих Compose image references без server-side build. +8. `python manage.py check` во временном container нового `web` image без TTY и без доступа к stdin deploy-скрипта. -8. `python manage.py migrate --noinput` с существующим React-dev `.env`, также без TTY +9. `python manage.py migrate --noinput` с существующим React-dev `.env`, также без TTY и с stdin, подключенным к `/dev/null`. -9. Пересоздание только `web` и `celerys` с явным запретом server-side build. -10. Проверка image ID, running state и публичный HTTPS health-check. +10. Пересоздание только `web` и `celerys` с явным запретом server-side build. +11. Проверка image ID, running state и публичный HTTPS health-check. React-dev сервер больше не устанавливает Python-зависимости и не обращается к PyPI во время deploy. Доступ к PyPI требуется только GitHub-hosted runner на diff --git a/docs/react-dev-backend.md b/docs/react-dev-backend.md index 7564ad79..5e1c958d 100644 --- a/docs/react-dev-backend.md +++ b/docs/react-dev-backend.md @@ -13,9 +13,17 @@ ```dotenv EXTRA_ALLOWED_HOSTS=api-react-dev.procollab.ru,react-dev.procollab.ru EXTRA_CSRF_TRUSTED_ORIGINS=https://api-react-dev.procollab.ru,https://react-dev.procollab.ru +NEXTGEN_SURFACE_ENABLED=True ``` -После изменения окружения настройки применятся при следующем запуске backend-процесса. Этот документ не описывает и не инициирует deploy. +`NEXTGEN_SURFACE_ENABLED` включает Application/Team/Submission/Evaluation, +React workspace, React news и public profiles API. По умолчанию setting равен +`False`, поэтому production и legacy dev не публикуют этот surface. Автодеплой +React-dev передаёт `True` в `web` и `celerys` через отдельный Compose override и +проверяет итоговую конфигурацию до миграций и перезапуска. + +После изменения окружения настройки применятся при следующем запуске +backend-процесса. Этот документ не описывает и не инициирует deploy. ## Правила разбора @@ -28,4 +36,7 @@ EXTRA_CSRF_TRUSTED_ORIGINS=https://api-react-dev.procollab.ru,https://react-dev. ## Ограничения -Настройка не меняет API endpoints, сущности, статусы или бизнес-логику. Она не выполняет проверку доступности доменов и не управляет nginx, Docker или инфраструктурой. Корректность формата trusted origins дополнительно проверяется стандартными проверками Django при запуске. +Настройки доменов не меняют API endpoints, сущности, статусы или бизнес-логику. +Они не выполняют проверку доступности доменов и не управляют nginx, Docker или +инфраструктурой. Корректность формата trusted origins дополнительно проверяется +стандартными проверками Django при запуске. diff --git a/feed/urls.py b/feed/urls.py index a2cc93c2..44e406a2 100644 --- a/feed/urls.py +++ b/feed/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.urls import path from feed.views import NewSimpleFeed @@ -14,30 +15,34 @@ urlpatterns = [ path("", NewSimpleFeed.as_view()), - path("news/", ReactNewsFeedListView.as_view(), name="react-news-list"), - path( - "news//", - ReactNewsFeedDetailView.as_view(), - name="react-news-detail", - ), - path( - "news//set-liked/", - ReactNewsSetLikedView.as_view(), - name="react-news-set-liked", - ), - path( - "news//set-viewed/", - ReactNewsSetViewedView.as_view(), - name="react-news-set-viewed", - ), - path( - "news//comments/", - ReactNewsCommentListCreateView.as_view(), - name="react-news-comment-list", - ), - path( - "news//comments//", - ReactNewsCommentDetailView.as_view(), - name="react-news-comment-detail", - ), ] + +if settings.NEXTGEN_SURFACE_ENABLED: + urlpatterns += [ + path("news/", ReactNewsFeedListView.as_view(), name="react-news-list"), + path( + "news//", + ReactNewsFeedDetailView.as_view(), + name="react-news-detail", + ), + path( + "news//set-liked/", + ReactNewsSetLikedView.as_view(), + name="react-news-set-liked", + ), + path( + "news//set-viewed/", + ReactNewsSetViewedView.as_view(), + name="react-news-set-viewed", + ), + path( + "news//comments/", + ReactNewsCommentListCreateView.as_view(), + name="react-news-comment-list", + ), + path( + "news//comments//", + ReactNewsCommentDetailView.as_view(), + name="react-news-comment-detail", + ), + ] diff --git a/invites/tests/test_invite_api.py b/invites/tests/test_invite_api.py index dd2816cb..584b5fd4 100644 --- a/invites/tests/test_invite_api.py +++ b/invites/tests/test_invite_api.py @@ -1,6 +1,6 @@ from types import SimpleNamespace -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework import status from rest_framework.test import APIClient @@ -15,12 +15,14 @@ invite_payload, link_project_to_program, ) +from notifications.models import Notification class InviteCreateAPITests(TestCase): def setUp(self): self.client = APIClient() + @override_settings(NEXTGEN_SURFACE_ENABLED=False) def test_project_leader_can_create_invite(self): leader = create_user(prefix="leader") recipient = create_user(prefix="recipient") @@ -39,6 +41,11 @@ def test_project_leader_can_create_invite(self): self.assertEqual(response.data["sender"]["id"], leader.id) self.assertEqual(response.data["role"], "Designer") self.assertIsNone(response.data["is_accepted"]) + notification = Notification.objects.get( + recipient=recipient, + type=Notification.Type.PROJECT_INVITE_CREATED, + ) + self.assertEqual(notification.action_url, "/office/projects/invites") def test_project_leader_can_create_invite_without_motivational_letter(self): leader = create_user(prefix="leader") @@ -124,7 +131,9 @@ def test_cannot_create_duplicate_active_invite(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Invite.objects.filter(project=project, user=recipient).count(), 1) + self.assertEqual( + Invite.objects.filter(project=project, user=recipient).count(), 1 + ) def test_program_project_invite_requires_program_membership(self): leader = create_user(prefix="leader") diff --git a/invites/tests/test_invite_decisions.py b/invites/tests/test_invite_decisions.py index 0af8a681..49699218 100644 --- a/invites/tests/test_invite_decisions.py +++ b/invites/tests/test_invite_decisions.py @@ -1,15 +1,17 @@ -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework import status from rest_framework.test import APIClient from projects.models import Collaborator from invites.tests.helpers import create_invite, create_user +from notifications.models import Notification class InviteDecisionAPITests(TestCase): def setUp(self): self.client = APIClient() + @override_settings(NEXTGEN_SURFACE_ENABLED=False) def test_invited_user_can_accept_invite_and_become_collaborator(self): recipient = create_user(prefix="recipient") invite = create_invite( @@ -32,7 +34,16 @@ def test_invited_user_can_accept_invite_and_become_collaborator(self): specialization="Market research", ).exists() ) + notification = Notification.objects.get( + recipient=invite.project.leader, + type=Notification.Type.PROJECT_INVITE_ACCEPTED, + ) + self.assertEqual( + notification.action_url, + f"/office/projects/{invite.project_id}/edit?section=team", + ) + @override_settings(NEXTGEN_SURFACE_ENABLED=False) def test_invited_user_can_decline_invite(self): recipient = create_user(prefix="recipient") invite = create_invite(user=recipient) @@ -43,6 +54,14 @@ def test_invited_user_can_decline_invite(self): invite.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(invite.is_accepted) + notification = Notification.objects.get( + recipient=invite.project.leader, + type=Notification.Type.PROJECT_INVITE_DECLINED, + ) + self.assertEqual( + notification.action_url, + f"/office/projects/{invite.project_id}/edit?section=team", + ) def test_other_user_cannot_accept_or_decline_invite(self): invite = create_invite() diff --git a/invites/tests/test_invite_detail_access.py b/invites/tests/test_invite_detail_access.py index a6ab7c77..508c82e2 100644 --- a/invites/tests/test_invite_detail_access.py +++ b/invites/tests/test_invite_detail_access.py @@ -1,9 +1,10 @@ -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework import status from rest_framework.test import APIClient from invites.models import Invite from invites.tests.helpers import create_invite, create_user +from notifications.models import Notification class InviteDetailAccessTests(TestCase): @@ -113,6 +114,7 @@ def test_outsider_cannot_update_invite(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(invite.motivational_letter, "Initial") + @override_settings(NEXTGEN_SURFACE_ENABLED=False) def test_project_leader_can_delete_invite(self): invite = create_invite() self.client.force_authenticate(invite.project.leader) @@ -121,6 +123,11 @@ def test_project_leader_can_delete_invite(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(Invite.objects.filter(pk=invite.pk).exists()) + notification = Notification.objects.get( + recipient=invite.user, + type=Notification.Type.PROJECT_INVITE_REVOKED, + ) + self.assertEqual(notification.action_url, "/office/projects/invites") def test_invited_user_cannot_delete_invite(self): invite = create_invite() diff --git a/invites/views.py b/invites/views.py index e158ce83..49d33e34 100644 --- a/invites/views.py +++ b/invites/views.py @@ -1,4 +1,5 @@ from django_filters import rest_framework as filters +from django.db import transaction from rest_framework import generics, permissions, status from rest_framework.response import Response @@ -12,6 +13,10 @@ accept_project_invitation, decline_project_invitation, ) +from notifications.events import ( + notify_project_invite_created, + notify_project_invite_resolved, +) class InviteList(generics.ListCreateAPIView): @@ -32,7 +37,9 @@ def create(self, request, *args, **kwargs): if serializer.validated_data["project"].leader != request.user: # additional check that the user is the invite's project's leader return Response(status=status.HTTP_403_FORBIDDEN) - instance = serializer.save(invited_by=request.user) + with transaction.atomic(): + instance = serializer.save(invited_by=request.user) + notify_project_invite_created(instance) headers = self.get_success_headers(serializer.data) # using detailed serializer so that it'll pass User and Project objects detailed @@ -48,6 +55,15 @@ class InviteDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = InviteDetailSerializer permission_classes = [InviteDetailPermission] + def perform_destroy(self, instance): + with transaction.atomic(): + notify_project_invite_resolved( + instance, + actor=self.request.user, + status="revoked", + ) + instance.delete() + class InviteAccept(generics.GenericAPIView): queryset = Invite.objects.get_invite_for_list_view() diff --git a/notifications/events.py b/notifications/events.py index fb761c87..254f3ed3 100644 --- a/notifications/events.py +++ b/notifications/events.py @@ -1,3 +1,5 @@ +from django.conf import settings + from notifications.models import Notification from notifications.services import create_notification, create_notifications @@ -13,6 +15,10 @@ def _user_name(user) -> str: return name or "Пользователь" +def _nextgen_notifications_enabled() -> bool: + return settings.NEXTGEN_SURFACE_ENABLED + + def notify_project_invite_created(invite) -> None: """Уведомляет пользователя о новом приглашении в проект.""" create_notification( @@ -72,9 +78,7 @@ def notify_vacancy_response_created(response) -> None: notification_type=Notification.Type.VACANCY_RESPONSE_CREATED, title="Новый отклик на вакансию", message=f"Получен отклик на вакансию «{vacancy.role}».", - action_url=( - f"/office/projects/{vacancy.project_id}/vacancies/" f"{vacancy.pk}/responses" - ), + action_url=f"/office/vacancies/{vacancy.pk}", event_key=_event_key("vacancy-response", response.pk, "created"), ) @@ -104,6 +108,8 @@ def notify_vacancy_response_resolved(response, *, actor, accepted: bool) -> None def notify_team_invite_created(invite) -> None: """Уведомляет пользователя о новом приглашении в команду заявки.""" + if not _nextgen_notifications_enabled(): + return create_notification( recipient_id=invite.user_id, actor_id=invite.invited_by_id, @@ -117,6 +123,8 @@ def notify_team_invite_created(invite) -> None: def notify_team_invite_resolved(invite, *, actor, status: str) -> None: """Уведомляет капитана либо приглашённого о завершении приглашения.""" + if not _nextgen_notifications_enabled(): + return application_id = invite.team.application_id config = { "accepted": ( @@ -155,6 +163,8 @@ def notify_team_invite_resolved(invite, *, actor, status: str) -> None: def notify_application_submitted(application, *, actor) -> None: """Уведомляет всех менеджеров программы об отправленной заявке.""" + if not _nextgen_notifications_enabled(): + return create_notifications( recipient_ids=application.program.managers.values_list("pk", flat=True), actor_id=actor.pk, @@ -173,6 +183,8 @@ def notify_application_submitted(application, *, actor) -> None: def notify_application_status_changed(application, *, actor) -> None: """Уведомляет владельца заявки о подтверждённом изменении статуса.""" + if not _nextgen_notifications_enabled(): + return if application.user_id is None: return create_notification( @@ -194,6 +206,8 @@ def notify_application_status_changed(application, *, actor) -> None: def notify_submission_submitted(submission, *, actor) -> None: """Уведомляет менеджеров программы об отправленном решении.""" + if not _nextgen_notifications_enabled(): + return create_notifications( recipient_ids=submission.program.managers.values_list("pk", flat=True), actor_id=actor.pk, @@ -212,6 +226,8 @@ def notify_submission_submitted(submission, *, actor) -> None: def notify_submission_status_changed(submission, *, actor) -> None: """Уведомляет владельца заявки и принятых участников её команды.""" + if not _nextgen_notifications_enabled(): + return from partner_programs.models import TeamMember recipient_ids = [submission.application.user_id] @@ -240,6 +256,8 @@ def notify_submission_status_changed(submission, *, actor) -> None: def notify_expert_assignment_created(assignment) -> None: """Уведомляет эксперта о назначенной работе.""" + if not _nextgen_notifications_enabled(): + return create_notification( recipient_id=assignment.expert.user_id, actor_id=assignment.assigned_by_id, @@ -253,6 +271,8 @@ def notify_expert_assignment_created(assignment) -> None: def notify_expert_assignment_revoked(assignment, *, actor) -> None: """Уведомляет эксперта об отзыве назначения.""" + if not _nextgen_notifications_enabled(): + return create_notification( recipient_id=assignment.expert.user_id, actor_id=actor.pk, @@ -266,6 +286,8 @@ def notify_expert_assignment_revoked(assignment, *, actor) -> None: def notify_evaluation_submitted(evaluation, *, actor) -> None: """Уведомляет менеджеров программы о финально отправленной оценке.""" + if not _nextgen_notifications_enabled(): + return submission = evaluation.submission create_notifications( recipient_ids=submission.program.managers.values_list("pk", flat=True), @@ -280,6 +302,8 @@ def notify_evaluation_submitted(evaluation, *, actor) -> None: def notify_news_comment_created(comment) -> None: """Уведомляет владельцев источника новости о новом комментарии.""" + if not _nextgen_notifications_enabled(): + return news = comment.news model = news.content_type.model if model == "customuser": diff --git a/notifications/tests/test_surface_quarantine.py b/notifications/tests/test_surface_quarantine.py new file mode 100644 index 00000000..b47f0440 --- /dev/null +++ b/notifications/tests/test_surface_quarantine.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from notifications.events import ( + notify_application_status_changed, + notify_application_submitted, + notify_evaluation_submitted, + notify_expert_assignment_created, + notify_expert_assignment_revoked, + notify_news_comment_created, + notify_project_invite_created, + notify_project_invite_resolved, + notify_submission_status_changed, + notify_submission_submitted, + notify_team_invite_created, + notify_team_invite_resolved, + notify_vacancy_response_created, + notify_vacancy_response_resolved, +) + + +@override_settings(NEXTGEN_SURFACE_ENABLED=False) +class NotificationSurfaceQuarantineTests(SimpleTestCase): + @patch("notifications.events.create_notifications") + @patch("notifications.events.create_notification") + def test_nextgen_events_do_not_write_notifications( + self, + create_notification, + create_notifications, + ): + opaque_event = object() + opaque_actor = object() + + notify_team_invite_created(opaque_event) + notify_team_invite_resolved(opaque_event, actor=opaque_actor, status="accepted") + notify_application_submitted(opaque_event, actor=opaque_actor) + notify_application_status_changed(opaque_event, actor=opaque_actor) + notify_submission_submitted(opaque_event, actor=opaque_actor) + notify_submission_status_changed(opaque_event, actor=opaque_actor) + notify_expert_assignment_created(opaque_event) + notify_expert_assignment_revoked(opaque_event, actor=opaque_actor) + notify_evaluation_submitted(opaque_event, actor=opaque_actor) + notify_news_comment_created(opaque_event) + + create_notification.assert_not_called() + create_notifications.assert_not_called() + + @patch("notifications.events.create_notification") + def test_project_invite_events_remain_enabled(self, create_notification): + leader = SimpleNamespace(pk=10, get_full_name=lambda: "Иван Иванов") + project = SimpleNamespace(pk=20, leader_id=leader.pk, name="Альфа") + invite = SimpleNamespace( + pk=30, + user_id=40, + invited_by_id=leader.pk, + project_id=project.pk, + project=project, + ) + + notify_project_invite_created(invite) + notify_project_invite_resolved(invite, actor=leader, status="revoked") + + self.assertEqual(create_notification.call_count, 2) + self.assertEqual( + create_notification.call_args_list[0].kwargs["action_url"], + "/office/projects/invites", + ) + self.assertEqual( + create_notification.call_args_list[1].kwargs["action_url"], + "/office/projects/invites", + ) + + @patch("notifications.events.create_notification") + def test_vacancy_events_remain_enabled_with_angular_routes( + self, + create_notification, + ): + leader = SimpleNamespace(pk=10) + applicant = SimpleNamespace(pk=40) + vacancy = SimpleNamespace( + pk=50, + project_id=20, + project=SimpleNamespace(leader_id=leader.pk), + role="Дизайнер", + ) + response = SimpleNamespace(pk=60, user_id=applicant.pk, vacancy=vacancy) + + notify_vacancy_response_created(response) + notify_vacancy_response_resolved(response, actor=leader, accepted=True) + + self.assertEqual(create_notification.call_count, 2) + self.assertEqual( + create_notification.call_args_list[0].kwargs["action_url"], + f"/office/vacancies/{vacancy.pk}", + ) + self.assertEqual( + create_notification.call_args_list[1].kwargs["action_url"], + "/office/vacancies/my", + ) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 54c78690..a184507a 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -4,6 +4,7 @@ import tablib from django import forms +from django.conf import settings from django.contrib import admin from django.db.models import QuerySet from django.http import HttpRequest @@ -37,7 +38,18 @@ from partner_programs.services.case_fields import case_field_has_values -@admin.register(Application) +def register_nextgen_admin(model): + """Registers future-domain models only where their runtime surface is enabled.""" + + def decorator(admin_class): + if settings.NEXTGEN_SURFACE_ENABLED: + admin.site.register(model, admin_class) + return admin_class + + return decorator + + +@register_nextgen_admin(Application) class ApplicationAdmin(admin.ModelAdmin): list_display = ( "id", @@ -77,7 +89,7 @@ class ApplicationAdmin(admin.ModelAdmin): date_hierarchy = "created_at" -@admin.register(Team) +@register_nextgen_admin(Team) class TeamAdmin(admin.ModelAdmin): list_display = ( "id", @@ -121,7 +133,7 @@ def get_program(self, obj): return obj.application.program -@admin.register(TeamMember) +@register_nextgen_admin(TeamMember) class TeamMemberAdmin(admin.ModelAdmin): list_display = ( "id", @@ -171,7 +183,7 @@ def get_program(self, obj): return obj.team.application.program -@admin.register(TeamInvite) +@register_nextgen_admin(TeamInvite) class TeamInviteAdmin(admin.ModelAdmin): list_display = ( "id", @@ -220,7 +232,7 @@ def get_program(self, obj): return obj.team.application.program -@admin.register(Submission) +@register_nextgen_admin(Submission) class SubmissionAdmin(admin.ModelAdmin): list_display = ( "id", @@ -262,7 +274,7 @@ class SubmissionAdmin(admin.ModelAdmin): date_hierarchy = "created_at" -@admin.register(SubmissionExpertAssignment) +@register_nextgen_admin(SubmissionExpertAssignment) class SubmissionExpertAssignmentAdmin(admin.ModelAdmin): list_display = ( "submission", @@ -312,7 +324,7 @@ class SubmissionExpertAssignmentAdmin(admin.ModelAdmin): date_hierarchy = "assigned_at" -@admin.register(Evaluation) +@register_nextgen_admin(Evaluation) class EvaluationAdmin(admin.ModelAdmin): list_display = ( "submission", @@ -355,7 +367,7 @@ class EvaluationAdmin(admin.ModelAdmin): date_hierarchy = "created_at" -@admin.register(EvaluationScore) +@register_nextgen_admin(EvaluationScore) class EvaluationScoreAdmin(admin.ModelAdmin): list_display = ( "evaluation", @@ -396,7 +408,7 @@ class EvaluationScoreAdmin(admin.ModelAdmin): date_hierarchy = "created_at" -@admin.register(EvaluationAmendment) +@register_nextgen_admin(EvaluationAmendment) class EvaluationAmendmentAdmin(admin.ModelAdmin): list_display = ( "id", @@ -544,8 +556,10 @@ class Meta: ) def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: - qs = super().get_queryset(request).prefetch_related( - "managers", "materials", "fields" + qs = ( + super() + .get_queryset(request) + .prefetch_related("managers", "materials", "fields") ) if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True @@ -564,9 +578,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None): "partner_programs/admin/program_manager_change_form.html" ) else: - self.change_form_template = ( - "partner_programs/admin/programs_change_form.html" - ) + self.change_form_template = "partner_programs/admin/programs_change_form.html" return super().change_view(request, object_id, form_url, extra_context) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 5802bb3b..0ea3c42e 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,5 +1,6 @@ # Roadmap: DEV-076, DEV-056 +from django.conf import settings from django.urls import path from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList @@ -49,16 +50,6 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), - path( - "managed/", - ManagedProgramListView.as_view(), - name="managed-program-list", - ), - path( - "/manager-overview/", - ManagerProgramOverviewView.as_view(), - name="manager-overview", - ), path( "/project-analytics/", ProjectAnalyticsAPIView.as_view(), @@ -89,31 +80,6 @@ ProjectAnalyticsProjectsNotSubmittedAPIView.as_view(), name="project-analytics-projects-not-submitted", ), - path( - "/evaluations/", - ProgramEvaluationListView.as_view(), - name="evaluation-list", - ), - path( - "/evaluations//", - ProgramEvaluationDetailView.as_view(), - name="evaluation-detail", - ), - path( - "/submission-assignments/", - ProgramSubmissionAssignmentListCreateView.as_view(), - name="submission-assignment-list-create", - ), - path( - "/applications/my/", - MyProgramApplicationView.as_view(), - name="my-application", - ), - path( - "/applications/", - ProgramApplicationCreateView.as_view(), - name="application-create", - ), path("/", PartnerProgramDetail.as_view()), path( "/acknowledge-welcome/", @@ -171,3 +137,42 @@ name="partner-program-export-rates", ), ] + +if settings.NEXTGEN_SURFACE_ENABLED: + urlpatterns += [ + path( + "managed/", + ManagedProgramListView.as_view(), + name="managed-program-list", + ), + path( + "/manager-overview/", + ManagerProgramOverviewView.as_view(), + name="manager-overview", + ), + path( + "/evaluations/", + ProgramEvaluationListView.as_view(), + name="evaluation-list", + ), + path( + "/evaluations//", + ProgramEvaluationDetailView.as_view(), + name="evaluation-detail", + ), + path( + "/submission-assignments/", + ProgramSubmissionAssignmentListCreateView.as_view(), + name="submission-assignment-list-create", + ), + path( + "/applications/my/", + MyProgramApplicationView.as_view(), + name="my-application", + ), + path( + "/applications/", + ProgramApplicationCreateView.as_view(), + name="application-create", + ), + ] diff --git a/procollab/settings.py b/procollab/settings.py index 8c7ad03c..d902ad49 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -24,6 +24,12 @@ cast=bool, ) +NEXTGEN_SURFACE_ENABLED = config( + "NEXTGEN_SURFACE_ENABLED", + default=False, + cast=bool, +) + AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool) TELEGRAM_BOT_TOKEN = config("TELEGRAM_BOT_TOKEN", default="", cast=str) @@ -180,18 +186,14 @@ "rest_framework.renderers.AdminRenderer", ], "DEFAULT_THROTTLE_RATES": { - "auth_register": config( - "DRF_THROTTLE_AUTH_REGISTER", default="5/min", cast=str - ), + "auth_register": config("DRF_THROTTLE_AUTH_REGISTER", default="5/min", cast=str), "auth_resend_email": config( "DRF_THROTTLE_AUTH_RESEND_EMAIL", default="3/min", cast=str ), "auth_reset_password": config( "DRF_THROTTLE_AUTH_RESET_PASSWORD", default="3/min", cast=str ), - "token_obtain": config( - "DRF_THROTTLE_TOKEN_OBTAIN", default="10/min", cast=str - ), + "token_obtain": config("DRF_THROTTLE_TOKEN_OBTAIN", default="10/min", cast=str), "program_register_new": config( "DRF_THROTTLE_PROGRAM_REGISTER_NEW", default="10/min", cast=str ), diff --git a/procollab/tests/test_nextgen_surface.py b/procollab/tests/test_nextgen_surface.py new file mode 100644 index 00000000..fbd3cf81 --- /dev/null +++ b/procollab/tests/test_nextgen_surface.py @@ -0,0 +1,151 @@ +from unittest import skipIf, skipUnless + +from django.conf import settings +from django.contrib import admin +from django.test import SimpleTestCase, TestCase +from django.urls import Resolver404, resolve + +from partner_programs.models import ( + Application, + Evaluation, + EvaluationAmendment, + EvaluationScore, + Submission, + SubmissionExpertAssignment, + Team, + TeamInvite, + TeamMember, +) +from partner_programs.tests.helpers import create_user + + +NEXTGEN_MODELS = ( + Application, + Team, + TeamMember, + TeamInvite, + Submission, + SubmissionExpertAssignment, + Evaluation, + EvaluationScore, + EvaluationAmendment, +) + +NEXTGEN_URLS = ( + "/applications/1/", + "/submissions/1/", + "/evaluations/1/", + "/expert/submissions/", + "/submission-assignments/1/revoke/", + "/team-invites/my/", + "/programs/managed/", + "/programs/1/manager-overview/", + "/programs/1/evaluations/", + "/programs/1/submission-assignments/", + "/programs/1/applications/", + "/programs/1/applications/my/", + "/projects/catalog/", + "/projects/my/", + "/projects/subscribed/", + "/projects/workspace/", + "/projects/workspace/invitations/incoming/", + "/projects/workspace/invitations/1/accept/", + "/projects/workspace/invitations/1/decline/", + "/projects/1/workspace/", + "/projects/1/workspace/invitations/", + "/projects/1/workspace/invitations/candidates/", + "/projects/1/workspace/invitations/1/revoke/", + "/projects/1/workspace/subscription/", + "/projects/1/workspace/goals/", + "/projects/1/workspace/goals/1/", + "/projects/1/workspace/achievements/", + "/projects/1/workspace/achievements/1/", + "/feed/news/", + "/feed/news/1/", + "/feed/news/1/set-liked/", + "/feed/news/1/set-viewed/", + "/feed/news/1/comments/", + "/feed/news/1/comments/1/", + "/auth/profiles/", + "/auth/profiles/1/", +) + +LEGACY_URLS = ( + "/programs/", + "/programs/1/", + "/programs/1/project-analytics/", + "/programs/1/project-analytics/assignments/", + "/programs/1/project-analytics/assignments/1/scores/", + "/programs/partner-program-projects/1/fields/", + "/programs/partner-program-projects/1/submit/", + "/programs/1/projects/", + "/programs/1/projects/filter/", + "/programs/1/projects/apply/", + "/programs/1/filters/", + "/programs/1/news/", + "/projects/1/", + "/projects/1/news/", + "/projects/1/collaborators/", + "/invites/", + "/invites/1/accept/", + "/vacancies/", + "/vacancies/1/responses/", + "/rate-project/1", + "/rate-project/rate/1", + "/courses/", + "/chats/directs/", + "/feed/", + "/auth/users/current/", + "/auth/users/1/news/", + "/notifications/", + "/notifications/unread-count/", + "/notifications/read-all/", + "/notifications/1/read/", +) + + +@skipIf( + settings.NEXTGEN_SURFACE_ENABLED, + "Feature-off contract runs in a process with NEXTGEN_SURFACE_ENABLED=False.", +) +class NextgenSurfaceDisabledTests(TestCase): + def test_nextgen_routes_do_not_resolve_and_return_404(self): + for url in NEXTGEN_URLS: + with self.subTest(url=url): + with self.assertRaises(Resolver404): + resolve(url) + self.assertEqual(self.client.get(url, secure=True).status_code, 404) + + def test_legacy_and_notification_routes_still_resolve(self): + for url in LEGACY_URLS: + with self.subTest(url=url): + self.assertIsNotNone(resolve(url)) + + def test_nextgen_models_are_not_registered_in_admin(self): + self.client.force_login( + create_user(is_staff=True, is_superuser=True, is_active=True) + ) + + for model in NEXTGEN_MODELS: + with self.subTest(model=model.__name__): + self.assertNotIn(model, admin.site._registry) + response = self.client.get( + f"/admin/{model._meta.app_label}/{model._meta.model_name}/" + ) + self.assertEqual(response.status_code, 404) + + +@skipUnless( + settings.NEXTGEN_SURFACE_ENABLED, + "Feature-on contract runs in a process with NEXTGEN_SURFACE_ENABLED=True.", +) +class NextgenSurfaceEnabledTests(SimpleTestCase): + def test_nextgen_routes_resolve(self): + for url in NEXTGEN_URLS: + with self.subTest(url=url): + self.assertIsNotNone(resolve(url)) + + def test_nextgen_models_are_registered_in_admin(self): + for model in NEXTGEN_MODELS: + with self.subTest(model=model.__name__): + self.assertIn(model, admin.site._registry) diff --git a/procollab/urls.py b/procollab/urls.py index 3fdc1d2e..bd57fe74 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -58,31 +58,6 @@ # отключены. Для возврата модуля раскомментировать строку ниже. # path("events/", include("events.urls", namespace="events")), path("programs/", include("partner_programs.urls", namespace="partner_programs")), - path( - "applications/", - include("partner_programs.application_urls", namespace="applications"), - ), - path( - "submissions/", - include("partner_programs.submission_urls", namespace="submissions"), - ), - path( - "evaluations/", - include("partner_programs.evaluation_urls", namespace="evaluations"), - ), - path( - "expert/", - include("partner_programs.expert_urls", namespace="expert_submissions"), - ), - path( - "submission-assignments//revoke/", - SubmissionAssignmentRevokeView.as_view(), - name="submission-assignment-revoke", - ), - path( - "team-invites/", - include("partner_programs.team_invite_urls", namespace="team_invites"), - ), path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")), @@ -101,6 +76,35 @@ path("anymail/", include("anymail.urls")), ] +if settings.NEXTGEN_SURFACE_ENABLED: + urlpatterns += [ + path( + "applications/", + include("partner_programs.application_urls", namespace="applications"), + ), + path( + "submissions/", + include("partner_programs.submission_urls", namespace="submissions"), + ), + path( + "evaluations/", + include("partner_programs.evaluation_urls", namespace="evaluations"), + ), + path( + "expert/", + include("partner_programs.expert_urls", namespace="expert_submissions"), + ), + path( + "submission-assignments//revoke/", + SubmissionAssignmentRevokeView.as_view(), + name="submission-assignment-revoke", + ), + path( + "team-invites/", + include("partner_programs.team_invite_urls", namespace="team_invites"), + ), + ] + if settings.DEBUG: import debug_toolbar diff --git a/projects/urls.py b/projects/urls.py index eac4b9b8..2fa8002f 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.urls import path from invites.workspace_views import ( @@ -80,78 +81,6 @@ ) urlpatterns = [ path("", ProjectList.as_view()), - path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), - path("my/", MyProjectsView.as_view(), name="workspace-my"), - path( - "subscribed/", - SubscribedProjectsView.as_view(), - name="workspace-subscribed", - ), - path( - "workspace/", - ProjectWorkspaceCreateView.as_view(), - name="workspace-create", - ), - path( - "workspace/invitations/incoming/", - IncomingProjectInvitationListView.as_view(), - name="workspace-invitations-incoming", - ), - path( - "workspace/invitations//accept/", - ProjectInvitationAcceptView.as_view(), - name="workspace-invitation-accept", - ), - path( - "workspace/invitations//decline/", - ProjectInvitationDeclineView.as_view(), - name="workspace-invitation-decline", - ), - path( - "/workspace/", - ProjectWorkspaceDetailView.as_view(), - name="workspace-detail", - ), - path( - "/workspace/invitations/", - ProjectInvitationListCreateView.as_view(), - name="workspace-invitations", - ), - path( - "/workspace/subscription/", - ProjectWorkspaceSubscriptionView.as_view(), - name="workspace-subscription", - ), - path( - "/workspace/invitations/candidates/", - ProjectInvitationCandidateSearchView.as_view(), - name="workspace-invitation-candidates", - ), - path( - "/workspace/invitations//revoke/", - ProjectInvitationRevokeView.as_view(), - name="workspace-invitation-revoke", - ), - path( - "/workspace/goals/", - ProjectWorkspaceGoalListView.as_view(), - name="workspace-goals", - ), - path( - "/workspace/goals//", - ProjectWorkspaceGoalDetailView.as_view(), - name="workspace-goal-detail", - ), - path( - "/workspace/achievements/", - ProjectWorkspaceAchievementListView.as_view(), - name="workspace-achievements", - ), - path( - "/workspace/achievements//", - ProjectWorkspaceAchievementDetailView.as_view(), - name="workspace-achievement-detail", - ), path("/like/", SetLikeOnProject.as_view()), path("/news/", NewsList.as_view()), path("/subscribe/", ProjectSubscribe.as_view()), @@ -210,3 +139,79 @@ path("achievements//", AchievementDetail.as_view()), path("/responses/", ProjectVacancyResponses.as_view()), ] + +if settings.NEXTGEN_SURFACE_ENABLED: + urlpatterns += [ + path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), + path("my/", MyProjectsView.as_view(), name="workspace-my"), + path( + "subscribed/", + SubscribedProjectsView.as_view(), + name="workspace-subscribed", + ), + path( + "workspace/", + ProjectWorkspaceCreateView.as_view(), + name="workspace-create", + ), + path( + "workspace/invitations/incoming/", + IncomingProjectInvitationListView.as_view(), + name="workspace-invitations-incoming", + ), + path( + "workspace/invitations//accept/", + ProjectInvitationAcceptView.as_view(), + name="workspace-invitation-accept", + ), + path( + "workspace/invitations//decline/", + ProjectInvitationDeclineView.as_view(), + name="workspace-invitation-decline", + ), + path( + "/workspace/", + ProjectWorkspaceDetailView.as_view(), + name="workspace-detail", + ), + path( + "/workspace/invitations/", + ProjectInvitationListCreateView.as_view(), + name="workspace-invitations", + ), + path( + "/workspace/subscription/", + ProjectWorkspaceSubscriptionView.as_view(), + name="workspace-subscription", + ), + path( + "/workspace/invitations/candidates/", + ProjectInvitationCandidateSearchView.as_view(), + name="workspace-invitation-candidates", + ), + path( + "/workspace/invitations//revoke/", + ProjectInvitationRevokeView.as_view(), + name="workspace-invitation-revoke", + ), + path( + "/workspace/goals/", + ProjectWorkspaceGoalListView.as_view(), + name="workspace-goals", + ), + path( + "/workspace/goals//", + ProjectWorkspaceGoalDetailView.as_view(), + name="workspace-goal-detail", + ), + path( + "/workspace/achievements/", + ProjectWorkspaceAchievementListView.as_view(), + name="workspace-achievements", + ), + path( + "/workspace/achievements//", + ProjectWorkspaceAchievementDetailView.as_view(), + name="workspace-achievement-detail", + ), + ] diff --git a/scripts/deploy_react_dev.sh b/scripts/deploy_react_dev.sh index eb6a57b4..73de63e8 100755 --- a/scripts/deploy_react_dev.sh +++ b/scripts/deploy_react_dev.sh @@ -9,6 +9,7 @@ readonly EXPECTED_COMPOSE_PROJECT="api-react" readonly CELERY_SERVICE="celerys" readonly HEALTH_URL="https://api-react-dev.procollab.ru/programs/?limit=1" readonly LOCK_FILE="${DEPLOY_DIR}/.react-dev-deploy.lock" +readonly NEXTGEN_COMPOSE_OVERRIDE="${DEPLOY_DIR}/.react-dev-runtime/nextgen-surface.compose.yml" readonly LOCK_TIMEOUT_SECONDS=10 readonly SERVICE_WAIT_ATTEMPTS=30 readonly SERVICE_WAIT_DELAY_SECONDS=2 @@ -48,6 +49,25 @@ fail() { return 1 } +write_nextgen_compose_override() { + local override_dir="${NEXTGEN_COMPOSE_OVERRIDE%/*}" + local temporary_file + + install -d -m 700 "$override_dir" + temporary_file="$(mktemp "${override_dir}/nextgen-surface.XXXXXX")" + cat > "$temporary_file" <<'YAML' +services: + web: + environment: + NEXTGEN_SURFACE_ENABLED: "True" + celerys: + environment: + NEXTGEN_SURFACE_ENABLED: "True" +YAML + chmod 600 "$temporary_file" + mv -f -- "$temporary_file" "$NEXTGEN_COMPOSE_OVERRIDE" +} + cleanup_health_file() { if [[ -n "$HEALTH_BODY_FILE" && -f "$HEALTH_BODY_FILE" ]]; then rm -f -- "$HEALTH_BODY_FILE" @@ -137,6 +157,45 @@ print(image) result_variable="$output" } +compose_service_environment_value() { + local service="$1" + local setting_name="$2" + local -n result_variable="$3" + local output="" + + output="$( + "${COMPOSE_CMD[@]}" config --format json | + docker run \ + --rm \ + --interactive \ + --entrypoint python \ + "$PREVIOUS_WEB_IMAGE_ID" \ + -c ' +import json +import sys + +service, setting_name = sys.argv[1:] +config = json.load(sys.stdin) +environment = config.get("services", {}).get(service, {}).get("environment", {}) +if not isinstance(environment, dict): + raise SystemExit(f"Environment is invalid for service {service}") +value = environment.get(setting_name) +if value is None: + raise SystemExit(f"{setting_name} is missing for service {service}") +print(value) +' \ + "$service" \ + "$setting_name" + )" + if [[ -z "$output" || "$output" == *$'\n'* ]]; then + printf '[react-dev-deploy] Service %s has invalid %s value.\n' \ + "$service" "$setting_name" >&2 + return 1 + fi + + result_variable="$output" +} + wait_for_service_running() { local service="$1" local attempt @@ -336,6 +395,8 @@ if ! flock --wait "$LOCK_TIMEOUT_SECONDS" 9; then fail "Другой React-dev deploy уже выполняется; lock не получен." fi +write_nextgen_compose_override + origin_url="$(git remote get-url origin)" case "$origin_url" in "https://github.com/${EXPECTED_REPOSITORY}" | \ @@ -397,7 +458,9 @@ if [[ "$compose_working_dir" != "$DEPLOY_DIR" ]]; then fi legacy_compose_file="$(realpath -m "${DEPLOY_DIR}/docker-compose.yml")" +nextgen_compose_override="$(realpath -e "$NEXTGEN_COMPOSE_OVERRIDE")" compose_config_files=() +nextgen_override_found=false IFS=',' read -r -a raw_compose_config_files <<< "$compose_config_files_label" for raw_config_file in "${raw_compose_config_files[@]}"; do config_file="${raw_config_file#"${raw_config_file%%[![:space:]]*}"}" @@ -415,11 +478,17 @@ for raw_config_file in "${raw_compose_config_files[@]}"; do if [[ "$config_file" == "$legacy_compose_file" ]]; then fail "Repository legacy docker-compose.yml запрещен для React-dev deploy." fi + if [[ "$config_file" == "$nextgen_compose_override" ]]; then + nextgen_override_found=true + fi compose_config_files+=("$config_file") done if ((${#compose_config_files[@]} == 0)); then fail "Compose config files не обнаружены." fi +if [[ "$nextgen_override_found" != true ]]; then + compose_config_files+=("$nextgen_compose_override") +fi COMPOSE_CMD=( docker compose @@ -491,6 +560,16 @@ if [[ "$configured_web_image_ref" != "$PREVIOUS_WEB_IMAGE_REF" ]] || [[ "$configured_celery_image_ref" != "$PREVIOUS_CELERY_IMAGE_REF" ]]; then fail "Running image references не совпадают с текущей Compose-конфигурацией." fi +for service in web "$CELERY_SERVICE"; do + nextgen_surface_enabled="" + compose_service_environment_value \ + "$service" \ + NEXTGEN_SURFACE_ENABLED \ + nextgen_surface_enabled + if [[ "$nextgen_surface_enabled" != "True" ]]; then + fail "NEXTGEN_SURFACE_ENABLED должен быть True для React-dev service ${service}." + fi +done git fetch origin master --prune if ! git cat-file -e "${DEPLOY_SHA}^{commit}"; then @@ -595,5 +674,6 @@ printf '%s\n' \ "COMPOSE_PROJECT=${COMPOSE_PROJECT}" \ "WEB_STATUS=${web_status}" \ "CELERY_STATUS=${celery_status}" \ + "NEXTGEN_SURFACE_ENABLED=True" \ "MIGRATION_COMPLETED=${MIGRATION_COMPLETED}" \ "HEALTH_CHECK_COMPLETED=true" diff --git a/users/urls.py b/users/urls.py index b6fec4bc..af545d5d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,4 +1,5 @@ -from django.urls import path, re_path, include +from django.conf import settings +from django.urls import include, path, re_path from news.views import NewsList, NewsDetail, NewsDetailSetViewed, NewsDetailSetLiked from users.views import ( @@ -36,17 +37,11 @@ app_name = "users" urlpatterns = [ - path("profiles/", PublicProfileListView.as_view(), name="public-profile-list"), - path( - "profiles//", - PublicProfileDetailView.as_view(), - name="public-profile-detail", - ), path( "specialists/", SpecialistsList.as_view() ), # this url actually returns mentors, experts and investors path("users/", UserList.as_view()), - path('public-users/', PublicUserListView.as_view(), name='public-users'), + path("public-users/", PublicUserListView.as_view(), name="public-users"), path("users/projects/", UserProjectsList.as_view()), path("users/projects/leader/", UserLeaderProjectsList.as_view()), path("users/liked/", LikedProjectList.as_view()), @@ -64,7 +59,10 @@ path("users//news//", NewsDetail.as_view()), path("users//news//set_viewed/", NewsDetailSetViewed.as_view()), path("users//news//set_liked/", NewsDetailSetLiked.as_view()), - path("users//approve_skill//", UserSkillsApproveDeclineView.as_view()), + path( + "users//approve_skill//", + UserSkillsApproveDeclineView.as_view(), + ), path("users/current/", CurrentUser.as_view()), path( "users/current/acknowledge-verification-notice/", @@ -103,3 +101,13 @@ include("users.password_reset_urls", namespace="password_reset"), ), ] + +if settings.NEXTGEN_SURFACE_ENABLED: + urlpatterns += [ + path("profiles/", PublicProfileListView.as_view(), name="public-profile-list"), + path( + "profiles//", + PublicProfileDetailView.as_view(), + name="public-profile-detail", + ), + ] From 55c2e93d96e45568dc50062d841521cc7ba75fbf Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 11 Sep 2026 09:31:53 +0300 Subject: [PATCH 2/2] Hide next-gen program policy when disabled --- partner_programs/admin.py | 28 ++++++++ partner_programs/serializers/programs.py | 7 ++ .../tests/test_program_admin_surface.py | 64 +++++++++++++++++++ .../test_program_application_policy_api.py | 35 +++++++++- ...est_program_current_project_application.py | 6 +- 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 partner_programs/tests/test_program_admin_surface.py diff --git a/partner_programs/admin.py b/partner_programs/admin.py index a184507a..ed737396 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -555,6 +555,34 @@ class Meta: ("Служебная информация", {"fields": ("datetime_created", "datetime_updated")}), ) + def get_list_display(self, request): + list_display = super().get_list_display(request) + if settings.NEXTGEN_SURFACE_ENABLED: + return list_display + return tuple(field for field in list_display if field != "participation_format") + + def get_list_display_links(self, request, list_display): + list_display_links = super().get_list_display_links(request, list_display) + if settings.NEXTGEN_SURFACE_ENABLED or not list_display_links: + return list_display_links + return tuple( + field for field in list_display_links if field != "participation_format" + ) + + def get_list_filter(self, request): + list_filter = super().get_list_filter(request) + if settings.NEXTGEN_SURFACE_ENABLED: + return list_filter + return tuple(field for field in list_filter if field != "participation_format") + + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj) + if settings.NEXTGEN_SURFACE_ENABLED: + return fieldsets + return tuple( + fieldset for fieldset in fieldsets if fieldset[0] != "Участие и заявки" + ) + def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: qs = ( super() diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 95929889..1a5f03f6 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth import get_user_model from rest_framework import serializers @@ -135,6 +136,12 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): read_only=True, ) + def get_fields(self): + fields = super().get_fields() + if not settings.NEXTGEN_SURFACE_ENABLED: + fields.pop("application_policy", None) + return fields + def get_materials(self, program: PartnerProgram): materials = program.materials.all() return PartnerProgramMaterialSerializer(materials, many=True).data diff --git a/partner_programs/tests/test_program_admin_surface.py b/partner_programs/tests/test_program_admin_surface.py new file mode 100644 index 00000000..19f56da0 --- /dev/null +++ b/partner_programs/tests/test_program_admin_surface.py @@ -0,0 +1,64 @@ +from django.contrib import admin +from django.test import RequestFactory, SimpleTestCase, override_settings + +from partner_programs.admin import PartnerProgramAdmin +from partner_programs.models import PartnerProgram + + +NEXTGEN_PROGRAM_FIELDS = { + "participation_format", + "team_min_size", + "team_max_size", + "datetime_application_ends", +} + + +class PartnerProgramAdminSurfaceTests(SimpleTestCase): + def setUp(self): + self.model_admin = PartnerProgramAdmin(PartnerProgram, admin.AdminSite()) + self.request = RequestFactory().get("/admin/partner_programs/partnerprogram/") + + def get_runtime_configuration(self): + list_display = tuple(self.model_admin.get_list_display(self.request)) + list_display_links = tuple( + self.model_admin.get_list_display_links(self.request, list_display) or () + ) + list_filter = tuple(self.model_admin.get_list_filter(self.request)) + fieldsets = self.model_admin.get_fieldsets(self.request) + fieldset_titles = {title for title, _options in fieldsets} + fieldset_fields = set() + for _title, options in fieldsets: + for field in options["fields"]: + if isinstance(field, (list, tuple)): + fieldset_fields.update(field) + else: + fieldset_fields.add(field) + return { + "list_display": list_display, + "list_display_links": list_display_links, + "list_filter": list_filter, + "fieldset_titles": fieldset_titles, + "fieldset_fields": fieldset_fields, + } + + @override_settings(NEXTGEN_SURFACE_ENABLED=False) + def test_nextgen_program_fields_are_hidden_when_surface_is_disabled(self): + configuration = self.get_runtime_configuration() + + self.assertNotIn("participation_format", configuration["list_display"]) + self.assertNotIn("participation_format", configuration["list_display_links"]) + self.assertNotIn("participation_format", configuration["list_filter"]) + self.assertNotIn("Участие и заявки", configuration["fieldset_titles"]) + self.assertTrue( + NEXTGEN_PROGRAM_FIELDS.isdisjoint(configuration["fieldset_fields"]) + ) + + @override_settings(NEXTGEN_SURFACE_ENABLED=True) + def test_nextgen_program_fields_are_visible_when_surface_is_enabled(self): + configuration = self.get_runtime_configuration() + + self.assertIn("participation_format", configuration["list_display"]) + self.assertIn("participation_format", configuration["list_display_links"]) + self.assertIn("participation_format", configuration["list_filter"]) + self.assertIn("Участие и заявки", configuration["fieldset_titles"]) + self.assertTrue(NEXTGEN_PROGRAM_FIELDS.issubset(configuration["fieldset_fields"])) diff --git a/partner_programs/tests/test_program_application_policy_api.py b/partner_programs/tests/test_program_application_policy_api.py index f7bd0aa8..6efea725 100644 --- a/partner_programs/tests/test_program_application_policy_api.py +++ b/partner_programs/tests/test_program_application_policy_api.py @@ -1,4 +1,4 @@ -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework.test import APIClient @@ -11,6 +11,7 @@ ) +@override_settings(NEXTGEN_SURFACE_ENABLED=True) class PartnerProgramApplicationPolicyAPITests(TestCase): def setUp(self): self.client = APIClient() @@ -121,3 +122,35 @@ def test_program_list_contract_does_not_include_application_policy(self): self.assertEqual(response.status_code, 200) self.assertNotIn("application_policy", response.data["results"][0]) + + +@override_settings(NEXTGEN_SURFACE_ENABLED=False) +class PartnerProgramApplicationPolicyDisabledAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def assert_legacy_detail_contract(self, response, *, is_member: bool): + self.assertEqual(response.status_code, 200) + self.assertNotIn("application_policy", response.data) + self.assertIs(response.data["is_user_member"], is_member) + self.assertIn("current_project_application", response.data) + self.assertIn("materials", response.data) + self.assertIn("courses", response.data) + + def test_non_member_detail_omits_application_policy(self): + program = create_partner_program() + self.client.force_authenticate(create_user(prefix="policy-program-outsider")) + + response = self.client.get(f"/programs/{program.pk}/") + + self.assert_legacy_detail_contract(response, is_member=False) + + def test_member_detail_omits_application_policy(self): + program = create_partner_program() + member = create_user(prefix="policy-program-member-off") + create_program_member(program, user=member) + self.client.force_authenticate(member) + + response = self.client.get(f"/programs/{program.pk}/") + + self.assert_legacy_detail_contract(response, is_member=True) diff --git a/partner_programs/tests/test_program_current_project_application.py b/partner_programs/tests/test_program_current_project_application.py index cf48db09..2b01c75d 100644 --- a/partner_programs/tests/test_program_current_project_application.py +++ b/partner_programs/tests/test_program_current_project_application.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from django.conf import settings from django.core.cache import cache from django.db import connection from django.test import TestCase @@ -275,7 +276,10 @@ def test_existing_production_detail_contract_is_preserved(self): }, {**expected, "is_user_member": is_member}, ) - self.assertIn("application_policy", response.data) + self.assertIs( + "application_policy" in response.data, + settings.NEXTGEN_SURFACE_ENABLED, + ) self.assertIs(response.data["is_user_manager"], is_member) self.assertEqual( response.data["materials"],