feat: material alias seeds expansion, bulk product delete, dashboard stats widgets
- Material alias seeds: 95 → 855 aliases covering German variants, DIN standards, Werkstoffnummern, industry terms, English equivalents, polymer abbreviations - Batch product delete/deactivate endpoint (POST /products/batch-delete) - Multi-select UI on Products page with floating action bar - Dashboard: RenderThroughput + MaterialCoverage widgets - Dashboard stats endpoint (GET /admin/dashboard-stats) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,118 @@ from app.models.user import User
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
product_ids: list[uuid.UUID]
|
||||
hard: bool = False
|
||||
|
||||
|
||||
@router.post("/batch-delete", status_code=status.HTTP_200_OK)
|
||||
async def batch_delete_products(
|
||||
body: BatchDeleteRequest,
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete or deactivate multiple products at once.
|
||||
|
||||
When hard=False (default), products are soft-deleted (is_active=False).
|
||||
When hard=True, products and their related data are permanently removed
|
||||
using the same cleanup logic as the single-product delete endpoint.
|
||||
"""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
if not body.product_ids:
|
||||
return {"deleted": 0, "not_found": 0}
|
||||
|
||||
# Deduplicate
|
||||
product_ids = list(set(body.product_ids))
|
||||
|
||||
# Load all products
|
||||
result = await db.execute(
|
||||
select(Product).where(Product.id.in_(product_ids))
|
||||
)
|
||||
products_found = {p.id: p for p in result.scalars().all()}
|
||||
not_found = len(product_ids) - len(products_found)
|
||||
|
||||
if not products_found:
|
||||
return {"deleted": 0, "not_found": not_found}
|
||||
|
||||
if not body.hard:
|
||||
# Soft delete: deactivate all found products
|
||||
for product in products_found.values():
|
||||
product.is_active = False
|
||||
await db.commit()
|
||||
return {"deleted": len(products_found), "not_found": not_found}
|
||||
|
||||
# Hard delete: reuse single-delete cleanup logic per product
|
||||
from app.domains.media.models import MediaAsset
|
||||
from app.core.storage import get_storage
|
||||
|
||||
all_storage_keys: list[str] = []
|
||||
all_result_paths: list[str] = []
|
||||
|
||||
for pid, product in products_found.items():
|
||||
# 1. Collect storage keys from MediaAssets
|
||||
media_result = await db.execute(
|
||||
select(MediaAsset.storage_key).where(MediaAsset.product_id == pid)
|
||||
)
|
||||
all_storage_keys.extend(row[0] for row in media_result.all() if row[0])
|
||||
|
||||
# 2. Collect render result paths from order lines
|
||||
ol_result = await db.execute(
|
||||
select(OrderLine.result_path).where(
|
||||
OrderLine.product_id == pid,
|
||||
OrderLine.result_path.isnot(None),
|
||||
)
|
||||
)
|
||||
all_result_paths.extend(row[0] for row in ol_result.all() if row[0])
|
||||
|
||||
# 3. Check if CadFile is orphaned
|
||||
cad_file_id = product.cad_file_id
|
||||
orphan_cad = False
|
||||
if cad_file_id:
|
||||
other_count = await db.execute(
|
||||
select(func.count(Product.id)).where(
|
||||
Product.cad_file_id == cad_file_id,
|
||||
Product.id.notin_([p for p in products_found]),
|
||||
)
|
||||
)
|
||||
orphan_cad = (other_count.scalar() or 0) == 0
|
||||
|
||||
# 4. Delete order_lines
|
||||
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == pid))
|
||||
|
||||
# 5. Delete orphaned CadFile
|
||||
if orphan_cad and cad_file_id:
|
||||
cad_media_result = await db.execute(
|
||||
select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id)
|
||||
)
|
||||
all_storage_keys.extend(row[0] for row in cad_media_result.all() if row[0])
|
||||
product.cad_file_id = None
|
||||
await db.flush()
|
||||
await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id))
|
||||
|
||||
# 6. Delete product (cascades MediaAsset + ProductRenderPosition)
|
||||
await db.delete(product)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 7. Clean up storage files (best-effort, after commit)
|
||||
storage = get_storage()
|
||||
for key in all_storage_keys:
|
||||
try:
|
||||
storage.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
for path in all_result_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"deleted": len(products_found), "not_found": not_found}
|
||||
|
||||
|
||||
def _best_render_url(product: Product, priority: list[str]) -> str | None:
|
||||
"""Walk the priority list and return the first available render URL.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user