Files
breakpilot-compliance/backend-compliance/compliance/api/company_profile_routes.py
Sharang Parnerkar f39c7ca40c refactor(backend/api): extract CompanyProfileService (Step 4 — file 4 of 18)
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>
2026-04-07 19:47:29 +02:00

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",
]