"""Project, repository and preview-domain endpoints. All routes are nested under ``/organizations/{org_id}/projects`` so the tenant context is resolved by ``get_org_context`` before any handler body runs. """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.application.services.project_service import ProjectService from app.core.database import get_session from app.core.errors import NotFoundError from app.domain.audit.models import AuditAction from app.domain.identity.models import CAN_MANAGE_PROJECTS from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service router = APIRouter( prefix="/organizations/{org_id}/projects", tags=["projects"] ) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class ProjectOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str organization_id: str name: str slug: str description: str default_branch: str created_at: datetime class CreateProjectRequest(BaseModel): name: str = Field(min_length=2, max_length=160) description: str = Field(default="", max_length=2000) class RepoOut(BaseModel): id: str provider: str owner: str repo_name: str clone_url: str web_url: str default_branch: str private: bool = True class RepoStatusOut(BaseModel): exists: bool private: bool default_branch: str branches: list[str] open_pull_requests: int class CreateBranchRequest(BaseModel): branch_name: str = Field(min_length=1, max_length=120) from_branch: str = Field(default="main", max_length=120) class BranchOut(BaseModel): name: str sha: str class OpenPullRequestRequest(BaseModel): head: str = Field(min_length=1, max_length=120) base: str = Field(default="main", max_length=120) title: str = Field(min_length=1, max_length=255) body: str = Field(default="", max_length=10_000) class PullRequestOut(BaseModel): number: int url: str state: str class PreviewDomainOut(BaseModel): id: str host: str port: int status: str protected: bool url: str class CreatePreviewDomainRequest(BaseModel): subdomain: str = Field(min_length=3, max_length=40) port: int | None = Field(default=None, ge=1, le=65535) class AllocatePortRequest(BaseModel): service_name: str = Field(default="web", max_length=80) class PortOut(BaseModel): port: int service_name: str # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED) async def create_project( payload: CreateProjectRequest, request: Request, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), session: AsyncSession = Depends(get_session), ) -> ProjectOut: context.require(CAN_MANAGE_PROJECTS) project = await service.create_project( organization_id=context.org_id, organization_slug=context.organization.slug, created_by_user_id=context.user.id, name=payload.name, description=payload.description, ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id, actor_user_id=context.user.id, target_type="project", target_id=project.id, metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua, ) return ProjectOut( id=project.id, organization_id=project.organization_id, name=project.name, slug=project.slug, description=project.description, default_branch=project.default_branch, created_at=project.created_at, ) @router.get("", response_model=list[ProjectOut]) async def list_projects( context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), ) -> list[ProjectOut]: projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset) return [ ProjectOut( id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug, description=p.description, default_branch=p.default_branch, created_at=p.created_at, ) for p in projects ] @router.get("/{project_id}", response_model=ProjectOut) async def get_project( project_id: str, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> ProjectOut: project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) return ProjectOut( id=project.id, organization_id=project.organization_id, name=project.name, slug=project.slug, description=project.description, default_branch=project.default_branch, created_at=project.created_at, ) # --------------------------------------------------------------------------- # Repository # --------------------------------------------------------------------------- @router.post("/{project_id}/repo/ensure", response_model=RepoOut) async def ensure_repo( project_id: str, request: Request, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), session: AsyncSession = Depends(get_session), ) -> RepoOut: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) repo = await service.ensure_repository( organization_slug=context.organization.slug, project=project ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.REPO_CREATE, organization_id=context.org_id, actor_user_id=context.user.id, target_type="repository", target_id=repo.id, metadata={"repo_name": repo.repo_name, "provider": repo.provider}, ip=ip, user_agent=ua, ) return RepoOut( id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url, web_url=repo.web_url, default_branch=repo.default_branch, private=True, ) @router.get("/{project_id}/repo", response_model=RepoOut) async def get_repo( project_id: str, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> RepoOut: project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) repo = await service.repos.get_for_project(project.id) if repo is None: raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.") # clone_url is a plain https URL; credentials are never included. return RepoOut( id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url, web_url=repo.web_url, default_branch=repo.default_branch, private=True, ) @router.get("/{project_id}/repo/status", response_model=RepoStatusOut) async def get_repo_status( project_id: str, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> RepoStatusOut: project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) status_result = await service.repo_status(project=project) return RepoStatusOut( exists=status_result.exists, private=status_result.private, default_branch=status_result.default_branch, branches=list(status_result.branches), open_pull_requests=status_result.open_pull_requests, ) @router.post("/{project_id}/repo/branches", response_model=BranchOut) async def create_branch( project_id: str, payload: CreateBranchRequest, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> BranchOut: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) branch = await service.git.create_branch( project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch ) return BranchOut(name=branch.name, sha=branch.sha) @router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut) async def open_pull_request( project_id: str, payload: OpenPullRequestRequest, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> PullRequestOut: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) pr = await service.git.open_pull_request( project_id=project.id, head=payload.head, base=payload.base, title=payload.title, body=payload.body, ) return PullRequestOut(number=pr.number, url=pr.url, state=pr.state) # --------------------------------------------------------------------------- # Preview domains # --------------------------------------------------------------------------- @router.post("/{project_id}/preview/ports", response_model=PortOut) async def allocate_port( project_id: str, payload: AllocatePortRequest, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> PortOut: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) port = await service.allocate_port(project=project, service_name=payload.service_name) return PortOut(port=port, service_name=payload.service_name) @router.post( "/{project_id}/preview/domains", response_model=PreviewDomainOut, status_code=status.HTTP_201_CREATED, ) async def create_preview_domain( project_id: str, payload: CreatePreviewDomainRequest, request: Request, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), session: AsyncSession = Depends(get_session), ) -> PreviewDomainOut: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) record = await service.create_preview_domain( project=project, requested_subdomain=payload.subdomain, created_by_user_id=context.user.id, port=payload.port, ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id, actor_user_id=context.user.id, target_type="preview_domain", target_id=record["id"], metadata={"host": record["host"], "port": record["port"]}, ip=ip, user_agent=ua, ) return PreviewDomainOut( id=record["id"], host=record["host"], port=record["port"], status=record["status"], protected=record["protected"], url=record["url"], ) @router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut]) async def list_preview_domains( project_id: str, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), ) -> list[PreviewDomainOut]: project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) records = await service.list_preview_domains(project=project) return [ PreviewDomainOut( id=r["id"], host=r["host"], port=r["port"], status=r["status"], protected=r["protected"], url=r["url"], ) for r in records ] @router.delete( "/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT ) async def delete_preview_domain( project_id: str, domain_id: str, request: Request, context: OrgContext = Depends(get_org_context), service: ProjectService = Depends(get_project_service), session: AsyncSession = Depends(get_session), ) -> None: context.require(CAN_MANAGE_PROJECTS) project = await service.get_or_404(organization_id=context.org_id, project_id=project_id) await service.remove_preview_domain(project=project, domain_id=domain_id) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id, actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id, ip=ip, user_agent=ua, )