Compare commits
22 Commits
backend-v0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b36ba76c | ||
|
|
5aa35b1d04 | ||
|
|
210981c953 | ||
|
|
239b32a246 | ||
|
|
0f9c2a1cbd | ||
|
|
127c5bff71 | ||
|
|
77496dac46 | ||
|
|
4c15f4c201 | ||
|
|
a266f56ddd | ||
|
|
ac3a62f108 | ||
|
|
8d4255181b | ||
|
|
7b6c12f924 | ||
|
|
2af5e49b8c | ||
|
|
aab5a51654 | ||
|
|
af175d88dd | ||
|
|
64ec1c5180 | ||
|
|
35fc170cef | ||
|
|
56aadf848b | ||
|
|
d060828256 | ||
|
|
62550d5cb5 | ||
|
|
fbeac890be | ||
|
|
c7c9184a71 |
260
backend/README.md
Normal file
260
backend/README.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# svg-service backend
|
||||||
|
|
||||||
|
Backend for SVG scheme upload, draft editing, pricing, diagnostics, publish preview, and publish lifecycle.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.11
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy async
|
||||||
|
- PostgreSQL 16
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
Default backend port: `9020`
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
- `GET /healthz`
|
||||||
|
|
||||||
|
Main API prefix:
|
||||||
|
- `/api/v1`
|
||||||
|
|
||||||
|
Auth header:
|
||||||
|
- `X-API-Key`
|
||||||
|
|
||||||
|
Default local admin key:
|
||||||
|
- `admin-local-dev-key`
|
||||||
|
|
||||||
|
## Core lifecycle
|
||||||
|
|
||||||
|
1. Upload SVG
|
||||||
|
2. Normalize and persist structure
|
||||||
|
3. Enter editor flow through context + ensure draft
|
||||||
|
4. Edit sectors / groups / seats in current draft
|
||||||
|
5. Configure pricing and inspect diagnostics
|
||||||
|
6. Build pricing snapshot
|
||||||
|
7. Inspect publish readiness and publish preview
|
||||||
|
8. Publish current draft
|
||||||
|
9. If editing is needed after publish, create or ensure a new draft again
|
||||||
|
|
||||||
|
## Main concepts
|
||||||
|
|
||||||
|
### Scheme
|
||||||
|
Top-level business entity.
|
||||||
|
|
||||||
|
### Scheme version
|
||||||
|
Concrete version of the scheme. A version can be `draft` or `published`.
|
||||||
|
|
||||||
|
### Current version
|
||||||
|
The version referenced by the scheme registry as active current.
|
||||||
|
|
||||||
|
### Draft
|
||||||
|
Editable current version. All editor mutations and draft pricing operations must target a current draft version only.
|
||||||
|
|
||||||
|
### Published version
|
||||||
|
Non-editable current version. If current version is published, editor flow must first create or ensure a new draft.
|
||||||
|
|
||||||
|
### Upload artifacts
|
||||||
|
Stored technical artifacts, including:
|
||||||
|
- original svg
|
||||||
|
- sanitized svg
|
||||||
|
- normalized json
|
||||||
|
- display svg
|
||||||
|
- publish preview json
|
||||||
|
|
||||||
|
## Editor entry flow
|
||||||
|
|
||||||
|
### 1. Inspect editor state
|
||||||
|
`GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
|
||||||
|
Response tells whether:
|
||||||
|
- current version is draft
|
||||||
|
- editor is available
|
||||||
|
- a new draft should be created
|
||||||
|
- recommended action is `use_current_draft` or `create_draft`
|
||||||
|
|
||||||
|
### 2. Ensure editable draft
|
||||||
|
`POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- if current version is already draft: returns it with `created=false`
|
||||||
|
- if current version is published: clones current version into a new current draft and returns it with `created=true`
|
||||||
|
|
||||||
|
Returned `scheme_version_id` should be reused as:
|
||||||
|
- `expected_scheme_version_id`
|
||||||
|
|
||||||
|
for draft reads and mutations.
|
||||||
|
|
||||||
|
## Optimistic concurrency
|
||||||
|
|
||||||
|
Mutable draft flows support optimistic concurrency through query params:
|
||||||
|
- `expected_current_scheme_version_id`
|
||||||
|
- `expected_scheme_version_id`
|
||||||
|
|
||||||
|
Typical typed conflicts:
|
||||||
|
- `stale_current_version`
|
||||||
|
- `stale_draft_version`
|
||||||
|
- `draft_not_editable`
|
||||||
|
- `publish_not_ready`
|
||||||
|
|
||||||
|
## Main operator routes
|
||||||
|
|
||||||
|
### System
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /api/v1/ping`
|
||||||
|
- `GET /api/v1/db/ping`
|
||||||
|
- `GET /api/v1/manifest`
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
- `POST /api/v1/schemes/upload`
|
||||||
|
- `GET /api/v1/uploads`
|
||||||
|
- `GET /api/v1/uploads/{upload_id}`
|
||||||
|
- `GET /api/v1/uploads/{upload_id}/normalized`
|
||||||
|
|
||||||
|
### Scheme registry
|
||||||
|
- `GET /api/v1/schemes`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/versions`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/versions`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/publish/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-readiness`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/unpublish`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/rollback`
|
||||||
|
|
||||||
|
### Editor / draft
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/summary`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/structure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview`
|
||||||
|
- `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}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
|
||||||
|
- `PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/seats/bulk`
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/categories`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/rules`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
|
||||||
|
### Pricing diagnostics
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/coverage`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics`
|
||||||
|
|
||||||
|
### Publish preview
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/publish-preview`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/remap/preview`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/remap/apply`
|
||||||
|
|
||||||
|
### Structure read model
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/sectors`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/groups`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg/display`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta`
|
||||||
|
|
||||||
|
### Test mode
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/audit`
|
||||||
|
|
||||||
|
### Admin / ops
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/validation`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate`
|
||||||
|
- `POST /api/v1/admin/display/backfill`
|
||||||
|
- `GET /api/v1/admin/artifacts/publish-preview/audit`
|
||||||
|
- `POST /api/v1/admin/artifacts/publish-preview/cleanup`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
## Cleanup of test pricing data
|
||||||
|
|
||||||
|
Cleanup endpoints are intended for removing diagnostic / test categories accidentally accumulated in a shared scheme.
|
||||||
|
|
||||||
|
Preview candidates:
|
||||||
|
`GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
|
||||||
|
Execute cleanup:
|
||||||
|
`POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
Safety notes:
|
||||||
|
- use `dry_run=true` first
|
||||||
|
- keep `delete_only_without_rules=true` unless you intentionally want a harder cleanup
|
||||||
|
- prefer matching by prefixes instead of raw ids for repetitive test artifacts
|
||||||
|
|
||||||
|
Helper script:
|
||||||
|
- `backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
`SCHEME_ID=... DRY_RUN=true ./backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
## Typical local flow
|
||||||
|
|
||||||
|
### 1. Read current version
|
||||||
|
`GET /api/v1/schemes/{scheme_id}/current`
|
||||||
|
|
||||||
|
### 2. Ensure draft
|
||||||
|
`POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
|
||||||
|
Store returned:
|
||||||
|
- `scheme_version_id`
|
||||||
|
|
||||||
|
### 3. Read draft state
|
||||||
|
- `GET /draft/summary?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/structure?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/validation?expected_scheme_version_id=...`
|
||||||
|
- `GET /draft/compare-preview?expected_scheme_version_id=...`
|
||||||
|
|
||||||
|
### 4. Perform editor mutations
|
||||||
|
Pass:
|
||||||
|
- `expected_scheme_version_id={draft_scheme_version_id}`
|
||||||
|
|
||||||
|
on every mutation route.
|
||||||
|
|
||||||
|
### 5. Inspect pricing quality
|
||||||
|
- `GET /pricing/coverage`
|
||||||
|
- `GET /pricing/unpriced-seats`
|
||||||
|
- `GET /pricing/explain/{seat_id}`
|
||||||
|
- `GET /pricing/rules/diagnostics`
|
||||||
|
|
||||||
|
### 6. Build snapshot and inspect readiness
|
||||||
|
- `POST /draft/pricing/snapshot`
|
||||||
|
- `GET /draft/publish-readiness`
|
||||||
|
- `GET /draft/publish-preview?refresh=true`
|
||||||
|
|
||||||
|
### 7. Publish
|
||||||
|
- `POST /publish?expected_scheme_version_id=...`
|
||||||
|
|
||||||
|
## Regression
|
||||||
|
|
||||||
|
Main operator regressions:
|
||||||
|
- `backend/scripts/smoke_regression.sh`
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
|
||||||
|
Run:
|
||||||
|
`API_URL=http://127.0.0.1:9020 API_KEY=admin-local-dev-key SCHEME_ID=... ./backend/scripts/smoke_regression.sh`
|
||||||
|
|
||||||
|
`API_URL=http://127.0.0.1:9020 API_KEY=admin-local-dev-key SCHEME_ID=... ./backend/scripts/editor_mutation_regression.sh`
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes.admin import router as admin_router
|
from app.api.routes.admin import router as admin_router
|
||||||
|
from app.api.routes.admin_cleanup import router as admin_cleanup_router
|
||||||
from app.api.routes.audit import router as audit_router
|
from app.api.routes.audit import router as audit_router
|
||||||
from app.api.routes.editor import router as editor_router
|
from app.api.routes.editor import router as editor_router
|
||||||
from app.api.routes.pricing import router as pricing_router
|
from app.api.routes.pricing import router as pricing_router
|
||||||
|
from app.api.routes.pricing_diagnostics import router as pricing_diagnostics_router
|
||||||
from app.api.routes.publish import router as publish_router
|
from app.api.routes.publish import router as publish_router
|
||||||
from app.api.routes.schemes import router as schemes_router
|
from app.api.routes.schemes import router as schemes_router
|
||||||
from app.api.routes.structure import router as structure_router
|
from app.api.routes.structure import router as structure_router
|
||||||
@@ -17,8 +19,10 @@ router.include_router(uploads_router)
|
|||||||
router.include_router(schemes_router)
|
router.include_router(schemes_router)
|
||||||
router.include_router(structure_router)
|
router.include_router(structure_router)
|
||||||
router.include_router(pricing_router)
|
router.include_router(pricing_router)
|
||||||
|
router.include_router(pricing_diagnostics_router)
|
||||||
router.include_router(test_mode_router)
|
router.include_router(test_mode_router)
|
||||||
router.include_router(audit_router)
|
router.include_router(audit_router)
|
||||||
router.include_router(admin_router)
|
router.include_router(admin_router)
|
||||||
|
router.include_router(admin_cleanup_router)
|
||||||
router.include_router(editor_router)
|
router.include_router(editor_router)
|
||||||
router.include_router(publish_router)
|
router.include_router(publish_router)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from app.repositories.scheme_artifacts import artifact_exists, list_scheme_artif
|
|||||||
from app.repositories.scheme_versions import get_current_scheme_version
|
from app.repositories.scheme_versions import get_current_scheme_version
|
||||||
from app.repositories.schemes import get_scheme_record_by_scheme_id, list_scheme_records
|
from app.repositories.schemes import get_scheme_record_by_scheme_id, list_scheme_records
|
||||||
from app.repositories.uploads import get_upload_record_by_upload_id
|
from app.repositories.uploads import get_upload_record_by_upload_id
|
||||||
from app.security.auth import require_api_key
|
from app.security.auth import require_admin_api_key
|
||||||
from app.services.artifact_maintenance import (
|
from app.services.artifact_maintenance import (
|
||||||
cleanup_publish_preview_storage,
|
cleanup_publish_preview_storage,
|
||||||
inspect_publish_preview_storage,
|
inspect_publish_preview_storage,
|
||||||
@@ -19,7 +19,7 @@ router = APIRouter()
|
|||||||
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/artifacts")
|
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/artifacts")
|
||||||
async def list_current_scheme_artifacts(
|
async def list_current_scheme_artifacts(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
version = await get_current_scheme_version(
|
version = await get_current_scheme_version(
|
||||||
@@ -50,7 +50,7 @@ async def list_current_scheme_artifacts(
|
|||||||
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/validation")
|
@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/validation")
|
||||||
async def validate_current_scheme(
|
async def validate_current_scheme(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
version = await get_current_scheme_version(
|
version = await get_current_scheme_version(
|
||||||
@@ -74,7 +74,7 @@ async def validate_current_scheme(
|
|||||||
async def regenerate_current_display(
|
async def regenerate_current_display(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
mode: str = Query(default="passthrough"),
|
mode: str = Query(default="passthrough"),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
version = await get_current_scheme_version(
|
version = await get_current_scheme_version(
|
||||||
@@ -98,7 +98,7 @@ async def bulk_backfill_display_artifacts(
|
|||||||
mode: str = Query(default="passthrough"),
|
mode: str = Query(default="passthrough"),
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
only_missing: bool = Query(default=True),
|
only_missing: bool = Query(default=True),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
schemes = await list_scheme_records(limit=limit, offset=0)
|
schemes = await list_scheme_records(limit=limit, offset=0)
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ async def bulk_backfill_display_artifacts(
|
|||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/admin/artifacts/publish-preview/audit")
|
@router.get(f"{settings.api_v1_prefix}/admin/artifacts/publish-preview/audit")
|
||||||
async def audit_publish_preview_storage(
|
async def audit_publish_preview_storage(
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
return await inspect_publish_preview_storage()
|
return await inspect_publish_preview_storage()
|
||||||
|
|
||||||
@@ -176,6 +176,6 @@ async def audit_publish_preview_storage(
|
|||||||
@router.post(f"{settings.api_v1_prefix}/admin/artifacts/publish-preview/cleanup")
|
@router.post(f"{settings.api_v1_prefix}/admin/artifacts/publish-preview/cleanup")
|
||||||
async def cleanup_publish_preview_artifacts_endpoint(
|
async def cleanup_publish_preview_artifacts_endpoint(
|
||||||
dry_run: bool = Query(default=True),
|
dry_run: bool = Query(default=True),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_admin_api_key),
|
||||||
):
|
):
|
||||||
return await cleanup_publish_preview_storage(dry_run=dry_run)
|
return await cleanup_publish_preview_storage(dry_run=dry_run)
|
||||||
|
|||||||
55
backend/app/api/routes/admin_cleanup.py
Normal file
55
backend/app/api/routes/admin_cleanup.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.admin_cleanup import (
|
||||||
|
PricingCleanupExecuteRequest,
|
||||||
|
PricingCleanupExecuteResponse,
|
||||||
|
PricingCleanupPreviewResponse,
|
||||||
|
)
|
||||||
|
from app.security.auth import require_admin_api_key
|
||||||
|
from app.services.pricing_cleanup import (
|
||||||
|
build_pricing_cleanup_preview,
|
||||||
|
execute_pricing_cleanup,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/pricing/categories/cleanup-preview",
|
||||||
|
response_model=PricingCleanupPreviewResponse,
|
||||||
|
)
|
||||||
|
async def get_pricing_cleanup_preview(
|
||||||
|
scheme_id: str,
|
||||||
|
code_prefix: list[str] = Query(default_factory=list),
|
||||||
|
name_prefix: list[str] = Query(default_factory=list),
|
||||||
|
pricing_category_id: list[str] = Query(default_factory=list),
|
||||||
|
delete_only_without_rules: bool = Query(default=True),
|
||||||
|
role: str = Depends(require_admin_api_key),
|
||||||
|
):
|
||||||
|
return await build_pricing_cleanup_preview(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
code_prefixes=code_prefix,
|
||||||
|
name_prefixes=name_prefix,
|
||||||
|
pricing_category_ids=pricing_category_id,
|
||||||
|
delete_only_without_rules=delete_only_without_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/pricing/categories/cleanup",
|
||||||
|
response_model=PricingCleanupExecuteResponse,
|
||||||
|
)
|
||||||
|
async def post_pricing_cleanup(
|
||||||
|
scheme_id: str,
|
||||||
|
payload: PricingCleanupExecuteRequest,
|
||||||
|
role: str = Depends(require_admin_api_key),
|
||||||
|
):
|
||||||
|
return await execute_pricing_cleanup(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
code_prefixes=payload.code_prefixes,
|
||||||
|
name_prefixes=payload.name_prefixes,
|
||||||
|
pricing_category_ids=payload.pricing_category_ids,
|
||||||
|
delete_only_without_rules=payload.delete_only_without_rules,
|
||||||
|
dry_run=payload.dry_run,
|
||||||
|
)
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
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
|
||||||
from app.repositories.scheme_groups import (
|
from app.repositories.scheme_groups import (
|
||||||
create_scheme_version_group,
|
create_scheme_version_group,
|
||||||
delete_scheme_version_group_by_record_id,
|
delete_scheme_version_group_by_record_id,
|
||||||
|
get_scheme_version_group_by_record_id,
|
||||||
list_scheme_version_groups,
|
list_scheme_version_groups,
|
||||||
update_scheme_version_group_by_record_id,
|
update_scheme_version_group_by_record_id,
|
||||||
)
|
)
|
||||||
@@ -12,15 +13,19 @@ from app.repositories.scheme_seats import (
|
|||||||
bulk_update_scheme_version_seats_by_record_id,
|
bulk_update_scheme_version_seats_by_record_id,
|
||||||
cascade_update_seat_group_reference,
|
cascade_update_seat_group_reference,
|
||||||
cascade_update_seat_sector_reference,
|
cascade_update_seat_sector_reference,
|
||||||
|
get_scheme_version_seat_by_record_id,
|
||||||
list_scheme_version_seats,
|
list_scheme_version_seats,
|
||||||
update_scheme_version_seat_by_record_id,
|
update_scheme_version_seat_by_record_id,
|
||||||
)
|
)
|
||||||
from app.repositories.scheme_sectors import (
|
from app.repositories.scheme_sectors import (
|
||||||
create_scheme_version_sector,
|
create_scheme_version_sector,
|
||||||
delete_scheme_version_sector_by_record_id,
|
delete_scheme_version_sector_by_record_id,
|
||||||
|
get_scheme_version_sector_by_record_id,
|
||||||
list_scheme_version_sectors,
|
list_scheme_version_sectors,
|
||||||
update_scheme_version_sector_by_record_id,
|
update_scheme_version_sector_by_record_id,
|
||||||
)
|
)
|
||||||
|
from app.repositories.scheme_versions import get_current_scheme_version
|
||||||
|
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||||
from app.schemas.editor import (
|
from app.schemas.editor import (
|
||||||
BulkSeatPatchRequest,
|
BulkSeatPatchRequest,
|
||||||
BulkSeatPatchResponse,
|
BulkSeatPatchResponse,
|
||||||
@@ -34,6 +39,8 @@ from app.schemas.editor import (
|
|||||||
DraftSeatItem,
|
DraftSeatItem,
|
||||||
DraftSectorItem,
|
DraftSectorItem,
|
||||||
DraftStructureResponse,
|
DraftStructureResponse,
|
||||||
|
DraftSummaryResponse,
|
||||||
|
EditorContextResponse,
|
||||||
GroupPatchRequest,
|
GroupPatchRequest,
|
||||||
GroupPatchResponse,
|
GroupPatchResponse,
|
||||||
RepairReferencesResponse,
|
RepairReferencesResponse,
|
||||||
@@ -47,23 +54,148 @@ from app.schemas.editor import (
|
|||||||
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.editor_validation import (
|
from app.services.editor_validation import (
|
||||||
|
validate_bulk_seat_patch_references,
|
||||||
validate_bulk_seat_patch_uniqueness,
|
validate_bulk_seat_patch_uniqueness,
|
||||||
validate_group_patch_uniqueness,
|
validate_group_patch_uniqueness,
|
||||||
validate_sector_patch_uniqueness,
|
validate_sector_patch_uniqueness,
|
||||||
|
validate_single_seat_patch_references,
|
||||||
validate_single_seat_patch_uniqueness,
|
validate_single_seat_patch_uniqueness,
|
||||||
)
|
)
|
||||||
|
from app.services.publish_readiness import build_publish_readiness
|
||||||
|
from app.services.scheme_validation import build_scheme_validation_report
|
||||||
from app.services.structure_diff import build_structure_diff
|
from app.services.structure_diff import build_structure_diff
|
||||||
from app.services.structure_sync import repair_structure_references
|
from app.services.structure_sync import repair_structure_references
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/structure", response_model=DraftStructureResponse)
|
def _seat_item(row) -> DraftSeatItem:
|
||||||
async def get_draft_structure(
|
return DraftSeatItem(
|
||||||
|
seat_record_id=row.seat_record_id,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=row.scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
seat_id=row.seat_id,
|
||||||
|
sector_id=row.sector_id,
|
||||||
|
group_id=row.group_id,
|
||||||
|
row_label=row.row_label,
|
||||||
|
seat_number=row.seat_number,
|
||||||
|
tag=row.tag,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
x=row.x,
|
||||||
|
y=row.y,
|
||||||
|
cx=row.cx,
|
||||||
|
cy=row.cy,
|
||||||
|
width=row.width,
|
||||||
|
height=row.height,
|
||||||
|
created_at=row.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_item(row) -> DraftSectorItem:
|
||||||
|
return DraftSectorItem(
|
||||||
|
sector_record_id=row.sector_record_id,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=row.scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
sector_id=row.sector_id,
|
||||||
|
name=row.name,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
created_at=row.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_item(row) -> DraftGroupItem:
|
||||||
|
return DraftGroupItem(
|
||||||
|
group_record_id=row.group_record_id,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=row.scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
group_id=row.group_id,
|
||||||
|
name=row.name,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
created_at=row.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/editor/context", response_model=EditorContextResponse)
|
||||||
|
async def get_editor_context(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
|
version = await get_current_scheme_version(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_is_draft = scheme.status == "draft" and version.status == "draft"
|
||||||
|
|
||||||
|
return EditorContextResponse(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_scheme_version_id=version.scheme_version_id,
|
||||||
|
current_version_number=version.version_number,
|
||||||
|
scheme_status=scheme.status,
|
||||||
|
scheme_version_status=version.status,
|
||||||
|
editor_available=True,
|
||||||
|
current_is_draft=current_is_draft,
|
||||||
|
create_draft_available=not current_is_draft,
|
||||||
|
recommended_action="use_current_draft" if current_is_draft else "create_draft",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/summary", response_model=DraftSummaryResponse)
|
||||||
|
async def get_draft_summary(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
seats = await list_scheme_version_seats(version.scheme_version_id)
|
||||||
|
sectors = await list_scheme_version_sectors(version.scheme_version_id)
|
||||||
|
groups = await list_scheme_version_groups(version.scheme_version_id)
|
||||||
|
|
||||||
|
validation = await build_scheme_validation_report(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
)
|
||||||
|
structure_diff = await build_structure_diff(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
draft_scheme_version_id=version.scheme_version_id,
|
||||||
|
)
|
||||||
|
readiness = await build_publish_readiness(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
status=version.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DraftSummaryResponse(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
status=version.status,
|
||||||
|
total_seats=len(seats),
|
||||||
|
total_sectors=len(sectors),
|
||||||
|
total_groups=len(groups),
|
||||||
|
validation_summary=validation["summary"],
|
||||||
|
structure_diff_summary=structure_diff["summary"],
|
||||||
|
publish_readiness=readiness["readiness"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/structure", response_model=DraftStructureResponse)
|
||||||
|
async def get_draft_structure(
|
||||||
|
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,
|
||||||
|
)
|
||||||
seats = await list_scheme_version_seats(version.scheme_version_id)
|
seats = await list_scheme_version_seats(version.scheme_version_id)
|
||||||
sectors = await list_scheme_version_sectors(version.scheme_version_id)
|
sectors = await list_scheme_version_sectors(version.scheme_version_id)
|
||||||
groups = await list_scheme_version_groups(version.scheme_version_id)
|
groups = await list_scheme_version_groups(version.scheme_version_id)
|
||||||
@@ -72,67 +204,47 @@ async def get_draft_structure(
|
|||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
status=version.status,
|
status=version.status,
|
||||||
seats=[
|
seats=[_seat_item(row) for row in seats],
|
||||||
DraftSeatItem(
|
sectors=[_sector_item(row) for row in sectors],
|
||||||
seat_record_id=row.seat_record_id,
|
groups=[_group_item(row) for row in groups],
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=row.scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
seat_id=row.seat_id,
|
|
||||||
sector_id=row.sector_id,
|
|
||||||
group_id=row.group_id,
|
|
||||||
row_label=row.row_label,
|
|
||||||
seat_number=row.seat_number,
|
|
||||||
tag=row.tag,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
x=row.x,
|
|
||||||
y=row.y,
|
|
||||||
cx=row.cx,
|
|
||||||
cy=row.cy,
|
|
||||||
width=row.width,
|
|
||||||
height=row.height,
|
|
||||||
created_at=row.created_at.isoformat(),
|
|
||||||
)
|
|
||||||
for row in seats
|
|
||||||
],
|
|
||||||
sectors=[
|
|
||||||
DraftSectorItem(
|
|
||||||
sector_record_id=row.sector_record_id,
|
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=row.scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
sector_id=row.sector_id,
|
|
||||||
name=row.name,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
created_at=row.created_at.isoformat(),
|
|
||||||
)
|
|
||||||
for row in sectors
|
|
||||||
],
|
|
||||||
groups=[
|
|
||||||
DraftGroupItem(
|
|
||||||
group_record_id=row.group_record_id,
|
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=row.scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
group_id=row.group_id,
|
|
||||||
name=row.name,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
created_at=row.created_at.isoformat(),
|
|
||||||
)
|
|
||||||
for row in groups
|
|
||||||
],
|
|
||||||
total_seats=len(seats),
|
total_seats=len(seats),
|
||||||
total_sectors=len(sectors),
|
total_sectors=len(sectors),
|
||||||
total_groups=len(groups),
|
total_groups=len(groups),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/validation")
|
||||||
|
async def get_draft_validation(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
report = await build_scheme_validation_report(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme.scheme_id,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"status": version.status,
|
||||||
|
"report": report,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/compare-preview", response_model=StructureDiffResponse)
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/compare-preview", response_model=StructureDiffResponse)
|
||||||
async def get_draft_compare_preview(
|
async def get_draft_compare_preview(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
diff = await build_structure_diff(
|
diff = await build_structure_diff(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
draft_scheme_version_id=version.scheme_version_id,
|
draft_scheme_version_id=version.scheme_version_id,
|
||||||
@@ -148,13 +260,91 @@ async def get_draft_compare_preview(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=DraftSeatItem)
|
||||||
|
async def get_draft_seat_by_record_id(
|
||||||
|
scheme_id: str,
|
||||||
|
seat_record_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,
|
||||||
|
)
|
||||||
|
row = await get_scheme_version_seat_by_record_id(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
seat_record_id=seat_record_id,
|
||||||
|
)
|
||||||
|
return _seat_item(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=DraftSectorItem)
|
||||||
|
async def get_draft_sector_by_record_id(
|
||||||
|
scheme_id: str,
|
||||||
|
sector_record_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,
|
||||||
|
)
|
||||||
|
row = await get_scheme_version_sector_by_record_id(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
sector_record_id=sector_record_id,
|
||||||
|
)
|
||||||
|
return _sector_item(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=DraftGroupItem)
|
||||||
|
async def get_draft_group_by_record_id(
|
||||||
|
scheme_id: str,
|
||||||
|
group_record_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,
|
||||||
|
)
|
||||||
|
row = await get_scheme_version_group_by_record_id(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
group_record_id=group_record_id,
|
||||||
|
)
|
||||||
|
return _group_item(row)
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors", response_model=CreateSectorResponse)
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors", response_model=CreateSectorResponse)
|
||||||
async def create_draft_sector(
|
async def create_draft_sector(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: CreateSectorRequest,
|
payload: CreateSectorRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await validate_sector_patch_uniqueness(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
sector_record_id="__create__",
|
||||||
|
new_sector_id=payload.sector_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await list_scheme_version_sectors(version.scheme_version_id)
|
||||||
|
for row in existing:
|
||||||
|
if payload.element_id and row.element_id == payload.element_id:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": "duplicate_sector_element_id",
|
||||||
|
"message": "Sector element binding already exists in current draft version",
|
||||||
|
"element_id": payload.element_id,
|
||||||
|
"conflict_sector_record_id": row.sector_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
row = await create_scheme_version_sector(
|
row = await create_scheme_version_sector(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -191,9 +381,33 @@ async def create_draft_sector(
|
|||||||
async def create_draft_group(
|
async def create_draft_group(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: CreateGroupRequest,
|
payload: CreateGroupRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await validate_group_patch_uniqueness(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
group_record_id="__create__",
|
||||||
|
new_group_id=payload.group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await list_scheme_version_groups(version.scheme_version_id)
|
||||||
|
for row in existing:
|
||||||
|
if payload.element_id and row.element_id == payload.element_id:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": "duplicate_group_element_id",
|
||||||
|
"message": "Group element binding already exists in current draft version",
|
||||||
|
"element_id": payload.element_id,
|
||||||
|
"conflict_group_record_id": row.group_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
row = await create_scheme_version_group(
|
row = await create_scheme_version_group(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -230,9 +444,13 @@ async def create_draft_group(
|
|||||||
async def delete_draft_sector(
|
async def delete_draft_sector(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
sector_record_id: str,
|
sector_record_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
await delete_scheme_version_sector_by_record_id(
|
await delete_scheme_version_sector_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -259,9 +477,13 @@ async def delete_draft_sector(
|
|||||||
async def delete_draft_group(
|
async def delete_draft_group(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
group_record_id: str,
|
group_record_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
await delete_scheme_version_group_by_record_id(
|
await delete_scheme_version_group_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -286,27 +508,43 @@ async def delete_draft_group(
|
|||||||
|
|
||||||
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=SeatPatchResponse)
|
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=SeatPatchResponse)
|
||||||
async def patch_draft_seat(
|
async def patch_draft_seat(
|
||||||
|
request: Request,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
seat_record_id: str,
|
seat_record_id: str,
|
||||||
payload: SeatPatchRequest,
|
payload: SeatPatchRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
await validate_single_seat_patch_uniqueness(
|
await validate_single_seat_patch_uniqueness(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
seat_record_id=seat_record_id,
|
seat_record_id=seat_record_id,
|
||||||
new_seat_id=payload.seat_id,
|
new_seat_id=payload.seat_id,
|
||||||
)
|
)
|
||||||
|
await validate_single_seat_patch_references(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
sector_id=payload.sector_id,
|
||||||
|
group_id=payload.group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_json = await request.json()
|
||||||
|
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
|
||||||
|
for field in ("seat_id", "sector_id", "group_id"):
|
||||||
|
if field in update_data and (update_data[field] is None or update_data[field] == ""):
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
raise_unprocessable(
|
||||||
|
code="business_identifier_nullification_forbidden",
|
||||||
|
message=f"{field} cannot be nullified or explicitly cleared",
|
||||||
|
)
|
||||||
|
|
||||||
row = await update_scheme_version_seat_by_record_id(
|
row = await update_scheme_version_seat_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
seat_record_id=seat_record_id,
|
seat_record_id=seat_record_id,
|
||||||
seat_id=payload.seat_id,
|
**update_data,
|
||||||
sector_id=payload.sector_id,
|
|
||||||
group_id=payload.group_id,
|
|
||||||
row_label=payload.row_label,
|
|
||||||
seat_number=payload.seat_number,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
@@ -338,17 +576,39 @@ async def patch_draft_seat(
|
|||||||
|
|
||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/bulk", response_model=BulkSeatPatchResponse)
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/bulk", response_model=BulkSeatPatchResponse)
|
||||||
async def bulk_patch_draft_seats(
|
async def bulk_patch_draft_seats(
|
||||||
|
request: Request,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: BulkSeatPatchRequest,
|
payload: BulkSeatPatchRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
items = [item.model_dump() for item in payload.items]
|
raw_json = await request.json()
|
||||||
|
items = []
|
||||||
|
for i, item in enumerate(payload.items):
|
||||||
|
item_raw = raw_json.get("items", [])[i] if "items" in raw_json else {}
|
||||||
|
items.append({k: item.model_dump(exclude_unset=True).get(k) for k in item_raw})
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
for field in ("seat_id", "sector_id", "group_id"):
|
||||||
|
if field in item and (item[field] is None or item[field] == ""):
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
raise_unprocessable(
|
||||||
|
code="business_identifier_nullification_forbidden",
|
||||||
|
message=f"{field} cannot be nullified or explicitly cleared",
|
||||||
|
)
|
||||||
await validate_bulk_seat_patch_uniqueness(
|
await validate_bulk_seat_patch_uniqueness(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
items=items,
|
items=items,
|
||||||
)
|
)
|
||||||
|
await validate_bulk_seat_patch_references(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
rows = await bulk_update_scheme_version_seats_by_record_id(
|
rows = await bulk_update_scheme_version_seats_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -386,12 +646,17 @@ async def bulk_patch_draft_seats(
|
|||||||
|
|
||||||
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=SectorPatchResponse)
|
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=SectorPatchResponse)
|
||||||
async def patch_draft_sector(
|
async def patch_draft_sector(
|
||||||
|
request: Request,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
sector_record_id: str,
|
sector_record_id: str,
|
||||||
payload: SectorPatchRequest,
|
payload: SectorPatchRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
await validate_sector_patch_uniqueness(
|
await validate_sector_patch_uniqueness(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -399,20 +664,28 @@ async def patch_draft_sector(
|
|||||||
new_sector_id=payload.sector_id,
|
new_sector_id=payload.sector_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
raw_json = await request.json()
|
||||||
|
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
|
||||||
|
for field in ("sector_id",):
|
||||||
|
if field in update_data and (update_data[field] is None or update_data[field] == ""):
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
raise_unprocessable(
|
||||||
|
code="business_identifier_nullification_forbidden",
|
||||||
|
message=f"{field} cannot be nullified or explicitly cleared",
|
||||||
|
)
|
||||||
|
|
||||||
row, old_sector_id = await update_scheme_version_sector_by_record_id(
|
row, old_sector_id = await update_scheme_version_sector_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
sector_record_id=sector_record_id,
|
sector_record_id=sector_record_id,
|
||||||
sector_id=payload.sector_id,
|
**update_data,
|
||||||
name=payload.name,
|
|
||||||
)
|
|
||||||
cascaded_count = await cascade_update_seat_sector_reference(
|
|
||||||
scheme_version_id=version.scheme_version_id,
|
|
||||||
old_sector_id=old_sector_id,
|
|
||||||
new_sector_id=payload.sector_id,
|
|
||||||
)
|
|
||||||
repair_result = await repair_structure_references(
|
|
||||||
scheme_version_id=version.scheme_version_id,
|
|
||||||
)
|
)
|
||||||
|
cascaded_count = 0
|
||||||
|
if "sector_id" in update_data and update_data["sector_id"] and update_data["sector_id"] != old_sector_id:
|
||||||
|
cascaded_count = await cascade_update_seat_sector_reference(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
old_sector_id=old_sector_id,
|
||||||
|
new_sector_id=update_data["sector_id"],
|
||||||
|
)
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -421,10 +694,10 @@ async def patch_draft_sector(
|
|||||||
object_ref=sector_record_id,
|
object_ref=sector_record_id,
|
||||||
details={
|
details={
|
||||||
"scheme_version_id": version.scheme_version_id,
|
"scheme_version_id": version.scheme_version_id,
|
||||||
"sector_id": payload.sector_id,
|
"old_sector_id": old_sector_id,
|
||||||
|
"new_sector_id": payload.sector_id,
|
||||||
"name": payload.name,
|
"name": payload.name,
|
||||||
"cascaded_seats_count": cascaded_count,
|
"cascaded_seats_count": cascaded_count,
|
||||||
"repair_result": repair_result,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,12 +712,17 @@ async def patch_draft_sector(
|
|||||||
|
|
||||||
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=GroupPatchResponse)
|
@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=GroupPatchResponse)
|
||||||
async def patch_draft_group(
|
async def patch_draft_group(
|
||||||
|
request: Request,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
group_record_id: str,
|
group_record_id: str,
|
||||||
payload: GroupPatchRequest,
|
payload: GroupPatchRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
await validate_group_patch_uniqueness(
|
await validate_group_patch_uniqueness(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -452,20 +730,28 @@ async def patch_draft_group(
|
|||||||
new_group_id=payload.group_id,
|
new_group_id=payload.group_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
raw_json = await request.json()
|
||||||
|
update_data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in raw_json}
|
||||||
|
for field in ("group_id",):
|
||||||
|
if field in update_data and (update_data[field] is None or update_data[field] == ""):
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
raise_unprocessable(
|
||||||
|
code="business_identifier_nullification_forbidden",
|
||||||
|
message=f"{field} cannot be nullified or explicitly cleared",
|
||||||
|
)
|
||||||
|
|
||||||
row, old_group_id = await update_scheme_version_group_by_record_id(
|
row, old_group_id = await update_scheme_version_group_by_record_id(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
group_record_id=group_record_id,
|
group_record_id=group_record_id,
|
||||||
group_id=payload.group_id,
|
**update_data,
|
||||||
name=payload.name,
|
|
||||||
)
|
|
||||||
cascaded_count = await cascade_update_seat_group_reference(
|
|
||||||
scheme_version_id=version.scheme_version_id,
|
|
||||||
old_group_id=old_group_id,
|
|
||||||
new_group_id=payload.group_id,
|
|
||||||
)
|
|
||||||
repair_result = await repair_structure_references(
|
|
||||||
scheme_version_id=version.scheme_version_id,
|
|
||||||
)
|
)
|
||||||
|
cascaded_count = 0
|
||||||
|
if "group_id" in update_data and update_data["group_id"] and update_data["group_id"] != old_group_id:
|
||||||
|
cascaded_count = await cascade_update_seat_group_reference(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
old_group_id=old_group_id,
|
||||||
|
new_group_id=update_data["group_id"],
|
||||||
|
)
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -474,10 +760,10 @@ async def patch_draft_group(
|
|||||||
object_ref=group_record_id,
|
object_ref=group_record_id,
|
||||||
details={
|
details={
|
||||||
"scheme_version_id": version.scheme_version_id,
|
"scheme_version_id": version.scheme_version_id,
|
||||||
"group_id": payload.group_id,
|
"old_group_id": old_group_id,
|
||||||
|
"new_group_id": payload.group_id,
|
||||||
"name": payload.name,
|
"name": payload.name,
|
||||||
"cascaded_seats_count": cascaded_count,
|
"cascaded_seats_count": cascaded_count,
|
||||||
"repair_result": repair_result,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,9 +779,14 @@ async def patch_draft_group(
|
|||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/repair-references", response_model=RepairReferencesResponse)
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/repair-references", response_model=RepairReferencesResponse)
|
||||||
async def repair_draft_references(
|
async def repair_draft_references(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
result = await repair_structure_references(
|
result = await repair_structure_references(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
)
|
)
|
||||||
@@ -511,7 +802,7 @@ async def repair_draft_references(
|
|||||||
return RepairReferencesResponse(
|
return RepairReferencesResponse(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
repaired_sector_refs_count=result["repaired_sector_refs_count"],
|
repaired_sector_refs_count=result.get("repaired_sector_refs_count", 0),
|
||||||
repaired_group_refs_count=result["repaired_group_refs_count"],
|
repaired_group_refs_count=result.get("repaired_group_refs_count", 0),
|
||||||
details=result["details"],
|
details=result,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, 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
|
||||||
@@ -14,9 +14,6 @@ from app.repositories.pricing import (
|
|||||||
update_price_rule,
|
update_price_rule,
|
||||||
update_pricing_category,
|
update_pricing_category,
|
||||||
)
|
)
|
||||||
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
|
|
||||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
|
||||||
from app.repositories.scheme_versions import get_current_scheme_version
|
|
||||||
from app.schemas.pricing import (
|
from app.schemas.pricing import (
|
||||||
PriceRuleCreateRequest,
|
PriceRuleCreateRequest,
|
||||||
PriceRuleItem,
|
PriceRuleItem,
|
||||||
@@ -27,27 +24,27 @@ from app.schemas.pricing import (
|
|||||||
PricingCategoryUpdateRequest,
|
PricingCategoryUpdateRequest,
|
||||||
)
|
)
|
||||||
from app.security.auth import require_api_key
|
from app.security.auth import require_api_key
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
from app.services.draft_guard import get_current_draft_context
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None:
|
async def _require_current_draft(
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme_id: str,
|
||||||
version = await get_current_scheme_version(
|
expected_scheme_version_id: str | None,
|
||||||
scheme_id=scheme.scheme_id,
|
):
|
||||||
current_version_number=scheme.current_version_number,
|
return await get_current_draft_context(
|
||||||
)
|
scheme_id=scheme_id,
|
||||||
if scheme.status != "draft" or version.status != "draft":
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
return None
|
|
||||||
|
|
||||||
return await replace_scheme_version_pricing_snapshot(
|
|
||||||
scheme_id=scheme.scheme_id,
|
|
||||||
scheme_version_id=version.scheme_version_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
|
||||||
async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key)):
|
async def get_pricing_bundle(
|
||||||
|
scheme_id: str,
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
categories = await list_pricing_categories(scheme_id)
|
categories = await list_pricing_categories(scheme_id)
|
||||||
rules = await list_price_rules(scheme_id)
|
rules = await list_price_rules(scheme_id)
|
||||||
|
|
||||||
@@ -82,26 +79,35 @@ async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key
|
|||||||
async def create_pricing_category_endpoint(
|
async def create_pricing_category_endpoint(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: PricingCategoryCreateRequest,
|
payload: PricingCategoryCreateRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
pricing_category_id = await create_pricing_category(
|
scheme, version = await _require_current_draft(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_category_id = await create_pricing_category(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
code=payload.code,
|
code=payload.code,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.category.created",
|
event_type="pricing.category.created",
|
||||||
object_type="pricing_category",
|
object_type="pricing_category",
|
||||||
object_ref=pricing_category_id,
|
object_ref=pricing_category_id,
|
||||||
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
|
details={
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"name": payload.name,
|
||||||
|
"code": payload.code,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"pricing_category_id": pricing_category_id,
|
"pricing_category_id": pricing_category_id,
|
||||||
"scheme_id": scheme_id,
|
"scheme_id": scheme.scheme_id,
|
||||||
"name": payload.name,
|
"name": payload.name,
|
||||||
"code": payload.code,
|
"code": payload.code,
|
||||||
}
|
}
|
||||||
@@ -112,22 +118,31 @@ async def update_pricing_category_endpoint(
|
|||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
pricing_category_id: str,
|
pricing_category_id: str,
|
||||||
payload: PricingCategoryUpdateRequest,
|
payload: PricingCategoryUpdateRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
row = await update_pricing_category(
|
scheme, version = await _require_current_draft(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await update_pricing_category(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
pricing_category_id=pricing_category_id,
|
pricing_category_id=pricing_category_id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
code=payload.code,
|
code=payload.code,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.category.updated",
|
event_type="pricing.category.updated",
|
||||||
object_type="pricing_category",
|
object_type="pricing_category",
|
||||||
object_ref=pricing_category_id,
|
object_ref=pricing_category_id,
|
||||||
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
|
details={
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"name": row.name,
|
||||||
|
"code": row.code,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -142,71 +157,85 @@ async def update_pricing_category_endpoint(
|
|||||||
async def delete_pricing_category_endpoint(
|
async def delete_pricing_category_endpoint(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
pricing_category_id: str,
|
pricing_category_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
await delete_pricing_category(
|
scheme, version = await _require_current_draft(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_pricing_category(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
pricing_category_id=pricing_category_id,
|
pricing_category_id=pricing_category_id,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.category.deleted",
|
event_type="pricing.category.deleted",
|
||||||
object_type="pricing_category",
|
object_type="pricing_category",
|
||||||
object_ref=pricing_category_id,
|
object_ref=pricing_category_id,
|
||||||
details={"snapshot": snapshot},
|
details={"scheme_version_id": version.scheme_version_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"deleted": True, "pricing_category_id": pricing_category_id}
|
return {
|
||||||
|
"deleted": True,
|
||||||
|
"pricing_category_id": pricing_category_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
|
||||||
async def create_price_rule_endpoint(
|
async def create_price_rule_endpoint(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: PriceRuleCreateRequest,
|
payload: PriceRuleCreateRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
|
scheme, version = await _require_current_draft(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount = Decimal(payload.amount)
|
amount = Decimal(payload.amount)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(
|
raise_unprocessable(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
code="invalid_amount",
|
||||||
detail="Некорректная сумма",
|
message="Некорректная сумма",
|
||||||
|
details={"amount": payload.amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
price_rule_id = await create_price_rule(
|
price_rule_id = await create_price_rule(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
pricing_category_id=payload.pricing_category_id,
|
pricing_category_id=payload.pricing_category_id,
|
||||||
target_type=payload.target_type,
|
target_type=payload.target_type,
|
||||||
target_ref=payload.target_ref,
|
target_ref=payload.target_ref,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency=payload.currency,
|
currency=payload.currency,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.rule.created",
|
event_type="pricing.rule.created",
|
||||||
object_type="price_rule",
|
object_type="price_rule",
|
||||||
object_ref=price_rule_id,
|
object_ref=price_rule_id,
|
||||||
details={
|
details={
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
"pricing_category_id": payload.pricing_category_id,
|
"pricing_category_id": payload.pricing_category_id,
|
||||||
"target_type": payload.target_type,
|
"target_type": payload.target_type,
|
||||||
"target_ref": payload.target_ref,
|
"target_ref": payload.target_ref,
|
||||||
"amount": payload.amount,
|
"amount": str(amount),
|
||||||
"currency": payload.currency,
|
"currency": payload.currency,
|
||||||
"snapshot": snapshot,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"price_rule_id": price_rule_id,
|
"price_rule_id": price_rule_id,
|
||||||
"scheme_id": scheme_id,
|
"scheme_id": scheme.scheme_id,
|
||||||
"pricing_category_id": payload.pricing_category_id,
|
"pricing_category_id": payload.pricing_category_id,
|
||||||
"target_type": payload.target_type,
|
"target_type": payload.target_type,
|
||||||
"target_ref": payload.target_ref,
|
"target_ref": payload.target_ref,
|
||||||
"amount": payload.amount,
|
"amount": str(amount),
|
||||||
"currency": payload.currency,
|
"currency": payload.currency,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,18 +245,25 @@ async def update_price_rule_endpoint(
|
|||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
price_rule_id: str,
|
price_rule_id: str,
|
||||||
payload: PriceRuleUpdateRequest,
|
payload: PriceRuleUpdateRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
|
scheme, version = await _require_current_draft(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount = Decimal(payload.amount)
|
amount = Decimal(payload.amount)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(
|
raise_unprocessable(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
code="invalid_amount",
|
||||||
detail="Некорректная сумма",
|
message="Некорректная сумма",
|
||||||
|
details={"amount": payload.amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
row = await update_price_rule(
|
row = await update_price_rule(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
price_rule_id=price_rule_id,
|
price_rule_id=price_rule_id,
|
||||||
pricing_category_id=payload.pricing_category_id,
|
pricing_category_id=payload.pricing_category_id,
|
||||||
target_type=payload.target_type,
|
target_type=payload.target_type,
|
||||||
@@ -235,20 +271,19 @@ async def update_price_rule_endpoint(
|
|||||||
amount=amount,
|
amount=amount,
|
||||||
currency=payload.currency,
|
currency=payload.currency,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.rule.updated",
|
event_type="pricing.rule.updated",
|
||||||
object_type="price_rule",
|
object_type="price_rule",
|
||||||
object_ref=price_rule_id,
|
object_ref=price_rule_id,
|
||||||
details={
|
details={
|
||||||
"pricing_category_id": payload.pricing_category_id,
|
"scheme_version_id": version.scheme_version_id,
|
||||||
"target_type": payload.target_type,
|
"pricing_category_id": row.pricing_category_id,
|
||||||
"target_ref": payload.target_ref,
|
"target_type": row.target_type,
|
||||||
"amount": payload.amount,
|
"target_ref": row.target_ref,
|
||||||
"currency": payload.currency,
|
"amount": str(row.amount),
|
||||||
"snapshot": snapshot,
|
"currency": row.currency,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,20 +302,28 @@ async def update_price_rule_endpoint(
|
|||||||
async def delete_price_rule_endpoint(
|
async def delete_price_rule_endpoint(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
price_rule_id: str,
|
price_rule_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
await delete_price_rule(
|
scheme, version = await _require_current_draft(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_price_rule(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
price_rule_id=price_rule_id,
|
price_rule_id=price_rule_id,
|
||||||
)
|
)
|
||||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
event_type="pricing.rule.deleted",
|
event_type="pricing.rule.deleted",
|
||||||
object_type="price_rule",
|
object_type="price_rule",
|
||||||
object_ref=price_rule_id,
|
object_ref=price_rule_id,
|
||||||
details={"snapshot": snapshot},
|
details={"scheme_version_id": version.scheme_version_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"deleted": True, "price_rule_id": price_rule_id}
|
return {
|
||||||
|
"deleted": True,
|
||||||
|
"price_rule_id": price_rule_id,
|
||||||
|
}
|
||||||
|
|||||||
196
backend/app/api/routes/pricing_diagnostics.py
Normal file
196
backend/app/api/routes/pricing_diagnostics.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.repositories.pricing import find_effective_price_rule
|
||||||
|
from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats
|
||||||
|
from app.repositories.scheme_versions import get_current_scheme_version
|
||||||
|
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||||
|
from app.schemas.pricing_diagnostics import PricingRuleDiagnosticsResponse
|
||||||
|
from app.security.auth import require_api_key
|
||||||
|
from app.services.pricing_rule_diagnostics import build_pricing_rule_diagnostics
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/diagnostics",
|
||||||
|
response_model=PricingRuleDiagnosticsResponse,
|
||||||
|
)
|
||||||
|
async def get_pricing_rule_diagnostics(
|
||||||
|
scheme_id: str,
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
|
version = await get_current_scheme_version(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
payload = await build_pricing_rule_diagnostics(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
)
|
||||||
|
return PricingRuleDiagnosticsResponse(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/coverage")
|
||||||
|
async def get_pricing_coverage(
|
||||||
|
scheme_id: str,
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
|
version = await get_current_scheme_version(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
seats = await list_scheme_version_seats(version.scheme_version_id)
|
||||||
|
|
||||||
|
priced = 0
|
||||||
|
unpriced = 0
|
||||||
|
|
||||||
|
for seat in seats:
|
||||||
|
if not seat.seat_id:
|
||||||
|
unpriced += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await find_effective_price_rule(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
seat_id=seat.seat_id,
|
||||||
|
group_id=seat.group_id,
|
||||||
|
sector_id=seat.sector_id,
|
||||||
|
)
|
||||||
|
priced += 1
|
||||||
|
except Exception:
|
||||||
|
unpriced += 1
|
||||||
|
|
||||||
|
total = len(seats)
|
||||||
|
coverage_percent = round((priced / total) * 100, 2) if total else 100.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme.scheme_id,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"total_seats": total,
|
||||||
|
"priced_seats": priced,
|
||||||
|
"unpriced_seats": unpriced,
|
||||||
|
"coverage_percent": coverage_percent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats")
|
||||||
|
async def get_unpriced_seats(
|
||||||
|
scheme_id: str,
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
|
version = await get_current_scheme_version(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
seats = await list_scheme_version_seats(version.scheme_version_id)
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
for seat in seats:
|
||||||
|
if not seat.seat_id:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"seat_record_id": seat.seat_record_id,
|
||||||
|
"seat_id": seat.seat_id,
|
||||||
|
"element_id": seat.element_id,
|
||||||
|
"sector_id": seat.sector_id,
|
||||||
|
"group_id": seat.group_id,
|
||||||
|
"row_label": seat.row_label,
|
||||||
|
"seat_number": seat.seat_number,
|
||||||
|
"reason_code": "missing_seat_id",
|
||||||
|
"reason_message": "Seat has no seat_id and cannot be priced.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await find_effective_price_rule(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
seat_id=seat.seat_id,
|
||||||
|
group_id=seat.group_id,
|
||||||
|
sector_id=seat.sector_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"seat_record_id": seat.seat_record_id,
|
||||||
|
"seat_id": seat.seat_id,
|
||||||
|
"element_id": seat.element_id,
|
||||||
|
"sector_id": seat.sector_id,
|
||||||
|
"group_id": seat.group_id,
|
||||||
|
"row_label": seat.row_label,
|
||||||
|
"seat_number": seat.seat_number,
|
||||||
|
"reason_code": "no_price_rule",
|
||||||
|
"reason_message": "No effective price rule was found for this seat.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme.scheme_id,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"total": len(items),
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/explain/{{seat_id}}")
|
||||||
|
async def explain_seat_pricing(
|
||||||
|
scheme_id: str,
|
||||||
|
seat_id: str,
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
|
version = await get_current_scheme_version(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
seat = await get_scheme_version_seat_by_seat_id(
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
seat_id=seat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
matched_rule_level, rule = await find_effective_price_rule(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
seat_id=seat.seat_id,
|
||||||
|
group_id=seat.group_id,
|
||||||
|
sector_id=seat.sector_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme.scheme_id,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"seat_id": seat.seat_id,
|
||||||
|
"element_id": seat.element_id,
|
||||||
|
"sector_id": seat.sector_id,
|
||||||
|
"group_id": seat.group_id,
|
||||||
|
"row_label": seat.row_label,
|
||||||
|
"seat_number": seat.seat_number,
|
||||||
|
"has_price": True,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"reason_message": "Effective price rule resolved successfully.",
|
||||||
|
"matched_rule": {
|
||||||
|
"matched_rule_level": matched_rule_level,
|
||||||
|
"matched_target_ref": rule["target_ref"],
|
||||||
|
"pricing_category_id": rule["pricing_category_id"],
|
||||||
|
"amount": str(rule["amount"]),
|
||||||
|
"currency": rule["currency"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme.scheme_id,
|
||||||
|
"scheme_version_id": version.scheme_version_id,
|
||||||
|
"seat_id": seat.seat_id,
|
||||||
|
"element_id": seat.element_id,
|
||||||
|
"sector_id": seat.sector_id,
|
||||||
|
"group_id": seat.group_id,
|
||||||
|
"row_label": seat.row_label,
|
||||||
|
"seat_number": seat.seat_number,
|
||||||
|
"has_price": False,
|
||||||
|
"reason_code": "no_price_rule",
|
||||||
|
"reason_message": "No effective price rule was found for this seat.",
|
||||||
|
"matched_rule": None,
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -22,9 +24,13 @@ router = APIRouter()
|
|||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/pricing/snapshot")
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/pricing/snapshot")
|
||||||
async def create_draft_pricing_snapshot(
|
async def create_draft_pricing_snapshot(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
result = await replace_scheme_version_pricing_snapshot(
|
result = await replace_scheme_version_pricing_snapshot(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -45,14 +51,21 @@ async def create_draft_pricing_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
baseline_scheme_version_id: str | None = Query(default=None),
|
baseline_scheme_version_id: str | None = Query(default=None),
|
||||||
refresh: bool = Query(default=False),
|
refresh: bool = Query(default=False),
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_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(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
@@ -70,13 +83,41 @@ 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(
|
async def preview_draft_remap(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: RemapPreviewRequest,
|
payload: RemapPreviewRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
items = await preview_remap(
|
items = await preview_remap(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
seat_record_ids=payload.seat_record_ids,
|
seat_record_ids=payload.seat_record_ids,
|
||||||
@@ -94,13 +135,20 @@ 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(
|
async def apply_draft_remap(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
payload: RemapApplyRequest,
|
payload: RemapApplyRequest,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
scheme, version = await get_current_draft_context(scheme_id)
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
items = await apply_remap(
|
items = await apply_remap(
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
seat_record_ids=payload.seat_record_ids,
|
seat_record_ids=payload.seat_record_ids,
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ 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
|
||||||
from app.repositories.scheme_groups import clone_scheme_version_groups
|
|
||||||
from app.repositories.scheme_seats import clone_scheme_version_seats
|
|
||||||
from app.repositories.scheme_sectors import clone_scheme_version_sectors
|
|
||||||
from app.repositories.scheme_versions import (
|
from app.repositories.scheme_versions import (
|
||||||
count_scheme_versions,
|
count_scheme_versions,
|
||||||
create_next_scheme_version_from_current,
|
create_next_scheme_version_from_current_checked,
|
||||||
|
ensure_draft_scheme_version_consistent,
|
||||||
get_current_scheme_version,
|
get_current_scheme_version,
|
||||||
list_scheme_versions,
|
list_scheme_versions,
|
||||||
)
|
)
|
||||||
@@ -28,6 +26,7 @@ from app.schemas.scheme_registry import (
|
|||||||
SchemeRollbackResponse,
|
SchemeRollbackResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.scheme_versions import (
|
from app.schemas.scheme_versions import (
|
||||||
|
EnsureDraftResponse,
|
||||||
SchemeVersionCreateResponse,
|
SchemeVersionCreateResponse,
|
||||||
SchemeVersionListItem,
|
SchemeVersionListItem,
|
||||||
SchemeVersionListResponse,
|
SchemeVersionListResponse,
|
||||||
@@ -137,27 +136,12 @@ async def get_scheme_versions(
|
|||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse)
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/versions", response_model=SchemeVersionCreateResponse)
|
||||||
async def create_next_scheme_version_endpoint(
|
async def create_next_scheme_version_endpoint(
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
|
expected_current_scheme_version_id: str | None = Query(default=None),
|
||||||
role: str = Depends(require_api_key),
|
role: str = Depends(require_api_key),
|
||||||
):
|
):
|
||||||
current_scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
current_version, new_version = await create_next_scheme_version_from_current_checked(
|
||||||
current_version = await get_current_scheme_version(
|
scheme_id=scheme_id,
|
||||||
scheme_id=current_scheme.scheme_id,
|
expected_current_scheme_version_id=expected_current_scheme_version_id,
|
||||||
current_version_number=current_scheme.current_version_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
new_version = await create_next_scheme_version_from_current(scheme_id)
|
|
||||||
|
|
||||||
await clone_scheme_version_sectors(
|
|
||||||
source_scheme_version_id=current_version.scheme_version_id,
|
|
||||||
target_scheme_version_id=new_version.scheme_version_id,
|
|
||||||
)
|
|
||||||
await clone_scheme_version_groups(
|
|
||||||
source_scheme_version_id=current_version.scheme_version_id,
|
|
||||||
target_scheme_version_id=new_version.scheme_version_id,
|
|
||||||
)
|
|
||||||
await clone_scheme_version_seats(
|
|
||||||
source_scheme_version_id=current_version.scheme_version_id,
|
|
||||||
target_scheme_version_id=new_version.scheme_version_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await create_audit_event(
|
await create_audit_event(
|
||||||
@@ -181,6 +165,52 @@ async def create_next_scheme_version_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/ensure", response_model=EnsureDraftResponse)
|
||||||
|
async def ensure_draft_scheme_version(
|
||||||
|
scheme_id: str,
|
||||||
|
expected_current_scheme_version_id: str | None = Query(default=None),
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
current_version, created, source_scheme_version_id = await ensure_draft_scheme_version_consistent(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
expected_current_scheme_version_id=expected_current_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
return EnsureDraftResponse(
|
||||||
|
scheme_id=current_version.scheme_id,
|
||||||
|
scheme_version_id=current_version.scheme_version_id,
|
||||||
|
version_number=current_version.version_number,
|
||||||
|
status=current_version.status,
|
||||||
|
normalized_storage_path=current_version.normalized_storage_path,
|
||||||
|
created=False,
|
||||||
|
source_scheme_version_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await create_audit_event(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
event_type="scheme.version.created",
|
||||||
|
object_type="scheme_version",
|
||||||
|
object_ref=current_version.scheme_version_id,
|
||||||
|
details={
|
||||||
|
"source_scheme_version_id": source_scheme_version_id,
|
||||||
|
"version_number": current_version.version_number,
|
||||||
|
"normalized_storage_path": current_version.normalized_storage_path,
|
||||||
|
"reason": "ensure_draft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return EnsureDraftResponse(
|
||||||
|
scheme_id=current_version.scheme_id,
|
||||||
|
scheme_version_id=current_version.scheme_version_id,
|
||||||
|
version_number=current_version.version_number,
|
||||||
|
status=current_version.status,
|
||||||
|
normalized_storage_path=current_version.normalized_storage_path,
|
||||||
|
created=True,
|
||||||
|
source_scheme_version_id=source_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation")
|
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation")
|
||||||
async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)):
|
async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)):
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
@@ -200,8 +230,15 @@ async def get_publish_validation(scheme_id: str, role: str = Depends(require_api
|
|||||||
|
|
||||||
|
|
||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish")
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish")
|
||||||
async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)):
|
async def publish_scheme_endpoint(
|
||||||
return await publish_current_draft_scheme(scheme_id=scheme_id)
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = Query(default=None),
|
||||||
|
role: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
return await publish_current_draft_scheme(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse)
|
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse)
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ async def get_effective_seat_price(scheme_id: str, seat_id: str, role: str = Dep
|
|||||||
matched_rule_level=matched_rule_level,
|
matched_rule_level=matched_rule_level,
|
||||||
matched_target_ref=rule["target_ref"],
|
matched_target_ref=rule["target_ref"],
|
||||||
pricing_category_id=rule["pricing_category_id"],
|
pricing_category_id=rule["pricing_category_id"],
|
||||||
amount=rule["amount"],
|
amount=str(rule["amount"]),
|
||||||
currency=rule["currency"],
|
currency=rule["currency"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ async def preview_test_seat(
|
|||||||
seat_id=seat_id,
|
seat_id=seat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not seat.seat_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="Невозможно построить preview: у места отсутствует seat_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
matched_rule_level = None
|
matched_rule_level = None
|
||||||
matched_target_ref = None
|
matched_target_ref = None
|
||||||
pricing_category_id = None
|
pricing_category_id = None
|
||||||
@@ -43,6 +37,27 @@ async def preview_test_seat(
|
|||||||
currency = None
|
currency = None
|
||||||
has_price = False
|
has_price = False
|
||||||
|
|
||||||
|
if not seat.seat_id:
|
||||||
|
return TestSeatPreviewResponse(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
seat_id=seat.seat_id,
|
||||||
|
element_id=seat.element_id,
|
||||||
|
sector_id=seat.sector_id,
|
||||||
|
group_id=seat.group_id,
|
||||||
|
row_label=seat.row_label,
|
||||||
|
seat_number=seat.seat_number,
|
||||||
|
selectable=False,
|
||||||
|
has_price=False,
|
||||||
|
matched_rule_level=None,
|
||||||
|
matched_target_ref=None,
|
||||||
|
pricing_category_id=None,
|
||||||
|
amount=None,
|
||||||
|
currency=None,
|
||||||
|
reason_code="missing_seat_id",
|
||||||
|
reason_message="Seat is not sellable because seat_id is missing.",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
matched_rule_level, rule = await find_effective_price_rule(
|
matched_rule_level, rule = await find_effective_price_rule(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -52,7 +67,7 @@ async def preview_test_seat(
|
|||||||
)
|
)
|
||||||
matched_target_ref = rule["target_ref"]
|
matched_target_ref = rule["target_ref"]
|
||||||
pricing_category_id = rule["pricing_category_id"]
|
pricing_category_id = rule["pricing_category_id"]
|
||||||
amount = rule["amount"]
|
amount = str(rule["amount"])
|
||||||
currency = rule["currency"]
|
currency = rule["currency"]
|
||||||
has_price = True
|
has_price = True
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
@@ -66,15 +81,25 @@ async def preview_test_seat(
|
|||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Не удалось построить preview: {exc.__class__.__name__}: {exc}",
|
detail={
|
||||||
|
"code": "test_preview_failed",
|
||||||
|
"message": f"Не удалось построить preview: {exc.__class__.__name__}: {exc}",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if has_price:
|
||||||
|
reason_code = "ok"
|
||||||
|
reason_message = "Seat is sellable."
|
||||||
|
else:
|
||||||
|
reason_code = "no_price_rule"
|
||||||
|
reason_message = "Seat is not sellable because no effective price rule was found."
|
||||||
|
|
||||||
return TestSeatPreviewResponse(
|
return TestSeatPreviewResponse(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
seat_id=seat.seat_id,
|
seat_id=seat.seat_id,
|
||||||
element_id=seat.element_id,
|
element_id=seat.element_id,
|
||||||
sector_id=seat.sector_id,
|
sector_id=seat.seat_id and seat.sector_id,
|
||||||
group_id=seat.group_id,
|
group_id=seat.group_id,
|
||||||
row_label=seat.row_label,
|
row_label=seat.row_label,
|
||||||
seat_number=seat.seat_number,
|
seat_number=seat.seat_number,
|
||||||
@@ -85,4 +110,6 @@ async def preview_test_seat(
|
|||||||
pricing_category_id=pricing_category_id,
|
pricing_category_id=pricing_category_id,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
|
reason_code=reason_code,
|
||||||
|
reason_message=reason_message,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ from app.repositories.scheme_artifacts import create_scheme_artifact
|
|||||||
from app.repositories.scheme_groups import replace_scheme_version_groups
|
from app.repositories.scheme_groups import replace_scheme_version_groups
|
||||||
from app.repositories.scheme_seats import replace_scheme_version_seats
|
from app.repositories.scheme_seats import replace_scheme_version_seats
|
||||||
from app.repositories.scheme_sectors import replace_scheme_version_sectors
|
from app.repositories.scheme_sectors import replace_scheme_version_sectors
|
||||||
from app.repositories.scheme_versions import create_initial_scheme_version
|
from app.repositories.schemes import create_scheme_from_upload_with_initial_version
|
||||||
from app.repositories.schemes import create_scheme_from_upload
|
|
||||||
from app.repositories.uploads import (
|
from app.repositories.uploads import (
|
||||||
count_upload_records,
|
count_upload_records,
|
||||||
create_upload_record,
|
create_upload_record,
|
||||||
@@ -202,17 +201,9 @@ async def upload_scheme_svg(
|
|||||||
processing_status="completed",
|
processing_status="completed",
|
||||||
)
|
)
|
||||||
|
|
||||||
scheme_id = await create_scheme_from_upload(
|
scheme_id, scheme_version_id = await create_scheme_from_upload_with_initial_version(
|
||||||
source_upload_id=upload_id,
|
source_upload_id=upload_id,
|
||||||
name=Path(filename).stem or filename,
|
name=Path(filename).stem or filename,
|
||||||
normalized_elements_count=summary["elements_count"],
|
|
||||||
normalized_seats_count=summary["seats_count"],
|
|
||||||
normalized_groups_count=summary["groups_count"],
|
|
||||||
normalized_sectors_count=summary["sectors_count"],
|
|
||||||
)
|
|
||||||
|
|
||||||
scheme_version_id = await create_initial_scheme_version(
|
|
||||||
scheme_id=scheme_id,
|
|
||||||
normalized_storage_path=normalized_storage_path,
|
normalized_storage_path=normalized_storage_path,
|
||||||
normalized_elements_count=summary["elements_count"],
|
normalized_elements_count=summary["elements_count"],
|
||||||
normalized_seats_count=summary["seats_count"],
|
normalized_seats_count=summary["seats_count"],
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
app_name: str = "svg-service"
|
app_name: str = Field(..., validation_alias="APP_NAME")
|
||||||
app_env: str = "development"
|
app_env: str = Field(..., validation_alias="APP_ENV")
|
||||||
app_port: int = 9020
|
app_port: int = Field(..., validation_alias="BACKEND_PORT")
|
||||||
api_v1_prefix: str = "/api/v1"
|
api_v1_prefix: str = Field(..., validation_alias="API_V1_PREFIX")
|
||||||
|
|
||||||
auth_header_name: str = "X-API-Key"
|
auth_header_name: str = Field(..., validation_alias="AUTH_HEADER_NAME")
|
||||||
admin_api_key: str = "admin-local-dev-key"
|
api_keys_admin: str = Field(..., validation_alias="API_KEYS_ADMIN")
|
||||||
viewer_api_key: str = "viewer-local-dev-key"
|
api_keys_operator: str = Field(..., validation_alias="API_KEYS_OPERATOR")
|
||||||
|
api_keys_viewer: str = Field(..., validation_alias="API_KEYS_VIEWER")
|
||||||
|
|
||||||
postgres_host: str = "postgres"
|
postgres_host: str = Field(..., validation_alias="POSTGRES_HOST")
|
||||||
postgres_port: int = 5432
|
postgres_port: int = Field(..., validation_alias="POSTGRES_PORT")
|
||||||
postgres_db: str = "svg_service"
|
postgres_db: str = Field(..., validation_alias="POSTGRES_DB")
|
||||||
postgres_user: str = "svg_service"
|
postgres_user: str = Field(..., validation_alias="POSTGRES_USER")
|
||||||
postgres_password: str = "svg_service_dev_password"
|
postgres_password: str = Field(..., validation_alias="POSTGRES_PASSWORD")
|
||||||
|
database_url_raw: str | None = Field(default=None, validation_alias="DATABASE_URL")
|
||||||
|
|
||||||
svg_max_file_size_bytes: int = 10 * 1024 * 1024
|
svg_max_file_size_bytes: int = Field(10 * 1024 * 1024, validation_alias="SVG_MAX_FILE_SIZE_BYTES")
|
||||||
svg_max_elements: int = 25000
|
svg_max_elements: int = Field(25000, validation_alias="SVG_MAX_ELEMENTS")
|
||||||
|
|
||||||
svg_allow_internal_use_references_only: bool = True
|
svg_allow_internal_use_references_only: bool = Field(True, validation_alias="SVG_ALLOW_INTERNAL_USE_REFERENCES_ONLY")
|
||||||
svg_forbid_foreign_object_v1: bool = True
|
svg_forbid_foreign_object_v1: bool = Field(True, validation_alias="SVG_FORBID_FOREIGN_OBJECT_V1")
|
||||||
svg_forbid_style_v1: bool = False
|
svg_forbid_style_v1: bool = Field(False, validation_alias="SVG_FORBID_STYLE_V1")
|
||||||
svg_forbid_image_v1: bool = True
|
svg_forbid_image_v1: bool = Field(True, validation_alias="SVG_FORBID_IMAGE_V1")
|
||||||
|
|
||||||
svg_display_enabled: bool = True
|
svg_display_enabled: bool = True
|
||||||
svg_display_mode: str = "passthrough"
|
svg_display_mode: str = "passthrough"
|
||||||
@@ -34,8 +37,9 @@ class Settings(BaseSettings):
|
|||||||
svg_display_force_viewbox: bool = True
|
svg_display_force_viewbox: bool = True
|
||||||
svg_display_technical_text_patterns: str = "debug,tech,helper,tmp,service"
|
svg_display_technical_text_patterns: str = "debug,tech,helper,tmp,service"
|
||||||
|
|
||||||
storage_root_dir: str = "/data"
|
storage_root_dir: str = Field(..., validation_alias="STORAGE_ROOT")
|
||||||
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",
|
||||||
@@ -44,16 +48,32 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_database_config(self) -> "Settings":
|
||||||
|
assembled_database_url = (
|
||||||
|
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
||||||
|
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||||
|
)
|
||||||
|
if self.database_url_raw and self.database_url_raw != assembled_database_url:
|
||||||
|
raise ValueError("DATABASE_URL must match POSTGRES_HOST/PORT/DB/USER/PASSWORD")
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def admin_keys(self) -> set[str]:
|
def admin_keys(self) -> set[str]:
|
||||||
return {item.strip() for item in self.admin_api_key.split(",") if item.strip()}
|
return {item.strip() for item in self.api_keys_admin.split(",") if item.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operator_keys(self) -> set[str]:
|
||||||
|
return {item.strip() for item in self.api_keys_operator.split(",") if item.strip()}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def viewer_keys(self) -> set[str]:
|
def viewer_keys(self) -> set[str]:
|
||||||
return {item.strip() for item in self.viewer_api_key.split(",") if item.strip()}
|
return {item.strip() for item in self.api_keys_viewer.split(",") if item.strip()}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
|
if self.database_url_raw:
|
||||||
|
return self.database_url_raw
|
||||||
return (
|
return (
|
||||||
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
||||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||||
|
|||||||
95
backend/app/repositories/pricing_cleanup.py
Normal file
95
backend/app/repositories/pricing_cleanup.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, outerjoin, select
|
||||||
|
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model(module_path: str, *candidate_names: str):
|
||||||
|
module = import_module(module_path)
|
||||||
|
for name in candidate_names:
|
||||||
|
model = getattr(module, name, None)
|
||||||
|
if model is not None:
|
||||||
|
return model
|
||||||
|
raise ImportError(
|
||||||
|
f"Unable to resolve model from {module_path}. "
|
||||||
|
f"Tried: {', '.join(candidate_names)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PricingCategoryModel = _resolve_model(
|
||||||
|
"app.models.pricing_category",
|
||||||
|
"PricingCategory",
|
||||||
|
"PricingCategoryRecord",
|
||||||
|
)
|
||||||
|
PriceRuleModel = _resolve_model(
|
||||||
|
"app.models.price_rule",
|
||||||
|
"PriceRule",
|
||||||
|
"PriceRuleRecord",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_pricing_categories_with_rule_counts(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
PricingCategoryModel.pricing_category_id,
|
||||||
|
PricingCategoryModel.scheme_id,
|
||||||
|
PricingCategoryModel.name,
|
||||||
|
PricingCategoryModel.code,
|
||||||
|
func.count(PriceRuleModel.price_rule_id).label("rules_count"),
|
||||||
|
)
|
||||||
|
.select_from(
|
||||||
|
outerjoin(
|
||||||
|
PricingCategoryModel,
|
||||||
|
PriceRuleModel,
|
||||||
|
PricingCategoryModel.pricing_category_id == PriceRuleModel.pricing_category_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(PricingCategoryModel.scheme_id == scheme_id)
|
||||||
|
.group_by(
|
||||||
|
PricingCategoryModel.pricing_category_id,
|
||||||
|
PricingCategoryModel.scheme_id,
|
||||||
|
PricingCategoryModel.name,
|
||||||
|
PricingCategoryModel.code,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
PricingCategoryModel.name.asc(),
|
||||||
|
PricingCategoryModel.code.asc(),
|
||||||
|
PricingCategoryModel.pricing_category_id.asc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pricing_category_id": row.pricing_category_id,
|
||||||
|
"scheme_id": row.scheme_id,
|
||||||
|
"name": row.name,
|
||||||
|
"code": row.code,
|
||||||
|
"rules_count": int(row.rules_count or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_pricing_categories_by_ids(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
pricing_category_ids: list[str],
|
||||||
|
) -> int:
|
||||||
|
if not pricing_category_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = delete(PricingCategoryModel).where(
|
||||||
|
PricingCategoryModel.scheme_id == scheme_id,
|
||||||
|
PricingCategoryModel.pricing_category_id.in_(pricing_category_ids),
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return int(result.rowcount or 0)
|
||||||
@@ -8,6 +8,49 @@ from app.models.scheme_group import SchemeGroupRecord
|
|||||||
from app.models.scheme_seat import SchemeSeatRecord
|
from app.models.scheme_seat import SchemeSeatRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(message: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": "group_uniqueness_violation",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_group_uniqueness(
|
||||||
|
*,
|
||||||
|
session,
|
||||||
|
scheme_version_id: str,
|
||||||
|
group_id: str | None,
|
||||||
|
element_id: str | None,
|
||||||
|
exclude_group_record_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if group_id:
|
||||||
|
stmt = select(SchemeGroupRecord).where(
|
||||||
|
SchemeGroupRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeGroupRecord.group_id == group_id,
|
||||||
|
)
|
||||||
|
if exclude_group_record_id:
|
||||||
|
stmt = stmt.where(SchemeGroupRecord.group_record_id != exclude_group_record_id)
|
||||||
|
|
||||||
|
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise _conflict(f"Group with group_id='{group_id}' already exists in current draft version")
|
||||||
|
|
||||||
|
if element_id:
|
||||||
|
stmt = select(SchemeGroupRecord).where(
|
||||||
|
SchemeGroupRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeGroupRecord.element_id == element_id,
|
||||||
|
)
|
||||||
|
if exclude_group_record_id:
|
||||||
|
stmt = stmt.where(SchemeGroupRecord.group_record_id != exclude_group_record_id)
|
||||||
|
|
||||||
|
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise _conflict(f"Group with element_id='{element_id}' already exists in current draft version")
|
||||||
|
|
||||||
|
|
||||||
async def replace_scheme_version_groups(
|
async def replace_scheme_version_groups(
|
||||||
*,
|
*,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
@@ -23,13 +66,29 @@ async def replace_scheme_version_groups(
|
|||||||
for row in existing_rows:
|
for row in existing_rows:
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
|
|
||||||
|
seen_group_ids: set[str] = set()
|
||||||
|
seen_element_ids: set[str] = set()
|
||||||
|
|
||||||
for item in groups:
|
for item in groups:
|
||||||
|
group_id = item.get("group_id")
|
||||||
|
element_id = item.get("id")
|
||||||
|
|
||||||
|
if group_id:
|
||||||
|
if group_id in seen_group_ids:
|
||||||
|
raise _conflict(f"Duplicate group_id='{group_id}' in replacement payload")
|
||||||
|
seen_group_ids.add(group_id)
|
||||||
|
|
||||||
|
if element_id:
|
||||||
|
if element_id in seen_element_ids:
|
||||||
|
raise _conflict(f"Duplicate element_id='{element_id}' in replacement payload")
|
||||||
|
seen_element_ids.add(element_id)
|
||||||
|
|
||||||
row = SchemeGroupRecord(
|
row = SchemeGroupRecord(
|
||||||
group_record_id=item["group_record_id"] if "group_record_id" in item and item["group_record_id"] else uuid4().hex,
|
group_record_id=item["group_record_id"] if "group_record_id" in item and item["group_record_id"] else uuid4().hex,
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
scheme_version_id=scheme_version_id,
|
scheme_version_id=scheme_version_id,
|
||||||
element_id=item.get("id"),
|
element_id=element_id,
|
||||||
group_id=item.get("group_id"),
|
group_id=group_id,
|
||||||
name=item.get("group_id"),
|
name=item.get("group_id"),
|
||||||
classes_raw=str(item.get("classes")),
|
classes_raw=str(item.get("classes")),
|
||||||
)
|
)
|
||||||
@@ -44,26 +103,51 @@ async def clone_scheme_version_groups(
|
|||||||
target_scheme_version_id: str,
|
target_scheme_version_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
await clone_scheme_version_groups_in_session(
|
||||||
select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == source_scheme_version_id)
|
session=session,
|
||||||
|
source_scheme_version_id=source_scheme_version_id,
|
||||||
|
target_scheme_version_id=target_scheme_version_id,
|
||||||
)
|
)
|
||||||
rows = list(result.scalars().all())
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
cloned = SchemeGroupRecord(
|
|
||||||
group_record_id=uuid4().hex,
|
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=target_scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
group_id=row.group_id,
|
|
||||||
name=row.name,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
)
|
|
||||||
session.add(cloned)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def clone_scheme_version_groups_in_session(
|
||||||
|
*,
|
||||||
|
session,
|
||||||
|
source_scheme_version_id: str,
|
||||||
|
target_scheme_version_id: str,
|
||||||
|
) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == source_scheme_version_id)
|
||||||
|
)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
|
||||||
|
seen_group_ids: set[str] = set()
|
||||||
|
seen_element_ids: set[str] = set()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.group_id:
|
||||||
|
if row.group_id in seen_group_ids:
|
||||||
|
raise _conflict(f"Duplicate group_id='{row.group_id}' while cloning draft")
|
||||||
|
seen_group_ids.add(row.group_id)
|
||||||
|
|
||||||
|
if row.element_id:
|
||||||
|
if row.element_id in seen_element_ids:
|
||||||
|
raise _conflict(f"Duplicate element_id='{row.element_id}' while cloning draft")
|
||||||
|
seen_element_ids.add(row.element_id)
|
||||||
|
|
||||||
|
cloned = SchemeGroupRecord(
|
||||||
|
group_record_id=uuid4().hex,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=target_scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
group_id=row.group_id,
|
||||||
|
name=row.name,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
)
|
||||||
|
session.add(cloned)
|
||||||
|
|
||||||
|
|
||||||
async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]:
|
async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -78,8 +162,7 @@ async def update_scheme_version_group_by_record_id(
|
|||||||
*,
|
*,
|
||||||
scheme_version_id: str,
|
scheme_version_id: str,
|
||||||
group_record_id: str,
|
group_record_id: str,
|
||||||
group_id: str | None,
|
**update_data,
|
||||||
name: str | None,
|
|
||||||
) -> tuple[SchemeGroupRecord, str | None]:
|
) -> tuple[SchemeGroupRecord, str | None]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -96,9 +179,20 @@ async def update_scheme_version_group_by_record_id(
|
|||||||
detail="Group record not found in current draft version",
|
detail="Group record not found in current draft version",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "group_id" in update_data:
|
||||||
|
await _ensure_group_uniqueness(
|
||||||
|
session=session,
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
group_id=update_data["group_id"],
|
||||||
|
element_id=row.element_id,
|
||||||
|
exclude_group_record_id=group_record_id,
|
||||||
|
)
|
||||||
|
|
||||||
old_group_id = row.group_id
|
old_group_id = row.group_id
|
||||||
row.group_id = group_id
|
if "group_id" in update_data:
|
||||||
row.name = name
|
row.group_id = update_data["group_id"]
|
||||||
|
if "name" in update_data:
|
||||||
|
row.name = update_data["name"]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -115,6 +209,13 @@ async def create_scheme_version_group(
|
|||||||
classes_raw: str | None,
|
classes_raw: str | None,
|
||||||
) -> SchemeGroupRecord:
|
) -> SchemeGroupRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
await _ensure_group_uniqueness(
|
||||||
|
session=session,
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
group_id=group_id,
|
||||||
|
element_id=element_id,
|
||||||
|
)
|
||||||
|
|
||||||
row = SchemeGroupRecord(
|
row = SchemeGroupRecord(
|
||||||
group_record_id=uuid4().hex,
|
group_record_id=uuid4().hex,
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
@@ -166,3 +267,26 @@ async def delete_scheme_version_group_by_record_id(
|
|||||||
|
|
||||||
await session.delete(group)
|
await session.delete(group)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_scheme_version_group_by_record_id(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
group_record_id: str,
|
||||||
|
) -> SchemeGroupRecord:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeGroupRecord).where(
|
||||||
|
SchemeGroupRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeGroupRecord.group_record_id == group_record_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Group record not found in current draft version",
|
||||||
|
)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|||||||
@@ -51,36 +51,48 @@ async def clone_scheme_version_seats(
|
|||||||
target_scheme_version_id: str,
|
target_scheme_version_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
await clone_scheme_version_seats_in_session(
|
||||||
select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == source_scheme_version_id)
|
session=session,
|
||||||
|
source_scheme_version_id=source_scheme_version_id,
|
||||||
|
target_scheme_version_id=target_scheme_version_id,
|
||||||
)
|
)
|
||||||
rows = list(result.scalars().all())
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
cloned = SchemeSeatRecord(
|
|
||||||
seat_record_id=__import__("uuid").uuid4().hex,
|
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=target_scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
seat_id=row.seat_id,
|
|
||||||
sector_id=row.sector_id,
|
|
||||||
group_id=row.group_id,
|
|
||||||
row_label=row.row_label,
|
|
||||||
seat_number=row.seat_number,
|
|
||||||
tag=row.tag,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
x=row.x,
|
|
||||||
y=row.y,
|
|
||||||
cx=row.cx,
|
|
||||||
cy=row.cy,
|
|
||||||
width=row.width,
|
|
||||||
height=row.height,
|
|
||||||
)
|
|
||||||
session.add(cloned)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def clone_scheme_version_seats_in_session(
|
||||||
|
*,
|
||||||
|
session,
|
||||||
|
source_scheme_version_id: str,
|
||||||
|
target_scheme_version_id: str,
|
||||||
|
) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == source_scheme_version_id)
|
||||||
|
)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
cloned = SchemeSeatRecord(
|
||||||
|
seat_record_id=__import__("uuid").uuid4().hex,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=target_scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
seat_id=row.seat_id,
|
||||||
|
sector_id=row.sector_id,
|
||||||
|
group_id=row.group_id,
|
||||||
|
row_label=row.row_label,
|
||||||
|
seat_number=row.seat_number,
|
||||||
|
tag=row.tag,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
x=row.x,
|
||||||
|
y=row.y,
|
||||||
|
cx=row.cx,
|
||||||
|
cy=row.cy,
|
||||||
|
width=row.width,
|
||||||
|
height=row.height,
|
||||||
|
)
|
||||||
|
session.add(cloned)
|
||||||
|
|
||||||
|
|
||||||
async def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]:
|
async def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -114,15 +126,10 @@ async def get_scheme_version_seat_by_seat_id(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def update_scheme_version_seat_by_record_id(
|
async def get_scheme_version_seat_by_record_id(
|
||||||
*,
|
*,
|
||||||
scheme_version_id: str,
|
scheme_version_id: str,
|
||||||
seat_record_id: str,
|
seat_record_id: str,
|
||||||
seat_id: str | None,
|
|
||||||
sector_id: str | None,
|
|
||||||
group_id: str | None,
|
|
||||||
row_label: str | None,
|
|
||||||
seat_number: str | None,
|
|
||||||
) -> SchemeSeatRecord:
|
) -> SchemeSeatRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -139,11 +146,40 @@ async def update_scheme_version_seat_by_record_id(
|
|||||||
detail="Seat record not found in current draft version",
|
detail="Seat record not found in current draft version",
|
||||||
)
|
)
|
||||||
|
|
||||||
row.seat_id = seat_id
|
return row
|
||||||
row.sector_id = sector_id
|
|
||||||
row.group_id = group_id
|
|
||||||
row.row_label = row_label
|
async def update_scheme_version_seat_by_record_id(
|
||||||
row.seat_number = seat_number
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
seat_record_id: str,
|
||||||
|
**update_data,
|
||||||
|
) -> SchemeSeatRecord:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeSeatRecord).where(
|
||||||
|
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeSeatRecord.seat_record_id == seat_record_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Seat record not found in current draft version",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "seat_id" in update_data:
|
||||||
|
row.seat_id = update_data["seat_id"]
|
||||||
|
if "sector_id" in update_data:
|
||||||
|
row.sector_id = update_data["sector_id"]
|
||||||
|
if "group_id" in update_data:
|
||||||
|
row.group_id = update_data["group_id"]
|
||||||
|
if "row_label" in update_data:
|
||||||
|
row.row_label = update_data["row_label"]
|
||||||
|
if "seat_number" in update_data:
|
||||||
|
row.seat_number = update_data["seat_number"]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -173,11 +209,16 @@ async def bulk_update_scheme_version_seats_by_record_id(
|
|||||||
detail=f"Seat record not found in current draft version: {item['seat_record_id']}",
|
detail=f"Seat record not found in current draft version: {item['seat_record_id']}",
|
||||||
)
|
)
|
||||||
|
|
||||||
row.seat_id = item.get("seat_id")
|
if "seat_id" in item:
|
||||||
row.sector_id = item.get("sector_id")
|
row.seat_id = item["seat_id"]
|
||||||
row.group_id = item.get("group_id")
|
if "sector_id" in item:
|
||||||
row.row_label = item.get("row_label")
|
row.sector_id = item["sector_id"]
|
||||||
row.seat_number = item.get("seat_number")
|
if "group_id" in item:
|
||||||
|
row.group_id = item["group_id"]
|
||||||
|
if "row_label" in item:
|
||||||
|
row.row_label = item["row_label"]
|
||||||
|
if "seat_number" in item:
|
||||||
|
row.seat_number = item["seat_number"]
|
||||||
updated_rows.append(row)
|
updated_rows.append(row)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -8,6 +8,49 @@ from app.models.scheme_sector import SchemeSectorRecord
|
|||||||
from app.models.scheme_seat import SchemeSeatRecord
|
from app.models.scheme_seat import SchemeSeatRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(message: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": "sector_uniqueness_violation",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_sector_uniqueness(
|
||||||
|
*,
|
||||||
|
session,
|
||||||
|
scheme_version_id: str,
|
||||||
|
sector_id: str | None,
|
||||||
|
element_id: str | None,
|
||||||
|
exclude_sector_record_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if sector_id:
|
||||||
|
stmt = select(SchemeSectorRecord).where(
|
||||||
|
SchemeSectorRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeSectorRecord.sector_id == sector_id,
|
||||||
|
)
|
||||||
|
if exclude_sector_record_id:
|
||||||
|
stmt = stmt.where(SchemeSectorRecord.sector_record_id != exclude_sector_record_id)
|
||||||
|
|
||||||
|
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise _conflict(f"Sector with sector_id='{sector_id}' already exists in current draft version")
|
||||||
|
|
||||||
|
if element_id:
|
||||||
|
stmt = select(SchemeSectorRecord).where(
|
||||||
|
SchemeSectorRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeSectorRecord.element_id == element_id,
|
||||||
|
)
|
||||||
|
if exclude_sector_record_id:
|
||||||
|
stmt = stmt.where(SchemeSectorRecord.sector_record_id != exclude_sector_record_id)
|
||||||
|
|
||||||
|
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise _conflict(f"Sector with element_id='{element_id}' already exists in current draft version")
|
||||||
|
|
||||||
|
|
||||||
async def replace_scheme_version_sectors(
|
async def replace_scheme_version_sectors(
|
||||||
*,
|
*,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
@@ -23,13 +66,29 @@ async def replace_scheme_version_sectors(
|
|||||||
for row in existing_rows:
|
for row in existing_rows:
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
|
|
||||||
|
seen_sector_ids: set[str] = set()
|
||||||
|
seen_element_ids: set[str] = set()
|
||||||
|
|
||||||
for item in sectors:
|
for item in sectors:
|
||||||
|
sector_id = item.get("sector_id")
|
||||||
|
element_id = item.get("id")
|
||||||
|
|
||||||
|
if sector_id:
|
||||||
|
if sector_id in seen_sector_ids:
|
||||||
|
raise _conflict(f"Duplicate sector_id='{sector_id}' in replacement payload")
|
||||||
|
seen_sector_ids.add(sector_id)
|
||||||
|
|
||||||
|
if element_id:
|
||||||
|
if element_id in seen_element_ids:
|
||||||
|
raise _conflict(f"Duplicate element_id='{element_id}' in replacement payload")
|
||||||
|
seen_element_ids.add(element_id)
|
||||||
|
|
||||||
row = SchemeSectorRecord(
|
row = SchemeSectorRecord(
|
||||||
sector_record_id=item["sector_record_id"] if "sector_record_id" in item and item["sector_record_id"] else uuid4().hex,
|
sector_record_id=item["sector_record_id"] if "sector_record_id" in item and item["sector_record_id"] else uuid4().hex,
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
scheme_version_id=scheme_version_id,
|
scheme_version_id=scheme_version_id,
|
||||||
element_id=item.get("id"),
|
element_id=element_id,
|
||||||
sector_id=item.get("sector_id"),
|
sector_id=sector_id,
|
||||||
name=item.get("sector_id"),
|
name=item.get("sector_id"),
|
||||||
classes_raw=str(item.get("classes")),
|
classes_raw=str(item.get("classes")),
|
||||||
)
|
)
|
||||||
@@ -44,26 +103,51 @@ async def clone_scheme_version_sectors(
|
|||||||
target_scheme_version_id: str,
|
target_scheme_version_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
await clone_scheme_version_sectors_in_session(
|
||||||
select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == source_scheme_version_id)
|
session=session,
|
||||||
|
source_scheme_version_id=source_scheme_version_id,
|
||||||
|
target_scheme_version_id=target_scheme_version_id,
|
||||||
)
|
)
|
||||||
rows = list(result.scalars().all())
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
cloned = SchemeSectorRecord(
|
|
||||||
sector_record_id=uuid4().hex,
|
|
||||||
scheme_id=row.scheme_id,
|
|
||||||
scheme_version_id=target_scheme_version_id,
|
|
||||||
element_id=row.element_id,
|
|
||||||
sector_id=row.sector_id,
|
|
||||||
name=row.name,
|
|
||||||
classes_raw=row.classes_raw,
|
|
||||||
)
|
|
||||||
session.add(cloned)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def clone_scheme_version_sectors_in_session(
|
||||||
|
*,
|
||||||
|
session,
|
||||||
|
source_scheme_version_id: str,
|
||||||
|
target_scheme_version_id: str,
|
||||||
|
) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == source_scheme_version_id)
|
||||||
|
)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
|
||||||
|
seen_sector_ids: set[str] = set()
|
||||||
|
seen_element_ids: set[str] = set()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.sector_id:
|
||||||
|
if row.sector_id in seen_sector_ids:
|
||||||
|
raise _conflict(f"Duplicate sector_id='{row.sector_id}' while cloning draft")
|
||||||
|
seen_sector_ids.add(row.sector_id)
|
||||||
|
|
||||||
|
if row.element_id:
|
||||||
|
if row.element_id in seen_element_ids:
|
||||||
|
raise _conflict(f"Duplicate element_id='{row.element_id}' while cloning draft")
|
||||||
|
seen_element_ids.add(row.element_id)
|
||||||
|
|
||||||
|
cloned = SchemeSectorRecord(
|
||||||
|
sector_record_id=uuid4().hex,
|
||||||
|
scheme_id=row.scheme_id,
|
||||||
|
scheme_version_id=target_scheme_version_id,
|
||||||
|
element_id=row.element_id,
|
||||||
|
sector_id=row.sector_id,
|
||||||
|
name=row.name,
|
||||||
|
classes_raw=row.classes_raw,
|
||||||
|
)
|
||||||
|
session.add(cloned)
|
||||||
|
|
||||||
|
|
||||||
async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]:
|
async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -78,8 +162,7 @@ async def update_scheme_version_sector_by_record_id(
|
|||||||
*,
|
*,
|
||||||
scheme_version_id: str,
|
scheme_version_id: str,
|
||||||
sector_record_id: str,
|
sector_record_id: str,
|
||||||
sector_id: str | None,
|
**update_data,
|
||||||
name: str | None,
|
|
||||||
) -> tuple[SchemeSectorRecord, str | None]:
|
) -> tuple[SchemeSectorRecord, str | None]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -96,9 +179,20 @@ async def update_scheme_version_sector_by_record_id(
|
|||||||
detail="Sector record not found in current draft version",
|
detail="Sector record not found in current draft version",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "sector_id" in update_data:
|
||||||
|
await _ensure_sector_uniqueness(
|
||||||
|
session=session,
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
sector_id=update_data["sector_id"],
|
||||||
|
element_id=row.element_id,
|
||||||
|
exclude_sector_record_id=sector_record_id,
|
||||||
|
)
|
||||||
|
|
||||||
old_sector_id = row.sector_id
|
old_sector_id = row.sector_id
|
||||||
row.sector_id = sector_id
|
if "sector_id" in update_data:
|
||||||
row.name = name
|
row.sector_id = update_data["sector_id"]
|
||||||
|
if "name" in update_data:
|
||||||
|
row.name = update_data["name"]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -115,6 +209,13 @@ async def create_scheme_version_sector(
|
|||||||
classes_raw: str | None,
|
classes_raw: str | None,
|
||||||
) -> SchemeSectorRecord:
|
) -> SchemeSectorRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
await _ensure_sector_uniqueness(
|
||||||
|
session=session,
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
sector_id=sector_id,
|
||||||
|
element_id=element_id,
|
||||||
|
)
|
||||||
|
|
||||||
row = SchemeSectorRecord(
|
row = SchemeSectorRecord(
|
||||||
sector_record_id=uuid4().hex,
|
sector_record_id=uuid4().hex,
|
||||||
scheme_id=scheme_id,
|
scheme_id=scheme_id,
|
||||||
@@ -166,3 +267,26 @@ async def delete_scheme_version_sector_by_record_id(
|
|||||||
|
|
||||||
await session.delete(sector)
|
await session.delete(sector)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_scheme_version_sector_by_record_id(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
sector_record_id: str,
|
||||||
|
) -> SchemeSectorRecord:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SchemeSectorRecord).where(
|
||||||
|
SchemeSectorRecord.scheme_version_id == scheme_version_id,
|
||||||
|
SchemeSectorRecord.sector_record_id == sector_record_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Sector record not found in current draft version",
|
||||||
|
)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|||||||
@@ -7,6 +7,125 @@ from sqlalchemy import asc, desc, func, select
|
|||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.scheme import SchemeRecord
|
from app.models.scheme import SchemeRecord
|
||||||
from app.models.scheme_version import SchemeVersionRecord
|
from app.models.scheme_version import SchemeVersionRecord
|
||||||
|
from app.repositories.scheme_groups import clone_scheme_version_groups_in_session
|
||||||
|
from app.repositories.scheme_seats import clone_scheme_version_seats_in_session
|
||||||
|
from app.repositories.scheme_sectors import clone_scheme_version_sectors_in_session
|
||||||
|
from app.services.api_errors import raise_conflict
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_current_version_inconsistent(*, scheme_id: str, current_version_number: int) -> None:
|
||||||
|
raise_conflict(
|
||||||
|
code="current_version_inconsistent",
|
||||||
|
message="Scheme current version pointer is inconsistent with scheme_versions state.",
|
||||||
|
details={
|
||||||
|
"scheme_id": scheme_id,
|
||||||
|
"current_version_number": current_version_number,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_stale_current_version(*, expected_scheme_version_id: str, actual_scheme_version_id: str) -> None:
|
||||||
|
raise_conflict(
|
||||||
|
code="stale_current_version",
|
||||||
|
message="Current scheme version changed. Reload scheme state before creating a new version.",
|
||||||
|
details={
|
||||||
|
"expected_scheme_version_id": expected_scheme_version_id,
|
||||||
|
"actual_scheme_version_id": actual_scheme_version_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_scheme_for_update(session, scheme_id: str) -> SchemeRecord:
|
||||||
|
scheme_result = await session.execute(
|
||||||
|
select(SchemeRecord)
|
||||||
|
.where(SchemeRecord.scheme_id == scheme_id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
scheme = scheme_result.scalar_one_or_none()
|
||||||
|
if scheme is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scheme not found",
|
||||||
|
)
|
||||||
|
return scheme
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_current_scheme_version_for_update(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
current_version_number: int,
|
||||||
|
) -> SchemeVersionRecord:
|
||||||
|
current_result = await session.execute(
|
||||||
|
select(SchemeVersionRecord)
|
||||||
|
.where(
|
||||||
|
SchemeVersionRecord.scheme_id == scheme_id,
|
||||||
|
SchemeVersionRecord.version_number == current_version_number,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
current_version = current_result.scalar_one_or_none()
|
||||||
|
if current_version is None:
|
||||||
|
_raise_current_version_inconsistent(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
current_version_number=current_version_number,
|
||||||
|
)
|
||||||
|
return current_version
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_next_draft_version(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
scheme: SchemeRecord,
|
||||||
|
source_version: SchemeVersionRecord,
|
||||||
|
) -> SchemeVersionRecord:
|
||||||
|
max_version_result = await session.execute(
|
||||||
|
select(func.coalesce(func.max(SchemeVersionRecord.version_number), 0)).where(
|
||||||
|
SchemeVersionRecord.scheme_id == scheme.scheme_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_version_number = int(max_version_result.scalar_one()) + 1
|
||||||
|
new_version = SchemeVersionRecord(
|
||||||
|
scheme_version_id=uuid4().hex,
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
version_number=next_version_number,
|
||||||
|
status="draft",
|
||||||
|
normalized_storage_path=source_version.normalized_storage_path,
|
||||||
|
normalized_elements_count=source_version.normalized_elements_count,
|
||||||
|
normalized_seats_count=source_version.normalized_seats_count,
|
||||||
|
normalized_groups_count=source_version.normalized_groups_count,
|
||||||
|
normalized_sectors_count=source_version.normalized_sectors_count,
|
||||||
|
display_svg_storage_path=source_version.display_svg_storage_path,
|
||||||
|
display_svg_status=source_version.display_svg_status,
|
||||||
|
display_svg_generated_at=source_version.display_svg_generated_at,
|
||||||
|
)
|
||||||
|
session.add(new_version)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await clone_scheme_version_sectors_in_session(
|
||||||
|
session=session,
|
||||||
|
source_scheme_version_id=source_version.scheme_version_id,
|
||||||
|
target_scheme_version_id=new_version.scheme_version_id,
|
||||||
|
)
|
||||||
|
await clone_scheme_version_groups_in_session(
|
||||||
|
session=session,
|
||||||
|
source_scheme_version_id=source_version.scheme_version_id,
|
||||||
|
target_scheme_version_id=new_version.scheme_version_id,
|
||||||
|
)
|
||||||
|
await clone_scheme_version_seats_in_session(
|
||||||
|
session=session,
|
||||||
|
source_scheme_version_id=source_version.scheme_version_id,
|
||||||
|
target_scheme_version_id=new_version.scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheme.current_version_number = new_version.version_number
|
||||||
|
scheme.status = "draft"
|
||||||
|
scheme.published_at = None
|
||||||
|
scheme.normalized_elements_count = source_version.normalized_elements_count
|
||||||
|
scheme.normalized_seats_count = source_version.normalized_seats_count
|
||||||
|
scheme.normalized_groups_count = source_version.normalized_groups_count
|
||||||
|
scheme.normalized_sectors_count = source_version.normalized_sectors_count
|
||||||
|
return new_version
|
||||||
|
|
||||||
|
|
||||||
async def create_initial_scheme_version(
|
async def create_initial_scheme_version(
|
||||||
@@ -75,9 +194,9 @@ async def get_current_scheme_version(scheme_id: str, current_version_number: int
|
|||||||
row = result.scalar_one_or_none()
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(
|
_raise_current_version_inconsistent(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
scheme_id=scheme_id,
|
||||||
detail="Current scheme version not found",
|
current_version_number=current_version_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
return row
|
return row
|
||||||
@@ -113,57 +232,87 @@ async def update_scheme_version_display_artifact(
|
|||||||
|
|
||||||
async def create_next_scheme_version_from_current(scheme_id: str) -> SchemeVersionRecord:
|
async def create_next_scheme_version_from_current(scheme_id: str) -> SchemeVersionRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
scheme_result = await session.execute(
|
async with session.begin():
|
||||||
select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id)
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
)
|
current_version = await _get_current_scheme_version_for_update(
|
||||||
scheme = scheme_result.scalar_one_or_none()
|
session,
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
if scheme is None:
|
current_version_number=scheme.current_version_number,
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
new_version = await _build_next_draft_version(
|
||||||
detail="Scheme not found",
|
session,
|
||||||
|
scheme=scheme,
|
||||||
|
source_version=current_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_result = await session.execute(
|
|
||||||
select(SchemeVersionRecord).where(
|
|
||||||
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
|
||||||
SchemeVersionRecord.version_number == scheme.current_version_number,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current_version = current_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if current_version is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Current scheme version not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
next_version_number = current_version.version_number + 1
|
|
||||||
new_version = SchemeVersionRecord(
|
|
||||||
scheme_version_id=uuid4().hex,
|
|
||||||
scheme_id=scheme.scheme_id,
|
|
||||||
version_number=next_version_number,
|
|
||||||
status="draft",
|
|
||||||
normalized_storage_path=current_version.normalized_storage_path,
|
|
||||||
normalized_elements_count=current_version.normalized_elements_count,
|
|
||||||
normalized_seats_count=current_version.normalized_seats_count,
|
|
||||||
normalized_groups_count=current_version.normalized_groups_count,
|
|
||||||
normalized_sectors_count=current_version.normalized_sectors_count,
|
|
||||||
display_svg_storage_path=current_version.display_svg_storage_path,
|
|
||||||
display_svg_status=current_version.display_svg_status,
|
|
||||||
display_svg_generated_at=current_version.display_svg_generated_at,
|
|
||||||
)
|
|
||||||
session.add(new_version)
|
|
||||||
|
|
||||||
scheme.current_version_number = next_version_number
|
|
||||||
scheme.status = "draft"
|
|
||||||
scheme.published_at = None
|
|
||||||
scheme.normalized_elements_count = current_version.normalized_elements_count
|
|
||||||
scheme.normalized_seats_count = current_version.normalized_seats_count
|
|
||||||
scheme.normalized_groups_count = current_version.normalized_groups_count
|
|
||||||
scheme.normalized_sectors_count = current_version.normalized_sectors_count
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(new_version)
|
await session.refresh(new_version)
|
||||||
|
|
||||||
return new_version
|
return new_version
|
||||||
|
|
||||||
|
|
||||||
|
async def create_next_scheme_version_from_current_checked(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
expected_current_scheme_version_id: str | None = None,
|
||||||
|
) -> tuple[SchemeVersionRecord, SchemeVersionRecord]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
|
current_version = await _get_current_scheme_version_for_update(
|
||||||
|
session,
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_current_scheme_version_id
|
||||||
|
and expected_current_scheme_version_id != current_version.scheme_version_id
|
||||||
|
):
|
||||||
|
_raise_stale_current_version(
|
||||||
|
expected_scheme_version_id=expected_current_scheme_version_id,
|
||||||
|
actual_scheme_version_id=current_version.scheme_version_id,
|
||||||
|
)
|
||||||
|
new_version = await _build_next_draft_version(
|
||||||
|
session,
|
||||||
|
scheme=scheme,
|
||||||
|
source_version=current_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.refresh(current_version)
|
||||||
|
await session.refresh(new_version)
|
||||||
|
return current_version, new_version
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_draft_scheme_version_consistent(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
expected_current_scheme_version_id: str | None = None,
|
||||||
|
) -> tuple[SchemeVersionRecord, bool, str | None]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
|
current_version = await _get_current_scheme_version_for_update(
|
||||||
|
session,
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_current_scheme_version_id
|
||||||
|
and expected_current_scheme_version_id != current_version.scheme_version_id
|
||||||
|
):
|
||||||
|
_raise_stale_current_version(
|
||||||
|
expected_scheme_version_id=expected_current_scheme_version_id,
|
||||||
|
actual_scheme_version_id=current_version.scheme_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheme.status == "draft" and current_version.status == "draft":
|
||||||
|
await session.refresh(current_version)
|
||||||
|
return current_version, False, None
|
||||||
|
|
||||||
|
new_version = await _build_next_draft_version(
|
||||||
|
session,
|
||||||
|
scheme=scheme,
|
||||||
|
source_version=current_version,
|
||||||
|
)
|
||||||
|
source_scheme_version_id = current_version.scheme_version_id
|
||||||
|
|
||||||
|
await session.refresh(new_version)
|
||||||
|
return new_version, True, source_scheme_version_id
|
||||||
|
|||||||
@@ -6,6 +6,51 @@ from sqlalchemy import desc, func, select
|
|||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.scheme import SchemeRecord
|
from app.models.scheme import SchemeRecord
|
||||||
from app.models.scheme_version import SchemeVersionRecord
|
from app.models.scheme_version import SchemeVersionRecord
|
||||||
|
from app.services.api_errors import raise_conflict
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_current_version_inconsistent(*, scheme_id: str, current_version_number: int) -> None:
|
||||||
|
raise_conflict(
|
||||||
|
code="current_version_inconsistent",
|
||||||
|
message="Scheme current version pointer is inconsistent with scheme_versions state.",
|
||||||
|
details={
|
||||||
|
"scheme_id": scheme_id,
|
||||||
|
"current_version_number": current_version_number,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_scheme_for_update(session, scheme_id: str) -> SchemeRecord:
|
||||||
|
scheme_result = await session.execute(
|
||||||
|
select(SchemeRecord)
|
||||||
|
.where(SchemeRecord.scheme_id == scheme_id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
scheme = scheme_result.scalar_one_or_none()
|
||||||
|
if scheme is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scheme not found",
|
||||||
|
)
|
||||||
|
return scheme
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_current_version_for_scheme(session, scheme: SchemeRecord) -> SchemeVersionRecord:
|
||||||
|
version_result = await session.execute(
|
||||||
|
select(SchemeVersionRecord)
|
||||||
|
.where(
|
||||||
|
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
||||||
|
SchemeVersionRecord.version_number == scheme.current_version_number,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
version = version_result.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
_raise_current_version_inconsistent(
|
||||||
|
scheme_id=scheme.scheme_id,
|
||||||
|
current_version_number=scheme.current_version_number,
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
async def create_scheme_from_upload(
|
async def create_scheme_from_upload(
|
||||||
@@ -37,6 +82,55 @@ async def create_scheme_from_upload(
|
|||||||
return scheme_id
|
return scheme_id
|
||||||
|
|
||||||
|
|
||||||
|
async def create_scheme_from_upload_with_initial_version(
|
||||||
|
*,
|
||||||
|
source_upload_id: str,
|
||||||
|
name: str,
|
||||||
|
normalized_storage_path: str,
|
||||||
|
normalized_elements_count: int,
|
||||||
|
normalized_seats_count: int,
|
||||||
|
normalized_groups_count: int,
|
||||||
|
normalized_sectors_count: int,
|
||||||
|
display_svg_storage_path: str | None = None,
|
||||||
|
display_svg_status: str = "pending",
|
||||||
|
display_svg_generated_at=None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
scheme_id = uuid4().hex
|
||||||
|
scheme_version_id = uuid4().hex
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
scheme = SchemeRecord(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
source_upload_id=source_upload_id,
|
||||||
|
name=name,
|
||||||
|
status="draft",
|
||||||
|
current_version_number=1,
|
||||||
|
normalized_elements_count=normalized_elements_count,
|
||||||
|
normalized_seats_count=normalized_seats_count,
|
||||||
|
normalized_groups_count=normalized_groups_count,
|
||||||
|
normalized_sectors_count=normalized_sectors_count,
|
||||||
|
)
|
||||||
|
version = SchemeVersionRecord(
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
version_number=1,
|
||||||
|
status="draft",
|
||||||
|
normalized_storage_path=normalized_storage_path,
|
||||||
|
normalized_elements_count=normalized_elements_count,
|
||||||
|
normalized_seats_count=normalized_seats_count,
|
||||||
|
normalized_groups_count=normalized_groups_count,
|
||||||
|
normalized_sectors_count=normalized_sectors_count,
|
||||||
|
display_svg_storage_path=display_svg_storage_path,
|
||||||
|
display_svg_status=display_svg_status,
|
||||||
|
display_svg_generated_at=display_svg_generated_at,
|
||||||
|
)
|
||||||
|
session.add(scheme)
|
||||||
|
session.add(version)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return scheme_id, scheme_version_id
|
||||||
|
|
||||||
|
|
||||||
async def list_scheme_records(limit: int = 50, offset: int = 0) -> list[SchemeRecord]:
|
async def list_scheme_records(limit: int = 50, offset: int = 0) -> list[SchemeRecord]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -72,127 +166,60 @@ async def get_scheme_record_by_scheme_id(scheme_id: str) -> SchemeRecord:
|
|||||||
|
|
||||||
async def publish_scheme(scheme_id: str) -> SchemeRecord:
|
async def publish_scheme(scheme_id: str) -> SchemeRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
scheme_result = await session.execute(
|
async with session.begin():
|
||||||
select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id)
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
)
|
version = await _get_current_version_for_scheme(session, scheme)
|
||||||
scheme = scheme_result.scalar_one_or_none()
|
scheme.status = "published"
|
||||||
|
scheme.published_at = func.now()
|
||||||
|
version.status = "published"
|
||||||
|
|
||||||
if scheme is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Scheme not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
version_result = await session.execute(
|
|
||||||
select(SchemeVersionRecord).where(
|
|
||||||
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
|
||||||
SchemeVersionRecord.version_number == scheme.current_version_number,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
version = version_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if version is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Current scheme version not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
scheme.status = "published"
|
|
||||||
scheme.published_at = func.now()
|
|
||||||
version.status = "published"
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(scheme)
|
await session.refresh(scheme)
|
||||||
|
|
||||||
return scheme
|
return scheme
|
||||||
|
|
||||||
|
|
||||||
async def unpublish_scheme(scheme_id: str) -> SchemeRecord:
|
async def unpublish_scheme(scheme_id: str) -> SchemeRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
scheme_result = await session.execute(
|
async with session.begin():
|
||||||
select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id)
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
)
|
version = await _get_current_version_for_scheme(session, scheme)
|
||||||
scheme = scheme_result.scalar_one_or_none()
|
scheme.status = "draft"
|
||||||
|
scheme.published_at = None
|
||||||
|
version.status = "draft"
|
||||||
|
|
||||||
if scheme is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Scheme not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
version_result = await session.execute(
|
|
||||||
select(SchemeVersionRecord).where(
|
|
||||||
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
|
||||||
SchemeVersionRecord.version_number == scheme.current_version_number,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
version = version_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if version is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Current scheme version not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
scheme.status = "draft"
|
|
||||||
scheme.published_at = None
|
|
||||||
version.status = "draft"
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(scheme)
|
await session.refresh(scheme)
|
||||||
|
|
||||||
return scheme
|
return scheme
|
||||||
|
|
||||||
|
|
||||||
async def rollback_scheme_to_version(scheme_id: str, target_version_number: int) -> SchemeRecord:
|
async def rollback_scheme_to_version(scheme_id: str, target_version_number: int) -> SchemeRecord:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
scheme_result = await session.execute(
|
async with session.begin():
|
||||||
select(SchemeRecord).where(SchemeRecord.scheme_id == scheme_id)
|
scheme = await _get_scheme_for_update(session, scheme_id)
|
||||||
)
|
current_version = await _get_current_version_for_scheme(session, scheme)
|
||||||
scheme = scheme_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if scheme is None:
|
target_result = await session.execute(
|
||||||
raise HTTPException(
|
select(SchemeVersionRecord).where(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
||||||
detail="Scheme not found",
|
SchemeVersionRecord.version_number == target_version_number,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
target_version = target_result.scalar_one_or_none()
|
||||||
|
|
||||||
target_result = await session.execute(
|
if target_version is None:
|
||||||
select(SchemeVersionRecord).where(
|
raise HTTPException(
|
||||||
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
SchemeVersionRecord.version_number == target_version_number,
|
detail="Target scheme version not found",
|
||||||
)
|
)
|
||||||
)
|
|
||||||
target_version = target_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if target_version is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Target scheme version not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
current_result = await session.execute(
|
|
||||||
select(SchemeVersionRecord).where(
|
|
||||||
SchemeVersionRecord.scheme_id == scheme.scheme_id,
|
|
||||||
SchemeVersionRecord.version_number == scheme.current_version_number,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current_version = current_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if current_version is not None:
|
|
||||||
current_version.status = "draft"
|
current_version.status = "draft"
|
||||||
|
target_version.status = "draft"
|
||||||
|
scheme.current_version_number = target_version.version_number
|
||||||
|
scheme.status = "draft"
|
||||||
|
scheme.published_at = None
|
||||||
|
|
||||||
target_version.status = "draft"
|
scheme.normalized_elements_count = target_version.normalized_elements_count
|
||||||
scheme.current_version_number = target_version.version_number
|
scheme.normalized_seats_count = target_version.normalized_seats_count
|
||||||
scheme.status = "draft"
|
scheme.normalized_groups_count = target_version.normalized_groups_count
|
||||||
scheme.published_at = None
|
scheme.normalized_sectors_count = target_version.normalized_sectors_count
|
||||||
|
|
||||||
scheme.normalized_elements_count = target_version.normalized_elements_count
|
|
||||||
scheme.normalized_seats_count = target_version.normalized_seats_count
|
|
||||||
scheme.normalized_groups_count = target_version.normalized_groups_count
|
|
||||||
scheme.normalized_sectors_count = target_version.normalized_sectors_count
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(scheme)
|
await session.refresh(scheme)
|
||||||
|
|
||||||
return scheme
|
return scheme
|
||||||
|
|||||||
51
backend/app/schemas/admin_cleanup.py
Normal file
51
backend/app/schemas/admin_cleanup.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCleanupPreviewItem(BaseModel):
|
||||||
|
pricing_category_id: str
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
rules_count: int = Field(ge=0)
|
||||||
|
matched_by: list[str]
|
||||||
|
deletable: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCleanupPreviewResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
code_prefixes: list[str]
|
||||||
|
name_prefixes: list[str]
|
||||||
|
pricing_category_ids: list[str]
|
||||||
|
delete_only_without_rules: bool
|
||||||
|
total_candidates: int = Field(ge=0)
|
||||||
|
safe_to_delete_count: int = Field(ge=0)
|
||||||
|
items: list[PricingCleanupPreviewItem]
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCleanupExecuteRequest(BaseModel):
|
||||||
|
code_prefixes: list[str] = Field(default_factory=list)
|
||||||
|
name_prefixes: list[str] = Field(default_factory=list)
|
||||||
|
pricing_category_ids: list[str] = Field(default_factory=list)
|
||||||
|
delete_only_without_rules: bool = True
|
||||||
|
dry_run: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCleanupSkippedItem(BaseModel):
|
||||||
|
pricing_category_id: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCleanupExecuteResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
dry_run: bool
|
||||||
|
delete_only_without_rules: bool
|
||||||
|
|
||||||
|
requested_total: int = Field(ge=0)
|
||||||
|
matched_total: int = Field(ge=0)
|
||||||
|
|
||||||
|
would_delete_count: int = Field(ge=0)
|
||||||
|
deleted_count: int = Field(ge=0)
|
||||||
|
skipped_count: int = Field(ge=0)
|
||||||
|
|
||||||
|
would_delete_category_ids: list[str]
|
||||||
|
deleted_category_ids: list[str]
|
||||||
|
skipped: list[PricingCleanupSkippedItem]
|
||||||
@@ -56,6 +56,30 @@ class DraftStructureResponse(BaseModel):
|
|||||||
total_groups: int
|
total_groups: int
|
||||||
|
|
||||||
|
|
||||||
|
class EditorContextResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
current_scheme_version_id: str
|
||||||
|
current_version_number: int
|
||||||
|
scheme_status: str
|
||||||
|
scheme_version_status: str
|
||||||
|
editor_available: bool
|
||||||
|
current_is_draft: bool
|
||||||
|
create_draft_available: bool
|
||||||
|
recommended_action: str
|
||||||
|
|
||||||
|
|
||||||
|
class DraftSummaryResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
scheme_version_id: str
|
||||||
|
status: str
|
||||||
|
total_seats: int
|
||||||
|
total_sectors: int
|
||||||
|
total_groups: int
|
||||||
|
validation_summary: dict
|
||||||
|
structure_diff_summary: dict
|
||||||
|
publish_readiness: dict
|
||||||
|
|
||||||
|
|
||||||
class SeatPatchRequest(BaseModel):
|
class SeatPatchRequest(BaseModel):
|
||||||
seat_id: str | None = Field(default=None, max_length=128)
|
seat_id: str | None = Field(default=None, max_length=128)
|
||||||
sector_id: str | None = Field(default=None, max_length=128)
|
sector_id: str | None = Field(default=None, max_length=128)
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
from decimal import Decimal, InvalidOperation
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_decimal_amount(value: Decimal) -> Decimal:
|
|
||||||
try:
|
|
||||||
normalized = Decimal(value)
|
|
||||||
except (InvalidOperation, TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("Некорректная сумма") from exc
|
|
||||||
|
|
||||||
if not normalized.is_finite():
|
|
||||||
raise ValueError("Некорректная сумма")
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteResponse(BaseModel):
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class PricingCategoryCreateRequest(BaseModel):
|
class PricingCategoryCreateRequest(BaseModel):
|
||||||
@@ -29,6 +11,22 @@ class PricingCategoryUpdateRequest(BaseModel):
|
|||||||
code: str | None = Field(default=None, max_length=128)
|
code: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRuleCreateRequest(BaseModel):
|
||||||
|
pricing_category_id: str = Field(..., max_length=32)
|
||||||
|
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
||||||
|
target_ref: str = Field(..., min_length=1, max_length=128)
|
||||||
|
amount: str = Field(..., min_length=1, max_length=32)
|
||||||
|
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRuleUpdateRequest(BaseModel):
|
||||||
|
pricing_category_id: str = Field(..., max_length=32)
|
||||||
|
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
||||||
|
target_ref: str = Field(..., min_length=1, max_length=128)
|
||||||
|
amount: str = Field(..., min_length=1, max_length=32)
|
||||||
|
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
class PricingCategoryItem(BaseModel):
|
class PricingCategoryItem(BaseModel):
|
||||||
pricing_category_id: str
|
pricing_category_id: str
|
||||||
scheme_id: str
|
scheme_id: str
|
||||||
@@ -37,6 +35,22 @@ class PricingCategoryItem(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRuleItem(BaseModel):
|
||||||
|
price_rule_id: str
|
||||||
|
scheme_id: str
|
||||||
|
pricing_category_id: str | None
|
||||||
|
target_type: str
|
||||||
|
target_ref: str
|
||||||
|
amount: str
|
||||||
|
currency: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PricingBundleResponse(BaseModel):
|
||||||
|
categories: list[PricingCategoryItem]
|
||||||
|
rules: list[PriceRuleItem]
|
||||||
|
|
||||||
|
|
||||||
class PricingCategoryCreateResponse(BaseModel):
|
class PricingCategoryCreateResponse(BaseModel):
|
||||||
pricing_category_id: str
|
pricing_category_id: str
|
||||||
scheme_id: str
|
scheme_id: str
|
||||||
@@ -51,50 +65,13 @@ class PricingCategoryUpdateResponse(BaseModel):
|
|||||||
code: str | None
|
code: str | None
|
||||||
|
|
||||||
|
|
||||||
class PriceRuleCreateRequest(BaseModel):
|
|
||||||
pricing_category_id: str | None = Field(default=None, max_length=32)
|
|
||||||
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
|
||||||
target_ref: str = Field(..., min_length=1, max_length=128)
|
|
||||||
amount: Decimal
|
|
||||||
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
|
||||||
|
|
||||||
@field_validator("amount")
|
|
||||||
@classmethod
|
|
||||||
def validate_amount(cls, value: Decimal) -> Decimal:
|
|
||||||
return _validate_decimal_amount(value)
|
|
||||||
|
|
||||||
|
|
||||||
class PriceRuleUpdateRequest(BaseModel):
|
|
||||||
pricing_category_id: str | None = Field(default=None, max_length=32)
|
|
||||||
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
|
||||||
target_ref: str = Field(..., min_length=1, max_length=128)
|
|
||||||
amount: Decimal
|
|
||||||
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
|
||||||
|
|
||||||
@field_validator("amount")
|
|
||||||
@classmethod
|
|
||||||
def validate_amount(cls, value: Decimal) -> Decimal:
|
|
||||||
return _validate_decimal_amount(value)
|
|
||||||
|
|
||||||
|
|
||||||
class PriceRuleItem(BaseModel):
|
|
||||||
price_rule_id: str
|
|
||||||
scheme_id: str
|
|
||||||
pricing_category_id: str | None
|
|
||||||
target_type: str
|
|
||||||
target_ref: str
|
|
||||||
amount: Decimal | str
|
|
||||||
currency: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class PriceRuleCreateResponse(BaseModel):
|
class PriceRuleCreateResponse(BaseModel):
|
||||||
price_rule_id: str
|
price_rule_id: str
|
||||||
scheme_id: str
|
scheme_id: str
|
||||||
pricing_category_id: str | None
|
pricing_category_id: str
|
||||||
target_type: str
|
target_type: str
|
||||||
target_ref: str
|
target_ref: str
|
||||||
amount: Decimal
|
amount: str
|
||||||
currency: str
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
@@ -104,10 +81,16 @@ class PriceRuleUpdateResponse(BaseModel):
|
|||||||
pricing_category_id: str | None
|
pricing_category_id: str | None
|
||||||
target_type: str
|
target_type: str
|
||||||
target_ref: str
|
target_ref: str
|
||||||
amount: Decimal
|
amount: str
|
||||||
currency: str
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResponse(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
pricing_category_id: str | None = None
|
||||||
|
price_rule_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EffectiveSeatPriceResponse(BaseModel):
|
class EffectiveSeatPriceResponse(BaseModel):
|
||||||
scheme_id: str
|
scheme_id: str
|
||||||
scheme_version_id: str
|
scheme_version_id: str
|
||||||
@@ -117,15 +100,5 @@ class EffectiveSeatPriceResponse(BaseModel):
|
|||||||
matched_rule_level: str
|
matched_rule_level: str
|
||||||
matched_target_ref: str
|
matched_target_ref: str
|
||||||
pricing_category_id: str | None
|
pricing_category_id: str | None
|
||||||
amount: Decimal | str
|
amount: str
|
||||||
currency: str
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
class SchemePricingResponse(BaseModel):
|
|
||||||
categories: list[PricingCategoryItem]
|
|
||||||
rules: list[PriceRuleItem]
|
|
||||||
|
|
||||||
|
|
||||||
class PricingBundleResponse(BaseModel):
|
|
||||||
categories: list[PricingCategoryItem]
|
|
||||||
rules: list[PriceRuleItem]
|
|
||||||
|
|||||||
55
backend/app/schemas/pricing_diagnostics.py
Normal file
55
backend/app/schemas/pricing_diagnostics.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCategoryMutationResponse(BaseModel):
|
||||||
|
pricing_category_id: str
|
||||||
|
scheme_id: str
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCategoryDeleteResponse(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
pricing_category_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRuleMutationResponse(BaseModel):
|
||||||
|
price_rule_id: str
|
||||||
|
scheme_id: str
|
||||||
|
pricing_category_id: str
|
||||||
|
target_type: str
|
||||||
|
target_ref: str
|
||||||
|
amount: str
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRuleDeleteResponse(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
price_rule_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleDiagnosticsItem(BaseModel):
|
||||||
|
price_rule_id: str
|
||||||
|
pricing_category_id: str
|
||||||
|
target_type: str
|
||||||
|
target_ref: str
|
||||||
|
amount: str
|
||||||
|
currency: str
|
||||||
|
matched_seats_count: int
|
||||||
|
matched_seat_ids: list[str]
|
||||||
|
orphan: bool
|
||||||
|
orphan_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleDiagnosticsSummary(BaseModel):
|
||||||
|
total_rules: int
|
||||||
|
orphan_rules_count: int
|
||||||
|
active_rules_count: int
|
||||||
|
matched_seats_total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleDiagnosticsResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
scheme_version_id: str
|
||||||
|
summary: PricingRuleDiagnosticsSummary
|
||||||
|
items: list[PricingRuleDiagnosticsItem]
|
||||||
45
backend/app/schemas/publish_readiness.py
Normal file
45
backend/app/schemas/publish_readiness.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PublishReadinessSnapshot(BaseModel):
|
||||||
|
available: bool
|
||||||
|
categories_count: int
|
||||||
|
rules_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublishReadinessPricingCoverage(BaseModel):
|
||||||
|
total_seats: int
|
||||||
|
priced_seats: int
|
||||||
|
unpriced_seats: int
|
||||||
|
coverage_percent: float
|
||||||
|
|
||||||
|
|
||||||
|
class PublishReadinessFlags(BaseModel):
|
||||||
|
validation_publishable: bool
|
||||||
|
snapshot_available: bool
|
||||||
|
require_full_pricing_coverage: bool
|
||||||
|
full_pricing_coverage: bool
|
||||||
|
pricing_gate_passed: bool
|
||||||
|
is_ready_to_publish: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PublishReadinessResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
scheme_version_id: str
|
||||||
|
status: str
|
||||||
|
validation_summary: dict
|
||||||
|
pricing_coverage: PublishReadinessPricingCoverage
|
||||||
|
snapshot: PublishReadinessSnapshot
|
||||||
|
readiness: PublishReadinessFlags
|
||||||
|
|
||||||
|
|
||||||
|
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: dict
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +15,7 @@ class SchemeVersionListItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SchemeVersionListResponse(BaseModel):
|
class SchemeVersionListResponse(BaseModel):
|
||||||
items: List[SchemeVersionListItem]
|
items: list[SchemeVersionListItem]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +25,13 @@ class SchemeVersionCreateResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
status: str
|
status: str
|
||||||
normalized_storage_path: str
|
normalized_storage_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureDraftResponse(BaseModel):
|
||||||
|
scheme_id: str
|
||||||
|
scheme_version_id: str
|
||||||
|
version_number: int
|
||||||
|
status: str
|
||||||
|
normalized_storage_path: str
|
||||||
|
created: bool
|
||||||
|
source_scheme_version_id: str | None = None
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class TestSeatPreviewResponse(BaseModel):
|
class TestSeatPreviewResponse(BaseModel):
|
||||||
scheme_id: str
|
scheme_id: str
|
||||||
scheme_version_id: str
|
scheme_version_id: str
|
||||||
seat_id: str
|
seat_id: str | None
|
||||||
element_id: str | None
|
element_id: str | None
|
||||||
sector_id: str | None
|
sector_id: str | None
|
||||||
group_id: str | None
|
group_id: str | None
|
||||||
@@ -17,5 +15,7 @@ class TestSeatPreviewResponse(BaseModel):
|
|||||||
matched_rule_level: str | None
|
matched_rule_level: str | None
|
||||||
matched_target_ref: str | None
|
matched_target_ref: str | None
|
||||||
pricing_category_id: str | None
|
pricing_category_id: str | None
|
||||||
amount: Decimal | None
|
amount: str | None
|
||||||
currency: str | None
|
currency: str | None
|
||||||
|
reason_code: str
|
||||||
|
reason_message: str
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.domain.roles import UserRole
|
from app.domain.roles import UserRole
|
||||||
@@ -14,7 +14,9 @@ def resolve_role(api_key: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Key")) -> str:
|
async def require_api_key(
|
||||||
|
x_api_key: str | None = Header(default=None, alias=settings.auth_header_name),
|
||||||
|
) -> str:
|
||||||
if not x_api_key:
|
if not x_api_key:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -29,3 +31,12 @@ async def require_api_key(x_api_key: str | None = Header(default=None, alias="X-
|
|||||||
)
|
)
|
||||||
|
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin_api_key(role: str = Depends(require_api_key)) -> str:
|
||||||
|
if role != UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin role required",
|
||||||
|
)
|
||||||
|
return role
|
||||||
|
|||||||
41
backend/app/services/api_errors.py
Normal file
41
backend/app/services/api_errors.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def raise_conflict(
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict = {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if details is not None:
|
||||||
|
payload["details"] = details
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_unprocessable(
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict = {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if details is not None:
|
||||||
|
payload.update(details)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=payload,
|
||||||
|
)
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from app.repositories.scheme_versions import get_current_scheme_version
|
from app.repositories.scheme_versions import get_current_scheme_version
|
||||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||||
|
from app.services.api_errors import raise_conflict
|
||||||
|
|
||||||
|
|
||||||
async def get_current_draft_context(scheme_id: str):
|
def build_stale_draft_version_detail(
|
||||||
|
*,
|
||||||
|
expected_scheme_version_id: str,
|
||||||
|
actual_scheme_version_id: str,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"code": "stale_draft_version",
|
||||||
|
"message": "Draft scheme version is stale. Reload current draft state before applying mutation.",
|
||||||
|
"expected_scheme_version_id": expected_scheme_version_id,
|
||||||
|
"actual_scheme_version_id": actual_scheme_version_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_draft_context(
|
||||||
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = None,
|
||||||
|
):
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
version = await get_current_scheme_version(
|
version = await get_current_scheme_version(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
@@ -12,9 +27,38 @@ async def get_current_draft_context(scheme_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if version.status != "draft" or scheme.status != "draft":
|
if version.status != "draft" or scheme.status != "draft":
|
||||||
raise HTTPException(
|
raise_conflict(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
code="draft_not_editable",
|
||||||
detail="Current scheme version is not editable because it is not in draft state",
|
message="Current scheme version is not editable because it is not in draft state",
|
||||||
|
details={
|
||||||
|
"scheme_status": scheme.status,
|
||||||
|
"scheme_version_status": version.status,
|
||||||
|
"actual_scheme_version_id": version.scheme_version_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id:
|
||||||
|
raise_conflict(
|
||||||
|
code="stale_draft_version",
|
||||||
|
message="Draft scheme version is stale. Reload current draft state before applying mutation.",
|
||||||
|
details={
|
||||||
|
"expected_scheme_version_id": expected_scheme_version_id,
|
||||||
|
"actual_scheme_version_id": version.scheme_version_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return scheme, version
|
return scheme, version
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_expected_draft_version_if_provided(
|
||||||
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None,
|
||||||
|
):
|
||||||
|
if not expected_scheme_version_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scheme, version = await get_current_draft_context(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
expected_scheme_version_id=expected_scheme_version_id,
|
||||||
|
)
|
||||||
|
return scheme, version
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
from fastapi import HTTPException, status
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.repositories.scheme_groups import list_scheme_version_groups
|
from app.repositories.scheme_groups import list_scheme_version_groups
|
||||||
from app.repositories.scheme_seats import list_scheme_version_seats
|
from app.repositories.scheme_seats import list_scheme_version_seats
|
||||||
from app.repositories.scheme_sectors import list_scheme_version_sectors
|
from app.repositories.scheme_sectors import list_scheme_version_sectors
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_uniqueness_error(message: str, detail: dict | None = None) -> None:
|
||||||
|
if detail:
|
||||||
|
code = detail.pop("code", "editor_uniqueness_error")
|
||||||
|
msg = detail.pop("message", message)
|
||||||
|
raise_unprocessable(code=code, message=msg, details=detail)
|
||||||
|
else:
|
||||||
|
raise_unprocessable(code="editor_uniqueness_error", message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_reference_error(message: str, detail: dict | None = None) -> None:
|
||||||
|
if detail:
|
||||||
|
code = detail.pop("code", "editor_reference_error")
|
||||||
|
msg = detail.pop("message", message)
|
||||||
|
raise_unprocessable(code=code, message=msg, details=detail)
|
||||||
|
else:
|
||||||
|
raise_unprocessable(code="editor_reference_error", message=message)
|
||||||
|
|
||||||
|
|
||||||
async def validate_single_seat_patch_uniqueness(
|
async def validate_single_seat_patch_uniqueness(
|
||||||
@@ -15,11 +34,18 @@ async def validate_single_seat_patch_uniqueness(
|
|||||||
return
|
return
|
||||||
|
|
||||||
seats = await list_scheme_version_seats(scheme_version_id)
|
seats = await list_scheme_version_seats(scheme_version_id)
|
||||||
for seat in seats:
|
for row in seats:
|
||||||
if seat.seat_id == new_seat_id and seat.seat_record_id != seat_record_id:
|
if row.seat_record_id == seat_record_id:
|
||||||
raise HTTPException(
|
continue
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
if row.seat_id == new_seat_id:
|
||||||
detail=f"seat_id already exists in draft version: {new_seat_id}",
|
_raise_uniqueness_error(
|
||||||
|
f"Seat id already exists in current draft version: {new_seat_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_seat_id",
|
||||||
|
"message": "Seat id already exists in current draft version",
|
||||||
|
"seat_id": new_seat_id,
|
||||||
|
"conflict_seat_record_id": row.seat_record_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,36 +55,50 @@ async def validate_bulk_seat_patch_uniqueness(
|
|||||||
items: list[dict],
|
items: list[dict],
|
||||||
) -> None:
|
) -> None:
|
||||||
seats = await list_scheme_version_seats(scheme_version_id)
|
seats = await list_scheme_version_seats(scheme_version_id)
|
||||||
existing = {seat.seat_id: seat.seat_record_id for seat in seats if seat.seat_id}
|
|
||||||
|
|
||||||
payload_new_ids = [item.get("seat_id") for item in items if item.get("seat_id")]
|
existing_by_seat_id: dict[str, str] = {
|
||||||
duplicates_inside_payload = sorted(
|
row.seat_id: row.seat_record_id
|
||||||
{
|
for row in seats
|
||||||
seat_id
|
if row.seat_id
|
||||||
for seat_id in payload_new_ids
|
}
|
||||||
if payload_new_ids.count(seat_id) > 1
|
|
||||||
}
|
seen_new_ids: dict[str, str] = {}
|
||||||
)
|
|
||||||
if duplicates_inside_payload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Duplicate seat_id values inside bulk payload: {', '.join(duplicates_inside_payload)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
new_seat_id = item.get("seat_id")
|
|
||||||
seat_record_id = item["seat_record_id"]
|
seat_record_id = item["seat_record_id"]
|
||||||
|
seat_id = item.get("seat_id")
|
||||||
|
|
||||||
if not new_seat_id:
|
if not seat_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_record_id = existing.get(new_seat_id)
|
existing_record_id = existing_by_seat_id.get(seat_id)
|
||||||
if existing_record_id and existing_record_id != seat_record_id:
|
if existing_record_id and existing_record_id != seat_record_id:
|
||||||
raise HTTPException(
|
_raise_uniqueness_error(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
f"Seat id already exists in current draft version: {seat_id}",
|
||||||
detail=f"seat_id already exists in draft version: {new_seat_id}",
|
{
|
||||||
|
"code": "duplicate_seat_id",
|
||||||
|
"message": "Seat id already exists in current draft version",
|
||||||
|
"seat_id": seat_id,
|
||||||
|
"conflict_seat_record_id": existing_record_id,
|
||||||
|
"input_seat_record_id": seat_record_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
seen_record_id = seen_new_ids.get(seat_id)
|
||||||
|
if seen_record_id and seen_record_id != seat_record_id:
|
||||||
|
_raise_uniqueness_error(
|
||||||
|
f"Seat id is duplicated inside bulk payload: {seat_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_seat_id_in_payload",
|
||||||
|
"message": "Seat id is duplicated inside bulk payload",
|
||||||
|
"seat_id": seat_id,
|
||||||
|
"first_seat_record_id": seen_record_id,
|
||||||
|
"second_seat_record_id": seat_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_new_ids[seat_id] = seat_record_id
|
||||||
|
|
||||||
|
|
||||||
async def validate_sector_patch_uniqueness(
|
async def validate_sector_patch_uniqueness(
|
||||||
*,
|
*,
|
||||||
@@ -69,12 +109,19 @@ async def validate_sector_patch_uniqueness(
|
|||||||
if not new_sector_id:
|
if not new_sector_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
sectors = await list_scheme_version_sectors(scheme_version_id)
|
rows = await list_scheme_version_sectors(scheme_version_id)
|
||||||
for sector in sectors:
|
for row in rows:
|
||||||
if sector.sector_id == new_sector_id and sector.sector_record_id != sector_record_id:
|
if row.sector_record_id == sector_record_id:
|
||||||
raise HTTPException(
|
continue
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
if row.sector_id == new_sector_id:
|
||||||
detail=f"sector_id already exists in draft version: {new_sector_id}",
|
_raise_uniqueness_error(
|
||||||
|
f"Sector id already exists in current draft version: {new_sector_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_sector_id",
|
||||||
|
"message": "Sector id already exists in current draft version",
|
||||||
|
"sector_id": new_sector_id,
|
||||||
|
"conflict_sector_record_id": row.sector_record_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,10 +134,216 @@ async def validate_group_patch_uniqueness(
|
|||||||
if not new_group_id:
|
if not new_group_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
groups = await list_scheme_version_groups(scheme_version_id)
|
rows = await list_scheme_version_groups(scheme_version_id)
|
||||||
for group in groups:
|
for row in rows:
|
||||||
if group.group_id == new_group_id and group.group_record_id != group_record_id:
|
if row.group_record_id == group_record_id:
|
||||||
raise HTTPException(
|
continue
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
if row.group_id == new_group_id:
|
||||||
detail=f"group_id already exists in draft version: {new_group_id}",
|
_raise_uniqueness_error(
|
||||||
|
f"Group id already exists in current draft version: {new_group_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_group_id",
|
||||||
|
"message": "Group id already exists in current draft version",
|
||||||
|
"group_id": new_group_id,
|
||||||
|
"conflict_group_record_id": row.group_record_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_create_sector_uniqueness(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
sector_id: str,
|
||||||
|
element_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
rows = await list_scheme_version_sectors(scheme_version_id)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.sector_id == sector_id:
|
||||||
|
_raise_uniqueness_error(
|
||||||
|
f"Sector id already exists in current draft version: {sector_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_sector_id",
|
||||||
|
"message": "Sector id already exists in current draft version",
|
||||||
|
"sector_id": sector_id,
|
||||||
|
"conflict_sector_record_id": row.sector_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if element_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.element_id == element_id:
|
||||||
|
_raise_uniqueness_error(
|
||||||
|
f"Sector element binding already exists in current draft version: {element_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_sector_element_id",
|
||||||
|
"message": "Sector element binding already exists in current draft version",
|
||||||
|
"element_id": element_id,
|
||||||
|
"conflict_sector_record_id": row.sector_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_create_group_uniqueness(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
group_id: str,
|
||||||
|
element_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
rows = await list_scheme_version_groups(scheme_version_id)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.group_id == group_id:
|
||||||
|
_raise_uniqueness_error(
|
||||||
|
f"Group id already exists in current draft version: {group_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_group_id",
|
||||||
|
"message": "Group id already exists in current draft version",
|
||||||
|
"group_id": group_id,
|
||||||
|
"conflict_group_record_id": row.group_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if element_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.element_id == element_id:
|
||||||
|
_raise_uniqueness_error(
|
||||||
|
f"Group element binding already exists in current draft version: {element_id}",
|
||||||
|
{
|
||||||
|
"code": "duplicate_group_element_id",
|
||||||
|
"message": "Group element binding already exists in current draft version",
|
||||||
|
"element_id": element_id,
|
||||||
|
"conflict_group_record_id": row.group_record_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_single_seat_patch_references(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
sector_id: str | None,
|
||||||
|
group_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
sector_ids = {
|
||||||
|
row.sector_id
|
||||||
|
for row in await list_scheme_version_sectors(scheme_version_id)
|
||||||
|
if row.sector_id
|
||||||
|
}
|
||||||
|
group_ids = {
|
||||||
|
row.group_id
|
||||||
|
for row in await list_scheme_version_groups(scheme_version_id)
|
||||||
|
if row.group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if sector_id is not None and sector_id not in sector_ids:
|
||||||
|
_raise_reference_error(
|
||||||
|
f"Sector id does not exist in current draft version: {sector_id}",
|
||||||
|
{
|
||||||
|
"code": "unknown_sector_id",
|
||||||
|
"message": "Sector id does not exist in current draft version",
|
||||||
|
"sector_id": sector_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_id is not None and group_id not in group_ids:
|
||||||
|
_raise_reference_error(
|
||||||
|
f"Group id does not exist in current draft version: {group_id}",
|
||||||
|
{
|
||||||
|
"code": "unknown_group_id",
|
||||||
|
"message": "Group id does not exist in current draft version",
|
||||||
|
"group_id": group_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_bulk_seat_patch_references(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
items: list[dict],
|
||||||
|
) -> None:
|
||||||
|
sector_ids = {
|
||||||
|
row.sector_id
|
||||||
|
for row in await list_scheme_version_sectors(scheme_version_id)
|
||||||
|
if row.sector_id
|
||||||
|
}
|
||||||
|
group_ids = {
|
||||||
|
row.group_id
|
||||||
|
for row in await list_scheme_version_groups(scheme_version_id)
|
||||||
|
if row.group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown_sector_refs = sorted(
|
||||||
|
{
|
||||||
|
item["sector_id"]
|
||||||
|
for item in items
|
||||||
|
if item.get("sector_id") is not None and item["sector_id"] not in sector_ids
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if unknown_sector_refs:
|
||||||
|
_raise_reference_error(
|
||||||
|
"Bulk payload contains unknown sector_id values",
|
||||||
|
{
|
||||||
|
"code": "unknown_sector_ids",
|
||||||
|
"message": "Bulk payload contains unknown sector_id values",
|
||||||
|
"sector_ids": unknown_sector_refs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown_group_refs = sorted(
|
||||||
|
{
|
||||||
|
item["group_id"]
|
||||||
|
for item in items
|
||||||
|
if item.get("group_id") is not None and item["group_id"] not in group_ids
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if unknown_group_refs:
|
||||||
|
_raise_reference_error(
|
||||||
|
"Bulk payload contains unknown group_id values",
|
||||||
|
{
|
||||||
|
"code": "unknown_group_ids",
|
||||||
|
"message": "Bulk payload contains unknown group_id values",
|
||||||
|
"group_ids": unknown_group_refs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_remap_target_references(
|
||||||
|
*,
|
||||||
|
scheme_version_id: str,
|
||||||
|
to_sector_id: str | None,
|
||||||
|
to_group_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
sector_ids = {
|
||||||
|
row.sector_id
|
||||||
|
for row in await list_scheme_version_sectors(scheme_version_id)
|
||||||
|
if row.sector_id
|
||||||
|
}
|
||||||
|
group_ids = {
|
||||||
|
row.group_id
|
||||||
|
for row in await list_scheme_version_groups(scheme_version_id)
|
||||||
|
if row.group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if to_sector_id is not None and to_sector_id not in sector_ids:
|
||||||
|
_raise_reference_error(
|
||||||
|
f"Target sector_id does not exist in current draft version: {to_sector_id}",
|
||||||
|
{
|
||||||
|
"code": "unknown_target_sector_id",
|
||||||
|
"message": "Target sector_id does not exist in current draft version",
|
||||||
|
"sector_id": to_sector_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if to_group_id is not None and to_group_id not in group_ids:
|
||||||
|
_raise_reference_error(
|
||||||
|
f"Target group_id does not exist in current draft version: {to_group_id}",
|
||||||
|
{
|
||||||
|
"code": "unknown_target_group_id",
|
||||||
|
"message": "Target group_id does not exist in current draft version",
|
||||||
|
"group_id": to_group_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
127
backend/app/services/pricing_cleanup.py
Normal file
127
backend/app/services/pricing_cleanup.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.repositories.pricing_cleanup import (
|
||||||
|
delete_pricing_categories_by_ids,
|
||||||
|
list_pricing_categories_with_rule_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_any_prefix(value: str | None, prefixes: list[str]) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
matches: list[str] = []
|
||||||
|
lower_value = value.lower()
|
||||||
|
for prefix in prefixes:
|
||||||
|
if lower_value.startswith(prefix.lower()):
|
||||||
|
matches.append(prefix)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
async def build_pricing_cleanup_preview(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
code_prefixes: list[str],
|
||||||
|
name_prefixes: list[str],
|
||||||
|
pricing_category_ids: list[str],
|
||||||
|
delete_only_without_rules: bool,
|
||||||
|
) -> dict:
|
||||||
|
rows = await list_pricing_categories_with_rule_counts(scheme_id=scheme_id)
|
||||||
|
requested_ids = set(pricing_category_ids)
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
safe_to_delete_count = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
matched_by: list[str] = []
|
||||||
|
|
||||||
|
for prefix in _matches_any_prefix(row["code"], code_prefixes):
|
||||||
|
matched_by.append(f"code_prefix:{prefix}")
|
||||||
|
|
||||||
|
for prefix in _matches_any_prefix(row["name"], name_prefixes):
|
||||||
|
matched_by.append(f"name_prefix:{prefix}")
|
||||||
|
|
||||||
|
if row["pricing_category_id"] in requested_ids:
|
||||||
|
matched_by.append("pricing_category_id")
|
||||||
|
|
||||||
|
if not matched_by:
|
||||||
|
continue
|
||||||
|
|
||||||
|
deletable = True
|
||||||
|
if delete_only_without_rules and row["rules_count"] > 0:
|
||||||
|
deletable = False
|
||||||
|
|
||||||
|
if deletable:
|
||||||
|
safe_to_delete_count += 1
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"pricing_category_id": row["pricing_category_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"code": row["code"],
|
||||||
|
"rules_count": row["rules_count"],
|
||||||
|
"matched_by": matched_by,
|
||||||
|
"deletable": deletable,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme_id,
|
||||||
|
"code_prefixes": code_prefixes,
|
||||||
|
"name_prefixes": name_prefixes,
|
||||||
|
"pricing_category_ids": pricing_category_ids,
|
||||||
|
"delete_only_without_rules": delete_only_without_rules,
|
||||||
|
"total_candidates": len(items),
|
||||||
|
"safe_to_delete_count": safe_to_delete_count,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_pricing_cleanup(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
code_prefixes: list[str],
|
||||||
|
name_prefixes: list[str],
|
||||||
|
pricing_category_ids: list[str],
|
||||||
|
delete_only_without_rules: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> dict:
|
||||||
|
preview = await build_pricing_cleanup_preview(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
code_prefixes=code_prefixes,
|
||||||
|
name_prefixes=name_prefixes,
|
||||||
|
pricing_category_ids=pricing_category_ids,
|
||||||
|
delete_only_without_rules=delete_only_without_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
deletable_items = [item for item in preview["items"] if item["deletable"]]
|
||||||
|
skipped_items = [item for item in preview["items"] if not item["deletable"]]
|
||||||
|
|
||||||
|
would_delete_ids = [item["pricing_category_id"] for item in deletable_items]
|
||||||
|
deleted_ids: list[str] = []
|
||||||
|
|
||||||
|
if not dry_run and would_delete_ids:
|
||||||
|
await delete_pricing_categories_by_ids(
|
||||||
|
scheme_id=scheme_id,
|
||||||
|
pricing_category_ids=would_delete_ids,
|
||||||
|
)
|
||||||
|
deleted_ids = list(would_delete_ids)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme_id,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"delete_only_without_rules": delete_only_without_rules,
|
||||||
|
"requested_total": len(pricing_category_ids) + len(code_prefixes) + len(name_prefixes),
|
||||||
|
"matched_total": preview["total_candidates"],
|
||||||
|
"would_delete_count": len(would_delete_ids),
|
||||||
|
"deleted_count": 0 if dry_run else len(deleted_ids),
|
||||||
|
"skipped_count": len(skipped_items),
|
||||||
|
"would_delete_category_ids": would_delete_ids,
|
||||||
|
"deleted_category_ids": deleted_ids,
|
||||||
|
"skipped": [
|
||||||
|
{
|
||||||
|
"pricing_category_id": item["pricing_category_id"],
|
||||||
|
"reason": "category_has_rules",
|
||||||
|
}
|
||||||
|
for item in skipped_items
|
||||||
|
],
|
||||||
|
}
|
||||||
98
backend/app/services/pricing_rule_diagnostics.py
Normal file
98
backend/app/services/pricing_rule_diagnostics.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.repositories.pricing import list_price_rules
|
||||||
|
from app.repositories.scheme_groups import list_scheme_version_groups
|
||||||
|
from app.repositories.scheme_seats import list_scheme_version_seats
|
||||||
|
from app.repositories.scheme_sectors import list_scheme_version_sectors
|
||||||
|
|
||||||
|
|
||||||
|
async def build_pricing_rule_diagnostics(
|
||||||
|
*,
|
||||||
|
scheme_id: str,
|
||||||
|
scheme_version_id: str,
|
||||||
|
) -> dict:
|
||||||
|
rules = await list_price_rules(scheme_id)
|
||||||
|
seats = await list_scheme_version_seats(scheme_version_id)
|
||||||
|
sectors = await list_scheme_version_sectors(scheme_version_id)
|
||||||
|
groups = await list_scheme_version_groups(scheme_version_id)
|
||||||
|
|
||||||
|
sector_ids = {row.sector_id for row in sectors if row.sector_id}
|
||||||
|
group_ids = {row.group_id for row in groups if row.group_id}
|
||||||
|
seat_ids = {row.seat_id for row in seats if row.seat_id}
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
matched_seats_total = 0
|
||||||
|
orphan_rules_count = 0
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
matched_seat_ids: list[str] = []
|
||||||
|
orphan = False
|
||||||
|
orphan_reason: str | None = None
|
||||||
|
|
||||||
|
if rule.target_type == "seat":
|
||||||
|
if rule.target_ref not in seat_ids:
|
||||||
|
orphan = True
|
||||||
|
orphan_reason = "target_seat_not_found"
|
||||||
|
else:
|
||||||
|
matched_seat_ids = [
|
||||||
|
seat.seat_id
|
||||||
|
for seat in seats
|
||||||
|
if seat.seat_id and seat.seat_id == rule.target_ref
|
||||||
|
]
|
||||||
|
|
||||||
|
elif rule.target_type == "group":
|
||||||
|
if rule.target_ref not in group_ids:
|
||||||
|
orphan = True
|
||||||
|
orphan_reason = "target_group_not_found"
|
||||||
|
else:
|
||||||
|
matched_seat_ids = [
|
||||||
|
seat.seat_id
|
||||||
|
for seat in seats
|
||||||
|
if seat.seat_id and seat.group_id == rule.target_ref
|
||||||
|
]
|
||||||
|
|
||||||
|
elif rule.target_type == "sector":
|
||||||
|
if rule.target_ref not in sector_ids:
|
||||||
|
orphan = True
|
||||||
|
orphan_reason = "target_sector_not_found"
|
||||||
|
else:
|
||||||
|
matched_seat_ids = [
|
||||||
|
seat.seat_id
|
||||||
|
for seat in seats
|
||||||
|
if seat.seat_id and seat.sector_id == rule.target_ref
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
orphan = True
|
||||||
|
orphan_reason = "unsupported_target_type"
|
||||||
|
|
||||||
|
if orphan:
|
||||||
|
orphan_rules_count += 1
|
||||||
|
|
||||||
|
matched_seats_total += len(matched_seat_ids)
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"price_rule_id": rule.price_rule_id,
|
||||||
|
"pricing_category_id": rule.pricing_category_id,
|
||||||
|
"target_type": rule.target_type,
|
||||||
|
"target_ref": rule.target_ref,
|
||||||
|
"amount": str(rule.amount),
|
||||||
|
"currency": rule.currency,
|
||||||
|
"matched_seats_count": len(matched_seat_ids),
|
||||||
|
"matched_seat_ids": matched_seat_ids,
|
||||||
|
"orphan": orphan,
|
||||||
|
"orphan_reason": orphan_reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheme_id": scheme_id,
|
||||||
|
"scheme_version_id": scheme_version_id,
|
||||||
|
"summary": {
|
||||||
|
"total_rules": len(items),
|
||||||
|
"orphan_rules_count": orphan_rules_count,
|
||||||
|
"active_rules_count": len(items) - orphan_rules_count,
|
||||||
|
"matched_seats_total": matched_seats_total,
|
||||||
|
},
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
99
backend/app/services/publish_readiness.py
Normal file
99
backend/app/services/publish_readiness.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
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_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
|
||||||
|
|
||||||
|
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 > 0 else 100.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": 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,15 +1,17 @@
|
|||||||
from fastapi import HTTPException, status
|
from __future__ import annotations
|
||||||
|
|
||||||
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.repositories.scheme_versions import get_current_scheme_version
|
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.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme
|
||||||
from app.services.scheme_validation import build_scheme_validation_report
|
from app.services.api_errors import raise_conflict
|
||||||
|
from app.services.publish_readiness import build_publish_readiness
|
||||||
|
|
||||||
|
|
||||||
async def publish_current_draft_scheme(
|
async def publish_current_draft_scheme(
|
||||||
*,
|
*,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
|
expected_scheme_version_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||||
version = await get_current_scheme_version(
|
version = await get_current_scheme_version(
|
||||||
@@ -18,20 +20,43 @@ async def publish_current_draft_scheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if scheme.status != "draft" or version.status != "draft":
|
if scheme.status != "draft" or version.status != "draft":
|
||||||
raise HTTPException(
|
raise_conflict(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
code="publish_not_ready",
|
||||||
detail="Current scheme version is not publishable because it is not in draft state",
|
message="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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
validation = await build_scheme_validation_report(
|
if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id:
|
||||||
|
raise_conflict(
|
||||||
|
code="publish_not_ready",
|
||||||
|
message="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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
readiness = await build_publish_readiness(
|
||||||
scheme_id=scheme.scheme_id,
|
scheme_id=scheme.scheme_id,
|
||||||
scheme_version_id=version.scheme_version_id,
|
scheme_version_id=version.scheme_version_id,
|
||||||
|
status=version.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not validation["summary"]["is_publishable"]:
|
if not readiness["readiness"]["is_ready_to_publish"]:
|
||||||
raise HTTPException(
|
raise_conflict(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
code="publish_not_ready",
|
||||||
detail="Scheme is not publishable in current state",
|
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(
|
snapshot = await replace_scheme_version_pricing_snapshot(
|
||||||
@@ -61,5 +86,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"],
|
"validation_summary": readiness["validation_summary"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from app.repositories.scheme_seats import (
|
from app.repositories.scheme_seats import (
|
||||||
bulk_remap_scheme_version_seats,
|
bulk_remap_scheme_version_seats,
|
||||||
list_scheme_version_seats,
|
list_scheme_version_seats,
|
||||||
)
|
)
|
||||||
|
from app.services.api_errors import raise_unprocessable
|
||||||
|
from app.services.editor_validation import validate_remap_target_references
|
||||||
|
|
||||||
|
|
||||||
def _match_seat(
|
def _match_seat(
|
||||||
@@ -34,11 +34,17 @@ async def preview_remap(
|
|||||||
to_group_id: str | None,
|
to_group_id: str | None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
if not any([seat_record_ids, from_sector_id, from_group_id]):
|
if not any([seat_record_ids, from_sector_id, from_group_id]):
|
||||||
raise HTTPException(
|
raise_unprocessable(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
code="remap_filter_required",
|
||||||
detail="At least one remap filter must be provided",
|
message="At least one remap filter must be provided",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await validate_remap_target_references(
|
||||||
|
scheme_version_id=scheme_version_id,
|
||||||
|
to_sector_id=to_sector_id,
|
||||||
|
to_group_id=to_group_id,
|
||||||
|
)
|
||||||
|
|
||||||
seats = await list_scheme_version_seats(scheme_version_id)
|
seats = await list_scheme_version_seats(scheme_version_id)
|
||||||
seat_record_id_set = set(seat_record_ids) if seat_record_ids else None
|
seat_record_id_set = set(seat_record_ids) if seat_record_ids else None
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ from app.repositories.scheme_sectors import list_scheme_version_sectors
|
|||||||
from app.services.baseline_selector import select_baseline_scheme_version
|
from app.services.baseline_selector import select_baseline_scheme_version
|
||||||
|
|
||||||
|
|
||||||
def _serialize_sector(row) -> dict:
|
def _sector_compare_value(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"sector_record_id": row.sector_record_id,
|
|
||||||
"element_id": row.element_id,
|
"element_id": row.element_id,
|
||||||
"sector_id": row.sector_id,
|
"sector_id": row.sector_id,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
@@ -16,9 +15,14 @@ def _serialize_sector(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_group(row) -> dict:
|
def _sector_response_value(row) -> dict:
|
||||||
|
payload = _sector_compare_value(row)
|
||||||
|
payload["sector_record_id"] = row.sector_record_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _group_compare_value(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"group_record_id": row.group_record_id,
|
|
||||||
"element_id": row.element_id,
|
"element_id": row.element_id,
|
||||||
"group_id": row.group_id,
|
"group_id": row.group_id,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
@@ -26,9 +30,14 @@ def _serialize_group(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_seat(row) -> dict:
|
def _group_response_value(row) -> dict:
|
||||||
|
payload = _group_compare_value(row)
|
||||||
|
payload["group_record_id"] = row.group_record_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_compare_value(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"seat_record_id": row.seat_record_id,
|
|
||||||
"element_id": row.element_id,
|
"element_id": row.element_id,
|
||||||
"seat_id": row.seat_id,
|
"seat_id": row.seat_id,
|
||||||
"sector_id": row.sector_id,
|
"sector_id": row.sector_id,
|
||||||
@@ -38,19 +47,33 @@ def _serialize_seat(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_diff(before_map: dict, after_map: dict) -> list[dict]:
|
def _seat_response_value(row) -> dict:
|
||||||
keys = sorted(set(before_map.keys()) | set(after_map.keys()))
|
payload = _seat_compare_value(row)
|
||||||
|
payload["seat_record_id"] = row.seat_record_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _build_diff(
|
||||||
|
*,
|
||||||
|
before_compare_map: dict,
|
||||||
|
after_compare_map: dict,
|
||||||
|
before_payload_map: dict,
|
||||||
|
after_payload_map: dict,
|
||||||
|
) -> list[dict]:
|
||||||
|
keys = sorted(set(before_payload_map.keys()) | set(after_payload_map.keys()))
|
||||||
result: list[dict] = []
|
result: list[dict] = []
|
||||||
|
|
||||||
for key in keys:
|
for key in keys:
|
||||||
before = before_map.get(key)
|
before_compare = before_compare_map.get(key)
|
||||||
after = after_map.get(key)
|
after_compare = after_compare_map.get(key)
|
||||||
|
before_payload = before_payload_map.get(key)
|
||||||
|
after_payload = after_payload_map.get(key)
|
||||||
|
|
||||||
if before is None and after is not None:
|
if before_compare is None and after_compare is not None:
|
||||||
status = "added"
|
status = "added"
|
||||||
elif before is not None and after is None:
|
elif before_compare is not None and after_compare is None:
|
||||||
status = "removed"
|
status = "removed"
|
||||||
elif before != after:
|
elif before_compare != after_compare:
|
||||||
status = "changed"
|
status = "changed"
|
||||||
else:
|
else:
|
||||||
status = "unchanged"
|
status = "unchanged"
|
||||||
@@ -59,13 +82,22 @@ def _build_diff(before_map: dict, after_map: dict) -> list[dict]:
|
|||||||
{
|
{
|
||||||
"key": key,
|
"key": key,
|
||||||
"status": status,
|
"status": status,
|
||||||
"before": before,
|
"before": before_payload,
|
||||||
"after": after,
|
"after": after_payload,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_key(row) -> str:
|
||||||
|
return row.sector_id if row.sector_id else (row.element_id if row.element_id else row.sector_record_id)
|
||||||
|
|
||||||
|
def _group_key(row) -> str:
|
||||||
|
return row.group_id if row.group_id else (row.element_id if row.element_id else row.group_record_id)
|
||||||
|
|
||||||
|
def _seat_key(row) -> str:
|
||||||
|
return row.seat_id if row.seat_id else (row.element_id if row.element_id else row.seat_record_id)
|
||||||
|
|
||||||
async def build_structure_diff(
|
async def build_structure_diff(
|
||||||
*,
|
*,
|
||||||
scheme_id: str,
|
scheme_id: str,
|
||||||
@@ -83,32 +115,68 @@ async def build_structure_diff(
|
|||||||
draft_seats = await list_scheme_version_seats(draft_scheme_version_id)
|
draft_seats = await list_scheme_version_seats(draft_scheme_version_id)
|
||||||
|
|
||||||
if baseline is None:
|
if baseline is None:
|
||||||
baseline_sector_map = {}
|
baseline_sector_compare_map = {}
|
||||||
baseline_group_map = {}
|
baseline_group_compare_map = {}
|
||||||
baseline_seat_map = {}
|
baseline_seat_compare_map = {}
|
||||||
|
baseline_sector_payload_map = {}
|
||||||
|
baseline_group_payload_map = {}
|
||||||
|
baseline_seat_payload_map = {}
|
||||||
baseline_scheme_version_id = None
|
baseline_scheme_version_id = None
|
||||||
else:
|
else:
|
||||||
baseline_scheme_version_id = baseline.scheme_version_id
|
baseline_scheme_version_id = baseline.scheme_version_id
|
||||||
baseline_sector_map = {
|
baseline_sectors = await list_scheme_version_sectors(baseline.scheme_version_id)
|
||||||
row.sector_record_id: _serialize_sector(row)
|
baseline_groups = await list_scheme_version_groups(baseline.scheme_version_id)
|
||||||
for row in await list_scheme_version_sectors(baseline.scheme_version_id)
|
baseline_seats = await list_scheme_version_seats(baseline.scheme_version_id)
|
||||||
|
baseline_sector_compare_map = {
|
||||||
|
_sector_key(row): _sector_compare_value(row)
|
||||||
|
for row in baseline_sectors
|
||||||
}
|
}
|
||||||
baseline_group_map = {
|
baseline_sector_payload_map = {
|
||||||
row.group_record_id: _serialize_group(row)
|
_sector_key(row): _sector_response_value(row)
|
||||||
for row in await list_scheme_version_groups(baseline.scheme_version_id)
|
for row in baseline_sectors
|
||||||
}
|
}
|
||||||
baseline_seat_map = {
|
baseline_group_compare_map = {
|
||||||
row.seat_record_id: _serialize_seat(row)
|
_group_key(row): _group_compare_value(row)
|
||||||
for row in await list_scheme_version_seats(baseline.scheme_version_id)
|
for row in baseline_groups
|
||||||
|
}
|
||||||
|
baseline_group_payload_map = {
|
||||||
|
_group_key(row): _group_response_value(row)
|
||||||
|
for row in baseline_groups
|
||||||
|
}
|
||||||
|
baseline_seat_compare_map = {
|
||||||
|
_seat_key(row): _seat_compare_value(row)
|
||||||
|
for row in baseline_seats
|
||||||
|
}
|
||||||
|
baseline_seat_payload_map = {
|
||||||
|
_seat_key(row): _seat_response_value(row)
|
||||||
|
for row in baseline_seats
|
||||||
}
|
}
|
||||||
|
|
||||||
draft_sector_map = {row.sector_record_id: _serialize_sector(row) for row in draft_sectors}
|
draft_sector_compare_map = {_sector_key(row): _sector_compare_value(row) for row in draft_sectors}
|
||||||
draft_group_map = {row.group_record_id: _serialize_group(row) for row in draft_groups}
|
draft_sector_payload_map = {_sector_key(row): _sector_response_value(row) for row in draft_sectors}
|
||||||
draft_seat_map = {row.seat_record_id: _serialize_seat(row) for row in draft_seats}
|
draft_group_compare_map = {_group_key(row): _group_compare_value(row) for row in draft_groups}
|
||||||
|
draft_group_payload_map = {_group_key(row): _group_response_value(row) for row in draft_groups}
|
||||||
|
draft_seat_compare_map = {_seat_key(row): _seat_compare_value(row) for row in draft_seats}
|
||||||
|
draft_seat_payload_map = {_seat_key(row): _seat_response_value(row) for row in draft_seats}
|
||||||
|
|
||||||
sector_diff = _build_diff(baseline_sector_map, draft_sector_map)
|
sector_diff = _build_diff(
|
||||||
group_diff = _build_diff(baseline_group_map, draft_group_map)
|
before_compare_map=baseline_sector_compare_map,
|
||||||
seat_diff = _build_diff(baseline_seat_map, draft_seat_map)
|
after_compare_map=draft_sector_compare_map,
|
||||||
|
before_payload_map=baseline_sector_payload_map,
|
||||||
|
after_payload_map=draft_sector_payload_map,
|
||||||
|
)
|
||||||
|
group_diff = _build_diff(
|
||||||
|
before_compare_map=baseline_group_compare_map,
|
||||||
|
after_compare_map=draft_group_compare_map,
|
||||||
|
before_payload_map=baseline_group_payload_map,
|
||||||
|
after_payload_map=draft_group_payload_map,
|
||||||
|
)
|
||||||
|
seat_diff = _build_diff(
|
||||||
|
before_compare_map=baseline_seat_compare_map,
|
||||||
|
after_compare_map=draft_seat_compare_map,
|
||||||
|
before_payload_map=baseline_seat_payload_map,
|
||||||
|
after_payload_map=draft_seat_payload_map,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"baseline_scheme_version_id": baseline_scheme_version_id,
|
"baseline_scheme_version_id": baseline_scheme_version_id,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
- GET /api/v1/schemes/{scheme_id}/current
|
- GET /api/v1/schemes/{scheme_id}/current
|
||||||
- GET /api/v1/schemes/{scheme_id}/versions
|
- GET /api/v1/schemes/{scheme_id}/versions
|
||||||
- POST /api/v1/schemes/{scheme_id}/versions
|
- POST /api/v1/schemes/{scheme_id}/versions
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/publish/validation
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness
|
||||||
- POST /api/v1/schemes/{scheme_id}/publish
|
- POST /api/v1/schemes/{scheme_id}/publish
|
||||||
- POST /api/v1/schemes/{scheme_id}/unpublish
|
- POST /api/v1/schemes/{scheme_id}/unpublish
|
||||||
- POST /api/v1/schemes/{scheme_id}/rollback
|
- POST /api/v1/schemes/{scheme_id}/rollback
|
||||||
@@ -42,8 +44,58 @@
|
|||||||
- PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
- PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
||||||
- DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
- DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
||||||
|
|
||||||
|
## app/api/routes/pricing_diagnostics.py
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/coverage
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics
|
||||||
|
|
||||||
## app/api/routes/test_mode.py
|
## app/api/routes/test_mode.py
|
||||||
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}
|
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}
|
||||||
|
|
||||||
## app/api/routes/audit.py
|
## app/api/routes/audit.py
|
||||||
- GET /api/v1/schemes/{scheme_id}/audit
|
- GET /api/v1/schemes/{scheme_id}/audit
|
||||||
|
|
||||||
|
## app/api/routes/publish.py
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview
|
||||||
|
- 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}/editor/context
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/ensure
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/summary
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/structure
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/validation
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview
|
||||||
|
- 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}
|
||||||
|
- DELETE /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}
|
||||||
|
- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## app/api/routes/admin.py
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate
|
||||||
|
- POST /api/v1/admin/display/backfill
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup
|
||||||
|
|
||||||
|
## app/api/routes/admin_cleanup.py
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Draft editor flow starts from editor/context and draft/ensure, not from direct blind mutation calls.
|
||||||
|
|||||||
528
backend/docs/frontend-integration-contract.md
Normal file
528
backend/docs/frontend-integration-contract.md
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
# Backend Integration Contract
|
||||||
|
|
||||||
|
This document is the frontend handoff contract for the `svg-service` backend. It is written as an integration baseline, not as an internal backend README.
|
||||||
|
|
||||||
|
## 1. Base URL and Auth
|
||||||
|
|
||||||
|
- Base URL: `http://<host>:9020`
|
||||||
|
- API prefix: `/api/v1`
|
||||||
|
- Auth header: `X-API-Key`
|
||||||
|
|
||||||
|
All non-`/healthz` routes require an API key.
|
||||||
|
|
||||||
|
Auth failure contract:
|
||||||
|
|
||||||
|
- missing API key -> `401` with string detail: `Missing API key`
|
||||||
|
- invalid API key -> `403` with string detail: `Invalid API key`
|
||||||
|
- valid non-admin key on admin-only route -> `403` with string detail: `Admin role required`
|
||||||
|
|
||||||
|
## 2. Roles and Access Boundaries
|
||||||
|
|
||||||
|
- `admin`
|
||||||
|
- full access to protected routes
|
||||||
|
- required for all `/api/v1/admin/...` routes
|
||||||
|
- `operator`
|
||||||
|
- allowed on non-admin protected routes
|
||||||
|
- denied on admin-only routes
|
||||||
|
- `viewer`
|
||||||
|
- allowed on non-admin protected routes
|
||||||
|
- denied on admin-only routes
|
||||||
|
|
||||||
|
Frontend implication:
|
||||||
|
|
||||||
|
- admin UI must treat admin routes as optional capabilities gated by role
|
||||||
|
- frontend must not assume `operator` or `viewer` can call cleanup, audit, backfill, or current-artifact admin routes
|
||||||
|
|
||||||
|
## 3. Core Entities
|
||||||
|
|
||||||
|
### Upload
|
||||||
|
|
||||||
|
Represents one uploaded SVG source and its normalized/sanitized artifacts.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `upload_id`
|
||||||
|
- `original_filename`
|
||||||
|
- `content_type`
|
||||||
|
- `size_bytes`
|
||||||
|
- `original_storage_path`
|
||||||
|
- `sanitized_storage_path`
|
||||||
|
- `normalized_storage_path`
|
||||||
|
- `normalized_elements_count`
|
||||||
|
- `normalized_seats_count`
|
||||||
|
- `normalized_groups_count`
|
||||||
|
- `normalized_sectors_count`
|
||||||
|
|
||||||
|
### Scheme
|
||||||
|
|
||||||
|
Top-level business object created from upload.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `scheme_id`
|
||||||
|
- `source_upload_id`
|
||||||
|
- `name`
|
||||||
|
- `status`
|
||||||
|
- `current_version_number`
|
||||||
|
- `published_at`
|
||||||
|
|
||||||
|
### Scheme Version
|
||||||
|
|
||||||
|
Versioned snapshot of the scheme structure and publish state.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `scheme_version_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `version_number`
|
||||||
|
- `status`
|
||||||
|
- `normalized_storage_path`
|
||||||
|
- `normalized_*_count`
|
||||||
|
|
||||||
|
### Sector
|
||||||
|
|
||||||
|
Structure entity in a specific `scheme_version`.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `sector_record_id`
|
||||||
|
- `sector_id`
|
||||||
|
- `element_id`
|
||||||
|
- `name`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `sector_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `sector_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Group
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `group_record_id`
|
||||||
|
- `group_id`
|
||||||
|
- `element_id`
|
||||||
|
- `name`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `group_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `group_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Seat
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `seat_record_id`
|
||||||
|
- `seat_id`
|
||||||
|
- `element_id`
|
||||||
|
- `sector_id`
|
||||||
|
- `group_id`
|
||||||
|
- `row_label`
|
||||||
|
- `seat_number`
|
||||||
|
|
||||||
|
Business identity priority:
|
||||||
|
|
||||||
|
- use `seat_id` when present
|
||||||
|
- fallback to `element_id`
|
||||||
|
- never treat `seat_record_id` as business identity across versions
|
||||||
|
|
||||||
|
### Pricing Category
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `pricing_category_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `name`
|
||||||
|
- `code`
|
||||||
|
|
||||||
|
### Price Rule
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `price_rule_id`
|
||||||
|
- `scheme_id`
|
||||||
|
- `pricing_category_id`
|
||||||
|
- `target_type`
|
||||||
|
- `target_ref`
|
||||||
|
- `amount`
|
||||||
|
- `currency`
|
||||||
|
|
||||||
|
### Artifact
|
||||||
|
|
||||||
|
Artifact registry row for generated backend files.
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `artifact_id`
|
||||||
|
- `artifact_type`
|
||||||
|
- `artifact_variant`
|
||||||
|
- `storage_path`
|
||||||
|
- `status`
|
||||||
|
- `meta_json`
|
||||||
|
|
||||||
|
Important artifact types currently exercised by regression:
|
||||||
|
|
||||||
|
- `sanitized_svg`
|
||||||
|
- `normalized_json`
|
||||||
|
- `display_svg`
|
||||||
|
- `publish_preview`
|
||||||
|
|
||||||
|
## 4. Lifecycle State Machine
|
||||||
|
|
||||||
|
### Fresh Upload
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. `POST /api/v1/schemes/upload`
|
||||||
|
2. backend creates:
|
||||||
|
- `upload`
|
||||||
|
- `scheme`
|
||||||
|
- initial `scheme_version`
|
||||||
|
- structure rows
|
||||||
|
- initial artifacts
|
||||||
|
|
||||||
|
Expected initial state:
|
||||||
|
|
||||||
|
- `scheme.status = draft`
|
||||||
|
- `scheme.current_version_number = 1`
|
||||||
|
- current version status = `draft`
|
||||||
|
|
||||||
|
### Current Draft
|
||||||
|
|
||||||
|
If current scheme/version is still draft:
|
||||||
|
|
||||||
|
- editor works directly against current version
|
||||||
|
- `draft/ensure` is idempotent
|
||||||
|
- `draft/ensure` returns `created=false`
|
||||||
|
|
||||||
|
### Ensure Draft From Published Current
|
||||||
|
|
||||||
|
If current scheme/version is published:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- backend creates a new draft version
|
||||||
|
- current pointer switches to the new draft
|
||||||
|
- version number increments
|
||||||
|
|
||||||
|
### Publish
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- current scheme is draft
|
||||||
|
- current version is draft
|
||||||
|
- publish readiness must be satisfied
|
||||||
|
|
||||||
|
Publish path:
|
||||||
|
|
||||||
|
1. optional `draft/pricing/snapshot`
|
||||||
|
2. `GET draft/publish-readiness`
|
||||||
|
3. optional `GET draft/publish-preview`
|
||||||
|
4. `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
|
||||||
|
- scheme becomes `published`
|
||||||
|
- current version becomes `published`
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Path:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/rollback`
|
||||||
|
|
||||||
|
Effect:
|
||||||
|
|
||||||
|
- current pointer switches to requested historical `version_number`
|
||||||
|
- scheme returns to `draft`
|
||||||
|
- target version becomes current editable draft
|
||||||
|
|
||||||
|
### Unpublish
|
||||||
|
|
||||||
|
Path:
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/unpublish`
|
||||||
|
|
||||||
|
Effect:
|
||||||
|
|
||||||
|
- current scheme becomes `draft`
|
||||||
|
- current version becomes `draft`
|
||||||
|
|
||||||
|
## 5. Editor Flow
|
||||||
|
|
||||||
|
### Entry Point
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/editor/context`
|
||||||
|
|
||||||
|
Use it first to decide whether:
|
||||||
|
|
||||||
|
- current draft can be edited directly
|
||||||
|
- or a new draft must be created from published current
|
||||||
|
|
||||||
|
Important response fields:
|
||||||
|
|
||||||
|
- `current_scheme_version_id`
|
||||||
|
- `current_version_number`
|
||||||
|
- `scheme_status`
|
||||||
|
- `scheme_version_status`
|
||||||
|
- `current_is_draft`
|
||||||
|
- `create_draft_available`
|
||||||
|
- `recommended_action`
|
||||||
|
|
||||||
|
### Draft Read Models
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/draft/ensure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/summary`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/structure`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/validation`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview`
|
||||||
|
|
||||||
|
Frontend should treat `draft/structure` as the main editable read model.
|
||||||
|
|
||||||
|
### Patch Operations
|
||||||
|
|
||||||
|
Supported flows:
|
||||||
|
|
||||||
|
- single seat patch
|
||||||
|
- bulk seat patch
|
||||||
|
- sector create/patch/delete
|
||||||
|
- group create/patch/delete
|
||||||
|
- repair references
|
||||||
|
- remap preview/apply
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- always send `expected_scheme_version_id` when mutating or reading draft state after editor entry
|
||||||
|
|
||||||
|
### Stale Conflict Handling
|
||||||
|
|
||||||
|
If backend returns a stale or draft editability conflict:
|
||||||
|
|
||||||
|
- stop optimistic local mutation flow
|
||||||
|
- re-read:
|
||||||
|
- `editor/context`
|
||||||
|
- `draft/summary`
|
||||||
|
- `draft/structure`
|
||||||
|
|
||||||
|
Do not keep editing against stale cached `scheme_version_id`.
|
||||||
|
|
||||||
|
## 6. Pricing Flow
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/categories`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/pricing/rules`
|
||||||
|
- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
|
||||||
|
|
||||||
|
### Read Models
|
||||||
|
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/coverage`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price`
|
||||||
|
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- empty pricing on a fresh upload is valid
|
||||||
|
- do not treat `categories=[]` and `rules=[]` as backend failure
|
||||||
|
|
||||||
|
## 7. Publish Flow
|
||||||
|
|
||||||
|
Main endpoints:
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
- `POST /api/v1/schemes/{scheme_id}/publish`
|
||||||
|
|
||||||
|
Frontend sequencing rule:
|
||||||
|
|
||||||
|
1. ensure draft
|
||||||
|
2. mutate if needed
|
||||||
|
3. create/refresh pricing
|
||||||
|
4. build pricing snapshot
|
||||||
|
5. read publish readiness
|
||||||
|
6. read publish preview if UI needs preview surface
|
||||||
|
7. publish
|
||||||
|
|
||||||
|
## 8. Admin/Ops Flow
|
||||||
|
|
||||||
|
Admin-only endpoints:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/current/validation`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate`
|
||||||
|
- `POST /api/v1/admin/display/backfill`
|
||||||
|
- `GET /api/v1/admin/artifacts/publish-preview/audit`
|
||||||
|
- `POST /api/v1/admin/artifacts/publish-preview/cleanup`
|
||||||
|
- `GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview`
|
||||||
|
- `POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup`
|
||||||
|
|
||||||
|
Healthy publish-preview audit contract:
|
||||||
|
|
||||||
|
- `orphan_files_count = 0`
|
||||||
|
- `missing_files_for_db_rows_count = 0`
|
||||||
|
- `db_rows_count == disk_files_count`
|
||||||
|
|
||||||
|
Frontend implication:
|
||||||
|
|
||||||
|
- admin tools must not be shown as generally available functionality
|
||||||
|
- admin cleanup/destructive flows must be role-gated on the client and still handle backend `403`
|
||||||
|
|
||||||
|
## 9. Typed Error Catalog
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- `401` string detail: `Missing API key`
|
||||||
|
- `403` string detail: `Invalid API key`
|
||||||
|
- `403` string detail: `Admin role required`
|
||||||
|
|
||||||
|
### Lifecycle / Draft / Publish
|
||||||
|
|
||||||
|
- `stale_draft_version`
|
||||||
|
- `stale_current_version`
|
||||||
|
- `current_version_inconsistent`
|
||||||
|
- `draft_not_editable`
|
||||||
|
- `publish_not_ready`
|
||||||
|
|
||||||
|
### Editor Uniqueness / References
|
||||||
|
|
||||||
|
- `editor_uniqueness_error`
|
||||||
|
- `editor_reference_error`
|
||||||
|
- `duplicate_seat_id`
|
||||||
|
- `duplicate_seat_id_in_payload`
|
||||||
|
- `duplicate_sector_id`
|
||||||
|
- `duplicate_group_id`
|
||||||
|
- `duplicate_sector_element_id`
|
||||||
|
- `duplicate_group_element_id`
|
||||||
|
- `unknown_sector_id`
|
||||||
|
- `unknown_group_id`
|
||||||
|
- `unknown_sector_ids`
|
||||||
|
- `unknown_group_ids`
|
||||||
|
- `unknown_target_sector_id`
|
||||||
|
- `unknown_target_group_id`
|
||||||
|
- `business_identifier_nullification_forbidden`
|
||||||
|
|
||||||
|
### Pricing / Remap / Test
|
||||||
|
|
||||||
|
- `invalid_amount`
|
||||||
|
- `remap_filter_required`
|
||||||
|
- `test_preview_failed`
|
||||||
|
|
||||||
|
### Validation Report Codes
|
||||||
|
|
||||||
|
These appear inside validation report payloads rather than as top-level HTTP conflict codes:
|
||||||
|
|
||||||
|
- `duplicate_seat_ids`
|
||||||
|
- `missing_seat_contract`
|
||||||
|
- `seats_without_sector_or_group`
|
||||||
|
- `seats_without_price`
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- do not parse only HTTP status
|
||||||
|
- always inspect structured `detail.code` when `detail` is an object
|
||||||
|
|
||||||
|
## 10. Frontend Obligations
|
||||||
|
|
||||||
|
- always handle auth failures `401` and `403`
|
||||||
|
- always handle stale/conflict responses on draft, publish, and lifecycle operations
|
||||||
|
- never treat `*_record_id` as stable cross-version business identity
|
||||||
|
- always prefer business ids:
|
||||||
|
- seat -> `seat_id`, fallback `element_id`
|
||||||
|
- sector -> `sector_id`, fallback `element_id`
|
||||||
|
- group -> `group_id`, fallback `element_id`
|
||||||
|
- re-read current/draft state after:
|
||||||
|
- any `409`
|
||||||
|
- publish
|
||||||
|
- rollback
|
||||||
|
- unpublish
|
||||||
|
- `draft/ensure` returning a newly created draft
|
||||||
|
- do not assume current version remains stable across concurrent operator sessions
|
||||||
|
- do not assume publish-preview artifacts or display artifacts are frontend-owned resources
|
||||||
|
|
||||||
|
## 11. Non-Persistent Assumptions Frontend Must Avoid
|
||||||
|
|
||||||
|
The frontend must not assume that these remain stable forever:
|
||||||
|
|
||||||
|
- `scheme_version_id`
|
||||||
|
- `seat_record_id`
|
||||||
|
- `sector_record_id`
|
||||||
|
- `group_record_id`
|
||||||
|
- artifact `storage_path`
|
||||||
|
- publish-preview cache artifacts
|
||||||
|
|
||||||
|
These are safe to treat as business-stable:
|
||||||
|
|
||||||
|
- `scheme_id`
|
||||||
|
- `version_number` within one scheme
|
||||||
|
- `seat_id` when present
|
||||||
|
- `sector_id` when present
|
||||||
|
- `group_id` when present
|
||||||
|
|
||||||
|
## 12. Known Limitations / Deferred Tech Debt
|
||||||
|
|
||||||
|
- some lifecycle negative contracts still return mixed styles:
|
||||||
|
- typed object conflicts for `409`
|
||||||
|
- plain string details for some `404` and auth cases
|
||||||
|
- validation warnings and error code families are not yet unified into one single global error envelope
|
||||||
|
- admin/ops routes are backend-internal tools, not end-user product APIs
|
||||||
|
- corruption remediation smoke exists only for `publish_preview`, not for every artifact type
|
||||||
|
|
||||||
|
## 13. Regression Baseline Frontend Can Rely On
|
||||||
|
|
||||||
|
The frontend can rely on the following regression-backed flows:
|
||||||
|
|
||||||
|
- fresh upload on clean DB
|
||||||
|
- current/draft/editor read flow
|
||||||
|
- editor mutations and stale draft protection
|
||||||
|
- pricing setup and publish flow
|
||||||
|
- version lifecycle:
|
||||||
|
- publish
|
||||||
|
- ensure draft from published current
|
||||||
|
- rollback
|
||||||
|
- unpublish
|
||||||
|
- admin ops:
|
||||||
|
- audit
|
||||||
|
- cleanup
|
||||||
|
- destructive pricing cleanup for safe fixture categories
|
||||||
|
- full admin permission matrix on implemented admin endpoints
|
||||||
|
- controlled `publish_preview` corruption detection and remediation
|
||||||
|
- negative upload validation
|
||||||
|
- negative auth matrix
|
||||||
|
- negative lifecycle matrix
|
||||||
|
|
||||||
|
## 14. Recommended Frontend Integration Sequence
|
||||||
|
|
||||||
|
For normal editor work:
|
||||||
|
|
||||||
|
1. authenticate
|
||||||
|
2. upload or pick `scheme_id`
|
||||||
|
3. read `editor/context`
|
||||||
|
4. call `draft/ensure` if needed
|
||||||
|
5. read `draft/structure`
|
||||||
|
6. mutate using current `scheme_version_id`
|
||||||
|
7. on `409`, reload editor state before retry
|
||||||
|
8. configure pricing if needed
|
||||||
|
9. create pricing snapshot
|
||||||
|
10. read publish readiness / preview
|
||||||
|
11. publish
|
||||||
|
|
||||||
|
For admin UI:
|
||||||
|
|
||||||
|
1. verify admin role in client auth state
|
||||||
|
2. call admin endpoints
|
||||||
|
3. still handle backend `403`
|
||||||
|
4. treat cleanup and remediation as explicit operator actions, not background automation
|
||||||
673
backend/docs/smoke-regression.md
Normal file
673
backend/docs/smoke-regression.md
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
# Smoke regression checklist
|
||||||
|
|
||||||
|
This file is the backend manual regression baseline for svg-service.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- docker compose stack is up
|
||||||
|
- backend responds on port 9020
|
||||||
|
- valid admin API key is available
|
||||||
|
- stable SVG fixture exists in repository, e.g. `sample-contract.svg`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Use these variables in shell:
|
||||||
|
|
||||||
|
export API_URL="http://127.0.0.1:9020"
|
||||||
|
export API_KEY="admin-local-dev-key"
|
||||||
|
export FIXTURE_SVG_PATH="/home/adminko/svg-service/sample-contract.svg"
|
||||||
|
|
||||||
|
## Active regression contour
|
||||||
|
|
||||||
|
Primary operator regressions:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_core.sh`
|
||||||
|
- `backend/scripts/smoke_pricing_publish.sh`
|
||||||
|
- `backend/scripts/smoke_version_lifecycle.sh`
|
||||||
|
- `backend/scripts/smoke_lifecycle_negative.sh`
|
||||||
|
- `backend/scripts/smoke_admin_ops.sh`
|
||||||
|
- `backend/scripts/smoke_auth_negative.sh`
|
||||||
|
- `backend/scripts/smoke_authz_admin_all.sh`
|
||||||
|
- `backend/scripts/smoke_artifact_corruption.sh`
|
||||||
|
- `backend/scripts/smoke_upload_negative.sh`
|
||||||
|
- `backend/scripts/smoke_regression.sh`
|
||||||
|
|
||||||
|
Only this set is part of the active backend regression contour.
|
||||||
|
|
||||||
|
The scripts are expected to fail fast on any contract break or unexpected 5xx.
|
||||||
|
|
||||||
|
`smoke_regression.sh` is now an orchestration wrapper:
|
||||||
|
|
||||||
|
- first runs `smoke_core.sh`
|
||||||
|
- then runs `smoke_pricing_publish.sh`
|
||||||
|
- then runs `smoke_version_lifecycle.sh`
|
||||||
|
- then runs `smoke_lifecycle_negative.sh`
|
||||||
|
- then runs `smoke_admin_ops.sh`
|
||||||
|
- then runs `smoke_authz_admin_all.sh`
|
||||||
|
- then runs `smoke_auth_negative.sh`
|
||||||
|
- then runs `smoke_artifact_corruption.sh`
|
||||||
|
- then runs `smoke_upload_negative.sh`
|
||||||
|
- returns non-zero if any scenario fails
|
||||||
|
|
||||||
|
## Standalone/manual scripts
|
||||||
|
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
- `backend/scripts/cleanup_test_pricing_data.sh`
|
||||||
|
|
||||||
|
These scripts are intentionally not called by `smoke_regression.sh`.
|
||||||
|
|
||||||
|
## Scenario split
|
||||||
|
|
||||||
|
### Core smoke on clean DB
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_core.sh`
|
||||||
|
|
||||||
|
This scenario is designed for a fully clean database.
|
||||||
|
|
||||||
|
It uploads a fresh SVG fixture, resolves the created `scheme_id`, validates current/draft read models, validates empty pricing state, and then runs `editor_mutation_regression.sh` on the same fresh scheme.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it does not require pre-existing `scheme_id`
|
||||||
|
- it does not require pricing categories or price rules
|
||||||
|
- it does not require publish snapshot or published baseline
|
||||||
|
- empty pricing on a fresh upload is a valid state, not a failure
|
||||||
|
|
||||||
|
### Pricing/publish smoke with fixture setup
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_pricing_publish.sh`
|
||||||
|
|
||||||
|
This scenario also uploads a fresh SVG fixture, then prepares its own pricing fixture before validating pricing and publish flow.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it creates its own pricing category
|
||||||
|
- it creates its own pricing rule
|
||||||
|
- it intentionally checks both a priced seat and an unpriced seat on the same fresh scheme
|
||||||
|
- it does not rely on historical pricing IDs, rules, or old schemes
|
||||||
|
|
||||||
|
### Version lifecycle smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_version_lifecycle.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG, publishes version 1, creates version 2 from published current, mutates the new draft, publishes version 2, rolls back to version 1, and then runs unpublish on the current scheme.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it validates multi-version lifecycle beyond fresh upload
|
||||||
|
- it checks that `draft/ensure` creates a new draft only after current becomes published
|
||||||
|
- it verifies rollback switches `current_version_number` to the requested target version
|
||||||
|
- it verifies the rolled-back current structure matches the target version semantics, not the later mutated draft
|
||||||
|
- it checks audit trail for `scheme.published`, `scheme.version.created`, `scheme.rolled_back`, and `scheme.unpublished`
|
||||||
|
|
||||||
|
### Lifecycle negative smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_lifecycle_negative.sh`
|
||||||
|
|
||||||
|
This scenario uses fresh disposable scheme data to verify negative lifecycle contracts without leaving the database in a broken state.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it checks rollback to a nonexistent version
|
||||||
|
- it checks stale current-version guards on `draft/ensure`
|
||||||
|
- it checks stale expected-version guards on `publish`
|
||||||
|
- it creates a temporary `current_version_inconsistent` pointer only inside the scenario and restores it before exit
|
||||||
|
|
||||||
|
### Admin/ops smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_admin_ops.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG and prepares its own admin-cleanup fixture inside the scenario before checking current-artifact inspection, validation, publish-preview audit/cleanup, and pricing-category cleanup preview/dry-run.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- it creates its own pricing categories for cleanup preview
|
||||||
|
- it creates its own protected pricing rule so cleanup preview has both deletable and skipped categories
|
||||||
|
- it does not rely on historical orphan artifacts, old schemes, or dirty pricing state
|
||||||
|
- it checks publish-preview cleanup in both dry-run and execute modes
|
||||||
|
- it requires the final publish-preview audit state to be healthy: `orphan_files_count=0` and `missing_files_for_db_rows_count=0`
|
||||||
|
- it executes destructive pricing cleanup only for self-created safe fixture data
|
||||||
|
|
||||||
|
### Admin authz smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_authz_admin_all.sh`
|
||||||
|
|
||||||
|
This scenario uploads a fresh SVG, prepares its own cleanup fixture data, and then checks permission boundaries for admin/operator/viewer on all currently implemented admin endpoints used by the regression contour.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- admin must be allowed on tested admin endpoints
|
||||||
|
- operator and viewer must be denied with controlled 403 responses
|
||||||
|
- the scenario does not rely on historical scheme ids or dirty pricing state
|
||||||
|
- destructive pricing cleanup execution is validated with fresh self-created fixture categories only
|
||||||
|
|
||||||
|
### Artifact corruption smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_artifact_corruption.sh`
|
||||||
|
|
||||||
|
This scenario creates fresh publish-preview artifacts and then simulates two controlled corruption cases only on the artifacts created inside the scenario.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- case A removes a preview file while leaving its DB row in place
|
||||||
|
- case B removes a preview DB row while leaving its file on disk
|
||||||
|
- audit must detect both inconsistencies correctly
|
||||||
|
- cleanup dry-run must stay readable and non-destructive
|
||||||
|
- cleanup execute must remediate the introduced inconsistency
|
||||||
|
- the scenario does not touch historical schemes or unrelated artifact rows/files
|
||||||
|
|
||||||
|
### Auth negative smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_auth_negative.sh`
|
||||||
|
|
||||||
|
This scenario checks the negative auth matrix on a representative route set.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- missing API key must return `401`
|
||||||
|
- invalid API key must return `403`
|
||||||
|
- valid non-admin key must return `403` only on admin-only endpoints
|
||||||
|
- the route set includes protected, editor, pricing, admin, and admin-cleanup endpoints
|
||||||
|
|
||||||
|
### Negative upload smoke
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `backend/scripts/smoke_upload_negative.sh`
|
||||||
|
|
||||||
|
This scenario checks controlled upload failures for invalid inputs.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- empty upload must fail with a controlled 4xx
|
||||||
|
- non-SVG uploads must fail with a controlled 4xx
|
||||||
|
- invalid extension/content-type combinations must fail with a controlled 4xx
|
||||||
|
- oversize upload must fail with a controlled 413 when the configured size limit is exceeded
|
||||||
|
- no negative case is allowed to return 500
|
||||||
|
|
||||||
|
## 1. Health / system
|
||||||
|
|
||||||
|
- GET /healthz -> 200 (smoke uses a bounded retry/wait loop and fails explicitly if the API never becomes ready)
|
||||||
|
- GET /api/v1/ping -> 200
|
||||||
|
- GET /api/v1/db/ping -> 200
|
||||||
|
- GET /api/v1/manifest -> 200
|
||||||
|
|
||||||
|
## 2. Core smoke coverage
|
||||||
|
|
||||||
|
`smoke_core.sh` checks:
|
||||||
|
|
||||||
|
- GET /healthz -> 200
|
||||||
|
- GET /api/v1/ping -> 200
|
||||||
|
- GET /api/v1/db/ping -> 200
|
||||||
|
- GET /api/v1/manifest -> 200
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET /api/v1/schemes -> 200 and resolves the fresh `scheme_id`
|
||||||
|
- GET /api/v1/schemes/{scheme_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/versions -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/summary -> 200
|
||||||
|
- 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 draft entities by record id -> 200
|
||||||
|
- stale `expected_scheme_version_id` conflict -> 409 with typed `stale_draft_version`
|
||||||
|
- GET current sectors/groups/seats -> 200
|
||||||
|
- GET current SVG display meta -> 200
|
||||||
|
- GET pricing bundle -> 200 with empty categories/rules
|
||||||
|
- GET pricing coverage -> 200 with zero priced seats
|
||||||
|
- GET pricing explain/{seat_id} -> 200 with `no_price_rule`
|
||||||
|
- GET pricing rules diagnostics -> 200 with empty state
|
||||||
|
- GET audit -> 200
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh` on the same fresh scheme
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- fresh upload is readable immediately through current/draft/editor endpoints
|
||||||
|
- empty pricing is accepted as normal state for a newly uploaded scheme
|
||||||
|
- no endpoint in core smoke returns 500
|
||||||
|
|
||||||
|
## 3. Pricing/publish smoke coverage
|
||||||
|
|
||||||
|
`smoke_pricing_publish.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET current / POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing category -> 200
|
||||||
|
- POST price rule -> 200
|
||||||
|
- GET pricing bundle -> 200 with created fixture data
|
||||||
|
- GET pricing coverage -> 200 with both priced and unpriced seats present
|
||||||
|
- GET pricing explain/{priced_seat_id} -> 200 with matched rule
|
||||||
|
- GET pricing explain/{unpriced_seat_id} -> 200 with `no_price_rule`
|
||||||
|
- GET current/seats/{priced_seat_id}/price -> 200
|
||||||
|
- GET test/seats/{priced_seat_id} -> 200
|
||||||
|
- GET test/seats/{unpriced_seat_id} -> 200
|
||||||
|
- POST draft/pricing/snapshot -> 200
|
||||||
|
- GET draft/publish-readiness -> 200
|
||||||
|
- GET draft/publish-preview?refresh=true -> 200
|
||||||
|
- GET draft/publish-preview -> 200
|
||||||
|
- POST publish -> 200
|
||||||
|
- GET scheme detail/current after publish -> 200 and published state
|
||||||
|
- GET audit -> 200 and contains `scheme.published`
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- fixture setup is fully self-contained
|
||||||
|
- priced-seat checks happen only after explicit pricing fixture creation
|
||||||
|
- publish flow is validated on a fresh scheme, not on historical DB data
|
||||||
|
|
||||||
|
## 4. Version lifecycle smoke coverage
|
||||||
|
|
||||||
|
`smoke_version_lifecycle.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET scheme detail/current immediately after upload -> version 1 draft
|
||||||
|
- POST draft ensure on version 1 -> 200 and remains same draft
|
||||||
|
- POST pricing category/rule fixture -> 200
|
||||||
|
- POST draft/pricing/snapshot on version 1 -> 200
|
||||||
|
- POST publish on version 1 -> 200
|
||||||
|
- POST draft ensure from published current -> 200 and creates version 2
|
||||||
|
- PATCH one draft seat field on version 2 -> 200
|
||||||
|
- GET draft compare-preview on version 2 -> 200 and shows changed state
|
||||||
|
- POST draft/pricing/snapshot on version 2 -> 200
|
||||||
|
- POST publish on version 2 -> 200
|
||||||
|
- POST rollback to version 1 -> 200
|
||||||
|
- POST unpublish current -> 200
|
||||||
|
- GET audit -> 200 with lifecycle events present
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- version numbering advances from 1 to 2 only when current was published
|
||||||
|
- current pointer tracks the published version before rollback
|
||||||
|
- rollback switches current pointer back to the requested target version
|
||||||
|
- rolled-back current structure matches version 1 semantics after version 2 mutation
|
||||||
|
- lifecycle audit events are present and JSON-serializable
|
||||||
|
|
||||||
|
## 5. Lifecycle negative smoke coverage
|
||||||
|
|
||||||
|
`smoke_lifecycle_negative.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- GET current on the fresh scheme -> 200
|
||||||
|
- POST rollback with nonexistent `target_version_number` -> controlled 404
|
||||||
|
- POST draft/ensure with stale `expected_current_scheme_version_id` -> typed 409
|
||||||
|
- POST publish with stale `expected_scheme_version_id` -> typed 409
|
||||||
|
- GET current after temporary `current_version_inconsistent` pointer corruption -> typed 409
|
||||||
|
- GET current again after scenario restoration -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- rollback to missing version stays controlled and non-500
|
||||||
|
- ensure-draft stale current pointer returns typed `stale_current_version`
|
||||||
|
- publish stale expected version stays controlled and non-500
|
||||||
|
- temporary pointer inconsistency returns typed `current_version_inconsistent`
|
||||||
|
- the temporary inconsistency is restored before the scenario exits
|
||||||
|
## 6. Admin/ops smoke coverage
|
||||||
|
|
||||||
|
`smoke_admin_ops.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing category fixture for cleanup preview -> 200
|
||||||
|
- POST protected pricing rule fixture -> 200
|
||||||
|
- POST draft/pricing/snapshot -> 200
|
||||||
|
- GET draft/publish-preview?refresh=true -> 200
|
||||||
|
- GET draft/publish-preview -> 200
|
||||||
|
- 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
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit after cleanup -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing after destructive cleanup -> 200
|
||||||
|
- repeated cleanup preview/dry-run after destructive cleanup -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- admin artifact listing stays readable for current draft version
|
||||||
|
- admin validation stays readable for current draft version
|
||||||
|
- publish-preview cleanup dry-run stays non-destructive and mirrors pre-clean audit counts
|
||||||
|
- publish-preview cleanup execute removes all orphan preview files and missing DB rows
|
||||||
|
- final publish-preview audit is strict healthy state: `orphan_files_count=0`, `missing_files_for_db_rows_count=0`, and `db_rows_count == disk_files_count`
|
||||||
|
- pricing cleanup preview identifies both deletable and protected categories created inside the scenario
|
||||||
|
- pricing cleanup dry-run never mutates fixture data
|
||||||
|
- destructive pricing cleanup deletes only the safe category without rules
|
||||||
|
- protected pricing category and its rule remain after destructive cleanup
|
||||||
|
- repeated cleanup state remains stable after destructive cleanup
|
||||||
|
|
||||||
|
## 7. Admin authz smoke coverage
|
||||||
|
|
||||||
|
`smoke_authz_admin_all.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- POST pricing fixture categories/rule for cleanup authz checks -> 200
|
||||||
|
- POST draft/publish-preview refresh fixture -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/current/validation as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate as admin -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/display/backfill as admin -> 200
|
||||||
|
- POST /api/v1/admin/display/backfill as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit as admin -> 200
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true as admin -> 200
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true as operator/viewer -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview as admin -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true as admin -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false as operator/viewer -> 403
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false as admin -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- expected role matrix is explicit and enforced
|
||||||
|
- admin endpoints stay available to admin
|
||||||
|
- operator and viewer are denied without 500
|
||||||
|
- destructive cleanup execution remains constrained to self-created safe fixture data
|
||||||
|
|
||||||
|
## 8. Auth negative smoke coverage
|
||||||
|
|
||||||
|
`smoke_auth_negative.sh` checks:
|
||||||
|
|
||||||
|
- GET /api/v1/manifest without API key -> 401
|
||||||
|
- GET /api/v1/manifest with invalid API key -> 403
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context without API key -> 401
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context with invalid API key -> 403
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing without API key -> 401
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit without API key -> 401
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/artifacts/publish-preview/audit with valid viewer key -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview without API key -> 401
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview with invalid API key -> 403
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview with valid viewer key -> 403
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- missing key contract is consistently `401`
|
||||||
|
- invalid key contract is consistently `403`
|
||||||
|
- valid non-admin key is denied only on admin-only endpoints
|
||||||
|
|
||||||
|
## 9. Artifact corruption smoke coverage
|
||||||
|
|
||||||
|
`smoke_artifact_corruption.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload -> 200
|
||||||
|
- POST draft ensure on the fresh scheme -> 200
|
||||||
|
- GET initial /api/v1/admin/artifacts/publish-preview/audit -> healthy 200
|
||||||
|
- case A: manually delete fresh preview file while keeping DB row
|
||||||
|
- GET audit after case A -> reports exactly one missing file for DB row
|
||||||
|
- POST cleanup dry_run=true after case A -> 200
|
||||||
|
- POST cleanup dry_run=false after case A -> 200 and deletes the broken DB row
|
||||||
|
- case B: manually delete fresh preview DB row while keeping file
|
||||||
|
- GET audit after case B -> reports exactly one orphan file
|
||||||
|
- POST cleanup dry_run=true after case B -> 200
|
||||||
|
- POST cleanup dry_run=false after case B -> 200 and deletes the orphan file
|
||||||
|
- final audit -> healthy 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- audit sees DB-row-without-file and file-without-DB-row separately and correctly
|
||||||
|
- dry-run remains readable and non-destructive in both corruption cases
|
||||||
|
- execute cleanup remediates only the inconsistency introduced in the scenario
|
||||||
|
- final audit is healthy again: `orphan_files_count=0`, `missing_files_for_db_rows_count=0`
|
||||||
|
|
||||||
|
## 10. Negative upload smoke coverage
|
||||||
|
|
||||||
|
`smoke_upload_negative.sh` checks:
|
||||||
|
|
||||||
|
- POST /api/v1/schemes/upload with empty SVG body -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with non-SVG text/plain body -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with SVG body but invalid extension/content-type pair -> controlled 400
|
||||||
|
- POST /api/v1/schemes/upload with body larger than manifest max_file_size_bytes -> controlled 413
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
|
||||||
|
- upload validation rejects bad inputs with explicit 4xx contracts
|
||||||
|
- configured max file size is read from manifest, not hardcoded in the script
|
||||||
|
- no negative upload case returns 500
|
||||||
|
|
||||||
|
## 11. Legacy endpoint families
|
||||||
|
|
||||||
|
The sections below remain the API baseline by area, but regression execution is now split between clean-DB core smoke and pricing/publish smoke.
|
||||||
|
|
||||||
|
## 5. Scheme registry
|
||||||
|
|
||||||
|
- GET /api/v1/schemes -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id} -> 200
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## 6. Editor entry flow
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- editor context returns current_scheme_version_id
|
||||||
|
- editor context distinguishes draft vs published state correctly
|
||||||
|
- ensure endpoint is idempotent on current draft
|
||||||
|
- ensure endpoint creates a new draft from published current when needed
|
||||||
|
- returned scheme_version_id is reusable as expected_scheme_version_id
|
||||||
|
|
||||||
|
## 7. Draft read model
|
||||||
|
|
||||||
|
Using current draft version id from draft/ensure:
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/summary?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/structure?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/validation?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview?expected_scheme_version_id={draft_version_id} -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- summary returns total_seats / total_sectors / total_groups
|
||||||
|
- summary returns validation_summary / structure_diff_summary / publish_readiness
|
||||||
|
- structure returns lists for seats / sectors / groups
|
||||||
|
- validation is deterministic
|
||||||
|
- compare preview returns stable diff structure
|
||||||
|
- stale expected_scheme_version_id returns typed 409 conflict
|
||||||
|
|
||||||
|
## 8. Draft entity reads
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- record endpoints return exact draft entities
|
||||||
|
- unknown record id returns 404
|
||||||
|
- stale expected_scheme_version_id returns typed 409 conflict
|
||||||
|
|
||||||
|
## 9. 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
|
||||||
|
|
||||||
|
## 10. 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
|
||||||
|
|
||||||
|
## 11. Pricing read model
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/pricing -> 200
|
||||||
|
- 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}/pricing/rules/diagnostics -> 200
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 only after pricing fixture exists
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known seat
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- fresh clean upload is allowed to have `categories=[]` and `rules=[]`
|
||||||
|
- fresh clean upload is allowed to have zero priced seats and `no_price_rule` explanations
|
||||||
|
- priced seat checks belong to pricing/publish smoke after fixture setup
|
||||||
|
- diagnostics returns stable empty state with zero rules on clean upload
|
||||||
|
- diagnostics returns matched seat visibility after fixture setup
|
||||||
|
- priced test seat amount is serialized as string when pricing exists
|
||||||
|
|
||||||
|
## 12. Draft mutation regression
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `backend/scripts/editor_mutation_regression.sh`
|
||||||
|
|
||||||
|
This script checks:
|
||||||
|
- create sector
|
||||||
|
- create group
|
||||||
|
- patch seat
|
||||||
|
- bulk seat update
|
||||||
|
- patch sector
|
||||||
|
- patch group
|
||||||
|
- duplicate entity validation paths
|
||||||
|
- stale draft conflict
|
||||||
|
- remap preview validation path
|
||||||
|
- repair references
|
||||||
|
- delete created sector/group
|
||||||
|
- post-mutation read-model consistency
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- created entities are returned by API
|
||||||
|
- patched draft records are actually changed
|
||||||
|
- bulk update changes persisted fields
|
||||||
|
- duplicate ids return 422
|
||||||
|
- stale expected_scheme_version_id returns typed 409
|
||||||
|
- remap preview without filters returns typed 422
|
||||||
|
- post-mutation summary / validation / compare-preview remain readable and deterministic
|
||||||
|
|
||||||
|
## 13. Draft publish preview
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true&baseline_scheme_version_id={published_version_id} -> 200
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## 14. Publish readiness and publish flow
|
||||||
|
|
||||||
|
For current draft version:
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200
|
||||||
|
- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={draft_version_id} -> 200 or 409
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- readiness explicitly shows snapshot_available and pricing gate state
|
||||||
|
- publish with stale expected version returns typed 409
|
||||||
|
- publish without draft state returns typed 409
|
||||||
|
- publish success updates current status to published
|
||||||
|
- audit trail contains scheme.published event
|
||||||
|
|
||||||
|
## 15. 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
|
||||||
|
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false -> 200
|
||||||
|
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200
|
||||||
|
- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=false -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- artifact audit does not report orphan files or missing files for DB rows in normal state
|
||||||
|
- healthy publish-preview audit is strict: `orphan_files_count=0` and `missing_files_for_db_rows_count=0`
|
||||||
|
- validation report is readable and deterministic
|
||||||
|
- pricing cleanup preview returns matched candidates and safe_to_delete_count
|
||||||
|
- pricing cleanup dry-run returns deleted_count=0
|
||||||
|
- destructive pricing cleanup deletes only safe fixture categories without rules
|
||||||
|
- admin role is allowed on admin endpoints
|
||||||
|
- operator/viewer are denied with controlled 403 on admin endpoints
|
||||||
|
- idempotent cleanup is valid in both states: `matched_total=0` with `would_delete_count=0`, or `matched_total>0` with `would_delete_count>0`
|
||||||
|
- smoke does not require cleanup dry-run to always find something to delete
|
||||||
|
- admin routes do not produce 500 for healthy scheme state
|
||||||
|
|
||||||
|
## 16. Audit trail
|
||||||
|
|
||||||
|
- GET /api/v1/schemes/{scheme_id}/audit -> 200
|
||||||
|
|
||||||
|
Validate:
|
||||||
|
- recent publish preview / pricing / version / publish events are present when corresponding operations were run
|
||||||
|
- audit total is non-negative
|
||||||
|
- event payloads stay JSON-serializable
|
||||||
|
|
||||||
|
## 17. Fail criteria
|
||||||
|
|
||||||
|
Regression is considered failed if any of the following happen:
|
||||||
|
|
||||||
|
- health or db ping fails
|
||||||
|
- any stable read endpoint returns 500
|
||||||
|
- passthrough display endpoint fails on known-good sample
|
||||||
|
- publish preview refresh or cached read returns 500
|
||||||
|
- publish readiness returns 500
|
||||||
|
- editor context or draft ensure returns 500
|
||||||
|
- draft summary / structure / validation / compare-preview returns 500
|
||||||
|
- editor mutation regression returns non-zero exit code
|
||||||
|
- clean upload empty pricing state is treated as a failure
|
||||||
|
- pricing bundle or diagnostics contract changes unexpectedly
|
||||||
|
- admin audit/cleanup endpoints fail on healthy environment
|
||||||
|
- pricing cleanup dry-run mutates data
|
||||||
|
- artifact retention grows without bound for repeated preview refresh on same variant
|
||||||
|
|
||||||
|
## 18. Operator note
|
||||||
|
|
||||||
|
Run this checklist after:
|
||||||
|
- schema changes
|
||||||
|
- pricing schema/repository refactors
|
||||||
|
- artifact lifecycle changes
|
||||||
|
- display pipeline changes
|
||||||
|
- route reorganization
|
||||||
|
- startup/import/config changes
|
||||||
|
- draft lifecycle changes
|
||||||
|
- publish readiness changes
|
||||||
|
- admin cleanup changes
|
||||||
|
- editor mutation changes
|
||||||
38
backend/scripts/cleanup_test_pricing_data.sh
Executable file
38
backend/scripts/cleanup_test_pricing_data.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_URL="${API_URL:-http://127.0.0.1:9020}"
|
||||||
|
API_KEY="${API_KEY:-admin-local-dev-key}"
|
||||||
|
SCHEME_ID="${SCHEME_ID:-}"
|
||||||
|
DRY_RUN="${DRY_RUN:-true}"
|
||||||
|
|
||||||
|
if [[ -z "${SCHEME_ID}" ]]; then
|
||||||
|
echo "SCHEME_ID is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQUEST_BODY=$(cat <<JSON
|
||||||
|
{
|
||||||
|
"code_prefixes": ["FAIL_", "DIAG_", "AUTO_", "TYPED_"],
|
||||||
|
"name_prefixes": ["should-fail-", "diag-", "auto ", "typed-response-"],
|
||||||
|
"pricing_category_ids": [],
|
||||||
|
"delete_only_without_rules": true,
|
||||||
|
"dry_run": ${DRY_RUN}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "===== CLEANUP PREVIEW ====="
|
||||||
|
curl -sS \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=FAIL_&code_prefix=DIAG_&code_prefix=AUTO_&code_prefix=TYPED_&name_prefix=should-fail-&name_prefix=diag-&name_prefix=auto%20&name_prefix=typed-response-" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== CLEANUP EXECUTE (DRY_RUN=${DRY_RUN}) ====="
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-d "${REQUEST_BODY}" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
|
||||||
|
| python3 -m json.tool
|
||||||
274
backend/scripts/editor_mutation_regression.sh
Executable file
274
backend/scripts/editor_mutation_regression.sh
Executable file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
API_URL="${API_URL:-http://127.0.0.1:9020}"
|
||||||
|
API_KEY="${API_KEY:-admin-local-dev-key}"
|
||||||
|
SCHEME_ID="${SCHEME_ID:-82086336d385427f9d56244f9e1dd772}"
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo
|
||||||
|
echo "===== $* ====="
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo
|
||||||
|
echo "[FAIL] $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
request() {
|
||||||
|
local name="$1"
|
||||||
|
local method="$2"
|
||||||
|
local url="$3"
|
||||||
|
local body="${4:-}"
|
||||||
|
local expected="${5:-200}"
|
||||||
|
|
||||||
|
local body_file="${TMP_DIR}/${name}.body"
|
||||||
|
local code_file="${TMP_DIR}/${name}.code"
|
||||||
|
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-o "${body_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
--data "${body}" \
|
||||||
|
"${url}" > "${code_file}"
|
||||||
|
else
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-o "${body_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" > "${code_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local code
|
||||||
|
code="$(cat "${code_file}")"
|
||||||
|
|
||||||
|
echo "[${method}] ${url} -> ${code}"
|
||||||
|
cat "${body_file}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${code}" != "${expected}" ]]; then
|
||||||
|
fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
json_get() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
python3 - <<PY
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data = json.loads(Path("${file}").read_text())
|
||||||
|
expr = "${expr}"
|
||||||
|
|
||||||
|
value = data
|
||||||
|
for part in expr.split("."):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part.startswith("[") and part.endswith("]"):
|
||||||
|
cond = part[1:-1]
|
||||||
|
try:
|
||||||
|
if cond.endswith("!=null"):
|
||||||
|
k = cond[:-6]
|
||||||
|
value = next(item for item in value if item.get(k) is not None)
|
||||||
|
elif cond.endswith("==null"):
|
||||||
|
k = cond[:-6]
|
||||||
|
value = next(item for item in value if item.get(k) is None)
|
||||||
|
elif cond == "LAST":
|
||||||
|
value = value[-1]
|
||||||
|
else:
|
||||||
|
value = value[0]
|
||||||
|
except StopIteration:
|
||||||
|
value = None
|
||||||
|
elif part.isdigit():
|
||||||
|
value = value[int(part)]
|
||||||
|
else:
|
||||||
|
value = value[part] if value else None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
print("")
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
print("true" if value else "false")
|
||||||
|
else:
|
||||||
|
print(value)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_eq() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local expected="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_get "${file}" "${expr}")"
|
||||||
|
if [[ "${actual}" != "${expected}" ]]; then
|
||||||
|
fail "Assertion failed: ${expr} expected '${expected}', got '${actual}'"
|
||||||
|
fi
|
||||||
|
echo "[OK] ${expr}=${actual}"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_current() {
|
||||||
|
request "current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/current.body" "scheme_version_id")"
|
||||||
|
CURRENT_STATUS="$(json_get "${TMP_DIR}/current.body" "status")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
echo "CURRENT_STATUS=${CURRENT_STATUS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_draft() {
|
||||||
|
request "ensure_draft" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure" "" "200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
DRAFT_CREATED="$(json_get "${TMP_DIR}/ensure_draft.body" "created")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
echo "DRAFT_CREATED=${DRAFT_CREATED}"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_structure() {
|
||||||
|
request "draft_structure" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
|
||||||
|
SEAT_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_record_id")"
|
||||||
|
SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_id")"
|
||||||
|
ORIG_SEAT_NUMBER="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_number")"
|
||||||
|
|
||||||
|
echo "SEAT_RECORD_ID=${SEAT_RECORD_ID}"
|
||||||
|
echo "SEAT_ID=${SEAT_ID}"
|
||||||
|
echo "ORIG_SEAT_NUMBER=${ORIG_SEAT_NUMBER}"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_read_models() {
|
||||||
|
request "draft_summary" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
request "draft_validation" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/validation?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
request "draft_compare_preview" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_summary.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_compare_preview.body" "draft_scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "health"
|
||||||
|
curl -fsS "${API_URL}/healthz" >/dev/null || fail "healthz failed"
|
||||||
|
echo "[OK] healthz"
|
||||||
|
|
||||||
|
log "current + ensure draft"
|
||||||
|
extract_current
|
||||||
|
ensure_draft
|
||||||
|
read_structure
|
||||||
|
check_read_models
|
||||||
|
|
||||||
|
STAMP="$(date +%s)"
|
||||||
|
TEST_SECTOR_ID="reg-sector-${STAMP}"
|
||||||
|
TEST_GROUP_ID="reg-group-${STAMP}"
|
||||||
|
TEST_SECTOR_ELEMENT_ID="reg-sector-element-${STAMP}"
|
||||||
|
TEST_GROUP_ELEMENT_ID="reg-group-element-${STAMP}"
|
||||||
|
|
||||||
|
log "create sector"
|
||||||
|
request "create_sector" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"element_id\":\"${TEST_SECTOR_ELEMENT_ID}\",\"sector_id\":\"${TEST_SECTOR_ID}\",\"name\":\"${TEST_SECTOR_ID}\"}" \
|
||||||
|
"200"
|
||||||
|
CREATE_SECTOR_RECORD_ID="$(json_get "${TMP_DIR}/create_sector.body" "sector_record_id")"
|
||||||
|
echo "CREATE_SECTOR_RECORD_ID=${CREATE_SECTOR_RECORD_ID}"
|
||||||
|
|
||||||
|
log "create group"
|
||||||
|
request "create_group" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"element_id\":\"${TEST_GROUP_ELEMENT_ID}\",\"group_id\":\"${TEST_GROUP_ID}\",\"name\":\"${TEST_GROUP_ID}\"}" \
|
||||||
|
"200"
|
||||||
|
CREATE_GROUP_RECORD_ID="$(json_get "${TMP_DIR}/create_group.body" "group_record_id")"
|
||||||
|
echo "CREATE_GROUP_RECORD_ID=${CREATE_GROUP_RECORD_ID}"
|
||||||
|
|
||||||
|
log "patch seat -> bind to new group"
|
||||||
|
request "patch_seat_group" "PATCH" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"group_id\":\"${TEST_GROUP_ID}\"}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
log "verify seat after patch"
|
||||||
|
request "seat_after_patch" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_after_patch.body" "group_id" "${TEST_GROUP_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_after_patch.body" "seat_number" "${ORIG_SEAT_NUMBER}"
|
||||||
|
|
||||||
|
log "patch group name"
|
||||||
|
request "patch_group" "PATCH" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups/records/${CREATE_GROUP_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"name\":\"${TEST_GROUP_ID}-updated\"}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
log "patch sector name"
|
||||||
|
request "patch_sector" "PATCH" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${CREATE_SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"name\":\"${TEST_SECTOR_ID}-updated\"}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
log "verify sector after patch"
|
||||||
|
request "sector_after_patch" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${CREATE_SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/sector_after_patch.body" "name" "${TEST_SECTOR_ID}-updated"
|
||||||
|
assert_json_eq "${TMP_DIR}/sector_after_patch.body" "sector_id" "${TEST_SECTOR_ID}"
|
||||||
|
|
||||||
|
log "bulk seat update validation path"
|
||||||
|
request "bulk_seats" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/bulk?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"items\":[{\"seat_record_id\":\"${SEAT_RECORD_ID}\",\"row_label\":\"ZZ\",\"seat_number\":\"999\"}]}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
log "verify seat after bulk patch"
|
||||||
|
request "seat_after_bulk" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_after_bulk.body" "row_label" "ZZ"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_after_bulk.body" "seat_number" "999"
|
||||||
|
|
||||||
|
log "typed error: duplicate sector id"
|
||||||
|
request "duplicate_sector" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"element_id\":\"dup-${TEST_SECTOR_ELEMENT_ID}\",\"sector_id\":\"${TEST_SECTOR_ID}\",\"name\":\"dup\"}" \
|
||||||
|
"422"
|
||||||
|
|
||||||
|
log "typed error: duplicate group id"
|
||||||
|
request "duplicate_group" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{\"element_id\":\"dup-${TEST_GROUP_ELEMENT_ID}\",\"group_id\":\"${TEST_GROUP_ID}\",\"name\":\"dup\"}" \
|
||||||
|
"422"
|
||||||
|
|
||||||
|
log "typed error: stale draft version"
|
||||||
|
request "stale_patch" "PATCH" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" \
|
||||||
|
"{\"row_label\":\"STALE\"}" \
|
||||||
|
"409"
|
||||||
|
|
||||||
|
log "typed error: remap preview without filters"
|
||||||
|
request "remap_preview_invalid" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/remap/preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{}" \
|
||||||
|
"422"
|
||||||
|
|
||||||
|
log "repair references"
|
||||||
|
request "repair_refs" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/repair-references?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"{}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
log "post-mutation read models"
|
||||||
|
check_read_models
|
||||||
|
|
||||||
|
log "done"
|
||||||
|
echo "[OK] editor mutation regression completed successfully"
|
||||||
319
backend/scripts/smoke_admin_ops.sh
Normal file
319
backend/scripts/smoke_admin_ops.sh
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-admin-ops"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "ensure_draft" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${CURRENT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "draft_structure" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
TARGET_SEAT_ID="$(python3 - "${TMP_DIR}/draft_structure.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat = next((item for item in payload.get("seats", []) if item.get("seat_id")), None)
|
||||||
|
if seat is None:
|
||||||
|
raise SystemExit("No seat with seat_id found for admin ops smoke")
|
||||||
|
print(seat["seat_id"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "TARGET_SEAT_ID=${TARGET_SEAT_ID}"
|
||||||
|
|
||||||
|
STAMP="$(date +%s)-$$"
|
||||||
|
CLEANUP_PREFIX="ADMINOPS_CLEAN_${STAMP}_"
|
||||||
|
DELETE_CATEGORY_NAME="adminops-clean-delete-${STAMP}"
|
||||||
|
DELETE_CATEGORY_CODE="${CLEANUP_PREFIX}DELETE"
|
||||||
|
KEEP_CATEGORY_NAME="adminops-clean-keep-${STAMP}"
|
||||||
|
KEEP_CATEGORY_CODE="${CLEANUP_PREFIX}KEEP"
|
||||||
|
|
||||||
|
request "create_delete_category" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${DELETE_CATEGORY_NAME}\",\"code\":\"${DELETE_CATEGORY_CODE}\"}"
|
||||||
|
DELETE_CATEGORY_ID="$(json_get "${TMP_DIR}/create_delete_category.body" "pricing_category_id")"
|
||||||
|
echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_keep_category" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${KEEP_CATEGORY_NAME}\",\"code\":\"${KEEP_CATEGORY_CODE}\"}"
|
||||||
|
KEEP_CATEGORY_ID="$(json_get "${TMP_DIR}/create_keep_category.body" "pricing_category_id")"
|
||||||
|
echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_keep_category_rule" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"pricing_category_id\":\"${KEEP_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${TARGET_SEAT_ID}\",\"amount\":\"555.00\",\"currency\":\"RUB\"}"
|
||||||
|
KEEP_RULE_ID="$(json_get "${TMP_DIR}/create_keep_category_rule.body" "price_rule_id")"
|
||||||
|
echo "KEEP_RULE_ID=${KEEP_RULE_ID}"
|
||||||
|
|
||||||
|
request "draft_pricing_snapshot" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
request "publish_preview_refresh" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
request "publish_preview_cached" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
request "admin_current_artifacts" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/admin_current_artifacts.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_int_ge "${TMP_DIR}/admin_current_artifacts.body" "total" "4"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_current_artifacts.body" "\"artifact_type\":\"publish_preview\""
|
||||||
|
|
||||||
|
request "admin_current_validation" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/admin_current_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_current_validation.body" "\"report\":"
|
||||||
|
|
||||||
|
request "publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_audit.body" "artifact_type" "publish_preview"
|
||||||
|
assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "db_rows_count" "1"
|
||||||
|
assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "disk_files_count" "1"
|
||||||
|
PRE_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "db_rows_count")"
|
||||||
|
PRE_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "disk_files_count")"
|
||||||
|
PRE_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "orphan_files_count")"
|
||||||
|
PRE_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "missing_files_for_db_rows_count")"
|
||||||
|
echo "PRE_CLEANUP_DB_ROWS_COUNT=${PRE_CLEANUP_DB_ROWS_COUNT}"
|
||||||
|
echo "PRE_CLEANUP_DISK_FILES_COUNT=${PRE_CLEANUP_DISK_FILES_COUNT}"
|
||||||
|
echo "PRE_CLEANUP_ORPHAN_FILES_COUNT=${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
|
||||||
|
echo "PRE_CLEANUP_MISSING_FILES_COUNT=${PRE_CLEANUP_MISSING_FILES_COUNT}"
|
||||||
|
|
||||||
|
request "publish_preview_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "artifact_type" "publish_preview"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "dry_run" "true"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_db_rows_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
|
||||||
|
|
||||||
|
request "publish_preview_cleanup_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "artifact_type" "publish_preview"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "dry_run" "false"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
|
||||||
|
|
||||||
|
request "publish_preview_audit_after_cleanup" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "artifact_type" "publish_preview"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
|
||||||
|
POST_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "db_rows_count")"
|
||||||
|
POST_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "disk_files_count")"
|
||||||
|
POST_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count")"
|
||||||
|
POST_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count")"
|
||||||
|
echo "POST_CLEANUP_DB_ROWS_COUNT=${POST_CLEANUP_DB_ROWS_COUNT}"
|
||||||
|
echo "POST_CLEANUP_DISK_FILES_COUNT=${POST_CLEANUP_DISK_FILES_COUNT}"
|
||||||
|
echo "POST_CLEANUP_ORPHAN_FILES_COUNT=${POST_CLEANUP_ORPHAN_FILES_COUNT}"
|
||||||
|
echo "POST_CLEANUP_MISSING_FILES_COUNT=${POST_CLEANUP_MISSING_FILES_COUNT}"
|
||||||
|
|
||||||
|
if [[ "${POST_CLEANUP_DB_ROWS_COUNT}" != "${POST_CLEANUP_DISK_FILES_COUNT}" ]]; then
|
||||||
|
fail "publish-preview audit mismatch after cleanup: db_rows_count=${POST_CLEANUP_DB_ROWS_COUNT}, disk_files_count=${POST_CLEANUP_DISK_FILES_COUNT}"
|
||||||
|
fi
|
||||||
|
echo "[OK] publish-preview audit is fully consistent after cleanup"
|
||||||
|
|
||||||
|
request "pricing_cleanup_preview" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_preview.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "total_candidates" "2"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "safe_to_delete_count" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_cleanup_preview.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
delete_category_id = sys.argv[2]
|
||||||
|
keep_category_id = sys.argv[3]
|
||||||
|
|
||||||
|
items = {item["pricing_category_id"]: item for item in payload.get("items", [])}
|
||||||
|
if delete_category_id not in items:
|
||||||
|
raise SystemExit(f"Delete candidate {delete_category_id} missing from cleanup preview")
|
||||||
|
if keep_category_id not in items:
|
||||||
|
raise SystemExit(f"Protected category {keep_category_id} missing from cleanup preview")
|
||||||
|
if not items[delete_category_id]["deletable"]:
|
||||||
|
raise SystemExit("Delete candidate is expected to be deletable")
|
||||||
|
if items[keep_category_id]["deletable"]:
|
||||||
|
raise SystemExit("Protected category is expected to be skipped because it has rules")
|
||||||
|
PY
|
||||||
|
echo "[OK] pricing cleanup preview matched expected deletable/skipped categories"
|
||||||
|
|
||||||
|
request "pricing_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
|
||||||
|
"200" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "dry_run" "true"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "matched_total" "2"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "would_delete_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "deleted_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "skipped_count" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_cleanup_dry_run.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
delete_category_id = sys.argv[2]
|
||||||
|
keep_category_id = sys.argv[3]
|
||||||
|
|
||||||
|
would_delete_ids = set(payload.get("would_delete_category_ids", []))
|
||||||
|
if would_delete_ids != {delete_category_id}:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Dry run expected would_delete_category_ids={[delete_category_id]}, got={sorted(would_delete_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
|
||||||
|
if skipped_ids != {keep_category_id}:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
echo "[OK] pricing cleanup dry-run kept protected category and selected only empty fixture category"
|
||||||
|
|
||||||
|
request "pricing_cleanup_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
|
||||||
|
"200" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "dry_run" "false"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "matched_total" "2"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "would_delete_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "deleted_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "skipped_count" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_cleanup_execute.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
delete_category_id = sys.argv[2]
|
||||||
|
keep_category_id = sys.argv[3]
|
||||||
|
|
||||||
|
deleted_ids = set(payload.get("deleted_category_ids", []))
|
||||||
|
if deleted_ids != {delete_category_id}:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Cleanup execute expected deleted_category_ids={[delete_category_id]}, got={sorted(deleted_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
|
||||||
|
if skipped_ids != {keep_category_id}:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Cleanup execute expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
echo "[OK] pricing cleanup execute deleted only safe fixture category"
|
||||||
|
|
||||||
|
request "pricing_bundle_after_cleanup" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" \
|
||||||
|
"200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "categories" "1"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "rules" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_bundle_after_cleanup.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" "${KEEP_RULE_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
delete_category_id = sys.argv[2]
|
||||||
|
keep_category_id = sys.argv[3]
|
||||||
|
keep_rule_id = sys.argv[4]
|
||||||
|
|
||||||
|
category_ids = {item["pricing_category_id"] for item in payload.get("categories", [])}
|
||||||
|
rule_ids = {item["price_rule_id"] for item in payload.get("rules", [])}
|
||||||
|
|
||||||
|
if delete_category_id in category_ids:
|
||||||
|
raise SystemExit("Deleted cleanup category still present in pricing bundle")
|
||||||
|
if keep_category_id not in category_ids:
|
||||||
|
raise SystemExit("Protected cleanup category missing after execute cleanup")
|
||||||
|
if keep_rule_id not in rule_ids:
|
||||||
|
raise SystemExit("Protected pricing rule missing after execute cleanup")
|
||||||
|
PY
|
||||||
|
echo "[OK] pricing bundle reflects destructive cleanup result"
|
||||||
|
|
||||||
|
request "pricing_cleanup_preview_after_cleanup" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "total_candidates" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "safe_to_delete_count" "0"
|
||||||
|
|
||||||
|
request "pricing_cleanup_dry_run_after_cleanup" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
|
||||||
|
"200" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "dry_run" "true"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "matched_total" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "would_delete_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "deleted_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "skipped_count" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "${KEEP_CATEGORY_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
keep_category_id = sys.argv[2]
|
||||||
|
|
||||||
|
would_delete_ids = payload.get("would_delete_category_ids", [])
|
||||||
|
if would_delete_ids:
|
||||||
|
raise SystemExit(f"Expected no deletable categories after cleanup, got={would_delete_ids}")
|
||||||
|
|
||||||
|
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
|
||||||
|
if skipped_ids != {keep_category_id}:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Post-cleanup dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
echo "[OK] repeated cleanup state is stable after destructive cleanup"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke admin ops completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
|
echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}"
|
||||||
|
echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}"
|
||||||
|
echo "KEEP_RULE_ID=${KEEP_RULE_ID}"
|
||||||
173
backend/scripts/smoke_artifact_corruption.sh
Normal file
173
backend/scripts/smoke_artifact_corruption.sh
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "${REPO_ROOT}/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-artifact-corruption"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "ensure_draft" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${CURRENT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "initial_publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/initial_publish_preview_audit.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/initial_publish_preview_audit.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
|
||||||
|
request "publish_preview_refresh_case_a" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
request "admin_current_artifacts_case_a" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
read -r CASE_A_ARTIFACT_ID CASE_A_STORAGE_PATH <<EOF
|
||||||
|
$(python3 - "${TMP_DIR}/admin_current_artifacts_case_a.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
items = [item for item in payload.get("items", []) if item.get("artifact_type") == "publish_preview"]
|
||||||
|
if not items:
|
||||||
|
raise SystemExit("No publish_preview artifact found for case A")
|
||||||
|
item = items[-1]
|
||||||
|
print(item["artifact_id"], item["storage_path"])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
echo "CASE_A_ARTIFACT_ID=${CASE_A_ARTIFACT_ID}"
|
||||||
|
echo "CASE_A_STORAGE_PATH=${CASE_A_STORAGE_PATH}"
|
||||||
|
|
||||||
|
docker compose exec -T svg-service python - "${CASE_A_STORAGE_PATH}" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit(f"Case A preview file missing before manual removal: {path}")
|
||||||
|
path.unlink()
|
||||||
|
PY
|
||||||
|
echo "[OK] case A manually removed preview file while DB row remains"
|
||||||
|
|
||||||
|
request "audit_case_a_broken" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_a_broken.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_a_broken.body" "missing_files_for_db_rows_count" "1"
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_case_a_broken.body" "\"artifact_id\":\"${CASE_A_ARTIFACT_ID}\""
|
||||||
|
|
||||||
|
request "cleanup_case_a_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_dry_run.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_dry_run.body" "missing_files_for_db_rows_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_dry_run.body" "deleted_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_dry_run.body" "deleted_db_rows_count" "0"
|
||||||
|
|
||||||
|
request "cleanup_case_a_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_execute.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_execute.body" "missing_files_for_db_rows_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_execute.body" "deleted_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_a_execute.body" "deleted_db_rows_count" "1"
|
||||||
|
assert_file_contains "${TMP_DIR}/cleanup_case_a_execute.body" "\"${CASE_A_ARTIFACT_ID}\""
|
||||||
|
|
||||||
|
request "audit_case_a_healthy" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_a_healthy.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_a_healthy.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
|
||||||
|
request "publish_preview_refresh_case_b" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
request "admin_current_artifacts_case_b" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
read -r CASE_B_ARTIFACT_ID CASE_B_STORAGE_PATH <<EOF
|
||||||
|
$(python3 - "${TMP_DIR}/admin_current_artifacts_case_b.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
items = [item for item in payload.get("items", []) if item.get("artifact_type") == "publish_preview"]
|
||||||
|
if not items:
|
||||||
|
raise SystemExit("No publish_preview artifact found for case B")
|
||||||
|
item = items[-1]
|
||||||
|
print(item["artifact_id"], item["storage_path"])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
echo "CASE_B_ARTIFACT_ID=${CASE_B_ARTIFACT_ID}"
|
||||||
|
echo "CASE_B_STORAGE_PATH=${CASE_B_STORAGE_PATH}"
|
||||||
|
|
||||||
|
CASE_B_DELETE_COUNT="$(docker compose exec -T postgres psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -Atc "with deleted as (delete from scheme_artifacts where artifact_id='${CASE_B_ARTIFACT_ID}' and artifact_type='publish_preview' and scheme_id='${SCHEME_ID}' returning 1) select count(*) from deleted;")"
|
||||||
|
if [[ "${CASE_B_DELETE_COUNT}" != "1" ]]; then
|
||||||
|
fail "Case B expected to delete exactly one publish_preview DB row, got ${CASE_B_DELETE_COUNT}"
|
||||||
|
fi
|
||||||
|
echo "[OK] case B manually removed publish_preview DB row while file remains"
|
||||||
|
|
||||||
|
request "audit_case_b_broken" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_b_broken.body" "orphan_files_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/audit_case_b_broken.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_case_b_broken.body" "\"${CASE_B_STORAGE_PATH}\""
|
||||||
|
|
||||||
|
request "cleanup_case_b_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_dry_run.body" "orphan_files_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_dry_run.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_dry_run.body" "deleted_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_dry_run.body" "deleted_db_rows_count" "0"
|
||||||
|
|
||||||
|
request "cleanup_case_b_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_execute.body" "orphan_files_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_execute.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_execute.body" "deleted_files_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/cleanup_case_b_execute.body" "deleted_db_rows_count" "0"
|
||||||
|
assert_file_contains "${TMP_DIR}/cleanup_case_b_execute.body" "\"${CASE_B_STORAGE_PATH}\""
|
||||||
|
|
||||||
|
request "final_publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/final_publish_preview_audit.body" "orphan_files_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/final_publish_preview_audit.body" "missing_files_for_db_rows_count" "0"
|
||||||
|
|
||||||
|
FINAL_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/final_publish_preview_audit.body" "db_rows_count")"
|
||||||
|
FINAL_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/final_publish_preview_audit.body" "disk_files_count")"
|
||||||
|
if [[ "${FINAL_DB_ROWS_COUNT}" != "${FINAL_DISK_FILES_COUNT}" ]]; then
|
||||||
|
fail "Final publish-preview audit mismatch after remediation: db_rows_count=${FINAL_DB_ROWS_COUNT}, disk_files_count=${FINAL_DISK_FILES_COUNT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke artifact corruption completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
|
echo "CASE_A_ARTIFACT_ID=${CASE_A_ARTIFACT_ID}"
|
||||||
|
echo "CASE_B_ARTIFACT_ID=${CASE_B_ARTIFACT_ID}"
|
||||||
78
backend/scripts/smoke_auth_negative.sh
Normal file
78
backend/scripts/smoke_auth_negative.sh
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
INVALID_API_KEY="${INVALID_API_KEY:-definitely-invalid-api-key}"
|
||||||
|
VIEWER_API_KEY="${VIEWER_API_KEY:-viewer-local-dev-key}"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-auth-negative"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
|
||||||
|
request_without_api_key "manifest_missing_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/manifest" \
|
||||||
|
"401"
|
||||||
|
request_with_api_key "${INVALID_API_KEY}" "manifest_invalid_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/manifest" \
|
||||||
|
"403"
|
||||||
|
assert_file_contains "${TMP_DIR}/manifest_missing_key.body" "Missing API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/manifest_invalid_key.body" "Invalid API key"
|
||||||
|
|
||||||
|
request_without_api_key "editor_context_missing_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" \
|
||||||
|
"401"
|
||||||
|
request_with_api_key "${INVALID_API_KEY}" "editor_context_invalid_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" \
|
||||||
|
"403"
|
||||||
|
assert_file_contains "${TMP_DIR}/editor_context_missing_key.body" "Missing API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/editor_context_invalid_key.body" "Invalid API key"
|
||||||
|
|
||||||
|
request_without_api_key "pricing_bundle_missing_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" \
|
||||||
|
"401"
|
||||||
|
request_with_api_key "${INVALID_API_KEY}" "pricing_bundle_invalid_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" \
|
||||||
|
"403"
|
||||||
|
assert_file_contains "${TMP_DIR}/pricing_bundle_missing_key.body" "Missing API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/pricing_bundle_invalid_key.body" "Invalid API key"
|
||||||
|
|
||||||
|
request_without_api_key "admin_audit_missing_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"401"
|
||||||
|
request_with_api_key "${INVALID_API_KEY}" "admin_audit_invalid_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "admin_audit_wrong_role" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
|
||||||
|
"403"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_audit_missing_key.body" "Missing API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_audit_invalid_key.body" "Invalid API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_audit_wrong_role.body" "Admin role required"
|
||||||
|
|
||||||
|
request_without_api_key "admin_cleanup_preview_missing_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview" \
|
||||||
|
"401"
|
||||||
|
request_with_api_key "${INVALID_API_KEY}" "admin_cleanup_preview_invalid_key" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview" \
|
||||||
|
"403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "admin_cleanup_preview_wrong_role" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview" \
|
||||||
|
"403"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_cleanup_preview_missing_key.body" "Missing API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_cleanup_preview_invalid_key.body" "Invalid API key"
|
||||||
|
assert_file_contains "${TMP_DIR}/admin_cleanup_preview_wrong_role.body" "Admin role required"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke auth negative completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
202
backend/scripts/smoke_authz_admin_all.sh
Normal file
202
backend/scripts/smoke_authz_admin_all.sh
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
ADMIN_API_KEY="${ADMIN_API_KEY:-admin-local-dev-key}"
|
||||||
|
OPERATOR_API_KEY="${OPERATOR_API_KEY:-operator-local-dev-key}"
|
||||||
|
VIEWER_API_KEY="${VIEWER_API_KEY:-viewer-local-dev-key}"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-authz-admin-all"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "ensure_draft" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${CURRENT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "draft_structure" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
TARGET_SEAT_ID="$(python3 - "${TMP_DIR}/draft_structure.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat = next((item for item in payload.get("seats", []) if item.get("seat_id")), None)
|
||||||
|
if seat is None:
|
||||||
|
raise SystemExit("No seat with seat_id found for authz admin all smoke")
|
||||||
|
print(seat["seat_id"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "TARGET_SEAT_ID=${TARGET_SEAT_ID}"
|
||||||
|
|
||||||
|
STAMP="$(date +%s)-$$"
|
||||||
|
CLEANUP_PREFIX="AUTHZ_ADMINALL_${STAMP}_"
|
||||||
|
DELETE_CATEGORY_NAME="authz-adminall-delete-${STAMP}"
|
||||||
|
DELETE_CATEGORY_CODE="${CLEANUP_PREFIX}DELETE"
|
||||||
|
KEEP_CATEGORY_NAME="authz-adminall-keep-${STAMP}"
|
||||||
|
KEEP_CATEGORY_CODE="${CLEANUP_PREFIX}KEEP"
|
||||||
|
|
||||||
|
request "create_delete_category" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${DELETE_CATEGORY_NAME}\",\"code\":\"${DELETE_CATEGORY_CODE}\"}"
|
||||||
|
DELETE_CATEGORY_ID="$(json_get "${TMP_DIR}/create_delete_category.body" "pricing_category_id")"
|
||||||
|
echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_keep_category" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${KEEP_CATEGORY_NAME}\",\"code\":\"${KEEP_CATEGORY_CODE}\"}"
|
||||||
|
KEEP_CATEGORY_ID="$(json_get "${TMP_DIR}/create_keep_category.body" "pricing_category_id")"
|
||||||
|
echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_keep_category_rule" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"pricing_category_id\":\"${KEEP_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${TARGET_SEAT_ID}\",\"amount\":\"777.00\",\"currency\":\"RUB\"}"
|
||||||
|
KEEP_RULE_ID="$(json_get "${TMP_DIR}/create_keep_category_rule.body" "price_rule_id")"
|
||||||
|
echo "KEEP_RULE_ID=${KEEP_RULE_ID}"
|
||||||
|
|
||||||
|
request "draft_pricing_snapshot" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
request "publish_preview_refresh" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_current_artifacts" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_current_artifacts" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_current_artifacts" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_current_artifacts.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_current_artifacts.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_current_validation" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_current_validation" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_current_validation" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_current_validation.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_current_validation.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_display_regenerate" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/display/regenerate?mode=passthrough" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_display_regenerate" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/display/regenerate?mode=passthrough" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_display_regenerate" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/display/regenerate?mode=passthrough" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_display_regenerate.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_display_regenerate.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_display_backfill" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/display/backfill?mode=passthrough&limit=1&only_missing=true" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_display_backfill" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/display/backfill?mode=passthrough&limit=1&only_missing=true" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_display_backfill" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/display/backfill?mode=passthrough&limit=1&only_missing=true" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_display_backfill.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_display_backfill.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_publish_preview_audit" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_publish_preview_audit.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_publish_preview_audit.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_publish_preview_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_publish_preview_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_publish_preview_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_publish_preview_cleanup_dry_run.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_publish_preview_cleanup_dry_run.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_pricing_cleanup_preview" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" "200"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_pricing_cleanup_preview" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" "403"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_pricing_cleanup_preview" "GET" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" "403"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_pricing_cleanup_preview.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_pricing_cleanup_preview.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_pricing_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "200" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_pricing_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "403" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_pricing_cleanup_dry_run" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "403" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_pricing_cleanup_dry_run.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_pricing_cleanup_dry_run.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${OPERATOR_API_KEY}" "operator_pricing_cleanup_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "403" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}"
|
||||||
|
request_with_api_key "${VIEWER_API_KEY}" "viewer_pricing_cleanup_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "403" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}"
|
||||||
|
assert_file_contains "${TMP_DIR}/operator_pricing_cleanup_execute.body" "Admin role required"
|
||||||
|
assert_file_contains "${TMP_DIR}/viewer_pricing_cleanup_execute.body" "Admin role required"
|
||||||
|
|
||||||
|
request_with_api_key "${ADMIN_API_KEY}" "admin_pricing_cleanup_execute" "POST" \
|
||||||
|
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "200" \
|
||||||
|
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/admin_pricing_cleanup_execute.body" "deleted_count" "1"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/admin_pricing_cleanup_execute.body" "skipped_count" "1"
|
||||||
|
|
||||||
|
request "pricing_bundle_after_admin_cleanup_execute" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" "200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_admin_cleanup_execute.body" "categories" "1"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_admin_cleanup_execute.body" "rules" "1"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/pricing_bundle_after_admin_cleanup_execute.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" "${KEEP_RULE_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
delete_category_id = sys.argv[2]
|
||||||
|
keep_category_id = sys.argv[3]
|
||||||
|
keep_rule_id = sys.argv[4]
|
||||||
|
|
||||||
|
category_ids = {item["pricing_category_id"] for item in payload.get("categories", [])}
|
||||||
|
rule_ids = {item["price_rule_id"] for item in payload.get("rules", [])}
|
||||||
|
|
||||||
|
if delete_category_id in category_ids:
|
||||||
|
raise SystemExit("Authz admin-all cleanup left deletable category behind")
|
||||||
|
if keep_category_id not in category_ids:
|
||||||
|
raise SystemExit("Authz admin-all cleanup removed protected category")
|
||||||
|
if keep_rule_id not in rule_ids:
|
||||||
|
raise SystemExit("Authz admin-all cleanup removed protected rule")
|
||||||
|
PY
|
||||||
|
echo "[OK] admin cleanup execute remained destructive only for safe fixture category"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke authz admin all completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
500
backend/scripts/smoke_common.sh
Normal file
500
backend/scripts/smoke_common.sh
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
API_URL="${API_URL:-http://127.0.0.1:9020}"
|
||||||
|
API_KEY="${API_KEY:-admin-local-dev-key}"
|
||||||
|
FIXTURE_SVG_PATH="${FIXTURE_SVG_PATH:-${REPO_ROOT}/sample-contract.svg}"
|
||||||
|
HEALTH_MAX_ATTEMPTS="${HEALTH_MAX_ATTEMPTS:-20}"
|
||||||
|
HEALTH_RETRY_DELAY_SECONDS="${HEALTH_RETRY_DELAY_SECONDS:-1}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo
|
||||||
|
echo "===== $* ====="
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo
|
||||||
|
echo "[FAIL] $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_fixture_svg() {
|
||||||
|
if [[ ! -f "${FIXTURE_SVG_PATH}" ]]; then
|
||||||
|
fail "Fixture SVG not found: ${FIXTURE_SVG_PATH}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
log "health"
|
||||||
|
echo "waiting for API to be ready..."
|
||||||
|
local health_ready="false"
|
||||||
|
local health_status=""
|
||||||
|
|
||||||
|
for ((i = 1; i <= HEALTH_MAX_ATTEMPTS; i++)); do
|
||||||
|
health_status="$(curl -sS -o /dev/null -w "%{http_code}" "${API_URL}/healthz" || true)"
|
||||||
|
if [[ "${health_status}" == "200" ]]; then
|
||||||
|
health_ready="true"
|
||||||
|
echo "API is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "waiting... (${i}/${HEALTH_MAX_ATTEMPTS}) healthz=${health_status}"
|
||||||
|
sleep "${HEALTH_RETRY_DELAY_SECONDS}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${health_ready}" != "true" ]]; then
|
||||||
|
fail "API did not become ready on ${API_URL}/healthz after ${HEALTH_MAX_ATTEMPTS} attempts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sS -i "${API_URL}/healthz"
|
||||||
|
}
|
||||||
|
|
||||||
|
request() {
|
||||||
|
local name="$1"
|
||||||
|
local method="$2"
|
||||||
|
local url="$3"
|
||||||
|
local expected_status="$4"
|
||||||
|
local body="${5:-}"
|
||||||
|
local out_file="${TMP_DIR}/${name}.body"
|
||||||
|
local status_file="${TMP_DIR}/${name}.status"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== ${name} ====="
|
||||||
|
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" \
|
||||||
|
--data "${body}" > "${status_file}"
|
||||||
|
else
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" > "${status_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local actual_status
|
||||||
|
actual_status="$(python3 - "$status_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "[${method}] ${url} -> ${actual_status}"
|
||||||
|
python3 - "$out_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||||
|
fail "Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
request_with_api_key() {
|
||||||
|
local api_key="$1"
|
||||||
|
local name="$2"
|
||||||
|
local method="$3"
|
||||||
|
local url="$4"
|
||||||
|
local expected_status="$5"
|
||||||
|
local body="${6:-}"
|
||||||
|
local out_file="${TMP_DIR}/${name}.body"
|
||||||
|
local status_file="${TMP_DIR}/${name}.status"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== ${name} ====="
|
||||||
|
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${api_key}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" \
|
||||||
|
--data "${body}" > "${status_file}"
|
||||||
|
else
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "X-API-Key: ${api_key}" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" > "${status_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local actual_status
|
||||||
|
actual_status="$(python3 - "$status_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "[${method}] ${url} -> ${actual_status}"
|
||||||
|
python3 - "$out_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||||
|
fail "Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
request_without_api_key() {
|
||||||
|
local name="$1"
|
||||||
|
local method="$2"
|
||||||
|
local url="$3"
|
||||||
|
local expected_status="$4"
|
||||||
|
local body="${5:-}"
|
||||||
|
local out_file="${TMP_DIR}/${name}.body"
|
||||||
|
local status_file="${TMP_DIR}/${name}.status"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== ${name} ====="
|
||||||
|
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" \
|
||||||
|
--data "${body}" > "${status_file}"
|
||||||
|
else
|
||||||
|
curl -sS \
|
||||||
|
-X "${method}" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
"${url}" > "${status_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local actual_status
|
||||||
|
actual_status="$(python3 - "$status_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "[${method}] ${url} -> ${actual_status}"
|
||||||
|
python3 - "$out_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||||
|
fail "Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_svg() {
|
||||||
|
local name="$1"
|
||||||
|
local upload_filename="$2"
|
||||||
|
local out_file="${TMP_DIR}/${name}.body"
|
||||||
|
local status_file="${TMP_DIR}/${name}.status"
|
||||||
|
|
||||||
|
require_fixture_svg
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== ${name} ====="
|
||||||
|
|
||||||
|
curl -sS \
|
||||||
|
-X POST \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
-F "file=@${FIXTURE_SVG_PATH};filename=${upload_filename};type=image/svg+xml" \
|
||||||
|
"${API_URL}/api/v1/schemes/upload" > "${status_file}"
|
||||||
|
|
||||||
|
local actual_status
|
||||||
|
actual_status="$(python3 - "$status_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "[POST] ${API_URL}/api/v1/schemes/upload -> ${actual_status}"
|
||||||
|
python3 - "$out_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${actual_status}" != "200" ]]; then
|
||||||
|
fail "Upload failed for ${upload_filename}: expected 200, got ${actual_status}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_file_expect_status() {
|
||||||
|
local name="$1"
|
||||||
|
local file_path="$2"
|
||||||
|
local upload_filename="$3"
|
||||||
|
local content_type="$4"
|
||||||
|
local expected_status="$5"
|
||||||
|
local out_file="${TMP_DIR}/${name}.body"
|
||||||
|
local status_file="${TMP_DIR}/${name}.status"
|
||||||
|
|
||||||
|
if [[ ! -f "${file_path}" ]]; then
|
||||||
|
fail "Upload fixture file not found: ${file_path}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== ${name} ====="
|
||||||
|
|
||||||
|
curl -sS \
|
||||||
|
-X POST \
|
||||||
|
-H "X-API-Key: ${API_KEY}" \
|
||||||
|
-o "${out_file}" \
|
||||||
|
-w "%{http_code}" \
|
||||||
|
-F "file=@${file_path};filename=${upload_filename};type=${content_type}" \
|
||||||
|
"${API_URL}/api/v1/schemes/upload" > "${status_file}"
|
||||||
|
|
||||||
|
local actual_status
|
||||||
|
actual_status="$(python3 - "$status_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "[POST] ${API_URL}/api/v1/schemes/upload -> ${actual_status}"
|
||||||
|
python3 - "$out_file" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
PY
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||||
|
fail "Upload failed for ${upload_filename}: expected ${expected_status}, got ${actual_status}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
json_get() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
python3 - "$file" "$expr" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
expr = sys.argv[2]
|
||||||
|
|
||||||
|
def apply_selector(value, selector):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if selector == "LAST":
|
||||||
|
return value[-1] if value else None
|
||||||
|
if selector.isdigit():
|
||||||
|
idx = int(selector)
|
||||||
|
return value[idx] if len(value) > idx else None
|
||||||
|
|
||||||
|
match = re.fullmatch(r"([^!=]+?)(==|!=)(.+)", selector)
|
||||||
|
if not match:
|
||||||
|
return value[0] if value else None
|
||||||
|
|
||||||
|
key, op, raw_expected = match.groups()
|
||||||
|
key = key.strip()
|
||||||
|
raw_expected = raw_expected.strip()
|
||||||
|
if raw_expected == "null":
|
||||||
|
expected = None
|
||||||
|
else:
|
||||||
|
expected = raw_expected
|
||||||
|
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_value = item.get(key)
|
||||||
|
matched = item_value == expected if op == "==" else item_value != expected
|
||||||
|
if matched:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = data
|
||||||
|
for part in expr.split("."):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part.startswith("[") and part.endswith("]"):
|
||||||
|
value = apply_selector(value, part[1:-1])
|
||||||
|
elif part.isdigit():
|
||||||
|
idx = int(part)
|
||||||
|
value = value[idx] if value is not None and len(value) > idx else None
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
value = value.get(part)
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
print("true" if value else "false")
|
||||||
|
elif value is None:
|
||||||
|
print("")
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
print(json.dumps(value, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(value)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
json_len() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
python3 - "$file" "$expr" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
expr = sys.argv[2]
|
||||||
|
value = data
|
||||||
|
for part in expr.split("."):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part.isdigit():
|
||||||
|
value = value[int(part)]
|
||||||
|
else:
|
||||||
|
value = value.get(part) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
print(0)
|
||||||
|
elif isinstance(value, (list, dict, str)):
|
||||||
|
print(len(value))
|
||||||
|
else:
|
||||||
|
print(0)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_eq() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local expected="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_get "${file}" "${expr}")"
|
||||||
|
if [[ "${actual}" != "${expected}" ]]; then
|
||||||
|
fail "${expr}: expected '${expected}', got '${actual}'"
|
||||||
|
fi
|
||||||
|
echo "[OK] ${expr}=${actual}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_int_eq() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local expected="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_get "${file}" "${expr}")"
|
||||||
|
if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then
|
||||||
|
fail "${expr}: expected integer, got '${actual}'"
|
||||||
|
fi
|
||||||
|
if (( actual != expected )); then
|
||||||
|
fail "${expr}: expected ${expected}, got ${actual}"
|
||||||
|
fi
|
||||||
|
echo "[OK] ${expr}=${actual}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_int_gt() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local threshold="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_get "${file}" "${expr}")"
|
||||||
|
if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then
|
||||||
|
fail "${expr}: expected integer, got '${actual}'"
|
||||||
|
fi
|
||||||
|
if (( actual <= threshold )); then
|
||||||
|
fail "${expr}: expected > ${threshold}, got ${actual}"
|
||||||
|
fi
|
||||||
|
echo "[OK] ${expr}=${actual} (> ${threshold})"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_int_ge() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local threshold="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_get "${file}" "${expr}")"
|
||||||
|
if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then
|
||||||
|
fail "${expr}: expected integer, got '${actual}'"
|
||||||
|
fi
|
||||||
|
if (( actual < threshold )); then
|
||||||
|
fail "${expr}: expected >= ${threshold}, got ${actual}"
|
||||||
|
fi
|
||||||
|
echo "[OK] ${expr}=${actual} (>= ${threshold})"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_len_eq() {
|
||||||
|
local file="$1"
|
||||||
|
local expr="$2"
|
||||||
|
local expected="$3"
|
||||||
|
local actual
|
||||||
|
actual="$(json_len "${file}" "${expr}")"
|
||||||
|
if (( actual != expected )); then
|
||||||
|
fail "len(${expr}): expected ${expected}, got ${actual}"
|
||||||
|
fi
|
||||||
|
echo "[OK] len(${expr})=${actual}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_file_contains() {
|
||||||
|
local file="$1"
|
||||||
|
local needle="$2"
|
||||||
|
if ! python3 - "$file" "$needle" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
haystack = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
needle = sys.argv[2]
|
||||||
|
if needle not in haystack:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
fail "Expected '${needle}' in ${file}"
|
||||||
|
fi
|
||||||
|
echo "[OK] found '${needle}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload() {
|
||||||
|
local scenario_prefix="$1"
|
||||||
|
local stamp
|
||||||
|
stamp="$(date +%s)-$$"
|
||||||
|
FRESH_SCHEME_NAME="${scenario_prefix}-${stamp}"
|
||||||
|
local upload_filename="${FRESH_SCHEME_NAME}.svg"
|
||||||
|
|
||||||
|
upload_svg "upload_${scenario_prefix}" "${upload_filename}"
|
||||||
|
request "schemes_after_upload_${scenario_prefix}" "GET" "${API_URL}/api/v1/schemes?limit=200&offset=0" "200"
|
||||||
|
|
||||||
|
if ! SCHEME_ID="$(python3 - "${TMP_DIR}/schemes_after_upload_${scenario_prefix}.body" "${FRESH_SCHEME_NAME}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
target_name = sys.argv[2]
|
||||||
|
for item in payload.get("items", []):
|
||||||
|
if item.get("name") == target_name:
|
||||||
|
print(item["scheme_id"])
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
)"; then
|
||||||
|
fail "Unable to resolve uploaded scheme_id for ${FRESH_SCHEME_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "FRESH_SCHEME_NAME=${FRESH_SCHEME_NAME}"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
|
}
|
||||||
133
backend/scripts/smoke_core.sh
Normal file
133
backend/scripts/smoke_core.sh
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
request "ping" "GET" "${API_URL}/api/v1/ping" "200"
|
||||||
|
request "db_ping" "GET" "${API_URL}/api/v1/db/ping" "200"
|
||||||
|
request "manifest" "GET" "${API_URL}/api/v1/manifest" "200"
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-core"
|
||||||
|
|
||||||
|
request "scheme_detail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail.body" "scheme_id" "${SCHEME_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail.body" "name" "${FRESH_SCHEME_NAME}"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail.body" "status" "draft"
|
||||||
|
|
||||||
|
request "scheme_versions" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/versions?limit=20&offset=0" "200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/scheme_versions.body" "items" "1"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
CURRENT_STATUS="$(json_get "${TMP_DIR}/scheme_current.body" "status")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
echo "CURRENT_STATUS=${CURRENT_STATUS}"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current.body" "status" "draft"
|
||||||
|
|
||||||
|
request "editor_context" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/editor_context.body" "current_scheme_version_id" "${CURRENT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/editor_context.body" "current_is_draft" "true"
|
||||||
|
|
||||||
|
request "ensure_draft" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure" "200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
DRAFT_CREATED="$(json_get "${TMP_DIR}/ensure_draft.body" "created")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
echo "DRAFT_CREATED=${DRAFT_CREATED}"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft.body" "scheme_version_id" "${CURRENT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft.body" "created" "false"
|
||||||
|
|
||||||
|
request "draft_summary" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_structure" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_validation" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/validation?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_compare_preview" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_summary.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_structure.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/draft_compare_preview.body" "draft_scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
|
||||||
|
TOTAL_SEATS="$(json_get "${TMP_DIR}/draft_summary.body" "total_seats")"
|
||||||
|
echo "TOTAL_SEATS=${TOTAL_SEATS}"
|
||||||
|
|
||||||
|
read -r SEAT_RECORD_ID SECTOR_RECORD_ID GROUP_RECORD_ID EXPLAIN_SEAT_ID <<EOF
|
||||||
|
$(python3 - "${TMP_DIR}/draft_structure.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seats = payload.get("seats", [])
|
||||||
|
sectors = payload.get("sectors", [])
|
||||||
|
groups = payload.get("groups", [])
|
||||||
|
|
||||||
|
seat_with_id = next((seat for seat in seats if seat.get("seat_id")), None)
|
||||||
|
if seat_with_id is None:
|
||||||
|
raise SystemExit("No seat with seat_id found in fresh draft structure")
|
||||||
|
|
||||||
|
print(
|
||||||
|
seat_with_id["seat_record_id"],
|
||||||
|
sectors[0]["sector_record_id"],
|
||||||
|
groups[0]["group_record_id"],
|
||||||
|
seat_with_id["seat_id"],
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "SEAT_RECORD_ID=${SEAT_RECORD_ID}"
|
||||||
|
echo "SECTOR_RECORD_ID=${SECTOR_RECORD_ID}"
|
||||||
|
echo "GROUP_RECORD_ID=${GROUP_RECORD_ID}"
|
||||||
|
echo "EXPLAIN_SEAT_ID=${EXPLAIN_SEAT_ID}"
|
||||||
|
|
||||||
|
request "stale_draft_conflict" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" "409"
|
||||||
|
assert_json_eq "${TMP_DIR}/stale_draft_conflict.body" "detail.code" "stale_draft_version"
|
||||||
|
|
||||||
|
request "draft_seat_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_sector_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_group_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups/records/${GROUP_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "draft_unknown_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/deadbeefdeadbeefdeadbeefdeadbeef" "404"
|
||||||
|
|
||||||
|
request "current_sectors" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/sectors" "200"
|
||||||
|
request "current_groups" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/groups" "200"
|
||||||
|
request "current_seats" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats" "200"
|
||||||
|
request "display_meta" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/svg/display/meta" "200"
|
||||||
|
|
||||||
|
request "pricing_bundle" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" "200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle.body" "categories" "0"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle.body" "rules" "0"
|
||||||
|
|
||||||
|
request "pricing_coverage" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/coverage" "200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_coverage.body" "priced_seats" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_coverage.body" "unpriced_seats" "${TOTAL_SEATS}"
|
||||||
|
|
||||||
|
request "pricing_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/unpriced-seats" "200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_unpriced.body" "total" "${TOTAL_SEATS}"
|
||||||
|
|
||||||
|
request "pricing_explain_empty" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${EXPLAIN_SEAT_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_empty.body" "has_price" "false"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_empty.body" "reason_code" "no_price_rule"
|
||||||
|
|
||||||
|
request "pricing_rule_diagnostics" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules/diagnostics" "200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_rule_diagnostics.body" "summary.total_rules" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_rule_diagnostics.body" "summary.active_rules_count" "0"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_rule_diagnostics.body" "summary.matched_seats_total" "0"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_rule_diagnostics.body" "items" "0"
|
||||||
|
|
||||||
|
request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200"
|
||||||
|
assert_json_int_ge "${TMP_DIR}/audit_trail.body" "total" "0"
|
||||||
|
|
||||||
|
log "editor mutation regression"
|
||||||
|
API_URL="${API_URL}" API_KEY="${API_KEY}" SCHEME_ID="${SCHEME_ID}" \
|
||||||
|
bash "${SCRIPT_DIR}/editor_mutation_regression.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke core completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
68
backend/scripts/smoke_lifecycle_negative.sh
Normal file
68
backend/scripts/smoke_lifecycle_negative.sh
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "${REPO_ROOT}/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-lifecycle-negative"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
|
||||||
|
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "rollback_nonexistent_version" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/rollback" \
|
||||||
|
"404" \
|
||||||
|
"{\"target_version_number\":999}"
|
||||||
|
assert_file_contains "${TMP_DIR}/rollback_nonexistent_version.body" "Target scheme version not found"
|
||||||
|
|
||||||
|
request "ensure_draft_stale_current_version" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" \
|
||||||
|
"409"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft_stale_current_version.body" "detail.code" "stale_current_version"
|
||||||
|
|
||||||
|
request "publish_stale_expected_version" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" \
|
||||||
|
"409"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_stale_expected_version.body" "detail.code" "publish_not_ready"
|
||||||
|
assert_file_contains "${TMP_DIR}/publish_stale_expected_version.body" "\"actual_scheme_version_id\":\"${CURRENT_VERSION_ID}\""
|
||||||
|
|
||||||
|
INCONSISTENT_VERSION_NUMBER="999"
|
||||||
|
UPDATED_VERSION_NUMBER="$(docker compose exec -T postgres psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -Atc "update schemes set current_version_number=${INCONSISTENT_VERSION_NUMBER} where scheme_id='${SCHEME_ID}' and current_version_number=1 returning current_version_number;" | python3 -c 'import sys; lines=[line.strip() for line in sys.stdin.read().splitlines() if line.strip()]; print(lines[0] if lines else "")')"
|
||||||
|
if [[ "${UPDATED_VERSION_NUMBER}" != "${INCONSISTENT_VERSION_NUMBER}" ]]; then
|
||||||
|
fail "Failed to introduce temporary current_version_inconsistent state for ${SCHEME_ID}"
|
||||||
|
fi
|
||||||
|
echo "[OK] introduced temporary current_version_inconsistent state for ${SCHEME_ID}"
|
||||||
|
|
||||||
|
restore_current_version_pointer() {
|
||||||
|
docker compose exec -T postgres psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -Atc "update schemes set current_version_number=1 where scheme_id='${SCHEME_ID}' and current_version_number=${INCONSISTENT_VERSION_NUMBER};" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'restore_current_version_pointer; rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
request "current_version_inconsistent" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/current" \
|
||||||
|
"409"
|
||||||
|
assert_json_eq "${TMP_DIR}/current_version_inconsistent.body" "detail.code" "current_version_inconsistent"
|
||||||
|
assert_file_contains "${TMP_DIR}/current_version_inconsistent.body" "\"current_version_number\":${INCONSISTENT_VERSION_NUMBER}"
|
||||||
|
|
||||||
|
restore_current_version_pointer
|
||||||
|
|
||||||
|
request "scheme_current_restored" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_restored.body" "scheme_version_id" "${CURRENT_VERSION_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_current_restored.body" "version_number" "1"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke lifecycle negative completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
117
backend/scripts/smoke_pricing_publish.sh
Normal file
117
backend/scripts/smoke_pricing_publish.sh
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-pricing-publish"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
request "ensure_draft" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure" "200"
|
||||||
|
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
|
||||||
|
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
|
||||||
|
|
||||||
|
request "draft_structure" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
|
||||||
|
read -r PRICED_SEAT_ID UNPRICED_SEAT_ID <<EOF
|
||||||
|
$(python3 - "${TMP_DIR}/draft_structure.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat_ids = [seat["seat_id"] for seat in payload.get("seats", []) if seat.get("seat_id")]
|
||||||
|
if len(seat_ids) < 2:
|
||||||
|
raise SystemExit("Fixture must contain at least two seats with seat_id for pricing smoke")
|
||||||
|
print(seat_ids[0], seat_ids[1])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "PRICED_SEAT_ID=${PRICED_SEAT_ID}"
|
||||||
|
echo "UNPRICED_SEAT_ID=${UNPRICED_SEAT_ID}"
|
||||||
|
|
||||||
|
STAMP="$(date +%s)-$$"
|
||||||
|
PRICING_CATEGORY_NAME="smoke-pricing-${STAMP}"
|
||||||
|
PRICING_CATEGORY_CODE="SMOKE_${STAMP}"
|
||||||
|
|
||||||
|
request "create_pricing_category" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${PRICING_CATEGORY_NAME}\",\"code\":\"${PRICING_CATEGORY_CODE}\"}"
|
||||||
|
PRICING_CATEGORY_ID="$(json_get "${TMP_DIR}/create_pricing_category.body" "pricing_category_id")"
|
||||||
|
echo "PRICING_CATEGORY_ID=${PRICING_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_price_rule" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"pricing_category_id\":\"${PRICING_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${PRICED_SEAT_ID}\",\"amount\":\"1234.56\",\"currency\":\"RUB\"}"
|
||||||
|
PRICE_RULE_ID="$(json_get "${TMP_DIR}/create_price_rule.body" "price_rule_id")"
|
||||||
|
echo "PRICE_RULE_ID=${PRICE_RULE_ID}"
|
||||||
|
|
||||||
|
request "pricing_bundle" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" "200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle.body" "categories" "1"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/pricing_bundle.body" "rules" "1"
|
||||||
|
|
||||||
|
request "pricing_coverage" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/coverage" "200"
|
||||||
|
assert_json_int_gt "${TMP_DIR}/pricing_coverage.body" "priced_seats" "0"
|
||||||
|
assert_json_int_gt "${TMP_DIR}/pricing_coverage.body" "unpriced_seats" "0"
|
||||||
|
|
||||||
|
request "pricing_explain_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${PRICED_SEAT_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_priced.body" "has_price" "true"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_priced.body" "reason_code" "ok"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_priced.body" "matched_rule.matched_rule_level" "seat"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_priced.body" "matched_rule.matched_target_ref" "${PRICED_SEAT_ID}"
|
||||||
|
|
||||||
|
request "pricing_explain_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${UNPRICED_SEAT_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_unpriced.body" "has_price" "false"
|
||||||
|
assert_json_eq "${TMP_DIR}/pricing_explain_unpriced.body" "reason_code" "no_price_rule"
|
||||||
|
|
||||||
|
request "pricing_rule_diagnostics" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules/diagnostics" "200"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/pricing_rule_diagnostics.body" "summary.total_rules" "1"
|
||||||
|
assert_json_int_gt "${TMP_DIR}/pricing_rule_diagnostics.body" "summary.matched_seats_total" "0"
|
||||||
|
|
||||||
|
request "seat_price" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats/${PRICED_SEAT_ID}/price" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_price.body" "matched_rule_level" "seat"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_price.body" "matched_target_ref" "${PRICED_SEAT_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/seat_price.body" "amount" "1234.56"
|
||||||
|
|
||||||
|
request "test_mode_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${PRICED_SEAT_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/test_mode_priced.body" "has_price" "true"
|
||||||
|
assert_json_eq "${TMP_DIR}/test_mode_priced.body" "selectable" "true"
|
||||||
|
|
||||||
|
request "test_mode_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${UNPRICED_SEAT_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/test_mode_unpriced.body" "has_price" "false"
|
||||||
|
assert_json_eq "${TMP_DIR}/test_mode_unpriced.body" "reason_code" "no_price_rule"
|
||||||
|
|
||||||
|
request "draft_pricing_snapshot" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "publish_readiness" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-readiness?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_readiness.body" "snapshot.available" "true"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_readiness.body" "readiness.is_ready_to_publish" "true"
|
||||||
|
|
||||||
|
request "publish_preview_refresh" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
request "publish_preview_cached" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
|
||||||
|
request "publish_scheme" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_scheme.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_scheme.body" "status" "published"
|
||||||
|
|
||||||
|
request "scheme_detail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail.body" "status" "published"
|
||||||
|
|
||||||
|
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current.body" "status" "published"
|
||||||
|
|
||||||
|
request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200"
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.published\""
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke pricing/publish completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
43
backend/scripts/smoke_regression.sh
Executable file
43
backend/scripts/smoke_regression.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo "===== smoke core ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_core.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke pricing/publish ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_pricing_publish.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke version lifecycle ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_version_lifecycle.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke lifecycle negative ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_lifecycle_negative.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke admin ops ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_admin_ops.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke authz admin all ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_authz_admin_all.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke auth negative ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_auth_negative.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke artifact corruption ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_artifact_corruption.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== smoke upload negative ====="
|
||||||
|
bash "${SCRIPT_DIR}/smoke_upload_negative.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke regression orchestration completed successfully"
|
||||||
53
backend/scripts/smoke_upload_negative.sh
Normal file
53
backend/scripts/smoke_upload_negative.sh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
require_fixture_svg
|
||||||
|
|
||||||
|
request "manifest" "GET" "${API_URL}/api/v1/manifest" "200"
|
||||||
|
MAX_FILE_SIZE_BYTES="$(json_get "${TMP_DIR}/manifest.body" "svg_limits.max_file_size_bytes")"
|
||||||
|
echo "MAX_FILE_SIZE_BYTES=${MAX_FILE_SIZE_BYTES}"
|
||||||
|
|
||||||
|
EMPTY_SVG_PATH="${TMP_DIR}/empty.svg"
|
||||||
|
NON_SVG_PATH="${TMP_DIR}/not-svg.txt"
|
||||||
|
SVG_BODY_WRONG_EXTENSION_PATH="${TMP_DIR}/svg-body.txt"
|
||||||
|
OVERSIZE_SVG_PATH="${TMP_DIR}/oversize.svg"
|
||||||
|
|
||||||
|
: > "${EMPTY_SVG_PATH}"
|
||||||
|
printf 'plain text payload\n' > "${NON_SVG_PATH}"
|
||||||
|
cp "${FIXTURE_SVG_PATH}" "${SVG_BODY_WRONG_EXTENSION_PATH}"
|
||||||
|
|
||||||
|
python3 - "${OVERSIZE_SVG_PATH}" "${MAX_FILE_SIZE_BYTES}" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
output_path = Path(sys.argv[1])
|
||||||
|
max_file_size_bytes = int(sys.argv[2])
|
||||||
|
payload = "<svg xmlns='http://www.w3.org/2000/svg'>" + (" " * max_file_size_bytes) + "</svg>"
|
||||||
|
output_path.write_text(payload, encoding="utf-8")
|
||||||
|
if output_path.stat().st_size <= max_file_size_bytes:
|
||||||
|
raise SystemExit("Generated oversize SVG is not larger than configured limit")
|
||||||
|
PY
|
||||||
|
|
||||||
|
upload_file_expect_status "upload_empty_file" "${EMPTY_SVG_PATH}" "empty.svg" "image/svg+xml" "400"
|
||||||
|
assert_file_contains "${TMP_DIR}/upload_empty_file.body" "Uploaded file is empty"
|
||||||
|
|
||||||
|
upload_file_expect_status "upload_non_svg_text_plain" "${NON_SVG_PATH}" "not-svg.txt" "text/plain" "400"
|
||||||
|
assert_file_contains "${TMP_DIR}/upload_non_svg_text_plain.body" "Only SVG files are allowed"
|
||||||
|
|
||||||
|
upload_file_expect_status "upload_svg_body_wrong_extension" "${SVG_BODY_WRONG_EXTENSION_PATH}" "valid-svg-body.txt" "text/plain" "400"
|
||||||
|
assert_file_contains "${TMP_DIR}/upload_svg_body_wrong_extension.body" "Only SVG files are allowed"
|
||||||
|
|
||||||
|
upload_file_expect_status "upload_oversize_svg" "${OVERSIZE_SVG_PATH}" "oversize.svg" "image/svg+xml" "413"
|
||||||
|
assert_file_contains "${TMP_DIR}/upload_oversize_svg.body" "SVG file exceeds configured size limit"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke upload negative completed successfully"
|
||||||
236
backend/scripts/smoke_version_lifecycle.sh
Normal file
236
backend/scripts/smoke_version_lifecycle.sh
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
# shellcheck source=backend/scripts/smoke_common.sh
|
||||||
|
source "${SCRIPT_DIR}/smoke_common.sh"
|
||||||
|
|
||||||
|
wait_for_health
|
||||||
|
|
||||||
|
create_fresh_scheme_from_upload "smoke-version-lifecycle"
|
||||||
|
|
||||||
|
request "scheme_detail_initial" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail_initial.body" "status" "draft"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_detail_initial.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_current_initial" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
VERSION1_ID="$(json_get "${TMP_DIR}/scheme_current_initial.body" "scheme_version_id")"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_current_initial.body" "version_number" "1"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_initial.body" "status" "draft"
|
||||||
|
echo "VERSION1_ID=${VERSION1_ID}"
|
||||||
|
|
||||||
|
request "ensure_draft_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft_v1.body" "scheme_version_id" "${VERSION1_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft_v1.body" "created" "false"
|
||||||
|
|
||||||
|
request "draft_structure_v1" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
read -r VERSION1_SEAT_RECORD_ID VERSION1_SEAT_ID ORIGINAL_ROW_LABEL ORIGINAL_SEAT_NUMBER <<EOF
|
||||||
|
$(python3 - "${TMP_DIR}/draft_structure_v1.body" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat = next((item for item in payload.get("seats", []) if item.get("seat_id")), None)
|
||||||
|
if seat is None:
|
||||||
|
raise SystemExit("No seat with seat_id found in version 1 draft structure")
|
||||||
|
print(
|
||||||
|
seat["seat_record_id"],
|
||||||
|
seat["seat_id"],
|
||||||
|
seat.get("row_label") or "__EMPTY__",
|
||||||
|
seat.get("seat_number") or "__EMPTY__",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "VERSION1_SEAT_RECORD_ID=${VERSION1_SEAT_RECORD_ID}"
|
||||||
|
echo "VERSION1_SEAT_ID=${VERSION1_SEAT_ID}"
|
||||||
|
echo "ORIGINAL_ROW_LABEL=${ORIGINAL_ROW_LABEL}"
|
||||||
|
echo "ORIGINAL_SEAT_NUMBER=${ORIGINAL_SEAT_NUMBER}"
|
||||||
|
|
||||||
|
STAMP="$(date +%s)-$$"
|
||||||
|
PRICING_CATEGORY_NAME="lifecycle-publish-${STAMP}"
|
||||||
|
PRICING_CATEGORY_CODE="LIFECYCLE_${STAMP}"
|
||||||
|
UPDATED_ROW_LABEL="LC-${STAMP}"
|
||||||
|
|
||||||
|
request "create_pricing_category_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"name\":\"${PRICING_CATEGORY_NAME}\",\"code\":\"${PRICING_CATEGORY_CODE}\"}"
|
||||||
|
PRICING_CATEGORY_ID="$(json_get "${TMP_DIR}/create_pricing_category_v1.body" "pricing_category_id")"
|
||||||
|
echo "PRICING_CATEGORY_ID=${PRICING_CATEGORY_ID}"
|
||||||
|
|
||||||
|
request "create_price_rule_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"pricing_category_id\":\"${PRICING_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${VERSION1_SEAT_ID}\",\"amount\":\"777.00\",\"currency\":\"RUB\"}"
|
||||||
|
PRICE_RULE_ID="$(json_get "${TMP_DIR}/create_price_rule_v1.body" "price_rule_id")"
|
||||||
|
echo "PRICE_RULE_ID=${PRICE_RULE_ID}"
|
||||||
|
|
||||||
|
request "draft_pricing_snapshot_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
request "publish_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_v1.body" "status" "published"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_v1.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_detail_after_publish_v1" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail_after_publish_v1.body" "status" "published"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_publish_v1.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_current_after_publish_v1" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "scheme_version_id" "${VERSION1_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "version_number" "1"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "status" "published"
|
||||||
|
|
||||||
|
request "ensure_draft_v2" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
VERSION2_ID="$(json_get "${TMP_DIR}/ensure_draft_v2.body" "scheme_version_id")"
|
||||||
|
echo "VERSION2_ID=${VERSION2_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft_v2.body" "created" "true"
|
||||||
|
assert_json_eq "${TMP_DIR}/ensure_draft_v2.body" "source_scheme_version_id" "${VERSION1_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/ensure_draft_v2.body" "version_number" "2"
|
||||||
|
|
||||||
|
request "draft_structure_v2_before_mutation" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
VERSION2_SEAT_RECORD_ID="$(python3 - "${TMP_DIR}/draft_structure_v2_before_mutation.body" "${VERSION1_SEAT_ID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat_id = sys.argv[2]
|
||||||
|
seat = next((item for item in payload.get("seats", []) if item.get("seat_id") == seat_id), None)
|
||||||
|
if seat is None:
|
||||||
|
raise SystemExit("Target seat_id not found in version 2 draft structure")
|
||||||
|
print(seat["seat_record_id"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "VERSION2_SEAT_RECORD_ID=${VERSION2_SEAT_RECORD_ID}"
|
||||||
|
|
||||||
|
request "patch_seat_v2" "PATCH" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${VERSION2_SEAT_RECORD_ID}?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200" \
|
||||||
|
"{\"row_label\":\"${UPDATED_ROW_LABEL}\"}"
|
||||||
|
assert_json_eq "${TMP_DIR}/patch_seat_v2.body" "row_label" "${UPDATED_ROW_LABEL}"
|
||||||
|
|
||||||
|
request "draft_structure_v2_after_mutation" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200"
|
||||||
|
assert_file_contains "${TMP_DIR}/draft_structure_v2_after_mutation.body" "\"row_label\":\"${UPDATED_ROW_LABEL}\""
|
||||||
|
|
||||||
|
request "draft_compare_preview_v2" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200"
|
||||||
|
assert_file_contains "${TMP_DIR}/draft_compare_preview_v2.body" "\"status\":\"changed\""
|
||||||
|
|
||||||
|
request "draft_pricing_snapshot_v2" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
request "publish_v2" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=${VERSION2_ID}" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/publish_v2.body" "status" "published"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/publish_v2.body" "current_version_number" "2"
|
||||||
|
|
||||||
|
request "scheme_detail_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail_after_publish_v2.body" "status" "published"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_publish_v2.body" "current_version_number" "2"
|
||||||
|
|
||||||
|
request "scheme_current_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "scheme_version_id" "${VERSION2_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "version_number" "2"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "status" "published"
|
||||||
|
|
||||||
|
request "scheme_versions_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/versions?limit=20&offset=0" "200"
|
||||||
|
assert_json_len_eq "${TMP_DIR}/scheme_versions_after_publish_v2.body" "items" "2"
|
||||||
|
|
||||||
|
request "rollback_to_v1" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/rollback" \
|
||||||
|
"200" \
|
||||||
|
"{\"target_version_number\":1}"
|
||||||
|
assert_json_eq "${TMP_DIR}/rollback_to_v1.body" "status" "draft"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/rollback_to_v1.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_detail_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail_after_rollback.body" "status" "draft"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_rollback.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_current_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_rollback.body" "scheme_version_id" "${VERSION1_ID}"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_current_after_rollback.body" "version_number" "1"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_current_after_rollback.body" "status" "draft"
|
||||||
|
|
||||||
|
request "editor_context_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/editor_context_after_rollback.body" "current_scheme_version_id" "${VERSION1_ID}"
|
||||||
|
assert_json_eq "${TMP_DIR}/editor_context_after_rollback.body" "current_is_draft" "true"
|
||||||
|
|
||||||
|
request "draft_structure_after_rollback" "GET" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION1_ID}" \
|
||||||
|
"200"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/draft_structure_after_rollback.body" "${VERSION1_SEAT_ID}" "${ORIGINAL_ROW_LABEL}" "${ORIGINAL_SEAT_NUMBER}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
seat_id = sys.argv[2]
|
||||||
|
expected_row_label = sys.argv[3]
|
||||||
|
expected_seat_number = sys.argv[4]
|
||||||
|
|
||||||
|
seat = next((item for item in payload.get("seats", []) if item.get("seat_id") == seat_id), None)
|
||||||
|
if seat is None:
|
||||||
|
raise SystemExit(f"Seat {seat_id} not found after rollback")
|
||||||
|
|
||||||
|
actual_row_label = seat.get("row_label") or "__EMPTY__"
|
||||||
|
actual_seat_number = seat.get("seat_number") or "__EMPTY__"
|
||||||
|
if actual_row_label != expected_row_label:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Rollback row_label mismatch: expected {expected_row_label}, got {actual_row_label}"
|
||||||
|
)
|
||||||
|
if actual_seat_number != expected_seat_number:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Rollback seat_number mismatch: expected {expected_seat_number}, got {actual_seat_number}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
echo "[OK] rollback restored version 1 seat semantics"
|
||||||
|
|
||||||
|
request "unpublish_after_rollback" "POST" \
|
||||||
|
"${API_URL}/api/v1/schemes/${SCHEME_ID}/unpublish" \
|
||||||
|
"200"
|
||||||
|
assert_json_eq "${TMP_DIR}/unpublish_after_rollback.body" "status" "draft"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/unpublish_after_rollback.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "scheme_detail_after_unpublish" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
|
||||||
|
assert_json_eq "${TMP_DIR}/scheme_detail_after_unpublish.body" "status" "draft"
|
||||||
|
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_unpublish.body" "current_version_number" "1"
|
||||||
|
|
||||||
|
request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200"
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.published\""
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.version.created\""
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.rolled_back\""
|
||||||
|
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.unpublished\""
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== done ====="
|
||||||
|
echo "[OK] smoke version lifecycle completed successfully"
|
||||||
|
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
|
||||||
|
echo "VERSION1_ID=${VERSION1_ID}"
|
||||||
|
echo "VERSION2_ID=${VERSION2_ID}"
|
||||||
@@ -25,10 +25,11 @@ services:
|
|||||||
container_name: svg-service
|
container_name: svg-service
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
command: ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${BACKEND_PORT}"]
|
||||||
ports:
|
ports:
|
||||||
- "9020:9020"
|
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/data
|
- ./storage:${STORAGE_ROOT}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Reference in New Issue
Block a user