🔥 chore(seed): remove the Edevs seed contour

Исполнение ADR-HUB-0028 §«Разделение bootstrap-данных»: в облачной модели
арендатора наполняют provisioning и продуктовые API, а не Edevs-specific
команды. Контур был замкнутым островом — единственная точка входа
seed_hub_initial_data, которую не вызывали ни CI, ни compose, ни deploy,
а CLI-тест прямо проверял, что init её не запускает.

Удалены seed_hub_initial_data, _seed_specs, seed_channels, seed_catalog,
seed_orders, seed_support и импортер AI-контента. bootstrap_owner остаётся:
на нём держится тест-сьют. Файлы content/ai-content-*.md сохранены.

Для SPEC-HUB-0027 это снимает половину проблемы этапа 3: унаследованных
seed-каналов, нарушающих P1-P2, больше нет — остаются только дефолты
модели Channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-07-19 23:45:37 +03:00
1 parent 11ad4f8eb3
commit f85883dff2
16 files changed
+6 -1014

No files matched your search

@@ -1,170 +0,0 @@
"""Импорт AI-контента из content/*.md в Знания и агентов (ADR-HUB-0023).
Файлы контента размечены секциями `=== code ===` (SPEC-HUB-0012):
- prompt-секции (system-*, qualify-*, sales-*, handoff-*) заполняют пустые поля
persona/instructions агента соответствующего канала — уже заполненные поля
не перезаписываются (владелец правит их через UI);
- остальные секции становятся Знаниями (upsert по заголовку = коду секции)
и добавляются в выбор знаний агентов: глобальные — всем каналам,
продуктовые — каналам своего продукта.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from django.db import transaction
from hub_platform.ai import indexing
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import Knowledge
from hub_platform.tenancy.context import TenantContext
_SECTION_RE = re.compile(r"^===\s*(?P<code>[a-z0-9-]+)\s*===\s*$", re.MULTILINE)
_PROMPT_PREFIXES = ("system", "qualify", "sales", "handoff")
# Суффикс prompt-секции -> код продукта канала (None — непродуктовый канал edevs).
_PRODUCT_BY_SUFFIX = {"edevs": None, "foxray": "foxray", "firepage": "firepage"}
# Префикс knowledge-секции -> код продукта (нет префикса в карте — глобальное знание).
_PRODUCT_BY_PREFIX = {"foxray": "foxray", "firepage": "firepage"}
CONTENT_SOURCES = (
"content/ai-content-company-filled.md",
"content/ai-content-firepage-filled.md",
"content/ai-content-foxray-filled.md",
)
@dataclass(frozen=True)
class ImportResult:
knowledge_created: int = 0
knowledge_updated: int = 0
fragments_created: int = 0
agents_updated: int = 0
skipped_sections: int = 0
def add(self, other: "ImportResult") -> "ImportResult":
return ImportResult(
knowledge_created=self.knowledge_created + other.knowledge_created,
knowledge_updated=self.knowledge_updated + other.knowledge_updated,
fragments_created=self.fragments_created + other.fragments_created,
agents_updated=self.agents_updated + other.agents_updated,
skipped_sections=self.skipped_sections + other.skipped_sections,
)
def parse_filled_content(text: str) -> dict[str, str]:
matches = list(_SECTION_RE.finditer(text))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
code = match.group("code")
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
content = text[start:end].strip()
if content:
sections[code] = content
return sections
def _import_knowledge(*, organization, code: str, content: str) -> tuple[Knowledge, ImportResult]:
knowledge = Knowledge.objects.filter(organization=organization, title=code).first()
if knowledge is None:
knowledge = Knowledge.objects.create(
organization=organization,
category=ensure_uncategorized_category(organization),
title=code,
content=content,
)
fragments = indexing.reindex_knowledge(knowledge)
return knowledge, ImportResult(knowledge_created=1, fragments_created=len(fragments))
if knowledge.content == content:
return knowledge, ImportResult()
knowledge.content = content
knowledge.save(update_fields=["content", "updated_at"])
fragments = indexing.reindex_knowledge(knowledge)
return knowledge, ImportResult(knowledge_updated=1, fragments_created=len(fragments))
def _agents_for_product(*, organization, product_code: str | None):
from hub_platform.ai.models import AIAgent
queryset = AIAgent.objects.select_related("channel").filter(channel__organization=organization)
if product_code is None:
return queryset.filter(channel__product__isnull=True)
return queryset.filter(channel__product__code=product_code)
def _apply_prompts(*, organization, prompts: dict[tuple[str | None, str], str]) -> int:
"""prompts: (product_code|None, kind) -> content. Заполняет только пустые поля."""
updated = 0
product_codes = {key[0] for key in prompts}
for product_code in product_codes:
persona = prompts.get((product_code, "system"), "")
instruction_parts = [
prompts[(product_code, kind)]
for kind in ("qualify", "sales", "handoff")
if (product_code, kind) in prompts
]
instructions = "\n\n".join(instruction_parts)
for agent in _agents_for_product(organization=organization, product_code=product_code):
fields = []
if persona and not agent.persona.strip():
agent.persona = persona
fields.append("persona")
if instructions and not agent.instructions.strip():
agent.instructions = instructions
fields.append("instructions")
if fields:
agent.save(update_fields=[*fields, "updated_at"])
updated += 1
return updated
def _assign_knowledge(*, organization, global_items: list[Knowledge], by_product: dict[str, list[Knowledge]]) -> None:
from hub_platform.ai.models import AIAgent
for agent in AIAgent.objects.select_related("channel__product").filter(channel__organization=organization):
items = list(global_items)
if agent.channel.product_id:
items += by_product.get(agent.channel.product.code, [])
if items:
agent.knowledge_items.add(*items)
@transaction.atomic
def import_ai_content(*, base_dir: Path, context: TenantContext) -> ImportResult:
organization = context.organization
result = ImportResult()
prompts: dict[tuple[str | None, str], str] = {}
global_items: list[Knowledge] = []
by_product: dict[str, list[Knowledge]] = {}
for relative_path in CONTENT_SOURCES:
path = base_dir / relative_path
if not path.exists():
continue
for code, content in parse_filled_content(path.read_text(encoding="utf-8")).items():
parts = code.split("-")
if len(parts) < 2:
result = result.add(ImportResult(skipped_sections=1))
continue
if parts[0] in _PROMPT_PREFIXES:
suffix = parts[-1]
if suffix not in _PRODUCT_BY_SUFFIX:
result = result.add(ImportResult(skipped_sections=1))
continue
prompts[(_PRODUCT_BY_SUFFIX[suffix], parts[0])] = content
continue
knowledge, section_result = _import_knowledge(organization=organization, code=code, content=content)
result = result.add(section_result)
product_code = _PRODUCT_BY_PREFIX.get(parts[0])
if product_code is None:
global_items.append(knowledge)
else:
by_product.setdefault(product_code, []).append(knowledge)
agents_updated = _apply_prompts(organization=organization, prompts=prompts)
_assign_knowledge(organization=organization, global_items=global_items, by_product=by_product)
return result.add(ImportResult(agents_updated=agents_updated))
+2 -2
View File
@@ -16,8 +16,8 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
def make_channel_with_agent(organization, *, code, name, product=None, model="openai/gpt-4o-mini"):
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не сидит
каналы/агентов — в проде это делает seed_channels, в тестах — этот helper."""
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
каналы/агентов — в тестах их собирает этот helper."""
channel = Channel.objects.create(organization=organization, code=code, name=name, product=product)
agent = AIAgent.objects.create(
channel=channel,
Whitespace-only changes.
Whitespace-only changes.
@@ -1,108 +0,0 @@
"""Seed processing channels (ADR-HUB-0019/0023). Idempotent.
Creates the company site channel "edevs" (no product) plus product channels for
FoxRay and FirePage, each with a draft AI agent (persona/tone/instructions),
bound to the configured OpenRouter provider integration if present.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from hub_platform.ai.models import DEFAULT_AI_MODEL, AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department, Organization
from hub_platform.integrations.models import Integration, IntegrationProvider
from hub_platform.products.models import Product
from hub_platform.tenancy.context import TenantActorKind, TenantContext
from hub_platform.tenancy.database import set_local_tenant
# Тон общения (поле tone агента): простой текст для мессенджера.
TONE = (
"Пиши простым текстом для мессенджера: без markdown-разметки (никаких **, ##, маркированных "
"списков и разделителей ---), короткими абзацами, на русском, по делу. "
"Не предлагай оператора по умолчанию: передавай человеку только если клиент сам просит человека, "
"вопрос выходит за рамки твоих знаний, или это жалоба/спорная ситуация."
)
EDEVS_PERSONA = (
"Ты — AI-ассистент компании Edevs на её главном сайте. "
"Компания делает два продукта: FirePage — готовые нишевые сайты на собственной CMS (разовая лицензия), "
"и FoxRay — сервис для ортодонтов по подписке (тарифы Бесплатный/Про/Макс)."
)
EDEVS_INSTRUCTIONS = (
"Помогай посетителю разобраться, какой продукт ему подходит, отвечай на вопросы о компании и продуктах. "
"Оплату и оформление ты не проводишь."
)
FOXRAY_PERSONA = "Ты — AI sales-ассистент продукта FoxRay (сервис для ортодонтов, подписка: Бесплатный/Про/Макс)."
FOXRAY_INSTRUCTIONS = "Помогай подобрать тариф и ответить на вопросы. Оплату и оформление не проводишь."
FIREPAGE_PERSONA = "Ты — AI sales-ассистент продукта FirePage (готовые нишевые сайты, разовая лицензия)."
FIREPAGE_INSTRUCTIONS = "Помогай выбрать сайт и ответить на вопросы. Оплату и оформление не проводишь."
# (code, name, product_code|None, persona, instructions)
CHANNELS = [
("edevs", "Edevs — главный сайт", None, EDEVS_PERSONA, EDEVS_INSTRUCTIONS),
("foxray-sales", "FoxRay — продажи", "foxray", FOXRAY_PERSONA, FOXRAY_INSTRUCTIONS),
("firepage-sales", "FirePage — продажи", "firepage", FIREPAGE_PERSONA, FIREPAGE_INSTRUCTIONS),
]
class Command(BaseCommand):
help = "Seed processing channels (edevs, foxray, firepage)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True, help="Organization public UUID")
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError):
self.stderr.write("organization not found")
return
context = TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
)
set_local_tenant(context)
sales = Department.objects.filter(organization=organization, code="sales").first()
provider = (
Integration.objects.filter(organization=organization, provider=IntegrationProvider.OPENROUTER)
.exclude(secret="")
.order_by("id")
.first()
)
created = 0
agents_created = 0
for code, name, product_code, persona, instructions in CHANNELS:
product = Product.objects.filter(organization=organization, code=product_code).first() if product_code else None
channel, was_created = Channel.objects.update_or_create(
organization=organization,
code=code,
defaults={
"name": name,
"department": sales,
"product": product,
"provider_integration": provider,
"is_active": True,
},
)
created += int(was_created)
# Агент канала (ADR-HUB-0023): одна сущность, без релизов.
if not AIAgent.objects.filter(channel=channel).exists():
AIAgent.objects.create(
organization=organization,
channel=channel,
name=f"{name} Agent",
status=AIAgentStatus.DRAFT,
model=DEFAULT_AI_MODEL,
persona=persona,
tone=TONE,
instructions=instructions,
)
agents_created += 1
self.stdout.write(
self.style.SUCCESS(
f"channels seeded: +{created} channels, +{agents_created} agents (provider={'set' if provider else 'none'})"
)
)
@@ -51,7 +51,8 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
for code, name in (("firepage", "FirePage"), ("foxray", "Foxray")):
product, _ = Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name})
ProductDepartment.objects.get_or_create(product=product, department=sales_department)
# AI-агенты теперь на уровне канала обработки (ADR-HUB-0019) — см. seed_channels.
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов,
# а не bootstrap: SPEC-HUB-0027.
owner, created_owner = HumanUser.objects.get_or_create(
email=HumanUser.objects.normalize_email(email),
@@ -1,92 +0,0 @@
"""Static reference specs для seed_hub_initial_data.
Вынесено отдельно, чтобы management-команда оставалась компактной (NO GOD FILES):
спецификации каналов/продуктов — данные, логика наполнения — в команде.
"""
# Тон общения (поле tone агента, ADR-HUB-0023): простой текст для мессенджера.
TONE = (
"Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, "
"на русском, по делу. Не проводи оплату и не обещай условия вне базы знаний."
)
CHANNEL_SPECS = (
{
"code": "edevs",
"name": "Edevs — главный сайт",
"product_code": None,
"department": "sales",
"persona": "Ты — AI-ассистент компании Edevs на её главном сайте.",
"instructions": "",
"policy": None, # публичный канал: дефолтные sales-флаги
},
{
"code": "foxray-sales",
"name": "FoxRay — продажи",
"product_code": "foxray",
"department": "sales",
"persona": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов.",
"instructions": "",
"policy": None,
},
{
"code": "firepage-sales",
"name": "FirePage — продажи",
"product_code": "firepage",
"department": "sales",
"persona": "Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты.",
"instructions": "",
"policy": None,
},
# Support-каналы (SPEC-HUB-0010 §4.2): authenticated in-product чат,
# anonymous/self-reported/sales/checkout запрещены.
{
"code": "foxray-support",
"name": "FoxRay — поддержка",
"product_code": "foxray",
"department": "support",
"persona": "Ты — AI поддержки продукта FoxRay.",
"instructions": (
"Помогай по использованию, учитывай verified product context. Не запрашивай"
" имя/email, не продавай и не обещай изменения аккаунта/оплаты без оператора."
),
"policy": {
"requires_authenticated_product_identity": True,
"allow_anonymous_sessions": False,
"allow_self_reported_contact": False,
"allow_sales_attribution": False,
"allow_checkout_actions": False,
},
},
{
"code": "firepage-support",
"name": "FirePage — поддержка",
"product_code": "firepage",
"department": "support",
"persona": "Ты — AI поддержки продукта FirePage.",
"instructions": (
"Помогай по использованию, учитывай verified product context. Не запрашивай"
" имя/email, не продавай и не обещай изменения аккаунта/оплаты без оператора."
),
"policy": {
"requires_authenticated_product_identity": True,
"allow_anonymous_sessions": False,
"allow_self_reported_contact": False,
"allow_sales_attribution": False,
"allow_checkout_actions": False,
},
},
)
PRODUCT_SPECS = (
{
"code": "firepage",
"name": "FirePage",
"site_url": "https://firepage.ru",
},
{
"code": "foxray",
"name": "FoxRay",
"site_url": "https://foxray.pro",
},
)
@@ -1,247 +0,0 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from django.contrib.auth.password_validation import validate_password
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from hub_platform.ai.content_importer import import_ai_content
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import DEFAULT_AI_MODEL, AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import (
Department,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.products.models import Product, ProductDepartment
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.database import tenant_atomic
from ._seed_specs import CHANNEL_SPECS, PRODUCT_SPECS, TONE
@dataclass(frozen=True)
class CoreSeedResult:
organization: Organization
sales_department: Department
support_department: Department
owner: HumanUser | None
created_owner: bool
def _option_or_env(options: dict, option_name: str, env_name: str) -> str:
return str(options.get(option_name) or os.environ.get(env_name, "")).strip()
@transaction.atomic
def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> CoreSeedResult:
organization, _ = Organization.objects.update_or_create(
slug="edevs",
defaults={"name": "ООО «ЭДЕВС»", "timezone": "Europe/Moscow", "currency": "RUB"},
)
ensure_uncategorized_category(organization)
sales_department, _ = Department.objects.update_or_create(
organization=organization,
code="sales",
defaults={"name": "Продажи"},
)
# Отдел поддержки (SPEC-HUB-0010 §4.1): authenticated in-product чат.
support_department, _ = Department.objects.update_or_create(
organization=organization,
code="support",
defaults={"name": "Поддержка"},
)
for spec in PRODUCT_SPECS:
product, _ = Product.objects.update_or_create(
organization=organization,
code=spec["code"],
defaults={
"name": spec["name"],
"site_url": spec["site_url"],
},
)
ProductDepartment.objects.get_or_create(product=product, department=sales_department)
owner = None
created_owner = False
existing_owner = (
organization.memberships.filter(role=EmployeeRole.OWNER).select_related("user").first()
)
if owner_email:
normalized_email = HumanUser.objects.normalize_email(owner_email)
owner, created_owner = HumanUser.objects.get_or_create(
email=normalized_email,
defaults={"full_name": owner_name, "is_staff": True, "is_superuser": True},
)
if created_owner:
validate_password(owner_password, user=owner)
owner.set_password(owner_password)
owner.save(update_fields=["password"])
changed_fields = []
if owner_name and owner.full_name != owner_name:
owner.full_name = owner_name
changed_fields.append("full_name")
if not owner.is_staff:
owner.is_staff = True
changed_fields.append("is_staff")
if not owner.is_superuser:
owner.is_superuser = True
changed_fields.append("is_superuser")
if changed_fields:
owner.save(update_fields=changed_fields)
OrganizationMembership.objects.update_or_create(
user=owner,
organization=organization,
defaults={
"role": EmployeeRole.OWNER,
"position_title": "Владелец",
# OWNER всегда на уровне компании (ADR-HUB-0027).
"primary_department": None,
"totp_required": False,
},
)
elif existing_owner:
owner = existing_owner.user
else:
raise CommandError(
"OWNER is required for the first production seed. Set CUS_SEED_OWNER_EMAIL and "
"CUS_SEED_OWNER_PASSWORD, or pass --owner-email and --owner-password."
)
record_audit_event(
organization=organization,
actor=owner,
action="identity.production_seed_applied",
object_type="Organization",
object_id=str(organization.id),
payload={"created_owner": created_owner, "applied_at": timezone.now().isoformat()},
)
return CoreSeedResult(
organization=organization,
sales_department=sales_department,
support_department=support_department,
owner=owner,
created_owner=created_owner,
)
def _seed_channels(*, context: TenantContext) -> tuple[int, int]:
organization = context.organization
created = 0
agents_created = 0
for spec in CHANNEL_SPECS:
product = (
Product.objects.get(organization=organization, code=spec["product_code"])
if spec["product_code"]
else None
)
department = Department.objects.get(organization=organization, code=spec["department"])
# Дефолтные sales-флаги (аноним/lead/sales/checkout разрешены), если policy не задан.
policy = spec["policy"] or {
"requires_authenticated_product_identity": False,
"allow_anonymous_sessions": True,
"allow_self_reported_contact": True,
"allow_sales_attribution": True,
"allow_checkout_actions": True,
}
channel, was_created = Channel.objects.update_or_create(
organization=organization,
code=spec["code"],
defaults={
"name": spec["name"],
"department": department,
"product": product,
"is_active": True,
**policy,
},
)
created += int(was_created)
# Агент канала (ADR-HUB-0023): одна сущность, без релизов.
if not AIAgent.objects.filter(channel=channel).exists():
AIAgent.objects.create(
channel=channel,
name=f"{spec['name']} Agent",
status=AIAgentStatus.DRAFT,
model=DEFAULT_AI_MODEL,
persona=spec["persona"],
tone=TONE,
instructions=spec["instructions"],
)
agents_created += 1
return created, agents_created
class Command(BaseCommand):
help = "Seed production Hub reference data, AI content and channel releases. Idempotent."
def add_arguments(self, parser) -> None:
parser.add_argument("--owner-email", default="")
parser.add_argument("--owner-password", default="")
parser.add_argument("--owner-name", default="")
def handle(self, *args: object, **options: object) -> None:
# Seed выполняется только при первичной установке: OWNER создаётся ровно
# однажды (см. _seed_core). На уже развёрнутой установке команда — no-op,
# чтобы не затирать данные, изменённые через UI/API (например цены офферов).
if OrganizationMembership.objects.filter(role=EmployeeRole.OWNER).exists():
self.stdout.write(self.style.WARNING(
"Installation already initialized (OWNER exists) — seed skipped."
))
return
owner_email = _option_or_env(options, "owner_email", "CUS_SEED_OWNER_EMAIL")
owner_password = _option_or_env(options, "owner_password", "CUS_SEED_OWNER_PASSWORD")
owner_name = _option_or_env(options, "owner_name", "CUS_SEED_OWNER_NAME")
if (
owner_email
and not owner_password
and not Organization.objects.filter(memberships__role=EmployeeRole.OWNER).exists()
):
raise CommandError("CUS_SEED_OWNER_PASSWORD is required when creating the first OWNER.")
core = _seed_core(
owner_email=owner_email, owner_password=owner_password, owner_name=owner_name
)
membership = core.organization.memberships.select_related(
"organization", "user"
).get(user=core.owner)
context = TenantContext.for_membership(membership)
with tenant_atomic(context):
call_command(
"seed_catalog",
organization=str(core.organization.public_id),
verbosity=0,
)
channels_created, agents_created = _seed_channels(context=context)
from hub_platform.support.seed_support import seed_support_reference
support_stats = seed_support_reference(context=context)
content_result = import_ai_content(
base_dir=Path(__file__).resolve().parents[6],
context=context,
)
owner_state = "created" if core.created_owner else "ready"
self.stdout.write(
self.style.SUCCESS(
"initial data seeded: "
f"owner={owner_state}, "
f"channels +{channels_created}, "
f"agents +{agents_created}, "
f"support {support_stats}, "
f"knowledge +{content_result.knowledge_created}"
f"/{content_result.knowledge_updated} updated, "
f"fragments +{content_result.fragments_created}, "
f"agents filled {content_result.agents_updated}, "
f"skipped {content_result.skipped_sections}"
)
)
@@ -3,9 +3,8 @@ from __future__ import annotations
from hub_platform.identity.models import Department, Organization
# System department codes (ADR-HUB-0022, SPEC-HUB-0010 §4.1). These were
# previously hardcoded strings in bootstrap.py / seed_hub_initial_data.py /
# conversations routing. Centralised here so provisioning and future callers
# share one source of truth.
# previously hardcoded strings in bootstrap.py / conversations routing.
# Centralised here so provisioning and future callers share one source of truth.
SALES_CODE = "sales"
SUPPORT_CODE = "support"
@@ -644,29 +644,6 @@ class ThrottlingTests(TestCase):
self.assertEqual(second.status_code, 429)
class SeedIdempotencyTests(TestCase):
"""seed_hub_initial_data — посев только при первичной установке: на уже
развёрнутой установке (OWNER существует) команда должна быть no-op, чтобы
не затирать данные, изменённые через UI/API (см. seed_catalog и цены офферов).
"""
def test_seed_skipped_when_owner_exists(self) -> None:
from django.core.management import call_command
from io import StringIO
from hub_platform.products.models import Offer
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
offers_before = Offer.objects.count()
out = StringIO()
call_command("seed_hub_initial_data", "--owner-email=owner@edevs.tech", "--owner-password=temporary-password", stdout=out)
self.assertIn("seed skipped", out.getvalue())
# Ни новые офферы, ни цены не создаются и не изменяются на развёрнутой установке.
self.assertEqual(Offer.objects.count(), offers_before)
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-HUB-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
@@ -1,58 +0,0 @@
"""Seed a few demo orders so the sales metrics light up in local dev.
Idempotent: does nothing if the organization already has orders. Uses existing
contacts and active offers — does not invent products/prices.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from hub_platform.conversations.models import Contact
from hub_platform.identity.models import Organization
from hub_platform.orders.models import Order
from hub_platform.orders.services import OrderItemInput, create_order, mark_paid
from hub_platform.products.models import Offer
from hub_platform.tenancy.context import TenantActorKind, TenantContext
from hub_platform.tenancy.database import set_local_tenant
class Command(BaseCommand):
help = "Seed demo orders for local development (idempotent)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True, help="Organization public UUID")
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError):
self.stderr.write("organization not found")
return
context = TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
set_local_tenant(context)
if Order.objects.filter(organization=organization).exists():
self.stdout.write("orders already present — skipping")
return
contacts = list(Contact.objects.filter(organization=organization).order_by("id")[:6])
offers = list(Offer.objects.filter(product__organization=organization, is_active=True).order_by("id"))
if not contacts or not offers:
self.stderr.write("need contacts and active offers — seed channels/catalog first")
return
created = 0
for index, contact in enumerate(contacts):
offer = offers[index % len(offers)]
order = create_order(
context=context,
contact=contact,
items=[OrderItemInput(offer_id=offer.id, quantity=1)],
)
if index % 3 != 0: # часть оставляем в ожидании оплаты
mark_paid(context=context, order=order)
created += 1
self.stdout.write(self.style.SUCCESS(f"seeded {created} demo orders"))
Whitespace-only changes.
Whitespace-only changes.
@@ -1,155 +0,0 @@
"""Seed the management catalog (offers + prices) for FirePage and Foxray.
Hub owns the commercial catalog (ADR-HUB-0018). Prices live here. The actual
charge happens in the product backend. This command is idempotent and reflects
the live offerings on firepage.ru and foxray.pro.
FirePage keeps a single product with one box-license offer per ready site
(one site = one license), so the FirePage sales agent stays one-per-product.
Foxray is one product with subscription tariffs (free / Pro / Max).
"""
from __future__ import annotations
from datetime import datetime, timezone
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from hub_platform.products.models import (
BillingPeriod,
Offer,
OfferFulfillmentType,
OfferPaymentType,
Price,
Product,
)
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantActorKind, TenantContext
from hub_platform.tenancy.database import set_local_tenant
VALID_FROM = datetime(2026, 1, 1, tzinfo=timezone.utc)
# product_code -> list of offers; each offer carries its price rows (amount in rubles).
CATALOG: dict[str, list[dict]] = {
"firepage": [
{
"code": "beautysoft",
"name": "BeautySoft",
"description": "Готовый сайт для студии красоты на CMS FirePage.",
"fulfillment": OfferFulfillmentType.BOX_LICENSE,
"payment": OfferPaymentType.ONE_TIME,
"ai_offerable": True,
"prices": [(BillingPeriod.ONE_TIME, 4900)],
},
{
"code": "beautybarbiecore",
"name": "BeautyBarbiecore",
"description": "Готовый сайт салона красоты в стиле barbiecore.",
"fulfillment": OfferFulfillmentType.BOX_LICENSE,
"payment": OfferPaymentType.ONE_TIME,
"ai_offerable": True,
"prices": [(BillingPeriod.ONE_TIME, 4900)],
},
{
"code": "beautytiffany",
"name": "Beauty Tiffany",
"description": "Готовый сайт салона красоты в палитре Tiffany.",
"fulfillment": OfferFulfillmentType.BOX_LICENSE,
"payment": OfferPaymentType.ONE_TIME,
"ai_offerable": True,
"prices": [(BillingPeriod.ONE_TIME, 4900)],
},
],
"foxray": [
{
"code": "free",
"name": "Бесплатный",
"description": "До 3 пациентов, все типы расчётов, PDF без брендирования, история.",
"fulfillment": OfferFulfillmentType.SAAS_ACCESS,
"payment": OfferPaymentType.SUBSCRIPTION,
"ai_offerable": True,
"prices": [(BillingPeriod.MONTH, 0)],
},
{
"code": "pro",
"name": "Про",
"description": "Безлимит пациентов, брендирование клиники, приоритетная поддержка.",
"fulfillment": OfferFulfillmentType.SAAS_ACCESS,
"payment": OfferPaymentType.SUBSCRIPTION,
"ai_offerable": True,
"prices": [(BillingPeriod.MONTH, 4900), (BillingPeriod.YEAR, 47040)],
},
{
"code": "max",
"name": "Макс",
"description": "Всё из Про, AI-расстановка точек, ранний доступ. Скоро.",
"fulfillment": OfferFulfillmentType.SAAS_ACCESS,
"payment": OfferPaymentType.SUBSCRIPTION,
"ai_offerable": False,
"is_active": False,
"prices": [],
},
],
}
class Command(BaseCommand):
help = "Seed FirePage and Foxray catalog offers and prices (idempotent)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True)
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError) as error:
raise CommandError("Unknown organization public UUID") from error
context = TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
set_local_tenant(context)
created_offers = 0
created_prices = 0
for product_code, offers in CATALOG.items():
product = Product.objects.filter(
organization=context.organization,
code=product_code,
).first()
if product is None:
self.stderr.write(f"product '{product_code}' not found — skipped")
continue
for spec in offers:
offer, offer_new = Offer.objects.update_or_create(
product=product,
code=spec["code"],
defaults={
"name": spec["name"],
"description": spec["description"],
"fulfillment_type": spec["fulfillment"],
"payment_type": spec["payment"],
"fiscal_name": spec["name"],
"is_active": spec.get("is_active", True),
"ai_offerable": spec["ai_offerable"],
},
)
created_offers += int(offer_new)
for billing_period, amount_rub in spec["prices"]:
_, price_new = Price.objects.update_or_create(
offer=offer,
currency="RUB",
billing_period=billing_period,
version=1,
defaults={
"amount_minor": amount_rub * 100,
"valid_from": VALID_FROM,
"is_active": True,
},
)
created_prices += int(price_new)
self.stdout.write(
self.style.SUCCESS(
f"catalog seeded: +{created_offers} offers, +{created_prices} prices"
)
)
@@ -1,154 +0,0 @@
"""Reference seed для отдела поддержки (ADR-HUB-0022, SPEC-HUB-0011).
Идемпотентно создаёт:
- ProductSupportIdentityContract foxray.support.v1 (ACTIVE) с примером
schema/mapping/operator_cards/ai_context/search/sensitive из SPEC-HUB-0011 §5.2;
- support_token_secret для каждого продукта продукта (только если пуст),
хранится зашифрованным (EncryptedCharField), НЕ печатается и НЕ логируется.
Секрет нужен продуктовому backend'у для подписи Product Support Token; выдача
секрета владельцу/продукту — отдельной командой (TODO), здесь только наполнение.
"""
from __future__ import annotations
import secrets
from django.db import transaction
from hub_platform.channels.models import Channel
from hub_platform.products.models import Product
from hub_platform.support.models import ContractStatus, ProductSupportContract
from hub_platform.tenancy.context import TenantContext
# Пример контракта FoxRay (SPEC-HUB-0011 §5.2): врач/клиника/подписка.
_FOXRAY_SUPPORT_V1 = {
"schema": {
"type": "object",
"required": ["doctor"],
"properties": {
"doctor": {
"type": "object",
"required": ["id", "email"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
},
},
"clinic": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
},
},
"subscription": {
"type": "object",
"properties": {
"tariff": {"type": "string"},
"status": {"type": "string"},
},
},
},
},
"identity_mapping": {
"subject": "$.doctor.id",
"account": "$.clinic.id",
"display_name": "$.doctor.name",
"display_email": "$.doctor.email",
},
"operator_ui": {
"operator_cards": [
{
"title": "Пользователь",
"fields": [
{"label": "Имя", "path": "$.doctor.name", "type": "text"},
{"label": "Email", "path": "$.doctor.email", "type": "email"},
],
},
{
"title": "Клиника",
"fields": [
{"label": "Название", "path": "$.clinic.name", "type": "text"},
{"label": "ID", "path": "$.clinic.id", "type": "code"},
],
},
{
"title": "Подписка",
"fields": [
{"label": "Тариф", "path": "$.subscription.tariff", "type": "badge"},
{"label": "Статус", "path": "$.subscription.status", "type": "badge"},
],
},
]
},
"ai_context": {
"allowed_paths": [
"$.doctor.name",
"$.clinic.name",
"$.subscription.tariff",
"$.subscription.status",
]
},
"search": {"paths": ["$.doctor.email", "$.doctor.name", "$.clinic.name"]},
"sensitive_fields": {"paths": ["$.doctor.email"]},
}
_SUPPORT_CHANNEL_BY_PRODUCT = {"foxray": "foxray-support", "firepage": "firepage-support"}
@transaction.atomic
def _ensure_contract(*, organization, product: Product) -> bool:
"""Создаёт foxray.support.v1 для foxray (пример). Возвращает created."""
if product.code != "foxray":
return False
contract, created = ProductSupportContract.objects.update_or_create(
organization=organization,
code="foxray.support.v1",
defaults={
"product": product,
"version": 1,
"status": ContractStatus.ACTIVE,
"schema_json": _FOXRAY_SUPPORT_V1["schema"],
"identity_mapping_json": _FOXRAY_SUPPORT_V1["identity_mapping"],
"operator_ui_json": _FOXRAY_SUPPORT_V1["operator_ui"],
"ai_context_json": _FOXRAY_SUPPORT_V1["ai_context"],
"search_mapping_json": _FOXRAY_SUPPORT_V1["search"],
"sensitive_fields_json": _FOXRAY_SUPPORT_V1["sensitive_fields"],
},
)
# Привязка контракта к support-каналу продукта (allowed_channels).
channel_code = _SUPPORT_CHANNEL_BY_PRODUCT.get(product.code)
if channel_code:
channel = Channel.objects.filter(organization=organization, code=channel_code).first()
if channel is not None:
contract.allowed_channels.add(channel)
return created
@transaction.atomic
def _ensure_secret(*, product: Product) -> bool:
"""Генерирует support_token_secret, если пуст. Возвращает created."""
if product.support_token_secret:
return False
# secrets.token_urlsafe(32) ~ 43 символа; EncryptedCharField шифрует при save.
product.support_token_secret = secrets.token_urlsafe(32)
product.save(update_fields=["support_token_secret"])
return True
def seed_support_reference(*, context: TenantContext) -> str:
"""Наполняет reference-данные поддержки. Возвращает строку-статистику."""
organization = context.organization
contracts_created = 0
secrets_created = 0
for product in Product.objects.filter(organization=organization):
if _ensure_contract(organization=organization, product=product):
contracts_created += 1
if _ensure_secret(product=product):
secrets_created += 1
return (
f"contracts +{contracts_created} (foxray.support.v1), "
f"secrets +{secrets_created}"
)
-1
View File
@@ -71,7 +71,6 @@ def test_deploy_success_orders_canonical_workflow(fake_env):
idx_exec = _index_of(log, "exec -T backend-app")
assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined
assert "seed_hub_initial_data" not in joined # init не запускает Edevs seed
# applied_release записан.
assert (fake_env.instance / "state" / "applied_release").exists()
assert not (fake_env.instance / "compose.yaml").exists()