Add inline collection management features; implement search and add functionality for collections in case view
This commit is contained in:
+13
-10
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user