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
41 changes: 41 additions & 0 deletions docs/react-dev-industry-reference-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Справочник отраслей React-dev

React-форма проекта загружает отрасли через `GET /industries/`. Endpoint возвращает
непагинированный массив объектов `id`, `name` и `datetime_created` непосредственно из
таблицы `industries_industry`.

Изолированная React-dev база создаётся отдельно от legacy dev и production. Schema
migration приложения `industries` создаёт таблицу, но исторически не добавляет в неё
справочные строки. Поэтому новая база может корректно отвечать `200 []`, а обязательное
поле отрасли в форме публикации останется без вариантов.

## Безопасное заполнение React-dev

Команда создаёт только отсутствующие названия из действующего legacy-справочника. Она
не удаляет, не переименовывает и не обновляет существующие строки, не фиксирует primary
key и безопасна для повторного запуска.

Перед любым изменением проверить план:

```console
python manage.py seed_react_dev_industries --confirm-react-dev --dry-run
```

После проверки выполнить в контейнере, подключённом именно к изолированной React-dev
базе:

```console
python manage.py seed_react_dev_industries --confirm-react-dev
```

Команда дополнительно требует `ALLOW_REACT_DEV_DEMO_SEED=True`. По умолчанию setting
равен `False`, поэтому запуск в production или legacy dev с обычной конфигурацией
завершается до записи данных. На production эту команду запускать нельзя.

После заполнения `GET /industries/` должен вернуть 27 элементов. Smoke-проверка проекта:

1. создать private draft через `POST /projects/workspace/`;
2. сохранить выбранный `industry` через `PATCH /projects/<id>/workspace/`;
3. повторно открыть workspace detail и проверить сохранённую отрасль;
4. заполнить остальные обязательные поля и отправить `draft=false`, `is_public=true`;
5. убедиться, что опубликованный Project сохранил тот же `industry`.
Empty file.
Empty file.
47 changes: 47 additions & 0 deletions industries/management/commands/seed_react_dev_industries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction

from industries.reference_data import ensure_react_dev_industries


class Command(BaseCommand):
help = "Заполнить обязательный справочник отраслей в изолированной React-dev базе."

def add_arguments(self, parser):
parser.add_argument(
"--confirm-react-dev",
action="store_true",
help="Подтвердить, что команда подключена к изолированной React-dev базе.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Показать результат и откатить созданные записи.",
)

def handle(self, *args, **options):
# Используем существующий закрытый по умолчанию guard, чтобы команда не могла
# случайно изменить production или legacy dev при обычной конфигурации.
if not settings.ALLOW_REACT_DEV_DEMO_SEED:
raise CommandError(
"Заполнение справочников React-dev отключено в настройках."
)
if not options["confirm_react_dev"]:
raise CommandError("Для запуска передайте флаг --confirm-react-dev.")

with transaction.atomic():
summary = ensure_react_dev_industries()
if options["dry_run"]:
transaction.set_rollback(True)

self.stdout.write(f"Создано отраслей: {summary.created}")
self.stdout.write(f"Уже существовало: {summary.existing}")
if options["dry_run"]:
self.stdout.write(
self.style.WARNING(
"Пробный запуск завершён; созданные записи не сохранены."
)
)
else:
self.stdout.write(self.style.SUCCESS("Справочник отраслей готов."))
63 changes: 63 additions & 0 deletions industries/reference_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from dataclasses import dataclass

from django.db import transaction

from industries.models import Industry


# Список повторяет действующий справочник legacy-контура; идентификаторы намеренно
# не фиксируются, потому что Project хранит локальный FK конкретной базы данных.
REACT_DEV_INDUSTRY_NAMES = (
"AR/VR",
"AgTech",
"CV/ML",
"Customer Services",
"Cybersecurity",
"Devices",
"E-auto & Taxi Services",
"E-commerce",
"EdTech",
"Entertainment Tech",
"FinTech",
"FoodTech",
"Games",
"GovTech",
"HRTech",
"InsurTech",
"InvestTech",
"LegalTech",
"Logistics",
"Mapping & Geolocation",
"MedTech",
"PropTech",
"RegTech",
"Smart Home Solutions",
"SportTech",
"Другое",
"Социальные проекты",
)


@dataclass(frozen=True)
class IndustryReferenceDataSummary:
created: int
existing: int


@transaction.atomic
def ensure_react_dev_industries() -> IndustryReferenceDataSummary:
"""Создаёт отсутствующие отрасли React-dev, не изменяя существующие записи."""
existing_names = set(
Industry.objects.filter(name__in=REACT_DEV_INDUSTRY_NAMES).values_list(
"name",
flat=True,
)
)
missing_names = [
name for name in REACT_DEV_INDUSTRY_NAMES if name not in existing_names
]
Industry.objects.bulk_create([Industry(name=name) for name in missing_names])
return IndustryReferenceDataSummary(
created=len(missing_names),
existing=len(existing_names),
)
131 changes: 131 additions & 0 deletions industries/tests/test_react_dev_industry_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import io

from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from rest_framework.test import APIClient

from industries.models import Industry
from industries.reference_data import REACT_DEV_INDUSTRY_NAMES
from industries.tests.helpers import create_user
from projects.models import Project


class ReactDevIndustrySeedTests(TestCase):
def run_seed(self, *extra_args, confirm=True):
args = list(extra_args)
if confirm:
args.append("--confirm-react-dev")
stdout = io.StringIO()
with override_settings(ALLOW_REACT_DEV_DEMO_SEED=True):
call_command("seed_react_dev_industries", *args, stdout=stdout)
return stdout.getvalue()

def test_seed_is_disabled_by_default_before_any_write(self):
with self.assertRaisesMessage(CommandError, "отключено"):
call_command(
"seed_react_dev_industries",
"--confirm-react-dev",
)

self.assertFalse(Industry.objects.exists())

def test_seed_requires_explicit_react_dev_confirmation(self):
with self.assertRaisesMessage(CommandError, "--confirm-react-dev"):
self.run_seed(confirm=False)

self.assertFalse(Industry.objects.exists())

def test_seed_creates_complete_reference_and_preserves_other_rows(self):
custom = Industry.objects.create(name="Локальная тестовая отрасль")

output = self.run_seed()

self.assertEqual(
set(
Industry.objects.filter(name__in=REACT_DEV_INDUSTRY_NAMES).values_list(
"name",
flat=True,
)
),
set(REACT_DEV_INDUSTRY_NAMES),
)
self.assertTrue(Industry.objects.filter(pk=custom.pk).exists())
self.assertIn(f"Создано отраслей: {len(REACT_DEV_INDUSTRY_NAMES)}", output)

def test_repeated_seed_does_not_create_duplicates(self):
self.run_seed()
first_count = Industry.objects.count()

output = self.run_seed()

self.assertEqual(Industry.objects.count(), first_count)
self.assertIn("Создано отраслей: 0", output)
self.assertIn(f"Уже существовало: {first_count}", output)

def test_dry_run_rolls_back_created_industries(self):
output = self.run_seed("--dry-run")

self.assertFalse(Industry.objects.exists())
self.assertIn("созданные записи не сохранены", output)


class ReactDevIndustryProjectLifecycleTests(TestCase):
def setUp(self):
with override_settings(ALLOW_REACT_DEV_DEMO_SEED=True):
call_command(
"seed_react_dev_industries",
"--confirm-react-dev",
stdout=io.StringIO(),
)
self.user = create_user(prefix="react-dev-industry-project")
self.client = APIClient()
self.client.force_authenticate(self.user)

def test_seeded_industry_supports_draft_edit_and_publication(self):
industries_response = self.client.get("/industries/")
self.assertEqual(industries_response.status_code, 200)
self.assertEqual(len(industries_response.data), len(REACT_DEV_INDUSTRY_NAMES))
industry_id = industries_response.data[0]["id"]

create_response = self.client.post("/projects/workspace/", {}, format="json")
self.assertEqual(create_response.status_code, 201)
project_id = create_response.data["id"]

draft_response = self.client.patch(
f"/projects/{project_id}/workspace/",
{
"name": "Черновик проекта",
"industry": industry_id,
"draft": True,
"is_public": False,
},
format="json",
)
self.assertEqual(draft_response.status_code, 200)
self.assertEqual(draft_response.data["industry"]["id"], industry_id)

edit_response = self.client.get(f"/projects/{project_id}/workspace/")
self.assertEqual(edit_response.status_code, 200)
self.assertEqual(edit_response.data["industry"]["id"], industry_id)

publish_response = self.client.patch(
f"/projects/{project_id}/workspace/",
{
"region": "Москва",
"description": "Описание проекта",
"problem": "Описание проблемы",
"target_audience": "Целевая аудитория",
"cover_image_address": "https://example.com/project-cover.png",
"draft": False,
"is_public": True,
},
format="json",
)
self.assertEqual(publish_response.status_code, 200)
self.assertEqual(publish_response.data["industry"]["id"], industry_id)

project = Project.objects.get(pk=project_id)
self.assertEqual(project.industry_id, industry_id)
self.assertFalse(project.draft)
self.assertTrue(project.is_public)
Loading