endoreg-db 0.8.3.3__py3-none-any.whl → 0.8.6.5__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.
- endoreg_db/data/ai_model_meta/default_multilabel_classification.yaml +23 -1
- endoreg_db/data/setup_config.yaml +38 -0
- endoreg_db/management/commands/create_model_meta_from_huggingface.py +1 -2
- endoreg_db/management/commands/load_ai_model_data.py +18 -15
- endoreg_db/management/commands/setup_endoreg_db.py +218 -33
- endoreg_db/models/media/pdf/raw_pdf.py +241 -97
- endoreg_db/models/media/video/pipe_1.py +30 -33
- endoreg_db/models/media/video/video_file.py +300 -187
- endoreg_db/models/medical/hardware/endoscopy_processor.py +10 -1
- endoreg_db/models/metadata/model_meta_logic.py +34 -45
- endoreg_db/models/metadata/sensitive_meta_logic.py +555 -150
- endoreg_db/serializers/__init__.py +26 -55
- endoreg_db/serializers/misc/__init__.py +1 -1
- endoreg_db/serializers/misc/file_overview.py +65 -35
- endoreg_db/serializers/misc/{vop_patient_data.py → sensitive_patient_data.py} +1 -1
- endoreg_db/serializers/video_examination.py +198 -0
- endoreg_db/services/lookup_service.py +228 -58
- endoreg_db/services/lookup_store.py +174 -30
- endoreg_db/services/pdf_import.py +585 -282
- endoreg_db/services/video_import.py +493 -240
- endoreg_db/urls/__init__.py +36 -23
- endoreg_db/urls/label_video_segments.py +2 -0
- endoreg_db/urls/media.py +103 -66
- endoreg_db/utils/setup_config.py +177 -0
- endoreg_db/views/__init__.py +5 -3
- endoreg_db/views/media/pdf_media.py +3 -1
- endoreg_db/views/media/video_media.py +1 -1
- endoreg_db/views/media/video_segments.py +187 -259
- endoreg_db/views/pdf/__init__.py +5 -8
- endoreg_db/views/pdf/pdf_stream.py +186 -0
- endoreg_db/views/pdf/reimport.py +110 -94
- endoreg_db/views/requirement/lookup.py +171 -287
- endoreg_db/views/video/__init__.py +0 -2
- endoreg_db/views/video/video_examination_viewset.py +202 -289
- {endoreg_db-0.8.3.3.dist-info → endoreg_db-0.8.6.5.dist-info}/METADATA +1 -2
- {endoreg_db-0.8.3.3.dist-info → endoreg_db-0.8.6.5.dist-info}/RECORD +38 -37
- endoreg_db/views/pdf/pdf_media.py +0 -239
- endoreg_db/views/pdf/pdf_stream_views.py +0 -127
- endoreg_db/views/video/video_media.py +0 -158
- {endoreg_db-0.8.3.3.dist-info → endoreg_db-0.8.6.5.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.3.3.dist-info → endoreg_db-0.8.6.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,26 +2,26 @@ import logging
|
|
|
2
2
|
import os
|
|
3
3
|
import random
|
|
4
4
|
import re # Neu hinzugefügt für Regex-Pattern
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
5
6
|
from hashlib import sha256
|
|
6
|
-
from
|
|
7
|
-
from typing import TYPE_CHECKING, Dict, Any, Optional, Type
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Type
|
|
8
8
|
|
|
9
|
-
from django.utils import timezone
|
|
10
9
|
from django.db import transaction
|
|
10
|
+
from django.utils import timezone
|
|
11
11
|
|
|
12
|
-
# Assuming these utils are correctly located
|
|
13
|
-
from endoreg_db.utils.hashs import get_patient_hash, get_patient_examination_hash
|
|
14
12
|
from endoreg_db.utils import guess_name_gender
|
|
15
13
|
|
|
14
|
+
# Assuming these utils are correctly located
|
|
15
|
+
from endoreg_db.utils.hashs import get_patient_examination_hash, get_patient_hash
|
|
16
|
+
|
|
16
17
|
# Import models needed for logic, use local imports inside functions if needed to break cycles
|
|
17
|
-
from ..administration import Center, Examiner,
|
|
18
|
-
from ..other import Gender
|
|
18
|
+
from ..administration import Center, Examiner, FirstName, LastName, Patient
|
|
19
19
|
from ..medical import PatientExamination
|
|
20
|
+
from ..other import Gender
|
|
20
21
|
from ..state import SensitiveMetaState
|
|
21
22
|
|
|
22
|
-
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
|
-
from .sensitive_meta import SensitiveMeta
|
|
24
|
+
from .sensitive_meta import SensitiveMeta # Import model for type hinting
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
SECRET_SALT = os.getenv("DJANGO_SALT", "default_salt")
|
|
@@ -35,23 +35,23 @@ DE_RX = re.compile(r"^\d{2}\.\d{2}\.\d{4}$")
|
|
|
35
35
|
def parse_any_date(s: str) -> Optional[date]:
|
|
36
36
|
"""
|
|
37
37
|
Parst Datumsstring mit Priorität auf deutsches Format (DD.MM.YYYY).
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
Unterstützte Formate:
|
|
40
40
|
1. DD.MM.YYYY (Priorität) - deutsches Format
|
|
41
41
|
2. YYYY-MM-DD (Fallback) - ISO-Format
|
|
42
42
|
3. Erweiterte Fallbacks über dateparser
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
Args:
|
|
45
45
|
s: Datumsstring zum Parsen
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
Returns:
|
|
48
48
|
date-Objekt oder None bei ungültigem/fehlendem Input
|
|
49
49
|
"""
|
|
50
50
|
if not s:
|
|
51
51
|
return None
|
|
52
|
-
|
|
52
|
+
|
|
53
53
|
s = s.strip()
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
# 1. German dd.mm.yyyy (PRIORITÄT)
|
|
56
56
|
if DE_RX.match(s):
|
|
57
57
|
try:
|
|
@@ -60,7 +60,7 @@ def parse_any_date(s: str) -> Optional[date]:
|
|
|
60
60
|
except ValueError as e:
|
|
61
61
|
logger.warning(f"Invalid German date format '{s}': {e}")
|
|
62
62
|
return None
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
# 2. ISO yyyy-mm-dd (Fallback für Rückwärtskompatibilität)
|
|
65
65
|
if ISO_RX.match(s):
|
|
66
66
|
try:
|
|
@@ -68,23 +68,20 @@ def parse_any_date(s: str) -> Optional[date]:
|
|
|
68
68
|
except ValueError as e:
|
|
69
69
|
logger.warning(f"Invalid ISO date format '{s}': {e}")
|
|
70
70
|
return None
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
# 3. Extended fallbacks
|
|
73
73
|
try:
|
|
74
74
|
# Try standard datetime parsing
|
|
75
75
|
return datetime.fromisoformat(s).date()
|
|
76
76
|
except Exception:
|
|
77
77
|
pass
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
try:
|
|
80
80
|
# Try dateparser with German locale preference
|
|
81
81
|
import dateparser
|
|
82
|
+
|
|
82
83
|
dt = dateparser.parse(
|
|
83
|
-
s,
|
|
84
|
-
settings={
|
|
85
|
-
"DATE_ORDER": "DMY",
|
|
86
|
-
"PREFER_DAY_OF_MONTH": "first"
|
|
87
|
-
}
|
|
84
|
+
s, settings={"DATE_ORDER": "DMY", "PREFER_DAY_OF_MONTH": "first"}
|
|
88
85
|
)
|
|
89
86
|
return dt.date() if dt else None
|
|
90
87
|
except Exception as e:
|
|
@@ -95,10 +92,10 @@ def parse_any_date(s: str) -> Optional[date]:
|
|
|
95
92
|
def format_date_german(d: Optional[date]) -> str:
|
|
96
93
|
"""
|
|
97
94
|
Formatiert date-Objekt als deutsches Datumsformat (DD.MM.YYYY).
|
|
98
|
-
|
|
95
|
+
|
|
99
96
|
Args:
|
|
100
97
|
d: date-Objekt oder None
|
|
101
|
-
|
|
98
|
+
|
|
102
99
|
Returns:
|
|
103
100
|
Formatiertes Datum als String oder leerer String bei None
|
|
104
101
|
"""
|
|
@@ -110,10 +107,10 @@ def format_date_german(d: Optional[date]) -> str:
|
|
|
110
107
|
def format_date_iso(d: Optional[date]) -> str:
|
|
111
108
|
"""
|
|
112
109
|
Formatiert date-Objekt als ISO-Format (YYYY-MM-DD).
|
|
113
|
-
|
|
110
|
+
|
|
114
111
|
Args:
|
|
115
112
|
d: date-Objekt oder None
|
|
116
|
-
|
|
113
|
+
|
|
117
114
|
Returns:
|
|
118
115
|
Formatiertes Datum als String oder leerer String bei None
|
|
119
116
|
"""
|
|
@@ -137,7 +134,7 @@ def generate_random_dob() -> datetime:
|
|
|
137
134
|
def generate_random_examination_date() -> date:
|
|
138
135
|
"""Generates a random date within the last 20 years."""
|
|
139
136
|
today = date.today()
|
|
140
|
-
start_date = today - timedelta(days=20 * 365)
|
|
137
|
+
start_date = today - timedelta(days=20 * 365) # Approximate 20 years back
|
|
141
138
|
time_between_dates = today - start_date
|
|
142
139
|
days_between_dates = time_between_dates.days
|
|
143
140
|
random_number_of_days = random.randrange(days_between_dates)
|
|
@@ -165,17 +162,22 @@ def calculate_patient_hash(instance: "SensitiveMeta", salt: str = SECRET_SALT) -
|
|
|
165
162
|
if not center:
|
|
166
163
|
raise ValueError("Center is required to calculate patient hash.")
|
|
167
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
|
+
|
|
168
168
|
hash_str = get_patient_hash(
|
|
169
169
|
first_name=first_name,
|
|
170
170
|
last_name=last_name,
|
|
171
171
|
dob=dob,
|
|
172
|
-
center=center.name,
|
|
172
|
+
center=center.name, # Use center name
|
|
173
173
|
salt=salt,
|
|
174
174
|
)
|
|
175
175
|
return sha256(hash_str.encode()).hexdigest()
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
def calculate_examination_hash(
|
|
178
|
+
def calculate_examination_hash(
|
|
179
|
+
instance: "SensitiveMeta", salt: str = SECRET_SALT
|
|
180
|
+
) -> str:
|
|
179
181
|
"""Calculates the examination hash for the instance."""
|
|
180
182
|
dob = instance.patient_dob
|
|
181
183
|
first_name = instance.patient_first_name
|
|
@@ -190,13 +192,12 @@ def calculate_examination_hash(instance: "SensitiveMeta", salt: str = SECRET_SAL
|
|
|
190
192
|
if not center:
|
|
191
193
|
raise ValueError("Center is required to calculate examination hash.")
|
|
192
194
|
|
|
193
|
-
|
|
194
195
|
hash_str = get_patient_examination_hash(
|
|
195
196
|
first_name=first_name,
|
|
196
197
|
last_name=last_name,
|
|
197
198
|
dob=dob,
|
|
198
199
|
examination_date=examination_date,
|
|
199
|
-
center=center.name,
|
|
200
|
+
center=center.name, # Use center name
|
|
200
201
|
salt=salt,
|
|
201
202
|
)
|
|
202
203
|
return sha256(hash_str.encode()).hexdigest()
|
|
@@ -206,15 +207,19 @@ def create_pseudo_examiner_logic(instance: "SensitiveMeta") -> "Examiner":
|
|
|
206
207
|
"""Creates or retrieves the pseudo examiner based on instance data."""
|
|
207
208
|
first_name = instance.examiner_first_name
|
|
208
209
|
last_name = instance.examiner_last_name
|
|
209
|
-
center = instance.center
|
|
210
|
+
center = instance.center # Should be set before calling save
|
|
210
211
|
|
|
211
212
|
if not first_name or not last_name or not center:
|
|
212
|
-
logger.warning(
|
|
213
|
+
logger.warning(
|
|
214
|
+
f"Incomplete examiner info for SensitiveMeta (pk={instance.pk or 'new'}). Using default examiner."
|
|
215
|
+
)
|
|
213
216
|
# Ensure default center exists or handle appropriately
|
|
214
217
|
try:
|
|
215
|
-
default_center = Center.objects.
|
|
218
|
+
default_center = Center.objects.get(name="endoreg_db_demo")
|
|
216
219
|
except Center.DoesNotExist:
|
|
217
|
-
logger.error(
|
|
220
|
+
logger.error(
|
|
221
|
+
"Default center 'endoreg_db_demo' not found. Cannot create default examiner."
|
|
222
|
+
)
|
|
218
223
|
raise ValueError("Default center 'endoreg_db_demo' not found.")
|
|
219
224
|
|
|
220
225
|
examiner, _created = Examiner.custom_get_or_create(
|
|
@@ -232,7 +237,7 @@ def get_or_create_pseudo_patient_logic(instance: "SensitiveMeta") -> "Patient":
|
|
|
232
237
|
"""Gets or creates the pseudo patient based on instance data."""
|
|
233
238
|
# Ensure necessary fields are set
|
|
234
239
|
if not instance.patient_hash:
|
|
235
|
-
|
|
240
|
+
instance.patient_hash = calculate_patient_hash(instance)
|
|
236
241
|
if not instance.center:
|
|
237
242
|
raise ValueError("Center must be set before creating pseudo patient.")
|
|
238
243
|
if not instance.patient_gender:
|
|
@@ -254,61 +259,168 @@ def get_or_create_pseudo_patient_logic(instance: "SensitiveMeta") -> "Patient":
|
|
|
254
259
|
return patient
|
|
255
260
|
|
|
256
261
|
|
|
257
|
-
def get_or_create_pseudo_patient_examination_logic(
|
|
262
|
+
def get_or_create_pseudo_patient_examination_logic(
|
|
263
|
+
instance: "SensitiveMeta",
|
|
264
|
+
) -> "PatientExamination":
|
|
258
265
|
"""Gets or creates the pseudo patient examination based on instance data."""
|
|
259
266
|
# Ensure necessary fields are set
|
|
260
267
|
if not instance.patient_hash:
|
|
261
|
-
|
|
268
|
+
instance.patient_hash = calculate_patient_hash(instance)
|
|
262
269
|
if not instance.examination_hash:
|
|
263
|
-
|
|
270
|
+
instance.examination_hash = calculate_examination_hash(instance)
|
|
264
271
|
|
|
265
272
|
# Ensure the pseudo patient exists first, as PatientExamination might depend on it
|
|
266
273
|
if not instance.pseudo_patient_id:
|
|
267
274
|
pseudo_patient = get_or_create_pseudo_patient_logic(instance)
|
|
268
|
-
instance.pseudo_patient_id = pseudo_patient.pk
|
|
269
|
-
|
|
270
|
-
patient_examination, _created =
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
+
instance.pseudo_patient_id = pseudo_patient.pk # Assign FK directly
|
|
276
|
+
|
|
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
|
+
)
|
|
275
284
|
)
|
|
276
285
|
return patient_examination
|
|
277
286
|
|
|
278
287
|
|
|
279
|
-
@transaction.atomic
|
|
288
|
+
@transaction.atomic # Ensure all operations within save succeed or fail together
|
|
280
289
|
def perform_save_logic(instance: "SensitiveMeta") -> "Examiner":
|
|
281
290
|
"""
|
|
282
291
|
Contains the core logic for preparing a SensitiveMeta instance for saving.
|
|
283
292
|
Handles data generation (dates), hash calculation, and linking pseudo-entities.
|
|
284
|
-
|
|
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
|
|
285
340
|
"""
|
|
286
341
|
|
|
287
342
|
# --- Pre-Save Checks and Data Generation ---
|
|
288
343
|
|
|
289
344
|
# 1. Ensure DOB and Examination Date exist
|
|
290
345
|
if not instance.patient_dob:
|
|
291
|
-
logger.debug(
|
|
346
|
+
logger.debug(
|
|
347
|
+
f"SensitiveMeta (pk={instance.pk or 'new'}): Patient DOB missing, generating random."
|
|
348
|
+
)
|
|
292
349
|
instance.patient_dob = generate_random_dob()
|
|
293
350
|
if not instance.examination_date:
|
|
294
|
-
logger.debug(
|
|
351
|
+
logger.debug(
|
|
352
|
+
f"SensitiveMeta (pk={instance.pk or 'new'}): Examination date missing, generating random."
|
|
353
|
+
)
|
|
295
354
|
instance.examination_date = generate_random_examination_date()
|
|
296
355
|
|
|
297
356
|
# 2. Ensure Center exists (should be set before calling save)
|
|
298
357
|
if not instance.center:
|
|
299
358
|
raise ValueError("Center must be set before saving SensitiveMeta.")
|
|
300
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
|
+
|
|
301
399
|
# 3. Ensure Gender exists (should be set before calling save, e.g., during creation/update)
|
|
302
400
|
if not instance.patient_gender:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.")
|
|
310
414
|
|
|
311
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.
|
|
312
424
|
instance.patient_hash = calculate_patient_hash(instance)
|
|
313
425
|
instance.examination_hash = calculate_examination_hash(instance)
|
|
314
426
|
|
|
@@ -333,10 +445,59 @@ def perform_save_logic(instance: "SensitiveMeta") -> "Examiner":
|
|
|
333
445
|
return examiner_instance
|
|
334
446
|
|
|
335
447
|
|
|
336
|
-
def create_sensitive_meta_from_dict(
|
|
337
|
-
""
|
|
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
|
|
338
490
|
|
|
339
|
-
|
|
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
|
+
}
|
|
340
501
|
selected_data = {k: v for k, v in data.items() if k in field_names}
|
|
341
502
|
|
|
342
503
|
# --- Convert patient_dob if it's a date object ---
|
|
@@ -348,80 +509,153 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
|
|
|
348
509
|
logger.debug("Converted patient_dob from date to aware datetime: %s", aware_dob)
|
|
349
510
|
elif isinstance(dob, str):
|
|
350
511
|
# Handle string DOB - check if it's a field name or actual date
|
|
351
|
-
if dob == "patient_dob" or dob in [
|
|
352
|
-
|
|
512
|
+
if dob == "patient_dob" or dob in [
|
|
513
|
+
"patient_first_name",
|
|
514
|
+
"patient_last_name",
|
|
515
|
+
"examination_date",
|
|
516
|
+
]:
|
|
517
|
+
logger.warning(
|
|
518
|
+
"Skipping invalid patient_dob value '%s' - appears to be field name",
|
|
519
|
+
dob,
|
|
520
|
+
)
|
|
353
521
|
selected_data.pop("patient_dob", None) # Remove invalid value
|
|
354
522
|
else:
|
|
355
523
|
# Try to parse as date string
|
|
356
524
|
try:
|
|
357
525
|
import dateparser
|
|
358
|
-
|
|
526
|
+
|
|
527
|
+
parsed_dob = dateparser.parse(
|
|
528
|
+
dob, languages=["de"], settings={"DATE_ORDER": "DMY"}
|
|
529
|
+
)
|
|
359
530
|
if parsed_dob:
|
|
360
|
-
aware_dob = timezone.make_aware(
|
|
531
|
+
aware_dob = timezone.make_aware(
|
|
532
|
+
parsed_dob.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
533
|
+
)
|
|
361
534
|
selected_data["patient_dob"] = aware_dob
|
|
362
|
-
logger.debug(
|
|
535
|
+
logger.debug(
|
|
536
|
+
"Parsed string patient_dob '%s' to aware datetime: %s",
|
|
537
|
+
dob,
|
|
538
|
+
aware_dob,
|
|
539
|
+
)
|
|
363
540
|
else:
|
|
364
|
-
logger.warning(
|
|
541
|
+
logger.warning(
|
|
542
|
+
"Could not parse patient_dob string '%s', removing from data",
|
|
543
|
+
dob,
|
|
544
|
+
)
|
|
365
545
|
selected_data.pop("patient_dob", None)
|
|
366
546
|
except Exception as e:
|
|
367
|
-
logger.warning(
|
|
547
|
+
logger.warning(
|
|
548
|
+
"Error parsing patient_dob string '%s': %s, removing from data",
|
|
549
|
+
dob,
|
|
550
|
+
e,
|
|
551
|
+
)
|
|
368
552
|
selected_data.pop("patient_dob", None)
|
|
369
553
|
# --- End Conversion ---
|
|
370
|
-
|
|
554
|
+
|
|
371
555
|
# Similar validation for examination_date
|
|
372
556
|
exam_date = selected_data.get("examination_date")
|
|
373
557
|
if isinstance(exam_date, str):
|
|
374
|
-
if exam_date == "examination_date" or exam_date in [
|
|
375
|
-
|
|
558
|
+
if exam_date == "examination_date" or exam_date in [
|
|
559
|
+
"patient_first_name",
|
|
560
|
+
"patient_last_name",
|
|
561
|
+
"patient_dob",
|
|
562
|
+
]:
|
|
563
|
+
logger.warning(
|
|
564
|
+
"Skipping invalid examination_date value '%s' - appears to be field name",
|
|
565
|
+
exam_date,
|
|
566
|
+
)
|
|
376
567
|
selected_data.pop("examination_date", None)
|
|
377
568
|
else:
|
|
378
569
|
# Try to parse as date string
|
|
379
570
|
try:
|
|
380
571
|
# First try simple ISO format for YYYY-MM-DD
|
|
381
|
-
if len(exam_date) == 10 and exam_date.count(
|
|
572
|
+
if len(exam_date) == 10 and exam_date.count("-") == 2:
|
|
382
573
|
try:
|
|
383
574
|
from datetime import datetime as dt
|
|
384
|
-
|
|
575
|
+
|
|
576
|
+
parsed_date = dt.strptime(exam_date, "%Y-%m-%d").date()
|
|
385
577
|
selected_data["examination_date"] = parsed_date
|
|
386
|
-
logger.debug(
|
|
578
|
+
logger.debug(
|
|
579
|
+
"Parsed ISO examination_date '%s' to date: %s",
|
|
580
|
+
exam_date,
|
|
581
|
+
parsed_date,
|
|
582
|
+
)
|
|
387
583
|
except ValueError:
|
|
388
584
|
# Fall back to dateparser for complex formats
|
|
389
585
|
import dateparser
|
|
390
|
-
|
|
586
|
+
|
|
587
|
+
parsed_date = dateparser.parse(
|
|
588
|
+
exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"}
|
|
589
|
+
)
|
|
391
590
|
if parsed_date:
|
|
392
591
|
selected_data["examination_date"] = parsed_date.date()
|
|
393
|
-
logger.debug(
|
|
592
|
+
logger.debug(
|
|
593
|
+
"Parsed string examination_date '%s' to date: %s",
|
|
594
|
+
exam_date,
|
|
595
|
+
parsed_date.date(),
|
|
596
|
+
)
|
|
394
597
|
else:
|
|
395
|
-
logger.warning(
|
|
598
|
+
logger.warning(
|
|
599
|
+
"Could not parse examination_date string '%s', removing from data",
|
|
600
|
+
exam_date,
|
|
601
|
+
)
|
|
396
602
|
selected_data.pop("examination_date", None)
|
|
397
603
|
else:
|
|
398
604
|
# Use dateparser for non-ISO formats
|
|
399
605
|
import dateparser
|
|
400
|
-
|
|
606
|
+
|
|
607
|
+
parsed_date = dateparser.parse(
|
|
608
|
+
exam_date, languages=["de"], settings={"DATE_ORDER": "DMY"}
|
|
609
|
+
)
|
|
401
610
|
if parsed_date:
|
|
402
611
|
selected_data["examination_date"] = parsed_date.date()
|
|
403
|
-
logger.debug(
|
|
612
|
+
logger.debug(
|
|
613
|
+
"Parsed string examination_date '%s' to date: %s",
|
|
614
|
+
exam_date,
|
|
615
|
+
parsed_date.date(),
|
|
616
|
+
)
|
|
404
617
|
else:
|
|
405
|
-
logger.warning(
|
|
618
|
+
logger.warning(
|
|
619
|
+
"Could not parse examination_date string '%s', removing from data",
|
|
620
|
+
exam_date,
|
|
621
|
+
)
|
|
406
622
|
selected_data.pop("examination_date", None)
|
|
407
623
|
except Exception as e:
|
|
408
|
-
logger.warning(
|
|
624
|
+
logger.warning(
|
|
625
|
+
"Error parsing examination_date string '%s': %s, removing from data",
|
|
626
|
+
exam_date,
|
|
627
|
+
e,
|
|
628
|
+
)
|
|
409
629
|
selected_data.pop("examination_date", None)
|
|
410
630
|
|
|
411
|
-
# 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
|
|
412
635
|
center_name = data.get("center_name")
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
center
|
|
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)}")
|
|
417
641
|
selected_data["center"] = center
|
|
418
|
-
|
|
419
|
-
|
|
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
|
+
)
|
|
420
654
|
|
|
421
655
|
# Handle Names and Gender
|
|
422
656
|
first_name = selected_data.get("patient_first_name") or DEFAULT_UNKNOWN_NAME
|
|
423
657
|
last_name = selected_data.get("patient_last_name") or DEFAULT_UNKNOWN_NAME
|
|
424
|
-
selected_data["patient_first_name"] = first_name
|
|
658
|
+
selected_data["patient_first_name"] = first_name # Ensure defaults are set
|
|
425
659
|
selected_data["patient_last_name"] = last_name
|
|
426
660
|
|
|
427
661
|
patient_gender_input = selected_data.get("patient_gender")
|
|
@@ -432,22 +666,34 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
|
|
|
432
666
|
elif isinstance(patient_gender_input, str):
|
|
433
667
|
# Input is a string (gender name)
|
|
434
668
|
try:
|
|
435
|
-
selected_data["patient_gender"] = Gender.objects.get(
|
|
669
|
+
selected_data["patient_gender"] = Gender.objects.get(
|
|
670
|
+
name=patient_gender_input
|
|
671
|
+
)
|
|
436
672
|
except Gender.DoesNotExist:
|
|
437
|
-
logger.warning(
|
|
673
|
+
logger.warning(
|
|
674
|
+
f"Gender with name '{patient_gender_input}' provided but not found. Attempting to guess or use default."
|
|
675
|
+
)
|
|
438
676
|
# Fall through to guessing logic if provided string name is invalid
|
|
439
|
-
patient_gender_input = None
|
|
440
|
-
|
|
441
|
-
if not isinstance(
|
|
677
|
+
patient_gender_input = None # Reset to trigger guessing
|
|
678
|
+
|
|
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)
|
|
442
682
|
gender_name_to_use = guess_name_gender(first_name)
|
|
443
683
|
if not gender_name_to_use:
|
|
444
|
-
logger.warning(
|
|
684
|
+
logger.warning(
|
|
685
|
+
f"Could not guess gender for name '{first_name}'. Setting Gender to unknown."
|
|
686
|
+
)
|
|
445
687
|
gender_name_to_use = "unknown"
|
|
446
688
|
try:
|
|
447
|
-
selected_data["patient_gender"] = Gender.objects.get(
|
|
689
|
+
selected_data["patient_gender"] = Gender.objects.get(
|
|
690
|
+
name=gender_name_to_use
|
|
691
|
+
)
|
|
448
692
|
except Gender.DoesNotExist:
|
|
449
693
|
# This should ideally not happen if "unknown" gender is guaranteed to exist
|
|
450
|
-
raise ValueError(
|
|
694
|
+
raise ValueError(
|
|
695
|
+
f"Default or guessed gender '{gender_name_to_use}' does not exist in Gender table."
|
|
696
|
+
)
|
|
451
697
|
|
|
452
698
|
# Update name DB
|
|
453
699
|
update_name_db(first_name, last_name)
|
|
@@ -456,32 +702,105 @@ def create_sensitive_meta_from_dict(cls: Type["SensitiveMeta"], data: Dict[str,
|
|
|
456
702
|
sensitive_meta = cls(**selected_data)
|
|
457
703
|
|
|
458
704
|
# Call save once at the end. This triggers the custom save logic.
|
|
459
|
-
sensitive_meta.save()
|
|
705
|
+
sensitive_meta.save() # This will call perform_save_logic internally
|
|
460
706
|
|
|
461
707
|
return sensitive_meta
|
|
462
708
|
|
|
463
709
|
|
|
464
|
-
def update_sensitive_meta_from_dict(
|
|
465
|
-
""
|
|
466
|
-
|
|
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
|
+
}
|
|
467
762
|
# Exclude FKs that should not be updated directly from dict keys (handled separately or via save logic)
|
|
468
|
-
excluded_fields = {
|
|
469
|
-
selected_data = {
|
|
763
|
+
excluded_fields = {"pseudo_patient", "pseudo_examination"}
|
|
764
|
+
selected_data = {
|
|
765
|
+
k: v for k, v in data.items() if k in field_names and k not in excluded_fields
|
|
766
|
+
}
|
|
470
767
|
|
|
471
|
-
# Handle potential Center update
|
|
768
|
+
# Handle potential Center update - accept both center_name (string) and center (object)
|
|
769
|
+
from ..administration import Center
|
|
770
|
+
|
|
771
|
+
center = data.get("center") # First try direct Center object
|
|
472
772
|
center_name = data.get("center_name")
|
|
473
|
-
|
|
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
|
|
474
787
|
try:
|
|
475
|
-
|
|
476
|
-
instance.center =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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)
|
|
480
799
|
|
|
481
800
|
# Set examiner names if provided, before calling save
|
|
482
801
|
examiner_first_name = data.get("examiner_first_name")
|
|
483
802
|
examiner_last_name = data.get("examiner_last_name")
|
|
484
|
-
if examiner_first_name is not None:
|
|
803
|
+
if examiner_first_name is not None: # Allow setting empty strings
|
|
485
804
|
instance.examiner_first_name = examiner_first_name
|
|
486
805
|
if examiner_last_name is not None:
|
|
487
806
|
instance.examiner_last_name = examiner_last_name
|
|
@@ -495,10 +814,14 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
|
|
|
495
814
|
elif isinstance(patient_gender_input, str):
|
|
496
815
|
gender_input_clean = patient_gender_input.strip()
|
|
497
816
|
# Try direct case-insensitive DB lookup first
|
|
498
|
-
gender_obj = Gender.objects.filter(
|
|
817
|
+
gender_obj = Gender.objects.filter(
|
|
818
|
+
name__iexact=gender_input_clean
|
|
819
|
+
).first()
|
|
499
820
|
if gender_obj:
|
|
500
821
|
selected_data["patient_gender"] = gender_obj
|
|
501
|
-
logger.debug(
|
|
822
|
+
logger.debug(
|
|
823
|
+
f"Successfully matched gender string '{patient_gender_input}' to Gender object via iexact lookup"
|
|
824
|
+
)
|
|
502
825
|
else:
|
|
503
826
|
# Use mapping helper for fallback
|
|
504
827
|
mapped = _map_gender_string_to_standard(gender_input_clean)
|
|
@@ -506,95 +829,177 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
|
|
|
506
829
|
gender_obj = Gender.objects.filter(name__iexact=mapped).first()
|
|
507
830
|
if gender_obj:
|
|
508
831
|
selected_data["patient_gender"] = gender_obj
|
|
509
|
-
logger.info(
|
|
832
|
+
logger.info(
|
|
833
|
+
f"Mapped gender '{patient_gender_input}' to '{mapped}' via fallback mapping"
|
|
834
|
+
)
|
|
510
835
|
else:
|
|
511
|
-
logger.warning(
|
|
512
|
-
|
|
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()
|
|
513
842
|
if unknown_gender:
|
|
514
843
|
selected_data["patient_gender"] = unknown_gender
|
|
515
|
-
logger.warning(
|
|
844
|
+
logger.warning(
|
|
845
|
+
f"Using 'unknown' gender as fallback for '{patient_gender_input}'"
|
|
846
|
+
)
|
|
516
847
|
else:
|
|
517
|
-
logger.error(
|
|
848
|
+
logger.error(
|
|
849
|
+
f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update."
|
|
850
|
+
)
|
|
518
851
|
selected_data.pop("patient_gender", None)
|
|
519
852
|
else:
|
|
520
853
|
# Last resort: try to get 'unknown' gender
|
|
521
|
-
unknown_gender = Gender.objects.filter(
|
|
854
|
+
unknown_gender = Gender.objects.filter(
|
|
855
|
+
name__iexact="unknown"
|
|
856
|
+
).first()
|
|
522
857
|
if unknown_gender:
|
|
523
858
|
selected_data["patient_gender"] = unknown_gender
|
|
524
|
-
logger.warning(
|
|
859
|
+
logger.warning(
|
|
860
|
+
f"Using 'unknown' gender as fallback for '{patient_gender_input}' (no mapping)"
|
|
861
|
+
)
|
|
525
862
|
else:
|
|
526
|
-
logger.error(
|
|
863
|
+
logger.error(
|
|
864
|
+
f"No 'unknown' gender found in database. Cannot handle gender '{patient_gender_input}'. Skipping gender update."
|
|
865
|
+
)
|
|
527
866
|
selected_data.pop("patient_gender", None)
|
|
528
867
|
else:
|
|
529
|
-
logger.warning(
|
|
868
|
+
logger.warning(
|
|
869
|
+
f"Unexpected patient_gender type {type(patient_gender_input)}: {patient_gender_input}. Skipping gender update."
|
|
870
|
+
)
|
|
530
871
|
selected_data.pop("patient_gender", None)
|
|
531
872
|
except Exception as e:
|
|
532
|
-
logger.exception(
|
|
873
|
+
logger.exception(
|
|
874
|
+
f"Error handling patient_gender '{patient_gender_input}': {e}. Skipping gender update."
|
|
875
|
+
)
|
|
533
876
|
selected_data.pop("patient_gender", None)
|
|
534
877
|
|
|
535
878
|
# Update other attributes from selected_data
|
|
536
879
|
patient_name_changed = False
|
|
537
880
|
for k, v in selected_data.items():
|
|
538
|
-
#
|
|
539
|
-
if
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
542
885
|
|
|
886
|
+
# Avoid overwriting examiner names if they were just explicitly set
|
|
887
|
+
if (
|
|
888
|
+
k not in ["examiner_first_name", "examiner_last_name"]
|
|
889
|
+
or (k == "examiner_first_name" and examiner_first_name is None)
|
|
890
|
+
or (k == "examiner_last_name" and examiner_last_name is None)
|
|
891
|
+
):
|
|
543
892
|
try:
|
|
544
893
|
# --- Convert patient_dob if it's a date object ---
|
|
545
894
|
value_to_set = v
|
|
546
895
|
if k == "patient_dob":
|
|
547
896
|
if isinstance(v, date) and not isinstance(v, datetime):
|
|
548
|
-
aware_dob = timezone.make_aware(
|
|
897
|
+
aware_dob = timezone.make_aware(
|
|
898
|
+
datetime.combine(v, datetime.min.time())
|
|
899
|
+
)
|
|
549
900
|
value_to_set = aware_dob
|
|
550
|
-
logger.debug(
|
|
901
|
+
logger.debug(
|
|
902
|
+
"Converted patient_dob from date to aware datetime during update: %s",
|
|
903
|
+
aware_dob,
|
|
904
|
+
)
|
|
551
905
|
elif isinstance(v, str):
|
|
552
906
|
# Handle string DOB - check if it's a field name or actual date
|
|
553
|
-
if v == "patient_dob" or v in [
|
|
554
|
-
|
|
907
|
+
if v == "patient_dob" or v in [
|
|
908
|
+
"patient_first_name",
|
|
909
|
+
"patient_last_name",
|
|
910
|
+
"examination_date",
|
|
911
|
+
]:
|
|
912
|
+
logger.warning(
|
|
913
|
+
"Skipping invalid patient_dob value '%s' during update - appears to be field name",
|
|
914
|
+
v,
|
|
915
|
+
)
|
|
555
916
|
continue # Skip this field
|
|
556
917
|
else:
|
|
557
918
|
# Try to parse as date string
|
|
558
919
|
try:
|
|
559
920
|
import dateparser
|
|
560
|
-
|
|
921
|
+
|
|
922
|
+
parsed_dob = dateparser.parse(
|
|
923
|
+
v, languages=["de"], settings={"DATE_ORDER": "DMY"}
|
|
924
|
+
)
|
|
561
925
|
if parsed_dob:
|
|
562
|
-
value_to_set = timezone.make_aware(
|
|
563
|
-
|
|
926
|
+
value_to_set = timezone.make_aware(
|
|
927
|
+
parsed_dob.replace(
|
|
928
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
logger.debug(
|
|
932
|
+
"Parsed string patient_dob '%s' during update to aware datetime: %s",
|
|
933
|
+
v,
|
|
934
|
+
value_to_set,
|
|
935
|
+
)
|
|
564
936
|
else:
|
|
565
|
-
logger.warning(
|
|
937
|
+
logger.warning(
|
|
938
|
+
"Could not parse patient_dob string '%s' during update, skipping",
|
|
939
|
+
v,
|
|
940
|
+
)
|
|
566
941
|
continue
|
|
567
942
|
except Exception as e:
|
|
568
|
-
logger.warning(
|
|
943
|
+
logger.warning(
|
|
944
|
+
"Error parsing patient_dob string '%s' during update: %s, skipping",
|
|
945
|
+
v,
|
|
946
|
+
e,
|
|
947
|
+
)
|
|
569
948
|
continue
|
|
570
949
|
elif k == "examination_date" and isinstance(v, str):
|
|
571
|
-
if v == "examination_date" or v in [
|
|
572
|
-
|
|
950
|
+
if v == "examination_date" or v in [
|
|
951
|
+
"patient_first_name",
|
|
952
|
+
"patient_last_name",
|
|
953
|
+
"patient_dob",
|
|
954
|
+
]:
|
|
955
|
+
logger.warning(
|
|
956
|
+
"Skipping invalid examination_date value '%s' during update - appears to be field name",
|
|
957
|
+
v,
|
|
958
|
+
)
|
|
573
959
|
continue
|
|
574
960
|
else:
|
|
575
961
|
# Try to parse as date string
|
|
576
962
|
try:
|
|
577
963
|
import dateparser
|
|
578
|
-
|
|
964
|
+
|
|
965
|
+
parsed_date = dateparser.parse(
|
|
966
|
+
v, languages=["de"], settings={"DATE_ORDER": "DMY"}
|
|
967
|
+
)
|
|
579
968
|
if parsed_date:
|
|
580
969
|
value_to_set = parsed_date.date()
|
|
581
|
-
logger.debug(
|
|
970
|
+
logger.debug(
|
|
971
|
+
"Parsed string examination_date '%s' during update to date: %s",
|
|
972
|
+
v,
|
|
973
|
+
value_to_set,
|
|
974
|
+
)
|
|
582
975
|
else:
|
|
583
|
-
logger.warning(
|
|
976
|
+
logger.warning(
|
|
977
|
+
"Could not parse examination_date string '%s' during update, skipping",
|
|
978
|
+
v,
|
|
979
|
+
)
|
|
584
980
|
continue
|
|
585
981
|
except Exception as e:
|
|
586
|
-
logger.warning(
|
|
982
|
+
logger.warning(
|
|
983
|
+
"Error parsing examination_date string '%s' during update: %s, skipping",
|
|
984
|
+
v,
|
|
985
|
+
e,
|
|
986
|
+
)
|
|
587
987
|
continue
|
|
588
988
|
# --- End Conversion ---
|
|
589
989
|
|
|
590
990
|
# Check if patient name is changing
|
|
591
|
-
if
|
|
991
|
+
if (
|
|
992
|
+
k in ["patient_first_name", "patient_last_name"]
|
|
993
|
+
and getattr(instance, k) != value_to_set
|
|
994
|
+
):
|
|
592
995
|
patient_name_changed = True
|
|
593
|
-
|
|
594
|
-
setattr(instance, k, value_to_set)
|
|
595
|
-
|
|
996
|
+
|
|
997
|
+
setattr(instance, k, value_to_set) # Use value_to_set
|
|
998
|
+
|
|
596
999
|
except Exception as e:
|
|
597
|
-
logger.error(
|
|
1000
|
+
logger.error(
|
|
1001
|
+
f"Error setting attribute '{k}' to '{v}': {e}. Skipping this field."
|
|
1002
|
+
)
|
|
598
1003
|
continue
|
|
599
1004
|
|
|
600
1005
|
# Update name DB if patient names changed
|
|
@@ -617,7 +1022,7 @@ def update_sensitive_meta_from_dict(instance: "SensitiveMeta", data: Dict[str, A
|
|
|
617
1022
|
def update_or_create_sensitive_meta_from_dict(
|
|
618
1023
|
cls: Type["SensitiveMeta"],
|
|
619
1024
|
data: Dict[str, Any],
|
|
620
|
-
instance: Optional["SensitiveMeta"] = None
|
|
1025
|
+
instance: Optional["SensitiveMeta"] = None,
|
|
621
1026
|
) -> "SensitiveMeta":
|
|
622
1027
|
"""Logic to update or create a SensitiveMeta instance from a dictionary."""
|
|
623
1028
|
# Check if the instance already exists based on unique fields
|
|
@@ -632,9 +1037,9 @@ def update_or_create_sensitive_meta_from_dict(
|
|
|
632
1037
|
def _map_gender_string_to_standard(gender_str: str) -> Optional[str]:
|
|
633
1038
|
"""Maps various gender string inputs to standard gender names used in the DB."""
|
|
634
1039
|
mapping = {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1040
|
+
"male": ["male", "m", "männlich", "man"],
|
|
1041
|
+
"female": ["female", "f", "weiblich", "woman"],
|
|
1042
|
+
"unknown": ["unknown", "unbekannt", "other", "diverse", ""],
|
|
638
1043
|
}
|
|
639
1044
|
gender_lower = gender_str.strip().lower()
|
|
640
1045
|
for standard, variants in mapping.items():
|