Skip to content

Commit bb2b17d

Browse files
authored
Merge pull request #6 from chrisgermon/claude/add-dicom-log-checker-8ADmK
Add DICOM log checker to settings page
2 parents 3a2d1aa + 34bad43 commit bb2b17d

5 files changed

Lines changed: 901 additions & 11 deletions

File tree

app/dicom_logger.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""DICOM operation logging module for tracking worklist queries and DICOM sends"""
2+
3+
import json
4+
from pathlib import Path
5+
from datetime import datetime
6+
from typing import List, Dict, Optional
7+
from enum import Enum
8+
import threading
9+
10+
11+
class DicomOperationType(str, Enum):
12+
WORKLIST_QUERY = "worklist_query"
13+
WORKLIST_QUERY_ALL = "worklist_query_all"
14+
DICOM_SEND = "dicom_send"
15+
DICOM_VERIFY = "dicom_verify"
16+
17+
18+
class DicomLogger:
19+
"""Thread-safe logger for DICOM operations"""
20+
21+
_instance = None
22+
_lock = threading.Lock()
23+
24+
def __new__(cls):
25+
if cls._instance is None:
26+
with cls._lock:
27+
if cls._instance is None:
28+
cls._instance = super().__new__(cls)
29+
cls._instance._initialized = False
30+
return cls._instance
31+
32+
def __init__(self):
33+
if self._initialized:
34+
return
35+
36+
self._initialized = True
37+
self._file_lock = threading.Lock()
38+
self.log_file = Path(__file__).parent.parent / "dicom_logs.json"
39+
self.max_logs = 500 # Keep last 500 entries
40+
41+
# Initialize log file if it doesn't exist
42+
if not self.log_file.exists():
43+
self._save_logs([])
44+
45+
def _load_logs(self) -> List[Dict]:
46+
"""Load logs from file"""
47+
try:
48+
if self.log_file.exists():
49+
content = self.log_file.read_text()
50+
if content.strip():
51+
return json.loads(content)
52+
return []
53+
except (json.JSONDecodeError, IOError):
54+
return []
55+
56+
def _save_logs(self, logs: List[Dict]) -> None:
57+
"""Save logs to file"""
58+
with self._file_lock:
59+
self.log_file.write_text(json.dumps(logs, indent=2))
60+
61+
def log(
62+
self,
63+
operation_type: DicomOperationType,
64+
success: bool,
65+
message: str,
66+
details: Optional[Dict] = None
67+
) -> None:
68+
"""Add a log entry"""
69+
entry = {
70+
"timestamp": datetime.now().isoformat(),
71+
"operation": operation_type.value,
72+
"success": success,
73+
"message": message,
74+
"details": details or {}
75+
}
76+
77+
logs = self._load_logs()
78+
logs.insert(0, entry) # Add to beginning (newest first)
79+
80+
# Trim to max size
81+
if len(logs) > self.max_logs:
82+
logs = logs[:self.max_logs]
83+
84+
self._save_logs(logs)
85+
86+
def log_worklist_query(
87+
self,
88+
success: bool,
89+
message: str,
90+
host: str,
91+
port: int,
92+
ae_title: str,
93+
calling_ae: str,
94+
patient_name: Optional[str] = None,
95+
patient_id: Optional[str] = None,
96+
accession_number: Optional[str] = None,
97+
modality: Optional[str] = None,
98+
results_count: int = 0
99+
) -> None:
100+
"""Log a worklist query operation"""
101+
details = {
102+
"host": host,
103+
"port": port,
104+
"ae_title": ae_title,
105+
"calling_ae": calling_ae,
106+
"filters": {
107+
"patient_name": patient_name,
108+
"patient_id": patient_id,
109+
"accession_number": accession_number,
110+
"modality": modality
111+
},
112+
"results_count": results_count
113+
}
114+
self.log(DicomOperationType.WORKLIST_QUERY, success, message, details)
115+
116+
def log_worklist_query_all(
117+
self,
118+
success: bool,
119+
message: str,
120+
host: str,
121+
port: int,
122+
ae_title: str,
123+
ae_titles_queried: int,
124+
successful_queries: int,
125+
results_count: int
126+
) -> None:
127+
"""Log a query-all worklist operation"""
128+
details = {
129+
"host": host,
130+
"port": port,
131+
"ae_title": ae_title,
132+
"ae_titles_queried": ae_titles_queried,
133+
"successful_queries": successful_queries,
134+
"results_count": results_count
135+
}
136+
self.log(DicomOperationType.WORKLIST_QUERY_ALL, success, message, details)
137+
138+
def log_dicom_send(
139+
self,
140+
success: bool,
141+
message: str,
142+
destination_name: str,
143+
host: str,
144+
port: int,
145+
ae_title: str,
146+
calling_ae: str,
147+
file_path: Optional[str] = None,
148+
patient_id: Optional[str] = None,
149+
patient_name: Optional[str] = None,
150+
study_uid: Optional[str] = None,
151+
accession_number: Optional[str] = None
152+
) -> None:
153+
"""Log a DICOM send operation"""
154+
details = {
155+
"destination": {
156+
"name": destination_name,
157+
"host": host,
158+
"port": port,
159+
"ae_title": ae_title,
160+
"calling_ae": calling_ae
161+
},
162+
"file_path": file_path,
163+
"patient_id": patient_id,
164+
"patient_name": patient_name,
165+
"study_uid": study_uid,
166+
"accession_number": accession_number
167+
}
168+
self.log(DicomOperationType.DICOM_SEND, success, message, details)
169+
170+
def log_dicom_verify(
171+
self,
172+
success: bool,
173+
message: str,
174+
destination_name: str,
175+
host: str,
176+
port: int,
177+
ae_title: str,
178+
calling_ae: str
179+
) -> None:
180+
"""Log a DICOM verification (C-ECHO) operation"""
181+
details = {
182+
"destination": {
183+
"name": destination_name,
184+
"host": host,
185+
"port": port,
186+
"ae_title": ae_title,
187+
"calling_ae": calling_ae
188+
}
189+
}
190+
self.log(DicomOperationType.DICOM_VERIFY, success, message, details)
191+
192+
def get_logs(
193+
self,
194+
operation_type: Optional[DicomOperationType] = None,
195+
success: Optional[bool] = None,
196+
limit: int = 100,
197+
offset: int = 0
198+
) -> tuple[List[Dict], int]:
199+
"""
200+
Get log entries with optional filtering
201+
202+
Returns:
203+
tuple: (logs, total_count)
204+
"""
205+
logs = self._load_logs()
206+
207+
# Filter by operation type
208+
if operation_type:
209+
logs = [l for l in logs if l.get("operation") == operation_type.value]
210+
211+
# Filter by success status
212+
if success is not None:
213+
logs = [l for l in logs if l.get("success") == success]
214+
215+
total_count = len(logs)
216+
217+
# Apply pagination
218+
logs = logs[offset:offset + limit]
219+
220+
return logs, total_count
221+
222+
def clear_logs(self) -> None:
223+
"""Clear all logs"""
224+
self._save_logs([])
225+
226+
227+
# Singleton instance
228+
dicom_logger = DicomLogger()

app/dicom_sender.py

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pydicom
55

66
from app.models import DicomDestination
7+
from app.dicom_logger import dicom_logger
78

89
class DicomSender:
910
"""Handle DICOM C-STORE operations"""
@@ -23,10 +24,22 @@ def send_dicom(
2324
Returns:
2425
tuple: (success, message)
2526
"""
27+
# Extract DICOM metadata for logging
28+
patient_id = None
29+
patient_name = None
30+
study_uid = None
31+
accession_number = None
32+
2633
try:
2734
# Read DICOM file
2835
ds = pydicom.dcmread(str(dicom_file_path))
2936

37+
# Extract metadata for logging
38+
patient_id = str(ds.get("PatientID", ""))
39+
patient_name = str(ds.get("PatientName", ""))
40+
study_uid = str(ds.get("StudyInstanceUID", ""))
41+
accession_number = str(ds.get("AccessionNumber", ""))
42+
3043
# Set calling AE title
3144
self.ae.ae_title = destination.calling_ae_title
3245

@@ -45,14 +58,74 @@ def send_dicom(
4558
assoc.release()
4659

4760
if status:
48-
return True, f"DICOM file sent successfully to {destination.name}"
61+
message = f"DICOM file sent successfully to {destination.name}"
62+
dicom_logger.log_dicom_send(
63+
success=True,
64+
message=message,
65+
destination_name=destination.name,
66+
host=destination.host,
67+
port=destination.port,
68+
ae_title=destination.ae_title,
69+
calling_ae=destination.calling_ae_title,
70+
file_path=str(dicom_file_path),
71+
patient_id=patient_id,
72+
patient_name=patient_name,
73+
study_uid=study_uid,
74+
accession_number=accession_number
75+
)
76+
return True, message
4977
else:
50-
return False, "C-STORE request failed"
78+
message = "C-STORE request failed"
79+
dicom_logger.log_dicom_send(
80+
success=False,
81+
message=message,
82+
destination_name=destination.name,
83+
host=destination.host,
84+
port=destination.port,
85+
ae_title=destination.ae_title,
86+
calling_ae=destination.calling_ae_title,
87+
file_path=str(dicom_file_path),
88+
patient_id=patient_id,
89+
patient_name=patient_name,
90+
study_uid=study_uid,
91+
accession_number=accession_number
92+
)
93+
return False, message
5194
else:
52-
return False, f"Association rejected by {destination.name}"
95+
message = f"Association rejected by {destination.name}"
96+
dicom_logger.log_dicom_send(
97+
success=False,
98+
message=message,
99+
destination_name=destination.name,
100+
host=destination.host,
101+
port=destination.port,
102+
ae_title=destination.ae_title,
103+
calling_ae=destination.calling_ae_title,
104+
file_path=str(dicom_file_path),
105+
patient_id=patient_id,
106+
patient_name=patient_name,
107+
study_uid=study_uid,
108+
accession_number=accession_number
109+
)
110+
return False, message
53111

54112
except Exception as e:
55-
return False, f"Failed to send DICOM: {str(e)}"
113+
message = f"Failed to send DICOM: {str(e)}"
114+
dicom_logger.log_dicom_send(
115+
success=False,
116+
message=message,
117+
destination_name=destination.name,
118+
host=destination.host,
119+
port=destination.port,
120+
ae_title=destination.ae_title,
121+
calling_ae=destination.calling_ae_title,
122+
file_path=str(dicom_file_path),
123+
patient_id=patient_id,
124+
patient_name=patient_name,
125+
study_uid=study_uid,
126+
accession_number=accession_number
127+
)
128+
return False, message
56129

57130
def verify_destination(self, destination: DicomDestination) -> tuple[bool, str]:
58131
"""
@@ -79,11 +152,51 @@ def verify_destination(self, destination: DicomDestination) -> tuple[bool, str]:
79152
assoc.release()
80153

81154
if status:
82-
return True, f"Successfully connected to {destination.name}"
155+
message = f"Successfully connected to {destination.name}"
156+
dicom_logger.log_dicom_verify(
157+
success=True,
158+
message=message,
159+
destination_name=destination.name,
160+
host=destination.host,
161+
port=destination.port,
162+
ae_title=destination.ae_title,
163+
calling_ae=destination.calling_ae_title
164+
)
165+
return True, message
83166
else:
84-
return False, "C-ECHO failed"
167+
message = "C-ECHO failed"
168+
dicom_logger.log_dicom_verify(
169+
success=False,
170+
message=message,
171+
destination_name=destination.name,
172+
host=destination.host,
173+
port=destination.port,
174+
ae_title=destination.ae_title,
175+
calling_ae=destination.calling_ae_title
176+
)
177+
return False, message
85178
else:
86-
return False, f"Association rejected by {destination.name}"
179+
message = f"Association rejected by {destination.name}"
180+
dicom_logger.log_dicom_verify(
181+
success=False,
182+
message=message,
183+
destination_name=destination.name,
184+
host=destination.host,
185+
port=destination.port,
186+
ae_title=destination.ae_title,
187+
calling_ae=destination.calling_ae_title
188+
)
189+
return False, message
87190

88191
except Exception as e:
89-
return False, f"Connection failed: {str(e)}"
192+
message = f"Connection failed: {str(e)}"
193+
dicom_logger.log_dicom_verify(
194+
success=False,
195+
message=message,
196+
destination_name=destination.name,
197+
host=destination.host,
198+
port=destination.port,
199+
ae_title=destination.ae_title,
200+
calling_ae=destination.calling_ae_title
201+
)
202+
return False, message

0 commit comments

Comments
 (0)