From d1c7feacf6125318f18d49734e76c279bfcde63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 11 Mar 2026 10:56:25 +0100 Subject: [PATCH] fix(export_glb): upsert MediaAsset instead of DELETE+INSERT to preserve stable URLs When re-generating a production or geometry GLB, the old approach deleted the existing MediaAsset record and created a new one with a new UUID. Any page that had the old download_url (/api/media/{old-id}/download) cached would then get a 404 when trying to download, because the asset ID no longer existed in the DB. Fix: update the existing MediaAsset record in-place (same UUID, new storage_key) so existing download URLs remain valid after regeneration. Create a new record only if no existing one is found. Applies to both generate_gltf_geometry_task and generate_gltf_production_task. Co-Authored-By: Claude Sonnet 4.6 --- .../app/domains/pipeline/tasks/export_glb.py | 104 +++++++++++------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/backend/app/domains/pipeline/tasks/export_glb.py b/backend/app/domains/pipeline/tasks/export_glb.py index bb4539a..cf6b0e2 100644 --- a/backend/app/domains/pipeline/tasks/export_glb.py +++ b/backend/app/domains/pipeline/tasks/export_glb.py @@ -124,36 +124,48 @@ def generate_gltf_geometry_task(self, cad_file_id: str): log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done") - # --- Store MediaAsset (replace existing gltf_geometry for this cad_file) --- + # --- Store MediaAsset (upsert: update existing to keep stable ID/URL) --- import uuid as _uuid - from sqlalchemy import create_engine as _ce, delete as _del + from sqlalchemy import create_engine as _ce, select as _sel2 from sqlalchemy.orm import Session as _Session from app.domains.media.models import MediaAsset, MediaAssetType _sync_url = app_settings.database_url.replace("+asyncpg", "") _eng2 = _ce(_sync_url) with _Session(_eng2) as _sess: - _sess.execute( - _del(MediaAsset).where( - MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), - MediaAsset.asset_type == MediaAssetType.gltf_geometry, - ) - ) _key = str(output_path) _prefix = str(app_settings.upload_dir).rstrip("/") + "/" if _key.startswith(_prefix): _key = _key[len(_prefix):] - asset = MediaAsset( - cad_file_id=_uuid.UUID(cad_file_id), - product_id=_uuid.UUID(product_id) if product_id else None, - asset_type=MediaAssetType.gltf_geometry, - storage_key=_key, - mime_type="model/gltf-binary", - file_size_bytes=output_path.stat().st_size if output_path.exists() else None, - ) - _sess.add(asset) - _sess.commit() - asset_id = str(asset.id) + _file_size = output_path.stat().st_size if output_path.exists() else None + + existing = _sess.execute( + _sel2(MediaAsset).where( + MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), + MediaAsset.asset_type == MediaAssetType.gltf_geometry, + ) + ).scalars().first() + + if existing: + existing.storage_key = _key + existing.mime_type = "model/gltf-binary" + existing.file_size_bytes = _file_size + if product_id: + existing.product_id = _uuid.UUID(product_id) + _sess.commit() + asset_id = str(existing.id) + else: + asset = MediaAsset( + cad_file_id=_uuid.UUID(cad_file_id), + product_id=_uuid.UUID(product_id) if product_id else None, + asset_type=MediaAssetType.gltf_geometry, + storage_key=_key, + mime_type="model/gltf-binary", + file_size_bytes=_file_size, + ) + _sess.add(asset) + _sess.commit() + asset_id = str(asset.id) _eng2.dispose() pl.step_done("export_glb_geometry", result={"glb_path": str(output_path), "asset_id": asset_id}) @@ -182,7 +194,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None import uuid as _uuid from pathlib import Path as _Path - from sqlalchemy import create_engine as _ce, delete as _del, select as _sel + from sqlalchemy import create_engine as _ce, delete as _del, select as _sel, update as _upd from sqlalchemy.orm import Session as _Session from app.config import settings as app_settings @@ -205,6 +217,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None _sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id)) ).scalar_one_or_none() step_path_str = _cad.stored_path if _cad else None + cad_mesh_attributes: dict = (_cad.mesh_attributes or {}) if _cad else {} settings_rows = _sess.execute(_sel(SystemSetting)).scalars().all() sys_settings = {s.key: s.value for s in settings_rows} @@ -302,6 +315,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None "--output_path", str(output_path), "--material_map", _json.dumps(mat_map), "--smooth_angle", str(smooth_angle), + "--mesh_attributes", _json.dumps(cad_mesh_attributes), ] if asset_library_blend: cmd += ["--asset_library_blend", asset_library_blend] @@ -333,30 +347,44 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done") - # --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) --- + # --- 4. Store MediaAsset (upsert: update existing record to keep stable ID/URL) --- + # Updating in-place (not DELETE+INSERT) preserves the existing asset UUID so that + # any frontend page holding a stale download_url continues to resolve correctly. _eng2 = _ce(_sync_url) with _Session(_eng2) as _sess: - _sess.execute( - _del(MediaAsset).where( - MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), - MediaAsset.asset_type == MediaAssetType.gltf_production, - ) - ) _key = str(output_path) _prefix = str(app_settings.upload_dir).rstrip("/") + "/" if _key.startswith(_prefix): _key = _key[len(_prefix):] - asset = MediaAsset( - cad_file_id=_uuid.UUID(cad_file_id), - product_id=_uuid.UUID(product_id) if product_id else None, - asset_type=MediaAssetType.gltf_production, - storage_key=_key, - mime_type="model/gltf-binary", - file_size_bytes=output_path.stat().st_size if output_path.exists() else None, - ) - _sess.add(asset) - _sess.commit() - asset_id = str(asset.id) + _file_size = output_path.stat().st_size if output_path.exists() else None + + existing = _sess.execute( + _sel(MediaAsset).where( + MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), + MediaAsset.asset_type == MediaAssetType.gltf_production, + ) + ).scalars().first() + + if existing: + existing.storage_key = _key + existing.mime_type = "model/gltf-binary" + existing.file_size_bytes = _file_size + if product_id: + existing.product_id = _uuid.UUID(product_id) + _sess.commit() + asset_id = str(existing.id) + else: + asset = MediaAsset( + cad_file_id=_uuid.UUID(cad_file_id), + product_id=_uuid.UUID(product_id) if product_id else None, + asset_type=MediaAssetType.gltf_production, + storage_key=_key, + mime_type="model/gltf-binary", + file_size_bytes=_file_size, + ) + _sess.add(asset) + _sess.commit() + asset_id = str(asset.id) _eng2.dispose() pl.step_done("export_glb_production", result={"glb_path": str(output_path), "asset_id": asset_id})