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:
greebo
2026-03-19 20:15:48 +03:00
parent 2af5e49b8c
commit 7b6c12f924
8 changed files with 197 additions and 45 deletions

View File

@@ -1,39 +1,40 @@
from __future__ import annotations
from fastapi import HTTPException, status
def raise_conflict(*, code: str, message: str, **extra) -> None:
detail = {
def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None:
payload = {
"code": code,
"message": message,
**extra,
}
if details:
payload.update(details)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
detail=payload,
)
def raise_unprocessable(*, code: str, message: str, **extra) -> None:
detail = {
def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None:
payload = {
"code": code,
"message": message,
**extra,
}
if details:
payload.update(details)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
detail=payload,
)
def raise_not_found(*, code: str, message: str, **extra) -> None:
detail = {
"code": code,
"message": message,
**extra,
}
def raise_publish_not_ready(*, reason: str, details: dict) -> None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "publish_not_ready",
"message": reason,
"details": details,
},
)

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

View File

@@ -1,9 +1,10 @@
from app.core.config import settings
from app.repositories.audit import create_audit_event
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.schemes import publish_scheme
from app.services.api_errors import raise_conflict
from app.services.scheme_validation import build_scheme_validation_report
from app.repositories.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme
from app.services.api_errors import raise_publish_not_ready
from app.services.publish_readiness import build_publish_readiness
async def publish_current_draft_scheme(
@@ -11,22 +12,29 @@ async def publish_current_draft_scheme(
scheme_id: str,
expected_scheme_version_id: str | None = None,
) -> dict:
scheme, version = await get_current_draft_context(
scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id,
)
validation = await build_scheme_validation_report(
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
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"]:
raise_conflict(
code="publish_validation_failed",
message="Scheme is not publishable in current state",
scheme_version_id=version.scheme_version_id,
validation_summary=validation["summary"],
if scheme.status != "draft" or version.status != "draft":
raise_publish_not_ready(
reason="Current scheme version is not publishable because it is not in draft state",
details={
"scheme_status": scheme.status,
"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(
@@ -34,6 +42,18 @@ async def publish_current_draft_scheme(
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)
await create_audit_event(
@@ -46,6 +66,7 @@ async def publish_current_draft_scheme(
"status": published_row.status,
"pricing_snapshot": snapshot,
"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,
"published_at": published_row.published_at.isoformat() if published_row.published_at else None,
"pricing_snapshot": snapshot,
"validation_summary": validation["summary"],
"publish_readiness": readiness,
}