feat(backend): add operational smoke tooling and safe pricing cleanup endpoints
- add backend README and refresh API map and smoke regression docs - add full backend smoke regression script - add admin pricing cleanup preview and dry-run endpoints - add helper script for test pricing cleanup - verify typed error contracts, draft flow, publish readiness and preview flows - verify publish preview retention and clean backend startup behavior
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
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.editor import router as editor_router
|
||||
from app.api.routes.pricing import router as pricing_router
|
||||
@@ -22,5 +23,6 @@ router.include_router(pricing_diagnostics_router)
|
||||
router.include_router(test_mode_router)
|
||||
router.include_router(audit_router)
|
||||
router.include_router(admin_router)
|
||||
router.include_router(admin_cleanup_router)
|
||||
router.include_router(editor_router)
|
||||
router.include_router(publish_router)
|
||||
|
||||
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_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_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_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,
|
||||
)
|
||||
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)
|
||||
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]
|
||||
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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user