Files
breakpilot-compliance/backend-compliance/compliance/api/project_routes.py
Benjamin Admin 0affa4eb66
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
feat(sdk): Multi-Projekt-Architektur — mehrere Projekte pro Tenant
Jeder Tenant kann jetzt mehrere Compliance-Projekte anlegen (z.B. verschiedene
Produkte, Tochterunternehmen). CompanyProfile ist pro Projekt kopierbar und
danach unabhaengig editierbar. Multi-Tab-Support via separater BroadcastChannel
und localStorage Keys pro Projekt.

- Migration 039: compliance_projects Tabelle, sdk_states.project_id
- Backend: FastAPI CRUD-Routes fuer Projekte mit Tenant-Isolation
- Frontend: ProjectSelector UI, SDKProvider mit projectId, URL ?project=
- State API: UPSERT auf (tenant_id, project_id) mit Abwaertskompatibilitaet
- Tests: pytest fuer Model-Validierung, Row-Konvertierung, Tenant-Isolation
- Docs: MKDocs Seite, CLAUDE.md, Backend README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:53:50 +01:00

301 lines
9.9 KiB
Python

"""
FastAPI routes for Compliance Projects (Multi-Projekt-Architektur).
Endpoints:
- GET /v1/projects → List all projects for a tenant
- POST /v1/projects → Create a new project
- GET /v1/projects/{project_id} → Get a single project
- PATCH /v1/projects/{project_id} → Update project (name, description)
- DELETE /v1/projects/{project_id} → Archive project (soft delete)
"""
import json
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from database import SessionLocal
from .tenant_utils import get_tenant_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/projects", tags=["projects"])
# =============================================================================
# REQUEST/RESPONSE MODELS
# =============================================================================
class CreateProjectRequest(BaseModel):
name: str
description: str = ""
customer_type: str = "new" # 'new' | 'existing'
copy_from_project_id: Optional[str] = None # Copy company profile from existing project
class UpdateProjectRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ProjectResponse(BaseModel):
id: str
tenant_id: str
name: str
description: str
customer_type: str
status: str
project_version: int
completion_percentage: int
created_at: str
updated_at: str
# =============================================================================
# HELPERS
# =============================================================================
def _row_to_response(row) -> dict:
"""Convert a DB row to ProjectResponse dict."""
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"name": row.name,
"description": row.description or "",
"customer_type": row.customer_type or "new",
"status": row.status or "active",
"project_version": row.project_version or 1,
"completion_percentage": row.completion_percentage or 0,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
async def list_projects(
tenant_id: str = Depends(get_tenant_id),
include_archived: bool = False,
):
"""List all projects for the tenant."""
db = SessionLocal()
try:
if include_archived:
query = text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE tenant_id = :tenant_id AND status != 'deleted'
ORDER BY created_at DESC
""")
else:
query = text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE tenant_id = :tenant_id AND status = 'active'
ORDER BY created_at DESC
""")
result = db.execute(query, {"tenant_id": tenant_id})
rows = result.fetchall()
return {
"projects": [_row_to_response(row) for row in rows],
"total": len(rows),
}
finally:
db.close()
@router.post("", status_code=201)
async def create_project(
body: CreateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Create a new compliance project.
Optionally copies the company profile (companyProfile) from an existing
project's sdk_states into the new project's state. This allows a tenant
to start a new project for a subsidiary with the same base data.
"""
db = SessionLocal()
try:
# Create the project row
result = db.execute(
text("""
INSERT INTO compliance_projects
(tenant_id, name, description, customer_type, status)
VALUES
(:tenant_id, :name, :description, :customer_type, 'active')
RETURNING id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
"""),
{
"tenant_id": tenant_id,
"name": body.name,
"description": body.description,
"customer_type": body.customer_type,
},
)
project_row = result.fetchone()
project_id = str(project_row.id)
# Build initial SDK state
initial_state = {
"version": "1.0.0",
"projectVersion": 1,
"tenantId": tenant_id,
"projectId": project_id,
"customerType": body.customer_type,
"companyProfile": None,
}
# If copy_from_project_id is provided, copy company profile
if body.copy_from_project_id:
source = db.execute(
text("""
SELECT state FROM sdk_states
WHERE tenant_id = :tenant_id AND project_id = :project_id
"""),
{
"tenant_id": tenant_id,
"project_id": body.copy_from_project_id,
},
).fetchone()
if source and source.state:
source_state = source.state if isinstance(source.state, dict) else json.loads(source.state)
if "companyProfile" in source_state:
initial_state["companyProfile"] = source_state["companyProfile"]
if "customerType" in source_state:
initial_state["customerType"] = source_state["customerType"]
# Create the sdk_states row for this project
db.execute(
text("""
INSERT INTO sdk_states (tenant_id, project_id, state, version, created_at, updated_at)
VALUES (:tenant_id, :project_id, :state::jsonb, 1, NOW(), NOW())
"""),
{
"tenant_id": tenant_id,
"project_id": project_id,
"state": json.dumps(initial_state),
},
)
db.commit()
logger.info("Created project %s for tenant %s", project_id, tenant_id)
return _row_to_response(project_row)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}")
async def get_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Get a single project by ID (tenant-scoped)."""
db = SessionLocal()
try:
result = db.execute(
text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
"""),
{"project_id": project_id, "tenant_id": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found")
return _row_to_response(row)
finally:
db.close()
@router.patch("/{project_id}")
async def update_project(
project_id: str,
body: UpdateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Update project name/description."""
db = SessionLocal()
try:
# Build SET clause dynamically
updates = {}
set_parts = ["updated_at = NOW()"]
if body.name is not None:
set_parts.append("name = :name")
updates["name"] = body.name
if body.description is not None:
set_parts.append("description = :description")
updates["description"] = body.description
updates["project_id"] = project_id
updates["tenant_id"] = tenant_id
result = db.execute(
text(f"""
UPDATE compliance_projects
SET {', '.join(set_parts)}
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
RETURNING id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
"""),
updates,
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found")
db.commit()
return _row_to_response(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/{project_id}")
async def archive_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Soft-delete (archive) a project."""
db = SessionLocal()
try:
result = db.execute(
text("""
UPDATE compliance_projects
SET status = 'archived', archived_at = NOW(), updated_at = NOW()
WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'active'
RETURNING id
"""),
{"project_id": project_id, "tenant_id": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found or already archived")
db.commit()
return {"success": True, "id": str(row.id), "status": "archived"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()