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
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>
301 lines
9.9 KiB
Python
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()
|