endoreg-db 0.8.4.4__py3-none-any.whl → 0.8.6.1__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.

Potentially problematic release.


This version of endoreg-db might be problematic. Click here for more details.

Files changed (36) hide show
  1. endoreg_db/management/commands/load_ai_model_data.py +2 -1
  2. endoreg_db/management/commands/setup_endoreg_db.py +11 -7
  3. endoreg_db/models/media/pdf/raw_pdf.py +241 -97
  4. endoreg_db/models/media/video/pipe_1.py +30 -33
  5. endoreg_db/models/media/video/video_file.py +300 -187
  6. endoreg_db/models/metadata/model_meta_logic.py +15 -1
  7. endoreg_db/models/metadata/sensitive_meta_logic.py +391 -70
  8. endoreg_db/serializers/__init__.py +26 -55
  9. endoreg_db/serializers/misc/__init__.py +1 -1
  10. endoreg_db/serializers/misc/file_overview.py +65 -35
  11. endoreg_db/serializers/misc/{vop_patient_data.py → sensitive_patient_data.py} +1 -1
  12. endoreg_db/serializers/video_examination.py +198 -0
  13. endoreg_db/services/lookup_service.py +228 -58
  14. endoreg_db/services/lookup_store.py +174 -30
  15. endoreg_db/services/pdf_import.py +585 -282
  16. endoreg_db/services/video_import.py +340 -101
  17. endoreg_db/urls/__init__.py +36 -23
  18. endoreg_db/urls/label_video_segments.py +2 -0
  19. endoreg_db/urls/media.py +3 -2
  20. endoreg_db/views/__init__.py +6 -3
  21. endoreg_db/views/media/pdf_media.py +3 -1
  22. endoreg_db/views/media/video_media.py +1 -1
  23. endoreg_db/views/media/video_segments.py +187 -259
  24. endoreg_db/views/pdf/__init__.py +5 -8
  25. endoreg_db/views/pdf/pdf_stream.py +187 -0
  26. endoreg_db/views/pdf/reimport.py +110 -94
  27. endoreg_db/views/requirement/lookup.py +171 -287
  28. endoreg_db/views/video/__init__.py +0 -2
  29. endoreg_db/views/video/video_examination_viewset.py +202 -289
  30. {endoreg_db-0.8.4.4.dist-info → endoreg_db-0.8.6.1.dist-info}/METADATA +1 -1
  31. {endoreg_db-0.8.4.4.dist-info → endoreg_db-0.8.6.1.dist-info}/RECORD +33 -34
  32. endoreg_db/views/pdf/pdf_media.py +0 -239
  33. endoreg_db/views/pdf/pdf_stream_views.py +0 -127
  34. endoreg_db/views/video/video_media.py +0 -158
  35. {endoreg_db-0.8.4.4.dist-info → endoreg_db-0.8.6.1.dist-info}/WHEEL +0 -0
  36. {endoreg_db-0.8.4.4.dist-info → endoreg_db-0.8.6.1.dist-info}/licenses/LICENSE +0 -0
@@ -80,7 +80,9 @@ def parse_any_date(s: str) -> Optional[date]:
80
80
  # Try dateparser with German locale preference
81
81
  import dateparser
82
82
 
83
- dt = dateparser.parse(s, settings={"DATE_ORDER": "DMY", "PREFER_DAY_OF_MONTH": "first"})
83
+ dt = dateparser.parse(
84
+ s, settings={"DATE_ORDER": "DMY", "PREFER_DAY_OF_MONTH": "first"}
85
+ )
84
86
  return dt.date() if dt else None
85
87
  except Exception as e:
86
88
  logger.debug(f"Dateparser fallback failed for '{s}': {e}")
@@ -160,6 +162,9 @@ def calculate_patient_hash(instance: "SensitiveMeta", salt: str = SECRET_SALT) -
160
162
  if not center:
161
163
  raise ValueError("Center is required to calculate patient hash.")
162
164
 
165
+ assert first_name is not None, "First name is required to calculate patient hash."
166
+ assert last_name is not None, "Last name is required to calculate patient hash."
167
+
163
168
  hash_str = get_patient_hash(
164
169
  first_name=first_name,
165
170
  last_name=last_name,
@@ -170,7 +175,9 @@ def calculate_patient_hash(instance: "SensitiveMeta", salt: str = SECRET_SALT) -
170
175
  return sha256(hash_str.encode()).hexdigest()
171
176
 
172
177
 
173
- def calculate_examination_hash(instance: "SensitiveMeta", salt: str = SECRET_SALT) -> str:
178
+ def calculate_examination_hash(
179
+ instance: "SensitiveMeta", salt: str = SECRET_SALT
180
+ ) -> str:
174
181
  """Calculates the examination hash for the instance."""
175
182
  dob = instance.patient_dob
176
183
  first_name = instance.patient_first_name
@@ -203,17 +210,25 @@ def create_pseudo_examiner_logic(instance: "SensitiveMeta") -> "Examiner":
203
210
  center = instance.center # Should be set before calling save
204
211
 
205
212
  if not first_name or not last_name or not center:
206
- logger.warning(f"Incomplete examiner info for SensitiveMeta (pk={instance.pk or 'new'}). Using default examiner.")
213
+ logger.warning(
214
+ f"Incomplete examiner info for SensitiveMeta (pk={instance.pk or 'new'}). Using default examiner."
215
+ )
207
216
  # Ensure default center exists or handle appropriately
208
217
  try:
209
- default_center = Center.objects.get_by_natural_key("endoreg_db_demo")
218
+ default_center = Center.objects.get(name="endoreg_db_demo")
210
219
  except Center.DoesNotExist:
211
- logger.error("Default center 'endoreg_db_demo' not found. Cannot create default examiner.")
220
+ logger.error(
221
+ "Default center 'endoreg_db_demo' not found. Cannot create default examiner."
222
+ )
212
223
  raise ValueError("Default center 'endoreg_db_demo' not found.")
213
224
 
214
- examiner, _created = Examiner.custom_get_or_create(first_name="Unknown", last_name="Unknown", center=default_center)
225
+ examiner, _created = Examiner.custom_get_or_create(
226
+ first_name="Unknown", last_name="Unknown", center=default_center
227
+ )
215
228
  else:
216
- examiner, _created = Examiner.custom_get_or_create(first_name=first_name, last_name=last_name, center=center)
229
+ examiner, _created = Examiner.custom_get_or_create(
230
+ first_name=first_name, last_name=last_name, center=center
231
+ )
217
232
 
218
233
  return examiner
219
234
 
@@ -259,11 +274,13 @@ def get_or_create_pseudo_patient_examination_logic(
259
274
  pseudo_patient = get_or_create_pseudo_patient_logic(instance)
260
275
  instance.pseudo_patient_id = pseudo_patient.pk # Assign FK directly
261
276
 
262
- patient_examination, _created = PatientExamination.get_or_create_pseudo_patient_examination_by_hash(
263
- patient_hash=instance.patient_hash,
264
- examination_hash=instance.examination_hash,
265
- # Optionally pass pseudo_patient if the method requires it
266
- # pseudo_patient=instance.pseudo_patient
277
+ patient_examination, _created = (
278
+ PatientExamination.get_or_create_pseudo_patient_examination_by_hash(
279
+ patient_hash=instance.patient_hash,
280
+ examination_hash=instance.examination_hash,
281
+ # Optionally pass pseudo_patient if the method requires it
282
+ # pseudo_patient=instance.pseudo_patient
283
+ )
267
284
  )
268
285
  return patient_examination
269
286
 
@@ -273,33 +290,137 @@ def perform_save_logic(instance: "SensitiveMeta") -> "Examiner":
273
290
  """
274
291
  Contains the core logic for preparing a SensitiveMeta instance for saving.
275
292
  Handles data generation (dates), hash calculation, and linking pseudo-entities.
276
- Returns the Examiner instance to be linked via M2M after the main save.
293
+
294
+ This function is called on every save() operation and implements a two-phase approach:
295
+
296
+ **Phase 1: Initial Creation (with defaults)**
297
+ - When a SensitiveMeta is first created (e.g., via get_or_create_sensitive_meta()),
298
+ it may have missing patient data (names, DOB, etc.)
299
+ - Default values are set to prevent hash calculation errors:
300
+ * patient_first_name: "unknown"
301
+ * patient_last_name: "unknown"
302
+ * patient_dob: random date (1920-2000)
303
+ - A temporary hash is calculated using these defaults
304
+ - Temporary pseudo-entities (Patient, Examination) are created
305
+
306
+ **Phase 2: Update (with extracted data)**
307
+ - When real patient data is extracted (e.g., from video OCR via lx_anonymizer),
308
+ update_from_dict() is called with actual values
309
+ - The instance fields are updated with real data (names, DOB, etc.)
310
+ - save() is called again, triggering this function
311
+ - Default-setting logic is skipped (fields are no longer empty)
312
+ - Hash is RECALCULATED with real data
313
+ - New pseudo-entities are created/retrieved based on new hash
314
+
315
+ **Example Flow:**
316
+ ```
317
+ # Initial creation
318
+ sm = SensitiveMeta.create_from_dict({"center": center})
319
+ # → patient_first_name = "unknown", patient_last_name = "unknown"
320
+ # → hash = sha256("unknown unknown 1990-01-01 ...")
321
+ # → pseudo_patient_temp created
322
+
323
+ # Later update with extracted data
324
+ sm.update_from_dict({"patient_first_name": "Max", "patient_last_name": "Mustermann"})
325
+ # → patient_first_name = "Max", patient_last_name = "Mustermann" (overwrites)
326
+ # → save() triggered → perform_save_logic() called again
327
+ # → Default-setting skipped (names already exist)
328
+ # → hash = sha256("Max Mustermann 1985-03-15 ...") (RECALCULATED)
329
+ # → pseudo_patient_real created/retrieved with new hash
330
+ ```
331
+
332
+ Args:
333
+ instance: The SensitiveMeta instance being saved
334
+
335
+ Returns:
336
+ Examiner: The pseudo examiner instance to be linked via M2M after save
337
+
338
+ Raises:
339
+ ValueError: If required fields (center, gender) cannot be determined
277
340
  """
278
341
 
279
342
  # --- Pre-Save Checks and Data Generation ---
280
343
 
281
344
  # 1. Ensure DOB and Examination Date exist
282
345
  if not instance.patient_dob:
283
- logger.debug(f"SensitiveMeta (pk={instance.pk or 'new'}): Patient DOB missing, generating random.")
346
+ logger.debug(
347
+ f"SensitiveMeta (pk={instance.pk or 'new'}): Patient DOB missing, generating random."
348
+ )
284
349
  instance.patient_dob = generate_random_dob()
285
350
  if not instance.examination_date:
286
- logger.debug(f"SensitiveMeta (pk={instance.pk or 'new'}): Examination date missing, generating random.")
351
+ logger.debug(
352
+ f"SensitiveMeta (pk={instance.pk or 'new'}): Examination date missing, generating random."
353
+ )
287
354
  instance.examination_date = generate_random_examination_date()
288
355
 
289
356
  # 2. Ensure Center exists (should be set before calling save)
290
357
  if not instance.center:
291
358
  raise ValueError("Center must be set before saving SensitiveMeta.")
292
359
 
360
+ # 2.5 CRITICAL: Set default patient names BEFORE hash calculation
361
+ #
362
+ # **Why this is necessary:**
363
+ # Hash calculation (step 4) requires first_name and last_name to be non-None.
364
+ # However, on initial creation (e.g., via get_or_create_sensitive_meta()), these
365
+ # fields may be empty because real patient data hasn't been extracted yet.
366
+ #
367
+ # **Two-phase approach:**
368
+ # - Phase 1 (Initial): Set defaults if names are missing
369
+ # → Allows hash calculation to succeed without errors
370
+ # → Creates temporary pseudo-entities with default hash
371
+ #
372
+ # - Phase 2 (Update): Real data extraction (OCR, manual input)
373
+ # → update_from_dict() sets real names ("Max", "Mustermann")
374
+ # → save() is called again
375
+ # → This block is SKIPPED (names already exist)
376
+ # → Hash is recalculated with real data (step 4)
377
+ # → New pseudo-entities created with correct hash
378
+ #
379
+ # **Example:**
380
+ # Initial: patient_first_name = "unknown" → hash = sha256("unknown unknown...")
381
+ # Updated: patient_first_name = "Max" → hash = sha256("Max Mustermann...")
382
+ #
383
+ if not instance.patient_first_name:
384
+ instance.patient_first_name = DEFAULT_UNKNOWN_NAME
385
+ logger.debug(
386
+ "SensitiveMeta (pk=%s): Patient first name missing, set to default '%s'.",
387
+ instance.pk or "new",
388
+ DEFAULT_UNKNOWN_NAME,
389
+ )
390
+
391
+ if not instance.patient_last_name:
392
+ instance.patient_last_name = DEFAULT_UNKNOWN_NAME
393
+ logger.debug(
394
+ "SensitiveMeta (pk=%s): Patient last name missing, set to default '%s'.",
395
+ instance.pk or "new",
396
+ DEFAULT_UNKNOWN_NAME,
397
+ )
398
+
293
399
  # 3. Ensure Gender exists (should be set before calling save, e.g., during creation/update)
294
400
  if not instance.patient_gender:
295
- # Attempt to guess if names are available
296
- first_name = instance.patient_first_name or DEFAULT_UNKNOWN_NAME
297
- gender = guess_name_gender(first_name)
298
- if not gender:
299
- raise ValueError("Patient gender could not be determined and must be set before saving.")
300
- instance.patient_gender = gender
401
+ # Use the now-guaranteed first_name for gender guessing
402
+ first_name = instance.patient_first_name
403
+ gender_str = guess_name_gender(first_name)
404
+ if not gender_str:
405
+ raise ValueError(
406
+ "Patient gender could not be determined and must be set before saving."
407
+ )
408
+ # Convert string to Gender object
409
+ try:
410
+ gender_obj = Gender.objects.get(name=gender_str)
411
+ instance.patient_gender = gender_obj
412
+ except Gender.DoesNotExist:
413
+ raise ValueError(f"Gender '{gender_str}' not found in database.")
301
414
 
302
415
  # 4. Calculate Hashes (depends on DOB, Exam Date, Center, Names)
416
+ #
417
+ # **IMPORTANT: Hashes are RECALCULATED on every save!**
418
+ # This enables the two-phase update pattern:
419
+ # - Initial save: Hash based on default "unknown unknown" names
420
+ # - Updated save: Hash based on real extracted names ("Max Mustermann")
421
+ #
422
+ # The new hash will link to different pseudo-entities, ensuring proper
423
+ # anonymization while maintaining referential integrity.
303
424
  instance.patient_hash = calculate_patient_hash(instance)
304
425
  instance.examination_hash = calculate_examination_hash(instance)
305
426
 
@@ -324,10 +445,59 @@ def perform_save_logic(instance: "SensitiveMeta") -> "Examiner":
324
445
  return examiner_instance
325
446
 
326
447
 
327
- def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str, Any]) -> "SensitiveMeta":
328
- """Logic to create a SensitiveMeta instance from a dictionary."""
448
+ def create_sensitive_meta_from_dict(
449
+ cls: Type["SensitiveMeta"], data: Dict[str, Any]
450
+ ) -> "SensitiveMeta":
451
+ """
452
+ Create a SensitiveMeta instance from a dictionary.
453
+
454
+ **Center handling:**
455
+ This function accepts TWO ways to specify the center:
456
+ 1. `center` (Center object) - Directly pass a Center instance
457
+ 2. `center_name` (string) - Pass the center name as a string (will be resolved to Center object)
458
+
459
+ At least ONE of these must be provided.
460
+
461
+ **Example usage:**
462
+ ```python
463
+ # Option 1: With Center object
464
+ data = {
465
+ "patient_first_name": "Patient",
466
+ "patient_last_name": "Unknown",
467
+ "patient_dob": date(1990, 1, 1),
468
+ "examination_date": date.today(),
469
+ "center": center_obj, # ← Center object
470
+ }
471
+ sm = SensitiveMeta.create_from_dict(data)
472
+
473
+ # Option 2: With center name string
474
+ data = {
475
+ "patient_first_name": "Patient",
476
+ "patient_last_name": "Unknown",
477
+ "patient_dob": date(1990, 1, 1),
478
+ "examination_date": date.today(),
479
+ "center_name": "university_hospital_wuerzburg", # ← String
480
+ }
481
+ sm = SensitiveMeta.create_from_dict(data)
482
+ ```
483
+
484
+ Args:
485
+ cls: The SensitiveMeta class
486
+ data: Dictionary containing field values
487
+
488
+ Returns:
489
+ SensitiveMeta: The created instance
329
490
 
330
- field_names = {f.name for f in cls._meta.get_fields() if not f.is_relation or f.one_to_one or (f.many_to_one and f.related_model)}
491
+ Raises:
492
+ ValueError: If neither center nor center_name is provided
493
+ ValueError: If center_name does not match any Center in database
494
+ """
495
+
496
+ field_names = {
497
+ f.name
498
+ for f in cls._meta.get_fields()
499
+ if not f.is_relation or f.one_to_one or (f.many_to_one and f.related_model)
500
+ }
331
501
  selected_data = {k: v for k, v in data.items() if k in field_names}
332
502
 
333
503
  # --- Convert patient_dob if it's a date object ---
@@ -354,9 +524,13 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
354
524
  try:
355
525
  import dateparser
356
526
 
357
- parsed_dob = dateparser.parse(dob, languages=["de"], settings={"DATE_ORDER": "DMY"})
527
+ parsed_dob = dateparser.parse(
528
+ dob, languages=["de"], settings={"DATE_ORDER": "DMY"}
529
+ )
358
530
  if parsed_dob:
359
- aware_dob = timezone.make_aware(parsed_dob.replace(hour=0, minute=0, second=0, microsecond=0))
531
+ aware_dob = timezone.make_aware(
532
+ parsed_dob.replace(hour=0, minute=0, second=0, microsecond=0)
533
+ )
360
534
  selected_data["patient_dob"] = aware_dob
361
535
  logger.debug(
362
536
  "Parsed string patient_dob '%s' to aware datetime: %s",
@@ -410,7 +584,9 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
410
584
  # Fall back to dateparser for complex formats
411
585
  import dateparser
412
586
 
413
- parsed_date = dateparser.parse(exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"})
587
+ parsed_date = dateparser.parse(
588
+ exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"}
589
+ )
414
590
  if parsed_date:
415
591
  selected_data["examination_date"] = parsed_date.date()
416
592
  logger.debug(
@@ -428,7 +604,9 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
428
604
  # Use dateparser for non-ISO formats
429
605
  import dateparser
430
606
 
431
- parsed_date = dateparser.parse(exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"})
607
+ parsed_date = dateparser.parse(
608
+ exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"}
609
+ )
432
610
  if parsed_date:
433
611
  selected_data["examination_date"] = parsed_date.date()
434
612
  logger.debug(
@@ -450,15 +628,29 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
450
628
  )
451
629
  selected_data.pop("examination_date", None)
452
630
 
453
- # Handle Center
631
+ # Handle Center - accept both center_name (string) and center (object)
632
+ from ..administration import Center
633
+
634
+ center = data.get("center") # First try direct Center object
454
635
  center_name = data.get("center_name")
455
- if not center_name:
456
- raise ValueError("center_name is required in data dictionary.")
457
- try:
458
- center = Center.objects.get_by_natural_key(center_name)
636
+
637
+ if center is not None:
638
+ # Center object provided directly - validate it's a Center instance
639
+ if not isinstance(center, Center):
640
+ raise ValueError(f"'center' must be a Center instance, got {type(center)}")
459
641
  selected_data["center"] = center
460
- except Center.DoesNotExist as exc:
461
- raise ValueError(f"Center with name '{center_name}' does not exist.") from exc
642
+ elif center_name:
643
+ # center_name string provided - resolve to Center object
644
+ try:
645
+ center = Center.objects.get(name=center_name)
646
+ selected_data["center"] = center
647
+ except Center.DoesNotExist:
648
+ raise ValueError(f"Center with name '{center_name}' does not exist.")
649
+ else:
650
+ # Neither center nor center_name provided
651
+ raise ValueError(
652
+ "Either 'center' (Center object) or 'center_name' (string) is required in data dictionary."
653
+ )
462
654
 
463
655
  # Handle Names and Gender
464
656
  first_name = selected_data.get("patient_first_name") or DEFAULT_UNKNOWN_NAME
@@ -474,22 +666,34 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
474
666
  elif isinstance(patient_gender_input, str):
475
667
  # Input is a string (gender name)
476
668
  try:
477
- selected_data["patient_gender"] = Gender.objects.get(name=patient_gender_input)
669
+ selected_data["patient_gender"] = Gender.objects.get(
670
+ name=patient_gender_input
671
+ )
478
672
  except Gender.DoesNotExist:
479
- logger.warning(f"Gender with name '{patient_gender_input}' provided but not found. Attempting to guess or use default.")
673
+ logger.warning(
674
+ f"Gender with name '{patient_gender_input}' provided but not found. Attempting to guess or use default."
675
+ )
480
676
  # Fall through to guessing logic if provided string name is invalid
481
677
  patient_gender_input = None # Reset to trigger guessing
482
678
 
483
- if not isinstance(selected_data.get("patient_gender"), Gender): # If not already a Gender object (e.g. was None, or string lookup failed)
679
+ if not isinstance(
680
+ selected_data.get("patient_gender"), Gender
681
+ ): # If not already a Gender object (e.g. was None, or string lookup failed)
484
682
  gender_name_to_use = guess_name_gender(first_name)
485
683
  if not gender_name_to_use:
486
- logger.warning(f"Could not guess gender for name '{first_name}'. Setting Gender to unknown.")
684
+ logger.warning(
685
+ f"Could not guess gender for name '{first_name}'. Setting Gender to unknown."
686
+ )
487
687
  gender_name_to_use = "unknown"
488
688
  try:
489
- selected_data["patient_gender"] = Gender.objects.get(name=gender_name_to_use)
689
+ selected_data["patient_gender"] = Gender.objects.get(
690
+ name=gender_name_to_use
691
+ )
490
692
  except Gender.DoesNotExist:
491
693
  # This should ideally not happen if "unknown" gender is guaranteed to exist
492
- raise ValueError(f"Default or guessed gender '{gender_name_to_use}' does not exist in Gender table.")
694
+ raise ValueError(
695
+ f"Default or guessed gender '{gender_name_to_use}' does not exist in Gender table."
696
+ )
493
697
 
494
698
  # Update name DB
495
699
  update_name_db(first_name, last_name)
@@ -503,22 +707,95 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
503
707
  return sensitive_meta
504
708
 
505
709
 
506
- def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, Any]) -> "SensitiveMeta":
507
- """Logic to update a SensitiveMeta instance from a dictionary."""
508
- field_names = {f.name for f in instance._meta.get_fields() if not f.is_relation or f.one_to_one or (f.many_to_one and f.related_model)}
710
+ def update_sensitive_meta_from_dict(
711
+ instance: "SensitiveMeta", data: Dict[str, Any]
712
+ ) -> "SensitiveMeta":
713
+ """
714
+ Updates a SensitiveMeta instance from a dictionary of new values.
715
+
716
+ **Integration with two-phase save pattern:**
717
+ This function is typically called after initial SensitiveMeta creation when real
718
+ patient data becomes available (e.g., extracted from video OCR, PDF parsing, or
719
+ manual annotation).
720
+
721
+ **Example workflow:**
722
+ ```python
723
+ # Phase 1: Initial creation with defaults
724
+ sm = SensitiveMeta.create_from_dict({"center": center})
725
+ # → patient_first_name = "unknown", hash = sha256("unknown...")
726
+
727
+ # Phase 2: Update with extracted data
728
+ extracted = {
729
+ "patient_first_name": "Max",
730
+ "patient_last_name": "Mustermann",
731
+ "patient_dob": date(1985, 3, 15)
732
+ }
733
+ update_sensitive_meta_from_dict(sm, extracted)
734
+ # → Sets: sm.patient_first_name = "Max", sm.patient_last_name = "Mustermann"
735
+ # → Calls: sm.save()
736
+ # → Triggers: perform_save_logic() again
737
+ # → Result: Hash recalculated with real data, new pseudo-entities created
738
+ ```
739
+
740
+ **Key behaviors:**
741
+ - Updates instance attributes from provided dictionary
742
+ - Handles type conversions (date strings → date objects, gender strings → Gender objects)
743
+ - Tracks patient name changes to update name database
744
+ - Calls save() at the end, triggering full save logic including hash recalculation
745
+ - Default-setting in perform_save_logic() is skipped (fields already populated)
746
+
747
+ Args:
748
+ instance: The existing SensitiveMeta instance to update
749
+ data: Dictionary of field names and new values
750
+
751
+ Returns:
752
+ The updated SensitiveMeta instance
753
+
754
+ Raises:
755
+ Exception: If save fails or required conversions fail
756
+ """
757
+ field_names = {
758
+ f.name
759
+ for f in instance._meta.get_fields()
760
+ if not f.is_relation or f.one_to_one or (f.many_to_one and f.related_model)
761
+ }
509
762
  # Exclude FKs that should not be updated directly from dict keys (handled separately or via save logic)
510
763
  excluded_fields = {"pseudo_patient", "pseudo_examination"}
511
- selected_data = {k: v for k, v in data.items() if k in field_names and k not in excluded_fields}
764
+ selected_data = {
765
+ k: v for k, v in data.items() if k in field_names and k not in excluded_fields
766
+ }
767
+
768
+ # Handle potential Center update - accept both center_name (string) and center (object)
769
+ from ..administration import Center
512
770
 
513
- # Handle potential Center update
771
+ center = data.get("center") # First try direct Center object
514
772
  center_name = data.get("center_name")
515
- if center_name:
773
+
774
+ if center is not None:
775
+ # Center object provided directly - validate and update
776
+ if isinstance(center, Center):
777
+ instance.center = center
778
+ logger.debug(f"Updated center from Center object: {center.name}")
779
+ else:
780
+ logger.warning(
781
+ f"Invalid center type {type(center)}, expected Center instance. Ignoring."
782
+ )
783
+ # Remove from selected_data to prevent override
784
+ selected_data.pop("center", None)
785
+ elif center_name:
786
+ # center_name string provided - resolve to Center object
516
787
  try:
517
- center = Center.objects.get_by_natural_key(center_name)
518
- instance.center = center # Update center directly
519
- except Center.DoesNotExist as exc:
520
- logger.warning(f"Center '{center_name}' not found during update. Keeping existing center.")
521
- selected_data.pop("center", None) # Remove from dict if not found
788
+ center_obj = Center.objects.get(name=center_name)
789
+ instance.center = center_obj
790
+ logger.debug(f"Updated center from center_name string: {center_name}")
791
+ except Center.DoesNotExist:
792
+ logger.warning(
793
+ f"Center '{center_name}' not found during update. Keeping existing center."
794
+ )
795
+ else:
796
+ # Both are None/missing - remove 'center' from selected_data to preserve existing value
797
+ selected_data.pop("center", None)
798
+ # If both are None/missing, keep existing center (no update needed)
522
799
 
523
800
  # Set examiner names if provided, before calling save
524
801
  examiner_first_name = data.get("examiner_first_name")
@@ -537,10 +814,14 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
537
814
  elif isinstance(patient_gender_input, str):
538
815
  gender_input_clean = patient_gender_input.strip()
539
816
  # Try direct case-insensitive DB lookup first
540
- gender_obj = Gender.objects.filter(name__iexact=gender_input_clean).first()
817
+ gender_obj = Gender.objects.filter(
818
+ name__iexact=gender_input_clean
819
+ ).first()
541
820
  if gender_obj:
542
821
  selected_data["patient_gender"] = gender_obj
543
- logger.debug(f"Successfully matched gender string '{patient_gender_input}' to Gender object via iexact lookup")
822
+ logger.debug(
823
+ f"Successfully matched gender string '{patient_gender_input}' to Gender object via iexact lookup"
824
+ )
544
825
  else:
545
826
  # Use mapping helper for fallback
546
827
  mapped = _map_gender_string_to_standard(gender_input_clean)
@@ -548,35 +829,60 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
548
829
  gender_obj = Gender.objects.filter(name__iexact=mapped).first()
549
830
  if gender_obj:
550
831
  selected_data["patient_gender"] = gender_obj
551
- logger.info(f"Mapped gender '{patient_gender_input}' to '{mapped}' via fallback mapping")
832
+ logger.info(
833
+ f"Mapped gender '{patient_gender_input}' to '{mapped}' via fallback mapping"
834
+ )
552
835
  else:
553
- logger.warning(f"Mapped gender '{patient_gender_input}' to '{mapped}', but no such Gender in DB. Trying 'unknown'.")
554
- unknown_gender = Gender.objects.filter(name__iexact="unknown").first()
836
+ logger.warning(
837
+ f"Mapped gender '{patient_gender_input}' to '{mapped}', but no such Gender in DB. Trying 'unknown'."
838
+ )
839
+ unknown_gender = Gender.objects.filter(
840
+ name__iexact="unknown"
841
+ ).first()
555
842
  if unknown_gender:
556
843
  selected_data["patient_gender"] = unknown_gender
557
- logger.warning(f"Using 'unknown' gender as fallback for '{patient_gender_input}'")
844
+ logger.warning(
845
+ f"Using 'unknown' gender as fallback for '{patient_gender_input}'"
846
+ )
558
847
  else:
559
- logger.error(f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update.")
848
+ logger.error(
849
+ f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update."
850
+ )
560
851
  selected_data.pop("patient_gender", None)
561
852
  else:
562
853
  # Last resort: try to get 'unknown' gender
563
- unknown_gender = Gender.objects.filter(name__iexact="unknown").first()
854
+ unknown_gender = Gender.objects.filter(
855
+ name__iexact="unknown"
856
+ ).first()
564
857
  if unknown_gender:
565
858
  selected_data["patient_gender"] = unknown_gender
566
- logger.warning(f"Using 'unknown' gender as fallback for '{patient_gender_input}' (no mapping)")
859
+ logger.warning(
860
+ f"Using 'unknown' gender as fallback for '{patient_gender_input}' (no mapping)"
861
+ )
567
862
  else:
568
- logger.error(f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update.")
863
+ logger.error(
864
+ f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update."
865
+ )
569
866
  selected_data.pop("patient_gender", None)
570
867
  else:
571
- logger.warning(f"Unexpected patient_gender type {type(patient_gender_input)}: {patient_gender_input}. Skipping gender update.")
868
+ logger.warning(
869
+ f"Unexpected patient_gender type {type(patient_gender_input)}: {patient_gender_input}. Skipping gender update."
870
+ )
572
871
  selected_data.pop("patient_gender", None)
573
872
  except Exception as e:
574
- logger.exception(f"Error handling patient_gender '{patient_gender_input}': {e}. Skipping gender update.")
873
+ logger.exception(
874
+ f"Error handling patient_gender '{patient_gender_input}': {e}. Skipping gender update."
875
+ )
575
876
  selected_data.pop("patient_gender", None)
576
877
 
577
878
  # Update other attributes from selected_data
578
879
  patient_name_changed = False
579
880
  for k, v in selected_data.items():
881
+ # Skip None values to avoid overwriting existing data
882
+ if v is None:
883
+ logger.debug(f"Skipping field '{k}' during update because value is None")
884
+ continue
885
+
580
886
  # Avoid overwriting examiner names if they were just explicitly set
581
887
  if (
582
888
  k not in ["examiner_first_name", "examiner_last_name"]
@@ -588,7 +894,9 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
588
894
  value_to_set = v
589
895
  if k == "patient_dob":
590
896
  if isinstance(v, date) and not isinstance(v, datetime):
591
- aware_dob = timezone.make_aware(datetime.combine(v, datetime.min.time()))
897
+ aware_dob = timezone.make_aware(
898
+ datetime.combine(v, datetime.min.time())
899
+ )
592
900
  value_to_set = aware_dob
593
901
  logger.debug(
594
902
  "Converted patient_dob from date to aware datetime during update: %s",
@@ -611,9 +919,15 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
611
919
  try:
612
920
  import dateparser
613
921
 
614
- parsed_dob = dateparser.parse(v, languages=["de"], settings={"DATE_ORDER": "DMY"})
922
+ parsed_dob = dateparser.parse(
923
+ v, languages=["de"], settings={"DATE_ORDER": "DMY"}
924
+ )
615
925
  if parsed_dob:
616
- value_to_set = timezone.make_aware(parsed_dob.replace(hour=0, minute=0, second=0, microsecond=0))
926
+ value_to_set = timezone.make_aware(
927
+ parsed_dob.replace(
928
+ hour=0, minute=0, second=0, microsecond=0
929
+ )
930
+ )
617
931
  logger.debug(
618
932
  "Parsed string patient_dob '%s' during update to aware datetime: %s",
619
933
  v,
@@ -648,7 +962,9 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
648
962
  try:
649
963
  import dateparser
650
964
 
651
- parsed_date = dateparser.parse(v, languages=["de"], settings={"DATE_ORDER": "DMY"})
965
+ parsed_date = dateparser.parse(
966
+ v, languages=["de"], settings={"DATE_ORDER": "DMY"}
967
+ )
652
968
  if parsed_date:
653
969
  value_to_set = parsed_date.date()
654
970
  logger.debug(
@@ -672,13 +988,18 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
672
988
  # --- End Conversion ---
673
989
 
674
990
  # Check if patient name is changing
675
- if k in ["patient_first_name", "patient_last_name"] and getattr(instance, k) != value_to_set:
991
+ if (
992
+ k in ["patient_first_name", "patient_last_name"]
993
+ and getattr(instance, k) != value_to_set
994
+ ):
676
995
  patient_name_changed = True
677
996
 
678
997
  setattr(instance, k, value_to_set) # Use value_to_set
679
998
 
680
999
  except Exception as e:
681
- logger.error(f"Error setting attribute '{k}' to '{v}': {e}. Skipping this field.")
1000
+ logger.error(
1001
+ f"Error setting attribute '{k}' to '{v}': {e}. Skipping this field."
1002
+ )
682
1003
  continue
683
1004
 
684
1005
  # Update name DB if patient names changed