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
161 changes: 161 additions & 0 deletions .github/workflows/backend-postgres-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Roadmap: DEV-074
name: Backend PostgreSQL CI

on:
pull_request:
branches:
- master
push:
branches:
- master
workflow_dispatch:

permissions:
contents: read

concurrency:
group: backend-postgres-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
postgres-tests:
name: Backend PostgreSQL CI
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: procollab_ci
POSTGRES_USER: procollab_ci
POSTGRES_PASSWORD: procollab_ci_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U procollab_ci -d procollab_ci"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
DJANGO_SETTINGS_MODULE: procollab.settings_ci
DJANGO_SECRET_KEY: ci-only-not-production-secret
DEBUG: "False"
DATABASE_NAME: procollab_ci
DATABASE_USER: procollab_ci
DATABASE_PASSWORD: procollab_ci_password
DATABASE_HOST: 127.0.0.1
DATABASE_PORT: "5432"
ALLOW_REACT_DEV_DEMO_SEED: "False"
AUTOPOSTING_ON: "False"

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install "poetry==1.2.2"
poetry config virtualenvs.create true
poetry install --no-interaction --no-ansi --no-root

- name: Verify PostgreSQL connection and locking support
run: |
poetry run python - <<'PY'
import django

django.setup()

from django.db import connection

connection.ensure_connection()
assert connection.vendor == "postgresql", connection.vendor
assert connection.features.has_select_for_update
print(f"Database vendor: {connection.vendor}")
print(
"select_for_update support: "
f"{connection.features.has_select_for_update}"
)
PY

- name: Check Django configuration
run: poetry run python manage.py check

- name: Check Django models
run: poetry run python manage.py check --tag models

- name: Check migration consistency
run: poetry run python manage.py makemigrations --check --dry-run

- name: Apply migrations
run: poetry run python manage.py migrate --noinput

- name: Check CI settings formatting
run: poetry run black --check procollab/settings_ci.py

- name: Lint CI settings
run: poetry run flake8 procollab/settings_ci.py

- name: Run PostgreSQL locking and constraint tests
run: |
poetry run python manage.py test \
partner_programs.tests.test_expert_evaluation_api.ConcurrentEvaluationSubmitTests \
partner_programs.tests.test_application_team_models.ApplicationTeamDatabaseConstraintTests \
partner_programs.tests.test_application_team_service.ApplicationTeamServiceSequentialConflictTests \
partner_programs.tests.test_program_participation_policy.PartnerProgramParticipationPolicyConstraintTests \
partner_programs.tests.test_team_api \
partner_programs.tests.test_team_invite_api \
partner_programs.tests.test_team_invite_service \
partner_programs.tests.test_team_management_service \
--verbosity 2

- name: Run full backend test suite
run: poetry run python manage.py test --verbosity 1 --keepdb

- name: Remove retained PostgreSQL test database
if: always()
run: |
poetry run python - <<'PY'
import os

import psycopg2
from psycopg2 import sql

test_database = "test_procollab_ci"

connection = psycopg2.connect(
dbname=os.environ["DATABASE_NAME"],
user=os.environ["DATABASE_USER"],
password=os.environ["DATABASE_PASSWORD"],
host=os.environ["DATABASE_HOST"],
port=os.environ["DATABASE_PORT"],
)
connection.autocommit = True

try:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = %s
AND pid <> pg_backend_pid()
""",
[test_database],
)
cursor.execute(
sql.SQL("DROP DATABASE IF EXISTS {}").format(
sql.Identifier(test_database)
)
)
finally:
connection.close()
PY
122 changes: 122 additions & 0 deletions docs/backend-postgres-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Backend PostgreSQL CI

<!-- Roadmap: DEV-074 -->

Workflow `Backend PostgreSQL CI` проверяет backend на настоящем PostgreSQL, а не
на SQLite. Это важно для транзакционных блокировок, partial constraints и
поведения `select_for_update`, которые SQLite не воспроизводит.

## Когда запускается

Workflow запускается:

- для pull request в `master`;
- для push в `master`;
- вручную через `workflow_dispatch`.

Имя обязательной проверки: `Backend PostgreSQL CI`.

## Что проверяется

Job поднимает одноразовый PostgreSQL 15 и последовательно выполняет:

1. установку зависимостей из существующего `poetry.lock`;
2. preflight-проверку подключения, backend `postgresql` и поддержки
`select_for_update`;
3. `manage.py check`;
4. `manage.py check --tag models`;
5. `makemigrations --check --dry-run`;
6. применение всех миграций;
7. Black и flake8 для `procollab/settings_ci.py`;
8. targeted-тесты блокировок и ограничений Evaluation, Application и Team;
9. полный набор backend-тестов.

Любая ошибка останавливает job. Ослабляющие флаги, пропуск ошибок и fake-миграции
не используются.

Полный suite запускается с `--keepdb`, чтобы завершившиеся WebSocket- и
асинхронные тесты не мешали Django вернуть результат из-за удаления базы внутри
того же Python-процесса. Это не переиспользование базы между job: каждый job
получает новый PostgreSQL container, а тестовая база создается заново. После
остановки тестового процесса обязательный шаг с `if: always()` завершает
оставшиеся подключения и явно удаляет `test_procollab_ci`. Ошибки тестов при
этом не скрываются и сохраняют job красным.

Первый полный прогон на PostgreSQL выявил попытку блокировать nullable-сторону
outer join в team/invite-сервисах. Lock-query сохраняют `select_related` для
загрузки графа, но используют `select_for_update(of=("self",))`, чтобы
блокировать только строку основной модели. Порядок ключей JSONB не считается
контрактом: тесты проверяют состав и значения JSON-полей независимо от порядка.

## Изоляция

`procollab.settings_ci` подключается только через переменную
`DJANGO_SETTINGS_MODULE`. Он не меняет обычный `procollab.settings` и не
используется deployment-контурами.

В CI применяются:

- одноразовая база `procollab_ci` и тестовая база `test_procollab_ci`;
- `LocMemCache` вместо Redis;
- `InMemoryChannelLayer` вместо Redis Channels;
- локальный email backend;
- синхронное выполнение Celery-задач с пробросом исключений;
- быстрый MD5 password hasher;
- отключенный React dev demo seed.

Логин и пароль PostgreSQL находятся прямо в workflow, потому что относятся
только к одноразовому service container. Это не production/dev credentials и не
секреты инфраструктуры.

## Переменные окружения

Workflow задает:

- `DJANGO_SETTINGS_MODULE=procollab.settings_ci`;
- `DJANGO_SECRET_KEY` с тестовым значением;
- `DEBUG=False`;
- `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_HOST`,
`DATABASE_PORT`;
- `ALLOW_REACT_DEV_DEMO_SEED=False`;
- `AUTOPOSTING_ON=False`.

## Локальный запуск

Локальные проверки следует запускать только с отдельным одноразовым PostgreSQL.
Нельзя направлять `settings_ci` на существующую dev- или production-базу.

Пример для PowerShell после запуска отдельного PostgreSQL:

```powershell
$env:DJANGO_SETTINGS_MODULE = "procollab.settings_ci"
$env:DJANGO_SECRET_KEY = "local-ci-only-secret"
$env:DEBUG = "False"
$env:DATABASE_NAME = "procollab_ci"
$env:DATABASE_USER = "procollab_ci"
$env:DATABASE_PASSWORD = "procollab_ci_password"
$env:DATABASE_HOST = "127.0.0.1"
$env:DATABASE_PORT = "5432"
$env:ALLOW_REACT_DEV_DEMO_SEED = "False"
$env:AUTOPOSTING_ON = "False"

poetry run python manage.py check
poetry run python manage.py migrate --noinput
poetry run python manage.py test --verbosity 1
```

После проверки одноразовую базу или контейнер нужно удалить.

## Типовые ошибки

- `connection refused`: PostgreSQL еще не готов или указан неверный порт.
- `password authentication failed`: переменные пользователя и пароля не
совпадают с настройками одноразового PostgreSQL.
- `database does not exist`: не создана исходная база `procollab_ci`.
- `makemigrations --check` предлагает миграцию: модели и миграции расходятся.
- preflight сообщает не `postgresql`: выбран неверный settings module или
переопределены параметры базы.
- тест блокировок зависает или завершается по timeout: следует проверить порядок
транзакционных блокировок и конкурентные изменения, а не отключать тест.

Workflow не выполняет deployment, не подключается к dev/production-сервисам и не
запускает команды на существующих базах.
13 changes: 6 additions & 7 deletions mailing/tests/test_models_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,9 @@ def test_template_render_context_contains_schema_users_and_fields(self):
self.assertTrue(context["picked_users"][0]["picked"])
self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id)
self.assertFalse(context["unpicked_users"][0]["picked"])
self.assertEqual(
context["template_fields"],
[
{"key": "title", "title": "Title", "default": "Default title"},
{"key": "text", "title": "Text", "default": ""},
],
)
template_fields = {field["key"]: field for field in context["template_fields"]}
self.assertEqual(set(template_fields), {"title", "text"})
self.assertEqual(template_fields["title"]["title"], "Title")
self.assertEqual(template_fields["title"]["default"], "Default title")
self.assertEqual(template_fields["text"]["title"], "Text")
self.assertEqual(template_fields["text"]["default"], "")
Loading
Loading