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">
|
<div id="add-case-form" class="p-2 border rounded">
|
||||||
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.
|
<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>
|
||||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
|
||||||
{% csrf_token %}
|
{% include 'atlas/partials/case_search_widget.html' %}
|
||||||
<label for="case">Select Case to add:</label>
|
|
||||||
<select name="case" id="case">
|
<div class="mt-2">
|
||||||
{% for case in cases %}
|
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
|
||||||
<button type="submit">Add</button>
|
|
||||||
<button _="on click remove #add-case-form">Cancel</button>
|
|
||||||
</form>
|
|
||||||
</span
|
|
||||||
@@ -4,139 +4,154 @@
|
|||||||
|
|
||||||
{% partialdef casedetails-management-links %}
|
{% 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 %}
|
{% endpartialdef %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{{collection.name}}</h2>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{% include 'exam_notes.html' %}
|
{% include 'exam_notes.html' %}
|
||||||
|
|
||||||
<div>
|
<p class="mb-1">
|
||||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
<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#publish-results" %}
|
||||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
</p>
|
||||||
Self review: {{collection.self_review}}<br />
|
|
||||||
Open access: {{collection.open_access}}<br />
|
<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 %}
|
{% if collection.prerequisites.exists %}
|
||||||
Prerequisite collections:
|
<div class="mt-3 mb-2">
|
||||||
<ul>
|
<strong>Prerequisites</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
{% for prereq in collection.prerequisites.all %}
|
{% for prereq in collection.prerequisites.all %}
|
||||||
<li>
|
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
|
||||||
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>
|
<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
|
<button
|
||||||
title="Sync users from all prerequisite collections into this collection"
|
title="Sync users from all prerequisite collections into this collection"
|
||||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="btn btn-sm btn-secondary"
|
class="btn btn-sm btn-secondary"
|
||||||
>Sync prerequisite users</button>
|
>Sync prerequisite users</button>
|
||||||
</p>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h3 class="mb-3">Cases</h3>
|
||||||
|
|
||||||
{% if collection.collection_type == "VIV" %}
|
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||||
<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 %}
|
{% for casedetail in casesdetails %}
|
||||||
<li data-question_pk={{casedetail.case.pk}}>
|
{% include 'atlas/partials/case_list_item.html' %}
|
||||||
|
|
||||||
|
|
||||||
<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 %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if can_edit %}
|
{% 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>
|
<div class="row mt-4">
|
||||||
<p><button id='button-add-case'
|
<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>
|
||||||
|
|
||||||
|
<button id='button-add-case'
|
||||||
title='click to add a case to this collection'
|
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">
|
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
|
Add case
|
||||||
</button>
|
</button>
|
||||||
<div id='case-list'></div>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
<div id='case-list'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
<details>
|
<details>
|
||||||
<summary>Answer management</summary>
|
<summary>Answer management</summary>
|
||||||
|
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mt-3">
|
||||||
<p>Manage answers for this collection. </p>
|
<p class="mb-1">Manage answers for this collection.</p>
|
||||||
|
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||||
Please note these are permanant and cannot be undone.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<button title="This will clear all user answers and attempts"
|
<button title="This will clear all user answers and attempts"
|
||||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
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>
|
>Reset all answers</button>
|
||||||
|
|
||||||
<button title="This will specific user answers and attempts"
|
<button title="This will specific user answers and attempts"
|
||||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="#user-list"
|
hx-target="#user-list"
|
||||||
>Reset user answers</button>
|
class="btn btn-warning btn-sm">
|
||||||
<div id="user-list"></div>
|
Reset user answers</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="user-list" class="mt-3"></div>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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_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: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: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>
|
||||||
|
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<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 '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 %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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("author/", views.author_list, name="author_list"),
|
||||||
path("case/", views.CaseView.as_view(), name="case_view"),
|
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/<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/", views.CollectionView.as_view(), name="collection_view"),
|
||||||
path("collection/user", views.user_collections, name="user_collections"),
|
path("collection/user", views.user_collections, name="user_collections"),
|
||||||
path("uploads", views.user_uploads, name="user_uploads"),
|
path("uploads", views.user_uploads, name="user_uploads"),
|
||||||
|
|||||||
+92
-3
@@ -1151,16 +1151,100 @@ def add_case_to_collection(request, collection_id):
|
|||||||
|
|
||||||
print(case)
|
print(case)
|
||||||
collection.add_case(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":
|
# 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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/add_case_to_collection.html",
|
"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
|
# Remove the case from the collection
|
||||||
collection.cases.remove(case)
|
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}")
|
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,8 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
$("#button-edit-order").click(function (evt) {
|
$("#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');
|
sortable('.sortable');
|
||||||
$("#full-question-list").addClass('sorting');
|
$("#full-question-list").addClass('sorting');
|
||||||
|
|
||||||
@@ -155,9 +156,29 @@
|
|||||||
for (var i = ul.children.length; i >= 0; i--) {
|
for (var i = ul.children.length; i >= 0; i--) {
|
||||||
ul.appendChild(ul.children[Math.random() * i | 0]);
|
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) => {
|
$("#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){
|
const upBtn = $("<button class='move-up btn btn-sm' title='Move up'>↑</button>").click(function(e){
|
||||||
@@ -166,6 +187,7 @@
|
|||||||
const prev = li.prev();
|
const prev = li.prev();
|
||||||
if (prev.length) {
|
if (prev.length) {
|
||||||
prev.before(li);
|
prev.before(li);
|
||||||
|
renumber();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>↓</button>").click(function(e){
|
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>↓</button>").click(function(e){
|
||||||
@@ -174,20 +196,27 @@
|
|||||||
const next = li.next();
|
const next = li.next();
|
||||||
if (next.length) {
|
if (next.length) {
|
||||||
next.after(li);
|
next.after(li);
|
||||||
|
renumber();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$(el).prepend(downBtn).prepend(upBtn);
|
$(el).prepend(downBtn).prepend(upBtn);
|
||||||
|
|
||||||
// Existing delete button logic
|
// 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($(
|
$(el).append($(
|
||||||
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
||||||
).click(() => {
|
).click(() => {
|
||||||
el.remove();
|
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($(
|
$("#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(() => {
|
).click(() => {
|
||||||
new_order = [];
|
new_order = [];
|
||||||
$("#full-question-list li").each((n, el) => {
|
$("#full-question-list li").each((n, el) => {
|
||||||
@@ -208,6 +237,21 @@
|
|||||||
|
|
||||||
if (data.status == "success") {
|
if (data.status == "success") {
|
||||||
toastr.info('Exam order changed.')
|
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 () {
|
.always(function () {
|
||||||
@@ -220,6 +264,20 @@
|
|||||||
).click(() => {
|
).click(() => {
|
||||||
location.reload();
|
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