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:
greebo
2026-03-19 22:54:12 +03:00
parent 127c5bff71
commit 0f9c2a1cbd
16 changed files with 551 additions and 235 deletions

View File

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

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

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

View 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]

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