c94d2fb1a7
- Implemented models for ResearchStudy, ResearchStudyArm, and ResearchParticipant. - Created views for listing, creating, updating, and exporting research studies. - Added bulk participant generation feature with CSV and JSON support. - Developed templates for study management, participant entry, and bulk generation. - Introduced URL routing for research study operations. - Added utility functions for randomisation and participant assignment. - Implemented participant intake process with demographic data collection. - Ensured proper handling of participant CIDs and assignment methods.
8548 lines
310 KiB
Python
Executable File
8548 lines
310 KiB
Python
Executable File
import json
|
||
import pprint
|
||
import csv
|
||
import random
|
||
from typing import Any
|
||
from dal import autocomplete
|
||
from django.db.models.base import Model as Model
|
||
from django.db.models.query import QuerySet
|
||
from django.db.models import Prefetch
|
||
from django.db.models import Q
|
||
from django.db import transaction
|
||
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django import forms
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from django.utils.html import escape
|
||
from django.views.decorators.http import require_POST
|
||
from django.views.decorators.http import require_http_methods
|
||
from django.template.loader import render_to_string
|
||
from django.db.models import Count, Max
|
||
from django.template.defaultfilters import filesizeformat
|
||
|
||
# from django.contrib.auth.models import User
|
||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||
from django.contrib.auth.models import User
|
||
from django.core.exceptions import (
|
||
FieldError,
|
||
ObjectDoesNotExist,
|
||
PermissionDenied,
|
||
ViewDoesNotExist,
|
||
)
|
||
|
||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||
from django.views.generic import View
|
||
from django.views.generic.detail import DetailView
|
||
import pydicom
|
||
from pydicom.uid import generate_uid
|
||
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
|
||
from django.contrib.admin.views.decorators import staff_member_required
|
||
from django.core.paginator import Paginator
|
||
|
||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||
from django.views.generic import ListView
|
||
from django_filters.views import FilterView
|
||
from django_tables2 import SingleTableMixin
|
||
from .filters import ResourceFilter
|
||
from .tables import ResourceTable
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
|
||
from django.urls import reverse_lazy, reverse
|
||
from django.urls.exceptions import NoReverseMatch
|
||
|
||
from django.http import Http404, JsonResponse
|
||
from django.http import HttpResponseRedirect, HttpResponse
|
||
from django.contrib.contenttypes.models import ContentType
|
||
import zipfile
|
||
import os
|
||
from django.core.files.base import ContentFile
|
||
from django.core.exceptions import PermissionDenied
|
||
|
||
from generic.models import CidUser, CidUserExam, CimarCase, Site, get_next_cid
|
||
from atlas.helpers import get_cases_available_to_user
|
||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||
|
||
from .forms import (
|
||
AddCollectionToCaseForm,
|
||
CaseAuthorForm,
|
||
CaseCollectionAuthorForm,
|
||
CaseCollectionCaseFormSet,
|
||
CaseCollectionForm,
|
||
CaseCollectionUpdateCaseForm,
|
||
CaseDetailForm,
|
||
CaseQuestionForm,
|
||
MoveSelectedSeriesForm,
|
||
CaseDisplaySetForm,
|
||
CaseForm,
|
||
CaseResourceFormSet,
|
||
CaseUpdateSeriesForm,
|
||
CidQuestionAnswerForm,
|
||
CidReportAnswerForm,
|
||
CidReportAnswerMarkForm,
|
||
ConditionAutocompleteForm,
|
||
ConditionForm,
|
||
ExamGroupsForm,
|
||
FindingForm,
|
||
JsonAnswerForm,
|
||
#ModelPriorCaseForm,
|
||
PriorCaseForm,
|
||
QuestionSchemaForm,
|
||
ResourceForm,
|
||
CaseReviewMessageForm,
|
||
SelfReviewForm,
|
||
SeriesAuthorForm,
|
||
SeriesForm,
|
||
SeriesImageFormSet,
|
||
SeriesFormSet,
|
||
SeriesFindingForm,
|
||
CaseDifferentialFormSet,
|
||
StructureForm,
|
||
SubspecialtyForm,
|
||
PresentationForm,
|
||
PathologicalProcessForm,
|
||
ProcedureForm,
|
||
UserQuestionAnswerForm,
|
||
UserReportAnswerForm,
|
||
)
|
||
from .models import (
|
||
Case,
|
||
CaseCollection,
|
||
CaseDetail,
|
||
CaseDisplaySet,
|
||
CasePrior,
|
||
CaseResource,
|
||
CaseReviewMessage,
|
||
CidReportAnswer,
|
||
Condition,
|
||
Differential,
|
||
PathologicalProcess,
|
||
Presentation,
|
||
QuestionSchema,
|
||
Resource,
|
||
SelfReview,
|
||
Series,
|
||
Examination,
|
||
Finding,
|
||
SeriesDetail,
|
||
Structure,
|
||
Subspecialty,
|
||
SeriesFinding,
|
||
SeriesImage,
|
||
UncategorisedDicom,
|
||
UserReportAnswer,
|
||
PrerequisiteRequired
|
||
, APIToken,
|
||
)
|
||
from .models import Procedure
|
||
from .models import NormalCase
|
||
from .tables import (
|
||
CaseCollectionTable,
|
||
CaseTable,
|
||
ConditionTable,
|
||
FindingTable,
|
||
PathologicalProcessTable,
|
||
PresentationTable,
|
||
QuestionSchemaTable,
|
||
SeriesTable,
|
||
StructureTable,
|
||
SubspecialtyTable,
|
||
)
|
||
from .filters import (
|
||
CaseCollectionFilter,
|
||
CaseFilter,
|
||
ConditionFilter,
|
||
FindingFilter,
|
||
PathologicalProcessFilter,
|
||
PresentationFilter,
|
||
QuestionSchemaFilter,
|
||
SeriesFilter,
|
||
StructureFilter,
|
||
SubspecialtyFilter,
|
||
NormalCaseFilter,
|
||
)
|
||
|
||
from .tasks import push_case_to_cimar_task
|
||
|
||
from django_tables2 import SingleTableView, SingleTableMixin
|
||
from django_filters.views import FilterView
|
||
|
||
# from autocomplete import HTMXAutoComplete
|
||
|
||
|
||
from .decorators import (
|
||
user_is_author_or_atlas_editor,
|
||
user_has_case_view_access,
|
||
user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access,
|
||
user_is_atlas_editor,
|
||
user_is_author_or_atlas_series_checker,
|
||
user_is_atlas_marker,
|
||
user_is_collection_author_or_atlas_editor,
|
||
)
|
||
|
||
from collections import defaultdict
|
||
import json
|
||
import statistics
|
||
import plotly.express as px
|
||
|
||
from django.core.cache import cache
|
||
from django.conf import settings
|
||
|
||
from helpers.images import compare_dicom_datasets, image_as_base64, print_dicom
|
||
|
||
import logging
|
||
from copy import deepcopy
|
||
from django.forms.models import model_to_dict
|
||
|
||
from generic.views import (
|
||
AuthorRequiredMixin,
|
||
ExamCloneMixin,
|
||
ExamGroupsUpdateBase,
|
||
ExamViews,
|
||
SeriesImagesZipViewBase,
|
||
)
|
||
from reversion.views import RevisionMixin
|
||
import reversion
|
||
|
||
|
||
import os
|
||
import difflib
|
||
|
||
from helpers.cimar import CimarAPI
|
||
|
||
from loguru import logger
|
||
|
||
|
||
@login_required
|
||
def series_truncate_htmx(request, series_id, start, end):
|
||
"""HTMX endpoint to truncate a series' images to the given inclusive
|
||
start/end bounds (zero-based). Returns an HTML fragment with the result.
|
||
"""
|
||
series = get_object_or_404(Series, pk=series_id)
|
||
|
||
if not series.check_user_can_edit(request.user):
|
||
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
|
||
|
||
images_removed = []
|
||
for n, image in enumerate(series.get_images()):
|
||
if n >= int(start) and n <= int(end):
|
||
# keep images within bounds
|
||
continue
|
||
else:
|
||
image.removed = True
|
||
image.image = None
|
||
image.save()
|
||
images_removed.append(image.pk)
|
||
|
||
# Recalculate stored series size
|
||
try:
|
||
series.get_total_image_size(refresh=True)
|
||
except Exception:
|
||
# ignore errors when recalculating
|
||
pass
|
||
|
||
# Render a simple message fragment for HTMX swap
|
||
html = render_to_string(
|
||
"atlas/partials/truncate_result.html",
|
||
{"images_removed": images_removed, "series": series},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def series_image_size_htmx(request, series_id):
|
||
"""HTMX endpoint returning the total size of images in a series.
|
||
|
||
If `?refresh=1` is provided the size will be recalculated.
|
||
"""
|
||
series = get_object_or_404(Series, pk=series_id)
|
||
|
||
# Permission: reuse existing series permission check if available
|
||
try:
|
||
can_view = series.check_user_can_view(request.user)
|
||
except Exception:
|
||
# fallback to basic permission: allow authenticated users
|
||
can_view = True
|
||
|
||
if not can_view:
|
||
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
|
||
|
||
refresh = request.GET.get("refresh") in ("1", "true", "True")
|
||
|
||
try:
|
||
size_bytes = series.get_total_image_size(refresh=refresh)
|
||
except Exception:
|
||
size_bytes = 0
|
||
|
||
human = filesizeformat(size_bytes)
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/_series_image_size.html",
|
||
{"series": series, "size_bytes": size_bytes, "size_human": human, "refresh": refresh},
|
||
request=request,
|
||
)
|
||
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def case_search_partial(request):
|
||
"""Return an HTMX partial listing cases matching the query parameter `q`.
|
||
|
||
This view returns a small list-group fragment that can be swapped into
|
||
the index page. It respects the user's available cases via
|
||
get_cases_available_to_user.
|
||
"""
|
||
# NOTE (search migration):
|
||
#
|
||
# This view currently performs simple `__icontains` lookups across Case
|
||
# fields and related models. For better relevance and performance you can
|
||
# migrate this to use PostgreSQL full-text search (FTS). Recommended steps:
|
||
#
|
||
# 1) Enable optional Postgres extensions (if you want trigram/unaccent):
|
||
# - Add migrations: `TrigramExtension()` and optionally `UnaccentExtension()`
|
||
# from `django.contrib.postgres.operations`.
|
||
#
|
||
# 2) Create a tsvector expression index (recommended) or add a
|
||
# SearchVectorField on the Case model. Example expression index SQL:
|
||
# CREATE INDEX CONCURRENTLY idx_case_search ON atlas_case
|
||
# USING GIN (to_tsvector('english', coalesce(title,'') || ' ' ||
|
||
# coalesce(description,'') || ' ' || coalesce(history,'')));
|
||
#
|
||
# Alternatively use a Django migration with `GinIndex` on an
|
||
# `SearchVectorField` or a `Func` expression.
|
||
#
|
||
# 3) Query using django.contrib.postgres.search utilities. Example:
|
||
# from django.contrib.postgres.search import (
|
||
# SearchVector, SearchQuery, SearchRank
|
||
# )
|
||
# sv = (SearchVector('title', weight='A') +
|
||
# SearchVector('description', weight='B') +
|
||
# SearchVector('history', weight='C'))
|
||
# sq = SearchQuery(q)
|
||
# qs = Case.objects.annotate(search=sv).filter(search=sq)
|
||
# .annotate(rank=SearchRank(F('search'), sq)).order_by('-rank')
|
||
#
|
||
# 4) For interactive/fuzzy typing consider pg_trgm (TrigramExtension)
|
||
# and `TrigramSimilarity` as a fallback when FTS returns few results.
|
||
#
|
||
# 5) Keep in mind to re-index (or recreate indexes) when you change
|
||
# the fields included in the tsvector. Use concurrent index creation
|
||
# for minimal downtime.
|
||
#
|
||
# If you want, I can prepare the migration, model/index changes and a
|
||
# Django-code example to wire FTS into this view and tune weights.
|
||
q = request.GET.get('q', '') or request.GET.get('case-search-input', '')
|
||
q = q.strip()
|
||
|
||
# Start with cases available to the user
|
||
qs = get_cases_available_to_user(request.user)
|
||
|
||
if q:
|
||
from django.db.models import Q
|
||
|
||
# Search across multiple Case fields and related names (conditions,
|
||
# presentations, subspecialties, series fields and authors).
|
||
qs = qs.filter(
|
||
Q(title__icontains=q)
|
||
| Q(description__icontains=q)
|
||
| Q(history__icontains=q)
|
||
| Q(discussion__icontains=q)
|
||
| Q(report__icontains=q)
|
||
| Q(condition__name__icontains=q)
|
||
| Q(presentation__name__icontains=q)
|
||
| Q(subspecialty__name__icontains=q)
|
||
| Q(series__description__icontains=q)
|
||
# Series.modality is a FK to generic.Modality; search its text field
|
||
| Q(series__modality__modality__icontains=q)
|
||
| Q(series__examination__examination__icontains=q)
|
||
| Q(author__first_name__icontains=q)
|
||
| Q(author__last_name__icontains=q)
|
||
| Q(author__username__icontains=q)
|
||
).distinct()
|
||
|
||
qs = qs.order_by('-created_date')[:20]
|
||
|
||
html = render_to_string('atlas/partials/_case_search_results.html', {'cases': qs}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def finding_search(request):
|
||
"""HTMX endpoint returning findings, optionally filtered by case and query."""
|
||
# Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header)
|
||
q = request.GET.get('q', '').strip()
|
||
case_id = request.GET.get('case')
|
||
# Use SeriesFinding objects for the widget (series-scoped findings)
|
||
qs = SeriesFinding.objects.select_related('series').prefetch_related('findings')
|
||
if case_id:
|
||
try:
|
||
cid = int(case_id)
|
||
# Series are linked to cases through SeriesDetail; filter SeriesFinding by linked series' case
|
||
qs = qs.filter(series__seriesdetail__case__pk=cid)
|
||
except Exception:
|
||
pass
|
||
if q:
|
||
qs = qs.filter(
|
||
Q(description__icontains=q) | Q(findings__name__icontains=q)
|
||
)
|
||
qs = qs.order_by('-pk').distinct()[:50]
|
||
html = render_to_string('atlas/partials/_finding_search_results.html', {'findings': qs}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def displayset_search(request):
|
||
"""HTMX endpoint returning display sets, scoped to a case if provided."""
|
||
# Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header)
|
||
q = request.GET.get('q', '').strip()
|
||
case_id = request.GET.get('case')
|
||
qs = CaseDisplaySet.objects.all()
|
||
if case_id:
|
||
try:
|
||
cid = int(case_id)
|
||
qs = qs.filter(case__pk=cid)
|
||
except Exception:
|
||
pass
|
||
if q:
|
||
qs = qs.filter(name__icontains=q)
|
||
qs = qs.order_by('name')[:50]
|
||
html = render_to_string('atlas/partials/_displayset_search_results.html', {'displaysets': qs}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
|
||
class AuthorOrCheckerRequiredMixin(object):
|
||
def get_object(self, *args, **kwargs):
|
||
obj = super().get_object(*args, **kwargs)
|
||
if (
|
||
self.request.user.groups.filter(name="atlas_editor").exists()
|
||
or self.request.user.is_superuser
|
||
):
|
||
return obj
|
||
if self.request.user not in obj.get_author_objects():
|
||
raise PermissionDenied() # or Http404
|
||
return obj
|
||
|
||
|
||
class AtlasEditorRequiredMixin(object):
|
||
def get_object(self, *args, **kwargs):
|
||
obj = super().get_object(*args, **kwargs)
|
||
if (
|
||
self.request.user.groups.filter(name="atlas_editor").exists()
|
||
or self.request.user.is_superuser
|
||
):
|
||
return obj
|
||
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
||
|
||
|
||
|
||
class NormalCaseList(LoginRequiredMixin, FilterView):
|
||
model = NormalCase
|
||
template_name = "atlas/normals_list.html"
|
||
filterset_class = NormalCaseFilter
|
||
paginate_by = 25
|
||
|
||
SORT_CHOICES = {
|
||
"newest": "-added_date",
|
||
"oldest": "added_date",
|
||
"age_asc": "age_days",
|
||
"age_desc": "-age_days",
|
||
"title_asc": "case__title",
|
||
"title_desc": "-case__title",
|
||
}
|
||
|
||
def get_selected_sort(self):
|
||
sort_key = self.request.GET.get("sort", "newest")
|
||
if sort_key not in self.SORT_CHOICES:
|
||
return "newest"
|
||
return sort_key
|
||
|
||
def get_queryset(self):
|
||
qs = NormalCase.objects.select_related("case", "examination", "modality")
|
||
# Restrict similar to CaseFilter: non-editors only see authored or open_access
|
||
if not self.request.user.groups.filter(name="atlas_editor").exists():
|
||
qs = qs.filter((Q(case__author__id=self.request.user.id) | Q(case__open_access=True)))
|
||
|
||
order_by = self.SORT_CHOICES[self.get_selected_sort()]
|
||
return qs.order_by(order_by)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context["selected_sort"] = self.get_selected_sort()
|
||
context["sort_choices"] = [
|
||
("newest", "Newest added"),
|
||
("oldest", "Oldest added"),
|
||
("age_asc", "Age (youngest first)"),
|
||
("age_desc", "Age (oldest first)"),
|
||
("title_asc", "Title (A–Z)"),
|
||
("title_desc", "Title (Z–A)"),
|
||
]
|
||
|
||
params = self.request.GET.copy()
|
||
if "page" in params:
|
||
params.pop("page")
|
||
context["querystring_without_page"] = params.urlencode()
|
||
|
||
params_without_sort = params.copy()
|
||
if "sort" in params_without_sort:
|
||
params_without_sort.pop("sort")
|
||
context["querystring_without_page_sort"] = params_without_sort.urlencode()
|
||
return context
|
||
|
||
@login_required
|
||
@user_has_case_view_access
|
||
def case_displaysets(request, pk):
|
||
"""Render the dedicated case display set workspace.
|
||
|
||
This is the full-page management/viewer screen used to review all display sets
|
||
for a case and, when permitted, add/edit/delete them inline via HTMX.
|
||
|
||
Query params:
|
||
- ``displayset``: highlight/select a specific display set row.
|
||
- ``edit``: when truthy and ``displayset`` is provided, auto-open that row's
|
||
inline edit form on page load.
|
||
"""
|
||
case = get_object_or_404(Case, pk=pk)
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
|
||
displayset_id = request.GET.get("displayset")
|
||
edit = request.GET.get("edit", False)
|
||
selected_displayset = None
|
||
if displayset_id:
|
||
try:
|
||
selected_displayset = case.display_sets.get(pk=displayset_id)
|
||
except CaseDisplaySet.DoesNotExist:
|
||
selected_displayset = None
|
||
|
||
return render(
|
||
request,
|
||
"atlas/case_displaysets.html",
|
||
{
|
||
"case": case,
|
||
"edit": edit,
|
||
"can_edit": can_edit,
|
||
"selected_displayset": selected_displayset,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def case_search_page(request):
|
||
"""Full page case search with quick filters.
|
||
|
||
Supports GET params:
|
||
- q: query string
|
||
- field: one of ['all','title','description','history','author']
|
||
- my: '1' to limit to cases authored by current user
|
||
- open: '1' to limit to open_access cases
|
||
- has_images: '1' to require series images
|
||
- subspecialty: id of subspecialty to filter
|
||
- page: pagination page
|
||
"""
|
||
q = request.GET.get("q", "").strip()
|
||
field = request.GET.get("field", "all")
|
||
my_cases = request.GET.get("my", "0") == "1"
|
||
open_access = request.GET.get("open", "0") == "1"
|
||
has_images = request.GET.get("has_images", "0") == "1"
|
||
subspecialty_id = request.GET.get("subspecialty")
|
||
|
||
qs = get_cases_available_to_user(request.user)
|
||
|
||
if my_cases:
|
||
qs = qs.filter(author=request.user)
|
||
|
||
if open_access:
|
||
qs = qs.filter(open_access=True)
|
||
|
||
if subspecialty_id:
|
||
try:
|
||
sid = int(subspecialty_id)
|
||
qs = qs.filter(subspecialty__id=sid)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if q:
|
||
if field == "title":
|
||
qs = qs.filter(title__icontains=q)
|
||
elif field == "description":
|
||
qs = qs.filter(description__icontains=q)
|
||
elif field == "history":
|
||
qs = qs.filter(history__icontains=q)
|
||
elif field == "author":
|
||
qs = qs.filter(
|
||
Q(author__first_name__icontains=q)
|
||
| Q(author__last_name__icontains=q)
|
||
| Q(author__username__icontains=q)
|
||
)
|
||
else:
|
||
qs = qs.filter(
|
||
Q(title__icontains=q)
|
||
| Q(description__icontains=q)
|
||
| Q(history__icontains=q)
|
||
| Q(discussion__icontains=q)
|
||
| Q(report__icontains=q)
|
||
| Q(condition__name__icontains=q)
|
||
| Q(presentation__name__icontains=q)
|
||
| Q(subspecialty__name__icontains=q)
|
||
| Q(series__description__icontains=q)
|
||
| Q(series__modality__modality__icontains=q)
|
||
| Q(series__examination__examination__icontains=q)
|
||
| Q(author__first_name__icontains=q)
|
||
| Q(author__last_name__icontains=q)
|
||
| Q(author__username__icontains=q)
|
||
).distinct()
|
||
|
||
if has_images:
|
||
qs = qs.filter(series__images__isnull=False).distinct()
|
||
|
||
qs = qs.order_by("-created_date")
|
||
|
||
# Paginate results
|
||
paginator = Paginator(qs, 25)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
# Recent cases (authored by current user)
|
||
recent_cases = get_cases_available_to_user(request.user).filter(author=request.user).order_by("-created_date")[:5]
|
||
|
||
subspecialty_queryset = Subspecialty.objects.all().order_by("name")
|
||
|
||
return render(
|
||
request,
|
||
"atlas/case_search_page.html",
|
||
{
|
||
"cases": page_obj,
|
||
"q": q,
|
||
"field": field,
|
||
"my": my_cases,
|
||
"open": open_access,
|
||
"has_images": has_images,
|
||
"subspecialty_queryset": subspecialty_queryset,
|
||
"selected_subspecialty": subspecialty_id,
|
||
"recent_cases": recent_cases,
|
||
},
|
||
)
|
||
|
||
|
||
|
||
@login_required
|
||
def api_tokens_page(request):
|
||
"""Simple UI to create and revoke API tokens for the logged-in user."""
|
||
created_token = None
|
||
|
||
if request.method == "POST":
|
||
action = request.POST.get("action")
|
||
if action == "create":
|
||
name = request.POST.get("name", "")
|
||
scopes = request.POST.get("scopes", "")
|
||
expires_days = request.POST.get("expires_days")
|
||
expires = None
|
||
if expires_days:
|
||
try:
|
||
expires = timezone.now() + timezone.timedelta(days=int(expires_days))
|
||
except Exception:
|
||
expires = None
|
||
|
||
token, obj = APIToken.create_token(request.user, name=name, scopes=scopes, expires=expires)
|
||
created_token = token
|
||
elif action == "revoke":
|
||
revoke_id = request.POST.get("revoke_id")
|
||
try:
|
||
t = APIToken.objects.get(pk=revoke_id, user=request.user)
|
||
t.revoked = True
|
||
t.save(update_fields=["revoked"])
|
||
except APIToken.DoesNotExist:
|
||
pass
|
||
|
||
tokens_qs = APIToken.objects.filter(user=request.user).order_by("-created")
|
||
tokens = [
|
||
{
|
||
"id": t.id,
|
||
"name": t.name,
|
||
"scopes": t.scopes,
|
||
"created": t.created,
|
||
"expires": t.expires,
|
||
"revoked": t.revoked,
|
||
}
|
||
for t in tokens_qs
|
||
]
|
||
|
||
return render(request, "atlas/api_tokens.html", {"tokens": tokens, "created_token": created_token})
|
||
|
||
@login_required
|
||
@user_has_case_view_access
|
||
@require_http_methods(["GET", "POST"])
|
||
def case_displaysets_add(request, pk):
|
||
"""Create a display set for a case and return HTMX fragments.
|
||
|
||
GET returns a blank display set form fragment.
|
||
POST validates and creates the display set, then returns the rendered list row.
|
||
"""
|
||
case = get_object_or_404(Case, pk=pk)
|
||
form = CaseDisplaySetForm(request.POST or None)
|
||
if request.method == "POST":
|
||
if form.is_valid():
|
||
ds = form.save(commit=False)
|
||
ds.case = case
|
||
ds.author = request.user
|
||
ds.viewerstate = request.POST.get("viewerstate") or None
|
||
ds.annotations = request.POST.get("annotations") or None
|
||
ds.save()
|
||
form.save_m2m()
|
||
# HTMX: return the new display set row HTML fragment
|
||
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True})
|
||
return HttpResponse(html)
|
||
else:
|
||
# HTMX: return the form HTML fragment with errors
|
||
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||
return HttpResponse(html, status=400)
|
||
else:
|
||
# HTMX: return the blank form HTML fragment
|
||
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
@login_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def case_displaysets_edit(request, pk):
|
||
"""Edit a single display set via HTMX.
|
||
|
||
This endpoint is fragment-only and is intended to be called from the dedicated
|
||
``case_displaysets`` page inline editor, not linked as a standalone page.
|
||
"""
|
||
ds = get_object_or_404(CaseDisplaySet, pk=pk)
|
||
|
||
case = ds.case
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit this display set.")
|
||
form = CaseDisplaySetForm(request.POST or None, instance=ds)
|
||
if request.method == "POST":
|
||
if form.is_valid():
|
||
ds = form.save(commit=False)
|
||
ds.viewerstate = request.POST.get("viewerstate") or ds.viewerstate
|
||
ds.annotations = request.POST.get("annotations") or ds.annotations
|
||
ds.save()
|
||
form.save_m2m()
|
||
# Return updated row HTML
|
||
html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}, request=request)
|
||
return HttpResponse(html)
|
||
else:
|
||
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||
return HttpResponse(html, status=400)
|
||
else:
|
||
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def case_displaysets_detail(request, pk):
|
||
"""Redirect to the case display set workspace focused on one display set.
|
||
|
||
Used as the canonical navigation target from non-management pages.
|
||
It redirects to ``case_displaysets`` with ``displayset=<pk>`` and forwards
|
||
optional ``edit`` intent (for example ``?edit=1``) so the target page can
|
||
auto-open inline editing for permitted users.
|
||
"""
|
||
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||
case = displayset.case
|
||
edit = request.GET.get("edit")
|
||
|
||
redirect_url = f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}"
|
||
if edit:
|
||
redirect_url = f"{redirect_url}&edit={edit}"
|
||
return redirect(redirect_url)
|
||
|
||
|
||
@login_required
|
||
def case_displaysets_modal(request, pk):
|
||
"""Return a display set detail fragment for modal/overlay viewers.
|
||
|
||
This supports pages that do not have the full ``case_displaysets`` workspace
|
||
open but still need to inspect a display set in-place.
|
||
"""
|
||
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||
can_edit = False
|
||
try:
|
||
can_edit = displayset.case.check_user_can_edit(request.user)
|
||
except Exception:
|
||
can_edit = False
|
||
return render(request, 'atlas/case_displayset_modal.html', {'ds': displayset, 'can_edit': can_edit})
|
||
|
||
|
||
def displayset_preview(request, pk):
|
||
"""Return a compact display set preview fragment for lightweight popups."""
|
||
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||
return render(request, 'atlas/partials/_displayset_preview.html', {'ds': displayset})
|
||
|
||
def case_displaysets_delete(request, pk):
|
||
"""Delete a display set and return a replacement row fragment/message for HTMX."""
|
||
try:
|
||
displayset = CaseDisplaySet.objects.get(pk=pk)
|
||
except CaseDisplaySet.DoesNotExist:
|
||
raise Http404("Display set not found.")
|
||
|
||
if not displayset.case.check_user_can_edit(request.user):
|
||
raise PermissionDenied("You do not have permission to delete this display set.")
|
||
displayset.delete()
|
||
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
|
||
|
||
|
||
def get_case_for_case_detail(pk: int) -> Case:
|
||
try:
|
||
case = (
|
||
Case.objects
|
||
.select_related() # Add any FK fields if needed
|
||
.prefetch_related(
|
||
Prefetch(
|
||
"seriesdetail_set",
|
||
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
|
||
Prefetch(
|
||
"series",
|
||
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
|
||
.prefetch_related(
|
||
Prefetch(
|
||
"findings",
|
||
queryset=SeriesFinding.objects.prefetch_related(
|
||
"findings", "conditions", "structures"
|
||
)
|
||
),
|
||
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
|
||
)
|
||
),
|
||
),
|
||
),
|
||
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
|
||
Prefetch("condition", queryset=Condition.objects.all()),
|
||
Prefetch("procedures", queryset=Procedure.objects.all()),
|
||
Prefetch("presentation", queryset=Presentation.objects.all()),
|
||
Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()),
|
||
Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
|
||
Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
|
||
Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related(
|
||
Prefetch("findings", queryset=Finding.objects.all()),
|
||
Prefetch("structures", queryset=Structure.objects.all()),
|
||
Prefetch("conditions", queryset=Condition.objects.all()),
|
||
)),
|
||
)
|
||
.get(pk=pk)
|
||
)
|
||
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
|
||
return case
|
||
except Case.DoesNotExist:
|
||
raise Http404("Case not found.")
|
||
|
||
@login_required
|
||
@user_has_case_view_access
|
||
def case_detail(request, pk):
|
||
case = get_case_for_case_detail(pk)
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
|
||
|
||
|
||
return render(
|
||
request,
|
||
"atlas/case_detail.html",
|
||
{
|
||
"case": case,
|
||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||
"can_edit": can_edit,
|
||
"move_series_form": move_series_form,
|
||
# Provide helper lists for resource search filters
|
||
"site_queryset": Site.objects.all(),
|
||
"subspecialty_queryset": Subspecialty.objects.all(),
|
||
},
|
||
)
|
||
|
||
|
||
CASE_INLINE_FIELD_CONFIG = {
|
||
"title": {"label": "Title", "kind": "text"},
|
||
"description": {"label": "Description", "kind": "textarea"},
|
||
"history": {"label": "History", "kind": "textarea"},
|
||
"discussion": {"label": "Discussion", "kind": "textarea"},
|
||
"report": {"label": "Report", "kind": "textarea"},
|
||
"diagnostic_certainty": {"label": "Diagnostic certainty", "kind": "select"},
|
||
"subspecialty": {"label": "Subspecialty", "kind": "select"},
|
||
}
|
||
|
||
|
||
def _dedupe_non_empty(values):
|
||
out = []
|
||
seen = set()
|
||
for value in values:
|
||
text = (value or "").strip()
|
||
if not text or text in seen:
|
||
continue
|
||
seen.add(text)
|
||
out.append(text)
|
||
return out
|
||
|
||
|
||
def _get_case_inline_suggestions(case, field_name):
|
||
condition_names = [c.name for c in case.condition.all()[:3]]
|
||
presentation_names = [p.name for p in case.presentation.all()[:3]]
|
||
procedure_names = [p.name for p in case.procedures.all()[:3]]
|
||
|
||
conditions = ", ".join(condition_names)
|
||
presentations = ", ".join(presentation_names)
|
||
procedures = ", ".join(procedure_names)
|
||
|
||
first_series = None
|
||
try:
|
||
ordered = case.get_ordered_series()
|
||
if ordered:
|
||
first_series = ordered[0]
|
||
except Exception:
|
||
first_series = None
|
||
|
||
modality_text = ""
|
||
examination_text = ""
|
||
if first_series is not None:
|
||
try:
|
||
if first_series.modality is not None:
|
||
modality_text = str(first_series.modality)
|
||
except Exception:
|
||
modality_text = ""
|
||
try:
|
||
if first_series.examination is not None:
|
||
examination_text = str(first_series.examination)
|
||
except Exception:
|
||
examination_text = ""
|
||
|
||
if field_name == "title":
|
||
return _dedupe_non_empty([
|
||
conditions,
|
||
f"{conditions} presenting with {presentations}" if conditions and presentations else "",
|
||
presentations,
|
||
f"{modality_text} {examination_text}" if modality_text or examination_text else "",
|
||
])[:4]
|
||
|
||
if field_name == "description":
|
||
return _dedupe_non_empty([
|
||
f"{modality_text} {examination_text}".strip(),
|
||
f"Case demonstrating {conditions}" if conditions else "",
|
||
f"Key presentation: {presentations}" if presentations else "",
|
||
])[:4]
|
||
|
||
if field_name == "history":
|
||
return _dedupe_non_empty([
|
||
f"Presentation: {presentations}." if presentations else "",
|
||
f"Relevant background includes {procedures}." if procedures else "",
|
||
f"Working diagnosis: {conditions}." if conditions else "",
|
||
])[:4]
|
||
|
||
if field_name == "discussion":
|
||
return _dedupe_non_empty([
|
||
f"Imaging findings are most consistent with {conditions}." if conditions else "",
|
||
f"Differential considerations include {conditions}." if conditions else "",
|
||
"Correlation with clinical history and prior imaging is advised.",
|
||
])[:4]
|
||
|
||
if field_name == "report":
|
||
return _dedupe_non_empty([
|
||
f"Findings are in keeping with {conditions}." if conditions else "",
|
||
f"Clinical indication: {presentations}." if presentations else "",
|
||
"No acute abnormality identified.",
|
||
])[:4]
|
||
|
||
return []
|
||
|
||
|
||
def _validate_case_inline_value(field_name, raw_value):
|
||
if field_name == "diagnostic_certainty":
|
||
try:
|
||
parsed = int(raw_value)
|
||
except Exception:
|
||
return None, "Please choose a valid diagnostic certainty option."
|
||
|
||
allowed = {choice.value for choice in Case.CertaintyChoices}
|
||
if parsed not in allowed:
|
||
return None, "Please choose a valid diagnostic certainty option."
|
||
return parsed, None
|
||
|
||
value = (raw_value or "").strip()
|
||
if field_name == "title":
|
||
if not value:
|
||
return None, "Title cannot be empty."
|
||
if len(value) > 255:
|
||
return None, "Title must be 255 characters or fewer."
|
||
|
||
return value, None
|
||
|
||
|
||
@login_required
|
||
@user_has_case_view_access
|
||
@require_http_methods(["GET", "POST"])
|
||
def case_inline_field(request, pk, field_name):
|
||
case = get_case_for_case_detail(pk)
|
||
|
||
field_cfg = CASE_INLINE_FIELD_CONFIG.get(field_name)
|
||
if field_cfg is None:
|
||
raise Http404("Unknown inline-edit field.")
|
||
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
editing = request.GET.get("edit") == "1"
|
||
show_label = request.GET.get("show_label", "1") not in ("0", "false", "False")
|
||
error_message = ""
|
||
|
||
# Special-case handling for many-to-many `subspecialty` field
|
||
if field_name == "subspecialty":
|
||
if request.method == "POST":
|
||
if not can_edit:
|
||
return HttpResponse(status=403)
|
||
editing = True
|
||
# Accept multiple values via POST list
|
||
posted = request.POST.getlist("value")
|
||
ids = []
|
||
for v in posted:
|
||
try:
|
||
ids.append(int(v))
|
||
except Exception:
|
||
continue
|
||
# keep only valid existing ids
|
||
valid_ids = list(
|
||
Subspecialty.objects.filter(pk__in=ids).values_list("pk", flat=True)
|
||
)
|
||
case.subspecialty.set(valid_ids)
|
||
editing = False
|
||
value = case.subspecialty.all()
|
||
else:
|
||
value = case.subspecialty.all()
|
||
if editing and not can_edit:
|
||
editing = False
|
||
else:
|
||
if request.method == "POST":
|
||
if not can_edit:
|
||
return HttpResponse(status=403)
|
||
|
||
editing = True
|
||
cleaned_value, error_message = _validate_case_inline_value(
|
||
field_name=field_name,
|
||
raw_value=request.POST.get("value", ""),
|
||
)
|
||
if error_message:
|
||
value = request.POST.get("value", "")
|
||
else:
|
||
setattr(case, field_name, cleaned_value)
|
||
case.save(update_fields=[field_name])
|
||
editing = False
|
||
value = getattr(case, field_name)
|
||
else:
|
||
value = getattr(case, field_name)
|
||
if editing and not can_edit:
|
||
editing = False
|
||
|
||
if field_name == "diagnostic_certainty":
|
||
display_value = case.get_diagnostic_certainty_display()
|
||
select_choices = [(str(choice.value), str(choice.label)) for choice in Case.CertaintyChoices]
|
||
selected_values = []
|
||
multiple = False
|
||
elif field_name == "subspecialty":
|
||
# For many-to-many, provide choices and selected values
|
||
display_value = ", ".join([str(s) for s in case.subspecialty.all()])
|
||
select_choices = [(str(s.pk), str(s.name)) for s in Subspecialty.objects.all()]
|
||
selected_values = [str(s.pk) for s in case.subspecialty.all()]
|
||
multiple = True
|
||
else:
|
||
display_value = value
|
||
select_choices = []
|
||
selected_values = []
|
||
multiple = False
|
||
|
||
endpoint_url = reverse("atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name})
|
||
if show_label:
|
||
endpoint_url_default = endpoint_url
|
||
endpoint_url_edit = f"{endpoint_url}?edit=1"
|
||
else:
|
||
endpoint_url_default = f"{endpoint_url}?show_label=0"
|
||
endpoint_url_edit = f"{endpoint_url}?edit=1&show_label=0"
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/case_inline_field.html",
|
||
{
|
||
"case": case,
|
||
"field_name": field_name,
|
||
"field_label": field_cfg["label"],
|
||
"field_kind": field_cfg["kind"],
|
||
"value": value,
|
||
"display_value": display_value,
|
||
"editing": editing,
|
||
"show_label": show_label,
|
||
"can_edit": can_edit,
|
||
"error_message": error_message,
|
||
"suggestions": _get_case_inline_suggestions(case, field_name),
|
||
"select_choices": select_choices,
|
||
"selected_values": selected_values,
|
||
"multiple": multiple,
|
||
"endpoint_url_default": endpoint_url_default,
|
||
"endpoint_url_edit": endpoint_url_edit,
|
||
"container_id": f"case-inline-{field_name}",
|
||
},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def toggle_case_normal(request, pk):
|
||
"""HTMX endpoint to toggle the NormalCase mark for a Case.
|
||
|
||
- Only users in the 'atlas_editor' group or superusers may toggle.
|
||
- On create, attempt to auto-extract age_years from DICOM metadata.
|
||
- Returns an HTML fragment that replaces the toggle block.
|
||
"""
|
||
case = get_case_for_case_detail(pk)
|
||
|
||
# Permission: rely on `can_edit` check on the Case model
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
if not can_edit:
|
||
return HttpResponse(status=403)
|
||
|
||
# If already marked normal, unmark (delete the NormalCase)
|
||
try:
|
||
normal = case.normal_case
|
||
except Exception:
|
||
normal = None
|
||
|
||
if normal is not None:
|
||
normal.delete()
|
||
# Remove any cached relation on the case and reload so the template
|
||
# sees the updated state (no normal_case).
|
||
try:
|
||
if hasattr(case, "normal_case"):
|
||
delattr(case, "normal_case")
|
||
except Exception:
|
||
pass
|
||
case = get_case_for_case_detail(pk)
|
||
else:
|
||
# Attempt to auto-extract age from DICOM
|
||
age = None
|
||
try:
|
||
from datetime import datetime
|
||
import re
|
||
|
||
# Iterate series then images to find first usable DICOM
|
||
for series in case.series.all():
|
||
img = series.images.filter(removed=False).first()
|
||
if not img:
|
||
continue
|
||
ds = img.get_dicom_data()
|
||
if not ds:
|
||
continue
|
||
|
||
# Try PatientAge (format often like '045Y' or '45Y')
|
||
try:
|
||
pa = getattr(ds, "PatientAge", None)
|
||
except Exception:
|
||
pa = None
|
||
|
||
if pa:
|
||
s = str(pa)
|
||
m = re.search(r"(\d+)", s)
|
||
if m:
|
||
age = int(m.group(1))
|
||
break
|
||
|
||
# Try birthdate + studydate
|
||
try:
|
||
pbd = getattr(ds, "PatientBirthDate", None)
|
||
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
|
||
except Exception:
|
||
pbd = None
|
||
sdate = None
|
||
|
||
if pbd and sdate:
|
||
try:
|
||
bd = datetime.strptime(str(pbd), "%Y%m%d")
|
||
sd = datetime.strptime(str(sdate), "%Y%m%d")
|
||
years = round((sd - bd).days / 365.25)
|
||
age = int(years)
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
except Exception:
|
||
age = None
|
||
|
||
# Create NormalCase
|
||
from .models import NormalCase
|
||
|
||
# Convert inferred integer years -> canonical days (approx)
|
||
if age is not None:
|
||
age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
|
||
else:
|
||
age_days = None
|
||
|
||
NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)
|
||
|
||
# Render the updated fragment
|
||
# Recompute flags for the fragment render
|
||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
html = render_to_string(
|
||
"atlas/partials/_normal_toggle.html",
|
||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def case_normal_form(request, pk):
|
||
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
|
||
case = get_case_for_case_detail(pk)
|
||
|
||
# Permission check: rely on `can_edit` helper on the Case model
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
if not can_edit:
|
||
return HttpResponse(status=403)
|
||
|
||
# Extract defaults
|
||
age = None
|
||
inferred_examination = None
|
||
inferred_modality = None
|
||
try:
|
||
for series in case.series.all():
|
||
img = series.images.filter(removed=False).first()
|
||
if not img:
|
||
continue
|
||
ds = img.get_dicom_data()
|
||
if not ds:
|
||
continue
|
||
|
||
pa = getattr(ds, "PatientAge", None)
|
||
if pa:
|
||
import re
|
||
m = re.search(r"(\d+)", str(pa))
|
||
if m:
|
||
age = int(m.group(1))
|
||
break
|
||
|
||
pbd = getattr(ds, "PatientBirthDate", None)
|
||
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
|
||
if pbd and sdate:
|
||
from datetime import datetime
|
||
try:
|
||
bd = datetime.strptime(str(pbd), "%Y%m%d")
|
||
sd = datetime.strptime(str(sdate), "%Y%m%d")
|
||
years = round((sd - bd).days / 365.25)
|
||
age = int(years)
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
# Try inferring exam/modality from first series
|
||
first_series = case.series.first()
|
||
if first_series is not None:
|
||
inferred_examination = getattr(first_series, "examination", None)
|
||
inferred_modality = getattr(first_series, "modality", None)
|
||
except Exception:
|
||
pass
|
||
|
||
# Build form with initial values
|
||
from .forms import NormalCaseForm
|
||
|
||
# Map inferred age (years) into age_value/unit defaults. If we inferred
|
||
# a fractional year (from birthdate), prefer months for ages < 2 years.
|
||
from .models import NormalCase
|
||
inferred_age_value = None
|
||
inferred_age_unit = None
|
||
try:
|
||
if age is not None:
|
||
# age currently holds integer years; for small ages prefer months
|
||
if age < 2:
|
||
inferred_age_value = int(age * 12)
|
||
inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
|
||
else:
|
||
inferred_age_value = int(age)
|
||
inferred_age_unit = NormalCase.AGE_UNIT_YEARS
|
||
except Exception:
|
||
inferred_age_value = None
|
||
inferred_age_unit = None
|
||
|
||
|
||
form = NormalCaseForm(initial={
|
||
"age_value": inferred_age_value,
|
||
"age_unit": inferred_age_unit,
|
||
"examination": inferred_examination,
|
||
"modality": inferred_modality,
|
||
})
|
||
|
||
# Log the rendered form HTML so we can verify inputs/names reach the client
|
||
from loguru import logger
|
||
try:
|
||
form_html = form.as_p()
|
||
logger.debug("case_normal_form rendered form HTML: {}", form_html)
|
||
except Exception as e:
|
||
logger.exception("Failed to render form.as_p(): {}", e)
|
||
|
||
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
|
||
logger.debug("case_normal_form returning HTML length: {}", len(html))
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def create_case_normal(request, pk):
|
||
case = get_case_for_case_detail(pk)
|
||
|
||
# Permission check: rely on `can_edit` helper on the Case model
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
if not can_edit:
|
||
return HttpResponse(status=403)
|
||
|
||
from .forms import NormalCaseForm
|
||
from loguru import logger
|
||
|
||
form = NormalCaseForm(request.POST)
|
||
logger.debug("create_case_normal POST data: {}", dict(request.POST))
|
||
if form.is_valid():
|
||
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
|
||
nc = form.save(commit=False)
|
||
nc.case = case
|
||
# Save instance before modifying many-to-many relations
|
||
nc.save()
|
||
form.save_m2m()
|
||
nc.author.add(request.user)
|
||
|
||
# Ensure the case reflects the newly created NormalCase when rendering
|
||
try:
|
||
if hasattr(case, "normal_case"):
|
||
delattr(case, "normal_case")
|
||
except Exception:
|
||
pass
|
||
case = get_case_for_case_detail(pk)
|
||
|
||
# Return the updated toggle fragment and close modal via inline script
|
||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
toggle_html = render_to_string(
|
||
"atlas/partials/_normal_toggle.html",
|
||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
|
||
request=request,
|
||
)
|
||
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
|
||
return HttpResponse(toggle_html)
|
||
else:
|
||
# On form errors, re-render the modal with errors
|
||
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
|
||
return HttpResponse(html, status=400)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def case_normal_edit(request, pk):
|
||
"""Edit an existing NormalCase. Accessible to NormalCase authors and atlas editors."""
|
||
normal = get_object_or_404(NormalCase, pk=pk)
|
||
|
||
# Permission: allow NormalCase authors or atlas editors/superusers
|
||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||
is_normal_author = normal.author.filter(id=request.user.id).exists()
|
||
if not (is_atlas_editor or is_normal_author):
|
||
return HttpResponse(status=403)
|
||
|
||
from .forms import NormalCaseForm
|
||
|
||
if request.method == "POST":
|
||
form = NormalCaseForm(request.POST, instance=normal)
|
||
if form.is_valid():
|
||
nc = form.save(commit=False)
|
||
nc.save()
|
||
form.save_m2m()
|
||
|
||
# Ensure the case reflects any updates
|
||
try:
|
||
if hasattr(nc.case, "normal_case"):
|
||
delattr(nc.case, "normal_case")
|
||
except Exception:
|
||
pass
|
||
|
||
case = get_case_for_case_detail(nc.case.pk)
|
||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
toggle_html = render_to_string(
|
||
"atlas/partials/_normal_toggle.html",
|
||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
|
||
request=request,
|
||
)
|
||
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
|
||
return HttpResponse(toggle_html)
|
||
else:
|
||
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": normal.case, "normal": normal}, request=request)
|
||
return HttpResponse(html, status=400)
|
||
|
||
# GET - render form in modal
|
||
form = NormalCaseForm(instance=normal)
|
||
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": normal.case, "normal": normal}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_thumbnail_fail(request, pk):
|
||
return series_thumbnail(request, pk=pk, fail_loudly=True)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_thumbnail(request, pk, fail_loudly=False):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
|
||
thumbnail, _ = series.get_thumbnail(recreate=True, fail_loudly=fail_loudly)
|
||
|
||
return HttpResponse(thumbnail)
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_viewer(request, pk, finding_pk=None):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
context = {
|
||
"series": series,
|
||
}
|
||
|
||
return render(
|
||
request,
|
||
"atlas/series_viewer_test.html",
|
||
context,
|
||
)
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_detail(request, pk, finding_pk=None):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
|
||
can_edit = series.check_user_can_edit(request.user)
|
||
|
||
editing_finding = -1
|
||
if finding_pk is not None:
|
||
finding = get_object_or_404(SeriesFinding, pk=finding_pk)
|
||
series_finding_form = SeriesFindingForm(None, instance=finding)
|
||
editing_finding = finding.pk
|
||
else:
|
||
series_finding_form = SeriesFindingForm(series_id=series.id)
|
||
|
||
context = {
|
||
"series": series,
|
||
"series_finding_form": series_finding_form,
|
||
"editing_finding": editing_finding,
|
||
"can_edit": can_edit,
|
||
}
|
||
|
||
if can_edit:
|
||
context["available_cases"] = Case.objects.filter(author__id=request.user.id) # Adjust the queryset as needed
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/series.html",
|
||
context,
|
||
)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_images_partial(request, pk):
|
||
"""Return a small fragment that lists images for a series. Intended for HTMX."""
|
||
series = get_object_or_404(Series, pk=pk)
|
||
logger.debug(series)
|
||
images = series.images.filter(removed=False).order_by("pk")
|
||
logger.debug(images)
|
||
return render(request, "atlas/partials/series_images.html", {"series": series, "images": images})
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def question_schema_detail(request, pk: int):
|
||
question_schema = get_object_or_404(QuestionSchema, pk=pk)
|
||
|
||
example_form = question_schema.get_example_form()
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/question_schema_detail.html",
|
||
{"question_schema": question_schema, "example_form": example_form},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def question_schema_schemas(request):
|
||
question_schemas = QuestionSchema.objects.all()
|
||
|
||
return render(
|
||
request,
|
||
"atlas/question_schemas_preset.html",
|
||
{
|
||
"question_schemas": question_schemas,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def condition_detail(request, pk):
|
||
condition = get_object_or_404(Condition, pk=pk)
|
||
|
||
# form used for the merge UI
|
||
merge_form = ConditionAutocompleteForm()
|
||
|
||
# form used to add a synonym (can post either an existing condition via autocomplete
|
||
# or provide a plain name in 'synonym_name')
|
||
synonym_form = ConditionAutocompleteForm()
|
||
|
||
can_merge = False
|
||
|
||
if (
|
||
request.user.is_superuser
|
||
or request.user.groups.filter(name="atlas_editor").exists()
|
||
):
|
||
can_merge = True
|
||
|
||
# Handle POST to add a synonym
|
||
if request.method == "POST" and request.POST.get("action") == "add_synonym":
|
||
# only allow staff/editors
|
||
if not (
|
||
request.user.is_superuser
|
||
or request.user.groups.filter(name="atlas_editor").exists()
|
||
):
|
||
return HttpResponse("Forbidden", status=403)
|
||
|
||
# Try autocomplete-selected condition first
|
||
form = ConditionAutocompleteForm(request.POST)
|
||
other = None
|
||
if form.is_valid():
|
||
other = form.cleaned_data.get("condition")
|
||
|
||
# keep the form (possibly with errors) to render back in HTMX responses
|
||
synonym_form = form
|
||
|
||
# If no autocomplete selection, accept a free-text name
|
||
synonym_name = request.POST.get("synonym_name")
|
||
if not other and synonym_name:
|
||
other, created = Condition.objects.get_or_create(name=synonym_name)
|
||
if created:
|
||
# created condition: treat as alias by setting canonical below
|
||
pass
|
||
|
||
if other and other != condition:
|
||
# Option B: treat synonym as alias/canonical relationship.
|
||
# Determine canonical/master for the current condition
|
||
master = condition.canonical if condition.canonical else condition
|
||
|
||
# If `other` belongs to a different canonical group, move its group
|
||
# to point at our master. If `other` has no canonical, set it.
|
||
if other.canonical:
|
||
# move the whole other group (canonical + aliases) to our master
|
||
group_master = other.canonical
|
||
members = Condition.objects.filter(Q(canonical=group_master) | Q(pk=group_master.pk))
|
||
for m in members:
|
||
if m.pk != master.pk:
|
||
m.canonical = master
|
||
m.save()
|
||
else:
|
||
# other is standalone; make it an alias of our master
|
||
other.canonical = master
|
||
other.save()
|
||
|
||
# Master is indicated by having no `canonical` pointer. Ensure
|
||
# it remains canonical (no save required unless changed elsewhere).
|
||
|
||
# If this was an HTMX request return the updated synonyms partial so the
|
||
# page can be updated without a full redirect. Otherwise perform the
|
||
# existing redirect back to the detail page.
|
||
if request.htmx:
|
||
html = render_to_string(
|
||
"atlas/partials/_synonyms_list.html",
|
||
{"condition": condition, "synonym_form": synonym_form, "can_merge": can_merge},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
return redirect("atlas:condition_detail", pk=condition.pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/condition_detail.html",
|
||
{
|
||
"condition": condition,
|
||
"form": merge_form,
|
||
"synonym_form": synonym_form,
|
||
"can_merge": can_merge,
|
||
},
|
||
)
|
||
|
||
|
||
# class GetConditionAutocomplete(HTMXAutoComplete):
|
||
# name = "conditions"
|
||
# multiselect = False
|
||
#
|
||
# class Meta:
|
||
# model = Condition
|
||
|
||
|
||
@login_required
|
||
@user_is_atlas_editor
|
||
def condition_merge(request, pk_to_merge):
|
||
if not request.htmx:
|
||
raise Http404
|
||
|
||
condition_to_merge = get_object_or_404(Condition, pk=pk_to_merge)
|
||
# condition_to_merge_into = get_object_or_404(Condition, pk=pk_to_merge_into)
|
||
|
||
logger.debug("condition_merge: case_set={}", condition_to_merge.case_set)
|
||
if condition_to_merge.rcr_curriculum:
|
||
return HttpResponse("You cannot merge rcr curriculum items")
|
||
|
||
condition_to_merge_into = request.POST.get("conditions")
|
||
|
||
if not condition_to_merge_into:
|
||
return HttpResponse("You need to select a condition to merge into.")
|
||
|
||
condition_to_merge_into = get_object_or_404(Condition, pk=condition_to_merge_into)
|
||
|
||
if condition_to_merge == condition_to_merge_into:
|
||
return HttpResponse("You cannot merge into the same condition.")
|
||
|
||
# move all relevant relationships
|
||
cases = condition_to_merge.case_set.all().values_list("id", flat=True)
|
||
condition_to_merge_into.case_set.add(cases)
|
||
# cases = Case.objects.filter(condition=condition_to_merge)
|
||
# cases.update(condition=condition_to_merge_into)
|
||
|
||
differentials = Differential.objects.filter(condition=condition_to_merge)
|
||
differentials.update(condition=condition_to_merge_into)
|
||
|
||
return HttpResponse("Merged")
|
||
|
||
# conditions = Condition.objects.all()
|
||
|
||
# return render(request, "atlas/condition_options.html", {"conditions": conditions})
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def subspecialty_detail(request, pk):
|
||
subspecialty = get_object_or_404(Subspecialty, pk=pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/subspecialty_detail.html",
|
||
{
|
||
"subspecialty": subspecialty,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def presentation_detail(request, pk):
|
||
presentation = get_object_or_404(Presentation, pk=pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/presentation_detail.html",
|
||
{
|
||
"presentation": presentation,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def procedure_detail(request, pk):
|
||
procedure = get_object_or_404(Procedure, pk=pk)
|
||
|
||
# Cases that have this procedure
|
||
cases = Case.objects.filter(procedures__id=procedure.pk)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/procedure_detail.html",
|
||
{
|
||
"procedure": procedure,
|
||
"cases": cases,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def pathological_process_detail(request, pk):
|
||
pathological_process = get_object_or_404(PathologicalProcess, pk=pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/pathological_process_detail.html",
|
||
{
|
||
"pathological_process": pathological_process,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def structure_detail(request, pk):
|
||
structure = get_object_or_404(Structure, pk=pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/structure_detail.html",
|
||
{
|
||
"structure": structure,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def finding_detail(request, pk):
|
||
finding = get_object_or_404(Finding, pk=pk)
|
||
|
||
# logging.debug(atlas.subspecialty.first().name.all())
|
||
return render(
|
||
request,
|
||
"atlas/finding_detail.html",
|
||
{
|
||
"finding": finding,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def resource_detail(request, pk):
|
||
resource = get_object_or_404(Resource, pk=pk)
|
||
|
||
return render(request, "atlas/resource_detail.html", {"resource": resource})
|
||
|
||
|
||
@login_required
|
||
def resource_view(request, pk):
|
||
resource = get_object_or_404(Resource, pk=pk)
|
||
|
||
return HttpResponse(resource.get_display())
|
||
|
||
|
||
@login_required
|
||
def author_detail(request, pk):
|
||
# logging.debug(Author.objects.all())
|
||
# author = get_object_or_404(Author, pk=pk)
|
||
author = User.objects.get(pk=pk)
|
||
|
||
if request.user.pk == pk:
|
||
atlas = Case.objects.filter(author=pk)
|
||
else:
|
||
atlas = Case.objects.filter(author=pk, open_access=True)
|
||
|
||
return render(
|
||
request, "atlas/category_detail.html", {"category": author, "atlas": atlas}
|
||
)
|
||
|
||
|
||
@user_is_author_or_atlas_editor
|
||
def author_list(request):
|
||
authors = User.objects.all()
|
||
|
||
return render(request, "atlas/author_list.html", {"authors": authors})
|
||
|
||
|
||
@login_required
|
||
def index(request):
|
||
return render(request, "atlas/index.html", {})
|
||
|
||
|
||
@login_required
|
||
def user_collections(request):
|
||
"""Return collection buckets for a user (available/in-progress/finished).
|
||
|
||
By default this uses ``request.user``. Superusers may optionally provide
|
||
``?user_id=<id>`` to inspect another user's collection state. HTMX requests
|
||
return the ``atlas/partials/_user_collections.html`` fragment; normal
|
||
requests render ``atlas/user_collections.html``.
|
||
"""
|
||
|
||
target_user = request.user
|
||
requested_user_id = request.GET.get("user_id")
|
||
if requested_user_id and request.user.is_superuser:
|
||
try:
|
||
target_user = User.objects.get(pk=int(requested_user_id))
|
||
except (User.DoesNotExist, ValueError, TypeError):
|
||
target_user = request.user
|
||
|
||
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
||
available_collections = target_user.user_casecollection_exams.all()
|
||
# Collections the user has already taken (recorded in generic.CidUserExam)
|
||
in_progress_ids = []
|
||
finished_ids = []
|
||
try:
|
||
ct = ContentType.objects.get_for_model(CaseCollection)
|
||
taken_exams = CidUserExam.objects.filter(user_user=target_user, content_type=ct)
|
||
in_progress_ids = list(
|
||
taken_exams.filter(completed=False).values_list("object_id", flat=True).distinct()
|
||
)
|
||
finished_ids = list(
|
||
taken_exams.filter(completed=True).values_list("object_id", flat=True).distinct()
|
||
)
|
||
except Exception:
|
||
# If anything goes wrong determining taken/finished exams, treat as none
|
||
in_progress_ids = []
|
||
finished_ids = []
|
||
|
||
# Build querysets for the three groups
|
||
taken_all_ids = set(in_progress_ids) | set(finished_ids)
|
||
|
||
# Available to start: explicitly allowed collections that the user hasn't taken at all
|
||
available_qs = available_collections.exclude(pk__in=taken_all_ids).order_by("name")
|
||
|
||
# In-progress (taken but not completed)
|
||
in_progress_qs = CaseCollection.objects.filter(pk__in=list(in_progress_ids)).order_by("name")
|
||
|
||
# Finished (completed)
|
||
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
|
||
|
||
context = {
|
||
"target_user": target_user,
|
||
"available_collections": available_qs,
|
||
"in_progress_collections": in_progress_qs,
|
||
"finished_collections": finished_qs,
|
||
}
|
||
|
||
# If called via HTMX return a small fragment suitable for swapping
|
||
# into the index page. Otherwise render the full page.
|
||
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
|
||
if is_htmx:
|
||
html = render_to_string("atlas/partials/_user_collections.html", context, request=request)
|
||
return HttpResponse(html)
|
||
|
||
return render(request, "atlas/user_collections.html", context)
|
||
|
||
|
||
# Note: keep the above full-page render for normal requests. If this
|
||
# endpoint is called by HTMX we prefer returning a small fragment so it
|
||
# can be swapped into the index page without duplicating layout.
|
||
|
||
|
||
@login_required
|
||
def user_related_collections(request):
|
||
"""Return up to 10 most recently modified collections where the user
|
||
is an author or a marker. If called via HTMX return a fragment suitable
|
||
for swapping into the index page.
|
||
"""
|
||
user = request.user
|
||
qs = (
|
||
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
|
||
.distinct()
|
||
.order_by("-updated")[:10]
|
||
)
|
||
|
||
related = []
|
||
for c in qs:
|
||
related.append(
|
||
{
|
||
"collection": c,
|
||
"is_author": c.author.filter(id=user.id).exists(),
|
||
"is_marker": c.markers.filter(id=user.id).exists(),
|
||
}
|
||
)
|
||
|
||
context = {"related_collections": related}
|
||
|
||
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
|
||
if is_htmx:
|
||
html = render_to_string("atlas/partials/_user_related_collections.html", context, request=request)
|
||
return HttpResponse(html)
|
||
|
||
return render(request, "atlas/user_collections.html", context)
|
||
|
||
@login_required
|
||
def collection_options(request):
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
collections = CaseCollection.objects.filter(author=request.user).order_by("name")
|
||
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
def collection_case_jump_partial(request, pk: int):
|
||
"""Return an HTMX partial containing a jump list for cases in the collection.
|
||
|
||
Expects optional GET params:
|
||
- current: the current case index (0-based) to mark active
|
||
- cid, passcode: optional candidate identifiers so links include them when present
|
||
"""
|
||
# Allow non-HTMX callers to still get the fragment (useful for testing)
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
casedetails = (
|
||
CaseDetail.objects.filter(collection=collection)
|
||
.select_related('case')
|
||
.order_by('sort_order')
|
||
)
|
||
|
||
try:
|
||
current = int(request.GET.get('current', 0) or 0)
|
||
except Exception:
|
||
current = 0
|
||
|
||
cid = request.GET.get('cid')
|
||
passcode = request.GET.get('passcode')
|
||
|
||
try:
|
||
cid_value = int(cid) if cid is not None else None
|
||
except Exception:
|
||
cid_value = None
|
||
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||
cid=cid_value,
|
||
user_user=request.user,
|
||
)
|
||
|
||
if cid_value is not None:
|
||
answers = CidReportAnswer.objects.filter(cid=cid_value, question__in=casedetails)
|
||
else:
|
||
answers = UserReportAnswer.objects.filter(user=request.user, question__in=casedetails)
|
||
|
||
answer_case_ids = {
|
||
ans.question.case_id
|
||
for ans in answers
|
||
if (ans.answer or ans.json_answer)
|
||
}
|
||
answer_by_case_id = {
|
||
ans.question.case_id: ans
|
||
for ans in answers
|
||
}
|
||
|
||
latest_reviews = (
|
||
cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
|
||
.select_related("case")
|
||
.order_by("case_id", "-review_update_date")
|
||
)
|
||
|
||
latest_review_by_case = {}
|
||
for review in latest_reviews:
|
||
if review.case_id not in latest_review_by_case:
|
||
latest_review_by_case[review.case_id] = review
|
||
|
||
jump_items = []
|
||
for idx, cd in enumerate(casedetails):
|
||
if cid is not None:
|
||
case_url = reverse(
|
||
"atlas:collection_case_view_take",
|
||
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
|
||
)
|
||
else:
|
||
case_url = reverse(
|
||
"atlas:collection_case_view_take_user",
|
||
kwargs={"pk": collection.pk, "case_number": idx},
|
||
)
|
||
|
||
add_url = reverse(
|
||
"atlas:add_self_review",
|
||
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id},
|
||
)
|
||
|
||
review = latest_review_by_case.get(cd.case_id)
|
||
timing_state = _build_case_timing_state(
|
||
collection,
|
||
cd,
|
||
answer_by_case_id.get(cd.case_id),
|
||
)
|
||
review_view_url = None
|
||
review_edit_url = None
|
||
if review is not None:
|
||
review_view_url = reverse(
|
||
"atlas:self_review_detail",
|
||
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id},
|
||
)
|
||
review_edit_url = reverse(
|
||
"atlas:self_review_update",
|
||
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id},
|
||
)
|
||
|
||
jump_items.append(
|
||
{
|
||
"idx": idx,
|
||
"cd": cd,
|
||
"case_url": case_url,
|
||
"self_review_add_url": add_url,
|
||
"self_review_view_url": review_view_url,
|
||
"self_review_edit_url": review_edit_url,
|
||
"has_answer": cd.case_id in answer_case_ids,
|
||
"has_self_review": review is not None,
|
||
"is_time_expired": timing_state["effective_time_limit"] is not None and timing_state["case_view_locked"],
|
||
}
|
||
)
|
||
|
||
html = render_to_string(
|
||
'atlas/partials/_collection_case_jump.html',
|
||
{
|
||
'collection': collection,
|
||
'casedetails': casedetails,
|
||
'jump_items': jump_items,
|
||
'current': current,
|
||
'cid': cid,
|
||
'passcode': passcode,
|
||
},
|
||
request=request,
|
||
)
|
||
|
||
return HttpResponse(html)
|
||
|
||
@login_required
|
||
def remove_cases_from_collection(request):
|
||
if not request.htmx:
|
||
return Http404
|
||
case_ids = request.POST.getlist('selection')
|
||
collection_id = request.POST.get('collection_id')
|
||
if not case_ids or not collection_id:
|
||
return HttpResponse("No cases selected or collection ID provided.")
|
||
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||
if request.user not in collection.author.all():
|
||
return HttpResponse("You do not have permission to remove cases from this collection.")
|
||
cases = Case.objects.filter(pk__in=case_ids)
|
||
for case_ in cases:
|
||
collection.cases.remove(case_)
|
||
return HttpResponse(f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)")
|
||
|
||
@login_required
|
||
def add_cases_to_collection(request):
|
||
if not request.htmx:
|
||
return Http404
|
||
case_ids = request.POST.getlist('selection')
|
||
collection_id = request.POST.get('collection_id')
|
||
if not case_ids or not collection_id:
|
||
return HttpResponse("No cases selected or collection ID provided.")
|
||
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||
if request.user not in collection.author.all():
|
||
return HttpResponse("You do not have permission to add cases to this collection.")
|
||
cases = Case.objects.filter(pk__in=case_ids)
|
||
for case_ in cases:
|
||
collection.cases.add(case_)
|
||
return HttpResponse(f"Added {len(cases)} cases to collection {collection.name} (refresh to see)")
|
||
|
||
@login_required
|
||
def set_open_access(request):
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
case_ids = request.POST.getlist("selection")
|
||
if not case_ids:
|
||
return HttpResponse("No case ID(s) provided.")
|
||
|
||
cases = Case.objects.filter(pk__in=case_ids)
|
||
|
||
for case in cases:
|
||
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
|
||
# but it is simpler to just check the first case.
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse(f"You do not have permission to set case {case.name} as open access.")
|
||
|
||
case.open_access = True
|
||
case.save()
|
||
|
||
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes).")
|
||
|
||
@login_required
|
||
def remove_open_access(request):
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
case_ids = request.POST.getlist("selection")
|
||
if not case_ids:
|
||
return HttpResponse("No case ID(s) provided.")
|
||
|
||
cases = Case.objects.filter(pk__in=case_ids)
|
||
|
||
for case in cases:
|
||
# This will not let give accurate permission errors if the user does not have permission to edit any of the cases
|
||
# but it is simpler to just check the first case.
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse(f"You do not have permission to remove open access from case {case.name}.")
|
||
|
||
case.open_access = False
|
||
case.save()
|
||
|
||
return HttpResponse(f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes).")
|
||
|
||
|
||
def add_case_to_collection(request, collection_id):
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||
|
||
if request.method == "POST":
|
||
case = get_object_or_404(Case, pk=request.POST["case"])
|
||
if request.user not in collection.author.all():
|
||
return HttpResponse("You do not have permission to add cases to this collection.")
|
||
|
||
if case in collection.cases.all():
|
||
return HttpResponse("Case already in collection")
|
||
|
||
logger.debug("collection_add_case: case={}", case)
|
||
collection.add_case(case)
|
||
# Render the new case list item and return it so HTMX can append it
|
||
try:
|
||
casedetail = collection.casedetail_set.get(case=case)
|
||
except Exception:
|
||
return HttpResponse("Case added, but could not render item.")
|
||
|
||
total = collection.casedetail_set.count()
|
||
can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists()
|
||
rendered = render_to_string(
|
||
"atlas/partials/case_list_item.html",
|
||
{
|
||
"casedetail": casedetail,
|
||
"collection": collection,
|
||
"forloop": {"counter": total, "counter0": max(0, total - 1)},
|
||
"can_edit": can_edit,
|
||
},
|
||
request=request,
|
||
)
|
||
# Also instruct the client to remove this case from any visible search/recent lists
|
||
remove_script = (
|
||
'<script>'
|
||
'(function(){'
|
||
'try {'
|
||
"var pk = '%s';" % case.pk +
|
||
"document.querySelectorAll('#case-search-results [data-case-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
|
||
"document.querySelectorAll('#case-search-results input[name=\"case\"][value=\"' + pk + '\"]').forEach(function(inp){ var li = inp.closest('.list-group-item'); if (li) li.remove(); });" +
|
||
'} catch(e) { console.error("remove added case from search error", e); }'
|
||
'})();'
|
||
'</script>'
|
||
)
|
||
|
||
return HttpResponse(rendered + remove_script)
|
||
|
||
# if request.method == "POST":
|
||
|
||
# don't show search results on initial load; the search partial will render only when a query exists
|
||
cases = Case.objects.none()
|
||
|
||
# Also compute recent cases for initial display (authored by current user and not in collection)
|
||
recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
|
||
recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
|
||
recent_cases = recent_qs.order_by("-created_date")[:5]
|
||
|
||
return render(
|
||
request,
|
||
"atlas/add_case_to_collection.html",
|
||
{"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": ""},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def case_search(request):
|
||
# HTMX endpoint for searching cases available to the user
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
q = request.GET.get("q", "").strip()
|
||
collection_id = request.GET.get("collection")
|
||
show_select = str(request.GET.get("show_select", "")).lower() in ("1", "true", "yes", "on")
|
||
collection = None
|
||
if collection_id:
|
||
try:
|
||
collection = CaseCollection.objects.get(pk=collection_id)
|
||
except CaseCollection.DoesNotExist:
|
||
collection = None
|
||
|
||
qs = get_cases_available_to_user(request.user, collection=collection)
|
||
if q:
|
||
qs = qs.filter(title__icontains=q)
|
||
|
||
# Recent cases: last 5 created that are available to the user and not already in the collection
|
||
# Restrict recent cases to those authored by the current user and not already in the collection
|
||
recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
|
||
if collection is not None:
|
||
recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
|
||
recent_cases = recent_qs.order_by("-created_date")[:5]
|
||
|
||
# Exclude recent cases from the main search results to avoid duplication
|
||
recent_pks = [c.pk for c in recent_cases]
|
||
|
||
# Build the base queryset for results, apply excludes first (before slicing)
|
||
cases_qs = qs.exclude(pk__in=recent_pks)
|
||
|
||
# Also exclude any cases that are already present in the collection
|
||
if collection is not None:
|
||
existing_pks = list(collection.cases.values_list("pk", flat=True))
|
||
if existing_pks:
|
||
cases_qs = cases_qs.exclude(pk__in=existing_pks)
|
||
|
||
cases = cases_qs.order_by("title")[:30]
|
||
|
||
return render(
|
||
request,
|
||
"atlas/partials/case_search_results.html",
|
||
{
|
||
"cases": cases,
|
||
"collection": collection,
|
||
"recent_cases": recent_cases,
|
||
"q": q,
|
||
"show_select_button": show_select,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def user_uploads_series(request, series_instance_uid: str):
|
||
# We don't restrict to teh uploading user here. Should we?
|
||
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=series_instance_uid)
|
||
|
||
if not dicoms:
|
||
raise Http404("Series not found")
|
||
|
||
return render(
|
||
request,
|
||
"atlas/user_upload_viewer.html",
|
||
{"dicoms": dicoms, "series_id": series_instance_uid},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def new_uploads(request):
|
||
return render(request, "atlas/new_uploads.html", {})
|
||
|
||
|
||
@login_required
|
||
@user_passes_test(lambda u: u.is_superuser)
|
||
def all_uploads(request, case_id: int | None = None):
|
||
return user_uploads(request, case_id, user_only=False)
|
||
|
||
|
||
@staff_member_required
|
||
def uploads_hash_search(request):
|
||
hash_query = (request.GET.get("hash") or "").strip().lower()
|
||
series_query = (request.GET.get("series") or "").strip()
|
||
study_query = (request.GET.get("study") or "").strip()
|
||
|
||
uploads_qs = UncategorisedDicom.objects.none()
|
||
imported_qs = SeriesImage.objects.none()
|
||
matched_series_qs = Series.objects.none()
|
||
|
||
has_query = bool(hash_query or series_query or study_query)
|
||
|
||
if has_query:
|
||
uploads_filters = Q()
|
||
imported_filters = Q()
|
||
matched_series_filters = Q()
|
||
|
||
if hash_query:
|
||
uploads_filters |= Q(image_blake3_hash__icontains=hash_query)
|
||
imported_filters |= Q(image_blake3_hash__icontains=hash_query)
|
||
|
||
if series_query:
|
||
uploads_filters |= Q(series_instance_uid__icontains=series_query)
|
||
imported_filters |= Q(series__series_instance_uid__icontains=series_query)
|
||
matched_series_filters |= Q(series_instance_uid__icontains=series_query)
|
||
|
||
if study_query:
|
||
uploads_filters |= Q(basic_dicom_tags__StudyInstanceUID__icontains=study_query)
|
||
imported_filters |= Q(series__study_instance_uid__icontains=study_query)
|
||
matched_series_filters |= Q(study_instance_uid__icontains=study_query)
|
||
|
||
uploads_qs = (
|
||
UncategorisedDicom.objects.filter(uploads_filters)
|
||
.select_related("user", "series")
|
||
.order_by("-created_date")
|
||
.distinct()
|
||
)
|
||
|
||
imported_qs = (
|
||
SeriesImage.objects.filter(imported_filters)
|
||
.select_related("series")
|
||
.order_by("-pk")
|
||
.distinct()
|
||
)
|
||
|
||
if matched_series_filters.children:
|
||
matched_series_qs = (
|
||
Series.objects.filter(matched_series_filters)
|
||
.prefetch_related("case")
|
||
.order_by("-modified_date")
|
||
.distinct()
|
||
)
|
||
else:
|
||
matched_series_qs = Series.objects.none()
|
||
|
||
uploads_paginator = Paginator(uploads_qs, 50)
|
||
uploads_page_number = request.GET.get("uploads_page")
|
||
uploads_page_obj = uploads_paginator.get_page(uploads_page_number)
|
||
|
||
imported_paginator = Paginator(imported_qs, 50)
|
||
imported_page_number = request.GET.get("imported_page")
|
||
imported_page_obj = imported_paginator.get_page(imported_page_number)
|
||
|
||
matched_series_paginator = Paginator(matched_series_qs, 50)
|
||
matched_series_page_number = request.GET.get("series_page")
|
||
matched_series_page_obj = matched_series_paginator.get_page(matched_series_page_number)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/uploads_hash_search.html",
|
||
{
|
||
"hash_query": hash_query,
|
||
"series_query": series_query,
|
||
"study_query": study_query,
|
||
"uploads_page_obj": uploads_page_obj,
|
||
"uploads_result_count": uploads_paginator.count,
|
||
"imported_page_obj": imported_page_obj,
|
||
"imported_result_count": imported_paginator.count,
|
||
"matched_series_page_obj": matched_series_page_obj,
|
||
"matched_series_result_count": matched_series_paginator.count,
|
||
"series_study_scan_limit": 0,
|
||
},
|
||
)
|
||
|
||
|
||
@user_passes_test(lambda u: u.is_superuser)
|
||
def other_user_uploads(request, user_pk: int, case_id: int | None = None):
|
||
return user_uploads(request, case_id, user_only=False, user_pk=user_pk)
|
||
|
||
|
||
@login_required
|
||
def user_uploads(
|
||
request,
|
||
case_id: int | None = None,
|
||
user_only: bool = True,
|
||
user_pk: int | None = None,
|
||
):
|
||
"""List uploaded (unimported) DICOM series and suggest continuation imports.
|
||
|
||
Scope: Authenticated users (or superusers viewing other users' uploads).
|
||
|
||
Functionality:
|
||
- Groups uploaded series by study for easier selection.
|
||
- Detects when at least one uploaded series from a study is already imported
|
||
into a case.
|
||
- Exposes per-study suggestions to import only the remaining uploaded series
|
||
into that same case.
|
||
"""
|
||
user = request.user
|
||
if user_only:
|
||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||
elif user_pk is not None:
|
||
user = get_object_or_404(User, pk=user_pk)
|
||
dicoms = UncategorisedDicom.objects.filter(user=user)
|
||
else:
|
||
dicoms = UncategorisedDicom.objects.filter()
|
||
|
||
data = defaultdict(list)
|
||
series_hashes = defaultdict(set)
|
||
for d in dicoms:
|
||
tags = d.basic_dicom_tags
|
||
|
||
if tags is None:
|
||
d.save()
|
||
tags = d.basic_dicom_tags
|
||
|
||
series_uid = tags.get("SeriesInstanceUID")
|
||
if not series_uid:
|
||
continue
|
||
|
||
data[series_uid].append((tags, d.created_date))
|
||
if d.image_blake3_hash:
|
||
series_hashes[series_uid].add(d.image_blake3_hash)
|
||
|
||
series_list = []
|
||
for series in data:
|
||
tags, date = data[series][0]
|
||
series_list.append((series, len(data[series]), tags, date))
|
||
|
||
series_list.sort(key=lambda l: l[-1] or "")
|
||
|
||
# Group series by StudyInstanceUID for better visual grouping in the template
|
||
grouped = {}
|
||
for series_uid, count, tags, date in series_list:
|
||
study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
|
||
study_desc = tags.get("StudyDescription", "")
|
||
# Normalise to a sortable string; DICOM StudyDate is "YYYYMMDD", fall
|
||
# back to the upload's created_date converted to the same format.
|
||
raw_study_date = tags.get("StudyDate")
|
||
if raw_study_date:
|
||
study_date = str(raw_study_date)
|
||
elif hasattr(date, "strftime"):
|
||
study_date = date.strftime("%Y%m%d")
|
||
else:
|
||
study_date = str(date)
|
||
if study_uid not in grouped:
|
||
grouped[study_uid] = {
|
||
"description": study_desc,
|
||
"date": study_date,
|
||
"series": [],
|
||
"import_suggestions": [],
|
||
"partial_imported_uids": [],
|
||
"matched_existing_series": [],
|
||
}
|
||
grouped[study_uid]["series"].append((series_uid, count, tags, date))
|
||
|
||
# Convert to a list preserving order sorted by group date
|
||
grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "")
|
||
|
||
# Build per-study suggestions where some series are already in a case but
|
||
# additional uploaded series from the same study are still pending import.
|
||
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
|
||
uploaded_study_uids = {
|
||
(tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
|
||
for _, _, tags, _ in series_list
|
||
if (tags.get("StudyInstanceUID") or tags.get("StudyID"))
|
||
}
|
||
uploaded_series_uid_to_study_uid = {
|
||
series_uid: (tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
|
||
for series_uid, _, tags, _ in series_list
|
||
}
|
||
uploaded_uid_to_existing_series_ids: dict[str, set[int]] = defaultdict(set)
|
||
existing_series_lookup: dict[int, Series] = {}
|
||
case_to_study_uids = defaultdict(set)
|
||
uploaded_hashes = {
|
||
image_hash
|
||
for hash_set in series_hashes.values()
|
||
for image_hash in hash_set
|
||
}
|
||
hash_to_uploaded_uids = defaultdict(set)
|
||
for uploaded_uid, hash_set in series_hashes.items():
|
||
for image_hash in hash_set:
|
||
hash_to_uploaded_uids[image_hash].add(uploaded_uid)
|
||
|
||
if uploaded_series_uids:
|
||
existing_series = (
|
||
Series.objects.filter(series_instance_uid__in=uploaded_series_uids)
|
||
.prefetch_related("case")
|
||
)
|
||
|
||
existing_study_series = Series.objects.filter(
|
||
study_instance_uid__in=uploaded_study_uids
|
||
).prefetch_related("case")
|
||
|
||
case_to_series_uids = defaultdict(set)
|
||
case_lookup = {}
|
||
|
||
for existing in existing_series:
|
||
if not existing.series_instance_uid:
|
||
continue
|
||
existing_series_lookup[existing.pk] = existing
|
||
uploaded_uid_to_existing_series_ids[existing.series_instance_uid].add(
|
||
existing.pk
|
||
)
|
||
for case_obj in existing.case.all():
|
||
if not case_obj.check_user_can_edit(request.user):
|
||
continue
|
||
case_lookup[case_obj.pk] = case_obj
|
||
case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
|
||
if existing.study_instance_uid:
|
||
case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)
|
||
|
||
for existing in existing_study_series:
|
||
if not existing.study_instance_uid:
|
||
continue
|
||
|
||
existing_series_lookup[existing.pk] = existing
|
||
for uploaded_uid, uploaded_study_uid in uploaded_series_uid_to_study_uid.items():
|
||
if uploaded_study_uid and uploaded_study_uid == existing.study_instance_uid:
|
||
uploaded_uid_to_existing_series_ids[uploaded_uid].add(existing.pk)
|
||
|
||
for case_obj in existing.case.all():
|
||
if not case_obj.check_user_can_edit(request.user):
|
||
continue
|
||
case_lookup[case_obj.pk] = case_obj
|
||
case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)
|
||
|
||
if uploaded_hashes:
|
||
imported_hash_matches = (
|
||
SeriesImage.objects.filter(image_blake3_hash__in=uploaded_hashes)
|
||
.exclude(series=None)
|
||
.select_related("series")
|
||
.prefetch_related("series__case")
|
||
)
|
||
|
||
for image in imported_hash_matches:
|
||
if image.series is None:
|
||
continue
|
||
|
||
existing_series_lookup[image.series.pk] = image.series
|
||
matched_uploaded_uids = hash_to_uploaded_uids.get(
|
||
image.image_blake3_hash, set()
|
||
)
|
||
for uploaded_uid in matched_uploaded_uids:
|
||
uploaded_uid_to_existing_series_ids[uploaded_uid].add(image.series.pk)
|
||
for case_obj in image.series.case.all():
|
||
if not case_obj.check_user_can_edit(request.user):
|
||
continue
|
||
case_lookup[case_obj.pk] = case_obj
|
||
case_to_series_uids[case_obj.pk].add(uploaded_uid)
|
||
study_uid = uploaded_series_uid_to_study_uid.get(uploaded_uid)
|
||
if study_uid:
|
||
case_to_study_uids[case_obj.pk].add(study_uid)
|
||
|
||
for study_uid, study_data in grouped_series:
|
||
study_uploaded_uids = {
|
||
s_uid for s_uid, _, _, _ in study_data.get("series", [])
|
||
}
|
||
if not study_uploaded_uids:
|
||
continue
|
||
|
||
case_candidates_for_study = set()
|
||
|
||
study_partial_imported_uids = sorted(
|
||
s_uid
|
||
for s_uid in study_uploaded_uids
|
||
if uploaded_uid_to_existing_series_ids.get(s_uid)
|
||
)
|
||
if study_partial_imported_uids:
|
||
study_data["partial_imported_uids"] = study_partial_imported_uids
|
||
study_data["partial_imported_count"] = len(study_partial_imported_uids)
|
||
|
||
matched_series_ids = set()
|
||
for uploaded_uid in study_partial_imported_uids:
|
||
matched_series_ids.update(
|
||
uploaded_uid_to_existing_series_ids.get(uploaded_uid, set())
|
||
)
|
||
|
||
study_data["matched_existing_series"] = [
|
||
existing_series_lookup[series_id]
|
||
for series_id in sorted(matched_series_ids)
|
||
if series_id in existing_series_lookup
|
||
]
|
||
|
||
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())
|
||
overlap = study_uploaded_uids & imported_uids
|
||
case_study_uids = case_to_study_uids.get(case_pk, set())
|
||
|
||
if not overlap and (not study_uid or study_uid not in case_study_uids):
|
||
continue
|
||
|
||
case_candidates_for_study.add(case_pk)
|
||
|
||
missing_uids = sorted(study_uploaded_uids - imported_uids)
|
||
if not missing_uids:
|
||
continue
|
||
|
||
study_data["import_suggestions"].append(
|
||
{
|
||
"case": case_lookup[case_pk],
|
||
"already_imported_count": len(overlap),
|
||
"missing_uids": missing_uids,
|
||
}
|
||
)
|
||
|
||
if 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(
|
||
study_data["import_suggestions"],
|
||
key=lambda s: s.get("already_imported_count", 0),
|
||
reverse=True,
|
||
)
|
||
study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk
|
||
|
||
case = False
|
||
if case_id is not None:
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/user_uploads.html",
|
||
{
|
||
"series_list": series_list,
|
||
"grouped_series": grouped_series,
|
||
"uploaded_series_uids": uploaded_series_uids,
|
||
"case": case,
|
||
"user": user,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def uploads_import_htmx(request, case_id: int | None = None):
|
||
"""HTMX endpoint to import uploaded DICOMs with user-facing status output.
|
||
|
||
Scope: Authenticated Atlas users.
|
||
|
||
Functionality:
|
||
- Imports selected uploaded series (or all pending uploads if none selected).
|
||
- Optionally associates imported series with a target case.
|
||
- Returns an HTML fragment suitable for HTMX swap into the uploads page.
|
||
"""
|
||
selected_uids = [
|
||
uid.strip() for uid in request.POST.getlist("selection") if uid.strip()
|
||
]
|
||
|
||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||
if selected_uids:
|
||
dicoms = dicoms.filter(series_instance_uid__in=selected_uids)
|
||
|
||
target_case = None
|
||
if case_id is not None:
|
||
target_case = get_object_or_404(Case, pk=case_id)
|
||
if not target_case.check_user_can_edit(request.user):
|
||
return HttpResponse(
|
||
'<div class="alert alert-danger">You do not have permission to import into this case.</div>',
|
||
status=403,
|
||
)
|
||
|
||
if not dicoms.exists():
|
||
return HttpResponse(
|
||
'<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>'
|
||
)
|
||
|
||
try:
|
||
# Import helper lives in the API module and handles the core create/update logic.
|
||
from .api import import_dicoms_helper
|
||
|
||
imported = import_dicoms_helper(request, case_id=case_id, dicoms=dicoms)
|
||
except Exception as exc:
|
||
return HttpResponse(
|
||
f'<div class="alert alert-danger mb-0">Import failed: {escape(str(exc))}</div>',
|
||
status=500,
|
||
)
|
||
|
||
imported_rows = [
|
||
{
|
||
"series": series,
|
||
"link": link,
|
||
}
|
||
for series, link in imported
|
||
]
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/_upload_import_result.html",
|
||
{
|
||
"imported_rows": imported_rows,
|
||
"imported_count": len(imported_rows),
|
||
"requested_count": len(selected_uids) if selected_uids else None,
|
||
"target_case": target_case,
|
||
},
|
||
request=request,
|
||
)
|
||
response = HttpResponse(html)
|
||
response["HX-Trigger"] = json.dumps(
|
||
{
|
||
"atlas:uploadsImported": {
|
||
"imported_count": len(imported_rows),
|
||
"requested_count": len(selected_uids) if selected_uids else len(imported_rows),
|
||
}
|
||
}
|
||
)
|
||
return response
|
||
|
||
|
||
class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
|
||
model = Case
|
||
success_url = reverse_lazy("atlas:case_view")
|
||
template_name = "confirm_delete.html"
|
||
|
||
|
||
# TODO fix permissions
|
||
class SeriesFindingDelete(RevisionMixin, PermissionRequiredMixin, DeleteView):
|
||
permission_required = "atlas.delete_seriesfinding"
|
||
model = SeriesFinding
|
||
template_name = "confirm_delete.html"
|
||
# success_url = reverse_lazy("atlas:case_view")
|
||
|
||
def get_object(self):
|
||
series_finding = super().get_object()
|
||
series = series_finding.series
|
||
if series.check_user_can_edit(self.request.user):
|
||
return series_finding
|
||
else:
|
||
raise PermissionDenied
|
||
|
||
def get_success_url(self):
|
||
pk = self.kwargs["pk"]
|
||
series_id = get_object_or_404(SeriesFinding, pk=pk).series.id
|
||
return reverse("atlas:series_detail", kwargs={"pk": series_id})
|
||
|
||
|
||
class SeriesDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
|
||
model = Series
|
||
template_name = "confirm_delete.html"
|
||
success_url = reverse_lazy("atlas:series_view")
|
||
|
||
|
||
class CaseCollectionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
|
||
model = CaseCollection
|
||
template_name = "confirm_delete.html"
|
||
success_url = reverse_lazy("atlas:collection_view")
|
||
|
||
|
||
class ConditionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
|
||
model = Condition
|
||
template_name = "confirm_delete.html"
|
||
success_url = reverse_lazy("atlas:condition_view")
|
||
|
||
|
||
class FindingDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
|
||
model = Finding
|
||
template_name = "confirm_delete.html"
|
||
success_url = reverse_lazy("atlas:finding_view")
|
||
|
||
|
||
class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
|
||
model = Structure
|
||
template_name = "confirm_delete.html"
|
||
success_url = reverse_lazy("atlas:structure_view")
|
||
|
||
|
||
class ResourceDelete(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, DeleteView):
|
||
model = Resource
|
||
template_name = "confirm_delete.html"
|
||
|
||
success_url = reverse_lazy("atlas:resource_view")
|
||
|
||
|
||
class ResourceCreate(LoginRequiredMixin, CreateView):
|
||
model = Resource
|
||
form_class = ResourceForm
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
return super().form_valid(form)
|
||
|
||
|
||
class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Resource
|
||
table_class = ResourceTable
|
||
template_name = "atlas/resource_list.html"
|
||
filterset_class = ResourceFilter
|
||
|
||
def get_queryset(self):
|
||
# Base queryset
|
||
qs = Resource.objects.all()
|
||
|
||
# Atlas editors and superusers see everything
|
||
if self.request.user.is_superuser or self.request.user.groups.filter(name="atlas_editor").exists():
|
||
return qs
|
||
|
||
# Otherwise only show open access resources or those authored by the user
|
||
return qs.filter(Q(open_access=True) | Q(author__id=self.request.user.id)).distinct()
|
||
|
||
|
||
class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = CaseCollection
|
||
form_class = CaseCollectionForm
|
||
template_name = "atlas/casecollection_form.html"
|
||
|
||
def get_initial(self):
|
||
# print(self.request)
|
||
if "pk" in self.kwargs:
|
||
initial = super(CaseCollectionCreate, self).get_initial()
|
||
# case = get_object_or_404(Case, pk=self.kwargs["pk"])
|
||
|
||
# initial["case"] = case.id
|
||
|
||
return initial
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(CaseCollectionCreate, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
|
||
# Only bind the case formset when the POST actually contains formset data.
|
||
# We don't require adding cases on create, so if the template doesn't
|
||
# include the casedetail management fields we should skip validation.
|
||
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
|
||
context["case_formset"] = CaseCollectionCaseFormSet(
|
||
self.request.POST,
|
||
self.request.FILES,
|
||
form_kwargs={"user": self.request.user},
|
||
)
|
||
context["case_formset"].full_clean()
|
||
else:
|
||
context["case_formset"] = CaseCollectionCaseFormSet(
|
||
form_kwargs={"user": self.request.user}
|
||
)
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
context = self.get_context_data(form=form)
|
||
case_formset = context["case_formset"]
|
||
# If the POST included case formset data, validate and save it. Otherwise
|
||
# skip formset processing so creating a collection without cases redirects
|
||
# normally to the detail view.
|
||
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
|
||
if case_formset.is_valid():
|
||
response = super().form_valid(form)
|
||
case_formset.instance = self.object
|
||
case_formset.save()
|
||
return response
|
||
else:
|
||
return super().form_invalid(form)
|
||
# No formset data present: proceed with normal create redirect.
|
||
return super().form_valid(form)
|
||
|
||
def get_success_url(self):
|
||
"""Redirect to the collection detail page after successful creation."""
|
||
return reverse("atlas:collection_detail", kwargs={"pk": self.object.pk})
|
||
|
||
|
||
class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Series
|
||
form_class = SeriesForm
|
||
|
||
def get_initial(self):
|
||
# print(self.request)
|
||
if "pk" in self.kwargs:
|
||
initial = super().get_initial()
|
||
case = get_object_or_404(Case, pk=self.kwargs["pk"])
|
||
|
||
initial["case"] = case.id
|
||
|
||
return initial
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(SeriesCreate, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(SeriesCreate, self).get_context_data(**kwargs)
|
||
|
||
if self.request.POST:
|
||
context["image_formset"] = SeriesImageFormSet(
|
||
self.request.POST, self.request.FILES
|
||
)
|
||
context["image_formset"].full_clean()
|
||
else:
|
||
context["image_formset"] = SeriesImageFormSet()
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
context = self.get_context_data(form=form)
|
||
series_formset = context["image_formset"]
|
||
if series_formset.is_valid():
|
||
response = super().form_valid(form)
|
||
series_formset.instance = self.object
|
||
series_formset.save()
|
||
return response
|
||
|
||
else:
|
||
return super().form_invalid(form)
|
||
|
||
|
||
class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
|
||
model = CaseCollection
|
||
form_class = CaseCollectionForm
|
||
template_name = "atlas/casecollection_form.html"
|
||
|
||
# fields = '__all__'
|
||
# #fields = [ 'condition' ]
|
||
# #initial = {'date_of_death': '05/01/2018'}
|
||
# exclude = [ 'created_date', 'published_date' ]
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(CaseCollectionUpdate, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
|
||
#if self.request.POST:
|
||
# context["case_formset"] = CaseCollectionCaseFormSet(
|
||
# self.request.POST,
|
||
# self.request.FILES,
|
||
# instance=self.object,
|
||
# form_kwargs={"user": self.request.user, "collection": self.object},
|
||
# )
|
||
# context["case_formset"].full_clean()
|
||
#else:
|
||
# context["case_formset"] = CaseCollectionCaseFormSet(
|
||
# instance=self.object,
|
||
# form_kwargs={"user": self.request.user, "collection": self.object},
|
||
# )
|
||
|
||
context["collection"] = context["casecollection"]
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
return super().form_valid(form)
|
||
context = self.get_context_data(form=form)
|
||
case_formset = context["case_formset"]
|
||
# logger.debug(formset.is_valid())
|
||
if case_formset.is_valid():
|
||
response = super().form_valid(form)
|
||
case_formset.instance = self.object
|
||
case_formset.save()
|
||
|
||
# restore exam orders
|
||
# for exam in self.object.exams.all():
|
||
# if exam in exam_orders and self.object in exam_orders[exam]:
|
||
# print(exam_orders[exam])
|
||
# exam.exam_questions.set(exam_orders[exam])
|
||
# exam.save()
|
||
|
||
return response
|
||
else:
|
||
return super().form_invalid(form)
|
||
|
||
|
||
class SeriesUpdate(
|
||
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
||
):
|
||
model = Series
|
||
form_class = SeriesForm
|
||
|
||
# fields = '__all__'
|
||
# #fields = [ 'condition' ]
|
||
# #initial = {'date_of_death': '05/01/2018'}
|
||
# exclude = [ 'created_date', 'published_date' ]
|
||
def get_context_data(self, **kwargs):
|
||
context = super(SeriesUpdate, self).get_context_data(**kwargs)
|
||
if self.request.POST:
|
||
context["image_formset"] = SeriesImageFormSet(
|
||
self.request.POST, self.request.FILES, instance=self.object
|
||
)
|
||
|
||
context["image_formset"].full_clean()
|
||
else:
|
||
context["image_formset"] = SeriesImageFormSet(instance=self.object)
|
||
return context
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(SeriesUpdate, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
context = self.get_context_data(form=form)
|
||
image_formset = context["image_formset"]
|
||
# logger.debug(formset.is_valid())
|
||
if image_formset.is_valid():
|
||
response = super().form_valid(form)
|
||
image_formset.instance = self.object
|
||
image_formset.save()
|
||
return response
|
||
else:
|
||
return super().form_invalid(form)
|
||
|
||
|
||
class ResourceUpdate(LoginRequiredMixin, UpdateView):
|
||
model = Resource
|
||
form_class = ResourceForm
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
return super().form_valid(form)
|
||
|
||
|
||
class ConditionUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
|
||
model = Condition
|
||
form_class = ConditionForm
|
||
|
||
|
||
class ConditionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Condition
|
||
form_class = ConditionForm
|
||
|
||
|
||
class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
|
||
model = QuestionSchema
|
||
form_class = QuestionSchemaForm
|
||
|
||
|
||
class QuestionSchemaUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
|
||
model = QuestionSchema
|
||
form_class = QuestionSchemaForm
|
||
|
||
|
||
class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
|
||
model = QuestionSchema
|
||
template_name = "confirm_delete.html"
|
||
|
||
class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
|
||
model = Finding
|
||
form_class = FindingForm
|
||
|
||
|
||
class FindingCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Finding
|
||
form_class = FindingForm
|
||
|
||
|
||
class StructureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
|
||
model = Structure
|
||
form_class = StructureForm
|
||
|
||
|
||
class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Structure
|
||
form_class = StructureForm
|
||
|
||
|
||
class SubspecialtyCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Subspecialty
|
||
form_class = SubspecialtyForm
|
||
template_name = "atlas/subspecialty_form.html"
|
||
|
||
|
||
class PresentationCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Presentation
|
||
form_class = PresentationForm
|
||
template_name = "atlas/presentation_form.html"
|
||
|
||
|
||
class PathologicalProcessCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = PathologicalProcess
|
||
form_class = PathologicalProcessForm
|
||
template_name = "atlas/pathological_process_form.html"
|
||
|
||
|
||
class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
||
model = Case
|
||
form_class = CaseForm
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(AtlasCreateBase, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(AtlasCreateBase, self).get_context_data(**kwargs)
|
||
if self.request.POST:
|
||
#context["series_formset"] = SeriesFormSet(
|
||
# self.request.POST,
|
||
# self.request.FILES,
|
||
# form_kwargs={"user": self.request.user},
|
||
#)
|
||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||
self.request.POST, self.request.FILES
|
||
)
|
||
context["caseresource_formset"] = CaseResourceFormSet(
|
||
self.request.POST,
|
||
self.request.FILES,
|
||
form_kwargs={"user": self.request.user},
|
||
)
|
||
#context["series_formset"].full_clean()
|
||
context["casedifferential_formset"].full_clean()
|
||
context["caseresource_formset"].full_clean()
|
||
else:
|
||
# context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id))
|
||
#context["series_formset"] = SeriesFormSet(
|
||
# form_kwargs={"user": self.request.user}
|
||
#)
|
||
context["casedifferential_formset"] = CaseDifferentialFormSet()
|
||
context["caseresource_formset"] = CaseResourceFormSet(
|
||
form_kwargs={"user": self.request.user}
|
||
)
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
|
||
# Add exam objects
|
||
|
||
self.object.save()
|
||
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
# Check Cimar Case
|
||
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
|
||
|
||
context = self.get_context_data(form=form)
|
||
#series_formset = context["series_formset"]
|
||
casedifferential_formset = context["casedifferential_formset"]
|
||
caseresource_formset = context["caseresource_formset"]
|
||
if (
|
||
#series_formset.is_valid()
|
||
casedifferential_formset.is_valid()
|
||
and caseresource_formset.is_valid()
|
||
):
|
||
response = super().form_valid(form)
|
||
#series_formset.instance = self.object
|
||
#series_formset.save()
|
||
casedifferential_formset.instance = self.object
|
||
casedifferential_formset.save()
|
||
caseresource_formset.instance = self.object
|
||
caseresource_formset.save()
|
||
# If the normal submit button is pressed we save as normal
|
||
if "submit" in self.request.POST:
|
||
return response
|
||
# else we redirect to the clone url
|
||
else:
|
||
return redirect("atlas:case_clone", pk=self.object.pk)
|
||
|
||
else:
|
||
return super().form_invalid(form)
|
||
|
||
|
||
# @login_required
|
||
# CaseCreate
|
||
class AtlasCreate(AtlasCreateBase, CreateView):
|
||
def get_initial(self):
|
||
if "pk" in self.kwargs:
|
||
initial = super().get_initial()
|
||
exam = get_object_or_404(CaseCollection, pk=self.kwargs["pk"])
|
||
|
||
initial["exams"] = [exam.id]
|
||
|
||
return initial
|
||
## There has to be a better way...
|
||
# try:
|
||
# s = (i.pk for i in self.request.user.atlas_default.site.all())
|
||
# self.initial.update({"site": s})
|
||
# except AttributeError:
|
||
# pass
|
||
# return self.initial
|
||
|
||
|
||
# CaseUpdate
|
||
class AtlasUpdate(
|
||
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
||
):
|
||
model = Case
|
||
form_class = CaseForm
|
||
|
||
# fields = '__all__'
|
||
# #fields = [ 'condition' ]
|
||
# #initial = {'date_of_death': '05/01/2018'}
|
||
# exclude = [ 'created_date', 'published_date' ]
|
||
|
||
|
||
#def get_object(self, queryset=None):
|
||
# # Prefetch all related objects needed for the form and formsets
|
||
# qs = self.model.objects.select_related(
|
||
# # Add all FK fields here
|
||
# ).prefetch_related(
|
||
# "series__examination",
|
||
# "series__plane",
|
||
# "series__contrast",
|
||
# "series__images",
|
||
# "subspecialty",
|
||
# "condition",
|
||
# "presentation",
|
||
# "pathological_process",
|
||
# "differentialcase",
|
||
# "caseresource_set",
|
||
# )
|
||
# return qs.get(pk=self.kwargs["pk"])
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(AtlasUpdate, self).get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(AtlasUpdate, self).get_context_data(**kwargs)
|
||
if self.request.POST:
|
||
#context["series_formset"] = SeriesFormSet(
|
||
# self.request.POST,
|
||
# self.request.FILES,
|
||
# instance=self.object,
|
||
# form_kwargs={"user": self.request.user},
|
||
#)
|
||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||
self.request.POST, self.request.FILES, instance=self.object
|
||
)
|
||
context["caseresource_formset"] = CaseResourceFormSet(
|
||
self.request.POST,
|
||
self.request.FILES,
|
||
instance=self.object,
|
||
form_kwargs={"user": self.request.user},
|
||
)
|
||
#context["series_formset"].full_clean()
|
||
context["casedifferential_formset"].full_clean()
|
||
context["caseresource_formset"].full_clean()
|
||
else:
|
||
#context["series_formset"] = SeriesFormSet(
|
||
# instance=self.object, form_kwargs={"user": self.request.user}
|
||
#)
|
||
context["casedifferential_formset"] = CaseDifferentialFormSet(
|
||
instance=self.object
|
||
)
|
||
context["caseresource_formset"] = CaseResourceFormSet(
|
||
instance=self.object,
|
||
form_kwargs={"user": self.request.user},
|
||
)
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save(commit=False)
|
||
self.object.save()
|
||
|
||
form.instance.author.add(self.request.user.id)
|
||
|
||
context = self.get_context_data(form=form)
|
||
#series_formset = context["series_formset"]
|
||
casedifferential_formset = context["casedifferential_formset"]
|
||
caseresource_formset = context["caseresource_formset"]
|
||
# logger.debug(formset.is_valid())
|
||
if (
|
||
#series_formset.is_valid()
|
||
casedifferential_formset.is_valid()
|
||
and caseresource_formset.is_valid()
|
||
):
|
||
response = super().form_valid(form)
|
||
#series_formset.instance = self.object
|
||
#series_formset.save()
|
||
casedifferential_formset.instance = self.object
|
||
casedifferential_formset.save()
|
||
caseresource_formset.instance = self.object
|
||
caseresource_formset.save()
|
||
|
||
return response
|
||
else:
|
||
return super().form_invalid(form)
|
||
|
||
class CollectionCaseUpdate(
|
||
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView):
|
||
model = CaseCollection
|
||
form_class = CaseCollectionUpdateCaseForm
|
||
template_name = "atlas/casecollection_update_case.html"
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super().get_form_kwargs()
|
||
kwargs.update({"user": self.request.user, "collection": self.object})
|
||
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
if self.request.POST:
|
||
context["case_formset"] = CaseCollectionCaseFormSet(
|
||
self.request.POST,
|
||
self.request.FILES,
|
||
instance=self.object,
|
||
form_kwargs={"user": self.request.user, "collection": self.object},
|
||
)
|
||
context["case_formset"].full_clean()
|
||
else:
|
||
context["case_formset"] = CaseCollectionCaseFormSet(
|
||
instance=self.object,
|
||
form_kwargs={"user": self.request.user, "collection": self.object},
|
||
)
|
||
context["collection"] = context["casecollection"]
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
context = self.get_context_data(form=form)
|
||
case_formset = context["case_formset"]
|
||
if case_formset.is_valid():
|
||
case_formset.instance = self.object
|
||
case_formset.save()
|
||
return super().form_valid(form)
|
||
else:
|
||
return self.form_invalid(form)
|
||
|
||
|
||
class AtlasSeriesUpdate(
|
||
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
|
||
):
|
||
model = Case
|
||
form_class = CaseUpdateSeriesForm
|
||
template_name = "atlas/case_update_series.html"
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super().get_form_kwargs()
|
||
kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
is_atlas_editor = self.request.user.groups.filter(name="atlas_editor").exists() or self.request.user.is_superuser
|
||
form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor}
|
||
|
||
if not is_atlas_editor:
|
||
queryset = Series.objects.filter(author__id=self.request.user.id)
|
||
else:
|
||
queryset = Series.objects.all()
|
||
queryset = queryset.select_related("modality").prefetch_related(
|
||
"examination", "plane", "contrast",
|
||
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position"))
|
||
|
||
)
|
||
|
||
if self.request.POST:
|
||
context["series_formset"] = SeriesFormSet(
|
||
self.request.POST,
|
||
self.request.FILES,
|
||
queryset=queryset,
|
||
instance=self.object,
|
||
form_kwargs=form_kwargs,
|
||
)
|
||
context["series_formset"].full_clean()
|
||
else:
|
||
context["series_formset"] = SeriesFormSet(
|
||
queryset=queryset,
|
||
instance=self.object,
|
||
form_kwargs=form_kwargs,
|
||
)
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
context = self.get_context_data(form=form)
|
||
series_formset = context["series_formset"]
|
||
if series_formset.is_valid():
|
||
series_formset.instance = self.object
|
||
series_formset.save()
|
||
return super().form_valid(form)
|
||
else:
|
||
return self.form_invalid(form)
|
||
|
||
def get_object(self, queryset=None):
|
||
qs = Case.objects.select_related().prefetch_related(
|
||
"seriesdetail_set__series__modality",
|
||
"seriesdetail_set__series__examination",
|
||
"seriesdetail_set__series__plane",
|
||
"seriesdetail_set__series__contrast",
|
||
"seriesdetail_set__series__images",
|
||
)
|
||
return qs.get(pk=self.kwargs["pk"])
|
||
|
||
def add_collection_to_case_form(request, case_id):
|
||
if request.method == "POST":
|
||
form = AddCollectionToCaseForm(request.POST, user=request.user)
|
||
if form.is_valid():
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
|
||
case.casecollection_set.add(*form.cleaned_data["casecollection"])
|
||
# do_something_with_form_data(form.cleaned_data)
|
||
return HttpResponse("Done")
|
||
return render(request, "web/htmx_contact_form_confirm.html")
|
||
else:
|
||
form = AddCollectionToCaseForm(user=request.user)
|
||
|
||
# Resolve the Case early so templates always receive it when available
|
||
try:
|
||
case = Case.objects.get(pk=case_id)
|
||
except Exception:
|
||
case = None
|
||
|
||
# If called via HTMX (inline from case view) return a compact fragment
|
||
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
|
||
if is_htmx:
|
||
# Provide the 10 most recently updated collections for this user by default
|
||
user = request.user
|
||
recent_base = (
|
||
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
|
||
.distinct()
|
||
.order_by("-updated")
|
||
)
|
||
# Exclude collections that already contain the case (if resolved)
|
||
if case is not None:
|
||
recent_base = recent_base.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True))
|
||
recent_qs = recent_base[:10]
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/add_collection_inline.html",
|
||
{"recent_collections": recent_qs, "case": case},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
return render(
|
||
request,
|
||
"generic_form.html",
|
||
{
|
||
"form": form,
|
||
"target_url": reverse("atlas:case_collection_form", args=(case_id,)),
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def collection_inline_search(request, case_id):
|
||
# HTMX endpoint: return recent collections or search results for the user
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
q = request.GET.get("q", "").strip()
|
||
user = request.user
|
||
|
||
base_qs = (
|
||
CaseCollection.objects.filter(Q(author=user) | Q(markers=user) | Q(valid_user_users=user) | Q(open_access=True))
|
||
.distinct()
|
||
)
|
||
|
||
try:
|
||
case = Case.objects.get(pk=case_id)
|
||
except Exception:
|
||
case = None
|
||
|
||
# Exclude collections already containing the case
|
||
if case is not None:
|
||
base_qs = base_qs.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True))
|
||
|
||
if q:
|
||
results = base_qs.filter(name__icontains=q).order_by("-updated")[:30]
|
||
else:
|
||
results = base_qs.order_by("-updated")[:10]
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/collection_inline_list.html",
|
||
{"collections": results, "case": case},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def add_collection_to_case_inline(request, case_id, collection_id):
|
||
# HTMX endpoint to add a single collection to a case and return the new list item
|
||
if not request.htmx:
|
||
return Http404
|
||
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||
|
||
# Permission: user must be able to edit the case
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to add collections to this case.", status=403)
|
||
|
||
# Add the association
|
||
if collection in case.casecollection_set.all():
|
||
return HttpResponse("Already in collection")
|
||
|
||
case.casecollection_set.add(collection)
|
||
|
||
# Render a list item to insert into the case's collection list
|
||
rendered = render_to_string(
|
||
"atlas/partials/collection_list_item.html",
|
||
{"collection": collection, "case": case, "can_edit": case.check_user_can_edit(request.user)},
|
||
request=request,
|
||
)
|
||
|
||
# Also instruct the client to remove the collection entry from the inline search list
|
||
remove_script = (
|
||
'<script>'
|
||
'(function(){'
|
||
'try {'
|
||
"var pk = '%s';" % collection.pk +
|
||
"document.querySelectorAll('#collection-inline-list [data-collection-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
|
||
'} catch(e) { console.error("remove added collection from inline list error", e); }'
|
||
'})();'
|
||
'</script>'
|
||
)
|
||
|
||
return HttpResponse(rendered + remove_script)
|
||
|
||
|
||
# class AddCollectionToCaseView(FormView):
|
||
# form_class = AddCollectionToCaseForm
|
||
# template_name = "generic_form.html"
|
||
#
|
||
# def form_valid(self, form):
|
||
# form.save()
|
||
# return super(AddCollectionToCaseView, self).form_valid(form)
|
||
#
|
||
# def get_form_kwargs(self):
|
||
# kwargs = super(AddCollectionToCaseView, self).get_form_kwargs()
|
||
# kwargs.update({"user": self.request.user})
|
||
# return kwargs
|
||
#
|
||
# def get_success_url(self) -> str:
|
||
# pk = self.kwargs["pk"]
|
||
# return reverse("atlas:case_detail", kwargs={"pk": pk})
|
||
|
||
|
||
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
|
||
"""Clones a existing atlas case"""
|
||
|
||
# fields = '__all__'
|
||
# #fields = [ 'condition' ]
|
||
# #initial = {'date_of_death': '05/01/2018'}
|
||
# exclude = [ 'created_date', 'published_date' ]
|
||
def get_initial(self):
|
||
# print(self.request)
|
||
old_object = get_object_or_404(Case, pk=self.kwargs["pk"])
|
||
|
||
if (
|
||
self.request.user.groups.filter(name="atlas_editor").exists()
|
||
or self.request.user.is_superuser
|
||
):
|
||
pass
|
||
else:
|
||
if self.request.user not in old_object.get_author_objects():
|
||
raise PermissionDenied() # or Http404
|
||
else:
|
||
pass
|
||
|
||
initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"])
|
||
return initial_data
|
||
|
||
|
||
class AtlasCloneNext(AtlasClone):
|
||
"""Extends AtlasClone to set the previous_case field to the case being cloned"""
|
||
|
||
def get_initial(self):
|
||
initial_data = super().get_initial()
|
||
initial_data["previous_case"] = self.kwargs["pk"]
|
||
return initial_data
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_editor
|
||
def atlas_scrap(request, pk):
|
||
try:
|
||
atlas = Case.objects.get(pk=pk)
|
||
except Case.DoesNotExist:
|
||
raise Http404("Case does not exist")
|
||
|
||
atlas.scrapped = not atlas.scrapped
|
||
atlas.save()
|
||
return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,)))
|
||
|
||
|
||
# @login_required
|
||
# def edit_abnormality_popup(request):
|
||
# instance = get_object_or_404(Abnormality, pk = pk)
|
||
# form = AbnormalityForm(request.POST or None)
|
||
# if form.is_valid():
|
||
# instance = form.save()
|
||
# return HttpResponse('<script>opener.closePopup(window, "%s", "%s", "#id_abnormality");</script>' % (instance.pk, instance))
|
||
# return render(request, "atlas/create_simple.html", {'form': form})
|
||
|
||
|
||
@login_required
|
||
def get_examination_id(request):
|
||
"""Return the primary key for an Examination looked up by name (admin popup helper).
|
||
|
||
Scope: authenticated users only.
|
||
Functionality: looks up an Examination by exact name and returns its ID as JSON;
|
||
used by inline admin form popups.
|
||
"""
|
||
if request.accepts("application/json"):
|
||
examination_name = request.GET.get("examination_name", "")
|
||
examination_id = Examination.objects.get(name=examination_name).id
|
||
data = {
|
||
"examination_id": examination_id,
|
||
}
|
||
return HttpResponse(json.dumps(data), content_type="application/json")
|
||
return HttpResponse("/")
|
||
|
||
|
||
class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Case
|
||
table_class = CaseTable
|
||
template_name = "atlas/case_view.html"
|
||
|
||
filterset_class = CaseFilter
|
||
|
||
|
||
@staff_member_required
|
||
def cases_by_size(request):
|
||
"""Admin view: list cases ordered by total images size to find large cases.
|
||
|
||
Shows case id, title, number of series, number of images and total size (bytes).
|
||
Optional GET parameter `min_size` (bytes) to filter cases larger than that.
|
||
"""
|
||
min_size = request.GET.get("min_size")
|
||
try:
|
||
min_size = int(min_size) if min_size else 0
|
||
except Exception:
|
||
min_size = 0
|
||
|
||
cases_qs = Case.objects.prefetch_related("series__images")
|
||
|
||
def human(n):
|
||
# simple human readable bytes
|
||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||
if n < 1024.0:
|
||
return f"{n:.1f}{unit}"
|
||
n /= 1024.0
|
||
return f"{n:.1f}PB"
|
||
|
||
rows = []
|
||
for c in cases_qs:
|
||
try:
|
||
total = c.get_total_series_images_size()
|
||
except Exception:
|
||
total = 0
|
||
series_count = c.series.count()
|
||
image_count = sum(s.images.filter(removed=False).count() for s in c.series.all())
|
||
if total >= min_size:
|
||
rows.append({
|
||
"case": c,
|
||
"total": total,
|
||
"series_count": series_count,
|
||
"image_count": image_count,
|
||
"human_total": human(total),
|
||
})
|
||
|
||
rows.sort(key=lambda r: r["total"], reverse=True)
|
||
|
||
rows.sort(key=lambda r: r["total"], reverse=True)
|
||
|
||
paginator = Paginator(rows, 50)
|
||
page_num = request.GET.get("page")
|
||
page = paginator.get_page(page_num)
|
||
|
||
def human(n):
|
||
# simple human readable bytes
|
||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||
if n < 1024.0:
|
||
return f"{n:.1f}{unit}"
|
||
n /= 1024.0
|
||
return f"{n:.1f}PB"
|
||
|
||
context = {"page": page, "human": human, "min_size": min_size}
|
||
return render(request, "atlas/cases_by_size.html", context)
|
||
|
||
|
||
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Series
|
||
table_class = SeriesTable
|
||
template_name = "atlas/series_view.html"
|
||
|
||
filterset_class = SeriesFilter
|
||
|
||
|
||
class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Condition
|
||
table_class = ConditionTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = ConditionFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
# Provide verbose names to templates without accessing _meta in templates
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = QuestionSchema
|
||
table_class = QuestionSchemaTable
|
||
template_name = "atlas/schema_table_view.html"
|
||
|
||
filterset_class = QuestionSchemaFilter
|
||
|
||
|
||
class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Subspecialty
|
||
table_class = SubspecialtyTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = SubspecialtyFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Presentation
|
||
table_class = PresentationTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = PresentationFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class ProcedureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, ListView):
|
||
model = Procedure
|
||
table_class = __import__('atlas.tables', fromlist=['ProcedureTable']).ProcedureTable
|
||
template_name = "atlas/view.html"
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class ProcedureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||
model = Procedure
|
||
form_class = ProcedureForm
|
||
template_name = "atlas/procedure_form.html"
|
||
def dispatch(self, request, *args, **kwargs):
|
||
try:
|
||
logger.debug("ProcedureCreate dispatch: method=%s, user=%s", request.method, request.user)
|
||
if request.method == 'POST':
|
||
logger.debug("ProcedureCreate POST data: %s", request.POST.dict())
|
||
except Exception:
|
||
logger.exception("Error logging ProcedureCreate dispatch")
|
||
return super().dispatch(request, *args, **kwargs)
|
||
def form_valid(self, form):
|
||
self.object = form.save()
|
||
return super().form_valid(form)
|
||
|
||
def form_invalid(self, form):
|
||
logger.warning("ProcedureCreate form invalid: %s", form.errors)
|
||
return super().form_invalid(form)
|
||
|
||
def get_success_url(self):
|
||
try:
|
||
return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
|
||
except Exception:
|
||
return reverse("atlas:procedure_view")
|
||
|
||
|
||
class ProcedureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
|
||
model = Procedure
|
||
form_class = ProcedureForm
|
||
template_name = "atlas/procedure_form.html"
|
||
def form_valid(self, form):
|
||
self.object = form.save()
|
||
return super().form_valid(form)
|
||
|
||
def form_invalid(self, form):
|
||
logger.warning("ProcedureUpdate form invalid: %s", form.errors)
|
||
return super().form_invalid(form)
|
||
|
||
def get_success_url(self):
|
||
try:
|
||
return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
|
||
except Exception:
|
||
return reverse("atlas:procedure_view")
|
||
|
||
|
||
class ProcedureDelete(RevisionMixin, LoginRequiredMixin, DeleteView):
|
||
model = Procedure
|
||
template_name = "confirm_delete.html"
|
||
|
||
|
||
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = PathologicalProcess
|
||
table_class = PathologicalProcessTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = PathologicalProcessFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Structure
|
||
table_class = StructureTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = StructureFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||
return ctx
|
||
|
||
|
||
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||
model = Finding
|
||
table_class = FindingTable
|
||
template_name = "atlas/view.html"
|
||
|
||
filterset_class = FindingFilter
|
||
|
||
def get_context_data(self, **kwargs):
|
||
ctx = super().get_context_data(**kwargs)
|
||
try:
|
||
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||
except NoReverseMatch:
|
||
ctx["create_url"] = None
|
||
return ctx
|
||
|
||
|
||
@login_required
|
||
def case_order_dicom(request, pk):
|
||
if request.htmx:
|
||
case: Case = get_object_or_404(Case, pk=pk)
|
||
|
||
fail = []
|
||
# TODO: don't try to order non stack series
|
||
for series in case.series.all():
|
||
try:
|
||
if series.check_user_can_edit(request.user):
|
||
series.order_by_dicom()
|
||
else:
|
||
fail.append(f"{series.pk}: invalid permission")
|
||
except:
|
||
fail.append(f"{series.pk}: error")
|
||
|
||
if fail:
|
||
return HttpResponse(
|
||
f"Unable to order series {','.join([str(i) for i in fail])}"
|
||
)
|
||
else:
|
||
return HttpResponse("Series reordered")
|
||
|
||
raise Http404
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_order_dicom(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
try:
|
||
series.order_by_dicom()
|
||
except:
|
||
return HttpResponse("<h1>Series does not appear to contain dicoms</h1>")
|
||
|
||
return redirect("atlas:series_detail", pk=pk)
|
||
|
||
|
||
@login_required
|
||
def image_diff(request):
|
||
if request.htmx:
|
||
image_ids = request.POST.getlist("image-id")
|
||
|
||
if len(image_ids) == 2:
|
||
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
|
||
second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])
|
||
|
||
first_ds = pydicom.dcmread(first.image.file)
|
||
# del first_ds.PixelData
|
||
# j1 = first_ds.to_json_dict()
|
||
f = print_dicom(first_ds, join=False)
|
||
|
||
second_ds = pydicom.dcmread(second.image.file)
|
||
# del second_ds.PixelData
|
||
# j2 = second_ds.to_json_dict()
|
||
s = print_dicom(second_ds, join=False)
|
||
|
||
diff = difflib.HtmlDiff().make_table(f, s)
|
||
|
||
dict_diff = pprint.pformat(compare_dicom_datasets(first_ds, second_ds))
|
||
|
||
# return HttpResponse(diff)
|
||
|
||
return HttpResponse(f"<pre>{dict_diff}</pre><br/>{diff}")
|
||
|
||
else:
|
||
ds_set = []
|
||
for pk in image_ids:
|
||
series_image = get_object_or_404(SeriesImage, pk=pk)
|
||
ds_set.append(pydicom.dcmread(series_image.image.file))
|
||
|
||
dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
|
||
return HttpResponse(f"<pre>{dict_diff}</pre>")
|
||
|
||
return HttpResponse("Fail")
|
||
|
||
|
||
@login_required
|
||
def series_image_dicom(request, pk):
|
||
image = get_object_or_404(SeriesImage, pk=pk)
|
||
|
||
return render(request, "atlas/series_image_dicom.html", {"image": image})
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_anonymise_dicom(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
|
||
series.anonymise_images()
|
||
|
||
return redirect("atlas:series_detail", pk=pk)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_order_dicom_instance(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
try:
|
||
series.order_by_dicom("InstanceNumber")
|
||
except:
|
||
return HttpResponse(
|
||
"<h1>Series does not appear to contain dicoms (or field InstanceNumber)</h1>"
|
||
)
|
||
|
||
return redirect("atlas:series_detail", pk=pk)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_order_dicom_SeriesInstanceUID(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
try:
|
||
series.order_by_dicom("SeriesInstanceUID")
|
||
except:
|
||
return HttpResponse(
|
||
"<h1>Series does not appear to contain dicoms (or field SeriesInstanceUID)</h1>"
|
||
)
|
||
|
||
return redirect("atlas:series_detail", pk=pk)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_order_upload_filename(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
series.order_by_upload_filename()
|
||
|
||
return redirect("atlas:series_detail", pk=pk)
|
||
|
||
|
||
# Common DICOM tags available for series splitting
|
||
SERIES_SPLIT_TAGS = [
|
||
("ImageType", "Image Type"),
|
||
("EchoNumbers", "Echo Numbers (multi-echo MRI)"),
|
||
("EchoTime", "Echo Time"),
|
||
("DiffusionBValue", "Diffusion B-Value (DWI)"),
|
||
("TemporalPositionIdentifier", "Temporal Position (dynamic series)"),
|
||
("AcquisitionNumber", "Acquisition Number"),
|
||
("FlipAngle", "Flip Angle"),
|
||
("ContrastBolusAgent", "Contrast/Bolus Agent"),
|
||
("CardiacNumberOfImages", "Cardiac Number of Images"),
|
||
]
|
||
|
||
|
||
def _normalise_dicom_split_value(value):
|
||
if isinstance(value, pydicom.multival.MultiValue):
|
||
return ",".join(map(str, value))
|
||
return str(value)
|
||
|
||
|
||
def _summarise_dicom_value(value):
|
||
if isinstance(value, pydicom.multival.MultiValue):
|
||
value_text = ",".join(map(str, value))
|
||
elif isinstance(value, (bytes, bytearray)):
|
||
value_text = f"<binary:{len(value)} bytes>"
|
||
else:
|
||
value_text = str(value)
|
||
|
||
if len(value_text) > 120:
|
||
return f"{value_text[:117]}..."
|
||
return value_text
|
||
|
||
|
||
def _analyse_series_split_options(series):
|
||
tags = [tag_value for tag_value, _ in SERIES_SPLIT_TAGS]
|
||
tag_value_sets = {tag: set() for tag in tags}
|
||
tag_missing = {tag: False for tag in tags}
|
||
|
||
for image in series.images.iterator():
|
||
ds = image.get_dicom_data()
|
||
for tag in tags:
|
||
if tag in ds:
|
||
tag_value_sets[tag].add(_normalise_dicom_split_value(ds[tag].value))
|
||
else:
|
||
tag_missing[tag] = True
|
||
|
||
split_options = []
|
||
for tag_value, tag_label in SERIES_SPLIT_TAGS:
|
||
if tag_missing[tag_value]:
|
||
continue
|
||
split_count = len(tag_value_sets[tag_value])
|
||
if split_count > 1:
|
||
split_options.append(
|
||
{
|
||
"tag_value": tag_value,
|
||
"tag_label": tag_label,
|
||
"split_count": split_count,
|
||
}
|
||
)
|
||
|
||
split_options.sort(key=lambda item: item["split_count"], reverse=True)
|
||
return split_options
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||
def series_tag_consistency(request, pk):
|
||
"""Analyze DICOM tags across all images in a series.
|
||
|
||
Scope: Any user with view access to the series.
|
||
|
||
Functionality:
|
||
- GET: returns a panel with an optional on-demand analysis.
|
||
- GET ?analyze=1: scans all tags across images and groups them by:
|
||
1) same in all images, 2) different across images, 3) missing in some images.
|
||
"""
|
||
series = get_object_or_404(Series, pk=pk)
|
||
analyze_requested = request.GET.get("analyze") == "1"
|
||
can_split = series.check_user_can_edit(request.user)
|
||
|
||
same_tags = []
|
||
different_tags = []
|
||
partial_tags = []
|
||
image_count = 0
|
||
|
||
if analyze_requested:
|
||
tag_values = defaultdict(set)
|
||
tag_presence = defaultdict(int)
|
||
|
||
for image in series.images.iterator():
|
||
image_count += 1
|
||
ds = image.get_dicom_data()
|
||
seen_in_image = set()
|
||
|
||
for element in ds.iterall():
|
||
# PixelData is large and not useful for consistency checks.
|
||
if element.tag == (0x7FE0, 0x0010):
|
||
continue
|
||
|
||
tag_name = element.keyword or str(element.tag)
|
||
tag_values[tag_name].add(_summarise_dicom_value(element.value))
|
||
|
||
if tag_name not in seen_in_image:
|
||
tag_presence[tag_name] += 1
|
||
seen_in_image.add(tag_name)
|
||
|
||
for tag_name in sorted(tag_values.keys()):
|
||
present_count = tag_presence[tag_name]
|
||
unique_values = tag_values[tag_name]
|
||
unique_count = len(unique_values)
|
||
sample_values = sorted(unique_values)[:3]
|
||
|
||
row = {
|
||
"tag_name": tag_name,
|
||
"present_count": present_count,
|
||
"unique_count": unique_count,
|
||
"sample_values": sample_values,
|
||
}
|
||
|
||
if present_count == image_count and unique_count == 1:
|
||
same_tags.append(row)
|
||
elif present_count == image_count:
|
||
different_tags.append(row)
|
||
else:
|
||
partial_tags.append(row)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/partials/_series_tag_consistency.html",
|
||
{
|
||
"series": series,
|
||
"analyze_requested": analyze_requested,
|
||
"can_split": can_split,
|
||
"image_count": image_count,
|
||
"same_tags": same_tags,
|
||
"different_tags": different_tags,
|
||
"partial_tags": partial_tags,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
@user_is_author_or_atlas_series_checker
|
||
def series_split_by_tag(request, pk):
|
||
"""Split a series into multiple series based on unique values of a chosen DICOM tag.
|
||
|
||
Scope: Atlas series authors and series checkers only (via decorator).
|
||
|
||
Functionality:
|
||
- GET: renders a partial form and, on demand, analyzes available DICOM tags
|
||
to list only tags that can actually split the series.
|
||
- POST: performs the split. The original series retains the images matching
|
||
the first unique tag value; new series are cloned for each subsequent value.
|
||
On success, returns an HTMX success fragment with number of series created.
|
||
On error (tag not found or non-DICOM images), returns an HTMX-swappable
|
||
error message fragment.
|
||
"""
|
||
series = get_object_or_404(Series, pk=pk)
|
||
|
||
if request.method == "GET":
|
||
analyze_requested = request.GET.get("analyze") == "1"
|
||
split_options = _analyse_series_split_options(series) if analyze_requested else []
|
||
return render(
|
||
request,
|
||
"atlas/partials/_series_split_by_tag.html",
|
||
{
|
||
"series": series,
|
||
"split_options": split_options,
|
||
"analyze_requested": analyze_requested,
|
||
},
|
||
)
|
||
|
||
dicom_tag = request.POST.get("dicom_tag", "").strip()
|
||
if not dicom_tag:
|
||
return HttpResponse(
|
||
'<div class="alert alert-danger">No DICOM tag specified.</div>'
|
||
)
|
||
|
||
# Support hex tuple notation e.g. "(0008,0008)"
|
||
if dicom_tag.startswith("(") and dicom_tag.endswith(")"):
|
||
dicom_tag = tuple(
|
||
[hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")]
|
||
)
|
||
|
||
image_map = defaultdict(list)
|
||
|
||
for image in series.images.iterator():
|
||
ds = image.get_dicom_data()
|
||
if dicom_tag not in ds:
|
||
return HttpResponse(
|
||
f'<div class="alert alert-danger">Tag <code>{escape(str(dicom_tag))}</code> not found in image {escape(str(image))}.</div>'
|
||
)
|
||
key = _normalise_dicom_split_value(ds[dicom_tag].value)
|
||
image_map[key].append(image)
|
||
|
||
if len(image_map) < 2:
|
||
return HttpResponse(
|
||
f'<div class="alert alert-warning">Tag <code>{escape(str(dicom_tag))}</code> has only one unique value, so no split was performed.</div>'
|
||
)
|
||
|
||
case = list(series.case.all())
|
||
authors = list(series.author.all())
|
||
new_series_pks = []
|
||
|
||
with transaction.atomic():
|
||
for n, (option, images) in enumerate(image_map.items()):
|
||
if n == 0:
|
||
series.images.set(images)
|
||
new_series_pks.append(series.pk)
|
||
else:
|
||
series.pk = None
|
||
series.save()
|
||
series.case.set(case)
|
||
series.author.set(authors)
|
||
series.images.set(images)
|
||
new_series_pks.append(series.pk)
|
||
|
||
logger.info(
|
||
"Series split by tag '{}': produced series PKs {}",
|
||
dicom_tag,
|
||
new_series_pks,
|
||
)
|
||
return HttpResponse(
|
||
f'<div class="alert alert-success">Split complete: <strong>{len(new_series_pks)}</strong> series created from tag <code>{escape(str(dicom_tag))}</code>.</div>'
|
||
)
|
||
|
||
|
||
# Vendored in from BaseZipView
|
||
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
|
||
series_object = Series
|
||
|
||
|
||
@login_required
|
||
def case_download(request, pk):
|
||
"""Allow a case author (or superuser) to download all linked series DICOMs
|
||
and a metadata export as a zip file.
|
||
"""
|
||
case = get_object_or_404(Case, pk=pk)
|
||
|
||
# Only allow superusers or case authors
|
||
if not (request.user.is_superuser or case.author.filter(id=request.user.id).exists()):
|
||
raise PermissionDenied()
|
||
|
||
temp_file = ContentFile(b"", name=f"case_{case.pk}.zip")
|
||
with zipfile.ZipFile(temp_file, mode="w", compression=zipfile.ZIP_DEFLATED) as zip_file:
|
||
metadata = {
|
||
"case": {
|
||
"id": case.pk,
|
||
"title": case.title,
|
||
"authors": [str(a) for a in case.author.all()],
|
||
# Non-DICOM metadata
|
||
"description": case.description,
|
||
"history": case.history,
|
||
"discussion": case.discussion,
|
||
"report": case.report,
|
||
"created_date": getattr(case, "created_date", None),
|
||
"modified_date": getattr(case, "modified_date", None),
|
||
"presentation": [str(p) for p in case.presentation.all()],
|
||
"subspecialty": [str(s) for s in case.subspecialty.all()],
|
||
"pathological_process": [str(p) for p in case.pathological_process.all()],
|
||
"procedures": [str(p) for p in case.procedures.all()],
|
||
"conditions": [str(c) for c in case.condition.all()],
|
||
"case_size_bytes": case.get_total_series_images_size() if hasattr(case, "get_total_series_images_size") else None,
|
||
},
|
||
"series": [],
|
||
}
|
||
|
||
# Iterate ordered series if available, otherwise fall back to related series
|
||
try:
|
||
ordered_series = case.get_ordered_series()
|
||
except Exception:
|
||
ordered_series = list(case.series.all())
|
||
|
||
for series in ordered_series:
|
||
series_entry = {"pk": series.pk, "description": str(series)}
|
||
# Prefer series-provided dicom JSON if available
|
||
try:
|
||
series_entry["dicom_json"] = series.get_series_dicom_json()
|
||
except Exception:
|
||
series_entry["dicom_json"] = None
|
||
|
||
images = series.images.filter(removed=False).order_by("position")
|
||
series_entry["images"] = []
|
||
for img in images:
|
||
try:
|
||
file_obj = img.image.file
|
||
fname = os.path.split(file_obj.name)[-1]
|
||
arcname = f"series_{series.pk}/{fname}"
|
||
zip_file.writestr(arcname, file_obj.read())
|
||
series_entry["images"].append({"filename": fname, "position": img.position})
|
||
except Exception:
|
||
# skip missing files
|
||
continue
|
||
|
||
metadata["series"].append(series_entry)
|
||
|
||
# Write metadata.json
|
||
zip_file.writestr("metadata.json", json.dumps(metadata, default=str, indent=2))
|
||
|
||
file_size = temp_file.tell()
|
||
temp_file.seek(0)
|
||
|
||
response = HttpResponse(temp_file, content_type="application/zip")
|
||
response["Content-Disposition"] = (
|
||
"attachment; filename=%s" % f"case_{case.pk}.zip"
|
||
)
|
||
response["Content-Length"] = file_size
|
||
return response
|
||
|
||
|
||
# At the moment only the series owner / author can delete findings but anyone can create
|
||
def create_series_findings(request):
|
||
# posts = Post.objects.all()
|
||
response_data = {}
|
||
|
||
if request.POST.get("action") == "post":
|
||
series_finding_id = int(request.POST.get("series_finding_id"))
|
||
series_id = request.POST.get("series")
|
||
findings_ids = json.loads(request.POST.get("findings"))
|
||
structure_ids = json.loads(request.POST.get("structures"))
|
||
condition_ids = json.loads(request.POST.get("conditions"))
|
||
description = request.POST.get("description")
|
||
#annotation_json = request.POST.get("annotation_json")
|
||
#viewport_json = request.POST.get("viewport_json")
|
||
#current_image_id_index = request.POST.get("current_image_id_index")
|
||
annotation_json_3d = request.POST.get("annotation_json_3d")
|
||
viewer_state_3d = request.POST.get("viewer_state_3d")
|
||
|
||
series = Series.objects.get(pk=series_id)
|
||
findings = Finding.objects.filter(pk__in=findings_ids)
|
||
structures = Structure.objects.filter(pk__in=structure_ids)
|
||
conditions = Condition.objects.filter(pk__in=condition_ids)
|
||
|
||
if series_finding_id > 0:
|
||
sf = get_object_or_404(SeriesFinding, pk=series_finding_id)
|
||
sf.series = series
|
||
sf.description = description
|
||
#sf.annotation_json = annotation_json
|
||
#sf.viewport_json = viewport_json
|
||
else:
|
||
sf = SeriesFinding.objects.create(
|
||
series=series,
|
||
description=description,
|
||
#annotation_json=annotation_json,
|
||
#viewport_json=viewport_json,
|
||
)
|
||
|
||
sf.findings.set(findings)
|
||
sf.structures.set(structures)
|
||
sf.conditions.set(conditions)
|
||
#sf.current_image_id_index = current_image_id_index
|
||
sf.annotation_json_3d = annotation_json_3d
|
||
sf.viewer_state_3d = viewer_state_3d
|
||
sf.save()
|
||
return JsonResponse({"success": True})
|
||
|
||
return JsonResponse({"success": False})
|
||
return render(request, "create_post.html", {"posts": posts})
|
||
|
||
|
||
class ConditionAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Condition.objects.none()
|
||
|
||
qs = Condition.objects.all()
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Condition.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
class ProcedureAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
if not self.request.user.is_authenticated:
|
||
return Procedure.objects.none()
|
||
|
||
qs = Procedure.objects.all()
|
||
|
||
if self.q:
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Procedure.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
class ConditionAutocompleteRCRCurriculum(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Condition.objects.none()
|
||
|
||
qs = Condition.objects.filter(rcr_curriculum=True)
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q, rcr_curriculum=True)
|
||
except FieldError:
|
||
return Condition.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
class PresentationAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Presentation.objects.none()
|
||
|
||
qs = Presentation.objects.all()
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Presentation.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
class FindingAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Finding.objects.none()
|
||
|
||
qs = Finding.objects.all()
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Finding.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
class StructureAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Structure.objects.none()
|
||
|
||
qs = Structure.objects.all()
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Structure.objects.none()
|
||
|
||
return qs
|
||
|
||
def has_add_permission(self, request):
|
||
"""Allow any authenticated user to create new Structure entries.
|
||
|
||
By default DAL requires the user to have the model add permission.
|
||
For Anatomy question creation we want any registered user to be
|
||
able to create structures, so relax that check to only require
|
||
authentication.
|
||
"""
|
||
try:
|
||
auth = request.user.is_authenticated
|
||
except Exception:
|
||
auth = False
|
||
|
||
return bool(auth)
|
||
|
||
|
||
class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
|
||
def get_queryset(self):
|
||
# Don't forget to filter out results depending on the visitor !
|
||
if not self.request.user.is_authenticated:
|
||
return Subspecialty.objects.none()
|
||
|
||
qs = Subspecialty.objects.all()
|
||
|
||
if self.q:
|
||
# This raises a fielderror which breaks creating a new item if not caught
|
||
try:
|
||
qs = qs.filter(name__icontains=self.q)
|
||
except FieldError:
|
||
return Subspecialty.objects.none()
|
||
|
||
return qs
|
||
|
||
|
||
@login_required
|
||
# @user_is_atlas_editor
|
||
def categories_list(request):
|
||
# Allow an optional global search across category models via GET `q`.
|
||
query = request.GET.get("q", "").strip()
|
||
# Optional `types` GET parameter (can be repeated) to restrict which category
|
||
# models to search. Expected values: 'conditions','findings','structures',
|
||
# 'subspecialties','presentations'. If omitted, search all.
|
||
types = request.GET.getlist("types")
|
||
|
||
results = None
|
||
if query:
|
||
from .models import Condition, Finding, Structure, Subspecialty, Presentation, Procedure
|
||
|
||
# Helper to conditionally populate each result list only when the
|
||
# corresponding type is requested (or when no types filter provided).
|
||
def include(name: str) -> bool:
|
||
return not types or name in types
|
||
|
||
results = {
|
||
"conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (),
|
||
"findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (),
|
||
"structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (),
|
||
"subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (),
|
||
"presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (),
|
||
"procedures": Procedure.objects.filter(name__icontains=query).order_by("name") if include("procedures") else (),
|
||
}
|
||
|
||
return render(request, "atlas/categories_list.html", {"query": query, "results": results})
|
||
|
||
|
||
@login_required
|
||
def categories_search_partial(request):
|
||
"""Return only the search results partial for HTMX requests.
|
||
|
||
Expects GET param `q`.
|
||
"""
|
||
query = request.GET.get("q", "").strip()
|
||
types = request.GET.getlist("types")
|
||
results = {"conditions": (), "findings": (), "structures": (), "subspecialties": (), "presentations": (), "procedures": ()}
|
||
if query:
|
||
from .models import Condition, Finding, Structure, Subspecialty, Presentation, Procedure
|
||
|
||
def include(name: str) -> bool:
|
||
return not types or name in types
|
||
|
||
results = {
|
||
"conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (),
|
||
"findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (),
|
||
"structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (),
|
||
"subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (),
|
||
"presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (),
|
||
"procedures": Procedure.objects.filter(name__icontains=query).order_by("name") if include("procedures") else (),
|
||
}
|
||
|
||
return render(request, "atlas/_categories_search_results.html", {"query": query, "results": results})
|
||
|
||
|
||
# def collection_view(request):
|
||
# collections = CaseCollection.objects.all()
|
||
#
|
||
# return render(
|
||
# request, "atlas/collection_view.html", {"collections": collections}
|
||
# )
|
||
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||
model = CaseCollection
|
||
table_class = CaseCollectionTable
|
||
template_name = "atlas/collection_view.html"
|
||
|
||
filterset_class = CaseCollectionFilter
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_viva(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
#cases = (
|
||
# collection.cases.all()
|
||
# .order_by("casedetail__sort_order")
|
||
# .prefetch_related(
|
||
# "series",
|
||
# "series__images",
|
||
# "series__contrast",
|
||
# "series__plane",
|
||
# "series__examination",
|
||
# )
|
||
#)
|
||
|
||
# Prefetch and annotate aggressively to avoid N+1 queries in the viva view.
|
||
# - Prefetch `case__series` using the ordering from SeriesDetail
|
||
# - Prefetch only non-removed images for those series (ordered by position)
|
||
# - Prefetch case resources and display sets
|
||
from django.db.models import Prefetch as _Prefetch
|
||
|
||
series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
|
||
series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
|
||
_Prefetch("images", queryset=series_images_qs)
|
||
)
|
||
|
||
casedetails = (
|
||
CaseDetail.objects
|
||
.filter(collection=collection)
|
||
.select_related("case")
|
||
.order_by("sort_order")
|
||
.prefetch_related(
|
||
_Prefetch("case__series", queryset=series_qs),
|
||
_Prefetch("case__series__modality"),
|
||
_Prefetch("case__series__examination"),
|
||
_Prefetch("case__series__plane"),
|
||
_Prefetch("case__series__contrast"),
|
||
_Prefetch("case__caseresource_set"),
|
||
_Prefetch("case__display_sets"),
|
||
)
|
||
.annotate(series_count=Count("case__seriesdetail"))
|
||
)
|
||
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_viva.html",
|
||
{"collection": collection, "casedetails": casedetails, "can_edit": True},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_viva_case(request, pk, case_number):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
# cases = collection.cases.all().order_by("casedetail__sort_order")
|
||
current_case = collection.get_case_by_index(case_number)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_viva_case.html",
|
||
{"collection": collection, "current_case": current_case},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_viva_series(request, pk, series_id):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
series = get_object_or_404(Series, pk=series_id)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_viva_series.html",
|
||
{"collection": collection, "current_series": series},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_detail(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
collection_content_type = ContentType.objects.get_for_model(collection)
|
||
messaging_enabled = _collection_case_query_messaging_enabled(collection)
|
||
|
||
# cases = collection.cases.all().order_by("casedetail__sort_order")
|
||
casedetails = (
|
||
CaseDetail.objects
|
||
.filter(collection=collection)
|
||
.select_related("case")
|
||
.annotate(series_count=Count("case__seriesdetail"))
|
||
.order_by("sort_order")
|
||
)
|
||
|
||
if messaging_enabled:
|
||
outstanding_messages = CaseReviewMessage.objects.filter(
|
||
cid_user_exam__content_type=collection_content_type,
|
||
cid_user_exam__object_id=collection.pk,
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
)
|
||
total_outstanding_messages = outstanding_messages.count()
|
||
|
||
user_message_rows = list(
|
||
collection.get_user_exams()
|
||
.select_related("user_user")
|
||
.annotate(
|
||
outstanding_message_count=Count(
|
||
"case_review_messages",
|
||
filter=Q(
|
||
case_review_messages__requires_acknowledgement=True,
|
||
case_review_messages__acknowledged_at__isnull=True,
|
||
),
|
||
distinct=True,
|
||
),
|
||
total_review_message_count=Count("case_review_messages", distinct=True),
|
||
)
|
||
.filter(Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0))
|
||
.order_by("-outstanding_message_count", "-total_review_message_count", "-start_time")
|
||
)
|
||
|
||
cid_message_rows = list(
|
||
collection.get_cid_exams()
|
||
.select_related("cid_user")
|
||
.annotate(
|
||
outstanding_message_count=Count(
|
||
"case_review_messages",
|
||
filter=Q(
|
||
case_review_messages__requires_acknowledgement=True,
|
||
case_review_messages__acknowledged_at__isnull=True,
|
||
),
|
||
distinct=True,
|
||
),
|
||
total_review_message_count=Count("case_review_messages", distinct=True),
|
||
)
|
||
.filter(Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0))
|
||
.order_by("-outstanding_message_count", "-total_review_message_count", "-start_time")
|
||
)
|
||
else:
|
||
total_outstanding_messages = 0
|
||
user_message_rows = []
|
||
cid_message_rows = []
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_detail.html",
|
||
{
|
||
"collection": collection,
|
||
"casesdetails": casedetails,
|
||
"can_edit": True,
|
||
"exam": collection,
|
||
"total_outstanding_messages": total_outstanding_messages,
|
||
"user_message_rows": user_message_rows,
|
||
"cid_message_rows": cid_message_rows,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_history(request, exam_id: int):
|
||
"""An overview page that shows who has attempted / finised the collection"""
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
casedetails = list(CaseDetail.objects.filter(collection=collection).order_by("sort_order"))
|
||
|
||
if _collection_case_query_messaging_enabled(collection):
|
||
userexams = list(
|
||
collection.get_user_exams()
|
||
.select_related("user_user")
|
||
.annotate(
|
||
outstanding_feedback_count=Count(
|
||
"case_review_messages",
|
||
filter=Q(
|
||
case_review_messages__requires_acknowledgement=True,
|
||
case_review_messages__acknowledged_at__isnull=True,
|
||
),
|
||
distinct=True,
|
||
),
|
||
total_review_message_count=Count("case_review_messages", distinct=True),
|
||
)
|
||
.order_by("-outstanding_feedback_count", "completed", "-start_time")
|
||
)
|
||
cidexams = list(
|
||
collection.get_cid_exams()
|
||
.select_related("cid_user")
|
||
.annotate(
|
||
outstanding_feedback_count=Count(
|
||
"case_review_messages",
|
||
filter=Q(
|
||
case_review_messages__requires_acknowledgement=True,
|
||
case_review_messages__acknowledged_at__isnull=True,
|
||
),
|
||
distinct=True,
|
||
),
|
||
total_review_message_count=Count("case_review_messages", distinct=True),
|
||
)
|
||
.order_by("-outstanding_feedback_count", "completed", "-start_time")
|
||
)
|
||
else:
|
||
userexams = list(collection.get_user_exams().select_related("user_user").order_by("completed", "-start_time"))
|
||
cidexams = list(collection.get_cid_exams().select_related("cid_user").order_by("completed", "-start_time"))
|
||
for userexam in userexams:
|
||
userexam.outstanding_feedback_count = 0
|
||
userexam.total_review_message_count = 0
|
||
for cidexam in cidexams:
|
||
cidexam.outstanding_feedback_count = 0
|
||
cidexam.total_review_message_count = 0
|
||
|
||
total_outstanding_messages = sum(item.outstanding_feedback_count for item in userexams) + sum(
|
||
item.outstanding_feedback_count for item in cidexams
|
||
)
|
||
completed_attempt_count = sum(1 for item in userexams if item.completed) + sum(
|
||
1 for item in cidexams if item.completed
|
||
)
|
||
total_attempt_count = len(userexams) + len(cidexams)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_history.html",
|
||
{
|
||
"collection": collection,
|
||
"casesdetails": casedetails,
|
||
"can_edit": True,
|
||
"userexams": userexams,
|
||
"cidexams": cidexams,
|
||
"total_cases": len(casedetails),
|
||
"total_attempt_count": total_attempt_count,
|
||
"completed_attempt_count": completed_attempt_count,
|
||
"total_outstanding_messages": total_outstanding_messages,
|
||
},
|
||
)
|
||
|
||
|
||
def _get_case_review_stats_for_exam(
|
||
*,
|
||
cid_user_exam: CidUserExam,
|
||
casedetails: list[CaseDetail] | QuerySet[CaseDetail],
|
||
messaging_enabled: bool = True,
|
||
) -> tuple[dict[int, dict[str, int | bool]], int, int]:
|
||
case_ids = [casedetail.case_id for casedetail in casedetails]
|
||
stats_by_case = {
|
||
case_id: {
|
||
"message_count": 0,
|
||
"outstanding_feedback_count": 0,
|
||
"outstanding_query_count": 0,
|
||
"outstanding_total_count": 0,
|
||
"has_messages": False,
|
||
"has_outstanding_feedback": False,
|
||
"has_outstanding_query": False,
|
||
"has_outstanding_any": False,
|
||
}
|
||
for case_id in case_ids
|
||
}
|
||
|
||
if not messaging_enabled:
|
||
return stats_by_case, 0, 0
|
||
|
||
message_rows = (
|
||
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case_id__in=case_ids)
|
||
.values("case_id")
|
||
.annotate(
|
||
message_count=Count("id"),
|
||
outstanding_total_count=Count(
|
||
"id",
|
||
filter=Q(
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
),
|
||
),
|
||
outstanding_feedback_count=Count(
|
||
"id",
|
||
filter=Q(
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
message_type=CaseReviewMessage.MessageType.FEEDBACK,
|
||
),
|
||
),
|
||
outstanding_query_count=Count(
|
||
"id",
|
||
filter=Q(
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
message_type=CaseReviewMessage.MessageType.QUERY,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
|
||
outstanding_case_count = 0
|
||
total_outstanding_feedback = 0
|
||
for row in message_rows:
|
||
outstanding_total_count = row["outstanding_total_count"]
|
||
outstanding_feedback_count = row["outstanding_feedback_count"]
|
||
outstanding_query_count = row["outstanding_query_count"]
|
||
stats_by_case[row["case_id"]] = {
|
||
"message_count": row["message_count"],
|
||
"outstanding_feedback_count": outstanding_feedback_count,
|
||
"outstanding_query_count": outstanding_query_count,
|
||
"outstanding_total_count": outstanding_total_count,
|
||
"has_messages": row["message_count"] > 0,
|
||
"has_outstanding_feedback": outstanding_feedback_count > 0,
|
||
"has_outstanding_query": outstanding_query_count > 0,
|
||
"has_outstanding_any": outstanding_total_count > 0,
|
||
}
|
||
if outstanding_total_count:
|
||
outstanding_case_count += 1
|
||
total_outstanding_feedback += outstanding_total_count
|
||
|
||
return stats_by_case, outstanding_case_count, total_outstanding_feedback
|
||
|
||
|
||
def _build_collection_feedback_rows(
|
||
*,
|
||
collection: CaseCollection,
|
||
cid_user_exam: CidUserExam,
|
||
casedetails: list[CaseDetail] | QuerySet[CaseDetail],
|
||
cid: int | None = None,
|
||
) -> tuple[list[dict[str, Any]], int, int]:
|
||
stats_by_case, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
|
||
rows = []
|
||
for casedetail in casedetails:
|
||
case_stats = stats_by_case.get(casedetail.case_id, {})
|
||
rows.append(
|
||
{
|
||
"casedetail": casedetail,
|
||
"stats": case_stats,
|
||
"thread_url": reverse(
|
||
"atlas:collection_case_review_thread",
|
||
kwargs={
|
||
"exam_id": collection.pk,
|
||
"cid_user_exam_id": cid_user_exam.pk,
|
||
"case_id": casedetail.case_id,
|
||
},
|
||
),
|
||
"cid": cid,
|
||
}
|
||
)
|
||
|
||
return rows, outstanding_case_count, total_outstanding_feedback
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_history_user(request, exam_id: int, user_pk: int):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
user = get_object_or_404(User, pk=user_pk)
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
collection=collection,
|
||
).order_by("sort_order")
|
||
|
||
reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date")
|
||
reviews_by_case = {}
|
||
for review in reviews:
|
||
reviews_by_case.setdefault(review.case_id, []).append(review)
|
||
|
||
history_rows = []
|
||
answer_count = 0
|
||
case_review_stats, _, _ = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
for casedetail in casedetails:
|
||
user_answer = casedetail.get_user_answers(user)
|
||
if user_answer and (user_answer.answer or user_answer.json_answer):
|
||
answer_count += 1
|
||
|
||
history_rows.append(
|
||
{
|
||
"casedetail": casedetail,
|
||
"user_answer": user_answer,
|
||
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
|
||
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
|
||
"thread_url": reverse(
|
||
"atlas:collection_case_review_thread",
|
||
kwargs={
|
||
"exam_id": collection.pk,
|
||
"cid_user_exam_id": cid_user_exam.pk,
|
||
"case_id": casedetail.case_id,
|
||
},
|
||
),
|
||
}
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_history_user.html",
|
||
{
|
||
"collection": collection,
|
||
"user": user,
|
||
"cid_user_exam": cid_user_exam,
|
||
"history_rows": history_rows,
|
||
"answer_count": answer_count,
|
||
"collection_length": len(history_rows),
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_history_ciduser(request, exam_id: int, cid: int):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
collection=collection,
|
||
).order_by("sort_order")
|
||
|
||
reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date")
|
||
reviews_by_case = {}
|
||
for review in reviews:
|
||
reviews_by_case.setdefault(review.case_id, []).append(review)
|
||
|
||
history_rows = []
|
||
answer_count = 0
|
||
case_review_stats, _, _ = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
for casedetail in casedetails:
|
||
user_answer = casedetail.get_cid_answers(cid)
|
||
if user_answer and (user_answer.answer or user_answer.json_answer):
|
||
answer_count += 1
|
||
|
||
history_rows.append(
|
||
{
|
||
"casedetail": casedetail,
|
||
"user_answer": user_answer,
|
||
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
|
||
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
|
||
"thread_url": reverse(
|
||
"atlas:collection_case_review_thread",
|
||
kwargs={
|
||
"exam_id": collection.pk,
|
||
"cid_user_exam_id": cid_user_exam.pk,
|
||
"case_id": casedetail.case_id,
|
||
},
|
||
),
|
||
"cid": cid,
|
||
}
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_history_user.html",
|
||
{
|
||
"collection": collection,
|
||
"user": cid,
|
||
"cid_user_exam": cid_user_exam,
|
||
"history_rows": history_rows,
|
||
"answer_count": answer_count,
|
||
"collection_length": len(history_rows),
|
||
"cid": cid,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_user_messages(request, exam_id: int, user_pk: int):
|
||
"""Supervisor page showing case conversations for one user attempt.
|
||
|
||
Outstanding items are prioritized. Full historical threads can be loaded on
|
||
demand from the same page.
|
||
"""
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
_enforce_collection_case_query_messaging_enabled(collection)
|
||
user = get_object_or_404(User, pk=user_pk)
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
|
||
|
||
casedetails = list(
|
||
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
|
||
)
|
||
feedback_rows, _, _ = _build_collection_feedback_rows(
|
||
collection=collection,
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
)
|
||
|
||
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")]
|
||
history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any")]
|
||
total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows)
|
||
show_all = request.GET.get("show") == "all"
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_user_messages.html",
|
||
{
|
||
"collection": collection,
|
||
"target_label": user.get_full_name() or user.username,
|
||
"target_user": user,
|
||
"cid_user_exam": cid_user_exam,
|
||
"outstanding_rows": outstanding_rows,
|
||
"history_rows": history_rows,
|
||
"total_outstanding_messages": total_outstanding_messages,
|
||
"show_all": show_all,
|
||
"cid": None,
|
||
"passcode": None,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_cid_messages(request, exam_id: int, cid: int):
|
||
"""Supervisor page showing case conversations for one CID attempt."""
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
_enforce_collection_case_query_messaging_enabled(collection)
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
|
||
|
||
casedetails = list(
|
||
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
|
||
)
|
||
feedback_rows, _, _ = _build_collection_feedback_rows(
|
||
collection=collection,
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
cid=cid,
|
||
)
|
||
|
||
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")]
|
||
history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any")]
|
||
total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows)
|
||
show_all = request.GET.get("show") == "all"
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_user_messages.html",
|
||
{
|
||
"collection": collection,
|
||
"target_label": f"CID {cid}",
|
||
"target_cid": cid,
|
||
"cid_user_exam": cid_user_exam,
|
||
"outstanding_rows": outstanding_rows,
|
||
"history_rows": history_rows,
|
||
"total_outstanding_messages": total_outstanding_messages,
|
||
"show_all": show_all,
|
||
"cid": cid,
|
||
"passcode": None,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def user_messages_inbox(request):
|
||
"""Generic message inbox for authenticated users.
|
||
|
||
Shows outstanding acknowledgement items and allows loading full message
|
||
history across all accessible collection attempts.
|
||
"""
|
||
|
||
user = request.user
|
||
collection_content_type = ContentType.objects.get_for_model(CaseCollection)
|
||
|
||
is_global_moderator = user.is_superuser or user.groups.filter(name="atlas_editor").exists()
|
||
|
||
base_attempts = CidUserExam.objects.filter(content_type=collection_content_type).select_related(
|
||
"user_user", "cid_user"
|
||
)
|
||
learner_attempts = list(base_attempts.filter(user_user=user))
|
||
|
||
moderator_attempts = []
|
||
if is_global_moderator:
|
||
moderator_attempts = list(base_attempts)
|
||
else:
|
||
authored_ids = list(CaseCollection.objects.filter(author=user).values_list("pk", flat=True))
|
||
if authored_ids:
|
||
moderator_attempts = list(base_attempts.filter(object_id__in=authored_ids))
|
||
|
||
attempts_map = {}
|
||
for attempt in learner_attempts:
|
||
attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "learner"}
|
||
for attempt in moderator_attempts:
|
||
attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "moderator"}
|
||
|
||
if not attempts_map:
|
||
return render(
|
||
request,
|
||
"atlas/user_messages_inbox.html",
|
||
{
|
||
"inbox_rows": [],
|
||
"total_outstanding": 0,
|
||
"total_needs_your_ack": 0,
|
||
"total_waiting_on_other": 0,
|
||
"show_all": request.GET.get("show") == "all",
|
||
},
|
||
)
|
||
|
||
attempts = [item["attempt"] for item in attempts_map.values()]
|
||
collection_ids = sorted({attempt.object_id for attempt in attempts})
|
||
|
||
collections_by_id = CaseCollection.objects.in_bulk(collection_ids)
|
||
enabled_collection_ids = {
|
||
collection_id
|
||
for collection_id, collection in collections_by_id.items()
|
||
if collection is not None and _collection_case_query_messaging_enabled(collection)
|
||
}
|
||
|
||
attempts = [attempt for attempt in attempts if attempt.object_id in enabled_collection_ids]
|
||
if not attempts:
|
||
return render(
|
||
request,
|
||
"atlas/user_messages_inbox.html",
|
||
{
|
||
"inbox_rows": [],
|
||
"total_outstanding": 0,
|
||
"total_needs_your_ack": 0,
|
||
"total_waiting_on_other": 0,
|
||
"show_all": request.GET.get("show") == "all",
|
||
},
|
||
)
|
||
|
||
attempt_ids = [attempt.pk for attempt in attempts]
|
||
collection_ids = sorted(enabled_collection_ids)
|
||
|
||
casedetails_by_collection = defaultdict(list)
|
||
all_case_ids = set()
|
||
for casedetail in (
|
||
CaseDetail.objects.filter(collection_id__in=collection_ids)
|
||
.select_related("case")
|
||
.order_by("collection_id", "sort_order")
|
||
):
|
||
casedetails_by_collection[casedetail.collection_id].append(casedetail)
|
||
all_case_ids.add(casedetail.case_id)
|
||
|
||
message_stats_by_attempt_case = defaultdict(dict)
|
||
if all_case_ids:
|
||
for row in (
|
||
CaseReviewMessage.objects.filter(
|
||
cid_user_exam_id__in=attempt_ids,
|
||
case_id__in=all_case_ids,
|
||
)
|
||
.values("cid_user_exam_id", "case_id")
|
||
.annotate(
|
||
message_count=Count("id"),
|
||
outstanding_total_count=Count(
|
||
"id",
|
||
filter=Q(
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
),
|
||
),
|
||
outstanding_query_count=Count(
|
||
"id",
|
||
filter=Q(
|
||
requires_acknowledgement=True,
|
||
acknowledged_at__isnull=True,
|
||
message_type=CaseReviewMessage.MessageType.QUERY,
|
||
),
|
||
),
|
||
)
|
||
):
|
||
outstanding_total_count = row["outstanding_total_count"]
|
||
outstanding_query_count = row["outstanding_query_count"]
|
||
message_stats_by_attempt_case[row["cid_user_exam_id"]][row["case_id"]] = {
|
||
"message_count": row["message_count"],
|
||
"outstanding_total_count": outstanding_total_count,
|
||
"outstanding_query_count": outstanding_query_count,
|
||
"outstanding_feedback_count": outstanding_total_count - outstanding_query_count,
|
||
"has_messages": row["message_count"] > 0,
|
||
"has_outstanding_any": outstanding_total_count > 0,
|
||
}
|
||
|
||
inbox_rows = []
|
||
for item in attempts_map.values():
|
||
attempt = item["attempt"]
|
||
viewer_role = item["viewer_role"]
|
||
collection = collections_by_id.get(attempt.object_id)
|
||
if collection is None or not _collection_case_query_messaging_enabled(collection):
|
||
continue
|
||
|
||
casedetails = casedetails_by_collection.get(collection.pk, [])
|
||
if not casedetails:
|
||
continue
|
||
|
||
attempt_stats = message_stats_by_attempt_case.get(attempt.pk, {})
|
||
feedback_rows = []
|
||
for casedetail in casedetails:
|
||
stats = attempt_stats.get(
|
||
casedetail.case_id,
|
||
{
|
||
"message_count": 0,
|
||
"outstanding_total_count": 0,
|
||
"outstanding_query_count": 0,
|
||
"outstanding_feedback_count": 0,
|
||
"has_messages": False,
|
||
"has_outstanding_any": False,
|
||
},
|
||
)
|
||
|
||
outstanding_query_count = stats.get("outstanding_query_count", 0)
|
||
outstanding_feedback_count = stats.get("outstanding_feedback_count", 0)
|
||
if viewer_role == "moderator":
|
||
needs_your_ack_count = outstanding_query_count
|
||
waiting_on_other_count = outstanding_feedback_count
|
||
else:
|
||
needs_your_ack_count = outstanding_feedback_count
|
||
waiting_on_other_count = outstanding_query_count
|
||
|
||
enriched_stats = {
|
||
**stats,
|
||
"needs_your_ack_count": needs_your_ack_count,
|
||
"waiting_on_other_count": waiting_on_other_count,
|
||
}
|
||
feedback_rows.append(
|
||
{
|
||
"casedetail": casedetail,
|
||
"stats": enriched_stats,
|
||
"thread_url": reverse(
|
||
"atlas:collection_case_review_thread",
|
||
kwargs={
|
||
"exam_id": collection.pk,
|
||
"cid_user_exam_id": attempt.pk,
|
||
"case_id": casedetail.case_id,
|
||
},
|
||
),
|
||
"cid": attempt.cid_user.cid if attempt.cid_user_id else None,
|
||
}
|
||
)
|
||
|
||
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")]
|
||
history_rows = [
|
||
row
|
||
for row in feedback_rows
|
||
if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any")
|
||
]
|
||
|
||
if not outstanding_rows and not history_rows:
|
||
continue
|
||
|
||
target_label = attempt.get_user_name()
|
||
if attempt.user_user_id:
|
||
detail_url = reverse(
|
||
"atlas:collection_user_messages",
|
||
kwargs={"exam_id": collection.pk, "user_pk": attempt.user_user_id},
|
||
)
|
||
else:
|
||
detail_url = reverse(
|
||
"atlas:collection_cid_messages",
|
||
kwargs={"exam_id": collection.pk, "cid": attempt.cid_user.cid},
|
||
)
|
||
|
||
outstanding_count = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows)
|
||
needs_your_ack_count = sum(row["stats"].get("needs_your_ack_count", 0) for row in feedback_rows)
|
||
waiting_on_other_count = sum(row["stats"].get("waiting_on_other_count", 0) for row in feedback_rows)
|
||
inbox_rows.append(
|
||
{
|
||
"collection": collection,
|
||
"attempt": attempt,
|
||
"target_label": target_label,
|
||
"viewer_role": viewer_role,
|
||
"detail_url": detail_url,
|
||
"outstanding_rows": outstanding_rows,
|
||
"history_rows": history_rows,
|
||
"outstanding_count": outstanding_count,
|
||
"needs_your_ack_count": needs_your_ack_count,
|
||
"waiting_on_other_count": waiting_on_other_count,
|
||
}
|
||
)
|
||
|
||
inbox_rows.sort(
|
||
key=lambda row: (
|
||
row["outstanding_count"] > 0,
|
||
row["outstanding_count"],
|
||
len(row["history_rows"]),
|
||
),
|
||
reverse=True,
|
||
)
|
||
|
||
total_outstanding = sum(row["outstanding_count"] for row in inbox_rows)
|
||
total_needs_your_ack = sum(row["needs_your_ack_count"] for row in inbox_rows)
|
||
total_waiting_on_other = sum(row["waiting_on_other_count"] for row in inbox_rows)
|
||
show_all = request.GET.get("show") == "all"
|
||
|
||
return render(
|
||
request,
|
||
"atlas/user_messages_inbox.html",
|
||
{
|
||
"inbox_rows": inbox_rows,
|
||
"total_outstanding": total_outstanding,
|
||
"total_needs_your_ack": total_needs_your_ack,
|
||
"total_waiting_on_other": total_waiting_on_other,
|
||
"show_all": show_all,
|
||
},
|
||
)
|
||
|
||
|
||
def _user_can_moderate_collection(collection: CaseCollection, user: User) -> bool:
|
||
if not getattr(user, "is_authenticated", False):
|
||
return False
|
||
|
||
return (
|
||
user.is_superuser
|
||
or user.groups.filter(name="atlas_editor").exists()
|
||
or collection.author.filter(pk=user.pk).exists()
|
||
)
|
||
|
||
|
||
def _collection_case_query_messaging_enabled(collection: CaseCollection) -> bool:
|
||
return bool(getattr(collection, "case_query_messaging_enabled", True))
|
||
|
||
|
||
def _enforce_collection_case_query_messaging_enabled(collection: CaseCollection):
|
||
if not _collection_case_query_messaging_enabled(collection):
|
||
raise Http404("Case query and messaging are disabled for this collection")
|
||
|
||
|
||
def _get_case_review_thread_context(
|
||
*,
|
||
collection: CaseCollection,
|
||
cid_user_exam: CidUserExam,
|
||
case: Case,
|
||
request,
|
||
cid: int | None = None,
|
||
passcode: str | None = None,
|
||
):
|
||
can_feedback = _user_can_moderate_collection(collection, request.user)
|
||
can_query = False
|
||
can_acknowledge_feedback = False
|
||
can_acknowledge_query = False
|
||
|
||
if not can_feedback:
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
if cid_user_exam.user_user_id:
|
||
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
|
||
elif cid_user_exam.cid_user_id and cid is not None:
|
||
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
|
||
can_acknowledge_feedback = can_query
|
||
except Exception:
|
||
can_query = False
|
||
can_acknowledge_feedback = False
|
||
|
||
if can_feedback:
|
||
can_acknowledge_query = True
|
||
|
||
messages = list(
|
||
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case=case)
|
||
.select_related("author")
|
||
.order_by("created_at")
|
||
)
|
||
|
||
supervisor_form = None
|
||
if can_feedback:
|
||
supervisor_form = CaseReviewMessageForm(
|
||
allow_ack=True,
|
||
message_placeholder="Give case-specific feedback or reply to a learner query...",
|
||
initial={"requires_acknowledgement": True},
|
||
)
|
||
|
||
query_form = None
|
||
if can_query:
|
||
query_form = CaseReviewMessageForm(
|
||
allow_ack=False,
|
||
message_placeholder="Ask a question or raise a query about this case...",
|
||
)
|
||
|
||
unacknowledged_count = sum(
|
||
1 for message in messages if message.requires_acknowledgement and not message.is_acknowledged
|
||
)
|
||
|
||
if can_feedback:
|
||
pending_for_viewer_count = sum(
|
||
1
|
||
for message in messages
|
||
if message.requires_acknowledgement
|
||
and not message.is_acknowledged
|
||
and message.message_type == CaseReviewMessage.MessageType.QUERY
|
||
)
|
||
else:
|
||
pending_for_viewer_count = sum(
|
||
1
|
||
for message in messages
|
||
if message.requires_acknowledgement
|
||
and not message.is_acknowledged
|
||
and message.message_type != CaseReviewMessage.MessageType.QUERY
|
||
)
|
||
|
||
return {
|
||
"collection": collection,
|
||
"cid_user_exam": cid_user_exam,
|
||
"case": case,
|
||
"messages": messages,
|
||
"can_feedback": can_feedback,
|
||
"can_query": can_query,
|
||
"can_acknowledge_feedback": can_acknowledge_feedback,
|
||
"can_acknowledge_query": can_acknowledge_query,
|
||
"supervisor_form": supervisor_form,
|
||
"query_form": query_form,
|
||
"thread_url": reverse(
|
||
"atlas:collection_case_review_thread",
|
||
kwargs={
|
||
"exam_id": collection.pk,
|
||
"cid_user_exam_id": cid_user_exam.pk,
|
||
"case_id": case.pk,
|
||
},
|
||
),
|
||
"thread_dom_id": f"case-review-thread-{cid_user_exam.pk}-{case.pk}",
|
||
"cid": cid,
|
||
"passcode": passcode,
|
||
"unacknowledged_count": unacknowledged_count,
|
||
"pending_for_viewer_count": pending_for_viewer_count,
|
||
}
|
||
|
||
|
||
@require_http_methods(["GET", "POST"])
|
||
def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, case_id: int):
|
||
"""Render and mutate the per-case conversation thread for one learner attempt.
|
||
|
||
The thread supports supervisor feedback/replies, learner queries, and learner
|
||
acknowledgement of flagged feedback. All POSTs return the refreshed HTMX
|
||
fragment for the same case thread.
|
||
"""
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
_enforce_collection_case_query_messaging_enabled(collection)
|
||
collection_content_type = ContentType.objects.get_for_model(collection)
|
||
cid_user_exam = get_object_or_404(
|
||
CidUserExam,
|
||
pk=cid_user_exam_id,
|
||
content_type=collection_content_type,
|
||
object_id=collection.pk,
|
||
)
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
|
||
if not CaseDetail.objects.filter(collection=collection, case=case).exists():
|
||
raise Http404("Case not found in collection")
|
||
|
||
cid = request.POST.get("cid") or request.GET.get("cid")
|
||
passcode = request.POST.get("passcode") or request.GET.get("passcode")
|
||
|
||
can_feedback = _user_can_moderate_collection(collection, request.user)
|
||
can_query = False
|
||
if not can_feedback:
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
if cid_user_exam.user_user_id:
|
||
can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id
|
||
elif cid_user_exam.cid_user_id and cid is not None:
|
||
can_query = str(cid_user_exam.cid_user.cid) == str(cid)
|
||
except Exception:
|
||
can_query = False
|
||
|
||
if not (can_feedback or can_query):
|
||
raise PermissionDenied
|
||
|
||
if request.method == "POST":
|
||
action = request.POST.get("action")
|
||
|
||
if action == "add_message":
|
||
if can_feedback:
|
||
form = CaseReviewMessageForm(
|
||
request.POST,
|
||
allow_ack=True,
|
||
message_placeholder="Give case-specific feedback or reply to a learner query...",
|
||
)
|
||
message_type = request.POST.get("message_type", CaseReviewMessage.MessageType.FEEDBACK)
|
||
if message_type not in {
|
||
CaseReviewMessage.MessageType.FEEDBACK,
|
||
CaseReviewMessage.MessageType.REPLY,
|
||
}:
|
||
message_type = CaseReviewMessage.MessageType.FEEDBACK
|
||
else:
|
||
form = CaseReviewMessageForm(
|
||
request.POST,
|
||
allow_ack=False,
|
||
message_placeholder="Ask a question or raise a query about this case...",
|
||
)
|
||
message_type = CaseReviewMessage.MessageType.QUERY
|
||
|
||
if form.is_valid():
|
||
message = form.save(commit=False)
|
||
message.cid_user_exam = cid_user_exam
|
||
message.case = case
|
||
if request.user.is_authenticated:
|
||
message.author = request.user
|
||
message.author_label = ""
|
||
else:
|
||
message.author = None
|
||
message.author_label = f"CID {cid}" if cid is not None else "Learner"
|
||
message.message_type = message_type
|
||
if message_type == CaseReviewMessage.MessageType.QUERY:
|
||
# Learner queries must be acknowledged by a supervisor/author.
|
||
message.requires_acknowledgement = True
|
||
elif message_type not in {
|
||
CaseReviewMessage.MessageType.FEEDBACK,
|
||
CaseReviewMessage.MessageType.REPLY,
|
||
}:
|
||
message.requires_acknowledgement = False
|
||
message.save()
|
||
|
||
elif action == "acknowledge":
|
||
message = get_object_or_404(
|
||
CaseReviewMessage,
|
||
pk=request.POST.get("message_id"),
|
||
cid_user_exam=cid_user_exam,
|
||
case=case,
|
||
)
|
||
|
||
if can_feedback and message.message_type != CaseReviewMessage.MessageType.QUERY:
|
||
raise PermissionDenied
|
||
if can_query and message.message_type == CaseReviewMessage.MessageType.QUERY:
|
||
raise PermissionDenied
|
||
if not (can_feedback or can_query):
|
||
raise PermissionDenied
|
||
|
||
if message.requires_acknowledgement:
|
||
message.acknowledge()
|
||
|
||
context = _get_case_review_thread_context(
|
||
collection=collection,
|
||
cid_user_exam=cid_user_exam,
|
||
case=case,
|
||
request=request,
|
||
cid=int(cid) if cid and str(cid).isdigit() else None,
|
||
passcode=passcode,
|
||
)
|
||
return render(request, "atlas/partials/_case_review_thread.html", context)
|
||
|
||
|
||
|
||
|
||
def collection_take_start(request, pk, cid=None, passcode=None):
|
||
"""The starting page for taking / reviewing a case collection
|
||
|
||
Args:
|
||
request (_type_): _description_
|
||
pk (_type_): _description_
|
||
|
||
Returns:
|
||
_type_: _description_
|
||
"""
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
valid_user = True
|
||
except Http404:
|
||
valid_user = False
|
||
except collection.InactiveException as e:
|
||
return HttpResponse(f"<h1>{str(e)}</h1>")
|
||
except PrerequisiteRequired as e:
|
||
# Show a friendly page linking to the required collection rather than 404
|
||
prereq = getattr(e, 'prereq', None)
|
||
return render(
|
||
request,
|
||
'atlas/prerequisite_required.html',
|
||
{'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode},
|
||
)
|
||
|
||
|
||
#valid_user = collection.check_logged_in_user(request)
|
||
|
||
|
||
cid_exam = None
|
||
if collection.collection_type == "REV" or valid_user:
|
||
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
|
||
|
||
# Build a lightweight summary of who results will be shared with.
|
||
sharing_summary = _collection_sharing_summary(collection, request.user)
|
||
|
||
template_variables = {
|
||
"collection": collection,
|
||
"valid_user": valid_user,
|
||
"cid_exam": cid_exam,
|
||
"sharing_summary": sharing_summary,
|
||
"start_screen_resources": collection.start_screen_resources.all(),
|
||
}
|
||
|
||
match collection.collection_type:
|
||
case x if x in ("REP", "QUE"):
|
||
return render(
|
||
request, "atlas/collection_take_start.html", template_variables
|
||
)
|
||
case "REV":
|
||
return render(
|
||
request, "atlas/collection_review_start.html", template_variables
|
||
)
|
||
case _:
|
||
return HttpResponse(f"Unknown collection type {collection.collection_type}")
|
||
|
||
|
||
def _case_number_for_collection_and_caseid(collection_pk, case_id):
|
||
"""Helper: given a collection PK and a case PK, return the case index (case_number).
|
||
|
||
Raises Http404 if the case is not found in the collection.
|
||
"""
|
||
collection = get_object_or_404(CaseCollection, pk=collection_pk)
|
||
try:
|
||
# attempt to resolve the case object (may raise Http404)
|
||
case_obj = get_case_for_case_detail(case_id)
|
||
except Http404:
|
||
raise
|
||
try:
|
||
return collection.get_index_of_case(case_obj)
|
||
except ValueError:
|
||
raise Http404(f"Case {case_id} not in collection {collection_pk}")
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_details_by_id(request, exam_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
|
||
return redirect("atlas:collection_case_details", exam_id=exam_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_questions_by_id(request, exam_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
|
||
return redirect("atlas:collection_case_questions", exam_id=exam_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_priors_by_id(request, exam_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
|
||
return redirect("atlas:collection_case_priors", exam_id=exam_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_dicom_json_by_id(request, exam_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
|
||
return redirect("atlas:collection_case_dicom_json", exam_id=exam_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_dicom_json_review_by_id(request, exam_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(exam_id, case_id)
|
||
return redirect("atlas:collection_case_dicom_json_review", exam_id=exam_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_view_by_id(request, pk, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(pk, case_id)
|
||
return redirect("atlas:collection_case_view", pk=pk, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_displaysetup_by_id(request, collection_id, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(collection_id, case_id)
|
||
return redirect("atlas:collection_case_displaysetup", collection_id=collection_id, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_view_take_by_id(request, pk, case_id, cid, passcode):
|
||
case_number = _case_number_for_collection_and_caseid(pk, case_id)
|
||
return redirect("atlas:collection_case_view_take", pk=pk, case_number=case_number, cid=cid, passcode=passcode)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_view_take_user_by_id(request, pk, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(pk, case_id)
|
||
return redirect("atlas:collection_case_view_take_user", pk=pk, case_number=case_number)
|
||
|
||
|
||
@login_required
|
||
def redirect_collection_case_view_take_user_answers_by_id(request, pk, case_id):
|
||
case_number = _case_number_for_collection_and_caseid(pk, case_id)
|
||
return redirect("atlas:collection_case_view_take_user_answers", pk=pk, case_number=case_number)
|
||
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_case_priors(request, exam_id, case_number):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
try:
|
||
case_obj = collection.get_case_by_index(case_number)
|
||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||
except Exception:
|
||
raise Http404("Case not found in collection")
|
||
|
||
if request.htmx:
|
||
# Ensure we can render the updated single-card partial and return it so HTMX
|
||
# can swap the card on the client side.
|
||
|
||
if "remove" in request.POST:
|
||
prior_pk = request.POST["remove"]
|
||
try:
|
||
p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
|
||
p.delete()
|
||
except CasePrior.DoesNotExist:
|
||
# Already removed; continue and render the not-added card
|
||
pass
|
||
|
||
prior_case = Case.objects.get(pk=prior_pk)
|
||
added = False
|
||
relation = ""
|
||
visibility = "AL"
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/_prior_card.html",
|
||
{
|
||
"case": prior_case,
|
||
"added": added,
|
||
"relation": relation,
|
||
"visibility": visibility,
|
||
"casedetail": casedetail,
|
||
"collection": collection,
|
||
},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
elif "prior_case_id" in request.POST:
|
||
if not request.POST.get("relation"):
|
||
# Return the card partial with an inline error so HTMX will replace
|
||
# the card and display the validation message rather than navigating
|
||
# to a new page or showing a plain text response.
|
||
prior_case = Case.objects.get(pk=request.POST.get("prior_case_id"))
|
||
added = False
|
||
relation = request.POST.get("relation", "")
|
||
visibility = request.POST.get("prior_visibility", "AL")
|
||
html = render_to_string(
|
||
"atlas/partials/_prior_card.html",
|
||
{
|
||
"case": prior_case,
|
||
"added": added,
|
||
"relation": relation,
|
||
"visibility": visibility,
|
||
"casedetail": casedetail,
|
||
"collection": collection,
|
||
"error": "You need to enter text to describe the relationship between the cases",
|
||
},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
|
||
p, created = CasePrior.objects.get_or_create(
|
||
casedetail=casedetail, prior_case=prior_case
|
||
)
|
||
p.relation_text = request.POST.get("relation", "")
|
||
p.prior_visibility = request.POST.get("prior_visibility", "AL")
|
||
p.save()
|
||
|
||
added = True
|
||
relation = p.relation_text
|
||
visibility = p.prior_visibility
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/_prior_card.html",
|
||
{
|
||
"case": prior_case,
|
||
"added": added,
|
||
"relation": relation,
|
||
"visibility": visibility,
|
||
"casedetail": casedetail,
|
||
"collection": collection,
|
||
},
|
||
request=request,
|
||
)
|
||
return HttpResponse(html)
|
||
else:
|
||
return HttpResponse("False", status=400)
|
||
|
||
collection = casedetail.collection
|
||
|
||
added_priors = CasePrior.objects.filter(casedetail=casedetail)
|
||
|
||
added_relation_map = {}
|
||
for prior in added_priors:
|
||
added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility)
|
||
|
||
case = casedetail.case
|
||
|
||
available_cases = case.get_all_prior_cases()
|
||
|
||
available_priors = []
|
||
for case in available_cases:
|
||
if case in added_relation_map:
|
||
available_priors.append((case, True, added_relation_map[case][0], added_relation_map[case][1]))
|
||
else:
|
||
available_priors.append((case, False, "", "AL"))
|
||
|
||
form = PriorCaseForm()
|
||
|
||
case_number, case_count = collection.get_index_of_case(
|
||
casedetail.case, case_count=True
|
||
)
|
||
|
||
previous = collection.get_previous_case(casedetail.case)
|
||
next = collection.get_next_case(casedetail.case)
|
||
# Determine whether the current user can edit this collection/case so
|
||
# included fragments which rely on `can_edit` have the value available.
|
||
can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists()
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_case_priors.html",
|
||
{
|
||
"casedetail": casedetail,
|
||
"form": form,
|
||
#"model_form": model_form,
|
||
# "example_form": example_form,
|
||
"collection": collection,
|
||
"case": casedetail.case,
|
||
"previous": previous,
|
||
"next": next,
|
||
"collection_length": case_count,
|
||
"case_number": case_number,
|
||
"added_priors": added_priors,
|
||
"available_priors": available_priors,
|
||
"can_edit": can_edit,
|
||
# Provide a default `error` value so `{% if error %}` checks in included
|
||
# partials won't raise during variable resolution when the key is absent.
|
||
"error": None,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_case_questions(request, exam_id, case_number):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
try:
|
||
case_obj = collection.get_case_by_index(case_number)
|
||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||
except Exception:
|
||
raise Http404("Case not found in collection")
|
||
|
||
if not collection.collection_type == "QUE":
|
||
raise Http404("Collection not in question mode")
|
||
|
||
if request.method == "POST":
|
||
# Called if the user saves the correct answer form
|
||
if request.POST.get("submit") == "answer":
|
||
# answers = request.POST
|
||
casedetail.question_answers = json.loads(request.POST.get("json_answer"))
|
||
casedetail.save()
|
||
example_form = JsonAnswerForm(
|
||
request.POST, question_schema=casedetail.question_schema
|
||
)
|
||
form = CaseQuestionForm(instance=casedetail)
|
||
# Called if the user saves the main form
|
||
elif request.POST.get("submit") == "save":
|
||
form = CaseQuestionForm(request.POST, instance=casedetail)
|
||
if form.is_valid():
|
||
form.save()
|
||
# Add any additional logic or redirection here
|
||
# example_form = JsonAnswerForm(request.POST, question_schema=casedetail.question_schema)
|
||
post_data = request.POST.copy()
|
||
if (
|
||
casedetail.question_schema is not None
|
||
and casedetail.question_answers is not None
|
||
):
|
||
question_keys = set(casedetail.question_schema["properties"].keys())
|
||
answer_keys = set(casedetail.question_answers.keys())
|
||
|
||
answers = {}
|
||
for key, value in casedetail.question_answers.items():
|
||
if key in question_keys:
|
||
answers[key] = value
|
||
|
||
diff = question_keys - answer_keys
|
||
|
||
for key in diff:
|
||
answers[key] = ""
|
||
|
||
post_data["json_answer"] = json.dumps(answers)
|
||
casedetail.question_answers = answers
|
||
casedetail.save()
|
||
example_form = JsonAnswerForm(
|
||
post_data, question_schema=casedetail.question_schema
|
||
)
|
||
|
||
form = CaseQuestionForm(instance=casedetail)
|
||
# This shouldn't happen
|
||
else:
|
||
assert False
|
||
form = CaseQuestionForm(request.POST, instance=casedetail)
|
||
|
||
else:
|
||
post_data = request.POST.copy()
|
||
post_data["json_answer"] = json.dumps(casedetail.question_answers)
|
||
example_form = JsonAnswerForm(
|
||
post_data, question_schema=casedetail.question_schema
|
||
)
|
||
|
||
form = CaseQuestionForm(instance=casedetail)
|
||
pass
|
||
# post_data = None
|
||
|
||
# if casedetail.question_schema is not None and casedetail.question_answers is not None:
|
||
# question_keys = set(casedetail.question_schema["properties"].keys())
|
||
# answer_keys = set(casedetail.question_answers.keys())
|
||
|
||
# answers = {}
|
||
# for key, value in casedetail.question_answers.items():
|
||
# if key in question_keys:
|
||
# answers[key] = value
|
||
|
||
# diff = question_keys - answer_keys
|
||
|
||
# for key in diff:
|
||
# answers[key] = ""
|
||
|
||
# post_data = request.POST.copy()
|
||
# post_data["json_answer"]= json.dumps(answers)
|
||
# casedetail.question_answers = answers
|
||
# example_form = JsonAnswerForm(post_data, question_schema=casedetail.question_schema)
|
||
|
||
# form = CaseQuestionForm(instance=casedetail)
|
||
|
||
# blank_form = JsonAnswerForm(question_schema=casedetail.question_schema)
|
||
case_number, case_count = collection.get_index_of_case(
|
||
casedetail.case, case_count=True
|
||
)
|
||
|
||
previous = collection.get_previous_case(casedetail.case)
|
||
next = collection.get_next_case(casedetail.case)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_case_questions.html",
|
||
{
|
||
"casedetail": casedetail,
|
||
"form": form,
|
||
"example_form": example_form,
|
||
"collection": collection,
|
||
"case": casedetail.case,
|
||
"previous": previous,
|
||
"next": next,
|
||
"collection_length": case_count,
|
||
"case_number": case_number,
|
||
},
|
||
)
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_case_details(request, exam_id, case_number):
|
||
# Accept either case_number (index into collection) or case_id (case PK).
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
try:
|
||
case_obj = collection.get_case_by_index(int(case_number))
|
||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||
except Exception:
|
||
raise Http404("Case not found in collection")
|
||
|
||
if request.method == "POST":
|
||
form = CaseDetailForm(request.POST, instance=casedetail)
|
||
if form.is_valid():
|
||
form.save()
|
||
if request.htmx:
|
||
return HttpResponse("Saved")
|
||
return redirect(request.path)
|
||
else:
|
||
if request.htmx:
|
||
return HttpResponse("Invalid form", status=400)
|
||
else:
|
||
form = CaseDetailForm(instance=casedetail)
|
||
|
||
case_number, case_count = collection.get_index_of_case(
|
||
casedetail.case, case_count=True
|
||
)
|
||
|
||
previous = collection.get_previous_case(casedetail.case)
|
||
next = collection.get_next_case(casedetail.case)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_case_details.html",
|
||
{
|
||
"casedetail": casedetail,
|
||
"form": form,
|
||
"collection": collection,
|
||
"case": casedetail.case,
|
||
"previous": previous,
|
||
"next": next,
|
||
"collection_length": case_count,
|
||
"case_number": case_number,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_mark_overview(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
if collection.collection_type == "REV":
|
||
raise Http404("Collection in review mode")
|
||
|
||
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
case__in=cases, collection=collection
|
||
).prefetch_related("case")
|
||
|
||
answers = CidReportAnswer.objects.filter(question__in=casedetails)
|
||
|
||
case_map = {}
|
||
for cd in casedetails:
|
||
case_map[cd] = []
|
||
|
||
for answer in answers:
|
||
case_map[answer.question].append(answer)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_mark_overview.html",
|
||
{
|
||
"collection": collection,
|
||
"answers": answers,
|
||
"casedetails": casedetails,
|
||
"case_map": case_map,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_mark_question(request, pk, case_number):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
if collection.collection_type == "REV":
|
||
raise Http404("Collection in review mode")
|
||
|
||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||
|
||
previous = case_number > 0
|
||
next = case_number < (case_count - 1)
|
||
|
||
casedetail = CaseDetail.objects.filter(case=case, collection=collection)
|
||
|
||
answers = CidReportAnswer.objects.filter(question__in=casedetail)
|
||
|
||
answer_forms = []
|
||
if request.method == "POST":
|
||
for answer in answers:
|
||
if answer:
|
||
form = CidReportAnswerMarkForm(
|
||
request.POST, instance=answer, prefix=answer.id
|
||
)
|
||
else:
|
||
form = CidReportAnswerMarkForm(request.POST, prefix=answer.id)
|
||
if form.is_valid():
|
||
answer = form.save(commit=False)
|
||
# answer.cid = cid
|
||
# answer.question = casedetail
|
||
# answer.published_date = timezone.now()
|
||
answer.save()
|
||
|
||
kwargs = {"pk": pk}
|
||
|
||
if "save" in request.POST:
|
||
return redirect(
|
||
"atlas:collection_mark_question", case_number=case_number, **kwargs
|
||
)
|
||
if "next" in request.POST:
|
||
return redirect(
|
||
"atlas:collection_mark_question", case_number=case_number + 1, **kwargs
|
||
)
|
||
elif "previous" in request.POST:
|
||
return redirect(
|
||
"atlas:collection_mark_question", case_number=case_number - 1, **kwargs
|
||
)
|
||
elif "goto" in request.POST:
|
||
return redirect(
|
||
"atlas:collection_mark_question",
|
||
case_number=int(request.POST.get("goto")),
|
||
**kwargs,
|
||
)
|
||
else:
|
||
for answer in answers:
|
||
form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk)
|
||
answer_forms.append((answer, form))
|
||
|
||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_mark_question.html",
|
||
{
|
||
"collection": collection,
|
||
"series_list": series_list,
|
||
"answers": answers,
|
||
"casedetail": casedetail,
|
||
"case": case,
|
||
"case_number": case_number,
|
||
"answer_forms": answer_forms,
|
||
"previous": previous,
|
||
"next": next,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def exam_cids(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
if (
|
||
request.user in collection.author.all()
|
||
or request.user.groups.filter(name="cid_user_manager").exists()
|
||
):
|
||
cid_users = collection.valid_cid_users.all()
|
||
|
||
cid_user_count = cid_users.count()
|
||
return render(
|
||
request,
|
||
"atlas/exam_cids.html",
|
||
{
|
||
"collection": collection,
|
||
"cid_users": cid_users,
|
||
"cid_user_count": cid_user_count,
|
||
},
|
||
)
|
||
|
||
raise PermissionDenied
|
||
|
||
|
||
def collection_take_overview_user(request, pk):
|
||
return collection_take_overview(request, pk)
|
||
|
||
|
||
@login_required
|
||
def collection_feedback_overview_user(request, pk: int):
|
||
return collection_feedback_overview(request, pk)
|
||
|
||
|
||
def collection_feedback_overview(
|
||
request, pk: int, cid: int | None = None, passcode: str | None = None
|
||
):
|
||
"""Show all case conversations for one learner attempt with outstanding items first."""
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
_enforce_collection_case_query_messaging_enabled(collection)
|
||
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
except PrerequisiteRequired as e:
|
||
prereq = getattr(e, 'prereq', None)
|
||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||
cid=cid, user_user=request.user
|
||
)
|
||
casedetails = list(
|
||
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
|
||
)
|
||
feedback_rows, outstanding_case_count, total_outstanding_feedback = _build_collection_feedback_rows(
|
||
collection=collection,
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
cid=cid,
|
||
)
|
||
|
||
outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_feedback")]
|
||
history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_feedback")]
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_feedback_overview.html",
|
||
{
|
||
"collection": collection,
|
||
"cid": cid,
|
||
"passcode": passcode,
|
||
"cid_user_exam": cid_user_exam,
|
||
"feedback_rows": feedback_rows,
|
||
"outstanding_rows": outstanding_rows,
|
||
"history_rows": history_rows,
|
||
"outstanding_case_count": outstanding_case_count,
|
||
"total_outstanding_feedback": total_outstanding_feedback,
|
||
},
|
||
)
|
||
|
||
|
||
def collection_take_overview(
|
||
request, pk: int, cid: int | None = None, passcode: str | None = None
|
||
):
|
||
"""
|
||
Summary page that shows which questions a user has attempted
|
||
(with / without answers)
|
||
|
||
Args:
|
||
request (_type_): _description_
|
||
pk (int): _description_
|
||
cid (int | None, optional): _description_. Defaults to None.
|
||
passcode (str | None, optional): _description_. Defaults to None.
|
||
|
||
Returns:
|
||
_type_: _description_
|
||
"""
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
except PrerequisiteRequired as e:
|
||
prereq = getattr(e, 'prereq', None)
|
||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||
cid=cid, user_user=request.user
|
||
)
|
||
|
||
if request.htmx:
|
||
if "finish" in request.POST:
|
||
cid_user_exam.completed = True
|
||
cid_user_exam.save()
|
||
|
||
collection.exam_user_status.create(
|
||
cid_user_exam=cid_user_exam,
|
||
status="submitted",
|
||
extra="manual submission",
|
||
)
|
||
|
||
return HttpResponse("True")
|
||
else:
|
||
raise Http404()
|
||
|
||
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||
|
||
casedetails = list(
|
||
CaseDetail.objects.filter(case__in=cases, collection=collection).select_related("case")
|
||
)
|
||
|
||
if cid is not None:
|
||
answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
|
||
else:
|
||
answers = UserReportAnswer.objects.filter(
|
||
user=request.user, question__in=casedetails
|
||
)
|
||
|
||
answer_question_map = {}
|
||
for ans in answers:
|
||
answer_question_map[ans.question] = ans
|
||
|
||
case_review_stats, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
|
||
question_answer_tuples = []
|
||
answer_count = 0
|
||
for cd in casedetails:
|
||
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
|
||
review_stats = case_review_stats.get(cd.case_id, {})
|
||
answer_obj = answer_question_map.get(cd)
|
||
timing_state = _build_case_timing_state(collection, cd, answer_obj)
|
||
if cd in answer_question_map and (
|
||
answer_question_map[cd].answer or answer_question_map[cd].json_answer
|
||
):
|
||
question_answer_tuples.append((cd, answer_question_map[cd], self_review, review_stats, timing_state))
|
||
answer_count += 1
|
||
else:
|
||
question_answer_tuples.append((cd, None, self_review, review_stats, timing_state))
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_take_overview.html",
|
||
{
|
||
"collection": collection,
|
||
"cid": cid,
|
||
"question_answer_tuples": question_answer_tuples,
|
||
"answer_count": answer_count,
|
||
"collection_length": len(cases),
|
||
"passcode": passcode,
|
||
"cid_user_exam": cid_user_exam,
|
||
"outstanding_case_count": outstanding_case_count,
|
||
"total_outstanding_feedback": total_outstanding_feedback,
|
||
"end_overview_resources": collection.end_overview_resources.all(),
|
||
},
|
||
)
|
||
|
||
|
||
def _build_case_timing_state(collection, casedetail, answer):
|
||
"""Return computed timing state for a collection case attempt.
|
||
|
||
- `effective_time_limit`: seconds until case view is locked (None means disabled)
|
||
- `effective_answer_grace_period`: extra answer-only seconds after view lock
|
||
- `case_view_locked`: user can no longer view case content/viewer
|
||
- `answer_entry_closed`: user can no longer submit/edit answer
|
||
"""
|
||
effective_time_limit = collection.get_effective_case_time_limit(casedetail)
|
||
effective_answer_grace_period = collection.get_effective_answer_entry_grace_period(
|
||
casedetail
|
||
)
|
||
|
||
if effective_time_limit is None:
|
||
return {
|
||
"effective_time_limit": None,
|
||
"effective_answer_grace_period": 0,
|
||
"case_view_locked": False,
|
||
"answer_entry_closed": False,
|
||
"elapsed_seconds": 0,
|
||
"case_view_seconds_remaining": None,
|
||
"answer_entry_seconds_remaining": None,
|
||
}
|
||
|
||
started_at = getattr(answer, "started_at", None)
|
||
elapsed_seconds = 0
|
||
if started_at is not None:
|
||
elapsed_seconds = max(0, int((timezone.now() - started_at).total_seconds()))
|
||
|
||
case_view_locked = elapsed_seconds >= effective_time_limit
|
||
answer_entry_closed = elapsed_seconds >= (
|
||
effective_time_limit + (effective_answer_grace_period or 0)
|
||
)
|
||
case_view_seconds_remaining = max(0, effective_time_limit - elapsed_seconds)
|
||
answer_entry_seconds_remaining = max(
|
||
0,
|
||
(effective_time_limit + (effective_answer_grace_period or 0)) - elapsed_seconds,
|
||
)
|
||
|
||
return {
|
||
"effective_time_limit": effective_time_limit,
|
||
"effective_answer_grace_period": effective_answer_grace_period or 0,
|
||
"case_view_locked": case_view_locked,
|
||
"answer_entry_closed": answer_entry_closed,
|
||
"elapsed_seconds": elapsed_seconds,
|
||
"case_view_seconds_remaining": case_view_seconds_remaining,
|
||
"answer_entry_seconds_remaining": answer_entry_seconds_remaining,
|
||
}
|
||
|
||
|
||
def _collection_sharing_summary(collection, user):
|
||
"""Return a dict describing who a registered user's results will be shared with
|
||
for the given collection, before an attempt is created.
|
||
|
||
Keys:
|
||
always_visible_to – list of User objects (authors + markers)
|
||
supervisor – User | None (linked supervisor account, if any)
|
||
supervisor_always – bool (results_supervisor_visible on the collection)
|
||
supervisor_opt_in – bool (user can choose to share with supervisor)
|
||
"""
|
||
always_visible = []
|
||
seen_pks = set()
|
||
for attr in ("author", "markers"):
|
||
if hasattr(collection, attr):
|
||
for u in getattr(collection, attr).select_related():
|
||
if u.pk not in seen_pks:
|
||
seen_pks.add(u.pk)
|
||
always_visible.append(u)
|
||
|
||
supervisor_always = getattr(collection, "results_supervisor_visible", False)
|
||
supervisor_user = None
|
||
supervisor_opt_in = False
|
||
if user and user.is_authenticated:
|
||
try:
|
||
sup = user.userprofile.supervisor
|
||
if sup:
|
||
supervisor_opt_in = True
|
||
if sup.user_id:
|
||
supervisor_user = sup.user
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"always_visible_to": always_visible,
|
||
"supervisor": supervisor_user,
|
||
"supervisor_always": supervisor_always,
|
||
"supervisor_opt_in": supervisor_opt_in,
|
||
}
|
||
|
||
|
||
@login_required
|
||
def collection_exam_sharing_panel(request, pk):
|
||
"""HTMX view to display and manage sharing settings for a CidUserExam attempt.
|
||
|
||
Scope: attempt owner only (user_user). Read-only rendering for all others.
|
||
|
||
GET – returns the sharing panel partial.
|
||
POST – handles one of three actions (action= toggle_supervisor | add_user | remove_user).
|
||
"""
|
||
cid_user_exam = get_object_or_404(CidUserExam, pk=pk)
|
||
|
||
if not request.htmx:
|
||
raise PermissionDenied()
|
||
|
||
is_owner = bool(cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk)
|
||
|
||
if request.method == "POST":
|
||
if not is_owner:
|
||
raise PermissionDenied()
|
||
|
||
action = request.POST.get("action")
|
||
|
||
if action == "toggle_supervisor":
|
||
cid_user_exam.share_with_supervisor = not cid_user_exam.share_with_supervisor
|
||
cid_user_exam.save(update_fields=["share_with_supervisor"])
|
||
|
||
elif action == "add_user":
|
||
try:
|
||
user_to_add = User.objects.get(pk=int(request.POST.get("user_id", "")))
|
||
if user_to_add.pk != request.user.pk:
|
||
cid_user_exam.shared_with_users.add(user_to_add)
|
||
except (User.DoesNotExist, ValueError, TypeError):
|
||
pass
|
||
|
||
elif action == "remove_user":
|
||
try:
|
||
user_to_remove = User.objects.get(pk=int(request.POST.get("user_id", "")))
|
||
cid_user_exam.shared_with_users.remove(user_to_remove)
|
||
except (User.DoesNotExist, ValueError, TypeError):
|
||
pass
|
||
|
||
sharing_info = cid_user_exam.get_sharing_info()
|
||
return render(
|
||
request,
|
||
"atlas/partials/_sharing_panel.html",
|
||
{
|
||
"cid_user_exam": cid_user_exam,
|
||
"sharing_info": sharing_info,
|
||
"is_owner": is_owner,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def collection_exam_user_search(request, pk):
|
||
"""HTMX endpoint to search for users to add to a CidUserExam's shared_with_users.
|
||
|
||
Scope: attempt owner only.
|
||
GET param q – minimum 2 characters. Returns a small result-list partial.
|
||
"""
|
||
cid_user_exam = get_object_or_404(CidUserExam, pk=pk)
|
||
|
||
if not request.htmx:
|
||
raise PermissionDenied()
|
||
|
||
if not (cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk):
|
||
raise PermissionDenied()
|
||
|
||
q = (request.GET.get("q") or "").strip()
|
||
users = []
|
||
if len(q) >= 2:
|
||
already_shared = set(cid_user_exam.shared_with_users.values_list("pk", flat=True))
|
||
users = list(
|
||
User.objects.filter(
|
||
Q(first_name__icontains=q)
|
||
| Q(last_name__icontains=q)
|
||
| Q(email__icontains=q)
|
||
| Q(username__icontains=q)
|
||
)
|
||
.exclude(pk=request.user.pk)
|
||
.exclude(pk__in=already_shared)
|
||
.order_by("last_name", "first_name")[:10]
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/partials/_sharing_user_search_results.html",
|
||
{
|
||
"users": users,
|
||
"cid_user_exam": cid_user_exam,
|
||
"q": q,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def collection_shared_with_me(request):
|
||
"""Legacy redirect — the shared-with-me page is now part of the supervision page."""
|
||
return redirect(reverse("atlas:collection_supervision"))
|
||
|
||
|
||
|
||
@login_required
|
||
def collection_shared_attempt_overview(request, cid_user_exam_pk):
|
||
"""Read-only overview of a learner's attempt, visible to authorised third parties.
|
||
|
||
Accessible to authors, markers, supervisors (when sharing is enabled), and
|
||
users manually added to CidUserExam.shared_with_users.
|
||
"""
|
||
cid_user_exam = get_object_or_404(
|
||
CidUserExam.objects.select_related("user_user", "cid_user"),
|
||
pk=cid_user_exam_pk,
|
||
)
|
||
|
||
if not cid_user_exam.can_user_view(request.user):
|
||
raise PermissionDenied()
|
||
|
||
collection = cid_user_exam.exam
|
||
if not isinstance(collection, CaseCollection):
|
||
raise Http404()
|
||
|
||
casedetails = list(
|
||
CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order")
|
||
)
|
||
|
||
reviews = cid_user_exam.selfreview_set.filter(
|
||
case__in=[cd.case for cd in casedetails]
|
||
).order_by("-review_update_date")
|
||
reviews_by_case = {}
|
||
for review in reviews:
|
||
reviews_by_case.setdefault(review.case_id, []).append(review)
|
||
|
||
case_review_stats, _, _ = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=casedetails,
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
|
||
history_rows = []
|
||
answer_count = 0
|
||
for casedetail in casedetails:
|
||
if cid_user_exam.user_user_id:
|
||
user_answer = casedetail.get_user_answers(cid_user_exam.user_user)
|
||
else:
|
||
user_answer = casedetail.get_cid_answers(cid_user_exam.cid_user.cid)
|
||
|
||
if user_answer and (user_answer.answer or user_answer.json_answer):
|
||
answer_count += 1
|
||
|
||
history_rows.append(
|
||
{
|
||
"casedetail": casedetail,
|
||
"user_answer": user_answer,
|
||
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
|
||
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
|
||
}
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_history_user.html",
|
||
{
|
||
"collection": collection,
|
||
"user": cid_user_exam.get_user_name(),
|
||
"cid_user_exam": cid_user_exam,
|
||
"history_rows": history_rows,
|
||
"answer_count": answer_count,
|
||
"collection_length": len(history_rows),
|
||
"cid": cid_user_exam.cid_user.cid if cid_user_exam.cid_user_id else None,
|
||
"read_only_shared_view": True,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def collection_supervision(request):
|
||
"""Supervision page: lists all collection attempts visible to the current user as
|
||
an author, marker, supervisor, or manual-share recipient.
|
||
|
||
Supports:
|
||
- GET param ``group_by``: ``user`` (default) or ``collection``
|
||
- GET params ``date_from`` / ``date_to``: ISO date strings to filter by
|
||
attempt start_time (inclusive).
|
||
- GET param ``q``: free-text search over learner name / username / email.
|
||
- GET param ``supervisor_only``: ``1`` to restrict to attempts where the current
|
||
user is the assigned supervisor (only shown when the user is a supervisor).
|
||
"""
|
||
from django.db.models import Q
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.utils.dateparse import parse_date
|
||
|
||
collection_ct = ContentType.objects.get_for_model(CaseCollection)
|
||
|
||
# ---- determine supervisor status ----
|
||
is_supervisor = False
|
||
trainee_users = User.objects.none()
|
||
trainee_cid_users = CidUser.objects.none()
|
||
try:
|
||
supervisor = request.user.supervisor
|
||
is_supervisor = True
|
||
trainee_users = User.objects.filter(userprofile__supervisor=supervisor)
|
||
trainee_cid_users = CidUser.objects.filter(supervisor=supervisor)
|
||
except Exception:
|
||
pass
|
||
|
||
supervisor_only = request.GET.get("supervisor_only", "") == "1" and is_supervisor
|
||
|
||
# ---- build base querysets of accessible attempts ----
|
||
manual_qs = CidUserExam.objects.filter(
|
||
shared_with_users=request.user,
|
||
content_type=collection_ct,
|
||
).exclude(user_user=request.user)
|
||
|
||
managed_collection_ids = list(
|
||
CaseCollection.objects.filter(
|
||
Q(author=request.user) | Q(markers=request.user)
|
||
).values_list("pk", flat=True)
|
||
)
|
||
authored_qs = CidUserExam.objects.filter(
|
||
content_type=collection_ct,
|
||
object_id__in=managed_collection_ids,
|
||
).exclude(user_user=request.user)
|
||
|
||
supervisor_qs = CidUserExam.objects.none()
|
||
supervisor_exam_qs = CidUserExam.objects.none()
|
||
if is_supervisor:
|
||
supervisor_qs = CidUserExam.objects.filter(
|
||
content_type=collection_ct,
|
||
share_with_supervisor=True,
|
||
).filter(
|
||
Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)
|
||
).exclude(user_user=request.user)
|
||
|
||
visible_collection_ids = list(
|
||
CaseCollection.objects.filter(results_supervisor_visible=True).values_list("pk", flat=True)
|
||
)
|
||
supervisor_exam_qs = CidUserExam.objects.filter(
|
||
content_type=collection_ct,
|
||
object_id__in=visible_collection_ids,
|
||
).filter(
|
||
Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)
|
||
).exclude(user_user=request.user)
|
||
|
||
if supervisor_only:
|
||
all_attempts = (supervisor_qs | supervisor_exam_qs).distinct()
|
||
else:
|
||
all_attempts = (
|
||
manual_qs | authored_qs | supervisor_qs | supervisor_exam_qs
|
||
).distinct()
|
||
|
||
all_attempts = all_attempts.select_related("user_user", "cid_user")
|
||
|
||
# ---- date filter ----
|
||
date_from_raw = request.GET.get("date_from", "").strip()
|
||
date_to_raw = request.GET.get("date_to", "").strip()
|
||
try:
|
||
if date_from_raw:
|
||
date_from = parse_date(date_from_raw)
|
||
if date_from:
|
||
all_attempts = all_attempts.filter(start_time__date__gte=date_from)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if date_to_raw:
|
||
date_to = parse_date(date_to_raw)
|
||
if date_to:
|
||
all_attempts = all_attempts.filter(start_time__date__lte=date_to)
|
||
except Exception:
|
||
pass
|
||
|
||
# ---- user / learner search ----
|
||
q = request.GET.get("q", "").strip()
|
||
if q:
|
||
all_attempts = all_attempts.filter(
|
||
Q(user_user__first_name__icontains=q)
|
||
| Q(user_user__last_name__icontains=q)
|
||
| Q(user_user__username__icontains=q)
|
||
| Q(user_user__email__icontains=q)
|
||
| Q(cid_user__name__icontains=q)
|
||
| Q(cid_user__email__icontains=q)
|
||
)
|
||
|
||
all_attempts = all_attempts.order_by("-start_time")
|
||
|
||
# ---- grouping ----
|
||
group_by = request.GET.get("group_by", "user")
|
||
if group_by not in ("user", "collection"):
|
||
group_by = "user"
|
||
|
||
grouped = {}
|
||
for attempt in all_attempts:
|
||
collection_obj = attempt.exam
|
||
if group_by == "collection":
|
||
key_label = str(collection_obj) if collection_obj else "Unknown collection"
|
||
key_id = getattr(collection_obj, "pk", 0)
|
||
key = (key_id, key_label)
|
||
else:
|
||
if attempt.user_user_id:
|
||
key_label = attempt.user_user.get_full_name() or attempt.user_user.username
|
||
key_id = attempt.user_user_id
|
||
elif attempt.cid_user_id:
|
||
name = attempt.cid_user.name or ""
|
||
key_label = f"CID {attempt.cid_user.cid}" + (f" – {name}" if name else "")
|
||
key_id = f"cid_{attempt.cid_user_id}"
|
||
else:
|
||
key_label = "Unknown learner"
|
||
key_id = 0
|
||
key = (key_id, key_label)
|
||
grouped.setdefault(key, []).append((attempt, collection_obj))
|
||
|
||
groups = sorted(grouped.items(), key=lambda x: str(x[0][1]).lower())
|
||
groups = [(label, rows) for (_, label), rows in groups]
|
||
|
||
return render(
|
||
request,
|
||
"atlas/supervision.html",
|
||
{
|
||
"groups": groups,
|
||
"group_by": group_by,
|
||
"date_from": date_from_raw,
|
||
"date_to": date_to_raw,
|
||
"q": q,
|
||
"is_supervisor": is_supervisor,
|
||
"supervisor_only": supervisor_only,
|
||
"total_attempts": all_attempts.count(),
|
||
},
|
||
)
|
||
|
||
|
||
def collection_case_view_take_user_answers(request, pk: int, case_number: int):
|
||
return collection_case_view_take_answers(request, pk, case_number)
|
||
|
||
|
||
def collection_case_view_take_answers(
|
||
request, pk: int, case_number: int, cid=None, passcode=None
|
||
):
|
||
""" """
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
except PrerequisiteRequired as e:
|
||
prereq = getattr(e, 'prereq', None)
|
||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||
|
||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||
cid=cid, user_user=request.user
|
||
)
|
||
|
||
casedetail = CaseDetail.objects.get(case=case, collection=collection)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_case_view_take_answers.html",
|
||
{
|
||
"collection": collection,
|
||
"case": case,
|
||
"casedetail": casedetail,
|
||
},
|
||
)
|
||
|
||
|
||
def collection_case_view_take_user(request, pk, case_number=None, case_id=None):
|
||
# Accept either case_number (index) or case_id (case PK) and forward both to
|
||
# the handler. This allows URLs that use either name to keep working.
|
||
return collection_case_view_take(request, pk, case_number=case_number, cid=None, passcode=None, case_id=case_id)
|
||
|
||
|
||
def collection_case_view_take(
|
||
request, pk, case_number=None, cid=None, passcode=None, case_id=None
|
||
):
|
||
"""Candidate-facing collection case taking/review screen.
|
||
|
||
This view is for end users taking a case (or reviewing their answers after
|
||
completion). It should remain answer-focused and must not expose management
|
||
edit controls for case content.
|
||
|
||
Supports either:
|
||
- ``case_number``: zero-based index within the collection
|
||
- ``case_id``: case PK (translated to the collection index)
|
||
|
||
Raises:
|
||
Http404: If access is invalid or no case is specified.
|
||
"""
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
# If caller provided a case_id (case PK) instead of a case_number (index),
|
||
# translate it into the index used by collection.get_case_by_index().
|
||
if case_id is not None and case_number is None:
|
||
try:
|
||
case_obj = get_case_for_case_detail(case_id)
|
||
except Http404:
|
||
raise
|
||
try:
|
||
case_number = collection.get_index_of_case(case_obj)
|
||
except ValueError:
|
||
raise Http404(f"Case {case_id} not in collection {pk}")
|
||
# Ensure we have a case_number to work with
|
||
if case_number is None:
|
||
raise Http404("No case specified")
|
||
|
||
form = None
|
||
answer: None | CidReportAnswer | UserReportAnswer = None
|
||
|
||
try:
|
||
collection.check_user_can_take(cid, passcode, request.user)
|
||
except PrerequisiteRequired as e:
|
||
prereq = getattr(e, 'prereq', None)
|
||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||
|
||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||
|
||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||
cid=cid, user_user=request.user
|
||
)
|
||
|
||
if cid_user_exam.start_time is None:
|
||
cid_user_exam.start_time = timezone.now()
|
||
cid_user_exam.save()
|
||
|
||
casedetail = CaseDetail.objects.get(case=case, collection=collection)
|
||
if not collection.review_only():
|
||
if collection.collection_type == "REP":
|
||
if cid is not None:
|
||
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
|
||
ReportAnswerForm = CidReportAnswerForm
|
||
else:
|
||
answer = casedetail.userreportanswer_set.filter(
|
||
user=request.user
|
||
).first()
|
||
ReportAnswerForm = UserReportAnswerForm
|
||
elif collection.collection_type == "QUE":
|
||
if cid is not None:
|
||
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
|
||
ReportAnswerForm = CidQuestionAnswerForm
|
||
else:
|
||
answer = casedetail.userreportanswer_set.filter(
|
||
user=request.user
|
||
).first()
|
||
ReportAnswerForm = UserQuestionAnswerForm
|
||
|
||
# If we have an answer object but it doesn't have a started_at, set it now.
|
||
if answer and getattr(answer, 'started_at', None) is None:
|
||
try:
|
||
answer.started_at = timezone.now()
|
||
answer.save()
|
||
except Exception:
|
||
# Non-fatal: if saving fails, continue without blocking the user
|
||
logger.exception("Failed to set started_at on answer", exc_info=True)
|
||
|
||
# If no answer exists yet, create a placeholder so we have a started_at timestamp
|
||
if not answer and collection.collection_type in ("REP", "QUE"):
|
||
try:
|
||
if cid is not None:
|
||
answer = CidReportAnswer(question=casedetail, cid=cid)
|
||
else:
|
||
answer = UserReportAnswer(question=casedetail, user=request.user)
|
||
answer.started_at = timezone.now()
|
||
answer.save()
|
||
except Exception:
|
||
# If creation fails, leave answer as None; the normal flow will handle form creation
|
||
answer = None
|
||
logger.exception("Failed to create initial answer object", exc_info=True)
|
||
|
||
timing_state = _build_case_timing_state(collection, casedetail, answer)
|
||
|
||
if request.method != "POST" and timing_state["answer_entry_closed"] and answer and not answer.completed:
|
||
answer.completed = True
|
||
if not answer.submitted_at:
|
||
answer.submitted_at = timezone.now()
|
||
answer.save()
|
||
|
||
if request.method == "POST":
|
||
if collection.collection_type in ("REP", "QUE"):
|
||
if not collection.publish_results:
|
||
if answer:
|
||
form = ReportAnswerForm(
|
||
request.POST, instance=answer, casedetail=casedetail
|
||
)
|
||
else:
|
||
form = ReportAnswerForm(request.POST, casedetail=casedetail)
|
||
|
||
timed_out_submission = request.POST.get('timed_out') == '1'
|
||
|
||
if not cid_user_exam.completed and not (
|
||
answer is not None and answer.completed
|
||
):
|
||
if timing_state["answer_entry_closed"] and not timed_out_submission:
|
||
if answer is not None and not answer.completed:
|
||
answer.completed = True
|
||
if not answer.submitted_at:
|
||
answer.submitted_at = timezone.now()
|
||
answer.save()
|
||
|
||
is_ajax = (
|
||
request.headers.get('HX-Request', '').lower() == 'true'
|
||
or request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
||
or getattr(request, 'htmx', False)
|
||
)
|
||
if is_ajax:
|
||
return JsonResponse(
|
||
{
|
||
'status': 'closed',
|
||
'locked': True,
|
||
'submitted_at': answer.submitted_at.isoformat() if answer and getattr(answer, 'submitted_at', None) else None,
|
||
}
|
||
)
|
||
|
||
if form.is_valid():
|
||
answer = form.save(commit=False)
|
||
answer.set_cid_or_user(cid=cid, user=request.user)
|
||
answer.question = casedetail
|
||
# Record submission timestamp
|
||
answer.submitted_at = timezone.now()
|
||
# answer.published_date = timezone.now()
|
||
|
||
if "complete_case" in request.POST:
|
||
if not collection.self_review:
|
||
raise Http404("Self review not enabled")
|
||
answer.completed = True
|
||
|
||
# If this was a timed-out submission, mark as completed
|
||
if request.POST.get('timed_out') == '1':
|
||
answer.completed = True
|
||
## Also mark the overall exam as completed for this user/CID
|
||
#try:
|
||
# cid_user_exam.completed = True
|
||
# cid_user_exam.save()
|
||
# # Record an exam status entry
|
||
# collection.exam_user_status.create(
|
||
# cid_user_exam=cid_user_exam,
|
||
# status="submitted",
|
||
# extra="autosubmit",
|
||
# )
|
||
#except Exception:
|
||
# logger.exception("Failed to mark cid_user_exam completed on autosubmit")
|
||
|
||
answer.save()
|
||
|
||
cid_user_exam.end_time = timezone.now()
|
||
cid_user_exam.save()
|
||
|
||
# If this was an AJAX/HTMX request, return JSON so the client
|
||
# can remain on the same page and update the UI without redirect.
|
||
# Detect HTMX/XHR requests robustly. HTMX sets the HX-Request header.
|
||
is_ajax = (
|
||
request.headers.get('HX-Request', '').lower() == 'true'
|
||
or request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
||
or getattr(request, 'htmx', False)
|
||
)
|
||
if is_ajax:
|
||
return JsonResponse(
|
||
{
|
||
'status': 'ok',
|
||
'locked': answer.completed,
|
||
'submitted_at': answer.submitted_at.isoformat() if getattr(answer, 'submitted_at', None) else None,
|
||
}
|
||
)
|
||
|
||
if cid is not None:
|
||
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
|
||
redirect_url = "atlas:collection_case_view_take"
|
||
overview_url = "atlas:collection_take_overview"
|
||
else:
|
||
kwargs = {"pk": pk}
|
||
redirect_url = "atlas:collection_case_view_take_user"
|
||
overview_url = "atlas:collection_take_overview_user"
|
||
|
||
if "next" in request.POST:
|
||
return redirect(redirect_url, case_number=case_number + 1, **kwargs)
|
||
elif "previous" in request.POST:
|
||
return redirect(redirect_url, case_number=case_number - 1, **kwargs)
|
||
elif "finish" in request.POST:
|
||
return redirect(overview_url, **kwargs)
|
||
elif "goto" in request.POST:
|
||
return redirect(
|
||
redirect_url,
|
||
case_number=int(request.POST.get("goto")),
|
||
**kwargs,
|
||
)
|
||
else:
|
||
if collection.collection_type in ("REP", "QUE"):
|
||
form = ReportAnswerForm(instance=answer, casedetail=casedetail)
|
||
|
||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
||
|
||
previous = case_number > 0
|
||
next = case_number < (case_count - 1)
|
||
|
||
# Set what details a user is / is not able to view when taking the case
|
||
# this can be configured via the collection edit menu
|
||
if (
|
||
collection.publish_results
|
||
or cid_user_exam.completed
|
||
or (
|
||
not collection.feedback_once_collection_complete
|
||
and answer is not None
|
||
and answer.completed
|
||
)
|
||
):
|
||
question_completed = True
|
||
show_title = collection.show_title_post
|
||
show_history = collection.show_history_post
|
||
show_description = collection.show_description_post
|
||
show_discussion = collection.show_discussion_post
|
||
show_report = collection.show_report_post
|
||
|
||
self_review = cid_user_exam.selfreview_set.filter(case=case)
|
||
|
||
resources = case.caseresource_set.all()
|
||
else:
|
||
question_completed = False
|
||
show_title = collection.show_title_pre
|
||
show_history = collection.show_history_pre
|
||
show_description = collection.show_description_pre
|
||
show_discussion = collection.show_discussion_pre
|
||
show_report = collection.show_report_pre
|
||
self_review = []
|
||
|
||
resources = case.caseresource_set.filter(pre_review=True)
|
||
|
||
prior_cases = casedetail.caseprior_set.all()
|
||
|
||
series_to_load = []
|
||
|
||
for series in series_list:
|
||
series_to_load.append((series, False, ""))
|
||
|
||
for prior in prior_cases:
|
||
match prior.prior_visibility:
|
||
case "AL":
|
||
for series in prior.prior_case.get_series():
|
||
series_to_load.append((series, True, prior.relation_text))
|
||
case "RE":
|
||
if question_completed:
|
||
for series in prior.prior_case.get_series():
|
||
series_to_load.append((series, True, prior.relation_text))
|
||
case _:
|
||
continue
|
||
|
||
# Determine whether any prior series will be shown to the candidate.
|
||
has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
|
||
prior_count = prior_cases.count()
|
||
case_review_stats, _, _ = _get_case_review_stats_for_exam(
|
||
cid_user_exam=cid_user_exam,
|
||
casedetails=[casedetail],
|
||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
||
)
|
||
|
||
case_view_locked = (
|
||
not question_completed and timing_state["case_view_locked"]
|
||
)
|
||
answer_entry_closed = (
|
||
not question_completed and timing_state["answer_entry_closed"]
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_case_view_take.html",
|
||
{
|
||
"form": form,
|
||
"collection": collection,
|
||
"case": case,
|
||
"casedetail": casedetail,
|
||
"series_list": series_list,
|
||
"series_to_load": series_to_load,
|
||
"has_priors": has_priors,
|
||
"prior_count": prior_count,
|
||
"case_number": case_number,
|
||
"previous": previous,
|
||
"next": next,
|
||
"cid": cid,
|
||
"passcode": passcode,
|
||
"answer": answer,
|
||
"show_title": show_title,
|
||
"show_history": show_history,
|
||
"show_description": show_description,
|
||
"show_discussion": show_discussion,
|
||
"show_report": show_report,
|
||
"resources": resources,
|
||
"cid_user_exam": cid_user_exam,
|
||
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
|
||
"question_completed": question_completed,
|
||
"self_review": self_review,
|
||
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None,
|
||
"effective_question_time_limit": timing_state["effective_time_limit"],
|
||
"effective_answer_entry_grace_period": timing_state["effective_answer_grace_period"],
|
||
"case_view_locked": case_view_locked,
|
||
"answer_entry_closed": answer_entry_closed,
|
||
"start_screen_resources": collection.start_screen_resources.all(),
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_case_view(request, pk, case_number=None, case_id=None):
|
||
"""Management view for a case within a collection.
|
||
|
||
This view is used by collection authors/atlas editors to manage case
|
||
content in collection context. It renders the full case detail template and
|
||
provides ``can_edit`` for inline editing widgets/partials.
|
||
|
||
Supports either:
|
||
- ``case_number``: zero-based index within the collection
|
||
- ``case_id``: case PK (translated to the collection index)
|
||
|
||
Raises:
|
||
Http404: If no valid case is specified.
|
||
"""
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
# Support either case_number (index) or case_id (case PK) in the URL/kwargs.
|
||
if case_id is not None and case_number is None:
|
||
try:
|
||
case_obj = get_case_for_case_detail(case_id)
|
||
except Http404:
|
||
raise
|
||
try:
|
||
case_number = collection.get_index_of_case(case_obj)
|
||
except ValueError:
|
||
raise Http404(f"Case {case_id} not in collection {pk}")
|
||
if case_number is None:
|
||
raise Http404("No case specified")
|
||
|
||
form = None
|
||
answer = None
|
||
|
||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||
|
||
case = get_case_for_case_detail(case.pk)
|
||
|
||
casedetail = CaseDetail.objects.get(case=case, collection=collection)
|
||
|
||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
||
|
||
previous = collection.get_previous_case(casedetail.case)
|
||
next = collection.get_next_case(casedetail.case)
|
||
can_edit = case.check_user_can_edit(request.user)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/case_detail.html",
|
||
{
|
||
"form": form,
|
||
"collection": collection,
|
||
"collection_length": case_count,
|
||
"case": case,
|
||
"casedetail": casedetail,
|
||
"series_list": series_list,
|
||
"case_number": case_number,
|
||
"previous": previous,
|
||
"next": next,
|
||
"can_edit": can_edit,
|
||
"answer": answer,
|
||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||
},
|
||
)
|
||
|
||
|
||
def collection_dicom_json(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
return JsonResponse(collection.get_ohif_dicom_json())
|
||
|
||
|
||
@user_is_author_or_atlas_editor
|
||
def use_dates_as_descriptions(request, pk):
|
||
case = get_object_or_404(Case, pk=pk)
|
||
|
||
series: Series
|
||
for series in case.series.all():
|
||
series.use_date_as_description()
|
||
|
||
return HttpResponse("Done")
|
||
|
||
def collection_case_dicom_json_review(request, exam_id, case_number):
|
||
return collection_case_dicom_json(request, exam_id, case_number, review=True)
|
||
|
||
|
||
def collection_case_dicom_json(request, exam_id, case_number, review=False):
|
||
# Support either case_number (index) or case_id (case PK)
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
try:
|
||
case_obj = collection.get_case_by_index(int(case_number))
|
||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||
except Exception:
|
||
raise Http404("Case not found in collection")
|
||
|
||
if review:
|
||
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
|
||
else:
|
||
priors = casedetail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"])
|
||
|
||
return JsonResponse(casedetail.case.get_case_dicom_json(priors=priors))
|
||
|
||
|
||
def case_dicom_json(request, pk):
|
||
case = get_object_or_404(Case, pk=pk)
|
||
|
||
return JsonResponse(case.get_case_dicom_json())
|
||
|
||
|
||
@login_required
|
||
def resource_search_widget(request):
|
||
"""HTMX endpoint: search existing `Resource` objects with simple filters.
|
||
|
||
GET params:
|
||
- q: query string (name/description)
|
||
- site: site id (optional)
|
||
- subspecialty: subspecialty id (optional)
|
||
- open_access: '1' or '0' (optional)
|
||
|
||
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
|
||
"""
|
||
|
||
q = request.GET.get("q", "").strip()
|
||
site = request.GET.get("site")
|
||
subspecialty = request.GET.get("subspecialty")
|
||
open_access = request.GET.get("open_access")
|
||
|
||
qs = Resource.objects.all()
|
||
if q:
|
||
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q))
|
||
if site:
|
||
try:
|
||
qs = qs.filter(sites__id=int(site))
|
||
except Exception:
|
||
pass
|
||
if subspecialty:
|
||
try:
|
||
qs = qs.filter(subspecialty__id=int(subspecialty))
|
||
except Exception:
|
||
pass
|
||
if open_access in ["0", "1"]:
|
||
qs = qs.filter(open_access=(open_access == "1"))
|
||
|
||
# Restrict to what the user may see: open_access or authored (unless editor)
|
||
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
|
||
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()
|
||
|
||
results = qs.order_by("name")[:50]
|
||
|
||
field = request.GET.get("field", "resource_search")
|
||
case_id = request.GET.get("case_id")
|
||
return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id})
|
||
|
||
|
||
@login_required
|
||
def resource_search_panel(request):
|
||
"""HTMX endpoint: return the full resource search panel HTML fragment.
|
||
|
||
Expects optional GET param `case_id` to prefill the hidden field.
|
||
"""
|
||
case_id = request.GET.get("case_id")
|
||
return render(request, "atlas/partials/resource_search_panel.html", {
|
||
"case": (Case.objects.filter(pk=case_id).first() if case_id else None),
|
||
"site_queryset": Site.objects.all(),
|
||
"subspecialty_queryset": Subspecialty.objects.all(),
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def case_attach_resource(request, pk):
|
||
"""Attach an existing resource to the case (HTMX POST).
|
||
|
||
POST params:
|
||
- resource_id: integer
|
||
- pre_review: '1' or '0' (optional)
|
||
|
||
Returns the updated `atlas/partials/case_resources_list.html` fragment.
|
||
"""
|
||
case = get_case_for_case_detail(pk)
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse(status=403)
|
||
|
||
resource_id = request.POST.get("resource_id")
|
||
pre_review = request.POST.get("pre_review", "0") == "1"
|
||
try:
|
||
resource = Resource.objects.get(pk=int(resource_id))
|
||
except Exception:
|
||
return HttpResponse(status=400)
|
||
|
||
# create if not exists
|
||
cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review})
|
||
if not created:
|
||
# update flag
|
||
cr.pre_review = pre_review
|
||
cr.save()
|
||
# Refresh case from DB to ensure related sets reflect changes and render updated list
|
||
try:
|
||
case = Case.objects.get(pk=case.pk)
|
||
except Exception:
|
||
pass
|
||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||
resp = HttpResponse(html)
|
||
# Notify any HTMX listeners that the case resources have changed
|
||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||
return resp
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def case_detach_resource(request, pk):
|
||
case = get_case_for_case_detail(pk)
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse(status=403)
|
||
resource_id = request.POST.get("resource_id")
|
||
try:
|
||
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
|
||
cr.delete()
|
||
except Exception:
|
||
pass
|
||
# Refresh case from DB and return updated partial
|
||
try:
|
||
case = Case.objects.get(pk=case.pk)
|
||
except Exception:
|
||
pass
|
||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||
resp = HttpResponse(html)
|
||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||
return resp
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def case_toggle_resource_pre(request, pk):
|
||
"""Toggle the pre_review flag on a CaseResource (HTMX POST).
|
||
|
||
POST params:
|
||
- resource_id: integer
|
||
|
||
Returns the updated `atlas/partials/case_resources_list.html` fragment.
|
||
"""
|
||
case = get_case_for_case_detail(pk)
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse(status=403)
|
||
|
||
resource_id = request.POST.get("resource_id")
|
||
try:
|
||
cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
|
||
except Exception:
|
||
return HttpResponse(status=400)
|
||
|
||
cr.pre_review = not bool(cr.pre_review)
|
||
cr.save()
|
||
|
||
# Refresh case from DB to ensure related sets reflect changes and render updated list
|
||
try:
|
||
case = Case.objects.get(pk=case.pk)
|
||
except Exception:
|
||
pass
|
||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||
resp = HttpResponse(html)
|
||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||
return resp
|
||
|
||
|
||
def series_dicom_json(request, pk):
|
||
series = get_object_or_404(Series, pk=pk)
|
||
|
||
return JsonResponse(series.get_ohif_dicom_json())
|
||
|
||
|
||
def series_tags_partial(request, pk):
|
||
logger.info(f"Fetching tags for series {pk}")
|
||
# Accept either a numeric PK or a SeriesInstanceUID string
|
||
series = None
|
||
try:
|
||
if str(pk).isdigit():
|
||
series = Series.objects.filter(pk=int(pk)).first()
|
||
except Exception:
|
||
series = None
|
||
|
||
if series is None:
|
||
# Try exact match first
|
||
series = Series.objects.filter(series_instance_uid=pk).first()
|
||
|
||
if series is None:
|
||
# Try URL-decoded value and stripped variants
|
||
try:
|
||
import urllib.parse
|
||
|
||
decoded = urllib.parse.unquote(pk)
|
||
except Exception:
|
||
decoded = pk
|
||
|
||
if decoded != pk:
|
||
series = Series.objects.filter(series_instance_uid=decoded).first()
|
||
|
||
if series is None:
|
||
# Fallback to contains (be permissive for slightly mangled UIDs)
|
||
try:
|
||
series = Series.objects.filter(series_instance_uid__contains=pk).first()
|
||
except Exception:
|
||
series = None
|
||
|
||
images = None
|
||
if series is not None:
|
||
images = list(series.images.all())
|
||
else:
|
||
# If no Series exists yet, try to find uploaded uncategorised dicoms
|
||
try:
|
||
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=pk)
|
||
except Exception:
|
||
dicoms = UncategorisedDicom.objects.none()
|
||
|
||
if not dicoms.exists():
|
||
try:
|
||
import urllib.parse
|
||
|
||
decoded = urllib.parse.unquote(pk)
|
||
except Exception:
|
||
decoded = pk
|
||
|
||
if decoded != pk:
|
||
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=decoded)
|
||
|
||
if not dicoms.exists():
|
||
try:
|
||
dicoms = UncategorisedDicom.objects.filter(series_instance_uid__contains=pk)
|
||
except Exception:
|
||
dicoms = UncategorisedDicom.objects.none()
|
||
|
||
if not dicoms.exists():
|
||
return HttpResponse(status=404)
|
||
|
||
images = list(dicoms)
|
||
num_images = len(images)
|
||
|
||
tag_values = {}
|
||
tag_counts = {}
|
||
|
||
for img in images:
|
||
# Uploaded uncategorised dicoms use a different API than SeriesImage
|
||
try:
|
||
if isinstance(img, UncategorisedDicom):
|
||
try:
|
||
ds = pydicom.dcmread(img.image)
|
||
except Exception:
|
||
# skip invalid dicoms
|
||
continue
|
||
else:
|
||
ds = img.get_dicom_data()
|
||
except Exception:
|
||
# Defensive: skip items that don't expose expected methods
|
||
continue
|
||
if not ds:
|
||
continue
|
||
# Iterate elements
|
||
for elem in ds:
|
||
kw = getattr(elem, "keyword", None) or str(elem.tag)
|
||
try:
|
||
val = elem.value
|
||
except Exception:
|
||
val = None
|
||
if isinstance(val, pydicom.multival.MultiValue):
|
||
sval = ",".join(map(str, val))
|
||
else:
|
||
sval = str(val) if val is not None else ""
|
||
|
||
tag_counts.setdefault(kw, 0)
|
||
tag_counts[kw] += 1
|
||
tag_values.setdefault(kw, set()).add(sval)
|
||
|
||
common_tags = {}
|
||
for k, vals in tag_values.items():
|
||
if tag_counts.get(k, 0) == num_images and len(vals) == 1:
|
||
common_tags[k] = list(vals)[0]
|
||
|
||
html = render_to_string("atlas/partials/series_tags.html", {"common_tags": common_tags}, request=request)
|
||
return HttpResponse(html)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def delete_collection_cid_answers(request, exam_id, cid):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
case__in=cases, collection=collection
|
||
).prefetch_related("case")
|
||
|
||
cid_user_answers = CidReportAnswer.objects.filter(
|
||
question__in=casedetails, cid=cid
|
||
)
|
||
|
||
temp = {}
|
||
for ans in cid_user_answers:
|
||
temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
|
||
ans.delete()
|
||
|
||
return JsonResponse(temp)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def delete_collection_answers(request, exam_id):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
case__in=cases, collection=collection
|
||
).prefetch_related("case")
|
||
|
||
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
|
||
|
||
temp = {}
|
||
for ans in cid_user_answers:
|
||
temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
|
||
ans.delete()
|
||
|
||
return JsonResponse(temp)
|
||
|
||
|
||
# TODO: this needs a major cleanup
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_scores_cid(request, pk):
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
if not collection.exam_mode:
|
||
raise Http404("Collection not in exam mode")
|
||
|
||
user_answers_and_marks = defaultdict(list)
|
||
user_answers_marks = defaultdict(list)
|
||
user_answers = defaultdict(list)
|
||
user_names = {}
|
||
# cid_passcodes = {}
|
||
|
||
by_question = defaultdict(dict)
|
||
unmarked = set()
|
||
|
||
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
|
||
|
||
casedetails = CaseDetail.objects.filter(
|
||
case__in=cases, collection=collection
|
||
).prefetch_related("case")
|
||
|
||
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
|
||
|
||
cids = set()
|
||
|
||
case_list = list(cases)
|
||
|
||
# Loop through all candidates
|
||
for cid_user_answer in cid_user_answers:
|
||
# Convoluted (probably...)
|
||
cid = cid_user_answer.cid
|
||
# cid_passcodes[cid] = cid_user_answer.passcode
|
||
cids.add(cid)
|
||
s = cid_user_answer
|
||
user_names[cid] = cid
|
||
|
||
q = cid_user_answer.question.case
|
||
|
||
# if not s:
|
||
# # skip if no answer
|
||
# user_answers_marks[cid].append(0)
|
||
# user_answers[cid].append("")
|
||
# by_question[q].append(("", 0))
|
||
# continue
|
||
|
||
ans = s.answer
|
||
|
||
answer_score = s.get_answer_score()
|
||
if answer_score == "unmarked" or answer_score == None:
|
||
index = case_list.index(q)
|
||
unmarked.add(index)
|
||
user_answers[cid].append(ans)
|
||
user_answers_marks[cid].append(answer_score)
|
||
user_answers_and_marks[cid].append((ans, answer_score))
|
||
|
||
by_question[q][cid] = (ans, answer_score, s.feedback)
|
||
|
||
user_scores = {}
|
||
user_scores_normalised = {}
|
||
for user in user_answers_marks:
|
||
user_scores[user] = sum(
|
||
[i for i in user_answers_marks[user] if (i != "unmarked" and i != None)]
|
||
)
|
||
|
||
user_scores_normalised[user] = user_scores[user]
|
||
|
||
# ignore scores of 0 for stats
|
||
user_scores_list = [i for i in user_scores.values() if i > 0]
|
||
|
||
# Use collection's case_question_max_score if set, otherwise default to 10
|
||
case_max = collection.case_question_max_score or 10
|
||
max_score = len(cases) * case_max
|
||
|
||
if len(user_scores_list) < 1:
|
||
mean = 0
|
||
median = 0
|
||
mode = 0
|
||
fig_html = ""
|
||
else:
|
||
mean = statistics.mean(user_scores_list)
|
||
median = statistics.median(user_scores_list)
|
||
try:
|
||
mode = statistics.mode(user_scores_list)
|
||
except statistics.StatisticsError:
|
||
mode = "No unique mode"
|
||
|
||
df = user_scores_list
|
||
fig = px.histogram(
|
||
df,
|
||
x=0,
|
||
title="{}: distribution of scores".format(collection),
|
||
labels={"0": "Score"},
|
||
height=400,
|
||
width=600,
|
||
)
|
||
fig_html = fig.to_html()
|
||
|
||
collection.stats_mean = mean
|
||
collection.stats_median = median
|
||
collection.stats_mode = mode
|
||
|
||
collection.stats_candidates = len(user_scores_list)
|
||
collection.stats_max_possible = max_score
|
||
|
||
collection.stats_min = min(user_scores_list)
|
||
collection.stats_max = max(user_scores_list)
|
||
|
||
# collection.stats_graph = fig_html
|
||
|
||
collection.user_scores = user_scores
|
||
|
||
collection.save()
|
||
|
||
return render(
|
||
request,
|
||
f"atlas/collection_scores.html",
|
||
{
|
||
"cids": sorted(cids),
|
||
# "cid_passcodes": cid_passcodes,
|
||
"exam": collection,
|
||
"collection": collection,
|
||
"unmarked": unmarked,
|
||
"questions": cases,
|
||
"by_question": by_question,
|
||
"user_answers": dict(user_answers),
|
||
"user_answers_marks": dict(user_answers_marks),
|
||
"user_scores": user_scores,
|
||
"user_scores_normalised": user_scores_normalised,
|
||
"user_scores_list": user_scores_list,
|
||
"user_names": user_names,
|
||
"user_answers_and_marks": user_answers_and_marks,
|
||
"max_score": max_score,
|
||
"mean": mean,
|
||
"median": median,
|
||
"mode": mode,
|
||
"plot": fig_html,
|
||
},
|
||
)
|
||
|
||
|
||
GenericExamViews = ExamViews(
|
||
CaseCollection, Case, None, CidReportAnswer, "atlas", "casecollection"
|
||
)
|
||
|
||
|
||
class CollectionCloneMixin:
|
||
def get_initial(self):
|
||
old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
|
||
initial_data = model_to_dict(old_object, exclude=["id"])
|
||
|
||
# We manually transfer the forign keys / m2m relationships
|
||
questions = old_object.cases.all().values_list("id", flat=True)
|
||
authors = old_object.author.all().values_list("id", flat=True)
|
||
|
||
self.cases = list(questions)
|
||
self.author = list(authors)
|
||
|
||
return initial_data
|
||
|
||
def form_valid(self, form):
|
||
object = form.save()
|
||
# Reapply these otherwise they get lost?
|
||
object.cases.set(self.cases)
|
||
object.author.set(self.author)
|
||
object.save()
|
||
return HttpResponseRedirect(object.get_absolute_url())
|
||
|
||
|
||
class CollectionClone(CollectionCloneMixin, CaseCollectionCreate):
|
||
"""Clone exam view"""
|
||
|
||
|
||
class SelfReviewDelete(DeleteView):
|
||
"""Delete a user's self-review entry for a specific case attempt.
|
||
|
||
The view resolves ``user_exam``, ``case`` and ``review`` from URL params and,
|
||
on success, returns the learner to the case take page.
|
||
"""
|
||
model = SelfReview
|
||
template_name = "confirm_delete.html"
|
||
# success_url = reverse_lazy("atlas:finding_view")
|
||
|
||
def get_initial(self):
|
||
user_exam_id = self.kwargs["user_exam_id"]
|
||
case_id = self.kwargs["case_id"]
|
||
review_id = self.kwargs["pk"]
|
||
|
||
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
self.case = get_object_or_404(Case, pk=case_id)
|
||
self.review = get_object_or_404(SelfReview, pk=review_id)
|
||
return {"user_exam": user_exam_id, "case": case_id, "review": review_id}
|
||
|
||
def get_context_data(self, **kwargs):
|
||
# This can also be accessed via view.**** in the template
|
||
context = super(SelfReviewDelete, self).get_context_data(**kwargs)
|
||
context["user_exam"] = self.user_exam
|
||
context["case"] = self.case
|
||
context["review"] = self.review
|
||
return context
|
||
|
||
def get_success_url(self) -> str:
|
||
return self.user_exam.exam.get_case_take_url(self.case)
|
||
|
||
|
||
class SelfReviewDetail(DetailView):
|
||
"""Read-only self-review view for a specific user exam and case.
|
||
|
||
This is the default viewing mode for self reviews. Add ``?embed=1`` to
|
||
render the compact sidebar-friendly template.
|
||
"""
|
||
|
||
model = SelfReview
|
||
|
||
def get_template_names(self):
|
||
if self.request.GET.get("embed") == "1":
|
||
return ["atlas/partials/_selfreview_detail_embed.html"]
|
||
return ["atlas/selfreview_detail.html"]
|
||
|
||
def get_object(self, queryset=None):
|
||
return get_object_or_404(
|
||
SelfReview,
|
||
pk=self.kwargs["pk"],
|
||
user_exam_id=self.kwargs["user_exam_id"],
|
||
case_id=self.kwargs["case_id"],
|
||
)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(SelfReviewDetail, self).get_context_data(**kwargs)
|
||
context["review"] = self.object
|
||
context["user_exam"] = self.object.user_exam
|
||
context["case"] = self.object.case
|
||
context["embed"] = self.request.GET.get("embed") == "1"
|
||
return context
|
||
|
||
|
||
class SelfReviewUpdate(UpdateView):
|
||
"""Edit an existing self-review entry for a case attempt.
|
||
|
||
Uses ``SelfReviewForm`` and redirects back to the case take page after save.
|
||
"""
|
||
model = SelfReview
|
||
form_class = SelfReviewForm
|
||
|
||
def get_template_names(self):
|
||
if self.request.GET.get("embed") == "1":
|
||
return ["atlas/partials/_selfreview_form_embed.html"]
|
||
return ["atlas/selfreview_form.html"]
|
||
|
||
def get_initial(self):
|
||
user_exam_id = self.kwargs["user_exam_id"]
|
||
case_id = self.kwargs["case_id"]
|
||
review_id = self.kwargs["pk"]
|
||
|
||
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
self.case = get_object_or_404(Case, pk=case_id)
|
||
self.review = get_object_or_404(SelfReview, pk=review_id)
|
||
return {"user_exam": user_exam_id, "case": case_id, "review": review_id}
|
||
|
||
def get_context_data(self, **kwargs):
|
||
# This can also be accessed via view.**** in the template
|
||
context = super(SelfReviewUpdate, self).get_context_data(**kwargs)
|
||
context["user_exam"] = self.user_exam
|
||
context["case"] = self.case
|
||
context["review"] = self.review
|
||
context["embed"] = self.request.GET.get("embed") == "1"
|
||
context["popup"] = self.request.GET.get("popup") == "1"
|
||
return context
|
||
|
||
def get_success_url(self) -> str:
|
||
if getattr(self, "object", None) is not None:
|
||
return self.object.user_exam.exam.get_case_take_url(self.object.case)
|
||
|
||
user_exam_id = self.kwargs["user_exam_id"]
|
||
case_id = self.kwargs["case_id"]
|
||
user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
return user_exam.exam.get_case_take_url(case)
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save()
|
||
|
||
if self.request.GET.get("popup") == "1":
|
||
return render(
|
||
self.request,
|
||
"atlas/partials/_selfreview_popup_success.html",
|
||
{"target_url": self.get_success_url()},
|
||
)
|
||
|
||
return HttpResponseRedirect(self.get_success_url())
|
||
|
||
# def get_object(self):
|
||
# return self.review
|
||
# return
|
||
# def get_initial(self):
|
||
# user_exam_id = self.kwargs["user_exam_id"]
|
||
# case_id = self.kwargs["case_id"]
|
||
|
||
# self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
# self.case = get_object_or_404(Case, pk=case_id)
|
||
# return {"user_exam": user_exam_id, "case": case_id}
|
||
|
||
|
||
class AddSelfReview(CreateView):
|
||
"""Create a new self-review entry linked to a user exam and case.
|
||
|
||
The form captures free-text comments and 1-5 self-assessment scores for
|
||
findings and interpretation, then returns the learner to the case take page.
|
||
"""
|
||
model = SelfReview
|
||
form_class = SelfReviewForm
|
||
|
||
def get_template_names(self):
|
||
if self.request.GET.get("embed") == "1":
|
||
return ["atlas/partials/_selfreview_form_embed.html"]
|
||
return ["atlas/selfreview_form.html"]
|
||
|
||
# fields = ("author", "note_type", "note")
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super(AddSelfReview, self).get_form_kwargs()
|
||
# update the kwargs for the form init method with yours
|
||
kwargs.update(self.kwargs) # self.kwargs contains all url conf params
|
||
# kwargs.update({"user": self.request.user})
|
||
return kwargs
|
||
|
||
def get_initial(self):
|
||
user_exam_id = self.kwargs["user_exam_id"]
|
||
case_id = self.kwargs["case_id"]
|
||
|
||
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
self.case = get_object_or_404(Case, pk=case_id)
|
||
return {"user_exam": user_exam_id, "case": case_id}
|
||
|
||
def get_context_data(self, **kwargs):
|
||
# This can also be accessed via view.**** in the template
|
||
context = super(AddSelfReview, self).get_context_data(**kwargs)
|
||
context["user_exam"] = self.user_exam
|
||
context["case"] = self.case
|
||
context["embed"] = self.request.GET.get("embed") == "1"
|
||
context["popup"] = self.request.GET.get("popup") == "1"
|
||
return context
|
||
|
||
def get_success_url(self) -> str:
|
||
if getattr(self, "object", None) is not None:
|
||
return self.object.user_exam.exam.get_case_take_url(self.object.case)
|
||
|
||
user_exam_id = self.kwargs["user_exam_id"]
|
||
case_id = self.kwargs["case_id"]
|
||
user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
return user_exam.exam.get_case_take_url(case)
|
||
|
||
def form_valid(self, form):
|
||
self.object = form.save()
|
||
|
||
if self.request.GET.get("popup") == "1":
|
||
return render(
|
||
self.request,
|
||
"atlas/partials/_selfreview_popup_success.html",
|
||
{"target_url": self.get_success_url()},
|
||
)
|
||
|
||
return HttpResponseRedirect(self.get_success_url())
|
||
# return reverse("atlas:collection_take_overview_user", kwargs={"pk": self.user_exam.collection.id })
|
||
|
||
|
||
# def form_valid(self, form):
|
||
# model = form.save(commit=False)
|
||
#
|
||
# model.save()
|
||
#
|
||
# response = super().form_valid(form)
|
||
#
|
||
# return response
|
||
|
||
|
||
@login_required
|
||
def uncategorised_dicoms(request):
|
||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||
|
||
data = []
|
||
for d in dicoms:
|
||
data.append((d.image.name, d.get_basic_dicom_tags()))
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
@login_required
|
||
def combine_series(request):
|
||
"""Combines a list of series into the first series in the list
|
||
|
||
NOTE: this will only migrate images
|
||
it will not migrate other models such as SeriesFinding or other details
|
||
"""
|
||
series_ids = request.POST.getlist("series-ids")
|
||
# series = get_object_or_404(Series, pk=series_id)
|
||
if len(series_ids) < 2:
|
||
return HttpResponse("Not enough series to combine")
|
||
|
||
base_series = None
|
||
series_to_add = []
|
||
|
||
for n, series_id in enumerate(series_ids):
|
||
series = get_object_or_404(Series, pk=series_id)
|
||
if not series.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit one of the series")
|
||
|
||
if n == 0:
|
||
base_series = series
|
||
else:
|
||
series_to_add.append(series)
|
||
|
||
for series in series_to_add:
|
||
for image in series.images.all():
|
||
image.series = base_series
|
||
image.save()
|
||
|
||
series.delete()
|
||
|
||
return HttpResponse(f"Series {series_ids} combines")
|
||
# return HttpResponse("Fail")
|
||
|
||
|
||
class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
|
||
model = CaseCollection
|
||
form_class = CaseCollectionAuthorForm
|
||
template_name = "author_form.html"
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(CaseCollectionAuthorUpdate, self).get_context_data(**kwargs)
|
||
context["collection"] = context["object"]
|
||
return context
|
||
|
||
|
||
class ExamGroupsUpdate(ExamGroupsUpdateBase):
|
||
model = CaseCollection
|
||
form_class = ExamGroupsForm
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(ExamGroupsUpdate, self).get_context_data(**kwargs)
|
||
context["exam"] = context["object"]
|
||
return context
|
||
|
||
|
||
class CaseAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
|
||
model = Case
|
||
form_class = CaseAuthorForm
|
||
template_name = "author_form.html"
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(CaseAuthorUpdate, self).get_context_data(**kwargs)
|
||
context["collection"] = context["object"]
|
||
return context
|
||
|
||
|
||
class SeriesAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
|
||
model = Series
|
||
form_class = SeriesAuthorForm
|
||
template_name = "author_form.html"
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super(SeriesAuthorUpdate, self).get_context_data(**kwargs)
|
||
context["collection"] = context["object"]
|
||
return context
|
||
|
||
|
||
def question_schemas_preset(request):
|
||
schemas = [
|
||
json.dumps(
|
||
{
|
||
"type": "object",
|
||
"title": "Questions",
|
||
"required": ["laterality", "diagnosis"],
|
||
"properties": {
|
||
"test": {
|
||
"type": "string",
|
||
"title": "test string",
|
||
"description": "What side is the lesion on?",
|
||
},
|
||
"diagnosis": {
|
||
"enum": [
|
||
"Meningioma",
|
||
"Schwannoma",
|
||
"Neurofibroma",
|
||
"Ependymoma",
|
||
],
|
||
"type": "string",
|
||
"title": "Diagnosis",
|
||
"description": "What is the diagnosis?",
|
||
},
|
||
"laterality": {
|
||
"enum": ["left", "right"],
|
||
"type": "string",
|
||
"title": "Laterality",
|
||
"widget": "radio",
|
||
"description": "What side is the lesion on?",
|
||
},
|
||
"laterality (copy 2)": {
|
||
"enum": ["left", "right"],
|
||
"type": "string",
|
||
"title": "Lateralityaoue",
|
||
"widget": "radio",
|
||
"description": "What side is the lesion on?",
|
||
},
|
||
},
|
||
}
|
||
),
|
||
]
|
||
|
||
return render(
|
||
request,
|
||
"atlas/question_schemas_preset.html",
|
||
{
|
||
"schemas": schemas,
|
||
},
|
||
)
|
||
|
||
|
||
def collection_question_schemas(request, exam_id: int):
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
questions = collection.cases.all().prefetch_related()
|
||
|
||
casedetails = (
|
||
CaseDetail.objects.filter(case__in=questions, collection=collection)
|
||
.prefetch_related("case")
|
||
.order_by("sort_order")
|
||
)
|
||
|
||
return render(
|
||
request,
|
||
"atlas/collection_question_schemas.html",
|
||
{
|
||
"collection": collection,
|
||
"casedetails": casedetails,
|
||
},
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_apply_question_template(request, pk: int):
|
||
"""Apply the collection's case_question_template to all CaseDetail entries.
|
||
|
||
Scope: Collection author / atlas_editor. HTMX endpoint.
|
||
Accepts optional 'overwrite' query param to overwrite existing schemas.
|
||
"""
|
||
if not request.htmx:
|
||
raise Http404("Invalid request")
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
if not collection.case_question_template:
|
||
return HttpResponse(
|
||
'<div class="alert alert-warning">No question template set on this collection.</div>'
|
||
)
|
||
|
||
overwrite = request.GET.get("overwrite", "false").lower() == "true"
|
||
updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite)
|
||
|
||
return HttpResponse(
|
||
f'<div class="alert alert-success">Applied question template to {updated_count} case(s).</div>'
|
||
)
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_sync_prerequisite_users(request, pk: int):
|
||
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
|
||
|
||
This endpoint is intended to be called via HTMX from the collection detail page.
|
||
"""
|
||
if not request.htmx:
|
||
raise Http404("Invalid request")
|
||
|
||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||
|
||
# Collect the closure of collections: the target collection and all prerequisites (recursively)
|
||
to_visit = [collection]
|
||
visited = {}
|
||
while to_visit:
|
||
col = to_visit.pop()
|
||
if col.pk in visited:
|
||
continue
|
||
visited[col.pk] = col
|
||
for p in col.prerequisites.all():
|
||
if p.pk not in visited:
|
||
to_visit.append(p)
|
||
|
||
collections = list(visited.values())
|
||
|
||
# Compute the union of all cid users and user users across the set
|
||
union_cid_users = set()
|
||
union_user_users = set()
|
||
union_cid_groups = set()
|
||
union_user_groups = set()
|
||
for col in collections:
|
||
union_cid_users.update(list(col.valid_cid_users.all()))
|
||
union_user_users.update(list(col.valid_user_users.all()))
|
||
union_cid_groups.update(list(col.cid_user_groups.all()))
|
||
union_user_groups.update(list(col.user_user_groups.all()))
|
||
|
||
# Apply the union to each collection and count additions
|
||
total_added_cid = 0
|
||
total_added_user = 0
|
||
total_added_cid_groups = 0
|
||
total_added_user_groups = 0
|
||
per_collection_added = []
|
||
for col in collections:
|
||
added_cid = 0
|
||
added_user = 0
|
||
added_cid_groups = 0
|
||
added_user_groups = 0
|
||
for cid_user in union_cid_users:
|
||
if not col.valid_cid_users.filter(pk=cid_user.pk).exists():
|
||
col.valid_cid_users.add(cid_user)
|
||
added_cid += 1
|
||
for user in union_user_users:
|
||
if not col.valid_user_users.filter(pk=user.pk).exists():
|
||
col.valid_user_users.add(user)
|
||
added_user += 1
|
||
# Sync groups as well
|
||
for gid in union_cid_groups:
|
||
if not col.cid_user_groups.filter(pk=gid.pk).exists():
|
||
col.cid_user_groups.add(gid)
|
||
added_cid_groups += 1
|
||
for ug in union_user_groups:
|
||
if not col.user_user_groups.filter(pk=ug.pk).exists():
|
||
col.user_user_groups.add(ug)
|
||
added_user_groups += 1
|
||
added_user += added_user_groups
|
||
added_cid += added_cid_groups
|
||
if added_cid or added_user:
|
||
col.save()
|
||
total_added_cid += added_cid
|
||
total_added_user += added_user
|
||
total_added_cid_groups += added_cid_groups
|
||
total_added_user_groups += added_user_groups
|
||
per_collection_added.append((col.pk, added_cid, added_user, added_cid_groups, added_user_groups))
|
||
|
||
html = render_to_string(
|
||
"atlas/partials/sync_prereq_result.html",
|
||
{
|
||
"total_added_cid": total_added_cid,
|
||
"total_added_user": total_added_user,
|
||
"total_added_cid_groups": total_added_cid_groups,
|
||
"total_added_user_groups": total_added_user_groups,
|
||
"per_collection_added": per_collection_added,
|
||
},
|
||
request=request,
|
||
)
|
||
|
||
return HttpResponse(html)
|
||
|
||
|
||
def collection_reset_answers_user_list(request, exam_id: int):
|
||
if request.htmx:
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
cid_users = collection.cid_users.all()
|
||
|
||
if cid_users:
|
||
return render(
|
||
request,
|
||
"atlas/collection_reset_answers_user_list.html",
|
||
{"collection": collection, "cid_users": cid_users},
|
||
)
|
||
else:
|
||
return HttpResponse("No user answers.")
|
||
|
||
else:
|
||
raise Http404("Invalid request")
|
||
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||
if request.htmx:
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
# Select all case details (answers are linked to these)
|
||
casedetails = collection.casedetail_set.all().prefetch_related()
|
||
|
||
# Delete all answers
|
||
for case in casedetails:
|
||
user_answers = case.userreportanswer_set.filter(user_id=user_id)
|
||
user_answers.delete()
|
||
|
||
collection.cid_users.filter(user_user_id=user_id).delete()
|
||
collection.exam_user_status.filter(cid_user_exam__user_user_id=user_id).delete()
|
||
|
||
return HttpResponse(f"User [{user_id}] answers deleted")
|
||
|
||
else:
|
||
raise Http404("Invalid request")
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
|
||
if request.htmx:
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
# Select all case details (answers are linked to these)
|
||
casedetails = collection.casedetail_set.all().prefetch_related()
|
||
|
||
# Delete all answers
|
||
for case in casedetails:
|
||
cid_answers = case.cidreportanswer_set.filter(cid=cid)
|
||
cid_answers.delete()
|
||
|
||
collection.cid_users.filter(cid=cid).delete()
|
||
collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
|
||
|
||
return HttpResponse(f"CID [{cid}] answers deleted")
|
||
|
||
else:
|
||
raise Http404("Invalid request")
|
||
|
||
|
||
def collection_reset_answers(request, exam_id: int):
|
||
if request.htmx:
|
||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||
|
||
# Select all case details (answers are linked to these)
|
||
casedetails = collection.casedetail_set.all().prefetch_related()
|
||
|
||
# Delete all answers
|
||
for case in casedetails:
|
||
cid_answers = case.cidreportanswer_set.all()
|
||
|
||
cid_answers.delete()
|
||
user_answers = case.userreportanswer_set.all()
|
||
user_answers.delete()
|
||
|
||
# User statuses
|
||
collection.exam_user_status.all().delete()
|
||
|
||
# CidUserExams
|
||
collection.cid_users.all().delete()
|
||
return HttpResponse("Success")
|
||
|
||
else:
|
||
raise Http404("Invalid request")
|
||
|
||
|
||
def push_case_to_cimar(request, case_id):
|
||
push_case_to_cimar_task.delay(case_id=case_id)
|
||
|
||
return HttpResponse("Push started")
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
|
||
api = CimarAPI()
|
||
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
|
||
|
||
# We use the same study_uid for all images in a case
|
||
study_uid = generate_uid()
|
||
|
||
for series in case.get_series():
|
||
for image in series.images.all():
|
||
data = api.upload_dicom(image.image.path, study_uid=study_uid)
|
||
|
||
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
|
||
|
||
case.cimar_uuid = cimar_uuid
|
||
|
||
case.save()
|
||
|
||
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
|
||
|
||
cimar_case.refresh_study()
|
||
|
||
return HttpResponse("Success")
|
||
|
||
|
||
@require_POST
|
||
def remove_case_from_collection(request, case_pk, collection_pk):
|
||
case = get_object_or_404(Case, pk=case_pk)
|
||
collection = get_object_or_404(CaseCollection, pk=collection_pk)
|
||
|
||
# Remove the case from the collection
|
||
collection.cases.remove(case)
|
||
|
||
# If this was an HTMX request, return an empty response so the caller
|
||
# can swap out (remove) the surrounding element (e.g., the <li>).
|
||
if request.htmx:
|
||
return HttpResponse("")
|
||
|
||
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||
|
||
|
||
@require_POST
|
||
def remove_selected_series_from_case(request, case_pk):
|
||
ids = request.POST.getlist('series-ids')
|
||
case = Case.objects.get(pk=case_pk)
|
||
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||
|
||
|
||
if not ids:
|
||
return HttpResponse("No series selected for removal.")
|
||
|
||
# Remove the association (for a through model)
|
||
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
|
||
return HttpResponse(f"Series removed from case.")
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def move_selected_series_to_case(request, case_pk):
|
||
source_case = get_object_or_404(Case, pk=case_pk)
|
||
|
||
if not source_case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||
|
||
form = MoveSelectedSeriesForm(request.POST, user=request.user, source_case=source_case)
|
||
if not form.is_valid():
|
||
return HttpResponse("Please choose a destination case.", status=400)
|
||
|
||
target_case = form.cleaned_data["destination_case"]
|
||
if not target_case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit the destination case.", status=403)
|
||
|
||
ids = request.POST.getlist("series-ids")
|
||
if not ids:
|
||
return HttpResponse("No series selected to move.")
|
||
|
||
series_to_move = list(
|
||
Series.objects.filter(pk__in=ids, case=source_case).distinct()
|
||
)
|
||
if not series_to_move:
|
||
return HttpResponse("No valid series selected to move.", status=400)
|
||
|
||
for series in series_to_move:
|
||
if not series.check_user_can_edit(request.user):
|
||
return HttpResponse(
|
||
"You do not have permission to edit one or more selected series.",
|
||
status=403,
|
||
)
|
||
|
||
next_sort_order = (
|
||
SeriesDetail.objects.filter(case=target_case).aggregate(max_sort=Max("sort_order"))["max_sort"] or -1
|
||
) + 1
|
||
|
||
added_to_target = 0
|
||
for series in series_to_move:
|
||
link_exists = SeriesDetail.objects.filter(case=target_case, series=series).exists()
|
||
if not link_exists:
|
||
SeriesDetail.objects.create(
|
||
case=target_case,
|
||
series=series,
|
||
sort_order=next_sort_order,
|
||
)
|
||
next_sort_order += 1
|
||
added_to_target += 1
|
||
|
||
SeriesDetail.objects.filter(case=source_case, series=series).delete()
|
||
|
||
return HttpResponse(
|
||
f"Moved {len(series_to_move)} series to case {target_case.pk}. Added {added_to_target} new links in destination case."
|
||
)
|
||
|
||
@require_POST
|
||
def reorder_series(request, case_pk):
|
||
case = get_object_or_404(Case, pk=case_pk)
|
||
if not case.check_user_can_edit(request.user):
|
||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||
# Get new order from POST
|
||
order = []
|
||
for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
|
||
order.append(int(request.POST[key]))
|
||
# Update sort_order in SeriesDetail
|
||
for idx, series_id in enumerate(order):
|
||
SeriesDetail.objects.filter(case=case, series_id=series_id).update(sort_order=idx)
|
||
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||
|
||
def linked_cases_overview(request, case_id):
|
||
"""
|
||
Display the linked-case chain around a case, including prior and follow-up cases.
|
||
"""
|
||
# Get the current case
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
|
||
# Traverse backward to get all previous cases
|
||
previous_cases = []
|
||
try:
|
||
current_case = case.previous_case
|
||
while current_case:
|
||
previous_cases.insert(0, current_case) # Insert at the beginning to maintain order
|
||
current_case = current_case.previous_case
|
||
except ObjectDoesNotExist:
|
||
pass
|
||
|
||
# Traverse forward to get all next cases
|
||
next_cases = []
|
||
try:
|
||
current_case = case.next_case
|
||
while current_case:
|
||
next_cases.append(current_case)
|
||
current_case = current_case.next_case
|
||
except ObjectDoesNotExist:
|
||
pass
|
||
|
||
chain_cases = previous_cases + [case] + next_cases
|
||
direct_previous = previous_cases[-1] if previous_cases else None
|
||
direct_next = next_cases[0] if next_cases else None
|
||
|
||
# Render the template with the linked cases
|
||
return render(
|
||
request,
|
||
"atlas/linked_cases_overview.html",
|
||
{
|
||
"current_case": case,
|
||
"previous_cases": previous_cases,
|
||
"next_cases": next_cases,
|
||
"chain_cases": chain_cases,
|
||
"chain_total": len(chain_cases),
|
||
"current_index": len(previous_cases) + 1,
|
||
"direct_previous": direct_previous,
|
||
"direct_next": direct_next,
|
||
},
|
||
)
|
||
|
||
@login_required
|
||
def add_series_to_case(request, series_pk):
|
||
if request.method == "POST":
|
||
case_id = request.POST.get("case_id")
|
||
case = get_object_or_404(Case, pk=case_id)
|
||
if not case.check_user_can_edit(request.user):
|
||
raise PermissionDenied("You do not have permission to edit this case.")
|
||
|
||
series = get_object_or_404(Series, pk=series_pk)
|
||
|
||
# Associate the series with the case
|
||
series.case.add(case)
|
||
|
||
# If this is an HTMX request, return the updated cases list partial so the
|
||
# client can replace the #cases-list innerHTML. Otherwise return a simple message.
|
||
if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
|
||
return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)})
|
||
|
||
return HttpResponse(f"Series {series_pk} added to case {case.title} ({case_id}).")
|
||
return HttpResponse("Invalid request.")
|
||
|
||
@login_required
|
||
def remove_series_from_case(request, series_pk, case_pk):
|
||
if request.method == "POST":
|
||
case = get_object_or_404(Case, pk=case_pk)
|
||
if not case.check_user_can_edit(request.user):
|
||
raise PermissionDenied("You do not have permission to edit this case.")
|
||
|
||
series = get_object_or_404(Series, pk=series_pk)
|
||
|
||
# Remove the association
|
||
series.case.remove(case)
|
||
|
||
# If this is an HTMX request, return the updated cases list partial so the
|
||
# client can replace the #cases-list innerHTML. Otherwise return a simple message.
|
||
if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
|
||
return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)})
|
||
|
||
return HttpResponse("Removed")
|
||
return HttpResponse("Invalid request.")
|
||
|
||
@login_required
|
||
def series_finding_details(request, finding_id):
|
||
finding = get_object_or_404(SeriesFinding, pk=finding_id)
|
||
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
|
||
|
||
|
||
@login_required
|
||
def series_finding_preview(request, finding_id):
|
||
"""Return a small HTML fragment summarising a SeriesFinding for popups/modals."""
|
||
finding = get_object_or_404(SeriesFinding, pk=finding_id)
|
||
return render(request, 'atlas/partials/_seriesfinding_preview.html', {'finding': finding})
|
||
|
||
@login_required
|
||
def series_finding_related(request, series_id):
|
||
series = get_object_or_404(Series, pk=series_id)
|
||
findings = series.get_related_findings()
|
||
return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings})
|
||
|
||
def seriesfinding_migration_status(request):
|
||
"""
|
||
View to list all SeriesFinding objects and their migration status.
|
||
Shows whether annotation_json_3d and viewer_state_3d are populated.
|
||
"""
|
||
findings = SeriesFinding.objects.all().select_related()
|
||
findings_status = []
|
||
for finding in findings:
|
||
findings_status.append({
|
||
"id": finding.id,
|
||
"str": str(finding),
|
||
"annotation_json_3d": bool(finding.annotation_json_3d),
|
||
"viewer_state_3d": bool(finding.viewer_state_3d),
|
||
"series": finding.series
|
||
})
|
||
return render(request, "atlas/seriesfinding_migration_status.html", {
|
||
"findings_status": findings_status
|
||
})
|
||
|
||
def series_bulk_delete(request):
|
||
if request.method == "POST":
|
||
ids = request.POST.getlist("series_ids")
|
||
# Only delete series the user has permission to delete
|
||
series_to_delete = Series.objects.filter(pk__in=ids)
|
||
deleted_ids = []
|
||
for series in series_to_delete:
|
||
if series.check_user_can_edit(request.user):
|
||
series.delete()
|
||
deleted_ids.append(series.pk)
|
||
# Optionally, you could add a message here about which were deleted
|
||
return redirect('atlas:series_view')
|
||
|
||
@user_is_collection_author_or_atlas_editor
|
||
def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None):
|
||
"""
|
||
View to set up the display options for a specific case in a collection.
|
||
This can include setting which series to display, annotations, etc.
|
||
"""
|
||
# Accept either case_number (index into collection) or case_id (case PK).
|
||
casedetail = None
|
||
try:
|
||
# If a case_id was passed explicitly, try that first
|
||
if case_id is not None:
|
||
casedetail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id)
|
||
else:
|
||
# Otherwise the route most likely supplied case_number; try resolving by index
|
||
collection = get_object_or_404(CaseCollection, pk=collection_id)
|
||
case_obj = collection.get_case_by_index(int(case_number))
|
||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||
except Http404:
|
||
raise
|
||
except Exception:
|
||
raise Http404("Case not found in collection")
|
||
|
||
if request.method == "POST":
|
||
data = json.loads(request.body.decode("utf-8"))
|
||
if data.get("reset_viewerstate"):
|
||
casedetail.default_viewerstate = None
|
||
casedetail.save()
|
||
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
|
||
viewerstate = data.get("viewerstate")
|
||
if viewerstate is not None:
|
||
casedetail.default_viewerstate = viewerstate
|
||
casedetail.save()
|
||
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
|
||
else:
|
||
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
|
||
|
||
collection = casedetail.collection
|
||
|
||
case_number, case_count = collection.get_index_of_case(
|
||
casedetail.case, case_count=True
|
||
)
|
||
|
||
previous = collection.get_previous_case(casedetail.case)
|
||
next = collection.get_next_case(casedetail.case)
|
||
|
||
return render(request, "atlas/collection_case_displaysetup.html", {
|
||
"casedetail": casedetail,
|
||
"collection": collection,
|
||
"case_number": case_number,
|
||
"case_count": case_count,
|
||
"previous": previous,
|
||
"next": next,
|
||
"collection_length": case_count,
|
||
|
||
})
|