diff --git a/.gitignore b/.gitignore
index 8893cba0..c8dfb9c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ venv
.env
*.log
+log.txt
temp/
diff --git a/atlas/tests/test_user_uploads.py b/atlas/tests/test_user_uploads.py
new file mode 100644
index 00000000..79736fd3
--- /dev/null
+++ b/atlas/tests/test_user_uploads.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import pytest
+from django.urls import reverse
+
+from atlas.models import SeriesImage, UncategorisedDicom
+
+
+@pytest.mark.django_db
+def test_user_uploads_partial_import_button_prefers_case_for_hash_matched_series(
+ client,
+ admin_user,
+ create_series,
+ create_case,
+ make_case,
+):
+ client.force_login(admin_user)
+
+ primary_case = create_case
+ primary_case.author.add(admin_user)
+
+ secondary_case = make_case(title="Secondary Case")
+ secondary_case.author.add(admin_user)
+
+ existing_series = create_series
+ existing_series.series_instance_uid = "atlas-series-1"
+ existing_series.study_instance_uid = "study-1"
+ existing_series.save(update_fields=["series_instance_uid", "study_instance_uid"])
+
+ primary_case.series.add(existing_series)
+ secondary_case.series.add(existing_series)
+
+ SeriesImage.objects.create(series=existing_series, image_blake3_hash="shared-hash")
+
+ UncategorisedDicom.objects.bulk_create(
+ [
+ UncategorisedDicom(
+ image="pending-upload.dcm",
+ user=admin_user,
+ series_instance_uid="uploaded-series-1",
+ image_blake3_hash="shared-hash",
+ basic_dicom_tags={
+ "SeriesInstanceUID": "uploaded-series-1",
+ "StudyInstanceUID": "study-1",
+ "StudyDescription": "Shared study",
+ "SeriesDescription": "Uploaded series",
+ "Modality": "CT",
+ },
+ )
+ ]
+ )
+
+ response = client.get(reverse("atlas:user_uploads"))
+
+ assert response.status_code == 200
+ content = response.content.decode("utf-8")
+ assert 'class="btn btn-sm btn-warning import-partial-series-btn"' in content
+ assert f'data-case-id="{primary_case.pk}"' in content
\ No newline at end of file
diff --git a/atlas/views.py b/atlas/views.py
index a013d187..4c445d5f 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -3357,6 +3357,7 @@ def user_uploads(
for series_uid, _, tags, _ in series_list
}
uploaded_uid_to_existing_series_ids: dict[str, set[int]] = defaultdict(set)
+ uploaded_uid_to_case_ids: dict[str, set[int]] = defaultdict(set)
existing_series_lookup: dict[int, Series] = {}
case_to_study_uids = defaultdict(set)
uploaded_hashes = {
@@ -3394,6 +3395,7 @@ def user_uploads(
continue
case_lookup[case_obj.pk] = case_obj
case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
+ uploaded_uid_to_case_ids[existing.series_instance_uid].add(case_obj.pk)
if existing.study_instance_uid:
case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)
@@ -3435,6 +3437,7 @@ def user_uploads(
continue
case_lookup[case_obj.pk] = case_obj
case_to_series_uids[case_obj.pk].add(uploaded_uid)
+ uploaded_uid_to_case_ids[uploaded_uid].add(case_obj.pk)
study_uid = uploaded_series_uid_to_study_uid.get(uploaded_uid)
if study_uid:
case_to_study_uids[case_obj.pk].add(study_uid)
@@ -3469,6 +3472,22 @@ def user_uploads(
if series_id in existing_series_lookup
]
+ matched_case_counts = defaultdict(int)
+ for uploaded_uid in study_partial_imported_uids:
+ for case_pk in uploaded_uid_to_case_ids.get(uploaded_uid, set()):
+ matched_case_counts[case_pk] += 1
+
+ if matched_case_counts:
+ preferred_case_id, _ = max(
+ matched_case_counts.items(),
+ key=lambda item: (
+ item[1],
+ len(case_to_series_uids.get(item[0], set()) & study_uploaded_uids),
+ -item[0],
+ ),
+ )
+ study_data["preferred_case_id"] = preferred_case_id
+
related_case_ids = set(case_to_series_uids.keys()) | set(case_to_study_uids.keys())
for case_pk in related_case_ids:
imported_uids = case_to_series_uids.get(case_pk, set())
@@ -3492,7 +3511,9 @@ def user_uploads(
}
)
- if len(case_candidates_for_study) == 1:
+ if study_data.get("preferred_case_id"):
+ pass
+ elif len(case_candidates_for_study) == 1:
study_data["preferred_case_id"] = next(iter(case_candidates_for_study))
elif study_data.get("import_suggestions"):
sorted_suggestions = sorted(
@@ -3576,7 +3597,6 @@ def uploads_import_htmx(request, case_id: int | None = None):
imported = import_dicoms_helper(request, case_id=case_id, dicoms=dicoms)
except Exception as exc:
logger.exception("Error during uploads import")
- logging.getLogger(__name__).exception("Error during uploads import")
return HttpResponse(
f'
Import failed: {escape(str(exc))}
',
status=500,
diff --git a/manage.py b/manage.py
index 4b552c36..c05bbe04 100755
--- a/manage.py
+++ b/manage.py
@@ -3,9 +3,12 @@ import os
import sys
from django.conf import settings
+from rad.logging_setup import configure_loguru
+
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
+ configure_loguru()
# Set up debugger if we are running in a container
if os.environ.get('RUN_MAIN'):
diff --git a/rad/logging_setup.py b/rad/logging_setup.py
new file mode 100644
index 00000000..7171b648
--- /dev/null
+++ b/rad/logging_setup.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from pathlib import Path
+
+from django.conf import settings
+from loguru import logger
+
+_CONFIGURED = False
+
+
+def _is_truthy(value: str | None) -> bool:
+ if value is None:
+ return False
+ return value.lower() in {"1", "true", "yes", "on"}
+
+
+def _get_stdout_level() -> str:
+ env_level = os.environ.get("LOGURU_STDOUT_LEVEL")
+ if env_level:
+ return env_level
+
+ debug_flag = os.environ.get("DEBUG")
+ if debug_flag is None:
+ try:
+ debug_flag = "1" if getattr(settings, "DEBUG", False) else "0"
+ except Exception:
+ debug_flag = "0"
+
+ return "DEBUG" if _is_truthy(debug_flag) else "INFO"
+
+
+def _get_log_file_path() -> Path:
+ override = os.environ.get("LOGURU_FILE")
+ if override:
+ return Path(override)
+
+ try:
+ logging_config = getattr(settings, "LOGGING", None) or {}
+ handler = (logging_config.get("handlers") or {}).get("file") or {}
+ filename = handler.get("filename")
+ if filename:
+ path = Path(filename)
+ if path.is_absolute():
+ return path
+ return Path(getattr(settings, "BASE_DIR", Path.cwd())) / path
+ except Exception:
+ pass
+
+ try:
+ return Path(getattr(settings, "BASE_DIR", Path.cwd())) / "log.txt"
+ except Exception:
+ return Path.cwd() / "log.txt"
+
+
+def configure_loguru() -> Path:
+ global _CONFIGURED
+ if _CONFIGURED:
+ return _get_log_file_path()
+
+ log_file_path = _get_log_file_path()
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
+ stdout_level = _get_stdout_level()
+ log_format = "[{time:DD/MMM/YYYY HH:mm:ss}] {level} [{name}:{line}] {message}"
+
+ logger.remove()
+ logger.add(
+ sys.stdout,
+ level=stdout_level,
+ enqueue=True,
+ backtrace=False,
+ diagnose=False,
+ format=log_format,
+ )
+ logger.add(
+ str(log_file_path),
+ level="DEBUG",
+ enqueue=True,
+ backtrace=True,
+ diagnose=False,
+ format=log_format + "\n{exception}",
+ rotation="10 MB",
+ retention="10 days",
+ )
+
+ logging.root.setLevel(logging.DEBUG)
+ _CONFIGURED = True
+ logger.debug("Loguru logging configured with stdout and file sinks (stdout={})", stdout_level)
+ return log_file_path
diff --git a/rad/tests/test_logs_view.py b/rad/tests/test_logs_view.py
index 821609f9..827e14c6 100644
--- a/rad/tests/test_logs_view.py
+++ b/rad/tests/test_logs_view.py
@@ -3,6 +3,22 @@ from __future__ import annotations
import pytest
from django.urls import reverse
+from rad import logging_setup
+
+
+def test_loguru_uses_django_file_handler_path(settings, tmp_path, monkeypatch):
+ monkeypatch.setattr(logging_setup, "_CONFIGURED", False)
+ settings.BASE_DIR = str(tmp_path)
+ settings.LOGGING = {
+ "handlers": {
+ "file": {
+ "filename": "log.txt",
+ }
+ }
+ }
+
+ assert logging_setup._get_log_file_path() == tmp_path / "log.txt"
+
@pytest.mark.django_db
def test_logs_view_filters_by_date_and_can_reset_log_file(client, admin_user, monkeypatch, tmp_path):
diff --git a/rad/wsgi.py b/rad/wsgi.py
index 20ead4cf..980a6aef 100644
--- a/rad/wsgi.py
+++ b/rad/wsgi.py
@@ -1,60 +1,12 @@
-"""
-WSGI config for rad project.
-
-It exposes the WSGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
-"""
+"""WSGI config for rad project."""
import os
from django.core.wsgi import get_wsgi_application
-from loguru import logger
-import sys
-from pathlib import Path
-import logging
-# Configure Loguru sinks early so app imports see consistent logging.
-# Log to stdout (so `docker logs` shows messages) and to a file at
-# `/var/log/rad/app.log` for persistent collection / host tailing.
-try:
- # ensure directory exists
- log_dir = Path("/var/log/rad")
- log_dir.mkdir(parents=True, exist_ok=True)
- # Determine stdout level: allow overriding with LOGURU_STDOUT_LEVEL env var,
- # otherwise expose DEBUG when DEBUG env var is truthy, fall back to INFO.
- raw_debug = os.environ.get("DEBUG", "0")
- env_level = os.environ.get("LOGURU_STDOUT_LEVEL")
- if env_level:
- stdout_level = env_level
- else:
- stdout_level = "DEBUG" if raw_debug.lower() in ("1", "true", "yes") else "INFO"
-
- # Add stdout sink (if not already added by other code)
- logger.remove() # remove default handlers to avoid duplicate lines
- logger.add(sys.stdout, level=stdout_level, enqueue=True, backtrace=False, diagnose=False)
- # Add rotating file sink for persistence in container. Use JSON lines
- # (serialize=True) to make logs easy to query in Loki/Grafana.
- logger.add(
- str(log_dir / "app.log"),
- rotation="10 MB",
- retention="10 days",
- level="DEBUG",
- enqueue=True,
- serialize=True,
- )
- logger.debug("Loguru logging configured with stdout and file sinks (stdout=%s)", stdout_level)
-
- logging.root.setLevel(logging.DEBUG)
-except Exception:
- # Best-effort only — don't crash WSGI startup if logging can't be configured
- try:
- logger.add(sys.stderr, level="DEBUG", enqueue=True, backtrace=False, diagnose=False)
- except Exception:
- pass
- logger.debug("Failed to configure Loguru logging sinks, continuing without file logging")
+from .logging_setup import configure_loguru
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
+configure_loguru()
application = get_wsgi_application()