Add inline collection management features; implement search and add functionality for collections in case view

This commit is contained in:
Ross
2026-02-02 11:55:43 +00:00
parent 00680c8b9e
commit f362be43ef
9 changed files with 196 additions and 13 deletions
+13 -10
View File
@@ -412,19 +412,22 @@ class NormalCaseFilter(django_filters.FilterSet):
helper = FormHelper()
helper.form_method = "get"
helper.form_tag = False
helper.form_class = "row g-2 align-items-end"
helper.form_class = "row g-2"
helper.layout = Layout(
Div(
Div("examination", css_class="col-md-3"),
Div("modality", css_class="col-md-3"),
css_class="row g-2",
),
Div(
Div(
Field("min_value", wrapper_class="mb-0"),
Field("max_value", wrapper_class="mb-0"),
Field("age_unit", wrapper_class="mb-0"),
css_class="col-md-6 d-flex gap-2 align-items-stretch border rounded p-2 bg-body-tertiary",
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
css_class="col-12 d-flex gap-2 align-items-center",
),
css_class="row g-2 align-items-end",
)
css_class="row g-2",
),
)
# Assign helper to the generated form instance and tweak widgets
if hasattr(self, 'form') and self.form is not None:
@@ -432,12 +435,12 @@ class NormalCaseFilter(django_filters.FilterSet):
try:
form = self.form
if "min_value" in form.fields:
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control", "min": "0", "step": "1", "style": "width:140px;"})
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "max_value" in form.fields:
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control", "min": "0", "step": "1", "style": "width:140px;"})
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "age_unit" in form.fields:
form.fields["age_unit"].initial = "days"
form.fields["age_unit"].widget.attrs.update({"class": "form-select", "style": "width:220px;"})
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
form.helper = helper
except Exception:
# don't crash if form structure differs
@@ -453,7 +453,7 @@
Add to collection</button>
{% endif %}
<div id="collection-form"></div>
<ul>
<ul id="case-{{ case.pk }}-collections">
{% for collection in case.casecollection_set.all %}
<li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
+1 -1
View File
@@ -25,7 +25,7 @@
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup changed delay:500ms"
hx-trigger="keyup delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
+1 -1
View File
@@ -16,7 +16,7 @@
{% endhelp%}
<div class="row mb-3">
<div class="col-md-4">
<div class="col-12">
<form method="get" class="form-inline">
{% crispy filter.form %}
<div class="mb-2">
@@ -0,0 +1,26 @@
<div id="add-collection-inline" class="p-2 border rounded">
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="collection-inline-search" name="q" type="search" placeholder="Search collections"
{% if case %}
hx-get="{% url 'atlas:collection_inline_search' case.pk %}"
{% else %}
hx-get="{% url 'atlas:collection_inline_search' 0 %}"
{% endif %}
hx-trigger="input delay:400ms, keyup[key=='Enter']"
hx-target="#collection-inline-list"
hx-indicator="#collection-inline-indicator">
<span class="input-group-text" id="collection-inline-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
</div>
</div>
<div id="collection-inline-list">
{% include 'atlas/partials/collection_inline_list.html' with collections=recent_collections case=case %}
</div>
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-collection-inline">Close</button>
</div>
</div>
@@ -0,0 +1,25 @@
<ul class="list-group" id="collection-inline-list-items">
{% if collections and collections|length > 0 %}
{% for collection in collections %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-collection-pk="{{ collection.pk }}">
<div class="flex-fill">
<div class="d-flex align-items-baseline">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}" class="me-2">{{ collection.name }}</a>
<small class="text-muted">{% if collection.author.all %}By {{ collection.author.all|join:", " }}{% endif %}</small>
</div>
{% if collection.description %}<div class="small text-muted">{{ collection.description|truncatechars:120 }}</div>{% endif %}
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' pk=collection.pk %}" target="_blank" rel="noopener">Open</a>
{% if case %}
<button class="btn btn-sm btn-primary" hx-post="{% url 'atlas:add_collection_to_case_inline' case.pk collection.pk %}" hx-target="#case-{{ case.pk }}-collections" hx-swap="beforeend">Add</button>
{% else %}
<span class="text-muted small">Open a case to add</span>
{% endif %}
</div>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-muted">No collections found.</li>
{% endif %}
</ul>
@@ -0,0 +1,12 @@
<li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
{% if can_edit %}
<button class="btn btn-danger btn-sm remove-button"
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
hx-target="#collection-{{ collection.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to remove this case from the collection?">
Remove
</button>
{% endif %}
</li>
+10
View File
@@ -539,6 +539,16 @@ urlpatterns = [
views.add_collection_to_case_form,
name="case_collection_form",
),
path(
"case/<int:case_id>/collection-inline-search/",
views.collection_inline_search,
name="collection_inline_search",
),
path(
"case/<int:case_id>/add-collection/<int:collection_id>/",
views.add_collection_to_case_inline,
name="add_collection_to_case_inline",
),
path("case/<int:pk>/clone", views.AtlasClone.as_view(), name="case_clone"),
path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"),
# path("verified/", views.verified, name="verified"),
+107
View File
@@ -2286,6 +2286,35 @@ def add_collection_to_case_form(request, case_id):
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",
@@ -2296,6 +2325,84 @@ def add_collection_to_case_form(request, 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"