feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -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