diff --git a/backend/app/api/routes/publish.py b/backend/app/api/routes/publish.py index 1472993..3482feb 100644 --- a/backend/app/api/routes/publish.py +++ b/backend/app/api/routes/publish.py @@ -52,31 +52,14 @@ async def create_draft_pricing_snapshot( @router.get( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-readiness", - response_model=PublishReadinessResponse, + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", + response_model=PublishPreviewResponse, ) -async def get_draft_publish_readiness( - scheme_id: str, - expected_scheme_version_id: str | None = Query(default=None), - role: str = Depends(require_api_key), -): - scheme, version = await get_current_draft_context( - scheme_id, - expected_scheme_version_id=expected_scheme_version_id, - ) - readiness = await build_publish_readiness( - scheme_id=scheme.scheme_id, - scheme_version_id=version.scheme_version_id, - ) - return PublishReadinessResponse(**readiness) - - -@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse) async def get_publish_preview( scheme_id: str, baseline_scheme_version_id: str | None = Query(default=None), - expected_scheme_version_id: str | None = Query(default=None), refresh: bool = Query(default=False), + expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( @@ -100,7 +83,31 @@ async def get_publish_preview( ) -@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/preview", response_model=RemapPreviewResponse) +@router.get( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-readiness", + response_model=PublishReadinessResponse, +) +async def get_publish_readiness( + scheme_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + readiness = await build_publish_readiness( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + status=version.status, + ) + return PublishReadinessResponse(**readiness) + + +@router.post( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/preview", + response_model=RemapPreviewResponse, +) async def preview_draft_remap( scheme_id: str, payload: RemapPreviewRequest, @@ -128,7 +135,10 @@ async def preview_draft_remap( ) -@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/apply", response_model=RemapApplyResponse) +@router.post( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/apply", + response_model=RemapApplyResponse, +) async def apply_draft_remap( scheme_id: str, payload: RemapApplyRequest, diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index 422d63b..cf5a866 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -18,7 +18,7 @@ from app.repositories.schemes import ( rollback_scheme_to_version, unpublish_scheme, ) -from app.schemas.publish_readiness import SchemePublishActionResponse +from app.schemas.publish_readiness import PublishExecutionResponse from app.schemas.scheme_registry import ( SchemeCurrentResponse, SchemeDetailResponse, @@ -217,17 +217,18 @@ async def get_publish_validation(scheme_id: str, role: str = Depends(require_api @router.post( f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", - response_model=SchemePublishActionResponse, + response_model=PublishExecutionResponse, ) async def publish_scheme_endpoint( scheme_id: str, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - return await publish_current_draft_scheme( + result = await publish_current_draft_scheme( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) + return PublishExecutionResponse(**result) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) diff --git a/backend/app/schemas/publish_readiness.py b/backend/app/schemas/publish_readiness.py index abc558f..4b25a33 100644 --- a/backend/app/schemas/publish_readiness.py +++ b/backend/app/schemas/publish_readiness.py @@ -1,15 +1,12 @@ +from __future__ import annotations + from pydantic import BaseModel -class PublishReadinessValidationSummary(BaseModel): - sectors_count: int - groups_count: int - seats_count: int - priced_seats_count: int - unpriced_seats_count: int - duplicate_seat_ids_count: int - seats_with_missing_contract_count: int - is_publishable: bool +class PublishReadinessSnapshot(BaseModel): + available: bool + categories_count: int + rules_count: int class PublishReadinessPricingCoverage(BaseModel): @@ -19,12 +16,6 @@ class PublishReadinessPricingCoverage(BaseModel): coverage_percent: float -class PublishReadinessSnapshot(BaseModel): - available: bool - categories_count: int - rules_count: int - - class PublishReadinessFlags(BaseModel): validation_publishable: bool snapshot_available: bool @@ -38,17 +29,17 @@ class PublishReadinessResponse(BaseModel): scheme_id: str scheme_version_id: str status: str - validation_summary: PublishReadinessValidationSummary + validation_summary: dict pricing_coverage: PublishReadinessPricingCoverage snapshot: PublishReadinessSnapshot readiness: PublishReadinessFlags -class SchemePublishActionResponse(BaseModel): +class PublishExecutionResponse(BaseModel): scheme_id: str scheme_version_id: str status: str current_version_number: int published_at: str | None pricing_snapshot: dict - validation_summary: PublishReadinessValidationSummary + validation_summary: dict diff --git a/backend/app/services/api_errors.py b/backend/app/services/api_errors.py index bcbeb16..b9a9a46 100644 --- a/backend/app/services/api_errors.py +++ b/backend/app/services/api_errors.py @@ -3,40 +3,29 @@ from __future__ import annotations from fastapi import HTTPException, status -def build_error_detail( - *, - code: str, - message: str, - details: dict | None = None, -) -> dict: - payload = { +def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None: + payload: dict = { "code": code, "message": message, } if details: payload["details"] = details - return payload - -def raise_conflict( - *, - code: str, - message: str, - details: dict | None = None, -) -> None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=build_error_detail(code=code, message=message, details=details), + detail=payload, ) -def raise_unprocessable( - *, - code: str, - message: str, - details: dict | None = None, -) -> None: +def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None: + payload: dict = { + "code": code, + "message": message, + } + if details: + payload["details"] = details + raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=build_error_detail(code=code, message=message, details=details), + detail=payload, ) diff --git a/backend/app/services/publish_readiness.py b/backend/app/services/publish_readiness.py index 104f74a..5f6839a 100644 --- a/backend/app/services/publish_readiness.py +++ b/backend/app/services/publish_readiness.py @@ -1,7 +1,5 @@ from __future__ import annotations -from fastapi import HTTPException, status - from app.core.config import settings from app.repositories.scheme_seats import list_scheme_version_seats from app.repositories.scheme_version_pricing import ( @@ -12,22 +10,15 @@ from app.repositories.scheme_version_pricing import ( from app.services.scheme_validation import build_scheme_validation_report -async def build_publish_readiness( - *, - scheme_id: str, - scheme_version_id: str, -) -> dict: - validation = await build_scheme_validation_report( - scheme_id=scheme_id, - scheme_version_id=scheme_version_id, - ) +async def _build_snapshot_pricing_coverage(*, scheme_version_id: str) -> dict: seats = await list_scheme_version_seats(scheme_version_id) snapshot_categories = await list_scheme_version_snapshot_categories(scheme_version_id) snapshot_rules = await list_scheme_version_snapshot_rules(scheme_version_id) + snapshot_available = len(snapshot_categories) > 0 or len(snapshot_rules) > 0 + priced_seats = 0 unpriced_seats = 0 - snapshot_available = bool(snapshot_categories or snapshot_rules) for seat in seats: if not seat.seat_id: @@ -46,43 +37,63 @@ async def build_publish_readiness( sector_id=seat.sector_id, ) priced_seats += 1 - except HTTPException as exc: - if exc.status_code != status.HTTP_404_NOT_FOUND: - raise - unpriced_seats += 1 except Exception: unpriced_seats += 1 total_seats = len(seats) - coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 100.0 - full_pricing_coverage = unpriced_seats == 0 - pricing_gate_passed = snapshot_available and ( - full_pricing_coverage if settings.publish_require_full_pricing_coverage else True - ) - validation_publishable = bool(validation["summary"]["is_publishable"]) + coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats > 0 else 100.0 return { - "scheme_id": scheme_id, - "scheme_version_id": scheme_version_id, - "status": "draft", - "validation_summary": validation["summary"], + "snapshot": { + "available": snapshot_available, + "categories_count": len(snapshot_categories), + "rules_count": len(snapshot_rules), + }, "pricing_coverage": { "total_seats": total_seats, "priced_seats": priced_seats, "unpriced_seats": unpriced_seats, "coverage_percent": coverage_percent, }, - "snapshot": { - "available": snapshot_available, - "categories_count": len(snapshot_categories), - "rules_count": len(snapshot_rules), - }, + } + + +async def build_publish_readiness( + *, + scheme_id: str, + scheme_version_id: str, + status: str, +) -> dict: + validation = await build_scheme_validation_report( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + ) + snapshot_state = await _build_snapshot_pricing_coverage( + scheme_version_id=scheme_version_id, + ) + + validation_publishable = bool(validation["summary"]["is_publishable"]) + snapshot_available = bool(snapshot_state["snapshot"]["available"]) + full_pricing_coverage = snapshot_state["pricing_coverage"]["unpriced_seats"] == 0 + require_full_pricing_coverage = bool(settings.publish_require_full_pricing_coverage) + pricing_gate_passed = snapshot_available and ( + full_pricing_coverage if require_full_pricing_coverage else True + ) + is_ready_to_publish = validation_publishable and pricing_gate_passed + + return { + "scheme_id": scheme_id, + "scheme_version_id": scheme_version_id, + "status": status, + "validation_summary": validation["summary"], + "pricing_coverage": snapshot_state["pricing_coverage"], + "snapshot": snapshot_state["snapshot"], "readiness": { "validation_publishable": validation_publishable, "snapshot_available": snapshot_available, - "require_full_pricing_coverage": settings.publish_require_full_pricing_coverage, + "require_full_pricing_coverage": require_full_pricing_coverage, "full_pricing_coverage": full_pricing_coverage, "pricing_gate_passed": pricing_gate_passed, - "is_ready_to_publish": validation_publishable and pricing_gate_passed, + "is_ready_to_publish": is_ready_to_publish, }, } diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 50a2b64..0ad5291 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from app.repositories.audit import create_audit_event from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot from app.repositories.scheme_versions import get_current_scheme_version @@ -41,13 +43,20 @@ async def publish_current_draft_scheme( readiness = await build_publish_readiness( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, + status=version.status, ) if not readiness["readiness"]["is_ready_to_publish"]: raise_conflict( code="publish_not_ready", - message="Draft scheme does not satisfy publish readiness requirements.", - details={"readiness": readiness["readiness"]}, + message="Scheme is not ready to publish in current draft state.", + details={ + "scheme_version_id": version.scheme_version_id, + "readiness": readiness["readiness"], + "validation_summary": readiness["validation_summary"], + "pricing_coverage": readiness["pricing_coverage"], + "snapshot": readiness["snapshot"], + }, ) snapshot = await replace_scheme_version_pricing_snapshot( diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 5c3da68..6f75c1e 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -56,14 +56,18 @@ ## app/api/routes/publish.py - POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness - GET /api/v1/schemes/{scheme_id}/draft/publish-preview +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness - POST /api/v1/schemes/{scheme_id}/draft/remap/preview - POST /api/v1/schemes/{scheme_id}/draft/remap/apply ## app/api/routes/editor.py - GET /api/v1/schemes/{scheme_id}/draft/structure - GET /api/v1/schemes/{scheme_id}/draft/compare-preview +- GET /api/v1/schemes/{scheme_id}/draft/validation +- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} +- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} +- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} - POST /api/v1/schemes/{scheme_id}/draft/sectors - POST /api/v1/schemes/{scheme_id}/draft/groups - DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} @@ -73,10 +77,6 @@ - PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} - PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} - POST /api/v1/schemes/{scheme_id}/draft/repair-references -- GET /api/v1/schemes/{scheme_id}/draft/validation -- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} ## app/api/routes/admin.py - GET /api/v1/admin/schemes/{scheme_id}/current/artifacts diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index a711d3b..d472d76 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -31,44 +31,129 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772" - GET /api/v1/schemes/{scheme_id}/current -> 200 - GET /api/v1/schemes/{scheme_id}/versions -> 200 +Validate: +- scheme_id is stable +- current version exists +- version list contains current version +- status and counts are consistent + ## 3. Structure read model - GET /api/v1/schemes/{scheme_id}/current/sectors -> 200 - GET /api/v1/schemes/{scheme_id}/current/groups -> 200 - GET /api/v1/schemes/{scheme_id}/current/seats -> 200 +Validate: +- total counts are non-negative +- known sample scheme returns expected object lists +- seats contain seat_id / sector_id / group_id contract where applicable + ## 4. SVG / display pipeline - GET /api/v1/schemes/{scheme_id}/current/svg -> 200 - GET /api/v1/schemes/{scheme_id}/current/svg/display -> 200 - GET /api/v1/schemes/{scheme_id}/current/svg/display/meta -> 200 +- GET /api/v1/schemes/{scheme_id}/current/svg/display?mode=optimized -> 200 or explicit controlled failure +- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta?mode=optimized -> 200 or explicit controlled failure + +Validate: +- response content type for svg endpoints is image/svg+xml +- meta returns scheme_id, scheme_version_id, view_box, width, height +- no 500 on passthrough mode +- unsupported mode returns 422 ## 5. Pricing read model - GET /api/v1/schemes/{scheme_id}/pricing -> 200 +- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 for priced seat +- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for priced and unpriced seat - GET /api/v1/schemes/{scheme_id}/pricing/coverage -> 200 - GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats -> 200 - GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 for priced seat -- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known priced and unpriced seats -## 6. Draft publish / readiness +Validate: +- pricing bundle contains categories and rules arrays +- effective seat price resolves according to domain priority +- test seat preview explains selectable / has_price state +- coverage endpoint is internally consistent +- explain endpoint returns matched_rule for priced seat and null for unpriced seat + +## 6. Draft editor read/write guards + +- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/compare-preview -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 +- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} with unknown sector_id -> 422 +- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk with unknown group_id -> 422 +- POST /api/v1/schemes/{scheme_id}/draft/remap/preview with unknown target group -> 422 +- POST /api/v1/schemes/{scheme_id}/draft/sectors duplicate sector_id -> 422 +- POST /api/v1/schemes/{scheme_id}/draft/groups duplicate group_id -> 422 + +Validate: +- stale expected_scheme_version_id returns 409 on guarded draft endpoints +- duplicate/reference failures return typed detail payloads +- successful read endpoints stay stable after failed mutations + +## 7. Draft publish preview / readiness - GET /api/v1/schemes/{scheme_id}/publish/validation -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 - POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot -> 200 when scheme is in draft - GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true -> 200 - GET /api/v1/schemes/{scheme_id}/draft/publish-preview -> 200 -- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id=deadbeef... -> 409 typed stale conflict +- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true&baseline_scheme_version_id={published_version_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness?expected_scheme_version_id={current_version_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness?expected_scheme_version_id=deadbeef... -> 409 -## 7. Admin / ops +Validate: +- refresh and cached read both succeed +- preview summary contains is_publishable / has_structure_changes / has_artifacts / snapshot_available +- pricing_coverage is internally consistent +- baseline override returns override strategy when explicit baseline is provided +- preview retention does not grow unbounded for same version+variant +- readiness returns validation_summary, pricing_coverage, snapshot, readiness flags + +## 8. Publish / version lifecycle + +- POST /api/v1/schemes/{scheme_id}/versions?expected_current_scheme_version_id=deadbeef... -> 409 +- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id=deadbeef... -> 409 +- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={current_version_id} -> 200 when environment is ready + +Validate: +- stale protection returns typed 409 payload +- successful publish returns scheme_id, scheme_version_id, status, current_version_number, published_at, pricing_snapshot, validation_summary +- after publish, current scheme status is published +- audit contains scheme.published event for the same scheme_version_id + +## 9. Admin / ops - GET /api/v1/admin/schemes/{scheme_id}/current/artifacts -> 200 - GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200 - GET /api/v1/admin/artifacts/publish-preview/audit -> 200 - POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200 -## 8. Fail criteria +Optional: +- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate?mode=passthrough -> 200 +- POST /api/v1/admin/display/backfill?mode=passthrough&limit=10&only_missing=true -> 200 + +Validate: +- audit endpoint does not report orphan files or missing files for DB rows in normal state +- validation report is readable and deterministic +- admin routes do not produce 500 for healthy scheme state + +## 10. Audit trail + +- GET /api/v1/schemes/{scheme_id}/audit -> 200 + +Validate: +- recent publish preview / pricing / version events are present when corresponding operations were run +- audit total is non-negative +- event payloads stay JSON-serializable + +## 11. Fail criteria Regression is considered failed if any of the following happen: @@ -76,6 +161,18 @@ Regression is considered failed if any of the following happen: - any stable read endpoint returns 500 - passthrough display endpoint fails on known-good sample - publish preview refresh or cached read returns 500 -- readiness endpoint contract breaks unexpectedly -- pricing diagnostics endpoints fail on healthy environment -- publish stale guard is not typed and deterministic +- pricing bundle contract changes unexpectedly +- admin audit/cleanup endpoints fail on healthy environment +- artifact retention grows without bound for repeated preview refresh on same variant +- publish readiness says ready but guarded publish fails for non-stale reasons + +## 12. Operator note + +Run this checklist after: +- schema changes +- pricing schema/repository refactors +- artifact lifecycle changes +- display pipeline changes +- route reorganization +- startup/import/config changes +- publish lifecycle changes