feat(backend): add publish readiness endpoint and enforce publish gate contract
add backend endpoint for publish readiness checks enforce publish gate contract before version publication make publish preconditions explicit and consistent for clients
This commit is contained in:
@@ -11,9 +11,11 @@ from app.schemas.publish_preview import (
|
|||||||
RemapPreviewResponse,
|
RemapPreviewResponse,
|
||||||
RemapPreviewSeatItem,
|
RemapPreviewSeatItem,
|
||||||
)
|
)
|
||||||
|
from app.schemas.publish_readiness import PublishReadinessResponse
|
||||||
from app.security.auth import require_api_key
|
from app.security.auth import require_api_key
|
||||||
from app.services.draft_guard import get_current_draft_context
|
from app.services.draft_guard import get_current_draft_context
|
||||||
from app.services.publish_preview import get_or_build_publish_preview_bundle
|
from app.services.publish_preview import get_or_build_publish_preview_bundle
|
||||||
|
from app.services.publish_readiness import build_publish_readiness
|
||||||
from app.services.remap_service import apply_remap, preview_remap
|
from app.services.remap_service import apply_remap, preview_remap
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,7 +28,7 @@ async def create_draft_pricing_snapshot(
|
|||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(
|
scheme, version = await get_current_draft_context(
|
||||||
scheme_id=scheme_id,
|
scheme_id,
|
||||||
expected_scheme_version_id=expected_scheme_version_id,
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
)
|
)
|
||||||
result = await replace_scheme_version_pricing_snapshot(
|
result = await replace_scheme_version_pricing_snapshot(
|
||||||
@@ -49,6 +51,35 @@ async def create_draft_pricing_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-readiness",
|
||||||
|
response_model=PublishReadinessResponse,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
require_full_pricing_coverage=settings.publish_require_full_pricing_coverage,
|
||||||
|
)
|
||||||
|
return PublishReadinessResponse(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
status=version.status,
|
||||||
|
validation_summary=readiness["validation_summary"],
|
||||||
|
pricing_coverage=readiness["pricing_coverage"],
|
||||||
|
snapshot=readiness["snapshot"],
|
||||||
|
readiness=readiness["readiness"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse)
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse)
|
||||||
async def get_publish_preview(
|
async def get_publish_preview(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
@@ -58,7 +89,7 @@ async def get_publish_preview(
|
|||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(
|
scheme, version = await get_current_draft_context(
|
||||||
scheme_id=scheme_id,
|
scheme_id,
|
||||||
expected_scheme_version_id=expected_scheme_version_id,
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
)
|
)
|
||||||
bundle = await get_or_build_publish_preview_bundle(
|
bundle = await get_or_build_publish_preview_bundle(
|
||||||
@@ -86,7 +117,7 @@ async def preview_draft_remap(
|
|||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(
|
scheme, version = await get_current_draft_context(
|
||||||
scheme_id=scheme_id,
|
scheme_id,
|
||||||
expected_scheme_version_id=expected_scheme_version_id,
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
)
|
)
|
||||||
items = await preview_remap(
|
items = await preview_remap(
|
||||||
@@ -114,7 +145,7 @@ async def apply_draft_remap(
|
|||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(
|
scheme, version = await get_current_draft_context(
|
||||||
scheme_id=scheme_id,
|
scheme_id,
|
||||||
expected_scheme_version_id=expected_scheme_version_id,
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
)
|
)
|
||||||
items = await apply_remap(
|
items = await apply_remap(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.repositories.audit import create_audit_event
|
from app.repositories.audit import create_audit_event
|
||||||
@@ -146,10 +146,8 @@ async def create_next_scheme_version_endpoint(
|
|||||||
current_version_number=current_scheme.current_version_number,
|
current_version_number=current_scheme.current_version_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if expected_current_scheme_version_id and expected_current_scheme_version_id != current_version.scheme_version_id:
|
||||||
expected_current_scheme_version_id is not None
|
from fastapi import HTTPException, status
|
||||||
and expected_current_scheme_version_id != current_version.scheme_version_id
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail={
|
detail={
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
storage_root_dir: str = "/data"
|
storage_root_dir: str = "/data"
|
||||||
publish_preview_retention_per_variant: int = 2
|
publish_preview_retention_per_variant: int = 2
|
||||||
|
publish_require_full_pricing_coverage: bool = False
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
11
backend/app/schemas/publish_readiness.py
Normal file
11
backend/app/schemas/publish_readiness.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PublishReadinessResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
scheme_version_id: str
|
||||||
|
status: str
|
||||||
|
validation_summary: dict
|
||||||
|
pricing_coverage: dict
|
||||||
|
snapshot: dict
|
||||||
|
readiness: dict
|
||||||
@@ -1,39 +1,40 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
def raise_conflict(*, code: str, message: str, **extra) -> None:
|
def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None:
|
||||||
detail = {
|
payload = {
|
||||||
"code": code,
|
"code": code,
|
||||||
"message": message,
|
"message": message,
|
||||||
**extra,
|
|
||||||
}
|
}
|
||||||
|
if details:
|
||||||
|
payload.update(details)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=detail,
|
detail=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def raise_unprocessable(*, code: str, message: str, **extra) -> None:
|
def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None:
|
||||||
detail = {
|
payload = {
|
||||||
"code": code,
|
"code": code,
|
||||||
"message": message,
|
"message": message,
|
||||||
**extra,
|
|
||||||
}
|
}
|
||||||
|
if details:
|
||||||
|
payload.update(details)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=detail,
|
detail=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def raise_not_found(*, code: str, message: str, **extra) -> None:
|
def raise_publish_not_ready(*, reason: str, details: dict) -> None:
|
||||||
detail = {
|
|
||||||
"code": code,
|
|
||||||
"message": message,
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=detail,
|
detail={
|
||||||
|
"code": "publish_not_ready",
|
||||||
|
"message": reason,
|
||||||
|
"details": details,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
86
backend/app/services/publish_readiness.py
Normal file
86
backend/app/services/publish_readiness.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.repositories.scheme_seats import list_scheme_version_seats
|
||||||
|
from app.repositories.scheme_version_pricing import (
|
||||||
|
find_effective_snapshot_price_rule,
|
||||||
|
list_scheme_version_snapshot_categories,
|
||||||
|
list_scheme_version_snapshot_rules,
|
||||||
|
)
|
||||||
|
from app.services.scheme_validation import build_scheme_validation_report
|
||||||
|
|
||||||
|
|
||||||
|
async def build_publish_readiness(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
scheme_version_id: str,
|
||||||
|
require_full_pricing_coverage: bool,
|
||||||
|
) -> dict:
|
||||||
|
validation = await build_scheme_validation_report(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for seat in seats:
|
||||||
|
if not seat.seat_id:
|
||||||
|
unpriced_seats += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not snapshot_available:
|
||||||
|
unpriced_seats += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await find_effective_snapshot_price_rule(
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
seat_id=seat.seat_id,
|
||||||
|
group_id=seat.group_id,
|
||||||
|
sector_id=seat.sector_id,
|
||||||
|
)
|
||||||
|
priced_seats += 1
|
||||||
|
except Exception:
|
||||||
|
unpriced_seats += 1
|
||||||
|
|
||||||
|
total_seats = len(seats)
|
||||||
|
coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 0.0
|
||||||
|
|
||||||
|
validation_publishable = bool(validation["summary"]["is_publishable"])
|
||||||
|
full_pricing_coverage = unpriced_seats == 0
|
||||||
|
pricing_gate_passed = full_pricing_coverage if require_full_pricing_coverage else True
|
||||||
|
|
||||||
|
is_ready_to_publish = (
|
||||||
|
validation_publishable
|
||||||
|
and snapshot_available
|
||||||
|
and pricing_gate_passed
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"validation_summary": validation["summary"],
|
||||||
|
"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),
|
||||||
|
},
|
||||||
|
"readiness": {
|
||||||
|
"validation_publishable": validation_publishable,
|
||||||
|
"snapshot_available": snapshot_available,
|
||||||
|
"require_full_pricing_coverage": require_full_pricing_coverage,
|
||||||
|
"full_pricing_coverage": full_pricing_coverage,
|
||||||
|
"pricing_gate_passed": pricing_gate_passed,
|
||||||
|
"is_ready_to_publish": is_ready_to_publish,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
from app.core.config import settings
|
||||||
from app.repositories.audit import create_audit_event
|
from app.repositories.audit import create_audit_event
|
||||||
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
|
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
|
||||||
from app.services.draft_guard import get_current_draft_context
|
from app.repositories.scheme_versions import get_current_scheme_version
|
||||||
from app.repositories.schemes import publish_scheme
|
from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme
|
||||||
from app.services.api_errors import raise_conflict
|
from app.services.api_errors import raise_publish_not_ready
|
||||||
from app.services.scheme_validation import build_scheme_validation_report
|
from app.services.publish_readiness import build_publish_readiness
|
||||||
|
|
||||||
|
|
||||||
async def publish_current_draft_scheme(
|
async def publish_current_draft_scheme(
|
||||||
@@ -11,22 +12,29 @@ async def publish_current_draft_scheme(
|
|||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
expected_scheme_version_id: str | None = None,
|
expected_scheme_version_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
scheme, version = await get_current_draft_context(
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
scheme_id=scheme_id,
|
version = await get_current_scheme_version(
|
||||||
expected_scheme_version_id=expected_scheme_version_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
validation = await build_scheme_validation_report(
|
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
current_version_number=scheme.current_version_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not validation["summary"]["is_publishable"]:
|
if scheme.status != "draft" or version.status != "draft":
|
||||||
raise_conflict(
|
raise_publish_not_ready(
|
||||||
code="publish_validation_failed",
|
reason="Current scheme version is not publishable because it is not in draft state",
|
||||||
message="Scheme is not publishable in current state",
|
details={
|
||||||
scheme_version_id=version.scheme_version_id,
|
"scheme_status": scheme.status,
|
||||||
validation_summary=validation["summary"],
|
"scheme_version_status": version.status,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id:
|
||||||
|
raise_publish_not_ready(
|
||||||
|
reason="Draft scheme version is stale. Reload current draft state before publishing.",
|
||||||
|
details={
|
||||||
|
"expected_scheme_version_id": expected_scheme_version_id,
|
||||||
|
"actual_scheme_version_id": version.scheme_version_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot = await replace_scheme_version_pricing_snapshot(
|
snapshot = await replace_scheme_version_pricing_snapshot(
|
||||||
@@ -34,6 +42,18 @@ async def publish_current_draft_scheme(
|
|||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readiness = await build_publish_readiness(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
require_full_pricing_coverage=settings.publish_require_full_pricing_coverage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not readiness["readiness"]["is_ready_to_publish"]:
|
||||||
|
raise_publish_not_ready(
|
||||||
|
reason="Scheme is not ready to publish",
|
||||||
|
details=readiness,
|
||||||
|
)
|
||||||
|
|
||||||
published_row = await publish_scheme(scheme.scheme_id)
|
published_row = await publish_scheme(scheme.scheme_id)
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
@@ -46,6 +66,7 @@ async def publish_current_draft_scheme(
|
|||||||
"status": published_row.status,
|
"status": published_row.status,
|
||||||
"pricing_snapshot": snapshot,
|
"pricing_snapshot": snapshot,
|
||||||
"scheme_version_id": version.scheme_version_id,
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"publish_readiness": readiness,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,5 +77,5 @@ async def publish_current_draft_scheme(
|
|||||||
"current_version_number": published_row.current_version_number,
|
"current_version_number": published_row.current_version_number,
|
||||||
"published_at": published_row.published_at.isoformat() if published_row.published_at else None,
|
"published_at": published_row.published_at.isoformat() if published_row.published_at else None,
|
||||||
"pricing_snapshot": snapshot,
|
"pricing_snapshot": snapshot,
|
||||||
"validation_summary": validation["summary"],
|
"publish_readiness": readiness,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,3 +89,6 @@
|
|||||||
- This file is an operational route index, not a generated OpenAPI export.
|
- This file is an operational route index, not a generated OpenAPI export.
|
||||||
- Update this map in the same change set when adding, removing, renaming, or moving routes.
|
- Update this map in the same change set when adding, removing, renaming, or moving routes.
|
||||||
- Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows.
|
- Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows.
|
||||||
|
|
||||||
|
## app/api/routes/publish.py
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness
|
||||||
|
|||||||
Reference in New Issue
Block a user