Files
breakpilot-compliance/backend-compliance/compliance/api/project_routes.py
Benjamin Admin 09cfb79840
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 35s
CI / test-python-backend-compliance (push) Successful in 38s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 24s
feat: Projekt-Verwaltung verbessern — Archivieren, Loeschen, Wiederherstellen
- Backend: Restore-Endpoint (POST /projects/{id}/restore) und
  Hard-Delete-Endpoint (DELETE /projects/{id}/permanent) hinzugefuegt
- Frontend: Dreistufiger Dialog (Archivieren / Endgueltig loeschen mit
  Bestaetigungsdialog) statt einfachem Loeschen
- Archivierte Projekte aufklappbar in der Projektliste mit
  Wiederherstellen-Button
- CustomerTypeSelector entfernt (redundant seit Multi-Projekt)
- Default tenantId von 'default' auf UUID geaendert (Backend-400-Fix)
- SQL-Cast :state::jsonb durch CAST(:state AS jsonb) ersetzt (SQLAlchemy-Fix)
- snake_case/camelCase-Mapping fuer Backend-Response (NaN-Datum-Fix)
- projectInfo wird beim Laden vom Backend geholt (Header zeigt Projektname)
- API-Client erzeugt sich on-demand (Race-Condition-Fix fuer Projektliste)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:48:02 +01:00

377 lines
12 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, CAST(:state AS 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()
@router.post("/{project_id}/restore")
async def restore_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Restore an archived project back to active."""
db = SessionLocal()
try:
result = db.execute(
text("""
UPDATE compliance_projects
SET status = 'active', archived_at = NULL, updated_at = NOW()
WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'archived'
RETURNING id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
"""),
{"project_id": project_id, "tenant_id": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found or not archived")
db.commit()
logger.info("Restored project %s for tenant %s", project_id, tenant_id)
return _row_to_response(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/{project_id}/permanent")
async def permanently_delete_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Permanently delete a project and all associated data."""
db = SessionLocal()
try:
# Verify project exists and belongs to tenant
check = db.execute(
text("""
SELECT id FROM compliance_projects
WHERE id = :project_id AND tenant_id = :tenant_id
"""),
{"project_id": project_id, "tenant_id": tenant_id},
).fetchone()
if not check:
raise HTTPException(status_code=404, detail="Project not found")
# Delete sdk_states (CASCADE should handle this, but be explicit)
db.execute(
text("DELETE FROM sdk_states WHERE project_id = :project_id AND tenant_id = :tenant_id"),
{"project_id": project_id, "tenant_id": tenant_id},
)
# Delete the project itself
db.execute(
text("DELETE FROM compliance_projects WHERE id = :project_id AND tenant_id = :tenant_id"),
{"project_id": project_id, "tenant_id": tenant_id},
)
db.commit()
logger.info("Permanently deleted project %s for tenant %s", project_id, tenant_id)
return {"success": True, "id": project_id, "status": "deleted"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()