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 = FormHelper()
|
||||||
helper.form_method = "get"
|
helper.form_method = "get"
|
||||||
helper.form_tag = False
|
helper.form_tag = False
|
||||||
helper.form_class = "row g-2 align-items-end"
|
helper.form_class = "row g-2"
|
||||||
helper.layout = Layout(
|
helper.layout = Layout(
|
||||||
Div(
|
Div(
|
||||||
Div("examination", css_class="col-md-3"),
|
Div("examination", css_class="col-md-3"),
|
||||||
Div("modality", css_class="col-md-3"),
|
Div("modality", css_class="col-md-3"),
|
||||||
|
css_class="row g-2",
|
||||||
|
),
|
||||||
|
Div(
|
||||||
Div(
|
Div(
|
||||||
Field("min_value", wrapper_class="mb-0"),
|
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||||
Field("max_value", wrapper_class="mb-0"),
|
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||||
Field("age_unit", wrapper_class="mb-0"),
|
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
|
||||||
css_class="col-md-6 d-flex gap-2 align-items-stretch border rounded p-2 bg-body-tertiary",
|
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
|
# Assign helper to the generated form instance and tweak widgets
|
||||||
if hasattr(self, 'form') and self.form is not None:
|
if hasattr(self, 'form') and self.form is not None:
|
||||||
@@ -432,12 +435,12 @@ class NormalCaseFilter(django_filters.FilterSet):
|
|||||||
try:
|
try:
|
||||||
form = self.form
|
form = self.form
|
||||||
if "min_value" in form.fields:
|
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:
|
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:
|
if "age_unit" in form.fields:
|
||||||
form.fields["age_unit"].initial = "days"
|
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
|
form.helper = helper
|
||||||
except Exception:
|
except Exception:
|
||||||
# don't crash if form structure differs
|
# don't crash if form structure differs
|
||||||
|
|||||||
@@ -453,7 +453,7 @@
|
|||||||
Add to collection</button>
|
Add to collection</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="collection-form"></div>
|
<div id="collection-form"></div>
|
||||||
<ul>
|
<ul id="case-{{ case.pk }}-collections">
|
||||||
{% for collection in case.casecollection_set.all %}
|
{% for collection in case.casecollection_set.all %}
|
||||||
<li id="collection-{{ collection.pk }}">
|
<li id="collection-{{ collection.pk }}">
|
||||||
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
|
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="d-flex align-items-center">
|
<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"
|
<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-get="{% url 'atlas:categories_search_partial' %}"
|
||||||
hx-trigger="keyup changed delay:500ms"
|
hx-trigger="keyup delay:500ms"
|
||||||
hx-target="#category-search-results"
|
hx-target="#category-search-results"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-include="#category-search-form"
|
hx-include="#category-search-form"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{% endhelp%}
|
{% endhelp%}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-12">
|
||||||
<form method="get" class="form-inline">
|
<form method="get" class="form-inline">
|
||||||
{% crispy filter.form %}
|
{% crispy filter.form %}
|
||||||
<div class="mb-2">
|
<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,
|
views.add_collection_to_case_form,
|
||||||
name="case_collection_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", views.AtlasClone.as_view(), name="case_clone"),
|
||||||
path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"),
|
path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"),
|
||||||
# path("verified/", views.verified, name="verified"),
|
# 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")
|
return render(request, "web/htmx_contact_form_confirm.html")
|
||||||
else:
|
else:
|
||||||
form = AddCollectionToCaseForm(user=request.user)
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"generic_form.html",
|
"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):
|
# class AddCollectionToCaseView(FormView):
|
||||||
# form_class = AddCollectionToCaseForm
|
# form_class = AddCollectionToCaseForm
|
||||||
# template_name = "generic_form.html"
|
# template_name = "generic_form.html"
|
||||||
|
|||||||
Reference in New Issue
Block a user