From b64986b357f770d3c1acaf572ca90a6c15c5af78 Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 7 Aug 2026 13:12:34 +0300 Subject: [PATCH] Add React dev industry reference data --- docs/react-dev-industry-reference-data.md | 41 ++++++ industries/management/__init__.py | 0 industries/management/commands/__init__.py | 0 .../commands/seed_react_dev_industries.py | 47 +++++++ industries/reference_data.py | 63 +++++++++ .../tests/test_react_dev_industry_seed.py | 131 ++++++++++++++++++ 6 files changed, 282 insertions(+) create mode 100644 docs/react-dev-industry-reference-data.md create mode 100644 industries/management/__init__.py create mode 100644 industries/management/commands/__init__.py create mode 100644 industries/management/commands/seed_react_dev_industries.py create mode 100644 industries/reference_data.py create mode 100644 industries/tests/test_react_dev_industry_seed.py diff --git a/docs/react-dev-industry-reference-data.md b/docs/react-dev-industry-reference-data.md new file mode 100644 index 00000000..f1084fd9 --- /dev/null +++ b/docs/react-dev-industry-reference-data.md @@ -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//workspace/`; +3. повторно открыть workspace detail и проверить сохранённую отрасль; +4. заполнить остальные обязательные поля и отправить `draft=false`, `is_public=true`; +5. убедиться, что опубликованный Project сохранил тот же `industry`. diff --git a/industries/management/__init__.py b/industries/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/management/commands/__init__.py b/industries/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/management/commands/seed_react_dev_industries.py b/industries/management/commands/seed_react_dev_industries.py new file mode 100644 index 00000000..95677f45 --- /dev/null +++ b/industries/management/commands/seed_react_dev_industries.py @@ -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("Справочник отраслей готов.")) diff --git a/industries/reference_data.py b/industries/reference_data.py new file mode 100644 index 00000000..0a85327d --- /dev/null +++ b/industries/reference_data.py @@ -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), + ) diff --git a/industries/tests/test_react_dev_industry_seed.py b/industries/tests/test_react_dev_industry_seed.py new file mode 100644 index 00000000..9aca3183 --- /dev/null +++ b/industries/tests/test_react_dev_industry_seed.py @@ -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)