Refactor NormalCase model: remove 'added_by' field, add 'author' ManyToManyField; update related views and templates for editing functionality

This commit is contained in:
Ross
2026-02-16 13:32:06 +00:00
parent 64103cac1e
commit 0b6661c018
9 changed files with 134 additions and 25 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
list_display = ("case", "display_age", "examination", "modality", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
@@ -0,0 +1,17 @@
# Generated by Django 6.0.1 on 2026-02-16 13:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0090_casecollection_created_casecollection_updated'),
]
operations = [
migrations.RemoveField(
model_name='normalcase',
name='added_by',
),
]
@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-02-16 13:29
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0091_remove_normalcase_added_by'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='normalcase',
name='author',
field=models.ManyToManyField(blank=True, related_name='normal_cases', to=settings.AUTH_USER_MODEL),
),
]
+8 -9
View File
@@ -766,7 +766,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return json.dumps(results)
class NormalCase(models.Model):
class NormalCase(models.Model, AuthorMixin):
"""Marks a Case as a 'normal' example used in the Atlas normals section.
This version stores a canonical `age_days` integer (patient age in days).
@@ -819,16 +819,15 @@ class NormalCase(models.Model):
help_text="Primary modality for this normal case (optional).",
)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="added_normals",
)
added_date = models.DateTimeField(default=timezone.now)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="normal_cases",
)
notes = models.TextField(null=True, blank=True)
class Meta:
+14 -2
View File
@@ -30,7 +30,8 @@
{% if page_obj.object_list %}
<div class="list-group">
{% for normal in page_obj.object_list %}
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="list-group-item d-flex justify-content-between align-items-start">
<a class="flex-grow-1 text-decoration-none text-reset" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
@@ -43,7 +44,18 @@
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
</a>
<div class="ms-2">
{% if request.user.is_superuser or request.user in normal.author.all %}
<button class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:case_normal_edit' normal.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
@@ -1,10 +1,10 @@
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
<form method="post" hx-post="{% if normal %}{% url 'atlas:case_normal_edit' normal.pk %}{% else %}{% url 'atlas:case_create_normal' case.pk %}{% endif %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
<h5 class="modal-title">{% if normal %}Edit normal for case {{ case.pk }}{% else %}Mark case {{ case.pk }} as Normal{% endif %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -4,15 +4,27 @@
{% if case.normal_case %}
<span class="badge bg-success">Normal</span>
<small class="text-muted">{{ case.normal_case.display_age }}</small>
{% if can_edit %}
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
hx-swap="outerHTML"
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
{% endif %}
<span>
{% if can_edit %}
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
hx-swap="outerHTML"
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
{% endif %}
{# Allow NormalCase authors (or atlas editors/superusers) to edit the Normal metadata #}
{% if case.normal_case and (request.user.is_superuser or request.user in case.normal_case.author.all or can_edit) %}
<button class="btn btn-sm btn-outline-secondary ms-2"
hx-get="{% url 'atlas:case_normal_edit' case.normal_case.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit Normal
</button>
{% endif %}
</span>
{% else %}
{% if can_edit %}
<button class="btn btn-sm btn-outline-primary"
+1
View File
@@ -541,6 +541,7 @@ urlpatterns = [
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
path("normal/<int:pk>/edit/", views.case_normal_edit, name="case_normal_edit"),
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
path(
"case/<int:case_id>/collection-form",
+50 -2
View File
@@ -347,7 +347,7 @@ class NormalCaseList(LoginRequiredMixin, FilterView):
paginate_by = 25
def get_queryset(self):
qs = NormalCase.objects.select_related("case", "examination", "modality", "added_by")
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)))
@@ -847,7 +847,7 @@ def create_case_normal(request, pk):
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
nc = form.save(commit=False)
nc.case = case
nc.added_by = request.user
nc.author.add(request.user)
nc.save()
# Ensure the case reflects the newly created NormalCase when rendering
@@ -874,6 +874,54 @@ def create_case_normal(request, pk):
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):