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:
2026-03-14 12:45:41 +01:00
parent 4f4a128e08
commit f0dd952f63
10 changed files with 1470 additions and 54 deletions
+112
View File
@@ -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.