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
@@ -1,483 +1,367 @@
1
1
  # api/viewsets/lookup.py
2
+ import logging
3
+ from ast import literal_eval
4
+ from collections.abc import Mapping
2
5
 
3
- from rest_framework import viewsets, status
4
-
6
+ from django.core.cache import cache
7
+ from rest_framework import status, viewsets
5
8
  from rest_framework.decorators import action
6
-
9
+ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
7
10
  from rest_framework.response import Response
8
11
 
9
-
10
- from rest_framework.parsers import JSONParser, FormParser, MultiPartParser
11
-
12
-
13
- from endoreg_db.services.lookup_store import LookupStore, DEFAULT_TTL_SECONDS
14
-
15
-
16
12
  # Use module import so tests can monkeypatch functions on the module
17
-
18
-
19
13
  from endoreg_db.services import lookup_service as ls
20
-
21
-
14
+ from endoreg_db.services.lookup_store import DEFAULT_TTL_SECONDS, LookupStore
22
15
  from endoreg_db.utils.permissions import EnvironmentAwarePermission
23
16
 
24
-
25
- from django.core.cache import cache
26
-
27
-
28
- import logging
29
-
30
-
31
- from ast import literal_eval
32
-
33
-
34
- from collections.abc import Mapping
35
-
36
-
37
-
38
-
39
-
40
17
  ORIGIN_MAP_PREFIX = "lookup:origin:"
41
-
42
-
43
18
  ISSUED_MAP_PREFIX = "lookup:issued_for_internal:"
44
19
 
45
-
46
-
47
-
48
-
49
20
  logger = logging.getLogger(__name__)
50
21
 
51
22
 
52
-
53
23
  class LookupViewSet(viewsets.ViewSet):
54
-
24
+ """
25
+ Django REST Framework ViewSet for managing lookup sessions.
26
+
27
+ This ViewSet provides REST API endpoints for the lookup system, which
28
+ evaluates medical examination requirements against patient data. It uses
29
+ token-based sessions stored in Django cache to maintain state across
30
+ multiple client requests.
31
+
32
+ Key features:
33
+ - Session initialization with patient examination data
34
+ - Retrieval of lookup data by token
35
+ - Partial updates to session data with automatic recomputation
36
+ - Manual recomputation of derived data
37
+ - Automatic session recovery for expired tokens
38
+
39
+ The API supports both internal service tokens and public client tokens,
40
+ with origin mapping to enable session restart functionality.
41
+
42
+ Endpoints:
43
+ - POST /init: Initialize new lookup session
44
+ - GET /{token}/all: Retrieve complete session data
45
+ - GET/PATCH /{token}/parts: Get/update partial session data
46
+ - POST /{token}/recompute: Manually trigger recomputation
47
+ """
55
48
 
56
49
  permission_classes = [EnvironmentAwarePermission]
57
-
58
-
59
50
  parser_classes = (JSONParser, FormParser, MultiPartParser)
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
- INPUT_KEYS = {"patient_examination_id", "selectedRequirementSetIds", "selectedChoices"}
70
-
71
-
51
+ INPUT_KEYS = {
52
+ "patient_examination_id",
53
+ "selectedRequirementSetIds",
54
+ "selectedChoices",
55
+ }
72
56
 
73
57
  @action(detail=False, methods=["post"])
74
-
75
58
  def init(self, request):
59
+ """
60
+ Initialize a new lookup session for a patient examination.
76
61
 
62
+ Creates a new token-based session containing initial lookup data
63
+ for the specified patient examination. Handles malformed payloads
64
+ and supports multiple initialization requests for the same examination
65
+ by issuing fresh public tokens while maintaining internal state.
77
66
 
67
+ Request body:
68
+ patient_examination_id: Integer ID of the patient examination
78
69
 
70
+ Returns:
71
+ JSON response with session token
79
72
 
73
+ Raises:
74
+ 400: Invalid patient_examination_id or creation failure
75
+ """
80
76
  try:
81
-
82
-
83
- debug_data = getattr(request, 'data', None)
84
-
85
-
86
- raw_post = getattr(getattr(request, '_request', None), 'POST', None)
87
-
88
-
77
+ debug_data = getattr(request, "data", None)
78
+ raw_post = getattr(getattr(request, "_request", None), "POST", None)
89
79
  body_preview = None
90
-
91
-
92
80
  try:
93
-
94
-
95
- body = getattr(getattr(request, '_request', None), 'body', b'')
96
-
97
-
81
+ body = getattr(getattr(request, "_request", None), "body", b"")
98
82
  body_preview = body[:200]
99
83
 
100
-
101
84
  except Exception:
102
-
103
-
104
85
  body_preview = None
105
-
106
-
107
- logger.debug("lookup.init incoming: data=%r POST=%r body[:200]=%r", debug_data, raw_post, body_preview)
108
-
86
+ logger.debug(
87
+ "lookup.init incoming: data=%r POST=%r body[:200]=%r",
88
+ debug_data,
89
+ raw_post,
90
+ body_preview,
91
+ )
109
92
 
110
93
  except Exception:
111
-
112
-
113
94
  pass
114
-
115
-
116
-
117
-
118
-
119
95
  # Prefer DRF data
120
-
121
-
122
- raw_pe = request.data.get("patient_examination_id") if hasattr(request, "data") else None
123
-
124
-
125
-
126
-
127
-
96
+ raw_pe = (
97
+ request.data.get("patient_examination_id")
98
+ if hasattr(request, "data")
99
+ else None
100
+ )
128
101
  # Fallback: parse malformed form payload where the entire dict was sent as a single key string
129
102
 
130
-
131
103
  if raw_pe is None:
132
-
133
-
134
- for candidate in (getattr(getattr(request, '_request', None), 'POST', None), request.data if hasattr(request, 'data') else None):
135
-
136
-
104
+ for candidate in (
105
+ getattr(getattr(request, "_request", None), "POST", None),
106
+ request.data if hasattr(request, "data") else None,
107
+ ):
137
108
  try:
138
-
139
-
140
109
  if isinstance(candidate, Mapping) and len(candidate.keys()) == 1:
141
-
142
-
143
110
  only_key = next(iter(candidate.keys()))
144
-
145
-
146
- if isinstance(only_key, str) and only_key.startswith('{') and only_key.endswith('}'):
147
-
148
-
111
+ if (
112
+ isinstance(only_key, str)
113
+ and only_key.startswith("{")
114
+ and only_key.endswith("}")
115
+ ):
149
116
  try:
150
-
151
-
152
117
  parsed = literal_eval(only_key)
153
-
154
-
155
- if isinstance(parsed, dict) and 'patient_examination_id' in parsed:
156
-
157
-
158
- raw_pe = parsed.get('patient_examination_id')
159
-
160
-
161
- logger.debug("lookup.init recovered pe_id from malformed payload: %r", raw_pe)
162
-
163
-
118
+ if (
119
+ isinstance(parsed, dict)
120
+ and "patient_examination_id" in parsed
121
+ ):
122
+ raw_pe = parsed.get("patient_examination_id")
123
+ logger.debug(
124
+ "lookup.init recovered pe_id from malformed payload: %r",
125
+ raw_pe,
126
+ )
164
127
  break
165
128
 
166
-
167
129
  except Exception:
168
-
169
-
170
130
  pass
171
131
 
172
-
173
132
  except Exception:
174
-
175
-
176
133
  pass
177
134
 
178
-
179
-
180
-
181
-
182
135
  # Fallback to query params
183
-
184
-
185
136
  if raw_pe is None:
186
-
187
-
188
137
  raw_pe = request.query_params.get("patient_examination_id")
189
138
 
190
-
191
-
192
-
193
-
194
139
  logger.debug("lookup.init raw_pe=%r type=%s", raw_pe, type(raw_pe))
195
140
 
196
-
197
-
198
-
199
-
200
141
  # Normalize potential list/tuple inputs (e.g., from form submissions)
201
-
202
-
203
142
  if isinstance(raw_pe, (list, tuple)):
204
-
205
-
206
143
  raw_pe = raw_pe[0] if raw_pe else None
207
144
 
208
-
209
145
  if raw_pe in (None, ""):
210
-
211
-
212
- return Response({"detail": "patient_examination_id must be an integer"}, status=status.HTTP_400_BAD_REQUEST)
213
-
214
-
215
-
216
-
146
+ return Response(
147
+ {"detail": "patient_examination_id must be an integer"},
148
+ status=status.HTTP_400_BAD_REQUEST,
149
+ )
217
150
 
218
151
  # Coerce to int robustly
219
-
220
-
221
152
  try:
222
-
223
-
224
153
  pe_id = int(str(raw_pe))
225
154
 
226
-
227
155
  except (TypeError, ValueError):
228
-
229
-
230
156
  logger.warning("lookup.init failed to int() raw_pe=%r", raw_pe)
231
-
232
-
233
- return Response({"detail": "patient_examination_id must be an integer"}, status=status.HTTP_400_BAD_REQUEST)
234
-
157
+ return Response(
158
+ {"detail": "patient_examination_id must be an integer"},
159
+ status=status.HTTP_400_BAD_REQUEST,
160
+ )
235
161
 
236
162
  if pe_id <= 0:
237
-
238
-
239
- return Response({"detail": "patient_examination_id must be positive"}, status=status.HTTP_400_BAD_REQUEST)
240
-
241
-
242
-
243
-
163
+ return Response(
164
+ {"detail": "patient_examination_id must be positive"},
165
+ status=status.HTTP_400_BAD_REQUEST,
166
+ )
244
167
 
245
168
  try:
246
-
247
-
248
169
  # Create internal session via service (may seed its own token/cache)
249
-
250
-
251
170
  internal_token = ls.create_lookup_token_for_pe(pe_id)
252
-
253
-
254
171
  internal_data = LookupStore(token=internal_token).get_all()
255
172
 
256
-
257
-
258
-
259
-
260
173
  issued_key = f"{ISSUED_MAP_PREFIX}{internal_token}"
261
174
 
262
-
263
175
  issued_count = cache.get(issued_key, 0)
264
176
 
265
-
266
-
267
-
268
-
269
177
  if issued_count == 0:
270
-
271
-
272
178
  # First issuance: return the service token directly
273
179
 
274
-
275
180
  token_to_return = internal_token
276
181
 
277
-
278
182
  cache.set(issued_key, 1, DEFAULT_TTL_SECONDS)
279
183
 
280
-
281
184
  else:
282
-
283
-
284
185
  # Subsequent inits for same internal token: issue a fresh public token seeded with internal data
285
186
 
286
-
287
187
  public_store = LookupStore()
288
188
 
289
-
290
- token_to_return = public_store.init(initial=internal_data, ttl=DEFAULT_TTL_SECONDS)
291
-
189
+ token_to_return = public_store.init(
190
+ initial=internal_data, ttl=DEFAULT_TTL_SECONDS
191
+ )
292
192
 
293
193
  cache.set(issued_key, issued_count + 1, DEFAULT_TTL_SECONDS)
294
194
 
295
-
296
-
297
-
298
-
299
195
  # Persist origin mapping so we can restart expired sessions
300
196
 
301
-
302
- cache.set(f"{ORIGIN_MAP_PREFIX}{token_to_return}", pe_id, DEFAULT_TTL_SECONDS)
197
+ cache.set(
198
+ f"{ORIGIN_MAP_PREFIX}{token_to_return}", pe_id, DEFAULT_TTL_SECONDS
199
+ )
303
200
 
304
201
  except Exception as e:
305
-
306
202
  return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
307
203
 
308
-
309
204
  return Response({"token": token_to_return}, status=status.HTTP_201_CREATED)
310
205
 
311
-
312
-
313
206
  @action(detail=True, methods=["get"], url_path="all")
314
-
315
207
  def get_all(self, request, pk=None):
208
+ """
209
+ Retrieve complete lookup data for a session token.
316
210
 
211
+ Returns all stored data for the given token. If data is not found,
212
+ attempts automatic session recovery using persisted origin mapping.
317
213
 
318
- if not pk:
214
+ Args:
215
+ pk: Session token
319
216
 
217
+ Returns:
218
+ Complete lookup data dictionary
320
219
 
321
- return Response({"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND)
220
+ Raises:
221
+ 404: Token not found and recovery failed
222
+ """
322
223
 
323
- store = LookupStore(token=pk)
224
+ if not pk:
225
+ return Response(
226
+ {"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND
227
+ )
324
228
 
229
+ store = LookupStore(token=pk)
325
230
 
326
231
  try:
327
-
328
-
329
232
  validated_data = store.validate_and_recover_data(pk)
330
233
 
331
-
332
234
  except Exception:
333
-
334
-
335
235
  validated_data = None
336
236
 
337
237
  if validated_data is None:
338
-
339
-
340
238
  # Try automatic restart once using persisted origin mapping
341
239
 
342
-
343
240
  pe_id = cache.get(f"{ORIGIN_MAP_PREFIX}{pk}")
344
241
 
345
-
346
242
  if pe_id:
347
-
348
-
349
243
  try:
350
-
351
-
352
244
  internal_token = ls.create_lookup_token_for_pe(int(pe_id))
353
245
 
354
-
355
246
  new_data = LookupStore(token=internal_token).get_all()
356
247
 
357
-
358
248
  if not new_data:
359
-
360
-
361
- return Response({"error": "Lookup data not available after restart", "token": pk}, status=status.HTTP_404_NOT_FOUND)
362
-
249
+ return Response(
250
+ {
251
+ "error": "Lookup data not available after restart",
252
+ "token": pk,
253
+ },
254
+ status=status.HTTP_404_NOT_FOUND,
255
+ )
363
256
 
364
257
  return Response(new_data, status=status.HTTP_200_OK)
365
258
 
366
-
367
259
  except Exception:
368
-
369
-
370
260
  pass
371
261
 
372
-
373
- return Response({"error": "Lookup data not found or expired", "token": pk}, status=status.HTTP_404_NOT_FOUND)
262
+ return Response(
263
+ {"error": "Lookup data not found or expired", "token": pk},
264
+ status=status.HTTP_404_NOT_FOUND,
265
+ )
374
266
 
375
267
  return Response(store.get_all())
376
268
 
377
-
378
-
379
269
  @action(detail=True, methods=["get", "patch"], url_path="parts")
380
-
381
270
  def parts(self, request, pk=None):
271
+ """
272
+ Get or update partial lookup data for a session.
382
273
 
274
+ GET: Retrieve specific keys from the session data.
275
+ PATCH: Update session data and trigger recomputation if input keys changed.
383
276
 
384
- if not pk:
277
+ GET query params:
278
+ keys: Comma-separated list of keys to retrieve
385
279
 
280
+ PATCH body:
281
+ updates: Dictionary of key-value pairs to update
386
282
 
387
- return Response({"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND)
283
+ Args:
284
+ pk: Session token
388
285
 
389
- store = LookupStore(token=pk)
286
+ Returns:
287
+ GET: Dictionary with requested keys
288
+ PATCH: Success confirmation
390
289
 
290
+ Raises:
291
+ 404: Token not found
292
+ 400: Invalid request parameters
293
+ """
391
294
 
295
+ if not pk:
296
+ return Response(
297
+ {"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND
298
+ )
392
299
 
393
- if request.method == "GET":
300
+ store = LookupStore(token=pk)
394
301
 
302
+ if request.method == "GET":
395
303
  keys_param = request.query_params.get("keys", "")
396
304
 
397
305
  keys = [k.strip() for k in keys_param.split(",") if k.strip()]
398
306
 
399
307
  if not keys:
400
-
401
- return Response({"detail": "Provide ?keys=key1,key2"}, status=status.HTTP_400_BAD_REQUEST)
402
-
308
+ return Response(
309
+ {"detail": "Provide ?keys=key1,key2"},
310
+ status=status.HTTP_400_BAD_REQUEST,
311
+ )
403
312
 
404
313
  try:
405
-
406
-
407
314
  return Response(store.get_many(keys))
408
315
 
409
-
410
316
  except Exception:
411
-
412
-
413
- return Response({"detail": "Lookup data not found or expired"}, status=status.HTTP_404_NOT_FOUND)
414
-
415
-
317
+ return Response(
318
+ {"detail": "Lookup data not found or expired"},
319
+ status=status.HTTP_404_NOT_FOUND,
320
+ )
416
321
 
417
322
  # PATCH
418
323
 
419
324
  updates = request.data.get("updates", {})
420
325
 
421
-
422
326
  if not isinstance(updates, dict) or not updates:
423
-
424
-
425
- return Response({"detail": "updates must be a non-empty object"}, status=status.HTTP_400_BAD_REQUEST)
426
-
427
-
327
+ return Response(
328
+ {"detail": "updates must be a non-empty object"},
329
+ status=status.HTTP_400_BAD_REQUEST,
330
+ )
428
331
 
429
332
  store.set_many(updates)
430
333
 
431
-
432
-
433
334
  if any(key in self.INPUT_KEYS for key in updates.keys()):
434
-
435
335
  try:
436
-
437
-
438
336
  ls.recompute_lookup(pk)
439
337
 
440
338
  except Exception as e:
441
-
442
339
  import logging
443
340
 
444
-
445
- logging.getLogger(__name__).error("Failed to recompute after patch for token %s: %s", pk, e)
446
-
447
-
341
+ logging.getLogger(__name__).error(
342
+ "Failed to recompute after patch for token %s: %s", pk, e
343
+ )
448
344
 
449
345
  return Response({"ok": True, "token": pk}, status=status.HTTP_200_OK)
450
346
 
451
-
452
-
453
347
  @action(detail=True, methods=["post"], url_path="recompute")
454
-
455
348
  def recompute(self, request, pk=None):
456
-
457
349
  """Recompute lookup data based on current PatientExamination and user selections"""
458
350
 
459
-
460
351
  if not pk:
461
-
462
-
463
- return Response({"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND)
352
+ return Response(
353
+ {"detail": "Token required"}, status=status.HTTP_404_NOT_FOUND
354
+ )
464
355
 
465
356
  try:
466
-
467
-
468
357
  updates = ls.recompute_lookup(pk)
469
358
 
470
-
471
- return Response({"ok": True, "token": pk, "updates": updates}, status=status.HTTP_200_OK)
472
-
473
-
474
-
475
-
359
+ return Response(
360
+ {"ok": True, "token": pk, "updates": updates}, status=status.HTTP_200_OK
361
+ )
476
362
 
477
363
  except ValueError as e:
478
-
479
364
  return Response({"detail": str(e)}, status=status.HTTP_404_NOT_FOUND)
480
365
 
481
366
  except Exception as e:
482
-
483
- return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
367
+ return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -35,10 +35,8 @@ __all__ = [
35
35
  'VideoApplyMaskView',
36
36
  'VideoRemoveFramesView',
37
37
 
38
- # Phase 1.2: Media Management Views ✅ IMPLEMENTED
39
38
  'VideoMediaView',
40
39
 
41
- # TODO Phase 1.2+: Future views
42
40
  'VideoCorrectionView',
43
41
  # 'TaskStatusView',
44
42
  # 'VideoDownloadProcessedView',