feat(backend): add publish readiness contract and guarded publish flow

add backend readiness contract for publish prechecks

guard publish flow with explicit validation and version-aware checks
make publish behavior more predictable for clients and safer against stale state
This commit is contained in:
greebo
2026-03-19 20:41:08 +03:00
parent 8d4255181b
commit ac3a62f108
8 changed files with 224 additions and 116 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
},
}

View File

@@ -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(