From a51a5c8d54d67ec44dd33e0b88da887cc2b57758 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 16 Feb 2026 14:04:37 +0000 Subject: [PATCH] Add Procedure model and integrate into CaseForm; update views and templates for procedure handling --- atlas/forms.py | 5 +++ .../0093_procedure_case_procedures.py | 30 +++++++++++++++ atlas/models.py | 37 +++++++++++++++++++ atlas/templates/atlas/case_display_block.html | 12 ++++++ atlas/templates/atlas/procedure_detail.html | 15 ++++++++ atlas/urls.py | 8 +++- atlas/views.py | 35 ++++++++++++++++++ 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 atlas/migrations/0093_procedure_case_procedures.py create mode 100644 atlas/templates/atlas/procedure_detail.html diff --git a/atlas/forms.py b/atlas/forms.py index e516468e..830af729 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -617,6 +617,7 @@ class CaseForm(ModelForm): "presentation", "discussion", "report", + "procedures", "condition", "pathological_process", "open_access", @@ -649,6 +650,7 @@ class CaseForm(ModelForm): "presentation", "discussion", "condition", + "procedures", "pathological_process", "report", "open_access", @@ -677,6 +679,9 @@ class CaseForm(ModelForm): "subspecialty": CheckboxSelectMultiple(), "pathological_process": CheckboxSelectMultiple(), "previous_case": CaseSelect(), + "procedures": autocomplete.ModelSelect2Multiple( + url="atlas:procedure-autocomplete" + ), } def clean_cimar_uuid(self): diff --git a/atlas/migrations/0093_procedure_case_procedures.py b/atlas/migrations/0093_procedure_case_procedures.py new file mode 100644 index 00000000..ffd57946 --- /dev/null +++ b/atlas/migrations/0093_procedure_case_procedures.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-16 14:01 + +import atlas.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0092_normalcase_author'), + ] + + operations = [ + migrations.CreateModel( + name='Procedure', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('canonical', models.ForeignKey(blank=True, help_text='If set, this Procedure is an alias and points to the canonical Procedure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.procedure')), + ('subspecialty', models.ManyToManyField(blank=True, to='atlas.subspecialty')), + ], + bases=(atlas.models.SynMixin, models.Model), + ), + migrations.AddField( + model_name='case', + name='procedures', + field=models.ManyToManyField(blank=True, help_text='Procedure(s) or operations the patient has undergone.', to='atlas.procedure'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index e5e129ab..a7fda4ce 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -396,6 +396,35 @@ class PathologicalProcess(models.Model): return format_html("{}", self.get_absolute_url(), self.name) +class Procedure(SynMixin, models.Model): + """A procedure or operation performed on the patient (attachable to Cases). + + This mirrors the lightweight structure used by `Presentation` / `PathologicalProcess`. + """ + name = models.CharField(max_length=255, unique=True) + + # Optional canonical/alias pointer for future synonym handling + canonical = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="aliases", + help_text="If set, this Procedure is an alias and points to the canonical Procedure.", + ) + + subspecialty = models.ManyToManyField("subspecialty", blank=True) + + def __str__(self) -> str: + return self.name + + def get_absolute_url(self): + return reverse("atlas:procedure_detail", kwargs={"pk": self.pk}) + + def get_link(self): + return format_html("{}", self.get_absolute_url(), self.name) + + class Differential(models.Model): condition = models.ForeignKey(Condition, on_delete=models.CASCADE) case = models.ForeignKey( @@ -484,6 +513,13 @@ class Case(models.Model, AuthorMixin, QuestionMixin): help_text="The presentation(s) the case is associated with.", ) + # Procedures performed on the patient (e.g. operations, interventions) + procedures = models.ManyToManyField( + "Procedure", + blank=True, + help_text="Procedure(s) or operations the patient has undergone.", + ) + pathological_process = models.ManyToManyField(PathologicalProcess, blank=True) differential = models.ManyToManyField( @@ -830,6 +866,7 @@ class NormalCase(models.Model, AuthorMixin): notes = models.TextField(null=True, blank=True) + class Meta: ordering = ["-added_date"] diff --git a/atlas/templates/atlas/case_display_block.html b/atlas/templates/atlas/case_display_block.html index c543d2a9..7e844ab4 100755 --- a/atlas/templates/atlas/case_display_block.html +++ b/atlas/templates/atlas/case_display_block.html @@ -302,6 +302,18 @@
  • {{con.get_link}}
  • {% endfor %} +
    + Procedure(s): + {% if case.procedures.all %} + + {% else %} + None + {% endif %} +
    Findings diff --git a/atlas/templates/atlas/procedure_detail.html b/atlas/templates/atlas/procedure_detail.html new file mode 100644 index 00000000..f422fbc9 --- /dev/null +++ b/atlas/templates/atlas/procedure_detail.html @@ -0,0 +1,15 @@ +{% extends 'atlas/base.html' %} + +{% block content %} +
    +

    {{ procedure.name }}

    +

    Cases with this procedure:

    +
      + {% for case in cases %} +
    • {{ case.get_link }}
    • + {% empty %} +
    • No cases found
    • + {% endfor %} +
    +
    +{% endblock %} diff --git a/atlas/urls.py b/atlas/urls.py index 62dc1782..cda6d538 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from django.views.generic import RedirectView, TemplateView -from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty +from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty, Procedure from . import views app_name = "atlas" @@ -628,6 +628,11 @@ urlpatterns = [ views.StructureAutocomplete.as_view(model=Structure, create_field="name", validate_create=True), name="structure-autocomplete", ), + path( + "procedure-autocomplete", + views.ProcedureAutocomplete.as_view(model=Procedure, create_field="name"), + name="procedure-autocomplete", + ), path( "subspecialty-autocomplete", views.SubspecialtyAutocomplete.as_view(model=Subspecialty, create_field="name"), @@ -637,6 +642,7 @@ urlpatterns = [ path( "presentation/", views.presentation_detail, name="presentation_detail" ), + path("procedure/", views.procedure_detail, name="procedure_detail"), path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"), path( "process/", diff --git a/atlas/views.py b/atlas/views.py index 5fea98fe..05bcf7ee 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -118,6 +118,7 @@ from .models import ( UserReportAnswer, PrerequisiteRequired ) +from .models import Procedure from .models import NormalCase from .tables import ( CaseCollectionTable, @@ -590,6 +591,7 @@ def get_case_for_case_detail(pk: int) -> Case: ), 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()), @@ -1198,6 +1200,23 @@ def presentation_detail(request, pk): ) +@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): @@ -2985,6 +3004,22 @@ class ConditionAutocomplete(autocomplete.Select2QuerySetView): 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 !