Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/backend-postgres-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/workflows/release-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ jobs:
run: poetry run python manage.py test
env:
DEBUG: True
NEXTGEN_SURFACE_ENABLED: True

build:
name: Build Image
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ celerybeat.pid
# Environments
.env
.venv
/.react-dev-runtime/
env/
venv/
ENV/
Expand Down
26 changes: 18 additions & 8 deletions docs/backend-react-dev-autodeploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 на
Expand Down
15 changes: 13 additions & 2 deletions docs/react-dev-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## Правила разбора

Expand All @@ -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 при запуске.
57 changes: 31 additions & 26 deletions feed/urls.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.conf import settings
from django.urls import path

from feed.views import NewSimpleFeed
Expand All @@ -14,30 +15,34 @@

urlpatterns = [
path("", NewSimpleFeed.as_view()),
path("news/", ReactNewsFeedListView.as_view(), name="react-news-list"),
path(
"news/<int:news_id>/",
ReactNewsFeedDetailView.as_view(),
name="react-news-detail",
),
path(
"news/<int:news_id>/set-liked/",
ReactNewsSetLikedView.as_view(),
name="react-news-set-liked",
),
path(
"news/<int:news_id>/set-viewed/",
ReactNewsSetViewedView.as_view(),
name="react-news-set-viewed",
),
path(
"news/<int:news_id>/comments/",
ReactNewsCommentListCreateView.as_view(),
name="react-news-comment-list",
),
path(
"news/<int:news_id>/comments/<int:comment_id>/",
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/<int:news_id>/",
ReactNewsFeedDetailView.as_view(),
name="react-news-detail",
),
path(
"news/<int:news_id>/set-liked/",
ReactNewsSetLikedView.as_view(),
name="react-news-set-liked",
),
path(
"news/<int:news_id>/set-viewed/",
ReactNewsSetViewedView.as_view(),
name="react-news-set-viewed",
),
path(
"news/<int:news_id>/comments/",
ReactNewsCommentListCreateView.as_view(),
name="react-news-comment-list",
),
path(
"news/<int:news_id>/comments/<int:comment_id>/",
ReactNewsCommentDetailView.as_view(),
name="react-news-comment-detail",
),
]
13 changes: 11 additions & 2 deletions invites/tests/test_invite_api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 20 additions & 1 deletion invites/tests/test_invite_decisions.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
Expand All @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion invites/tests/test_invite_detail_access.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
18 changes: 17 additions & 1 deletion invites/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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()
Expand Down
Loading
Loading