compliance/api/company_profile_routes.py (640 LOC) -> 154 LOC thin routes.
Unusual for this repo: persistence uses raw SQL via sqlalchemy.text()
because the underlying compliance_company_profiles table has ~45 columns
with complex jsonb coercion and there is no SQLAlchemy model for it.
New files:
compliance/schemas/company_profile.py (127) — 4 request/response models
compliance/services/company_profile_service.py (340) — Service class + row_to_response + log_audit
compliance/services/_company_profile_sql.py (139) — 70-line INSERT/UPDATE statements
separated for readability
Minor behavioral improvement: the handlers now use Depends(get_db) for
session management instead of the bespoke `db = SessionLocal(); try: ...
finally: db.close()` pattern. This makes the routes consistent with
every other refactored service, fixes the broken-ness under test
dependency_overrides, and removes 6 duplicate try/finally blocks.
Legacy exports preserved: CompanyProfileRequest, CompanyProfileResponse,
AuditEntryResponse, AuditListResponse, row_to_response, and log_audit are
re-exported from compliance.api.company_profile_routes so that the two
existing test files
(tests/test_company_profile_routes.py, tests/test_company_profile_extend.py)
keep importing from the same path.
Pre-existing broken tests noted: 6 tests in those files feed a 40-tuple
row into row_to_response, but _BASE_COLUMNS_LIST has 46 columns (has had
since the Phase 2 Stammdaten extension). These tests fail on main too
(verified via `git stash` round-trip). Not fixed in this commit — they
require a rewrite of the test's _make_row helper, which is out of scope
for a pure structural refactor. Flagged for follow-up.
Verified:
- 173/173 pytest compliance/tests/ tests/contracts/ pass
- OpenAPI 360/484 unchanged
- mypy compliance/ -> Success on 127 source files
- company_profile_routes.py 640 -> 154 LOC
- All new files under soft 300 target except service (340, under hard 500)
- Hard-cap violations: 15 -> 14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
"""
|
|
FastAPI routes for Company Profile CRUD with audit logging.
|
|
|
|
Endpoints:
|
|
- GET /v1/company-profile - Get company profile
|
|
- POST /v1/company-profile - Create or update (upsert)
|
|
- PATCH /v1/company-profile - Partial update
|
|
- DELETE /v1/company-profile - Delete (DSGVO Art. 17)
|
|
- GET /v1/company-profile/audit - Audit log for changes
|
|
- GET /v1/company-profile/template-context - Flat dict for Jinja2
|
|
|
|
Phase 1 Step 4 refactor: handlers delegate to CompanyProfileService.
|
|
Legacy helper + schema names are re-exported so existing test imports
|
|
(``from compliance.api.company_profile_routes import
|
|
CompanyProfileRequest, row_to_response, log_audit``) continue to work.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.company_profile import (
|
|
AuditEntryResponse,
|
|
AuditListResponse,
|
|
CompanyProfileRequest,
|
|
CompanyProfileResponse,
|
|
)
|
|
from compliance.services.company_profile_service import (
|
|
CompanyProfileService,
|
|
log_audit,
|
|
row_to_response,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/v1/company-profile", tags=["company-profile"])
|
|
|
|
|
|
def get_company_profile_service(
|
|
db: Session = Depends(get_db),
|
|
) -> CompanyProfileService:
|
|
return CompanyProfileService(db)
|
|
|
|
|
|
def _resolve_ids(
|
|
tenant_id: str, x_tenant_id: Optional[str], project_id: Optional[str]
|
|
) -> tuple[str, Optional[str]]:
|
|
"""Resolve tenant_id and project_id from params/headers."""
|
|
tid = x_tenant_id or tenant_id
|
|
pid = project_id if project_id and project_id != "null" else None
|
|
return tid, pid
|
|
|
|
|
|
# =============================================================================
|
|
# ROUTES
|
|
# =============================================================================
|
|
|
|
@router.get("", response_model=CompanyProfileResponse)
|
|
async def get_company_profile(
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> CompanyProfileResponse:
|
|
"""Get company profile for a tenant (optionally per project)."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
|
|
with translate_domain_errors():
|
|
return service.get(tid, pid)
|
|
|
|
|
|
@router.post("", response_model=CompanyProfileResponse)
|
|
async def upsert_company_profile(
|
|
profile: CompanyProfileRequest,
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> CompanyProfileResponse:
|
|
"""Create or update company profile (upsert)."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id or profile.project_id)
|
|
with translate_domain_errors():
|
|
return service.upsert(tid, pid, profile)
|
|
|
|
|
|
@router.delete("", status_code=200)
|
|
async def delete_company_profile(
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> dict[str, Any]:
|
|
"""Delete company profile for a tenant (DSGVO Recht auf Loeschung, Art. 17)."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
|
|
with translate_domain_errors():
|
|
return service.delete(tid, pid)
|
|
|
|
|
|
@router.get("/template-context")
|
|
async def get_template_context(
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> dict[str, Any]:
|
|
"""Return flat dict for Jinja2 template substitution in document generation."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
|
|
with translate_domain_errors():
|
|
return service.template_context(tid, pid)
|
|
|
|
|
|
@router.get("/audit", response_model=AuditListResponse)
|
|
async def get_audit_log(
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> AuditListResponse:
|
|
"""Get audit log for company profile changes."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
|
|
with translate_domain_errors():
|
|
return service.audit_log(tid, pid)
|
|
|
|
|
|
@router.patch("", response_model=CompanyProfileResponse)
|
|
async def patch_company_profile(
|
|
updates: dict[str, Any],
|
|
tenant_id: str = "default",
|
|
project_id: Optional[str] = Query(None),
|
|
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
|
service: CompanyProfileService = Depends(get_company_profile_service),
|
|
) -> CompanyProfileResponse:
|
|
"""Partial update for company profile."""
|
|
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id or updates.get("project_id"))
|
|
with translate_domain_errors():
|
|
return service.patch(tid, pid, updates)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Legacy re-exports for tests that imported directly from this module.
|
|
# Do not add new imports to this list — import from the new home instead.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
__all__ = [
|
|
"router",
|
|
"CompanyProfileRequest",
|
|
"CompanyProfileResponse",
|
|
"AuditEntryResponse",
|
|
"AuditListResponse",
|
|
"row_to_response",
|
|
"log_audit",
|
|
]
|