mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔥 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:
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))
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user