endoreg-db 0.8.3.7__py3-none-any.whl → 0.8.6.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. endoreg_db/data/ai_model_meta/default_multilabel_classification.yaml +23 -1
  2. endoreg_db/data/setup_config.yaml +38 -0
  3. endoreg_db/management/commands/create_model_meta_from_huggingface.py +19 -5
  4. endoreg_db/management/commands/load_ai_model_data.py +18 -15
  5. endoreg_db/management/commands/setup_endoreg_db.py +218 -33
  6. endoreg_db/models/media/pdf/raw_pdf.py +241 -97
  7. endoreg_db/models/media/video/pipe_1.py +30 -33
  8. endoreg_db/models/media/video/video_file.py +300 -187
  9. endoreg_db/models/medical/hardware/endoscopy_processor.py +10 -1
  10. endoreg_db/models/metadata/model_meta_logic.py +63 -43
  11. endoreg_db/models/metadata/sensitive_meta_logic.py +251 -25
  12. endoreg_db/serializers/__init__.py +26 -55
  13. endoreg_db/serializers/misc/__init__.py +1 -1
  14. endoreg_db/serializers/misc/file_overview.py +65 -35
  15. endoreg_db/serializers/misc/{vop_patient_data.py → sensitive_patient_data.py} +1 -1
  16. endoreg_db/serializers/video_examination.py +198 -0
  17. endoreg_db/services/lookup_service.py +228 -58
  18. endoreg_db/services/lookup_store.py +174 -30
  19. endoreg_db/services/pdf_import.py +585 -282
  20. endoreg_db/services/video_import.py +485 -242
  21. endoreg_db/urls/__init__.py +36 -23
  22. endoreg_db/urls/label_video_segments.py +2 -0
  23. endoreg_db/urls/media.py +3 -2
  24. endoreg_db/utils/setup_config.py +177 -0
  25. endoreg_db/views/__init__.py +5 -3
  26. endoreg_db/views/media/pdf_media.py +3 -1
  27. endoreg_db/views/media/video_media.py +1 -1
  28. endoreg_db/views/media/video_segments.py +187 -259
  29. endoreg_db/views/pdf/__init__.py +5 -8
  30. endoreg_db/views/pdf/pdf_stream.py +187 -0
  31. endoreg_db/views/pdf/reimport.py +110 -94
  32. endoreg_db/views/requirement/lookup.py +171 -287
  33. endoreg_db/views/video/__init__.py +0 -2
  34. endoreg_db/views/video/video_examination_viewset.py +202 -289
  35. {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/METADATA +1 -2
  36. {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/RECORD +38 -37
  37. endoreg_db/views/pdf/pdf_media.py +0 -239
  38. endoreg_db/views/pdf/pdf_stream_views.py +0 -127
  39. endoreg_db/views/video/video_media.py +0 -158
  40. {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/WHEEL +0 -0
  41. {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/licenses/LICENSE +0 -0
@@ -8,74 +8,96 @@ Changelog:
8
8
  October 14, 2025: Added file locking mechanism to prevent race conditions
9
9
  during concurrent video imports (matches PDF import pattern)
10
10
  """
11
- from datetime import date
11
+
12
12
  import logging
13
- import sys
14
13
  import os
14
+ import random
15
15
  import shutil
16
+ import sys
16
17
  import time
17
18
  from contextlib import contextmanager
19
+ from datetime import date
18
20
  from pathlib import Path
19
- from typing import Union, Dict, Any, Optional, List, Tuple
21
+ from typing import Any, Dict, List, Optional, Tuple, Union
22
+
20
23
  from django.db import transaction
24
+ from django.db.models.fields.files import FieldFile
25
+ from lx_anonymizer import FrameCleaner
21
26
  from moviepy import video
22
- from endoreg_db.models import VideoFile, SensitiveMeta
23
- from endoreg_db.utils.paths import STORAGE_DIR, VIDEO_DIR, ANONYM_VIDEO_DIR
24
- import random
25
- from endoreg_db.utils.hashs import get_video_hash
27
+
28
+ from endoreg_db.models import EndoscopyProcessor, SensitiveMeta, VideoFile
26
29
  from endoreg_db.models.media.video.video_file_anonymize import _cleanup_raw_assets
27
- from django.db.models.fields.files import FieldFile
28
- from endoreg_db.models import EndoscopyProcessor
30
+ from endoreg_db.utils.hashs import get_video_hash
31
+ from endoreg_db.utils.paths import ANONYM_VIDEO_DIR, STORAGE_DIR, VIDEO_DIR
29
32
 
30
33
  # File lock configuration (matches PDF import)
31
34
  STALE_LOCK_SECONDS = 6000 # 100 minutes - reclaim locks older than this
32
- MAX_LOCK_WAIT_SECONDS = 90 # New: wait up to 90s for a non-stale lock to clear before skipping
35
+ MAX_LOCK_WAIT_SECONDS = (
36
+ 90 # New: wait up to 90s for a non-stale lock to clear before skipping
37
+ )
33
38
 
34
39
  logger = logging.getLogger(__name__)
35
40
 
36
41
 
37
- class VideoImportService():
42
+ class VideoImportService:
38
43
  """
39
44
  Service for importing and anonymizing video files.
40
45
  Uses a central video instance pattern for cleaner state management.
41
-
46
+
42
47
  Features (October 14, 2025):
43
48
  - File locking to prevent concurrent processing of the same video
44
49
  - Stale lock detection and reclamation (600s timeout)
45
50
  - Hash-based duplicate detection
46
51
  - Graceful fallback processing without lx_anonymizer
47
52
  """
48
-
53
+
49
54
  def __init__(self, project_root: Optional[Path] = None):
50
-
51
55
  # Set up project root path
52
56
  if project_root:
53
57
  self.project_root = Path(project_root)
54
58
  else:
55
59
  self.project_root = Path(__file__).parent.parent.parent.parent
56
-
60
+
57
61
  # Track processed files to prevent duplicates
58
- self.processed_files = set(str(Path(ANONYM_VIDEO_DIR) / file) for file in os.listdir(ANONYM_VIDEO_DIR))
59
-
62
+ try:
63
+ # Ensure anonym_video directory exists before listing files
64
+ anonym_video_dir = Path(ANONYM_VIDEO_DIR)
65
+ if anonym_video_dir.exists():
66
+ self.processed_files = set(
67
+ str(anonym_video_dir / file)
68
+ for file in os.listdir(ANONYM_VIDEO_DIR)
69
+ )
70
+ else:
71
+ logger.info(f"Creating anonym_videos directory: {anonym_video_dir}")
72
+ anonym_video_dir.mkdir(parents=True, exist_ok=True)
73
+ self.processed_files = set()
74
+ except Exception as e:
75
+ logger.warning(f"Failed to initialize processed files tracking: {e}")
76
+ self.processed_files = set()
77
+
60
78
  # Central video instance and processing context
61
79
  self.current_video: Optional[VideoFile] = None
62
80
  self.processing_context: Dict[str, Any] = {}
63
-
81
+
64
82
  self.delete_source = True
65
-
83
+
66
84
  self.logger = logging.getLogger(__name__)
67
85
 
86
+ self.cleaner = (
87
+ None # This gets instantiated in the perform_frame_cleaning method
88
+ )
89
+
68
90
  def _require_current_video(self) -> VideoFile:
69
91
  """Return the current VideoFile or raise if it has not been initialized."""
70
92
  if self.current_video is None:
71
93
  raise RuntimeError("Current video instance is not set")
72
94
  return self.current_video
73
-
95
+
74
96
  @contextmanager
75
97
  def _file_lock(self, path: Path):
76
98
  """
77
99
  Create a file lock to prevent duplicate processing of the same video.
78
-
100
+
79
101
  This context manager creates a .lock file alongside the video file.
80
102
  If the lock file already exists, it checks if it's stale (older than
81
103
  STALE_LOCK_SECONDS) and reclaims it if necessary. If it's not stale,
@@ -99,24 +121,27 @@ class VideoImportService():
99
121
  except FileNotFoundError:
100
122
  # Race: lock removed between exists and stat; retry acquire in next loop
101
123
  age = None
102
-
124
+
103
125
  if age is not None and age > STALE_LOCK_SECONDS:
104
126
  try:
105
127
  logger.warning(
106
128
  "Stale lock detected for %s (age %.0fs). Reclaiming lock...",
107
- path, age
129
+ path,
130
+ age,
108
131
  )
109
132
  lock_path.unlink()
110
133
  except Exception as e:
111
- logger.warning("Failed to remove stale lock %s: %s", lock_path, e)
134
+ logger.warning(
135
+ "Failed to remove stale lock %s: %s", lock_path, e
136
+ )
112
137
  # Loop continues and retries acquire immediately
113
138
  continue
114
-
139
+
115
140
  # Not stale: wait until deadline, then give up gracefully
116
141
  if time.time() >= deadline:
117
142
  raise ValueError(f"File already being processed: {path}")
118
143
  time.sleep(1.0)
119
-
144
+
120
145
  os.write(fd, b"lock")
121
146
  os.close(fd)
122
147
  fd = None
@@ -129,11 +154,11 @@ class VideoImportService():
129
154
  lock_path.unlink()
130
155
  except OSError:
131
156
  pass
132
-
157
+
133
158
  def processed(self) -> bool:
134
159
  """Indicates if the current file has already been processed."""
135
- return getattr(self, '_processed', False)
136
-
160
+ return getattr(self, "_processed", False)
161
+
137
162
  def import_and_anonymize(
138
163
  self,
139
164
  file_path: Union[Path, str],
@@ -146,11 +171,15 @@ class VideoImportService():
146
171
  High-level helper that orchestrates the complete video import and anonymization process.
147
172
  Uses the central video instance pattern for improved state management.
148
173
  """
174
+ # DEFENSIVE: Initialize processing_context immediately to prevent KeyError crashes
175
+ self.processing_context = {"file_path": Path(file_path)}
176
+
149
177
  try:
150
178
  # Initialize processing context
151
- self._initialize_processing_context(file_path, center_name, processor_name,
152
- save_video, delete_source)
153
-
179
+ self._initialize_processing_context(
180
+ file_path, center_name, processor_name, save_video, delete_source
181
+ )
182
+
154
183
  # Validate and prepare file (may raise ValueError if another worker holds a non-stale lock)
155
184
  try:
156
185
  self._validate_and_prepare_file()
@@ -160,115 +189,130 @@ class VideoImportService():
160
189
  self.logger.info(f"Skipping {file_path}: {ve}")
161
190
  return None
162
191
  raise
163
-
192
+
164
193
  # Create or retrieve video instance
165
194
  self._create_or_retrieve_video_instance()
166
-
195
+
167
196
  # Create sensitive meta file, ensure raw is moved out of processing folder watched by file watcher.
168
197
  self._create_sensitive_file()
169
-
198
+
170
199
  # Setup processing environment
171
200
  self._setup_processing_environment()
172
-
201
+
173
202
  # Process frames and metadata
174
203
  self._process_frames_and_metadata()
175
-
204
+
176
205
  # Finalize processing
177
206
  self._finalize_processing()
178
-
207
+
179
208
  # Move files and cleanup
180
209
  self._cleanup_and_archive()
181
-
210
+
182
211
  return self.current_video
183
-
212
+
184
213
  except Exception as e:
185
- self.logger.error(f"Video import and anonymization failed for {file_path}: {e}")
214
+ # Safe file path access - handles cases where processing_context wasn't initialized
215
+ safe_file_path = getattr(self, "processing_context", {}).get(
216
+ "file_path", file_path
217
+ )
218
+ # Debug: Log context state for troubleshooting
219
+ context_keys = list(getattr(self, "processing_context", {}).keys())
220
+ self.logger.debug(f"Context keys during error: {context_keys}")
221
+ self.logger.error(
222
+ f"Video import and anonymization failed for {safe_file_path}: {e}"
223
+ )
186
224
  self._cleanup_on_error()
187
225
  raise
188
226
  finally:
189
227
  self._cleanup_processing_context()
190
228
 
191
- def _initialize_processing_context(self, file_path: Union[Path, str], center_name: str,
192
- processor_name: str, save_video: bool, delete_source: bool):
229
+ def _initialize_processing_context(
230
+ self,
231
+ file_path: Union[Path, str],
232
+ center_name: str,
233
+ processor_name: str,
234
+ save_video: bool,
235
+ delete_source: bool,
236
+ ):
193
237
  """Initialize the processing context for the current video import."""
194
238
  self.processing_context = {
195
- 'file_path': Path(file_path),
196
- 'center_name': center_name,
197
- 'processor_name': processor_name,
198
- 'save_video': save_video,
199
- 'delete_source': delete_source,
200
- 'processing_started': False,
201
- 'frames_extracted': False,
202
- 'anonymization_completed': False,
203
- 'error_reason': None
239
+ "file_path": Path(file_path),
240
+ "center_name": center_name,
241
+ "processor_name": processor_name,
242
+ "save_video": save_video,
243
+ "delete_source": delete_source,
244
+ "processing_started": False,
245
+ "frames_extracted": False,
246
+ "anonymization_completed": False,
247
+ "error_reason": None,
204
248
  }
205
-
249
+
206
250
  self.logger.info(f"Initialized processing context for: {file_path}")
207
251
 
208
252
  def _validate_and_prepare_file(self):
209
253
  """
210
254
  Validate the video file and prepare for processing.
211
-
255
+
212
256
  Uses file locking to prevent concurrent processing of the same video file.
213
257
  This prevents race conditions where multiple workers might try to process
214
258
  the same video simultaneously.
215
-
259
+
216
260
  The lock is acquired here and held for the entire import process.
217
261
  See _file_lock() for lock reclamation logic.
218
262
  """
219
- file_path = self.processing_context['file_path']
220
-
263
+ file_path = self.processing_context["file_path"]
264
+
221
265
  # Acquire file lock to prevent concurrent processing
222
266
  # Lock will be held until finally block in import_and_anonymize()
223
267
  try:
224
- self.processing_context['_lock_context'] = self._file_lock(file_path)
225
- self.processing_context['_lock_context'].__enter__()
268
+ self.processing_context["_lock_context"] = self._file_lock(file_path)
269
+ self.processing_context["_lock_context"].__enter__()
226
270
  except Exception:
227
271
  self._cleanup_processing_context()
228
272
  raise
229
-
273
+
230
274
  self.logger.info("Acquired file lock for: %s", file_path)
231
-
275
+
232
276
  # Check if already processed (memory-based check)
233
277
  if str(file_path) in self.processed_files:
234
278
  self.logger.info("File %s already processed, skipping", file_path)
235
279
  self._processed = True
236
280
  raise ValueError(f"File already processed: {file_path}")
237
-
281
+
238
282
  # Check file exists
239
283
  if not file_path.exists():
240
284
  raise FileNotFoundError(f"Video file not found: {file_path}")
241
-
285
+
242
286
  self.logger.info("File validation completed for: %s", file_path)
243
287
 
244
288
  def _create_or_retrieve_video_instance(self):
245
289
  """Create or retrieve the VideoFile instance and move to final storage."""
246
-
290
+
247
291
  self.logger.info("Creating VideoFile instance...")
248
-
292
+
249
293
  self.current_video = VideoFile.create_from_file_initialized(
250
- file_path=self.processing_context['file_path'],
251
- center_name=self.processing_context['center_name'],
252
- processor_name=self.processing_context['processor_name'],
253
- delete_source=self.processing_context['delete_source'],
254
- save_video_file=self.processing_context['save_video'],
294
+ file_path=self.processing_context["file_path"],
295
+ center_name=self.processing_context["center_name"],
296
+ processor_name=self.processing_context["processor_name"],
297
+ delete_source=self.processing_context["delete_source"],
298
+ save_video_file=self.processing_context["save_video"],
255
299
  )
256
-
300
+
257
301
  if not self.current_video:
258
302
  raise RuntimeError("Failed to create VideoFile instance")
259
-
303
+
260
304
  # Immediately move to final storage locations
261
305
  self._move_to_final_storage()
262
-
306
+
263
307
  self.logger.info("Created VideoFile with UUID: %s", self.current_video.uuid)
264
-
308
+
265
309
  # Get and mark processing state
266
310
  state = VideoFile.get_or_create_state(self.current_video)
267
311
  if not state:
268
312
  raise RuntimeError("Failed to create VideoFile state")
269
-
313
+
270
314
  state.mark_processing_started(save=True)
271
- self.processing_context['processing_started'] = True
315
+ self.processing_context["processing_started"] = True
272
316
 
273
317
  def _move_to_final_storage(self):
274
318
  """
@@ -302,12 +346,23 @@ class VideoImportService():
302
346
  except Exception:
303
347
  stored_raw_path = None
304
348
 
305
- # Fallback: derive from UUID + suffix
349
+ # Fallback: derive from UUID + suffix - ALWAYS use UUID for consistency
306
350
  if not stored_raw_path:
307
351
  suffix = source_path.suffix or ".mp4"
308
352
  uuid_str = getattr(_current_video, "uuid", None)
309
- filename = f"{uuid_str}{suffix}" if uuid_str else source_path.name
353
+ if uuid_str:
354
+ filename = f"{uuid_str}{suffix}"
355
+ else:
356
+ # Emergency fallback with timestamp to avoid conflicts
357
+ import time
358
+
359
+ timestamp = int(time.time())
360
+ filename = f"video_{timestamp}{suffix}"
361
+ self.logger.warning(
362
+ "No UUID available, using timestamp-based filename: %s", filename
363
+ )
310
364
  stored_raw_path = videos_dir / filename
365
+ self.logger.debug("Using UUID-based raw filename: %s", filename)
311
366
 
312
367
  delete_source = bool(self.processing_context.get("delete_source", True))
313
368
  stored_raw_path.parent.mkdir(parents=True, exist_ok=True)
@@ -322,7 +377,9 @@ class VideoImportService():
322
377
  except Exception:
323
378
  shutil.copy2(source_path, stored_raw_path)
324
379
  os.remove(source_path)
325
- self.logger.info("Copied & removed raw video to: %s", stored_raw_path)
380
+ self.logger.info(
381
+ "Copied & removed raw video to: %s", stored_raw_path
382
+ )
326
383
  else:
327
384
  shutil.copy2(source_path, stored_raw_path)
328
385
  self.logger.info("Copied raw video to: %s", stored_raw_path)
@@ -345,7 +402,6 @@ class VideoImportService():
345
402
  self.processing_context["raw_video_path"] = stored_raw_path
346
403
  self.processing_context["video_filename"] = stored_raw_path.name
347
404
 
348
-
349
405
  def _setup_processing_environment(self):
350
406
  """Setup the processing environment without file movement."""
351
407
  video = self._require_current_video()
@@ -353,71 +409,96 @@ class VideoImportService():
353
409
  # Initialize video specifications
354
410
  video.initialize_video_specs()
355
411
 
356
- # Initialize frame objects in database
357
- video.initialize_frames()
358
-
412
+
413
+
359
414
  # Extract frames BEFORE processing to prevent pipeline 1 conflicts
360
415
  self.logger.info("Pre-extracting frames to avoid pipeline conflicts...")
361
416
  try:
362
417
  frames_extracted = video.extract_frames(overwrite=False)
363
418
  if frames_extracted:
364
- self.processing_context['frames_extracted'] = True
419
+ self.processing_context["frames_extracted"] = True
365
420
  self.logger.info("Frame extraction completed successfully")
366
-
421
+ # Initialize frame objects in database
422
+ video.initialize_frames(video.get_frame_paths())
423
+
367
424
  # CRITICAL: Immediately save the frames_extracted state to database
368
425
  # to prevent refresh_from_db() in pipeline 1 from overriding it
369
426
  state = video.get_or_create_state()
370
427
  if not state.frames_extracted:
371
428
  state.frames_extracted = True
372
- state.save(update_fields=['frames_extracted'])
429
+ state.save(update_fields=["frames_extracted"])
373
430
  self.logger.info("Persisted frames_extracted=True to database")
374
431
  else:
375
432
  self.logger.warning("Frame extraction failed, but continuing...")
376
- self.processing_context['frames_extracted'] = False
433
+ self.processing_context["frames_extracted"] = False
377
434
  except Exception as e:
378
- self.logger.warning(f"Frame extraction failed during setup: {e}, but continuing...")
379
- self.processing_context['frames_extracted'] = False
380
-
435
+ self.logger.warning(
436
+ f"Frame extraction failed during setup: {e}, but continuing..."
437
+ )
438
+ self.processing_context["frames_extracted"] = False
439
+
381
440
  # Ensure default patient data
382
441
  self._ensure_default_patient_data(video_instance=video)
383
-
442
+
384
443
  self.logger.info("Processing environment setup completed")
385
444
 
386
445
  def _process_frames_and_metadata(self):
387
446
  """Process frames and extract metadata with anonymization."""
388
447
  # Check frame cleaning availability
389
- frame_cleaning_available, frame_cleaner = self._ensure_frame_cleaning_available()
448
+ frame_cleaning_available, frame_cleaner = (
449
+ self._ensure_frame_cleaning_available()
450
+ )
390
451
  video = self._require_current_video()
391
452
 
392
453
  raw_file_field = video.raw_file
393
- has_raw_file = isinstance(raw_file_field, FieldFile) and bool(raw_file_field.name)
454
+ has_raw_file = isinstance(raw_file_field, FieldFile) and bool(
455
+ raw_file_field.name
456
+ )
394
457
 
395
458
  if not (frame_cleaning_available and has_raw_file):
396
- self.logger.warning("Frame cleaning not available or conditions not met, using fallback anonymization.")
459
+ self.logger.warning(
460
+ "Frame cleaning not available or conditions not met, using fallback anonymization."
461
+ )
397
462
  self._fallback_anonymize_video()
398
463
  return
399
464
 
400
465
  try:
401
- self.logger.info("Starting frame-level anonymization with processor ROI masking...")
402
-
466
+ self.logger.info(
467
+ "Starting frame-level anonymization with processor ROI masking..."
468
+ )
469
+
403
470
  # Get processor ROI information
404
- endoscope_data_roi_nested, endoscope_image_roi = self._get_processor_roi_info()
405
-
471
+ endoscope_data_roi_nested, endoscope_image_roi = (
472
+ self._get_processor_roi_info()
473
+ )
474
+
406
475
  # Perform frame cleaning with timeout to prevent blocking
407
- from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
408
-
476
+ from concurrent.futures import ThreadPoolExecutor
477
+ from concurrent.futures import TimeoutError as FutureTimeoutError
478
+
409
479
  with ThreadPoolExecutor(max_workers=1) as executor:
410
- future = executor.submit(self._perform_frame_cleaning, endoscope_data_roi_nested, endoscope_image_roi)
480
+ future = executor.submit(
481
+ self._perform_frame_cleaning,
482
+ endoscope_data_roi_nested,
483
+ endoscope_image_roi,
484
+ )
411
485
  try:
412
486
  # Increased timeout to better accommodate ffmpeg + OCR
413
- future.result(timeout=300)
414
- self.processing_context['anonymization_completed'] = True
415
- self.logger.info("Frame cleaning completed successfully within timeout")
487
+ future.result(timeout=50000)
488
+ self.processing_context["anonymization_completed"] = True
489
+ self.logger.info(
490
+ "Frame cleaning completed successfully within timeout"
491
+ )
416
492
  except FutureTimeoutError:
417
- self.logger.warning("Frame cleaning timed out; entering grace period check for cleaned output")
493
+ self.logger.warning(
494
+ "Frame cleaning timed out; entering grace period check for cleaned output"
495
+ )
418
496
  # Grace period: detect if cleaned file appears shortly after timeout
419
- raw_video_path = self.processing_context.get('raw_video_path')
420
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name if raw_video_path else "video.mp4")
497
+ raw_video_path = self.processing_context.get("raw_video_path")
498
+ video_filename = self.processing_context.get(
499
+ "video_filename",
500
+ Path(raw_video_path).name if raw_video_path else "video.mp4",
501
+ )
421
502
  grace_seconds = 60
422
503
  expected_cleaned_path: Optional[Path] = None
423
504
  processed_field = video.processed_file
@@ -430,46 +511,68 @@ class VideoImportService():
430
511
  if expected_cleaned_path is not None:
431
512
  for _ in range(grace_seconds):
432
513
  if expected_cleaned_path.exists():
433
- self.processing_context['cleaned_video_path'] = expected_cleaned_path
434
- self.processing_context['anonymization_completed'] = True
435
- self.logger.info("Detected cleaned video during grace period: %s", expected_cleaned_path)
514
+ self.processing_context["cleaned_video_path"] = (
515
+ expected_cleaned_path
516
+ )
517
+ self.processing_context["anonymization_completed"] = (
518
+ True
519
+ )
520
+ self.logger.info(
521
+ "Detected cleaned video during grace period: %s",
522
+ expected_cleaned_path,
523
+ )
436
524
  found = True
437
525
  break
438
526
  time.sleep(1)
439
527
  else:
440
528
  self._fallback_anonymize_video()
441
529
  if not found:
442
- raise TimeoutError("Frame cleaning operation timed out - likely Ollama connection issue")
530
+ raise TimeoutError(
531
+ "Frame cleaning operation timed out - likely Ollama connection issue"
532
+ )
443
533
 
444
534
  except Exception as e:
445
- self.logger.warning("Frame cleaning failed (reason: %s), falling back to simple copy", e)
535
+ self.logger.warning(
536
+ "Frame cleaning failed (reason: %s), falling back to simple copy", e
537
+ )
446
538
  # Try fallback anonymization when frame cleaning fails
447
539
  try:
448
540
  self._fallback_anonymize_video()
449
541
  except Exception as fallback_error:
450
- self.logger.error("Fallback anonymization also failed: %s", fallback_error)
542
+ self.logger.error(
543
+ "Fallback anonymization also failed: %s", fallback_error
544
+ )
451
545
  # If even fallback fails, mark as not anonymized but continue import
452
- self.processing_context['anonymization_completed'] = False
453
- self.processing_context['error_reason'] = f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
546
+ self.processing_context["anonymization_completed"] = False
547
+ self.processing_context["error_reason"] = (
548
+ f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
549
+ )
454
550
 
455
551
  def _save_anonymized_video(self):
456
-
457
552
  original_raw_file_path_to_delete = None
458
553
  original_raw_frame_dir_to_delete = None
459
554
  video = self._require_current_video()
460
555
  anonymized_video_path = video.get_target_anonymized_video_path()
461
556
 
462
557
  if not anonymized_video_path.exists():
463
- raise RuntimeError(f"Processed video file not found after assembly for {video.uuid}: {anonymized_video_path}")
558
+ raise RuntimeError(
559
+ f"Processed video file not found after assembly for {video.uuid}: {anonymized_video_path}"
560
+ )
464
561
 
465
562
  new_processed_hash = get_video_hash(anonymized_video_path)
466
- if video.__class__.objects.filter(processed_video_hash=new_processed_hash).exclude(pk=video.pk).exists():
563
+ if (
564
+ video.__class__.objects.filter(processed_video_hash=new_processed_hash)
565
+ .exclude(pk=video.pk)
566
+ .exists()
567
+ ):
467
568
  raise ValueError(
468
569
  f"Processed video hash {new_processed_hash} already exists for another video (Video: {video.uuid})."
469
570
  )
470
571
 
471
572
  video.processed_video_hash = new_processed_hash
472
- video.processed_file.name = anonymized_video_path.relative_to(STORAGE_DIR).as_posix()
573
+ video.processed_file.name = anonymized_video_path.relative_to(
574
+ STORAGE_DIR
575
+ ).as_posix()
473
576
 
474
577
  update_fields = [
475
578
  "processed_video_hash",
@@ -485,11 +588,13 @@ class VideoImportService():
485
588
 
486
589
  update_fields.extend(["raw_file", "video_hash"])
487
590
 
488
- transaction.on_commit(lambda: _cleanup_raw_assets(
489
- video_uuid=video.uuid,
490
- raw_file_path=original_raw_file_path_to_delete,
491
- raw_frame_dir=original_raw_frame_dir_to_delete
492
- ))
591
+ transaction.on_commit(
592
+ lambda: _cleanup_raw_assets(
593
+ video_uuid=video.uuid,
594
+ raw_file_path=original_raw_file_path_to_delete,
595
+ raw_frame_dir=original_raw_frame_dir_to_delete,
596
+ )
597
+ )
493
598
 
494
599
  video.save(update_fields=update_fields)
495
600
  video.state.mark_anonymized(save=True)
@@ -505,60 +610,75 @@ class VideoImportService():
505
610
  self.logger.info("Attempting fallback video anonymization...")
506
611
  video = self.current_video
507
612
  if video is None:
508
- self.logger.warning("No VideoFile instance available for fallback anonymization")
509
-
613
+ self.logger.warning(
614
+ "No VideoFile instance available for fallback anonymization"
615
+ )
510
616
 
511
617
  # Strategy 2: Simple copy (no processing, just copy raw to processed)
512
- self.logger.info("Using simple copy fallback (raw video will be used as 'processed' video)")
513
- self.processing_context['anonymization_completed'] = False
514
- self.processing_context['use_raw_as_processed'] = True
515
- self.logger.warning("Fallback: Video will be imported without anonymization (raw copy used)")
618
+ self.logger.info(
619
+ "Using simple copy fallback (raw video will be used as 'processed' video)"
620
+ )
621
+ self.processing_context["anonymization_completed"] = False
622
+ self.processing_context["use_raw_as_processed"] = True
623
+ self.logger.warning(
624
+ "Fallback: Video will be imported without anonymization (raw copy used)"
625
+ )
516
626
  except Exception as e:
517
- self.logger.error(f"Error during fallback anonymization: {e}", exc_info=True)
518
- self.processing_context['anonymization_completed'] = False
519
- self.processing_context['error_reason'] = str(e)
627
+ self.logger.error(
628
+ f"Error during fallback anonymization: {e}", exc_info=True
629
+ )
630
+ self.processing_context["anonymization_completed"] = False
631
+ self.processing_context["error_reason"] = str(e)
632
+
520
633
  def _finalize_processing(self):
521
634
  """Finalize processing and update video state."""
522
635
  self.logger.info("Updating video processing state...")
523
-
636
+
524
637
  with transaction.atomic():
525
638
  video = self._require_current_video()
526
639
  try:
527
640
  video.refresh_from_db()
528
641
  except Exception as refresh_error:
529
- self.logger.warning("Could not refresh VideoFile %s from DB: %s", video.uuid, refresh_error)
642
+ self.logger.warning(
643
+ "Could not refresh VideoFile %s from DB: %s",
644
+ video.uuid,
645
+ refresh_error,
646
+ )
530
647
 
531
648
  state = video.get_or_create_state()
532
-
649
+
533
650
  # Only mark frames as extracted if they were successfully extracted
534
- if self.processing_context.get('frames_extracted', False):
651
+ if self.processing_context.get("frames_extracted", False):
535
652
  state.frames_extracted = True
536
653
  self.logger.info("Marked frames as extracted in state")
537
654
  else:
538
655
  self.logger.warning("Frames were not extracted, not updating state")
539
-
656
+
540
657
  # Always mark these as true (metadata extraction attempts were made)
541
658
  state.frames_initialized = True
542
659
  state.video_meta_extracted = True
543
660
  state.text_meta_extracted = True
544
-
661
+
545
662
  # ✅ FIX: Only mark as processed if anonymization actually completed
546
- anonymization_completed = self.processing_context.get('anonymization_completed', False)
663
+ anonymization_completed = self.processing_context.get(
664
+ "anonymization_completed", False
665
+ )
547
666
  if anonymization_completed:
548
667
  state.mark_sensitive_meta_processed(save=False)
549
- self.logger.info("Anonymization completed - marking sensitive meta as processed")
668
+ self.logger.info(
669
+ "Anonymization completed - marking sensitive meta as processed"
670
+ )
550
671
  else:
551
672
  self.logger.warning(
552
- "Anonymization NOT completed - NOT marking as processed. "
553
- f"Reason: {self.processing_context.get('error_reason', 'Unknown')}"
673
+ f"Anonymization NOT completed - NOT marking as processed. Reason: {self.processing_context.get('error_reason', 'Unknown')}"
554
674
  )
555
675
  # Explicitly mark as NOT processed
556
676
  state.sensitive_meta_processed = False
557
-
677
+
558
678
  # Save all state changes
559
679
  state.save()
560
680
  self.logger.info("Video processing state updated")
561
-
681
+
562
682
  # Signal completion
563
683
  self._signal_completion()
564
684
 
@@ -572,17 +692,20 @@ class VideoImportService():
572
692
  video = self._require_current_video()
573
693
 
574
694
  processed_video_path = None
575
- if 'cleaned_video_path' in self.processing_context:
576
- processed_video_path = self.processing_context['cleaned_video_path']
695
+ if "cleaned_video_path" in self.processing_context:
696
+ processed_video_path = self.processing_context["cleaned_video_path"]
577
697
  else:
578
- raw_video_path = self.processing_context.get('raw_video_path')
698
+ raw_video_path = self.processing_context.get("raw_video_path")
579
699
  if raw_video_path and Path(raw_video_path).exists():
580
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name)
581
- processed_filename = f"processed_{video_filename}"
700
+ # Use UUID-based naming to avoid conflicts
701
+ suffix = Path(raw_video_path).suffix or ".mp4"
702
+ processed_filename = f"processed_{video.uuid}{suffix}"
582
703
  processed_video_path = Path(raw_video_path).parent / processed_filename
583
704
  try:
584
705
  shutil.copy2(str(raw_video_path), str(processed_video_path))
585
- self.logger.info("Copied raw video for processing: %s", processed_video_path)
706
+ self.logger.info(
707
+ "Copied raw video for processing: %s", processed_video_path
708
+ )
586
709
  except Exception as exc:
587
710
  self.logger.error("Failed to copy raw video: %s", exc)
588
711
  processed_video_path = None
@@ -602,62 +725,86 @@ class VideoImportService():
602
725
  relative_path = anonym_target_path.relative_to(storage_root)
603
726
  video.processed_file.name = str(relative_path)
604
727
  video.save(update_fields=["processed_file"])
605
- self.logger.info("Updated processed_file path to: %s", relative_path)
728
+ self.logger.info(
729
+ "Updated processed_file path to: %s", relative_path
730
+ )
606
731
  except Exception as exc:
607
- self.logger.error("Failed to update processed_file path: %s", exc)
608
- video.processed_file.name = f"anonym_videos/{anonym_video_filename}"
609
- video.save(update_fields=['processed_file'])
732
+ self.logger.error(
733
+ "Failed to update processed_file path: %s", exc
734
+ )
735
+ video.processed_file.name = (
736
+ f"anonym_videos/{anonym_video_filename}"
737
+ )
738
+ video.save(update_fields=["processed_file"])
610
739
  self.logger.info(
611
740
  "Updated processed_file path using fallback: %s",
612
741
  f"anonym_videos/{anonym_video_filename}",
613
742
  )
614
743
 
615
- self.processing_context['anonymization_completed'] = True
744
+ self.processing_context["anonymization_completed"] = True
616
745
  else:
617
- self.logger.warning("Processed video file not found after move: %s", anonym_target_path)
746
+ self.logger.warning(
747
+ "Processed video file not found after move: %s",
748
+ anonym_target_path,
749
+ )
618
750
  except Exception as exc:
619
- self.logger.error("Failed to move processed video to anonym_videos: %s", exc)
751
+ self.logger.error(
752
+ "Failed to move processed video to anonym_videos: %s", exc
753
+ )
620
754
  else:
621
- self.logger.warning("No processed video available - processed_file will remain empty")
755
+ self.logger.warning(
756
+ "No processed video available - processed_file will remain empty"
757
+ )
622
758
 
623
759
  try:
624
760
  from endoreg_db.utils.paths import RAW_FRAME_DIR
761
+
625
762
  shutil.rmtree(RAW_FRAME_DIR, ignore_errors=True)
626
- self.logger.debug("Cleaned up temporary frames directory: %s", RAW_FRAME_DIR)
763
+ self.logger.debug(
764
+ "Cleaned up temporary frames directory: %s", RAW_FRAME_DIR
765
+ )
627
766
  except Exception as exc:
628
767
  self.logger.warning("Failed to remove directory %s: %s", RAW_FRAME_DIR, exc)
629
768
 
630
- source_path = self.processing_context['file_path']
631
- if self.processing_context['delete_source'] and Path(source_path).exists():
769
+ source_path = self.processing_context["file_path"]
770
+ if self.processing_context["delete_source"] and Path(source_path).exists():
632
771
  try:
633
772
  os.remove(source_path)
634
773
  self.logger.info("Removed remaining source file: %s", source_path)
635
774
  except Exception as exc:
636
- self.logger.warning("Failed to remove source file %s: %s", source_path, exc)
775
+ self.logger.warning(
776
+ "Failed to remove source file %s: %s", source_path, exc
777
+ )
637
778
 
638
779
  if not video.processed_file or not Path(video.processed_file.path).exists():
639
- self.logger.warning("No processed_file found after cleanup - video will be unprocessed")
780
+ self.logger.warning(
781
+ "No processed_file found after cleanup - video will be unprocessed"
782
+ )
640
783
  try:
641
784
  video.anonymize(delete_original_raw=self.delete_source)
642
- video.save(update_fields=['processed_file'])
785
+ video.save(update_fields=["processed_file"])
643
786
  self.logger.info("Late-stage anonymization succeeded")
644
787
  except Exception as e:
645
788
  self.logger.error("Late-stage anonymization failed: %s", e)
646
- self.processing_context['anonymization_completed'] = False
789
+ self.processing_context["anonymization_completed"] = False
647
790
 
648
791
  self.logger.info("Cleanup and archiving completed")
649
792
 
650
- self.processed_files.add(str(self.processing_context['file_path']))
793
+ self.processed_files.add(str(self.processing_context["file_path"]))
651
794
 
652
795
  with transaction.atomic():
653
796
  video.refresh_from_db()
654
- if hasattr(video, 'state') and self.processing_context.get('anonymization_completed'):
797
+ if hasattr(video, "state") and self.processing_context.get(
798
+ "anonymization_completed"
799
+ ):
655
800
  video.state.mark_sensitive_meta_processed(save=True)
656
801
 
657
- self.logger.info("Import and anonymization completed for VideoFile UUID: %s", video.uuid)
802
+ self.logger.info(
803
+ "Import and anonymization completed for VideoFile UUID: %s", video.uuid
804
+ )
658
805
  self.logger.info("Raw video stored in: /data/videos")
659
806
  self.logger.info("Processed video stored in: /data/anonym_videos")
660
-
807
+
661
808
  def _create_sensitive_file(
662
809
  self,
663
810
  video_instance: VideoFile | None = None,
@@ -681,7 +828,9 @@ class VideoImportService():
681
828
  if source_path is None:
682
829
  raise ValueError("No file path available for creating sensitive file")
683
830
  if not raw_field:
684
- raise ValueError("VideoFile must have a raw_file to create a sensitive file")
831
+ raise ValueError(
832
+ "VideoFile must have a raw_file to create a sensitive file"
833
+ )
685
834
 
686
835
  target_dir = VIDEO_DIR / "sensitive"
687
836
  if not target_dir.exists():
@@ -691,9 +840,13 @@ class VideoImportService():
691
840
  target_file_path = target_dir / source_path.name
692
841
  try:
693
842
  shutil.move(str(source_path), str(target_file_path))
694
- self.logger.info("Moved raw file to sensitive directory: %s", target_file_path)
843
+ self.logger.info(
844
+ "Moved raw file to sensitive directory: %s", target_file_path
845
+ )
695
846
  except Exception as exc:
696
- self.logger.warning("Failed to move raw file to sensitive dir, copying instead: %s", exc)
847
+ self.logger.warning(
848
+ "Failed to move raw file to sensitive dir, copying instead: %s", exc
849
+ )
697
850
  shutil.copy(str(source_path), str(target_file_path))
698
851
  try:
699
852
  os.remove(source_path)
@@ -707,7 +860,10 @@ class VideoImportService():
707
860
  relative_path = target_file_path.relative_to(storage_root)
708
861
  video.raw_file.name = str(relative_path)
709
862
  video.save(update_fields=["raw_file"])
710
- self.logger.info("Updated video.raw_file to point to sensitive location: %s", relative_path)
863
+ self.logger.info(
864
+ "Updated video.raw_file to point to sensitive location: %s",
865
+ relative_path,
866
+ )
711
867
  except Exception as exc:
712
868
  self.logger.warning("Failed to set relative path, using fallback: %s", exc)
713
869
  video.raw_file.name = f"videos/sensitive/{target_file_path.name}"
@@ -716,15 +872,18 @@ class VideoImportService():
716
872
  "Updated video.raw_file using fallback method: videos/sensitive/%s",
717
873
  target_file_path.name,
718
874
  )
719
-
875
+
720
876
  self.processing_context["raw_video_path"] = target_file_path
721
877
  self.processing_context["video_filename"] = target_file_path.name
722
878
 
723
-
724
- self.logger.info("Created sensitive file for %s at %s", video.uuid, target_file_path)
879
+ self.logger.info(
880
+ "Created sensitive file for %s at %s", video.uuid, target_file_path
881
+ )
725
882
  return target_file_path
726
883
 
727
- def _get_processor_roi_info(self) -> Tuple[Optional[List[List[Dict[str, Any]]]], Optional[Dict[str, Any]]]:
884
+ def _get_processor_roi_info(
885
+ self,
886
+ ) -> Tuple[Optional[List[List[Dict[str, Any]]]], Optional[Dict[str, Any]]]:
728
887
  """Get processor ROI information for masking."""
729
888
  endoscope_data_roi_nested = None
730
889
  endoscope_image_roi = None
@@ -735,10 +894,15 @@ class VideoImportService():
735
894
  video_meta = getattr(video, "video_meta", None)
736
895
  processor = getattr(video_meta, "processor", None) if video_meta else None
737
896
  if processor:
738
- assert isinstance(processor, EndoscopyProcessor), "Processor is not of type EndoscopyProcessor"
897
+ assert isinstance(processor, EndoscopyProcessor), (
898
+ "Processor is not of type EndoscopyProcessor"
899
+ )
739
900
  endoscope_image_roi = processor.get_roi_endoscope_image()
740
- endoscope_data_roi_nested = processor.get_rois()
741
- self.logger.info("Retrieved processor ROI information: endoscope_image_roi=%s", endoscope_image_roi)
901
+ endoscope_data_roi_nested = processor.get_sensitive_rois()
902
+ self.logger.info(
903
+ "Retrieved processor ROI information: endoscope_image_roi=%s",
904
+ endoscope_image_roi,
905
+ )
742
906
  else:
743
907
  self.logger.warning(
744
908
  "No processor found for video %s, proceeding without ROI masking",
@@ -760,28 +924,40 @@ class VideoImportService():
760
924
 
761
925
  return endoscope_data_roi_nested, endoscope_image_roi
762
926
 
763
- def _ensure_default_patient_data(self, video_instance: VideoFile | None = None) -> None:
927
+ def _ensure_default_patient_data(
928
+ self, video_instance: VideoFile | None = None
929
+ ) -> None:
764
930
  """Ensure minimum patient data is present on the video's SensitiveMeta."""
765
931
 
766
932
  video = video_instance or self._require_current_video()
767
933
 
768
934
  sensitive_meta = getattr(video, "sensitive_meta", None)
769
935
  if not sensitive_meta:
770
- self.logger.info("No SensitiveMeta found for video %s, creating default", video.uuid)
936
+ self.logger.info(
937
+ "No SensitiveMeta found for video %s, creating default", video.uuid
938
+ )
771
939
  default_data = {
772
940
  "patient_first_name": "Patient",
773
941
  "patient_last_name": "Unknown",
774
942
  "patient_dob": date(1990, 1, 1),
775
943
  "examination_date": date.today(),
776
- "center_name": video.center.name if video.center else "university_hospital_wuerzburg",
944
+ "center_name": video.center.name
945
+ if video.center
946
+ else "university_hospital_wuerzburg",
777
947
  }
778
948
  try:
779
949
  sensitive_meta = SensitiveMeta.create_from_dict(default_data)
780
950
  video.sensitive_meta = sensitive_meta
781
951
  video.save(update_fields=["sensitive_meta"])
782
- self.logger.info("Created default SensitiveMeta for video %s", video.uuid)
952
+ self.logger.info(
953
+ "Created default SensitiveMeta for video %s", video.uuid
954
+ )
783
955
  except Exception as exc:
784
- self.logger.error("Failed to create default SensitiveMeta for video %s: %s", video.uuid, exc)
956
+ self.logger.error(
957
+ "Failed to create default SensitiveMeta for video %s: %s",
958
+ video.uuid,
959
+ exc,
960
+ )
785
961
  return
786
962
  else:
787
963
  update_data: Dict[str, Any] = {}
@@ -805,14 +981,16 @@ class VideoImportService():
805
981
  list(update_data.keys()),
806
982
  )
807
983
  except Exception as exc:
808
- self.logger.error("Failed to update SensitiveMeta for video %s: %s", video.uuid, exc)
809
-
810
-
984
+ self.logger.error(
985
+ "Failed to update SensitiveMeta for video %s: %s",
986
+ video.uuid,
987
+ exc,
988
+ )
811
989
 
812
990
  def _ensure_frame_cleaning_available(self):
813
991
  """
814
992
  Ensure frame cleaning modules are available by adding lx-anonymizer to path.
815
-
993
+
816
994
  Returns:
817
995
  Tuple of (availability_flag, FrameCleaner_class, ReportReader_class)
818
996
  """
@@ -821,14 +999,14 @@ class VideoImportService():
821
999
  from lx_anonymizer import FrameCleaner # type: ignore[import]
822
1000
 
823
1001
  if FrameCleaner:
824
- return True, FrameCleaner
825
-
1002
+ return True, FrameCleaner()
1003
+
826
1004
  except Exception as e:
827
- self.logger.warning(f"Frame cleaning not available: {e} Please install or update lx_anonymizer.")
828
-
829
- return False, None
1005
+ self.logger.warning(
1006
+ f"Frame cleaning not available: {e} Please install or update lx_anonymizer."
1007
+ )
830
1008
 
831
-
1009
+ return False, None
832
1010
 
833
1011
  def _perform_frame_cleaning(self, endoscope_data_roi_nested, endoscope_image_roi):
834
1012
  """Perform frame cleaning and anonymization."""
@@ -839,8 +1017,8 @@ class VideoImportService():
839
1017
  raise RuntimeError("Frame cleaning not available")
840
1018
 
841
1019
  # Prepare parameters for frame cleaning
842
- raw_video_path = self.processing_context.get('raw_video_path')
843
-
1020
+ raw_video_path = self.processing_context.get("raw_video_path")
1021
+
844
1022
  if not raw_video_path or not Path(raw_video_path).exists():
845
1023
  try:
846
1024
  self.current_video = self._require_current_video()
@@ -848,33 +1026,40 @@ class VideoImportService():
848
1026
  except Exception:
849
1027
  raise RuntimeError(f"Raw video path not found: {raw_video_path}")
850
1028
 
851
-
852
- # Create temporary output path for cleaned video
853
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name)
854
- cleaned_filename = f"cleaned_{video_filename}"
1029
+ # Create temporary output path for cleaned video using UUID to avoid naming conflicts
1030
+ video = self._require_current_video()
1031
+ # Ensure raw_video_path is not None
1032
+ if not raw_video_path:
1033
+ raise RuntimeError(
1034
+ "raw_video_path is None, cannot construct cleaned_video_path"
1035
+ )
1036
+ suffix = Path(raw_video_path).suffix or ".mp4"
1037
+ cleaned_filename = f"cleaned_{video.uuid}{suffix}"
855
1038
  cleaned_video_path = Path(raw_video_path).parent / cleaned_filename
856
-
857
-
858
-
1039
+ self.logger.debug("Using UUID-based cleaned filename: %s", cleaned_filename)
1040
+
859
1041
  # Clean video with ROI masking (heavy I/O operation)
860
1042
  actual_cleaned_path, extracted_metadata = frame_cleaner.clean_video(
861
1043
  video_path=Path(raw_video_path),
862
1044
  endoscope_image_roi=endoscope_image_roi,
863
1045
  endoscope_data_roi_nested=endoscope_data_roi_nested,
864
1046
  output_path=cleaned_video_path,
865
- technique="mask_overlay"
1047
+ technique="mask_overlay",
866
1048
  )
867
-
868
-
1049
+
869
1050
  # Store cleaned video path for later use in _cleanup_and_archive
870
- self.processing_context['cleaned_video_path'] = actual_cleaned_path
871
- self.processing_context['extracted_metadata'] = extracted_metadata
872
-
1051
+ self.processing_context["cleaned_video_path"] = actual_cleaned_path
1052
+ self.processing_context["extracted_metadata"] = extracted_metadata
1053
+
873
1054
  # Update sensitive metadata with extracted information
874
1055
  self._update_sensitive_metadata(extracted_metadata)
875
- self.logger.info(f"Extracted metadata from frame cleaning: {extracted_metadata}")
876
-
877
- self.logger.info(f"Frame cleaning with ROI masking completed: {actual_cleaned_path}")
1056
+ self.logger.info(
1057
+ f"Extracted metadata from frame cleaning: {extracted_metadata}"
1058
+ )
1059
+
1060
+ self.logger.info(
1061
+ f"Frame cleaning with ROI masking completed: {actual_cleaned_path}"
1062
+ )
878
1063
  self.logger.info("Cleaned video will be moved to anonym_videos during cleanup")
879
1064
 
880
1065
  def _update_sensitive_metadata(self, extracted_metadata: Dict[str, Any]):
@@ -891,22 +1076,67 @@ class VideoImportService():
891
1076
 
892
1077
  sm = sensitive_meta
893
1078
  updated_fields = []
894
-
1079
+
1080
+ # Ensure center is set from video.center if not in extracted_metadata
1081
+ metadata_to_update = extracted_metadata.copy()
1082
+
1083
+ # FIX: Set center object instead of center_name string
1084
+ if not hasattr(sm, "center") or not sm.center:
1085
+ if video.center:
1086
+ metadata_to_update["center"] = video.center
1087
+ self.logger.debug(
1088
+ "Added center object '%s' to metadata for SensitiveMeta update",
1089
+ video.center.name,
1090
+ )
1091
+ else:
1092
+ center_name = metadata_to_update.get("center_name")
1093
+ if center_name:
1094
+ try:
1095
+ from ..models.administration import Center
1096
+
1097
+ center_obj = Center.objects.get(name=center_name)
1098
+ metadata_to_update["center"] = center_obj
1099
+ self.logger.debug(
1100
+ "Loaded center object '%s' from center_name", center_name
1101
+ )
1102
+ metadata_to_update.pop("center_name", None)
1103
+ except Center.DoesNotExist:
1104
+ self.logger.error(
1105
+ "Center '%s' not found in database", center_name
1106
+ )
1107
+ return
1108
+
895
1109
  try:
896
- sm.update_from_dict(extracted_metadata)
897
- updated_fields = list(extracted_metadata.keys())
1110
+ sm.update_from_dict(metadata_to_update)
1111
+ updated_fields = list(
1112
+ extracted_metadata.keys()
1113
+ ) # Only log originally extracted fields
898
1114
  except KeyError as e:
899
1115
  self.logger.warning(f"Failed to update SensitiveMeta field {e}")
900
-
1116
+ return
1117
+
901
1118
  if updated_fields:
902
- sm.save(update_fields=updated_fields)
903
- self.logger.info("Updated SensitiveMeta fields for video %s: %s", video.uuid, updated_fields)
1119
+ try:
1120
+ sm.save() # Remove update_fields to allow all necessary fields to be saved
1121
+ self.logger.info(
1122
+ "Updated SensitiveMeta fields for video %s: %s",
1123
+ video.uuid,
1124
+ updated_fields,
1125
+ )
904
1126
 
905
- state = video.get_or_create_state()
906
- state.mark_sensitive_meta_processed(save=True)
907
- self.logger.info("Marked sensitive metadata as processed for video %s", video.uuid)
1127
+ state = video.get_or_create_state()
1128
+ state.mark_sensitive_meta_processed(save=True)
1129
+ self.logger.info(
1130
+ "Marked sensitive metadata as processed for video %s", video.uuid
1131
+ )
1132
+ except Exception as e:
1133
+ self.logger.error(f"Failed to save SensitiveMeta: {e}")
1134
+ raise # Re-raise to trigger fallback in calling method
908
1135
  else:
909
- self.logger.info("No SensitiveMeta fields updated for video %s - all existing values preserved", video.uuid)
1136
+ self.logger.info(
1137
+ "No SensitiveMeta fields updated for video %s - all existing values preserved",
1138
+ video.uuid,
1139
+ )
910
1140
 
911
1141
  def _signal_completion(self):
912
1142
  """Signal completion to the tracking system."""
@@ -922,21 +1152,28 @@ class VideoImportService():
922
1152
  raw_exists = False
923
1153
 
924
1154
  video_processing_complete = (
925
- video.sensitive_meta is not None and
926
- video.video_meta is not None and
927
- raw_exists
1155
+ video.sensitive_meta is not None
1156
+ and video.video_meta is not None
1157
+ and raw_exists
928
1158
  )
929
1159
 
930
1160
  if video_processing_complete:
931
- self.logger.info("Video %s processing completed successfully - ready for validation", video.uuid)
1161
+ self.logger.info(
1162
+ "Video %s processing completed successfully - ready for validation",
1163
+ video.uuid,
1164
+ )
932
1165
 
933
1166
  # Update completion flags if they exist
934
1167
  completion_fields = []
935
- for field_name in ['import_completed', 'processing_complete', 'ready_for_validation']:
1168
+ for field_name in [
1169
+ "import_completed",
1170
+ "processing_complete",
1171
+ "ready_for_validation",
1172
+ ]:
936
1173
  if hasattr(video, field_name):
937
1174
  setattr(video, field_name, True)
938
1175
  completion_fields.append(field_name)
939
-
1176
+
940
1177
  if completion_fields:
941
1178
  video.save(update_fields=completion_fields)
942
1179
  self.logger.info("Updated completion flags: %s", completion_fields)
@@ -945,15 +1182,15 @@ class VideoImportService():
945
1182
  "Video %s processing incomplete - missing required components",
946
1183
  video.uuid,
947
1184
  )
948
-
1185
+
949
1186
  except Exception as e:
950
1187
  self.logger.warning(f"Failed to signal completion status: {e}")
951
1188
 
952
1189
  def _cleanup_on_error(self):
953
1190
  """Cleanup processing context on error."""
954
- if self.current_video and hasattr(self.current_video, 'state'):
1191
+ if self.current_video and hasattr(self.current_video, "state"):
955
1192
  try:
956
- if self.processing_context.get('processing_started'):
1193
+ if self.processing_context.get("processing_started"):
957
1194
  self.current_video.state.frames_extracted = False
958
1195
  self.current_video.state.frames_initialized = False
959
1196
  self.current_video.state.video_meta_extracted = False
@@ -965,29 +1202,34 @@ class VideoImportService():
965
1202
  def _cleanup_processing_context(self):
966
1203
  """
967
1204
  Cleanup processing context and release file lock.
968
-
1205
+
969
1206
  This method is always called in the finally block of import_and_anonymize()
970
1207
  to ensure the file lock is released even if processing fails.
971
1208
  """
1209
+ # DEFENSIVE: Ensure processing_context exists before accessing it
1210
+ if not hasattr(self, "processing_context"):
1211
+ self.processing_context = {}
1212
+
972
1213
  try:
973
1214
  # Release file lock if it was acquired
974
- lock_context = self.processing_context.get('_lock_context')
1215
+ lock_context = self.processing_context.get("_lock_context")
975
1216
  if lock_context is not None:
976
1217
  try:
977
1218
  lock_context.__exit__(None, None, None)
978
1219
  self.logger.info("Released file lock")
979
1220
  except Exception as e:
980
1221
  self.logger.warning(f"Error releasing file lock: {e}")
981
-
1222
+
982
1223
  # Remove file from processed set if processing failed
983
- file_path = self.processing_context.get('file_path')
984
- if file_path and not self.processing_context.get('anonymization_completed'):
1224
+ file_path = self.processing_context.get("file_path")
1225
+ if file_path and not self.processing_context.get("anonymization_completed"):
985
1226
  file_path_str = str(file_path)
986
1227
  if file_path_str in self.processed_files:
987
1228
  self.processed_files.remove(file_path_str)
988
- self.logger.info(f"Removed {file_path_str} from processed files (failed processing)")
989
-
990
-
1229
+ self.logger.info(
1230
+ f"Removed {file_path_str} from processed files (failed processing)"
1231
+ )
1232
+
991
1233
  except Exception as e:
992
1234
  self.logger.warning(f"Error during context cleanup: {e}")
993
1235
  finally:
@@ -995,6 +1237,7 @@ class VideoImportService():
995
1237
  self.current_video = None
996
1238
  self.processing_context = {}
997
1239
 
1240
+
998
1241
  # Convenience function for callers/tests that expect a module-level import_and_anonymize
999
1242
  def import_and_anonymize(
1000
1243
  file_path,
@@ -1013,4 +1256,4 @@ def import_and_anonymize(
1013
1256
  processor_name=processor_name,
1014
1257
  save_video=save_video,
1015
1258
  delete_source=delete_source,
1016
- )
1259
+ )