feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -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("اتصال به آدرسهای داخلی مجاز نیست.")
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user