diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index 67d3227..ac0c3b3 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -12,7 +12,7 @@ from app.models.system_setting import SystemSetting from app.models.cad_file import CadFile, ProcessingStatus from app.models.output_type import OutputType as OutputTypeModel from app.schemas.user import UserOut, UserUpdate, UserCreate -from app.utils.auth import require_admin, hash_password +from app.utils.auth import require_global_admin, hash_password router = APIRouter(prefix="/admin", tags=["admin"]) @@ -122,7 +122,7 @@ class SettingsUpdate(BaseModel): @router.get("/users", response_model=list[UserOut]) async def list_users( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(User).order_by(User.created_at.desc())) @@ -132,7 +132,7 @@ async def list_users( @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED) async def create_user( body: UserCreate, - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(User).where(User.email == body.email)) @@ -155,7 +155,7 @@ async def create_user( async def update_user( user_id: uuid.UUID, body: UserUpdate, - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(User).where(User.id == user_id)) @@ -173,7 +173,7 @@ async def update_user( @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: uuid.UUID, - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(User).where(User.id == user_id)) @@ -241,7 +241,7 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut: @router.get("/settings", response_model=SettingsOut) async def get_settings( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): return _settings_to_out(await _load_settings(db)) @@ -250,7 +250,7 @@ async def get_settings( @router.put("/settings", response_model=SettingsOut) async def update_settings( body: SettingsUpdate, - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS: @@ -373,7 +373,7 @@ async def update_settings( @router.post("/settings/process-unprocessed", status_code=status.HTTP_202_ACCEPTED) async def process_unprocessed_steps( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Queue all STEP files that are not yet completed. @@ -416,7 +416,7 @@ async def process_unprocessed_steps( @router.post("/settings/regenerate-thumbnails", status_code=status.HTTP_202_ACCEPTED) async def regenerate_thumbnails( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Re-queue completed CAD files that are linked to a product for thumbnail regeneration.""" @@ -439,7 +439,7 @@ async def regenerate_thumbnails( @router.get("/settings/orphaned-cad-files") async def get_orphaned_cad_files( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Return count and total disk size of CadFiles not linked to any product.""" @@ -459,7 +459,7 @@ async def get_orphaned_cad_files( @router.post("/settings/cleanup-orphaned-cad-files") async def cleanup_orphaned_cad_files( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Delete CadFile DB records and associated files on disk for all orphaned CadFiles. @@ -504,7 +504,7 @@ async def cleanup_orphaned_cad_files( @router.post("/settings/reextract-metadata", status_code=status.HTTP_202_ACCEPTED) async def reextract_all_metadata( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Re-extract OCC metadata (dimensions, sharp edges) for all completed CAD files. @@ -534,7 +534,7 @@ async def reextract_all_metadata( @router.post("/settings/generate-missing-canonical-scenes", status_code=status.HTTP_202_ACCEPTED) async def generate_missing_canonical_scenes( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Queue canonical scene (geometry GLB + USD master) generation for every completed CAD file that has no gltf_geometry MediaAsset.""" @@ -565,14 +565,28 @@ async def generate_missing_canonical_scenes( @router.post("/settings/generate-missing-usd-masters", status_code=status.HTTP_202_ACCEPTED) async def generate_missing_usd_masters( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): - """Queue USD master export for every completed CAD file that has no usd_master MediaAsset.""" + """Queue USD master export for completed CAD files linked to a product that have no usd_master MediaAsset. + + Only CadFiles referenced by at least one Product are included — orphan CadFiles + (uploaded but never linked to a product) are skipped to avoid unnecessary work. + """ from app.domains.media.models import MediaAsset, MediaAssetType + from app.domains.products.models import Product + + # Only CadFiles that are actually used by a product + product_cad_ids_result = await db.execute( + select(Product.cad_file_id).where(Product.cad_file_id.isnot(None)).distinct() + ) + product_cad_ids = {row[0] for row in product_cad_ids_result.all()} result = await db.execute( - select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed) + select(CadFile).where( + CadFile.processing_status == ProcessingStatus.completed, + CadFile.id.in_(product_cad_ids), + ) ) cad_files = result.scalars().all() @@ -595,7 +609,7 @@ async def generate_missing_usd_masters( @router.post("/settings/recover-stuck-processing", status_code=status.HTTP_200_OK) async def recover_stuck_processing( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'. @@ -629,7 +643,7 @@ async def recover_stuck_processing( @router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK) async def seed_workflows( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), db: AsyncSession = Depends(get_db), ): """Create the standard workflow definitions if they do not already exist.""" @@ -685,7 +699,7 @@ async def seed_workflows( @router.get("/settings/renderer-status") async def renderer_status( - admin: User = Depends(require_admin), + admin: User = Depends(require_global_admin), ): """Check health of renderer services.""" from app.services.render_blender import find_blender, is_blender_available @@ -706,7 +720,7 @@ async def renderer_status( @router.post("/import-media-assets") async def import_existing_media_assets( db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_admin), + current_user: User = Depends(require_global_admin), ): """Import existing cad thumbnails and order line renders as MediaAsset records.""" from app.domains.media.models import MediaAsset, MediaAssetType