Files
Hartmut f5ca91ee02 feat: layout hamburger, media browser filters+previews, billing fixes
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width
- Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable
- Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files
- Admin: "Import Existing Media" button → POST /api/admin/import-media-assets
- Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path
- SSE task logs: task_logs.py core + router, LiveRenderLog component
- CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render
- render-worker: add trimesh layer to Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:09:27 +01:00

106 lines
3.4 KiB
Python

"""Billing router — Invoice CRUD + PDF."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.utils.auth import require_admin_or_pm
from app.domains.billing.schemas import InvoiceCreate, InvoiceOut, InvoiceStatusUpdate
from app.domains.billing.service import (
create_invoice, get_invoices, get_invoice,
update_invoice_status, delete_invoice, render_pdf,
)
# Keep the old pricing router re-export for backward compat
from app.api.routers.pricing import router as pricing_router
invoice_router = APIRouter(prefix="/billing", tags=["billing"])
@invoice_router.get("/invoices", response_model=list[InvoiceOut])
async def list_invoices(
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
return await get_invoices(db, skip=skip, limit=limit)
@invoice_router.post("/invoices", response_model=InvoiceOut, status_code=status.HTTP_201_CREATED)
async def create_invoice_endpoint(
body: InvoiceCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
tenant_id = getattr(current_user, 'tenant_id', None)
return await create_invoice(
db,
tenant_id=tenant_id,
order_line_ids=body.order_line_ids,
notes=body.notes,
issued_at=body.issued_at,
due_at=body.due_at,
vat_rate=body.vat_rate,
currency=body.currency,
)
@invoice_router.get("/invoices/{invoice_id}", response_model=InvoiceOut)
async def get_invoice_endpoint(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
inv = await get_invoice(db, invoice_id)
if not inv:
raise HTTPException(status_code=404, detail="Invoice not found")
return inv
@invoice_router.patch("/invoices/{invoice_id}", response_model=InvoiceOut)
async def update_invoice_status_endpoint(
invoice_id: uuid.UUID,
body: InvoiceStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
inv = await update_invoice_status(db, invoice_id, body.status)
if not inv:
raise HTTPException(status_code=404, detail="Invoice not found")
return inv
@invoice_router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
key = await render_pdf(db, invoice_id)
if not key:
raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)")
from app.core.storage import get_storage
pdf_bytes = get_storage().download_bytes(key)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"},
)
@invoice_router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_invoice_endpoint(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
ok = await delete_invoice(db, invoice_id)
if not ok:
raise HTTPException(status_code=400, detail="Only draft invoices can be deleted")
__all__ = ["invoice_router", "pricing_router"]