Refactor NormalCase model: remove 'added_by' field, add 'author' ManyToManyField; update related views and templates for editing functionality
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
|
|||||||
|
|
||||||
|
|
||||||
class NormalCaseAdmin(VersionAdmin):
|
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")
|
list_filter = ("examination", "modality")
|
||||||
search_fields = ("case__title", "case__pk")
|
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
@@ -766,7 +766,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
return json.dumps(results)
|
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.
|
"""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).
|
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).",
|
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)
|
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)
|
notes = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
{% if page_obj.object_list %}
|
{% if page_obj.object_list %}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for normal in page_obj.object_list %}
|
{% 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">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||||
@@ -43,7 +44,18 @@
|
|||||||
{% if normal.notes %}
|
{% if normal.notes %}
|
||||||
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
|
<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-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
<div class="modal-content">
|
<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 %}
|
{% csrf_token %}
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@@ -4,15 +4,27 @@
|
|||||||
{% if case.normal_case %}
|
{% if case.normal_case %}
|
||||||
<span class="badge bg-success">Normal</span>
|
<span class="badge bg-success">Normal</span>
|
||||||
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||||
{% if can_edit %}
|
<span>
|
||||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
{% if can_edit %}
|
||||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||||
hx-target="#normal-toggle-block"
|
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||||
hx-swap="outerHTML"
|
hx-target="#normal-toggle-block"
|
||||||
hx-confirm="Unmark this case as normal?">
|
hx-swap="outerHTML"
|
||||||
Unmark normal
|
hx-confirm="Unmark this case as normal?">
|
||||||
</button>
|
Unmark normal
|
||||||
{% endif %}
|
</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 %}
|
{% else %}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ urlpatterns = [
|
|||||||
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
|
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>/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("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:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
|
||||||
path(
|
path(
|
||||||
"case/<int:case_id>/collection-form",
|
"case/<int:case_id>/collection-form",
|
||||||
|
|||||||
+50
-2
@@ -347,7 +347,7 @@ class NormalCaseList(LoginRequiredMixin, FilterView):
|
|||||||
paginate_by = 25
|
paginate_by = 25
|
||||||
|
|
||||||
def get_queryset(self):
|
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
|
# Restrict similar to CaseFilter: non-editors only see authored or open_access
|
||||||
if not self.request.user.groups.filter(name="atlas_editor").exists():
|
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)))
|
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)
|
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
|
||||||
nc = form.save(commit=False)
|
nc = form.save(commit=False)
|
||||||
nc.case = case
|
nc.case = case
|
||||||
nc.added_by = request.user
|
nc.author.add(request.user)
|
||||||
nc.save()
|
nc.save()
|
||||||
|
|
||||||
# Ensure the case reflects the newly created NormalCase when rendering
|
# 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)
|
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
|
||||||
return HttpResponse(html, status=400)
|
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
|
@login_required
|
||||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
def series_thumbnail_fail(request, pk):
|
def series_thumbnail_fail(request, pk):
|
||||||
|
|||||||
Reference in New Issue
Block a user