feat(org): org creation wizard + app profile (v2 phase A1-2)

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
@@ -0,0 +1,596 @@
"""Organization creation wizard — from zero to a running app in one transaction.
The wizard is the *only* path that creates a project. It guarantees the 1-org-1-app
rule by checking before insertion and by relying on the DB unique constraint.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec, DeployTargetTester
from app.application.ports.git_provider import GitProviderPort
from app.application.ports.repo_workspace import RemoteCheck, RemoteCredentials, RepoWorkspacePort
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.core.crypto import encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, new_id, slugify, utcnow
from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.domain.platform.rules import is_blocked_address
from app.domain.projects.catalog import (
ProfileDefaults,
defaults_for,
)
from app.domain.projects.models import (
ProjectExternalGit,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.org.wizard")
#: A minimal allowed scheme set for user-supplied git remotes.
_GIT_SCHEMES = {"https", "ssh"}
@dataclass(slots=True, kw_only=True)
class WizardPayload:
"""Validated data the wizard submits on ``complete``."""
org_name: str
slug: str
app_name: str
app_type: str
language: str
framework: str
# Optional overrides on top of catalog defaults
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
# Optional deploy target
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
class OrgSetupService:
"""Backend for the org-creation wizard and app-profile management."""
def __init__(
self,
session: AsyncSession,
*,
repo_workspace: RepoWorkspacePort | None = None,
deploy_tester: DeployTargetTester | None = None,
git_provider: GitProviderPort | None = None,
workforce_bootstrap: WorkforceBootstrapPort | None = None,
delivery_hooks: DeliveryHooks | None = None,
) -> None:
self.session = session
self.orgs = OrganizationRepository(session)
self.memberships = MembershipRepository(session)
self.projects = ProjectRepository(session)
self.external_git = ProjectExternalGitRepository(session)
self.git_repos = ProjectGitRepoRepository(session)
self.git_credentials = ProjectGitCredentialRepository(session)
self.deploys = DeployTargetRepository(session)
self.repo_workspace = repo_workspace
self.deploy_tester = deploy_tester
self.git_provider = git_provider
self.workforce_bootstrap = workforce_bootstrap
self.delivery_hooks = delivery_hooks
# ------------------------------------------------------------------
# Catalog
# ------------------------------------------------------------------
@staticmethod
def catalog() -> dict:
from app.domain.projects.catalog import catalog_payload
return catalog_payload()
# ------------------------------------------------------------------
# Draft
# ------------------------------------------------------------------
async def get_draft(self, user_id: str) -> dict:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
return {"step": 1, "data": {}}
return {"step": row.step, "data": json.loads(row.data_json)}
async def save_draft(self, user_id: str, step: int, data: dict) -> None:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
row = OrgSetupDraftRow(
user_id=user_id, step=step, data_json=json.dumps(data)
)
self.session.add(row)
else:
row.step = step
row.data_json = json.dumps(data)
await self.session.flush()
# ------------------------------------------------------------------
# Git remote validation
# ------------------------------------------------------------------
async def check_git(
self,
*,
url: str,
auth_type: str = "none",
username: str | None = None,
secret: str | None = None,
) -> RemoteCheck:
_validate_git_url(url)
if self.repo_workspace is None:
raise ValidationError("git workspace در دسترس نیست.")
return await self.repo_workspace.test_remote(
url=url,
credentials=RemoteCredentials(
auth_type=auth_type, username=username, secret=secret
),
)
# ------------------------------------------------------------------
# Deploy-target validation
# ------------------------------------------------------------------
async def check_deploy(self, *, target: DeployTargetSpec) -> DeployCheck:
if self.deploy_tester is None:
raise ValidationError("تستر مقصد دیپلوی در دسترس نیست.")
return await self.deploy_tester.test_connection(target=target)
# ------------------------------------------------------------------
# Wizard completion
# ------------------------------------------------------------------
async def complete(self, user: User, payload: WizardPayload) -> dict:
if not user.can_create_organization:
raise PermissionDeniedError(
"ساخت سازمان فقط برای مالک سامانه و مدیران امکان‌پذیر است."
)
# -- slug uniqueness -------------------------------------------------
base_slug = slugify(payload.slug or payload.org_name)
slug = ensure_unique_slug(base_slug, await self.orgs.list_all_slugs())
# -- defaults from catalog -------------------------------------------
catalog_defaults = defaults_for(
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
)
# -- create org + owner membership + wallet --------------------------
org = await self.orgs.create(
Organization(name=payload.org_name.strip(), slug=slug, owner_user_id=user.id)
)
await self.memberships.add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
# -- git setup -------------------------------------------------------
if payload.git_source == "platform":
await self._setup_platform_git(project_row.id, slug)
elif payload.git_source == "external":
await self._setup_external_git(
project_row.id,
url=payload.external_repo_url or "",
auth_type=payload.external_git_auth_type,
username=payload.external_git_username,
secret=payload.external_git_secret,
)
else:
raise ValidationError("منبع git نامعتبر است.")
# -- optional deploy target ------------------------------------------
deploy_target_id: str | None = None
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
"organization": {
"id": org.id,
"name": org.name,
"slug": org.slug,
},
"project": {
"id": project_row.id,
"name": project_row.name,
"slug": project_row.slug,
},
"deploy_target_id": deploy_target_id,
}
async def _create_single_app(
self,
org_id: str,
user_id: str,
payload: WizardPayload,
defaults: ProfileDefaults,
) -> ProjectRow:
"""Create the one project for this org, raising 409 if one exists."""
existing = await self.session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if existing is not None:
raise ConflictError("این سازمان قبلاً یک اپلیکیشن دارد.")
row = ProjectRow(
id=new_id(),
organization_id=org_id,
name=payload.app_name.strip(),
slug=payload.slug.strip(),
created_by_user_id=user_id,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=(payload.runtime_version or defaults.runtime_version),
package_manager=(payload.package_manager or defaults.package_manager),
setup_command=(payload.setup_command or defaults.setup_command),
build_command=(
payload.build_command
if payload.build_command is not None
else defaults.build_command
),
test_command=(
payload.test_command
if payload.test_command is not None
else defaults.test_command
),
start_command=(payload.start_command or defaults.start_command),
app_port=(payload.app_port if payload.app_port is not None else defaults.app_port),
healthcheck_path=payload.healthcheck_path or defaults.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
default_branch="main",
wizard_completed_at=utcnow(),
created_at=utcnow(),
)
self.session.add(row)
await self.session.flush()
return row
async def _setup_platform_git(self, project_id: str, repo_name: str) -> None:
if self.git_provider is None:
raise ValidationError("git provider در دسترس نیست.")
repo_info = await self.git_provider.ensure_project_repo(
project_id=project_id, repo_name=repo_name
)
creds = await self.git_provider.ensure_credentials(project_id=project_id)
await self.git_repos.upsert(
ProjectGitRepo(
project_id=project_id,
provider="gitea",
owner=repo_info.owner,
repo_name=repo_info.repo_name,
clone_url=repo_info.clone_url,
web_url=repo_info.web_url,
default_branch=repo_info.default_branch or "main",
)
)
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=creds.username,
encrypted_password=encrypt(creds.password),
)
)
async def _setup_external_git(
self,
project_id: str,
*,
url: str,
auth_type: str,
username: str | None,
secret: str | None,
) -> None:
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
async def _setup_deploy_target(
self, org_id: str, project_id: str, payload: WizardPayload
) -> str:
secret = payload.deploy_target_secret
values = {
"organization_id": org_id,
"project_id": project_id,
"kind": payload.deploy_target_kind,
"host": payload.deploy_target_host or "",
"port": payload.deploy_target_port,
"username": payload.deploy_target_username or "",
"auth_type": payload.deploy_target_auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": payload.deploy_target_remote_path or "/",
"is_active": True,
}
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
)
# ------------------------------------------------------------------
# App profile editing (post-wizard)
# ------------------------------------------------------------------
async def get_profile(self, *, organization_id: str, project_id: str) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
profile = await AppProfileRepository(self.session).get(
organization_id=organization_id, project_id=project_id
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_profile(
self, *, organization_id: str, project_id: str, values: dict
) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
repo = AppProfileRepository(self.session)
unknown = set(values) - EDITABLE_FIELDS
if unknown:
raise ValidationError(f"فیلدهای غیرقابل ویرایش: {sorted(unknown)}")
profile = await repo.update_profile(
organization_id=organization_id, project_id=project_id, values=values
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_deploy_target(
self,
*,
organization_id: str,
project_id: str,
kind: str,
host: str,
port: int,
username: str,
auth_type: str = "password",
secret: str | None = None,
remote_path: str = "/",
) -> dict:
# Verify project belongs to org
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
values = {
"organization_id": organization_id,
"project_id": project_id,
"kind": kind,
"host": host or "",
"port": port,
"username": username or "",
"auth_type": auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": remote_path or "/",
"is_active": True,
}
target = await self.deploys.upsert(values=values)
return {
"id": target.id,
"kind": target.kind,
"host": target.host,
"port": target.port,
"username": target.username,
"auth_type": target.auth_type,
"remote_path": target.remote_path,
"has_secret": bool(target.secret_encrypted),
"last_test_ok": target.last_test_ok,
"last_test_message": target.last_test_message,
}
async def update_git_credentials(
self,
*,
organization_id: str,
project_id: str,
auth_type: str,
username: str | None = None,
secret: str | None = None,
) -> dict:
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
git_source = (row.git_source or "platform").lower()
if git_source == "platform":
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=username or "",
encrypted_password=encrypt(secret) if secret else "",
)
)
return {"git_source": "platform", "has_secret": bool(secret)}
# external
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
return {"git_source": "external", "has_secret": bool(secret)}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_git_url(url: str) -> None:
"""Lightweight format check before we hand the URL to the git adapter.
Rejects: wrong scheme, embedded credentials, obvious localhost/private IPs.
The DNS-resolved SSRF check lives in ``RepoWorkspacePort.test_remote``.
"""
clean = (url or "").strip()
if not clean:
raise ValidationError("آدرس git نمی‌تواند خالی باشد.")
# Normalise scp-style ``git@host:path`` so urlsplit can parse it.
working = clean
if "://" not in working and re.match(r"^[^/@\s]+@[^/:\s]+:", working):
working = "ssh://" + working.replace(":", "/", 1)
parts = urlsplit(working)
scheme = (parts.scheme or "").lower()
if scheme not in _GIT_SCHEMES:
raise ValidationError("آدرس git باید با https:// یا ssh:// یا git@ شروع شود.")
if parts.password is not None:
raise ValidationError("آدرس git نباید شامل نام کاربری و رمز باشد.")
host = parts.hostname or ""
if not host:
raise ValidationError("میزبان آدرس git مشخص نیست.")
lowered = host.lower().strip(".")
if lowered in {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}:
raise ValidationError("اتصال به میزبان محلی مجاز نیست.")
# Reject blocked IP literals without DNS.
try:
import ipaddress
ipaddress.ip_address(lowered.strip("[]"))
except ValueError:
pass
else:
if is_blocked_address(lowered.strip("[]")):
raise ValidationError("اتصال به آدرس‌های داخلی مجاز نیست.")
+1
View File
@@ -21,6 +21,7 @@ class AuditAction:
PASSWORD_CHANGE = "auth.password_change"
ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete"
ORG_WIZARD_COMPLETE = "organization.wizard_complete"
#: An organization changed hands because its owning manager was demoted or
#: deleted — the platform owner takes over. Never automatic for any other
#: reason.
+403
View File
@@ -0,0 +1,403 @@
"""The org-creation wizard's application catalog.
Pure Python, no I/O: the wizard frontend fetches it from ``GET /setup/catalog``
and the backend uses ``defaults_for`` to prefill — and to *validate* — the app
profile a user submits. Keeping the catalog in one place means the "Next.js"
card and the server's idea of what "Next.js" means can never drift apart, and a
typo in a framework name fails loudly at validation time instead of producing a
half-built project.
The catalog is intentionally conservative: it lists what the platform actually
knows how to build and run, not every language on earth. Each framework carries
enough defaults that a user who accepts the suggestions gets a working app
without touching a terminal.
"""
from __future__ import annotations
from dataclasses import dataclass
#: The kinds of app an organization can host. Mirrors ``APP_TYPES`` in
#: ``app.domain.projects.profile``; declared again here so the catalog is the
#: single source the frontend renders from.
APP_TYPES: tuple[str, ...] = ("web_app", "api", "static_site", "bot", "worker", "other")
#: Display labels (Persian) keyed by app type, for the wizard cards.
APP_TYPE_LABELS: dict[str, str] = {
"web_app": "وب‌اپلیکیشن",
"api": "ای‌پی‌آی",
"static_site": "سایت ایستا",
"bot": "بات",
"worker": "ورکر",
"other": "سایر",
}
GIT_SOURCES: tuple[str, ...] = ("platform", "external")
@dataclass(slots=True, frozen=True, kw_only=True)
class ProfileDefaults:
"""The values ``defaults_for`` hands back for a (type, language, framework)."""
app_type: str
language: str
framework: str
runtime_version: str
package_manager: str
setup_command: str
build_command: str | None
test_command: str | None
start_command: str
app_port: int
healthcheck_path: str
docker_image: str
@property
def needs_build(self) -> bool:
return self.build_command is not None and self.build_command.strip() != ""
@dataclass(slots=True, frozen=True, kw_only=True)
class FrameworkEntry:
"""One selectable framework inside a language group."""
key: str
display: str
defaults: ProfileDefaults
@dataclass(slots=True, frozen=True, kw_only=True)
class LanguageEntry:
"""A language group and the frameworks it offers."""
key: str
display: str
frameworks: tuple[FrameworkEntry, ...]
def _fw(
*,
key: str,
display: str,
app_type: str,
runtime_version: str,
package_manager: str,
setup_command: str,
start_command: str,
app_port: int,
healthcheck_path: str = "/",
build_command: str | None = None,
test_command: str | None = None,
docker_image: str,
) -> FrameworkEntry:
return FrameworkEntry(
key=key,
display=display,
defaults=ProfileDefaults(
app_type=app_type,
language="", # filled by the language group below
framework=key,
runtime_version=runtime_version,
package_manager=package_manager,
setup_command=setup_command,
build_command=build_command,
test_command=test_command,
start_command=start_command,
app_port=app_port,
healthcheck_path=healthcheck_path,
docker_image=docker_image,
),
)
# ---------------------------------------------------------------------------
# Node.js
# ---------------------------------------------------------------------------
_NODE = (
_fw(
key="next",
display="Next.js",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/",
docker_image="node:20-slim",
),
_fw(
key="express",
display="Express",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command=None,
test_command="npm run test",
start_command="npm start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="nestjs",
display="NestJS",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="vite-react",
display="Vite + React",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command=None,
start_command="npm run dev -- --host 0.0.0.0",
app_port=5173,
healthcheck_path="/",
docker_image="node:20-slim",
),
)
# ---------------------------------------------------------------------------
# Python
# ---------------------------------------------------------------------------
_PYTHON = (
_fw(
key="fastapi",
display="FastAPI",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="uvicorn main:app --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
_fw(
key="django",
display="Django",
app_type="web_app",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="python manage.py test",
start_command="gunicorn config.wsgi:application --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="python:3.12-slim",
),
_fw(
key="flask",
display="Flask",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="gunicorn app:app --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
)
# ---------------------------------------------------------------------------
# PHP
# ---------------------------------------------------------------------------
_PHP = (
_fw(
key="laravel",
display="Laravel",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command="npm install && npm run build",
test_command="php artisan test",
start_command="php artisan serve --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-fpm",
),
_fw(
key="plain",
display="PHP ساده",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command=None,
test_command="php -l .",
start_command="php -S 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-cli",
),
)
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
_GO = (
_fw(
key="plain",
display="Go ساده",
app_type="api",
runtime_version="1.22",
package_manager="go mod",
setup_command="go mod download",
build_command="go build -o bin/app ./cmd",
test_command="go test ./...",
start_command="./bin/app",
app_port=8080,
healthcheck_path="/health",
docker_image="golang:1.22",
),
)
# ---------------------------------------------------------------------------
# Static
# ---------------------------------------------------------------------------
_STATIC = (
_fw(
key="html",
display="HTML ایستا",
app_type="static_site",
runtime_version="",
package_manager="",
setup_command="",
build_command=None,
test_command=None,
start_command="",
app_port=80,
healthcheck_path="/",
docker_image="nginx:alpine",
),
)
#: The full catalog, in display order.
LANGUAGES: tuple[LanguageEntry, ...] = (
LanguageEntry(key="node", display="Node.js", frameworks=_NODE),
LanguageEntry(key="python", display="Python", frameworks=_PYTHON),
LanguageEntry(key="php", display="PHP", frameworks=_PHP),
LanguageEntry(key="go", display="Go", frameworks=_GO),
LanguageEntry(key="static", display="ایستا", frameworks=_STATIC),
)
#: Fast lookup ``(language, framework) -> ProfileDefaults``.
_LOOKUP: dict[tuple[str, str], ProfileDefaults] = {}
def _build_lookup() -> None:
from dataclasses import asdict
for lang in LANGUAGES:
for fw in lang.frameworks:
base = asdict(fw.defaults)
base["language"] = lang.key
defaults = ProfileDefaults(**base)
_LOOKUP[(lang.key, fw.key)] = defaults
_build_lookup()
def defaults_for(*, app_type: str, language: str, framework: str) -> ProfileDefaults:
"""Resolve the catalog defaults for a concrete choice.
Raises ``ValueError`` when the combination is not in the catalog — the
backend calls this while validating the wizard's ``complete`` payload, so an
unknown framework (a stale frontend, a hand-crafted request) is rejected
before anything is written.
"""
key = (language, framework)
if key not in _LOOKUP:
raise ValueError(
f"چارچوب ناشناخته: language={language!r} framework={framework!r}"
)
found = _LOOKUP[key]
if found.app_type != app_type:
raise ValueError(
f"نوع اپلیکیشن با چارچوب هم‌خوانی ندارد: انتظار {found.app_type!r}، دریافت {app_type!r}"
)
return found
def is_known_language(language: str) -> bool:
return any(lang.key == language for lang in LANGUAGES)
def is_known_framework(language: str, framework: str) -> bool:
return (language, framework) in _LOOKUP
def catalog_payload() -> dict:
"""Structure returned by ``GET /setup/catalog``."""
def _framework_out(fw: FrameworkEntry) -> dict:
d = fw.defaults
return {
"key": fw.key,
"display": fw.display,
"app_type": d.app_type,
"defaults": {
"runtime_version": d.runtime_version,
"package_manager": d.package_manager,
"setup_command": d.setup_command,
"build_command": d.build_command,
"test_command": d.test_command,
"start_command": d.start_command,
"app_port": d.app_port,
"healthcheck_path": d.healthcheck_path,
"docker_image": d.docker_image,
},
}
return {
"app_types": list(APP_TYPES),
"app_type_labels": APP_TYPE_LABELS,
"git_sources": list(GIT_SOURCES),
"languages": [
{
"key": lang.key,
"display": lang.display,
"frameworks": [_framework_out(fw) for fw in lang.frameworks],
}
for lang in LANGUAGES
],
}
__all__ = [
"APP_TYPES",
"APP_TYPE_LABELS",
"GIT_SOURCES",
"LANGUAGES",
"FrameworkEntry",
"LanguageEntry",
"ProfileDefaults",
"catalog_payload",
"defaults_for",
"is_known_framework",
"is_known_language",
]
+28 -1
View File
@@ -76,4 +76,31 @@ class PlatformInviteRow(Base):
return self.expires_at <= reference
__all__ = ["PlatformInviteRow"]
class OrgSetupDraftRow(Base):
"""A user's in-progress org-creation wizard state.
The wizard autosaves its current step here so a refresh resumes. By design
the payload never contains secrets: git tokens, SSH keys and deploy-target
passwords are submitted only on the final ``complete`` call and are held
encrypted elsewhere (``project_external_git`` / ``deploy_targets``). Storing
them in the draft would defeat the encryption at rest.
"""
__tablename__ = "org_setup_drafts"
id: Mapped[str] = pk()
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
#: The furthest step the user reached (1..5). Lets the frontend jump back.
step: Mapped[int] = mapped_column(default=1, nullable=False)
#: JSON blob of the non-secret wizard fields. Validated upstream; secrets
#: are stripped before this row is written.
data_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
created_at: Mapped[datetime] = created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
@@ -6,6 +6,442 @@ route its full path (no prefix is applied here besides ``/api``).
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_delivery import (
build_delivery_hooks,
build_deploy_target_tester,
build_repo_workspace,
)
from app.application.container_engine import build_workforce_bootstrap
from app.application.services.audit_service import record_audit
from app.application.services.org_setup_service import OrgSetupService, WizardPayload
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.domain.projects.catalog import APP_TYPES, GIT_SOURCES
from app.infrastructure.adapters.registry import get_git_provider
from app.infrastructure.db.models import ProjectRow
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_current_user,
get_org_context,
require_capability,
)
router = APIRouter(tags=["app-setup"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class CatalogOut(BaseModel):
model_config = ConfigDict(extra="ignore")
catalog: dict
class DraftRequest(BaseModel):
step: int = Field(ge=1, le=5)
data: dict = {}
class DraftOut(BaseModel):
step: int
data: dict
class CheckGitRequest(BaseModel):
url: str = Field(min_length=1, max_length=500)
auth_type: str = "none"
username: str | None = None
secret: str | None = None
class CheckDeployRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class WizardCompleteRequest(BaseModel):
org_name: str = Field(min_length=2, max_length=160)
slug: str = Field(min_length=2, max_length=120, pattern=r"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")
app_name: str = Field(min_length=1, max_length=160)
app_type: str = Field(min_length=1)
language: str = Field(min_length=1)
framework: str = Field(min_length=1)
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
@field_validator("app_type")
@classmethod
def _valid_app_type(cls, v: str) -> str:
if v not in APP_TYPES:
raise ValueError("نوع اپلیکیشن نامعتبر است.")
return v
@field_validator("git_source")
@classmethod
def _valid_git_source(cls, v: str) -> str:
if v not in GIT_SOURCES:
raise ValueError("منبع git نامعتبر است.")
return v
class WizardCompleteOut(BaseModel):
organization: dict
project: dict
deploy_target_id: str | None = None
class ProfileOut(BaseModel):
model_config = ConfigDict(extra="ignore")
project_id: str
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
default_branch: str = "main"
wizard_completed: bool = False
class ProfilePatchRequest(BaseModel):
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str | None = None
git_source: str | None = None
external_repo_url: str | None = None
default_branch: str | None = None
class DeployTargetOut(BaseModel):
id: str
kind: str
host: str
port: int
username: str
auth_type: str
remote_path: str
has_secret: bool
last_test_ok: bool | None = None
last_test_message: str | None = None
class DeployTargetPutRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class GitCredentialsPutRequest(BaseModel):
auth_type: str = "token"
username: str | None = None
secret: str | None = None
class GitCredentialsOut(BaseModel):
git_source: str
has_secret: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_service(session: AsyncSession) -> OrgSetupService:
return OrgSetupService(
session,
repo_workspace=build_repo_workspace(session),
deploy_tester=build_deploy_target_tester(session),
git_provider=get_git_provider(),
workforce_bootstrap=build_workforce_bootstrap(session),
delivery_hooks=build_delivery_hooks(session),
)
async def _project_for_org(session: AsyncSession, org_id: str) -> ProjectRow:
row = await session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if row is None:
from app.core.errors import NotFoundError
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
return row
# ---------------------------------------------------------------------------
# Wizard routes (no org context — the org does not exist yet)
# ---------------------------------------------------------------------------
@router.get("/setup/catalog", response_model=dict)
async def get_catalog(
_user=Depends(get_current_user),
) -> dict:
return OrgSetupService.catalog()
@router.get("/setup/draft", response_model=DraftOut)
async def get_draft(
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> DraftOut:
svc = OrgSetupService(session)
data = await svc.get_draft(user.id)
return DraftOut(step=data["step"], data=data["data"])
@router.put("/setup/draft", status_code=status.HTTP_204_NO_CONTENT)
async def save_draft(
payload: DraftRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
svc = OrgSetupService(session)
await svc.save_draft(user.id, step=payload.step, data=payload.data)
@router.post("/setup/check-git", response_model=dict)
async def check_git(
payload: CheckGitRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
svc = _build_service(session)
result = await svc.check_git(
url=payload.url,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return {
"ok": result.ok,
"default_branch": result.default_branch,
"branches": result.branches,
"message": result.message,
}
@router.post("/setup/check-deploy", response_model=dict)
async def check_deploy(
payload: CheckDeployRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
from app.application.ports.deploy_target import DeployTargetSpec
svc = _build_service(session)
result = await svc.check_deploy(
target=DeployTargetSpec(
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
)
return {
"ok": result.ok,
"message": result.message,
"fingerprint": result.fingerprint,
}
@router.post(
"/setup/complete",
response_model=WizardCompleteOut,
status_code=status.HTTP_201_CREATED,
)
async def complete_wizard(
payload: WizardCompleteRequest,
request: Request,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WizardCompleteOut:
svc = _build_service(session)
result = await svc.complete(
user,
WizardPayload(
org_name=payload.org_name,
slug=payload.slug,
app_name=payload.app_name,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=payload.runtime_version,
package_manager=payload.package_manager,
setup_command=payload.setup_command,
build_command=payload.build_command,
test_command=payload.test_command,
start_command=payload.start_command,
app_port=payload.app_port,
healthcheck_path=payload.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
external_git_auth_type=payload.external_git_auth_type,
external_git_username=payload.external_git_username,
external_git_secret=payload.external_git_secret,
deploy_target_kind=payload.deploy_target_kind,
deploy_target_host=payload.deploy_target_host,
deploy_target_port=payload.deploy_target_port,
deploy_target_username=payload.deploy_target_username,
deploy_target_auth_type=payload.deploy_target_auth_type,
deploy_target_secret=payload.deploy_target_secret,
deploy_target_remote_path=payload.deploy_target_remote_path,
),
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_WIZARD_COMPLETE,
organization_id=result["organization"]["id"],
actor_user_id=user.id,
target_type="organization",
target_id=result["organization"]["id"],
metadata={
"org_name": payload.org_name,
"slug": payload.slug,
"app_name": payload.app_name,
"app_type": payload.app_type,
"language": payload.language,
"framework": payload.framework,
},
ip=ip,
user_agent=ua,
)
return WizardCompleteOut(
organization=result["organization"],
project=result["project"],
deploy_target_id=result.get("deploy_target_id"),
)
# ---------------------------------------------------------------------------
# App profile editing (org-scoped)
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def get_app_profile(
org_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.get_profile(organization_id=org_id, project_id=project.id)
return ProfileOut(**data)
@router.patch("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def patch_app_profile(
org_id: str,
payload: ProfilePatchRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
values = {k: v for k, v in payload.model_dump().items() if v is not None}
data = await svc.update_profile(
organization_id=org_id, project_id=project.id, values=values
)
return ProfileOut(**data)
@router.put("/organizations/{org_id}/app/deploy-target", response_model=DeployTargetOut)
async def put_deploy_target(
org_id: str,
payload: DeployTargetPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> DeployTargetOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_deploy_target(
organization_id=org_id,
project_id=project.id,
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
return DeployTargetOut(**data)
@router.put("/organizations/{org_id}/app/git", response_model=GitCredentialsOut)
async def put_git_credentials(
org_id: str,
payload: GitCredentialsPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> GitCredentialsOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_git_credentials(
organization_id=org_id,
project_id=project.id,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return GitCredentialsOut(**data)