Compare commits
16
Commits
e407326c25
...
b47bb141e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47bb141e8 | ||
|
|
712ecec25d | ||
|
|
0193daf166 | ||
|
|
279d6344f1 | ||
|
|
8c8d484dce | ||
|
|
9760be8f27 | ||
|
|
5e0068d490 | ||
|
|
875744a08a | ||
|
|
c02a46fab1 | ||
|
|
6b73006122 | ||
|
|
7eb478b111 | ||
|
|
10c18bd514 | ||
|
|
1407fe83a2 | ||
|
|
e9716f3caf | ||
|
|
749450b03e | ||
|
|
7e72f731b3 |
@@ -1,14 +1,9 @@
|
||||
<span id="add-case-form">
|
||||
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% include 'atlas/partials/case_search_widget.html' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,139 +4,154 @@
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
Open access: {{collection.open_access}}<br />
|
||||
{% if collection.prerequisites.exists %}
|
||||
Prerequisite collections:
|
||||
<ul>
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
||||
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
<p>
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||
{% else %}
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||
_="on click
|
||||
writeText(#take-url's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy url' into me"
|
||||
class="btn btn-sm">copy link</button>
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}>
|
||||
|
||||
|
||||
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
:
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% endif %}
|
||||
{{casedetail.case.title}}
|
||||
|
||||
{% partial casedetails-management-links %}
|
||||
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
|
||||
<p><button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||
Add case
|
||||
</button>
|
||||
<div id='case-list'></div>
|
||||
</p>
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
Please note these are permanant and cannot be undone.
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
>Reset all answers</button>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
>Reset user answers</button>
|
||||
<div id="user-list"></div>
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
@@ -32,6 +31,7 @@
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<li class="list-group-item d-flex gap-3 align-items-start" data-question_pk="{{ casedetail.case.pk }}">
|
||||
|
||||
<div class="flex-fill">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<a class="h6 mb-0 text-decoration-none case-link"
|
||||
href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}"
|
||||
data-case-base-href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=0 %}">
|
||||
{{ casedetail.case.title }}
|
||||
</a>
|
||||
<div class="small text-muted"><span class="case-number">Case {{ forloop.counter }}</span> — <span class="text-truncate">{{ casedetail.case.description|default_if_none:""|truncatechars:80 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<span class="text-warning">No series attached</span>
|
||||
{% else %}
|
||||
{{ casedetail.case.series.count }} series
|
||||
{% endif %}
|
||||
{% if casedetail.question_schema %}
|
||||
• <span class="text-success">Questions defined</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
</li>
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Search results #}
|
||||
{% if q %}
|
||||
{% if cases and cases.exists %}
|
||||
<div class="list-group mb-2">
|
||||
{% for case in cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small mb-2">No cases found.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Recent cases quick-add (rendered below search results) #}
|
||||
{% if recent_cases and recent_cases|length > 0 %}
|
||||
<div>
|
||||
<div class="small text-muted mb-1">Recently created</div>
|
||||
<div class="list-group">
|
||||
{% for case in recent_cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="case-search-widget">
|
||||
<label for="case-search-input" class="form-label">Search cases</label>
|
||||
<input id="case-search-input" name="q" class="form-control" type="search"
|
||||
placeholder="Type to search cases..."
|
||||
hx-get="{% url 'atlas:case_search' %}"
|
||||
hx-include="#case-search-input"
|
||||
hx-vals='{"collection": "{{ collection.pk }}"}'
|
||||
hx-target="#case-search-results"
|
||||
hx-trigger="keyup changed delay:400ms"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="case-search-results" class="mt-2">
|
||||
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
|
||||
try {
|
||||
var input = document.getElementById('case-search-input');
|
||||
if (!input) return;
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
try {
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}";
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
|
||||
} else {
|
||||
// fallback to fetch
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.getElementById('case-search-results');
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case search fetch error', e); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
});
|
||||
} catch(e) { console.error('case search widget init error', e); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<i class="bi bi-display"></i>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
|
||||
<i class="bi bi-info-square"></i>
|
||||
</a>
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
||||
{% if can_edit %}
|
||||
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -34,6 +34,7 @@ urlpatterns = [
|
||||
path("author/", views.author_list, name="author_list"),
|
||||
path("case/", views.CaseView.as_view(), name="case_view"),
|
||||
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
|
||||
path("collection/case_search", views.case_search, name="case_search"),
|
||||
path("collection/", views.CollectionView.as_view(), name="collection_view"),
|
||||
path("collection/user", views.user_collections, name="user_collections"),
|
||||
path("uploads", views.user_uploads, name="user_uploads"),
|
||||
|
||||
+92
-3
@@ -1151,16 +1151,100 @@ def add_case_to_collection(request, collection_id):
|
||||
|
||||
print(case)
|
||||
collection.add_case(case)
|
||||
return HttpResponse(f"Case added to collection {collection.name} (refresh to see)")
|
||||
# Render the new case list item and return it so HTMX can append it
|
||||
try:
|
||||
casedetail = collection.casedetail_set.get(case=case)
|
||||
except Exception:
|
||||
return HttpResponse("Case added, but could not render item.")
|
||||
|
||||
total = collection.casedetail_set.count()
|
||||
can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists()
|
||||
rendered = render_to_string(
|
||||
"atlas/partials/case_list_item.html",
|
||||
{
|
||||
"casedetail": casedetail,
|
||||
"collection": collection,
|
||||
"forloop": {"counter": total, "counter0": max(0, total - 1)},
|
||||
"can_edit": can_edit,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
# Also instruct the client to remove this case from any visible search/recent lists
|
||||
remove_script = (
|
||||
'<script>'
|
||||
'(function(){'
|
||||
'try {'
|
||||
"var pk = '%s';" % case.pk +
|
||||
"document.querySelectorAll('#case-search-results [data-case-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
|
||||
"document.querySelectorAll('#case-search-results input[name=\"case\"][value=\"' + pk + '\"]').forEach(function(inp){ var li = inp.closest('.list-group-item'); if (li) li.remove(); });" +
|
||||
'} catch(e) { console.error("remove added case from search error", e); }'
|
||||
'})();'
|
||||
'</script>'
|
||||
)
|
||||
|
||||
return HttpResponse(rendered + remove_script)
|
||||
|
||||
# if request.method == "POST":
|
||||
|
||||
cases = get_cases_available_to_user(request.user)
|
||||
# don't show search results on initial load; the search partial will render only when a query exists
|
||||
cases = Case.objects.none()
|
||||
|
||||
# Also compute recent cases for initial display (authored by current user and not in collection)
|
||||
recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
|
||||
recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
|
||||
recent_cases = recent_qs.order_by("-created_date")[:5]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/add_case_to_collection.html",
|
||||
{"cases": cases, "collection": collection},
|
||||
{"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": ""},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def case_search(request):
|
||||
# HTMX endpoint for searching cases available to the user
|
||||
if not request.htmx:
|
||||
return Http404
|
||||
|
||||
q = request.GET.get("q", "").strip()
|
||||
collection_id = request.GET.get("collection")
|
||||
collection = None
|
||||
if collection_id:
|
||||
try:
|
||||
collection = CaseCollection.objects.get(pk=collection_id)
|
||||
except CaseCollection.DoesNotExist:
|
||||
collection = None
|
||||
|
||||
qs = get_cases_available_to_user(request.user, collection=collection)
|
||||
if q:
|
||||
qs = qs.filter(title__icontains=q)
|
||||
|
||||
# Recent cases: last 5 created that are available to the user and not already in the collection
|
||||
# Restrict recent cases to those authored by the current user and not already in the collection
|
||||
recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
|
||||
if collection is not None:
|
||||
recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
|
||||
recent_cases = recent_qs.order_by("-created_date")[:5]
|
||||
|
||||
# Exclude recent cases from the main search results to avoid duplication
|
||||
recent_pks = [c.pk for c in recent_cases]
|
||||
|
||||
# Build the base queryset for results, apply excludes first (before slicing)
|
||||
cases_qs = qs.exclude(pk__in=recent_pks)
|
||||
|
||||
# Also exclude any cases that are already present in the collection
|
||||
if collection is not None:
|
||||
existing_pks = list(collection.cases.values_list("pk", flat=True))
|
||||
if existing_pks:
|
||||
cases_qs = cases_qs.exclude(pk__in=existing_pks)
|
||||
|
||||
cases = cases_qs.order_by("title")[:30]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/partials/case_search_results.html",
|
||||
{"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": q},
|
||||
)
|
||||
|
||||
|
||||
@@ -4215,6 +4299,11 @@ def remove_case_from_collection(request, case_pk, collection_pk):
|
||||
# Remove the case from the collection
|
||||
collection.cases.remove(case)
|
||||
|
||||
# If this was an HTMX request, return an empty response so the caller
|
||||
# can swap out (remove) the surrounding element (e.g., the <li>).
|
||||
if request.htmx:
|
||||
return HttpResponse("")
|
||||
|
||||
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@
|
||||
})
|
||||
})
|
||||
$("#button-edit-order").click(function (evt) {
|
||||
$(this).remove();
|
||||
// hide the edit button while in edit mode so we can restore it after saving
|
||||
$(this).addClass('d-none').prop('disabled', true);
|
||||
sortable('.sortable');
|
||||
$("#full-question-list").addClass('sorting');
|
||||
|
||||
@@ -155,17 +156,38 @@
|
||||
for (var i = ul.children.length; i >= 0; i--) {
|
||||
ul.appendChild(ul.children[Math.random() * i | 0]);
|
||||
}
|
||||
renumber();
|
||||
}))
|
||||
|
||||
|
||||
// helper to renumber visible cases and update links
|
||||
function renumber() {
|
||||
$("#full-question-list li").each((i, e) => {
|
||||
// update Case N label
|
||||
$(e).find('.case-number').text('Case ' + (i + 1));
|
||||
|
||||
// update link href based on data-case-base-href (replace trailing 0)
|
||||
const a = $(e).find('.case-link');
|
||||
if (a.length) {
|
||||
const base = a.attr('data-case-base-href');
|
||||
if (base) {
|
||||
// replace trailing '/0' with new index
|
||||
const newHref = base.replace(/\/0$/, '/' + i);
|
||||
a.attr('href', newHref);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$("#full-question-list li").each((n, el) => {
|
||||
// Add up/down arrows at the start of each <li>
|
||||
// Add up/down arrows at the start of each <li>
|
||||
const upBtn = $("<button class='move-up btn btn-sm' title='Move up'>↑</button>").click(function(e){
|
||||
e.preventDefault();
|
||||
const li = $(this).closest("li");
|
||||
const prev = li.prev();
|
||||
if (prev.length) {
|
||||
prev.before(li);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>↓</button>").click(function(e){
|
||||
@@ -174,20 +196,27 @@
|
||||
const next = li.next();
|
||||
if (next.length) {
|
||||
next.after(li);
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
$(el).prepend(downBtn).prepend(upBtn);
|
||||
|
||||
// Existing delete button logic
|
||||
$(el).append($(
|
||||
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
||||
).click(() => {
|
||||
el.remove();
|
||||
}));
|
||||
// Prefer server-side HTMX remove button when present; otherwise keep a fallback
|
||||
if ($(el).find('.case-remove').length === 0) {
|
||||
// fallback: append an inline client-side remove button (for contexts without server-side remove)
|
||||
$(el).append($(
|
||||
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
||||
).click(() => {
|
||||
el.remove();
|
||||
renumber();
|
||||
}));
|
||||
} else {
|
||||
// If HTMX remove exists it will handle the server call and swap; renumber after swap via htmx:afterSwap
|
||||
}
|
||||
});
|
||||
|
||||
$("#full-question-list").after($(
|
||||
"<button title='click and drag questions to change order'>Save exam order</button>"
|
||||
"<button id='save-order-button' class='btn btn-primary btn-sm' title='click and drag questions to change order'>Save exam order</button>"
|
||||
).click(() => {
|
||||
new_order = [];
|
||||
$("#full-question-list li").each((n, el) => {
|
||||
@@ -208,6 +237,21 @@
|
||||
|
||||
if (data.status == "success") {
|
||||
toastr.info('Exam order changed.')
|
||||
renumber();
|
||||
|
||||
// cleanup ordering controls and restore edit button
|
||||
$('#random-order-button').remove();
|
||||
$('#save-order-button').remove();
|
||||
$('#cancel-order-button').remove();
|
||||
// remove per-item move controls
|
||||
$('#full-question-list .move-up, #full-question-list .move-down').remove();
|
||||
// remove fallback delete buttons if desired (keep server-side removes intact)
|
||||
$('#full-question-list .exam-question-delete').remove();
|
||||
// remove visual sorting state
|
||||
$('#full-question-list').removeClass('sorting');
|
||||
|
||||
// restore the edit button so the user can re-enter edit mode
|
||||
$('#button-edit-order').removeClass('d-none').prop('disabled', false);
|
||||
}
|
||||
})
|
||||
.always(function () {
|
||||
@@ -220,6 +264,20 @@
|
||||
).click(() => {
|
||||
location.reload();
|
||||
}));
|
||||
// Keep list numbering in sync after HTMX swaps that affect the case list
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try {
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||
if (!target) return;
|
||||
if (target.id === 'full-question-list' || (target.closest && target.closest('#full-question-list'))) {
|
||||
setTimeout(function(){
|
||||
if (typeof renumber === 'function') renumber();
|
||||
}, 10);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('htmx afterSwap renumber error', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user