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()