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
@@ -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)