feat(hub): add production deploy pipeline and initial seed

This commit is contained in:
Andrey committed 2026-07-05 00:00:52 +03:00
1 parent 9d93387806
commit b61e0a8b6d
15 files changed
+1065 -22

No files matched your search

+5
View File
@@ -1,4 +1,7 @@
.env
.env.*
!.env.example
!.env.production.example
.git
.venv
__pycache__
@@ -8,5 +11,7 @@ dist
coverage
playwright-report
test-results
data
backups
design
docs
+4 -5
View File
@@ -3,9 +3,9 @@ COMPOSE_PROJECT_NAME=edevs_hub
HUB_ENV=local
HUB_DEBUG=true
HUB_SECRET_KEY=change-me-only-for-local-development
HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost,pay.localhost,chat.localhost
HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5174,http://localhost:5175
HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost
HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5175
HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из
# HUB_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
@@ -42,8 +42,7 @@ POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
INTERNAL_UI_PORT=5173
CHECKOUT_PORT=5174
WEB_CHAT_PORT=5175
BACKEND_PORT=8010
VITE_API_BASE_URL=http://localhost:8010/api/v1
VITE_API_BASE_URL=http://localhost:8010
+52
View File
@@ -0,0 +1,52 @@
COMPOSE_PROJECT_NAME=edevs_hub
# Filled by GitLab deploy job or by hand on the server.
HUB_BACKEND_IMAGE=registry.example.com/edevs/hub/backend:change-me
HUB_FRONTEND_IMAGE=registry.example.com/edevs/hub/frontend:change-me
HUB_FRONTEND_HOST_PORT=8080
HUB_ENV=production
HUB_DEBUG=false
HUB_SECRET_KEY=change-me-long-random-secret
HUB_FIELD_ENCRYPTION_KEY=change-me-fernet-key
HUB_ALLOWED_HOSTS=hub.edevs.tech
HUB_CSRF_TRUSTED_ORIGINS=https://hub.edevs.tech
HUB_CORS_ALLOWED_ORIGINS=https://hub.edevs.tech
INTERNAL_UI_BASE_URL=https://hub.edevs.tech
HUB_COOKIE_SECURE=true
HUB_SSL_REDIRECT=true
HUB_HSTS_SECONDS=31536000
HUB_COOKIE_SAMESITE=Lax
POSTGRES_DB=edevs_hub
POSTGRES_USER=edevs_hub
POSTGRES_PASSWORD=change-me-db-password
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
HUB_AI_PROVIDER=openrouter
HUB_OPENROUTER_API_KEY=
HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
HUB_AI_REQUEST_TIMEOUT=30
HUB_AI_MAX_RETRIES=2
HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=
EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=true
DEFAULT_FROM_EMAIL=Edevs Hub <no-reply@edevs.tech>
# Required only for the first production seed when no OWNER exists yet.
# The seed command never resets an existing user's password.
HUB_SEED_OWNER_EMAIL=
HUB_SEED_OWNER_PASSWORD=
HUB_SEED_OWNER_NAME=
HUB_GUNICORN_WORKERS=3
HUB_GUNICORN_TIMEOUT=60
+111
View File
@@ -0,0 +1,111 @@
stages:
- validate
- test
- build
- deploy
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_DRIVER: overlay2
BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA"
FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA"
BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest"
FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest"
backend:test:
stage: test
image: python:3.12-slim
services:
- name: pgvector/pgvector:pg16
alias: postgres
- name: redis:7-alpine
alias: redis
variables:
HUB_DEBUG: "true"
HUB_SECRET_KEY: "ci-test-secret"
HUB_FIELD_ENCRYPTION_KEY: ""
POSTGRES_DB: "edevs_hub_test"
POSTGRES_USER: "edevs_hub"
POSTGRES_PASSWORD: "edevs_hub"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
REDIS_URL: "redis://redis:6379/0"
before_script:
- cd apps/backend
- python -m pip install --upgrade pip
- pip install -r requirements.txt
script:
- python manage.py check
- python manage.py test
rules:
- if: '$CI_COMMIT_BRANCH'
frontend:build:
stage: validate
image: node:22-alpine
before_script:
- npm ci
script:
- npm run typecheck
- npm run build
rules:
- if: '$CI_COMMIT_BRANCH'
images:build:
stage: build
image: docker:27
services:
- name: docker:27-dind
alias: docker
needs:
- backend:test
- frontend:build
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- docker build -f apps/backend/Dockerfile.production -t "$BACKEND_IMAGE" -t "$BACKEND_LATEST_IMAGE" .
- docker build -f deploy/docker/frontend.Dockerfile -t "$FRONTEND_IMAGE" -t "$FRONTEND_LATEST_IMAGE" .
- docker push "$BACKEND_IMAGE"
- docker push "$FRONTEND_IMAGE"
- |
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
docker push "$BACKEND_LATEST_IMAGE"
docker push "$FRONTEND_LATEST_IMAGE"
fi
rules:
- if: '$CI_COMMIT_BRANCH'
deploy:production:
stage: deploy
image: alpine:3.20
needs:
- images:build
before_script:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "sudo mkdir -p '$DEPLOY_PATH' && sudo chown '$DEPLOY_USER':'$DEPLOY_USER' '$DEPLOY_PATH'"
- scp -P "${DEPLOY_PORT:-22}" compose.production.yaml "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/compose.production.yaml"
- |
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "printf '%s\n' \
'HUB_BACKEND_IMAGE=$BACKEND_IMAGE' \
'HUB_FRONTEND_IMAGE=$FRONTEND_IMAGE' \
> '$DEPLOY_PATH/.env.release'"
- |
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && \
echo '$CI_REGISTRY_PASSWORD' | sudo docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin && \
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml pull && \
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml up -d && \
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py migrate --noinput && \
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py collectstatic --noinput && \
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py seed_hub_initial_data"
environment:
name: production
url: https://hub.edevs.tech
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --system hub && adduser --system --ingroup hub hub
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
COPY apps/backend /app/apps/backend
COPY content /app/content
RUN chown -R hub:hub /app
WORKDIR /app/apps/backend
USER hub
EXPOSE 8000
CMD ["gunicorn", "hub_backend.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"]
@@ -0,0 +1,290 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from django.core.management.base import CommandError
from django.db import transaction
from hub_platform.ai import documents as document_service
from hub_platform.ai import indexing
from hub_platform.ai.models import (
DocumentScope,
InclusionMode,
KnowledgeCategory,
KnowledgeDocument,
KnowledgeDocumentVersion,
PromptCategory,
PromptDocument,
PromptDocumentVersion,
)
from hub_platform.products.models import Product
DocumentKind = Literal["prompt", "knowledge"]
_SECTION_RE = re.compile(r"^===\s*(?P<code>[a-z0-9-]+)\s*===\s*$", re.MULTILINE)
@dataclass(frozen=True)
class ContentSpec:
kind: DocumentKind
title: str
category: str
product_code: str | None = None
inclusion_mode: str = InclusionMode.RETRIEVAL
@dataclass(frozen=True)
class ImportResult:
prompts_created: int = 0
prompt_versions_created: int = 0
knowledge_created: int = 0
knowledge_versions_created: int = 0
fragments_created: int = 0
skipped_sections: int = 0
def add(self, other: ImportResult) -> ImportResult:
return ImportResult(
prompts_created=self.prompts_created + other.prompts_created,
prompt_versions_created=self.prompt_versions_created + other.prompt_versions_created,
knowledge_created=self.knowledge_created + other.knowledge_created,
knowledge_versions_created=self.knowledge_versions_created
+ other.knowledge_versions_created,
fragments_created=self.fragments_created + other.fragments_created,
skipped_sections=self.skipped_sections + other.skipped_sections,
)
PROMPT_CATEGORY_BY_PREFIX = {
"system": PromptCategory.SYSTEM,
"qualify": PromptCategory.QUALIFICATION,
"sales": PromptCategory.SALES_BEHAVIOR,
"handoff": PromptCategory.OPERATOR_HANDOFF,
}
KNOWLEDGE_CATEGORY_BY_SUFFIX = {
"overview": KnowledgeCategory.OVERVIEW,
"products": KnowledgeCategory.OVERVIEW,
"audience": KnowledgeCategory.AUDIENCE,
"tariffs": KnowledgeCategory.COMMERCIAL,
"catalog": KnowledgeCategory.COMMERCIAL,
"technical": KnowledgeCategory.TECHNICAL,
"faq": KnowledgeCategory.FAQ,
"objections": KnowledgeCategory.OBJECTIONS,
"limitations": KnowledgeCategory.LIMITATIONS,
}
MANDATORY_KNOWLEDGE_CODES = {
"company-overview",
"company-products",
"foxray-overview",
"foxray-tariffs",
"firepage-overview",
"firepage-catalog",
}
PRODUCT_CODE_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",
)
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 infer_content_spec(code: str) -> ContentSpec | None:
parts = code.split("-")
if len(parts) < 2:
return None
prompt_category = PROMPT_CATEGORY_BY_PREFIX.get(parts[0])
product_code = PRODUCT_CODE_BY_PREFIX.get(parts[-1])
if prompt_category:
return ContentSpec(
kind="prompt",
title=code,
category=prompt_category,
product_code=product_code,
)
knowledge_category = KNOWLEDGE_CATEGORY_BY_SUFFIX.get(parts[-1])
if knowledge_category is None:
return None
return ContentSpec(
kind="knowledge",
title=code,
category=knowledge_category,
product_code=PRODUCT_CODE_BY_PREFIX.get(parts[0]),
inclusion_mode=InclusionMode.MANDATORY
if code in MANDATORY_KNOWLEDGE_CODES
else InclusionMode.RETRIEVAL,
)
def _next_version(version_model, document) -> int:
latest = version_model.objects.filter(document=document).order_by("-version").first()
return latest.version + 1 if latest else 1
def _latest_published(version_model, document):
return (
version_model.objects.filter(document=document, status="PUBLISHED")
.order_by("-version")
.first()
)
def _resolve_product(*, organization, product_code: str | None) -> Product | None:
if product_code is None:
return None
try:
return Product.objects.get(organization=organization, code=product_code)
except Product.DoesNotExist as error:
raise CommandError(
f"Product '{product_code}' is required before importing AI content"
) from error
@transaction.atomic
def _import_prompt(
*, organization, author, code: str, content: str, spec: ContentSpec
) -> ImportResult:
product = _resolve_product(organization=organization, product_code=spec.product_code)
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
document, created = PromptDocument.objects.update_or_create(
organization=organization,
product=product,
code=code,
defaults={
"title": spec.title,
"category": spec.category,
"scope": scope,
"is_enabled": True,
},
)
latest = _latest_published(PromptDocumentVersion, document)
if latest and latest.content == content:
return ImportResult(prompts_created=int(created))
version = PromptDocumentVersion.objects.create(
document=document,
version=_next_version(PromptDocumentVersion, document),
content=content,
created_by=author,
)
document_service.publish_version(version=version)
return ImportResult(prompts_created=int(created), prompt_versions_created=1)
@transaction.atomic
def _import_knowledge(
*, organization, author, code: str, content: str, spec: ContentSpec
) -> ImportResult:
product = _resolve_product(organization=organization, product_code=spec.product_code)
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
document, created = KnowledgeDocument.objects.update_or_create(
organization=organization,
product=product,
code=code,
defaults={
"title": spec.title,
"category": spec.category,
"scope": scope,
"is_enabled": True,
"inclusion_mode": spec.inclusion_mode,
},
)
latest = _latest_published(KnowledgeDocumentVersion, document)
if latest and latest.content == content:
return ImportResult(knowledge_created=int(created))
version = KnowledgeDocumentVersion.objects.create(
document=document,
version=_next_version(KnowledgeDocumentVersion, document),
content=content,
created_by=author,
)
document_service.publish_version(version=version)
fragments = indexing.reindex_knowledge_version(version)
return ImportResult(
knowledge_created=int(created),
knowledge_versions_created=1,
fragments_created=len(fragments),
)
def ensure_channel_system_prompts(*, organization, author) -> ImportResult:
from hub_platform.channels.models import Channel
result = ImportResult()
for channel in Channel.objects.filter(organization=organization).select_related("product"):
if not channel.system_prompt.strip():
continue
code = f"system-{channel.product.code}" if channel.product_id else "system-edevs"
product = channel.product if channel.product_id else None
if PromptDocument.objects.filter(
organization=organization, product=product, code=code
).exists():
continue
document = PromptDocument.objects.create(
organization=organization,
product=product,
code=code,
title=code,
category=PromptCategory.SYSTEM,
scope=DocumentScope.PRODUCT if product else DocumentScope.GLOBAL,
is_enabled=True,
)
version = PromptDocumentVersion.objects.create(
document=document,
version=1,
content=channel.system_prompt,
created_by=author,
)
document_service.publish_version(version=version)
result = result.add(ImportResult(prompts_created=1, prompt_versions_created=1))
return result
def import_ai_content(*, base_dir: Path, organization, author) -> ImportResult:
result = ImportResult()
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():
spec = infer_content_spec(code)
if spec is None:
result = result.add(ImportResult(skipped_sections=1))
continue
if spec.kind == "prompt":
section_result = _import_prompt(
organization=organization,
author=author,
code=code,
content=content,
spec=spec,
)
else:
section_result = _import_knowledge(
organization=organization,
author=author,
code=code,
content=content,
spec=spec,
)
result = result.add(section_result)
return result
@@ -0,0 +1,134 @@
from __future__ import annotations
from django.db import transaction
from hub_platform.ai.models import (
AIAgent,
ChannelAIRelease,
DocumentScope,
DocumentStatus,
KnowledgeDocumentVersion,
PromptDocumentVersion,
ReleaseKnowledgeVersion,
ReleasePromptVersion,
ReleaseStatus,
)
from hub_platform.ai.releases import publish_release
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
from hub_platform.identity.models import HumanUser, Organization
def _release_version_ids(release: ChannelAIRelease) -> tuple[set[int], set[int]]:
knowledge_ids = {link.knowledge_version_id for link in release.knowledge_versions.all()}
prompt_ids = {link.prompt_version_id for link in release.prompt_versions.all()}
return knowledge_ids, prompt_ids
def _next_release_version(channel: Channel) -> int:
latest = ChannelAIRelease.objects.filter(channel=channel).order_by("-version").first()
return latest.version + 1 if latest else 1
def _published_knowledge_versions(channel: Channel) -> list[KnowledgeDocumentVersion]:
queryset = KnowledgeDocumentVersion.objects.filter(
document__organization=channel.organization,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
if channel.product_id:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
document__product=channel.product
)
else:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
return list(queryset.distinct().order_by("document__code", "-version"))
def _published_prompt_versions(channel: Channel) -> list[PromptDocumentVersion]:
queryset = PromptDocumentVersion.objects.filter(
document__organization=channel.organization,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
if channel.product_id:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
document__product=channel.product
)
else:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
selected: dict[str, PromptDocumentVersion] = {}
for version in (
queryset.select_related("document").distinct().order_by("document__category", "-version")
):
category = version.document.category
current = selected.get(category)
if current is None:
selected[category] = version
continue
current_priority = int(bool(current.document.product_id))
candidate_priority = int(bool(version.document.product_id))
if (candidate_priority, version.version) > (current_priority, current.version):
selected[category] = version
return list(selected.values())
@transaction.atomic
def _create_release_snapshot(
*, channel: Channel, agent: AIAgent, author: HumanUser | None
) -> ChannelAIRelease:
release = ChannelAIRelease.objects.create(
channel=channel,
version=_next_release_version(channel),
status=ReleaseStatus.DRAFT,
model=agent.model,
model_params=agent.model_params,
allowed_tools=agent.allowed_tools,
limits=agent.limits,
retrieval_index_version="",
notes="Production seed release",
created_by=author,
)
ReleaseKnowledgeVersion.objects.bulk_create(
[
ReleaseKnowledgeVersion(release=release, knowledge_version=version)
for version in _published_knowledge_versions(channel)
]
)
ReleasePromptVersion.objects.bulk_create(
[
ReleasePromptVersion(release=release, prompt_version=version)
for version in _published_prompt_versions(channel)
]
)
return release
def ensure_agents_and_releases(
*, organization: Organization, author: HumanUser | None
) -> tuple[int, int]:
agents_created = 0
releases_created = 0
for channel in Channel.objects.filter(organization=organization).order_by("code"):
agent, agent_created = AIAgent.objects.update_or_create(
channel=channel,
defaults={
"name": f"{channel.name} Agent",
"is_active": True,
"model": channel.model or DEFAULT_CHANNEL_MODEL,
},
)
agents_created += int(agent_created)
draft = _create_release_snapshot(channel=channel, agent=agent, author=author)
desired = _release_version_ids(draft)
current = (
ChannelAIRelease.objects.filter(channel=channel, status=ReleaseStatus.PUBLISHED)
.prefetch_related("knowledge_versions", "prompt_versions")
.order_by("-version")
.first()
)
if current and current.model == agent.model and _release_version_ids(current) == desired:
draft.delete()
continue
publish_release(release=draft)
releases_created += 1
return agents_created, releases_created
@@ -0,0 +1,241 @@
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 ensure_channel_system_prompts, import_ai_content
from hub_platform.ai.seed_releases import ensure_agents_and_releases
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import (
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
)
from hub_platform.products.models import Product, ProductDepartment
STYLE = (
" Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, "
"на русском, по делу. Не проводи оплату и не обещай условия вне базы знаний."
)
CHANNEL_SPECS = (
{
"code": "edevs",
"name": "Edevs — главный сайт",
"product_code": None,
"system_prompt": "Ты — AI-ассистент компании Edevs на её главном сайте." + STYLE,
},
{
"code": "foxray-sales",
"name": "FoxRay — продажи",
"product_code": "foxray",
"system_prompt": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов." + STYLE,
},
{
"code": "firepage-sales",
"name": "FirePage — продажи",
"product_code": "firepage",
"system_prompt": "Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты."
+ STYLE,
},
)
PRODUCT_SPECS = (
{
"code": "firepage",
"name": "FirePage",
"site_url": "https://firepage.ru",
"summary": "Готовые нишевые сайты на собственной CMS с разовой лицензией.",
},
{
"code": "foxray",
"name": "FoxRay",
"site_url": "https://foxray.pro",
"summary": "Онлайн-сервис для цефалометрического анализа ТРГ.",
},
)
@dataclass(frozen=True)
class CoreSeedResult:
organization: Organization
sales_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"},
)
sales_department, _ = Department.objects.update_or_create(
organization=organization,
code="sales",
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"],
"summary": spec["summary"],
},
)
ProductDepartment.objects.get_or_create(product=product, department=sales_department)
owner = None
created_owner = False
existing_owner = (
organization.employees.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)
EmployeeProfile.objects.update_or_create(
user=owner,
defaults={
"organization": organization,
"role": EmployeeRole.OWNER,
"department": sales_department,
"totp_required": False,
},
)
elif existing_owner:
owner = existing_owner.user
else:
raise CommandError(
"OWNER is required for the first production seed. Set HUB_SEED_OWNER_EMAIL and "
"HUB_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, sales_department, owner, created_owner)
def _seed_channels(*, organization: Organization, department: Department) -> int:
created = 0
for spec in CHANNEL_SPECS:
product = (
Product.objects.get(organization=organization, code=spec["product_code"])
if spec["product_code"]
else None
)
_, was_created = Channel.objects.update_or_create(
organization=organization,
code=spec["code"],
defaults={
"name": spec["name"],
"department": department,
"product": product,
"model": DEFAULT_CHANNEL_MODEL,
"system_prompt": spec["system_prompt"],
"is_active": True,
},
)
created += int(was_created)
return 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:
owner_email = _option_or_env(options, "owner_email", "HUB_SEED_OWNER_EMAIL")
owner_password = _option_or_env(options, "owner_password", "HUB_SEED_OWNER_PASSWORD")
owner_name = _option_or_env(options, "owner_name", "HUB_SEED_OWNER_NAME")
if (
owner_email
and not owner_password
and not Organization.objects.filter(employees__role=EmployeeRole.OWNER).exists()
):
raise CommandError("HUB_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
)
call_command("seed_catalog", verbosity=0)
channels_created = _seed_channels(
organization=core.organization, department=core.sales_department
)
content_result = import_ai_content(
base_dir=Path(__file__).resolve().parents[6],
organization=core.organization,
author=core.owner,
)
content_result = content_result.add(
ensure_channel_system_prompts(organization=core.organization, author=core.owner)
)
agents_created, releases_created = ensure_agents_and_releases(
organization=core.organization, author=core.owner
)
owner_state = "created" if core.created_owner else "ready"
prompt_stats = (
f"{content_result.prompts_created}/{content_result.prompt_versions_created} versions"
)
knowledge_stats = (
f"{content_result.knowledge_created}/"
f"{content_result.knowledge_versions_created} versions"
)
self.stdout.write(
self.style.SUCCESS(
"initial data seeded: "
f"owner={owner_state}, "
f"channels +{channels_created}, "
f"agents +{agents_created}, "
f"releases +{releases_created}, "
f"prompts +{prompt_stats}, "
f"knowledge +{knowledge_stats}, "
f"fragments +{content_result.fragments_created}, "
f"skipped {content_result.skipped_sections}"
)
)
+10 -2
View File
@@ -1,4 +1,12 @@
const API_BASE = "http://localhost:8010";
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
function resolveApiUrl(path: string): string {
if (!configuredApiBase) return path;
if (configuredApiBase.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
return `${configuredApiBase}${path.slice("/api/v1".length)}`;
}
return `${configuredApiBase}${path}`;
}
function getCookie(name: string): string {
const cookie = document.cookie
@@ -15,7 +23,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
headers.set("Content-Type", "application/json");
headers.set("X-CSRFToken", getCookie("csrftoken"));
}
const response = await fetch(`${API_BASE}${path}`, {
const response = await fetch(resolveApiUrl(path), {
...init,
credentials: "include",
headers,
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
backend:
image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required}
restart: unless-stopped
env_file:
- .env.production
command: >
sh -c "gunicorn hub_backend.wsgi:application
--bind 0.0.0.0:8000
--workers $${HUB_GUNICORN_WORKERS:-3}
--timeout $${HUB_GUNICORN_TIMEOUT:-60}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live/', timeout=3)",
]
interval: 10s
timeout: 5s
retries: 10
worker:
image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required}
restart: unless-stopped
env_file:
- .env.production
command: python manage.py run_worker
depends_on:
backend:
condition: service_healthy
frontend:
image: ${HUB_FRONTEND_IMAGE:?HUB_FRONTEND_IMAGE is required}
restart: unless-stopped
ports:
- "127.0.0.1:${HUB_FRONTEND_HOST_PORT:-8080}:80"
depends_on:
backend:
condition: service_healthy
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/internal-ui/package.json ./apps/internal-ui/package.json
COPY apps/web-chat/package.json ./apps/web-chat/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/contracts/package.json ./packages/contracts/package.json
COPY packages/shared/package.json ./packages/shared/package.json
RUN npm ci
COPY apps/internal-ui ./apps/internal-ui
COPY apps/web-chat ./apps/web-chat
COPY packages ./packages
ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm --workspace @edevs/internal-ui run build
RUN npm --workspace @edevs/web-chat run build
FROM nginx:1.27-alpine
COPY deploy/nginx/frontend.production.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/internal-ui/dist /usr/share/nginx/html
COPY --from=builder /app/apps/web-chat/dist /usr/share/nginx/html/chat
EXPOSE 80
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location = /chat-widget.js {
proxy_pass http://backend:8000/chat-widget.js;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location /admin/ {
proxy_pass http://backend:8000/admin/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location /static/ {
proxy_pass http://backend:8000/static/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location /chat/ {
try_files $uri $uri/ /chat/index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name hub.edevs.tech;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name hub.edevs.tech;
# Set these paths after issuing the certificate with certbot.
ssl_certificate /etc/letsencrypt/live/hub.edevs.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hub.edevs.tech/privkey.pem;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
}
-15
View File
@@ -30,18 +30,3 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name chat.localhost;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
location / {
proxy_pass http://web-chat:5175;
proxy_set_header Host $host;
}
}