Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19eb10c089 | ||
|
|
2ab5a8e3b5 | ||
|
|
c2f12f384a | ||
|
|
33f32dab8b | ||
|
|
a676d437bb | ||
|
|
ba5b11cce2 | ||
|
|
a6c5b99620 | ||
|
|
666f49f2c2 | ||
|
|
46608a0758 | ||
|
|
cfdf33b9ce | ||
|
|
bdc97922af | ||
|
|
3626f3918e | ||
|
|
49fbff76e9 | ||
|
|
7ec81133d8 | ||
|
|
c67db4a39e | ||
|
|
d2f4c21883 | ||
|
|
1f5931aab2 | ||
|
|
08c3558861 | ||
|
|
add7a1cf38 | ||
|
|
65f08cce6c | ||
|
|
3c8238d737 | ||
|
|
23f33ac7ae | ||
|
|
ab130a08ed | ||
|
|
45e01a78c1 | ||
|
|
25e5ef78ed | ||
|
|
13dd79b508 | ||
|
|
89986ba2e9 | ||
|
|
f9e69e1206 | ||
|
|
e7de6c8970 | ||
|
|
ea139456a8 | ||
|
|
035da12e96 | ||
|
|
3b9942054d | ||
|
|
5761117e68 | ||
|
|
f316dc9bcb | ||
|
|
bdb7b653b6 | ||
|
|
1eeb0d89d9 | ||
|
|
f3702db5f6 | ||
|
|
a6a7d75144 | ||
|
|
14bba93c4d | ||
|
|
f29df840c6 | ||
|
|
10d30cdaa6 | ||
|
|
dc4cf0c3a2 | ||
|
|
99b951b32b | ||
|
|
c2c8ec5b13 | ||
|
|
6d5303cd0d | ||
|
|
f6b990a00a | ||
|
|
3c0deec140 | ||
|
|
e3241b503d | ||
|
|
04174484b1 | ||
|
|
168a4126d3 | ||
|
|
a0850c0030 | ||
|
|
a882a3592b | ||
|
|
8074b56a80 | ||
|
|
2e79df1eae | ||
|
|
9bf68cd136 | ||
|
|
42163448bf | ||
|
|
1066a3fb9f | ||
|
|
3ade162b28 | ||
|
|
f4186bf923 | ||
|
|
5bb2aea8ef | ||
|
|
8911c09c0e | ||
|
|
7342b11b27 | ||
|
|
953295cc4d | ||
|
|
374ee17911 | ||
|
|
18aef751ca | ||
|
|
f362be43ef | ||
|
|
00680c8b9e | ||
|
|
eea3433042 | ||
|
|
8dad4a5a5c | ||
|
|
75d23c0ba9 | ||
|
|
ff2b6f4c65 | ||
|
|
22e5f9a496 | ||
|
|
16865fab03 | ||
|
|
5e14df4836 | ||
|
|
625f6017a6 | ||
|
|
c45e3c8765 | ||
|
|
ee33f62def | ||
|
|
3a5fe9a85c | ||
|
|
efc47bb2e8 | ||
|
|
38d70f8ff0 | ||
|
|
2159b045cd | ||
|
|
4628f39a49 | ||
|
|
90ceff1b02 | ||
|
|
a512c2ba09 | ||
|
|
a61c8529ae | ||
|
|
a667566db6 | ||
|
|
d070adc326 | ||
|
|
048168e87d | ||
|
|
c881da244b | ||
|
|
797eb9c54a | ||
|
|
0a1b21cec6 | ||
|
|
d3d3638c1c | ||
|
|
81e22dfc88 | ||
|
|
7ea43a6f44 | ||
|
|
eb8fe58cf7 | ||
|
|
f149a88bd6 | ||
|
|
d4d747517c | ||
|
|
0ee378484b | ||
|
|
ea583744c9 | ||
|
|
e0d589f4d0 | ||
|
|
1c95526ff9 | ||
|
|
662fc4e06e | ||
|
|
a333ff492d | ||
|
|
bb9741b786 | ||
|
|
75be7c1739 | ||
|
|
015a5d18b5 | ||
|
|
d16041026f | ||
|
|
774a1b8c8a | ||
|
|
8fc42169f3 | ||
|
|
834d91e84d | ||
|
|
ef03a97e9e | ||
|
|
dd7437bc66 | ||
|
|
21d3130fa5 | ||
|
|
89e7e49e31 | ||
|
|
8bceb10a13 | ||
|
|
13a98a1e66 | ||
|
|
9c16f93820 | ||
|
|
1c27d48a36 | ||
|
|
562ad1c0a4 | ||
|
|
fcd77fd799 | ||
|
|
d8f8ef7326 | ||
|
|
f551d071f0 | ||
|
|
9b6fd31cee | ||
|
|
8b7da1554a | ||
|
|
d4247adb13 | ||
|
|
b89ca918fa | ||
|
|
569ddb4343 | ||
|
|
b040dfcd82 | ||
|
|
db51ef23be | ||
|
|
8c796c3048 | ||
|
|
b837c406cf | ||
|
|
42d209af5a | ||
|
|
3d1b9a57f9 | ||
|
|
8d638cc08b | ||
|
|
20a06323d5 | ||
|
|
3928df6d75 | ||
|
|
bf344f20e1 | ||
|
|
e4cfb1f782 | ||
|
|
f44f3bbcfe | ||
|
|
ba0c380f18 | ||
|
|
459b7f9b93 | ||
|
|
378d784435 | ||
|
|
96e88c05a4 | ||
|
|
457fb06dc0 | ||
|
|
debe093bc9 | ||
|
|
086bd8f589 | ||
|
|
71dc50b307 | ||
|
|
c0c12ea9f9 | ||
|
|
429adfc376 | ||
|
|
36a809ea40 | ||
|
|
443d93c35f | ||
|
|
d7682ab31d | ||
|
|
e600b8eb91 | ||
|
|
3e691d774c | ||
|
|
c3a70b079d | ||
|
|
4ffadd3914 | ||
|
|
fa18c33e8a | ||
|
|
057c391995 | ||
|
|
94fbd38634 | ||
|
|
4ecc32f837 | ||
|
|
768e1bf8c5 | ||
|
|
32c4305754 | ||
|
|
465f819973 | ||
|
|
3e1cb4b54f | ||
|
|
b9d86a7639 | ||
|
|
e5fd99f491 | ||
|
|
efeed2a8b1 | ||
|
|
f14ede86d3 | ||
|
|
517c2ebe06 | ||
|
|
c5f9b830eb | ||
|
|
2ab85357a9 | ||
|
|
91cf2cab29 | ||
|
|
34c4c0d830 | ||
|
|
98cef9ea57 | ||
|
|
df0b26de36 | ||
|
|
17330681a2 | ||
|
|
112c96dc00 | ||
|
|
0bea34d406 | ||
|
|
06ceb9c2ce | ||
|
|
8ac292eb0c | ||
|
|
ff6b8a8a48 | ||
|
|
46313cf5bb | ||
|
|
13a23eee12 | ||
|
|
a1bc96f99a | ||
|
|
9fd5e56356 | ||
|
|
a02f274a4e | ||
|
|
d3d0e865c3 | ||
|
|
7163e51335 | ||
|
|
7b47a75657 | ||
|
|
54e1cf4ce0 | ||
|
|
c071f38920 | ||
|
|
03b23b9178 | ||
|
|
00b736d8d6 | ||
|
|
885ab1aad4 | ||
|
|
77edc17732 | ||
|
|
d98305caff | ||
|
|
12bd66d4d8 | ||
|
|
508f8a22a2 | ||
|
|
158db133b3 | ||
|
|
83a009e31b | ||
|
|
00dfaf9888 | ||
|
|
7e4e66a74f | ||
|
|
7a4cf3627d | ||
|
|
4db1f2ca1a | ||
|
|
cbff1317d0 | ||
|
|
0644ed99f4 | ||
|
|
c01000f06e | ||
|
|
2f014ce1a5 | ||
|
|
654d0ae686 | ||
|
|
18dbb27a3e | ||
|
|
4bec329aff | ||
|
|
1e2300bd70 | ||
|
|
7674f29124 | ||
|
|
d39879bbca | ||
|
|
012c86340d | ||
|
|
6f50c53519 | ||
|
|
53aefa29df | ||
|
|
4617a643d2 | ||
|
|
d7afab15e1 | ||
|
|
ad5e7aeed8 | ||
|
|
b4cf5b9d70 | ||
|
|
f156f4c85e | ||
|
|
4ab2e3a15e | ||
|
|
6daa643df8 | ||
|
|
f8874a2556 | ||
|
|
b4f5992986 | ||
|
|
9a3a8b932a | ||
|
|
eb06e63f77 | ||
|
|
a4f4ba464a | ||
|
|
6fafccc907 | ||
|
|
d96dd77cdc | ||
|
|
4e47083fcd | ||
|
|
bf895e516e | ||
|
|
49229b0408 | ||
|
|
997b5374c2 | ||
|
|
9d434ec80d | ||
|
|
af616a9f4a | ||
|
|
d29c23a799 | ||
|
|
0917947a98 |
@@ -18,5 +18,13 @@ GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
|
||||
# Nginx host ports for local development (override prod defaults)
|
||||
NGINX_HTTP_PORT=8000
|
||||
NGINX_HTTPS_PORT=8443
|
||||
NGINX_HTTP_PORT=8080
|
||||
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
|
||||
NGINX_HTTPS_PORT=8444
|
||||
|
||||
# Use Django's file-based email backend for local development so messages
|
||||
# are written to files under the mounted `./logs/emails` directory and are
|
||||
# easy to inspect on the host.
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
|
||||
EMAIL_FILE_PATH=/var/log/rad/emails
|
||||
+4
-2
@@ -14,5 +14,7 @@ venv
|
||||
temp/
|
||||
|
||||
# Loki runtime data
|
||||
/docker/loki-data/
|
||||
/docker/loki-wal/
|
||||
docker/loki-data/
|
||||
docker/loki-wal/
|
||||
# Local runtime logs collected by Promtail
|
||||
/logs/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
README
|
||||
======
|
||||
|
||||
Quick notes for running this repository (development & observability)
|
||||
|
||||
Running locally (development)
|
||||
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
|
||||
|
||||
```sh
|
||||
# run nginx on non-privileged ports (see .env.dev)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
|
||||
- `NGINX_HTTP_PORT=8080`
|
||||
- `NGINX_HTTPS_PORT=8444`
|
||||
|
||||
Logging & observability (Loki + Promtail + Grafana)
|
||||
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
|
||||
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
|
||||
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
|
||||
- `../logs:/var/log/rad:rw` (nginx)
|
||||
- `../logs:/var/log/rad:ro` (promtail)
|
||||
|
||||
Host setup for logs
|
||||
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
|
||||
|
||||
```sh
|
||||
mkdir -p logs
|
||||
# development: make writable by all (change this for production)
|
||||
sudo chown $USER:$USER logs
|
||||
chmod 0777 logs
|
||||
```
|
||||
|
||||
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
|
||||
|
||||
```sh
|
||||
# run on the host (determine nginx UID as needed)
|
||||
sudo chown -R 101:101 /srv/rad/logs
|
||||
sudo chmod 0755 /srv/rad/logs
|
||||
```
|
||||
|
||||
Viewing logs in Grafana
|
||||
- Grafana is available on the port exposed by compose (default `3000`).
|
||||
- The Loki datasource is configured to use `http://loki:3100` (see compose). In Grafana Explore choose the Loki datasource and run queries such as:
|
||||
|
||||
- `{job="rad_app"}` (all collected logs)
|
||||
- `{job="rad_app", filename="/var/log/rad/nginx.access.log"}`
|
||||
|
||||
Notes & recommendations
|
||||
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
|
||||
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
|
||||
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
|
||||
|
||||
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
|
||||
+35
-2
@@ -34,7 +34,9 @@ from dal import autocomplete
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit
|
||||
from crispy_forms.layout import Submit, Layout, Div
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
from generic.widgets import ExamSearchWidget
|
||||
|
||||
class AnatomyAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -127,7 +129,38 @@ class AnatomyQuestionForm(ModelForm):
|
||||
self.fields["exams"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=exam_queryset,
|
||||
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=Exam),
|
||||
)
|
||||
|
||||
# Now that all fields have been created, set the helper layout so
|
||||
# Crispy can find the referenced field names (including
|
||||
# `answer_suggest_incorrect`). Placing the layout here ensures the
|
||||
# collapse/accordion renders server-side.
|
||||
self.helper.layout = Layout(
|
||||
"question_type",
|
||||
"image",
|
||||
"description",
|
||||
# Place modality → body_part fields in a horizontal flex row
|
||||
Div(
|
||||
Div("modality", css_class="flex-fill me-2"),
|
||||
Div("region", css_class="flex-fill me-2"),
|
||||
Div("structure", css_class="flex-fill me-2"),
|
||||
Div("examination", css_class="flex-fill me-2"),
|
||||
Div("body_part", css_class="flex-fill"),
|
||||
css_class="d-flex flex-wrap align-items-start mb-2",
|
||||
),
|
||||
"answer_help",
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
"Suggest incorrect answers",
|
||||
"answer_suggest_incorrect",
|
||||
active=False,
|
||||
)
|
||||
),
|
||||
"feedback",
|
||||
"open_access",
|
||||
"exams",
|
||||
Div(Submit("submit", "Submit"), css_class="form-group"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0024_exam_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
+3
-3
@@ -191,9 +191,9 @@ class AnatomyQuestion(QuestionBase):
|
||||
if not unmarked_answers:
|
||||
return "No answers to mark"
|
||||
return format_html(
|
||||
"<span class='warn'>{} answer unmarked:</span> {}".format(
|
||||
len(unmarked_answers), ", ".join(unmarked_answers)
|
||||
)
|
||||
"<span class='warn'>{} answer unmarked:</span> {}",
|
||||
len(unmarked_answers),
|
||||
", ".join(unmarked_answers),
|
||||
)
|
||||
|
||||
def get_unmarked_user_answers(self, exam_pk: int|None = None):
|
||||
|
||||
@@ -233,6 +233,10 @@
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
|
||||
<p>Images can be selected either directly via the browse button or by dragging and dropping them into the designated area below.</p>
|
||||
|
||||
<p>Supported formats include JPEG, PNG, and GIF. It is also possible to use DICOM images.</p>
|
||||
{% endhelp %}
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
@@ -241,10 +245,9 @@
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
{% crispy form %}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,47 +1,127 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="question-display-block">
|
||||
<div class="hide-show">HIDE/SHOW</div>
|
||||
<div class="inner-display-block">
|
||||
Question <span class="question-number">No question loaded</span><br />
|
||||
<span id="question-title"></span><br />
|
||||
<span id="question-question"></span>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
</div>
|
||||
<div class="answers">Answers: </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anatomy">
|
||||
<div class="container anatomy my-3">
|
||||
{% include 'user_score_header.html' %}
|
||||
<ul class="score-answer-list">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-2 d-flex align-items-center justify-content-between">
|
||||
<div class="small text-muted">Filter questions by marks</div>
|
||||
<div>
|
||||
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
|
||||
<option value="all">All scores</option>
|
||||
<option value="2">2 (full)</option>
|
||||
<option value="1">1</option>
|
||||
<option value="0">0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
|
||||
<br/>
|
||||
<span class="user-answer-score user-answer-score-{{score}}">
|
||||
<span class="submitted-user-answer">
|
||||
<pre>{{ans}}</pre>
|
||||
</span>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">Question {{ forloop.counter }}</div>
|
||||
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
|
||||
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
|
||||
{% if exam.publish_results or view_all_results %}
|
||||
Marks: <span class="answer-score">{{score}}</span>
|
||||
<div class="small mt-2">
|
||||
Marks:
|
||||
{% if score == 2 %}
|
||||
<span class="badge bg-success ms-1">{{ score }}</span>
|
||||
{% elif score == 1 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">{{ score }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
{% if exam.publish_results %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{% if exam.publish_results %}
|
||||
<div class="card sticky-top" style="top:1rem;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Viewer</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="viewer-block">
|
||||
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
|
||||
<div>Question <span class="question-number">No question loaded</span></div>
|
||||
<div id="question-title" class="fw-bold my-2"></div>
|
||||
<div id="question-question" class="mb-2"></div>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
|
||||
<div class="small text-muted">Answers:</div>
|
||||
<div class="answers mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Ensure the sticky viewer stays within its column and the canvas scales */
|
||||
.anatomy .card.sticky-top { max-width: 100%; }
|
||||
/* Keep viewer usable by enforcing a minimum width on larger viewports */
|
||||
@media (min-width: 768px) {
|
||||
.anatomy .card.sticky-top { min-width: 600px; }
|
||||
}
|
||||
.anatomy .viewer-block .viewer-body { overflow: auto; }
|
||||
.viewer-block .answers { background: #f8f9fa; padding: .5rem; border-radius: .25rem; color: #212529; }
|
||||
.viewer-block .answers, .viewer-block .answers * { color: #212529 !important; }
|
||||
.viewer-block .answers pre { margin: 0; white-space: pre-wrap; }
|
||||
.dicom-viewer, .dicom-viewer .canvas-panel, .dicom-viewer .cornerstone-element { max-width: 100%; box-sizing: border-box; }
|
||||
.dicom-viewer canvas { max-width: 100% !important; width: 100% !important; height: auto !important; display: block; }
|
||||
#single-dicom-viewer { width: 100%; }
|
||||
/* hide horizontal overflow from the card body so the page doesn't scroll sideways */
|
||||
.anatomy .card .card-body { overflow-x: hidden; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
{% if exam.publish_results %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$(".hide-show").click((evt, el) => {
|
||||
$(".inner-display-block").toggle();
|
||||
})
|
||||
$("#hide-viewer").on('click', function(){ $('.viewer-block .viewer-body').toggle(); });
|
||||
|
||||
$(".view-question-link").on("click", function (e) {
|
||||
console.log("click", e)
|
||||
// Filter questions by score using Bootstrap's d-none for consistent layout
|
||||
$("#filter-score").on('change', function(){
|
||||
const val = $(this).val();
|
||||
$(".list-group-item[data-score]").each(function(){
|
||||
const s = $(this).data('score');
|
||||
if(val === 'all' || String(s) === String(val)) $(this).removeClass('d-none'); else $(this).addClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$(".view-question-button").on("click", function (e) {
|
||||
question_number = e.currentTarget.dataset.qn;
|
||||
$("#single-dicom-viewer").empty();
|
||||
$.ajax({
|
||||
@@ -53,29 +133,23 @@
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
|
||||
let event = new CustomEvent('loadDicomViewerUrls', {
|
||||
"detail": { "images": data.images, "annotations": data.annotations }
|
||||
});
|
||||
|
||||
let event = new CustomEvent('loadDicomViewerUrls', { "detail": { "images": data.images, "annotations": data.annotations } });
|
||||
window.dispatchEvent(event);
|
||||
$(".question-display-block .answers").empty().append(data.answers.toString());
|
||||
$(".viewer-block .answers").empty().append(data.answers.toString());
|
||||
$("#question-title").empty().append(data.title);
|
||||
$("#question-question").empty().append(data.question);
|
||||
n = parseInt(question_number) + 1
|
||||
$(".question-display-block .question-number").empty().append(n);
|
||||
$(".inner-display-block").show();
|
||||
})
|
||||
.always(function () {
|
||||
console.log('[Done]');
|
||||
})
|
||||
$(".viewer-block .question-number").empty().append(n);
|
||||
$(".viewer-block .viewer-body").show();
|
||||
})
|
||||
.always(function () { console.log('[Done]'); });
|
||||
});
|
||||
|
||||
$(".view-question-link").first().click();
|
||||
|
||||
// auto-click first view button to populate viewer
|
||||
const firstView = $(".view-question-button:visible").first();
|
||||
if(firstView.length){ firstView.click(); }
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
{% load partials %}
|
||||
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="card-body">
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
|
||||
</div>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-start">
|
||||
<details class="mb-0 flex-grow-1 me-2">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
|
||||
</details>
|
||||
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
|
||||
+3
-96
@@ -786,6 +786,9 @@ def mark2_overview(request, pk):
|
||||
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not GenericExamViews.check_user_marker_access(request.user, pk):
|
||||
raise PermissionDenied
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
@@ -1045,102 +1048,6 @@ def mark2_question_marked(request, pk):
|
||||
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
|
||||
|
||||
|
||||
|
||||
#@login_required
|
||||
#def exam_scores_refresh(request, pk):
|
||||
# exam = get_object_or_404(Exam, pk=pk)
|
||||
#
|
||||
# if not exam.exam_mode:
|
||||
# raise Http404("Packet not in exam mode")
|
||||
#
|
||||
# questions = exam.exam_questions.all() # Don't worry about order here
|
||||
#
|
||||
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
#
|
||||
# # Force a score update
|
||||
# for c in cids:
|
||||
# c.get_answer_score(cached=False)
|
||||
#
|
||||
# return render(
|
||||
# request,
|
||||
# "anatomy/base.html",
|
||||
# {
|
||||
# "simple_content": "Answer scores updated",
|
||||
# },
|
||||
# )
|
||||
|
||||
|
||||
def exam_scores_cid_user(request, pk, cid, passcode):
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
if not exam.check_cid_user(cid, passcode, request.user):
|
||||
raise Http404("Error accessing exam")
|
||||
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
answers_and_marks = []
|
||||
answers_marks = []
|
||||
answers = []
|
||||
|
||||
view_all_results = False
|
||||
if request.user.groups.filter(name="view_all_results").exists():
|
||||
view_all_results = True
|
||||
|
||||
for q in questions:
|
||||
# Get user answer
|
||||
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
|
||||
if not user_answer:
|
||||
# skip if no answer
|
||||
answers_marks.append(0)
|
||||
answers.append("")
|
||||
answer_score = 0
|
||||
ans = "Not answered"
|
||||
else:
|
||||
ans = user_answer.answer
|
||||
|
||||
answer_score = user_answer.get_answer_score()
|
||||
|
||||
correct_answer = q.get_primary_answer()
|
||||
|
||||
if not exam.publish_results and not view_all_results:
|
||||
correct_answer = "*****"
|
||||
answer_score = 0
|
||||
answers.append(ans)
|
||||
answers_marks.append(answer_score)
|
||||
answers_and_marks.append((ans, answer_score, correct_answer))
|
||||
|
||||
if "unmarked" in answers_marks:
|
||||
answered = [i for i in answers_marks if type(i) == int]
|
||||
total_score = sum(answered)
|
||||
unmarked_number = len(answers_marks) - len(answered)
|
||||
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
|
||||
else:
|
||||
total_score = sum(answers_marks)
|
||||
|
||||
max_score = len(questions) * 2
|
||||
|
||||
return render(
|
||||
request,
|
||||
"anatomy/exam_scores_user.html",
|
||||
{
|
||||
"exam": exam,
|
||||
"cid": cid,
|
||||
"passcode": passcode,
|
||||
"questions": questions,
|
||||
"answers": answers,
|
||||
"answers_marks": answers_marks,
|
||||
"total_score": total_score,
|
||||
"max_score": max_score,
|
||||
"answers_and_marks": answers_and_marks,
|
||||
"view_all_results": view_all_results,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
model = AnatomyQuestion
|
||||
form_class = AnatomyQuestionForm
|
||||
|
||||
@@ -249,28 +249,6 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
return len(dupes)
|
||||
|
||||
|
||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
images_removed = []
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= start and n <= end:
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
|
||||
def get_cases_user(request):
|
||||
return Case.objects.filter(author=request.user)
|
||||
|
||||
+187
-10
@@ -1,4 +1,8 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Div, Field
|
||||
from collections import OrderedDict
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -17,6 +21,7 @@ from .models import (
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -81,6 +86,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
||||
user=None,
|
||||
request=None,
|
||||
):
|
||||
# Default the archive filter to False when not provided so collections
|
||||
# are not archived by default in listings.
|
||||
if data is None:
|
||||
data = QueryDict('', mutable=True)
|
||||
data['archive'] = 'false'
|
||||
else:
|
||||
# QueryDict instances need to be copied to be mutable
|
||||
try:
|
||||
if 'archive' not in data:
|
||||
data = data.copy()
|
||||
data['archive'] = 'false'
|
||||
except Exception:
|
||||
# Fallback for non-QueryDict mappings
|
||||
if isinstance(data, dict) and 'archive' not in data:
|
||||
data = dict(data)
|
||||
data['archive'] = 'false'
|
||||
if not request.user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = queryset.prefetch_related("author").filter(
|
||||
author__id=request.user.id
|
||||
@@ -95,9 +116,22 @@ class CaseFilter(django_filters.FilterSet):
|
||||
author = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_authors, null_label="No author"
|
||||
)
|
||||
# Show a distinct list of `Series.examination` values instead of raw Series
|
||||
# entries so examinations that differ only by plane/description appear once.
|
||||
series = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_series, null_label="No series"
|
||||
field_name='series__examination',
|
||||
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
|
||||
label='Examination',
|
||||
)
|
||||
# Free-text search to help find the correct examination when there are
|
||||
# many similarly-named Series/Examination entries.
|
||||
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
|
||||
# Full list of conditions and a text search to find conditions
|
||||
condition = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Condition.objects.all(),
|
||||
label='Condition',
|
||||
)
|
||||
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
|
||||
differential_case = django_filters.ModelMultipleChoiceFilter(
|
||||
label="Differential",
|
||||
field_name="differentialcase__condition",
|
||||
@@ -132,6 +166,58 @@ class CaseFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_examination_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Examination records.
|
||||
|
||||
Matches against the Examination string representation and any
|
||||
common identifying fields (name/code). Uses a case-insensitive
|
||||
contains lookup and filters Cases which have a Series linked to
|
||||
a matching Examination.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
from generic.models import Examination
|
||||
|
||||
# Only attempt to match PK when the provided value looks like an integer
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value) | Q(pk=pk_val)
|
||||
).distinct()
|
||||
else:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value)
|
||||
).distinct()
|
||||
|
||||
return queryset.filter(series__examination__in=matches).distinct()
|
||||
|
||||
def filter_condition_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Condition records.
|
||||
|
||||
Matches both direct `condition` M2M and `differential` entries.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Only try PK match when numeric
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
|
||||
else:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
|
||||
|
||||
# Match either direct condition M2M or differential (through Differential)
|
||||
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
|
||||
|
||||
|
||||
class SeriesFilter(django_filters.FilterSet):
|
||||
case = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -353,8 +439,16 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||
# Numeric min/max values entered by the user (interpreted via `age_unit`)
|
||||
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
|
||||
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
|
||||
AGE_UNIT_CHOICES = (
|
||||
("days", "Days"),
|
||||
("weeks", "Weeks"),
|
||||
("months", "Months"),
|
||||
("years", "Years"),
|
||||
)
|
||||
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
@@ -364,9 +458,10 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
# Use the global Examination and Modality querysets from the Series model
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
# Set up the actual querysets for the filters
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
# We'll set the actual querysets for the filters after applying
|
||||
# any NormalCase queryset restrictions for the current user so
|
||||
# that the choices only include examinations/modalities that
|
||||
# actually have NormalCase records available.
|
||||
|
||||
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||
@@ -376,18 +471,100 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
queryset = NormalCase.objects.select_related("case")
|
||||
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||
|
||||
# Determine the NormalCase queryset we'll use to build choices
|
||||
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
|
||||
|
||||
# Limit examination and modality choices to those present on the
|
||||
# available NormalCase records (respecting user restrictions).
|
||||
try:
|
||||
exam_ids = nc_qs.values_list("examination_id", flat=True)
|
||||
mod_ids = nc_qs.values_list("modality_id", flat=True)
|
||||
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
|
||||
except Exception:
|
||||
# Fallback to all if something goes wrong
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
def filter_min_age(self, queryset, name, value):
|
||||
# Attach a Crispy FormHelper to render the filter form as a
|
||||
# horizontal Bootstrap grid. We set `form_tag=False` because the
|
||||
# template already provides the <form> element.
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
helper = FormHelper()
|
||||
helper.form_method = "get"
|
||||
helper.form_tag = False
|
||||
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 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",
|
||||
),
|
||||
)
|
||||
# Assign helper to the generated form instance and tweak widgets
|
||||
if hasattr(self, 'form') and self.form is not None:
|
||||
# Wider numeric inputs and default unit
|
||||
try:
|
||||
form = self.form
|
||||
if "min_value" in form.fields:
|
||||
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 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 w-auto", "style": "min-width:220px;"})
|
||||
form.helper = helper
|
||||
except Exception:
|
||||
# don't crash if form structure differs
|
||||
self.form.helper = helper
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _convert_to_days(self, value, unit: str) -> int:
|
||||
try:
|
||||
v = float(value)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if unit == "days":
|
||||
return int(v)
|
||||
if unit == "weeks":
|
||||
return int(v * 7)
|
||||
if unit == "months":
|
||||
# approximate month length
|
||||
return int(v * (365.25 / 12.0))
|
||||
# default: years
|
||||
return int(v * 365.25)
|
||||
|
||||
def filter_min_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
|
||||
except Exception:
|
||||
unit = "years"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_age(self, queryset, name, value):
|
||||
def filter_max_value(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
|
||||
except Exception:
|
||||
unit = "years"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
+42
-5
@@ -720,11 +720,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
series_qs = self.series.all()
|
||||
if exclude_series_ids is not None:
|
||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
||||
images = [
|
||||
[image.image.url for image in series.images.all()]
|
||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
||||
]
|
||||
series_qs = series_qs.exclude(id__in=exclude_series_ids)
|
||||
|
||||
# Ensure series are ordered by the through model sort_order
|
||||
series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
|
||||
|
||||
images = []
|
||||
for series in series_list:
|
||||
# If images were prefetched, use the cached list and filter in Python
|
||||
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
|
||||
if prefetched is not None:
|
||||
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
|
||||
else:
|
||||
imgs = [image.image.url for image in series.images.filter(removed=False)]
|
||||
images.append(imgs)
|
||||
|
||||
if as_json:
|
||||
return json.dumps(images)
|
||||
@@ -1039,6 +1048,27 @@ class Series(SeriesBase):
|
||||
else:
|
||||
return f"{self.examination} ({self.plane})"
|
||||
|
||||
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
|
||||
"""Check if the user can edit the series.
|
||||
|
||||
Args:
|
||||
user (User): The user to check.
|
||||
Returns:
|
||||
bool: True if the user can edit the series, False otherwise.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
if self.author.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
if allow_if_can_edit_cases:
|
||||
# Check if user can edit any of the cases this series is in
|
||||
for case in self.case.all():
|
||||
if case.check_user_can_edit(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_full_str(self):
|
||||
if self.case:
|
||||
case_id = ", ".join([str(case) for case in self.case.all()])
|
||||
@@ -1627,6 +1657,13 @@ class CaseDetail(models.Model):
|
||||
return self.case.history or "No history provided"
|
||||
return ""
|
||||
|
||||
def get_history(self):
|
||||
if self.redact_history:
|
||||
return "[Redacted]"
|
||||
if self.override_history and self.override_history != "":
|
||||
return self.override_history
|
||||
return self.case.history or "No history provided"
|
||||
|
||||
def get_case_series_nested(self, include_priors=True):
|
||||
case_series_images = self.case.get_series_images_nested(as_json=False)
|
||||
|
||||
|
||||
+44
-16
@@ -19,6 +19,8 @@ from .models import (
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
@@ -83,12 +85,14 @@ class CaseTable(SelectionTable):
|
||||
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
return mark_safe(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
|
||||
'<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
|
||||
'<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
|
||||
'</svg> View'
|
||||
)
|
||||
else:
|
||||
return f'View'
|
||||
return 'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
@@ -108,7 +112,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
return mark_safe(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
@@ -130,7 +134,7 @@ class CaseTable(SelectionTable):
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
@@ -151,7 +155,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
associated_cases = tables.Column(
|
||||
@@ -166,7 +170,7 @@ class CaseTable(SelectionTable):
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
clone = tables.LinkColumn(
|
||||
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
@@ -199,7 +203,7 @@ class PopupLinkColumn(tables.Column):
|
||||
obj = value.first()
|
||||
|
||||
if obj is None:
|
||||
return format_html("<span>No image<span>")
|
||||
return mark_safe("<span>No image</span>")
|
||||
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
@@ -209,7 +213,7 @@ class SeriesTable(SelectionTable):
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:series_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
popup = tables.Column(empty_values=(), orderable=False)
|
||||
|
||||
@@ -290,7 +294,7 @@ class ConditionTable(SelectionTable):
|
||||
)
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -317,7 +321,7 @@ class FindingTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(SelectionTable):
|
||||
@@ -344,7 +348,7 @@ class StructureTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(SelectionTable):
|
||||
@@ -412,7 +416,7 @@ class SubspecialtyTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -432,8 +436,11 @@ class CaseCollectionTable(SelectionTable):
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
|
||||
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
|
||||
#clone = tables.LinkColumn(
|
||||
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
#)
|
||||
@@ -449,6 +456,8 @@ class CaseCollectionTable(SelectionTable):
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"updated",
|
||||
"collection_type",
|
||||
"publish_results",
|
||||
"active",
|
||||
@@ -466,6 +475,25 @@ class CaseCollectionTable(SelectionTable):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_created(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
# compact date for display, full datetime in tooltip
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
def render_updated(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
|
||||
@@ -49,6 +49,15 @@
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef case-series %}
|
||||
{% for series in case.get_ordered_series %}
|
||||
@@ -201,15 +200,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
|
||||
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||
{% if caseresource.pre_review %}
|
||||
<span title="This resource will be avalible to review when or taking a case">(pre)</span>
|
||||
{% else %}
|
||||
<span title="This resource will only be available when reviewing a completed case">(post)</span>
|
||||
<div class="mb-3">
|
||||
<h5>Resources</h5>
|
||||
|
||||
{% if can_edit%}
|
||||
<div class="mb-2">
|
||||
<div id="resource-search-panel-placeholder">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:resource_search_panel' %}?case_id={{ case.pk }}"
|
||||
hx-target="#resource-search-panel-placeholder"
|
||||
hx-swap="innerHTML">
|
||||
Add Resource
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/case_resources_list.html' with case=case %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
@@ -306,7 +316,6 @@
|
||||
|
||||
<!-- Series Findings -->
|
||||
<h5>Series Findings</h5>
|
||||
{% if case.ordered_series %}
|
||||
{% for series in case.ordered_series %}
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
|
||||
@@ -346,14 +355,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% empty%}
|
||||
No series associated with case.
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Case Display Sets -->
|
||||
<h5 class="mt-3">Case Display Sets</h5>
|
||||
{% if case.display_sets %}
|
||||
<ul class="list-group">
|
||||
{% for ds in case.display_sets.all %}
|
||||
<li class="list-group-item">
|
||||
@@ -363,6 +370,7 @@
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
||||
<button type="button" class="btn btn-secondary btn-sm view-displayset-modal ms-2" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in Modal</button>
|
||||
</span>
|
||||
<div>
|
||||
<strong>Findings:</strong>
|
||||
@@ -414,11 +422,10 @@
|
||||
</details>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<span class="text-muted">No display sets for this case.</span>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">No display sets for this case.</span>
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
@@ -437,19 +444,19 @@
|
||||
<b>Collections:</b>
|
||||
|
||||
{% if can_edit %}
|
||||
<button
|
||||
<button class="btn btn-sm btn-primary"
|
||||
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
|
||||
hx-target="#collection-form">
|
||||
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>
|
||||
{% if can_edit %}
|
||||
<button
|
||||
class="btn btn-danger btn-sm remove-button"
|
||||
class="btn btn-sm btn-outline-danger remove-button"
|
||||
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
|
||||
hx-target="#collection-{{ collection.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
@@ -483,6 +490,22 @@
|
||||
{% include 'question_notes.html' %}
|
||||
|
||||
</div>
|
||||
<div class="modal fade" id="displaysetModal" tabindex="-1" aria-labelledby="displaysetModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="displaysetModalLabel">Display Set Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="displaysetModalBody">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a
|
||||
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
|
||||
@@ -763,6 +786,30 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Display Set modal handler
|
||||
const dsModal = new bootstrap.Modal(document.getElementById("displaysetModal"));
|
||||
const dsModalBody = document.getElementById("displaysetModalBody");
|
||||
|
||||
document.querySelectorAll(".view-displayset-modal").forEach(button => {
|
||||
button.addEventListener("click", function () {
|
||||
const url = this.getAttribute("data-url");
|
||||
dsModalBody.innerHTML = "<p>Loading...</p>";
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
dsModalBody.innerHTML = html;
|
||||
setTimeout(function () {
|
||||
try { window.mountDicomViewers(); } catch (e) { console.warn('mountDicomViewers not available', e); }
|
||||
}, 500);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading display set details:", error);
|
||||
dsModalBody.innerHTML = "<p>Failed to load display set.</p>";
|
||||
});
|
||||
dsModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="case-displayset-modal">
|
||||
<div class="mb-3">
|
||||
<h5>{{ ds.name }}</h5>
|
||||
{% if can_edit %}
|
||||
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
|
||||
{% endif %}
|
||||
{% if ds.description %}
|
||||
<div class="text-muted">{{ ds.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
|
||||
<div class="mb-3">
|
||||
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
|
||||
</div>
|
||||
|
||||
{% if ds.viewerstate and ds.annotations %}
|
||||
<div class="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
|
||||
>Reload display set</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<strong>Findings:</strong>
|
||||
{% if ds.findings.all %}
|
||||
<ul>
|
||||
{% for finding in ds.findings.all %}
|
||||
<li>{{ finding }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Structures:</strong>
|
||||
{% if ds.structures.all %}
|
||||
<ul>
|
||||
{% for structure in ds.structures.all %}
|
||||
<li>{{ structure }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Conditions:</strong>
|
||||
{% if ds.conditions.all %}
|
||||
<ul>
|
||||
{% for condition in ds.conditions.all %}
|
||||
<li>{{ condition }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||
<div>
|
||||
<strong>Viewer State:</strong>
|
||||
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Annotations:</strong>
|
||||
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,7 +38,8 @@
|
||||
{% block js %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.mountDicomViewers();
|
||||
// Delay mounting slightly to ensure viewer assets and DOM are ready
|
||||
setTimeout(function() { window.mountDicomViewers(); }, 500);
|
||||
|
||||
|
||||
{% if selected_displayset %}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Search Cases</h2>
|
||||
|
||||
<form method="get" class="row g-2 mb-3" id="case-search-form">
|
||||
<div class="col-md-6">
|
||||
<input type="search" name="q" value="{{ q }}" class="form-control" placeholder="Search cases...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="field" class="form-select">
|
||||
<option value="all" {% if field == 'all' %}selected{% endif %}>All fields</option>
|
||||
<option value="title" {% if field == 'title' %}selected{% endif %}>Title</option>
|
||||
<option value="description" {% if field == 'description' %}selected{% endif %}>Description</option>
|
||||
<option value="history" {% if field == 'history' %}selected{% endif %}>History</option>
|
||||
<option value="author" {% if field == 'author' %}selected{% endif %}>Author</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<select name="subspecialty" class="form-select">
|
||||
<option value="">Any subspecialty</option>
|
||||
{% for s in subspecialty_queryset %}
|
||||
<option value="{{ s.pk }}" {% if selected_subspecialty and selected_subspecialty|stringformat:"s" == s.pk|stringformat:"s" %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="my" value="1" class="form-check-input" {% if my %}checked{% endif %}> My cases</label>
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="open" value="1" class="form-check-input" {% if open %}checked{% endif %}> Open access</label>
|
||||
<label class="form-check-label"><input type="checkbox" name="has_images" value="1" class="form-check-input" {% if has_images %}checked{% endif %}> Has images</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<a href="{% url 'atlas:case_search_page' %}" class="btn btn-outline-secondary ms-2">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=None q=q %}
|
||||
|
||||
{# pagination controls #}
|
||||
{% if cases.has_other_pages %}
|
||||
<nav aria-label="Case results pagination">
|
||||
<ul class="pagination mt-3">
|
||||
{% if cases.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.previous_page_number }}">Previous</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ cases.number }} of {{ cases.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if cases.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.next_page_number }}">Next</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -72,29 +72,6 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{% crispy form form.helper %}
|
||||
{% comment %} <h3>Cases:</h3>
|
||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||
<input type="button" value="Add More Cases" id="add_more_case">
|
||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||
<div id="case_formset" class="sortable list_formset">
|
||||
<ol>
|
||||
{% for form in case_formset %}
|
||||
<li class="no-error case-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
{{ case_formset.management_form }}
|
||||
</form>
|
||||
<div id="empty_case_form" style="display:none">
|
||||
<li class='no_error case-formset'>
|
||||
{{ case_formset.empty_form }}
|
||||
</li>
|
||||
</div> {% endcomment %}
|
||||
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||
</form>
|
||||
<script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-trigger="keyup delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
@@ -20,25 +19,30 @@
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
{% if casesdetails|length > 20 %}
|
||||
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
|
||||
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% 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" %}
|
||||
{% 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>
|
||||
Collection type: <span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
Self review: <span class="badge bg-light text-dark me-2">{{ collection.self_review }}</span>
|
||||
Open access:
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
Exam mode: <span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
@@ -152,6 +156,33 @@
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
<!-- Modal for case fields (history / discussion / report) -->
|
||||
<div id="case-field-modal" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
var pk = '{{ collection.pk }}';
|
||||
var key = 'collection_warning_dismissed_' + pk;
|
||||
var alertEl = document.getElementById('collection-warning-' + pk);
|
||||
if (!alertEl) return;
|
||||
if (localStorage.getItem(key) === '1') {
|
||||
alertEl.remove();
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('collection-warning-close-' + pk);
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
} catch (e) { console.warn('collection warning persistence error', e); }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
|
||||
{% 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>
|
||||
|
||||
@@ -71,6 +71,22 @@
|
||||
<div class="question-block-snippet" style="display:none;">
|
||||
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
|
||||
</div>
|
||||
{# Attached resources for this case: allow loading into the shared viewer #}
|
||||
{% if case.caseresource_set.exists %}
|
||||
<div class="mt-2">
|
||||
<strong>Resources:</strong>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
{% for cr in case.caseresource_set.all %}
|
||||
{% with res=cr.resource %}
|
||||
<div>
|
||||
<span class="me-2">{{ res.name }}</span>
|
||||
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case.display_sets.all %}
|
||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||
{% for ds in case.display_sets.all %}
|
||||
@@ -335,6 +351,19 @@
|
||||
//}
|
||||
//win2 = openSecondaryWindow(url);
|
||||
})
|
||||
// Load resource into linked viewer
|
||||
$('.open-resource').click(function() {
|
||||
var src = $(this).data('src');
|
||||
var title = $(this).data('title') || 'Resource';
|
||||
$('#loading-case').show();
|
||||
$("#open-viewer-local").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
|
||||
$('#current-case-history').html('');
|
||||
$('#current-case-discussion').html('');
|
||||
$('#current-case-report').html('');
|
||||
|
||||
bc.postMessage({"type": "open", "url": src});
|
||||
});
|
||||
$('.send-message').click(function() {
|
||||
var url = $(this).data('target');
|
||||
bc.postMessage(url);
|
||||
|
||||
@@ -83,6 +83,20 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Also include cases that reference this condition via CaseDisplaySet #}
|
||||
{% if condition.casedisplayset_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Cases via Display Sets</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for ds in condition.casedisplayset_set.all %}
|
||||
<li class="mb-1">{{ ds.case.get_link }} <small class="text-muted">[Display set: {{ ds.name }}]</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.differential_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# Also include cases referenced via CaseDisplaySet that list this finding #}
|
||||
{% if finding.casedisplayset_set.all %}
|
||||
<h4>Cases via Display Sets</h4>
|
||||
<ul>
|
||||
{% for ds in finding.casedisplayset_set.all %}
|
||||
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if finding.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
|
||||
@@ -14,14 +14,27 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
|
||||
<button class="btn btn-primary"
|
||||
hx-get="{% url 'atlas:user_collections' %}"
|
||||
hx-target="#user-collections-placeholder"
|
||||
hx-swap="innerHTML">
|
||||
Collections to view / take
|
||||
</button>
|
||||
<button class="btn btn-outline-primary ms-2"
|
||||
hx-get="{% url 'atlas:user_related_collections' %}"
|
||||
hx-target="#user-related-collections-placeholder"
|
||||
hx-swap="innerHTML">
|
||||
My collections
|
||||
</button>
|
||||
<div id="user-collections-placeholder" class="mt-3"></div>
|
||||
<div id="user-related-collections-placeholder" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
@@ -29,6 +42,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- user collections will load into the Collections card when requested -->
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
@@ -38,6 +52,7 @@
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -50,9 +65,10 @@
|
||||
<input class="form-control" id="case-search-input" name="q" type="search"
|
||||
placeholder="Search cases by title or description"
|
||||
hx-get="{% url 'atlas:case_search_partial' %}"
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
|
||||
hx-trigger="input delay:500ms, keyup[key=='Enter']"
|
||||
hx-target="#case-search-results"
|
||||
hx-indicator="#case-search-indicator" />
|
||||
<a class="btn btn-outline-secondary ms-2" href="{% url 'atlas:case_search_page' %}" title="Open advanced case search">Advanced</a>
|
||||
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Normals</h1>
|
||||
{% help "Help" %}
|
||||
<p>This page lists all normal cases available in the atlas.</p>
|
||||
<p>You can use the filter form to narrow down the list based on criteria such as age, modality, or examination type.</p>
|
||||
<p>Click on a case title to view the case.</p>
|
||||
|
||||
<p>See a case that isn't normal? Please add feedback to the case so it can be reviewed.</p>
|
||||
|
||||
{% endhelp%}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-12">
|
||||
<form method="get" class="form-inline">
|
||||
{% for field in filter.form.visible_fields %}
|
||||
<div class="mb-2">
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% crispy filter.form %}
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-primary" type="submit">Filter</button>
|
||||
<a class="btn btn-link" href="?">Clear</a>
|
||||
@@ -59,3 +63,5 @@
|
||||
</nav>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
@@ -9,18 +9,25 @@
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %}
|
||||
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %}
|
||||
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %}
|
||||
{% for cond in case.condition.all %}
|
||||
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for pres in case.presentation.all %}
|
||||
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for ss in case.subspecialty.all %}
|
||||
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
{# show up to three series modalities/examinations if present #}
|
||||
{% for s in case.series.all|slice:":3" %}
|
||||
<span class="badge bg-light text-dark me-1">
|
||||
{% if s.modality %}{{ s.modality.modality }}{% endif %}
|
||||
{% if s.examination %} / {{ s.examination.examination }}{% endif %}
|
||||
{% if s.modality and s.modality.modality or s.examination and s.examination.examination %}
|
||||
<span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}">
|
||||
{% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<small class="text-muted">No series</small>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% if case.normal_case %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||
{% if can_mark_normal %}
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||
hx-target="#normal-toggle-block"
|
||||
@@ -14,7 +14,7 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if can_mark_normal %}
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||
hx-target="body"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{% comment %}Partial listing of a user's collections for HTMX swaps{% endcomment %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Your collections</h5>
|
||||
|
||||
<h6 class="mt-3">Available to start</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% for collection in available_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No collections available to start.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">In progress</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% for collection in in_progress_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-warning text-dark ms-2">In progress</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No in-progress collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">Finished</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% for collection in finished_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-success ms-2">Completed</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No finished collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Related collections</h5>
|
||||
|
||||
<ul class="list-unstyled">
|
||||
{% for item in related_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_detail' item.collection.pk %}">{{ item.collection.name }}</a>
|
||||
{% if item.is_author %}<span class="badge bg-primary ms-2">Author</span>{% endif %}
|
||||
{% if item.is_marker %}<span class="badge bg-secondary ms-2">Marker</span>{% endif %}
|
||||
{% if item.collection.description %}<small class="text-muted"> — {{ item.collection.description }}</small>{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No related collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'atlas:collection_view' %}" class="btn btn-sm btn-outline-primary">View all collections</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,14 @@
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ field|capfirst }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% if content %}
|
||||
<div class="pre-whitespace">{{ content|linebreaks }}</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No {{ field }} available for this case.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
@@ -13,19 +13,89 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
{% if casedetail.series_count|default:0 == 0 %}
|
||||
<span class="text-warning">This case has no series / stacks attached</span>
|
||||
{% else %}
|
||||
{{ casedetail.case.series.count }} series
|
||||
{{ casedetail.series_count }} series
|
||||
{% endif %}
|
||||
{% if casedetail.question_schema %}
|
||||
• <span class="text-success">Questions defined</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="btn-group btn-group-sm ms-2" role="group" aria-label="case-field-links">
|
||||
<!-- History button: opens modal populated from hidden content on page -->
|
||||
{% if casedetail.case.history %}
|
||||
<a class="btn btn-outline-secondary" href="#"
|
||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-history').innerHTML;"
|
||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View history">
|
||||
<i class="bi bi-clock-history text-primary"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No history">
|
||||
<i class="bi bi-clock-history text-muted"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Discussion button -->
|
||||
{% if casedetail.case.discussion %}
|
||||
<a class="btn btn-outline-secondary" href="#"
|
||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-discussion').innerHTML;"
|
||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View discussion">
|
||||
<i class="bi bi-chat-left-text text-primary"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No discussion">
|
||||
<i class="bi bi-chat-left-text text-muted"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Report button -->
|
||||
{% if casedetail.case.report %}
|
||||
<a class="btn btn-outline-secondary" href="#"
|
||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-report').innerHTML;"
|
||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View report">
|
||||
<i class="bi bi-file-earmark-text text-primary"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No report">
|
||||
<i class="bi bi-file-earmark-text text-muted"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{# Pre-render hidden modal content for this case to avoid HTMX calls #}
|
||||
<div class="d-none">
|
||||
<div id="case-field-{{ casedetail.case.pk }}-history">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">History</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<br/>
|
||||
{% if casedetail.get_history %}
|
||||
<div class="pre-whitespace">{{ casedetail.get_history|linebreaks }}</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No history available for this case.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="case-field-{{ casedetail.case.pk }}-discussion">
|
||||
{% include 'atlas/partials/case_field_modal.html' with field='discussion' content=casedetail.case.discussion %}
|
||||
</div>
|
||||
<div id="case-field-{{ casedetail.case.pk }}-report">
|
||||
{% include 'atlas/partials/case_field_modal.html' with field='report' content=casedetail.case.report %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div id="case-resources-list">
|
||||
{% if case.caseresource_set.exists %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for cr in case.caseresource_set.all %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-caseresource-id="{{ cr.id }}" data-pre="{{ cr.pre_review|yesno:'1,0' }}">
|
||||
<div>
|
||||
<a href="{% url 'atlas:resource_detail' cr.resource.pk %}">{{ cr.resource.name }}</a>
|
||||
{% if cr.pre_review %}
|
||||
<span class="badge bg-info text-dark ms-2 small">pre</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary text-light ms-2 small">post</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" title="Toggle pre/post" hx-post="{% url 'atlas:case_toggle_resource_pre' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Toggle</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" hx-post="{% url 'atlas:case_detach_resource' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Remove</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-muted">No resources attached to this case.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,26 +1,31 @@
|
||||
{# Search results #}
|
||||
{% if q %}
|
||||
{% if cases and cases.exists %}
|
||||
{% if cases and cases|length > 0 %}
|
||||
<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>
|
||||
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ case.title }}</h6>
|
||||
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
|
||||
</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 class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% for cond in case.condition.all %}
|
||||
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for pres in case.presentation.all %}
|
||||
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for ss in case.subspecialty.all %}
|
||||
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
|
||||
</a>
|
||||
{% 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 %}
|
||||
@@ -33,11 +38,15 @@
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
{% if collection %}
|
||||
<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>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<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_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' 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_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' 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_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success"></i>
|
||||
{% else %}
|
||||
@@ -18,12 +18,14 @@
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" 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">
|
||||
@@ -34,7 +36,7 @@
|
||||
|
||||
{# 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?">
|
||||
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk 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>
|
||||
|
||||
@@ -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-sm btn-outline-danger 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>
|
||||
@@ -0,0 +1,146 @@
|
||||
<div class="resource-media-wrapper" id="resource_media_wrapper_{{ resource.pk }}" data-src="{{ src|escape }}">
|
||||
<noscript>
|
||||
<div><a href="{{ src }}" target="_blank" rel="noopener">Open media</a></div>
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
|
||||
if (document.getElementById(wrapperId).dataset._inited) return;
|
||||
var wrap = document.getElementById(wrapperId);
|
||||
if (!wrap) return;
|
||||
wrap.dataset._inited = '1';
|
||||
var src = wrap.getAttribute('data-src');
|
||||
if (!src) return;
|
||||
var lower = src.toLowerCase();
|
||||
|
||||
function createImg(src) {
|
||||
var img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.className = 'img-fluid resource-media-element rounded';
|
||||
img.style.maxHeight = '60vh';
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.cursor = 'zoom-in';
|
||||
return img;
|
||||
}
|
||||
function createVideo(src) {
|
||||
var video = document.createElement('video');
|
||||
video.className = 'resource-media-element rounded';
|
||||
video.controls = true;
|
||||
video.preload = 'metadata';
|
||||
video.style.maxWidth = '100%';
|
||||
video.style.maxHeight = '60vh';
|
||||
video.style.objectFit = 'contain';
|
||||
var srcEl = document.createElement('source');
|
||||
srcEl.src = src;
|
||||
video.appendChild(srcEl);
|
||||
return video;
|
||||
}
|
||||
function createIframe(src, embedUrl) {
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = embedUrl || src;
|
||||
iframe.setAttribute('frameborder','0');
|
||||
iframe.setAttribute('allowfullscreen','');
|
||||
iframe.className = 'resource-media-element rounded';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '60vh';
|
||||
return iframe;
|
||||
}
|
||||
|
||||
var el = null;
|
||||
// YouTube
|
||||
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
|
||||
// build embed url
|
||||
var videoId = null;
|
||||
try {
|
||||
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
|
||||
if (m && m[1]) videoId = m[1];
|
||||
} catch(e){}
|
||||
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
|
||||
el = createIframe(src, embed);
|
||||
} else if (lower.indexOf('vimeo.com') !== -1) {
|
||||
var m2 = src.match(/vimeo\.com\/(\d+)/);
|
||||
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
|
||||
el = createIframe(src, embed2);
|
||||
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
|
||||
el = createVideo(src);
|
||||
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
|
||||
el = createImg(src);
|
||||
} else {
|
||||
// unknown: try image first, fallback to link
|
||||
el = createImg(src);
|
||||
// if image fails to load, replace with link
|
||||
el.addEventListener('error', function(){
|
||||
var a = document.createElement('a');
|
||||
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
|
||||
wrap.innerHTML = ''; wrap.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
// append preview element
|
||||
if (el) {
|
||||
wrap.appendChild(el);
|
||||
|
||||
// click -> open modal/full-size
|
||||
el.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
// create modal container if not present
|
||||
var modalId = 'resource_media_modal_{{ resource.pk }}';
|
||||
var existing = document.getElementById(modalId);
|
||||
if (!existing) {
|
||||
var modalDiv = document.createElement('div');
|
||||
modalDiv.className = 'modal fade resource-media-modal';
|
||||
modalDiv.id = modalId;
|
||||
modalDiv.tabIndex = -1;
|
||||
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
|
||||
document.body.appendChild(modalDiv);
|
||||
}
|
||||
var modalEl = document.getElementById(modalId);
|
||||
var body = modalEl.querySelector('.modal-body');
|
||||
body.innerHTML = '';
|
||||
|
||||
// create full-size node depending on type
|
||||
if (el.tagName.toLowerCase() === 'img') {
|
||||
var big = document.createElement('img');
|
||||
big.src = src;
|
||||
big.style.maxWidth = '95vw';
|
||||
big.style.maxHeight = '95vh';
|
||||
big.style.objectFit = 'contain';
|
||||
big.className = 'img-fluid';
|
||||
body.appendChild(big);
|
||||
} else if (el.tagName.toLowerCase() === 'video') {
|
||||
var bigv = document.createElement('video');
|
||||
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
|
||||
var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
|
||||
body.appendChild(bigv);
|
||||
} else if (el.tagName.toLowerCase() === 'iframe') {
|
||||
var frame = document.createElement('iframe');
|
||||
frame.src = el.src;
|
||||
frame.setAttribute('frameborder','0');
|
||||
frame.setAttribute('allowfullscreen','');
|
||||
frame.style.width = '95vw'; frame.style.height = '80vh';
|
||||
body.appendChild(frame);
|
||||
} else {
|
||||
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
|
||||
}
|
||||
|
||||
// show modal using Bootstrap if available
|
||||
try {
|
||||
var bsModal = new bootstrap.Modal(modalEl);
|
||||
bsModal.show();
|
||||
} catch (e) {
|
||||
// fallback: open in new tab
|
||||
window.open(src, '_blank', 'noopener');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.resource-media-wrapper .resource-media-element { display: inline-block; }
|
||||
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
|
||||
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
|
||||
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div id="resource-search-panel" class="mb-2">
|
||||
<div id="resource-search-form" class="row g-2">
|
||||
<input type="hidden" name="case_id" value="{{ case.pk }}" />
|
||||
<div class="col-auto" style="flex: 1 1 300px;">
|
||||
<input type="search" id="resource-search-q" name="q" class="form-control form-control-sm" placeholder="Search resources by name, description or URL"
|
||||
hx-get="{% url 'atlas:resource_search_widget' %}"
|
||||
hx-trigger="input delay:500ms"
|
||||
hx-target="#resource-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#resource-search-panel"
|
||||
hx-indicator=".htmx-indicator">
|
||||
</div>
|
||||
<div class="htmx-indicator col-auto">
|
||||
<img src="/img/bars.svg" alt=""/> Searching...
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="site" class="form-select form-select-sm">
|
||||
<option value="">All sites</option>
|
||||
{% for s in site_queryset %}
|
||||
<option value="{{ s.pk }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="subspecialty" class="form-select form-select-sm">
|
||||
<option value="">All subspecialties</option>
|
||||
{% for sp in subspecialty_queryset %}
|
||||
<option value="{{ sp.pk }}">{{ sp.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="resource-open-access-{{ case.pk }}" name="open_access" value="1">
|
||||
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resource-search-results" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- pure HTMX: no JS fallback; hx-trigger on the input uses input+debounce -->
|
||||
@@ -0,0 +1,39 @@
|
||||
{% if results %}
|
||||
<div id="resource-search-results" hx-swap-oob="true">
|
||||
<ul class="list-unstyled mb-0" id="resource_search_results_list_{{ field }}">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-resource-id="{{ item.id }}">
|
||||
<div class="flex-grow-1">
|
||||
<a href="{% url 'atlas:resource_detail' item.pk %}" target="_blank">{{ item.name|escape }}</a>
|
||||
<div class="small text-muted">{{ item.get_display|safe }}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success resource-attach-btn"
|
||||
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
|
||||
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "0"}'
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#case-resources-list">
|
||||
Attach
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-success resource-attach-pre-btn"
|
||||
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
|
||||
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "1"}'
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#case-resources-list">
|
||||
Attach (pre)
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-2 text-end">
|
||||
<small class="text-muted">{{ results|length }} results</small>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="resource-search-results" hx-swap-oob="true"><em>No resources found</em></div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="alert alert-success">
|
||||
<strong>Truncate complete.</strong>
|
||||
<div class="small mt-1">Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
|
||||
</div>
|
||||
{% if images_removed %}
|
||||
<details class="mt-2">
|
||||
<summary class="small">Show removed image IDs</summary>
|
||||
<div class="small mt-1">{{ images_removed|join:", " }}</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -14,6 +14,22 @@
|
||||
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Media preview: image or video autosized, clickable to open full-size modal #}
|
||||
{% if resource.file or resource.url %}
|
||||
<div class="resource-media-preview mb-3 text-center">
|
||||
{% comment %}Prefer local file if present, otherwise try URL{% endcomment %}
|
||||
{% if resource.file %}
|
||||
{% with src=resource.file.url %}
|
||||
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with src=resource.url %}
|
||||
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.description %}
|
||||
<p class="card-text">{{ resource.description|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -252,7 +252,9 @@
|
||||
}
|
||||
|
||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
||||
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
|
||||
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/
|
||||
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
|
||||
htmx.ajax("GET", truncateUrl, "#truncate-output")
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{# Include cases referenced via CaseDisplaySet that list this structure #}
|
||||
{% if structure.casedisplayset_set.all %}
|
||||
<h4>Cases via Display Sets</h4>
|
||||
<ul>
|
||||
{% for ds in structure.casedisplayset_set.all %}
|
||||
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if structure.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
|
||||
@@ -3,51 +3,12 @@
|
||||
{% block content %}
|
||||
<h2>Collections</h2>
|
||||
|
||||
The following collections are available for you to view / take. If you received a directly link to a collection it will not appear here unless you have started it.
|
||||
<p>
|
||||
The following collections are available for you to view / take. If you received a direct
|
||||
link to a collection it will not appear here unless you have started it.
|
||||
</p>
|
||||
|
||||
|
||||
<h3>Available to start</h3>
|
||||
<ul>
|
||||
{% for collection in available_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No collections available to start.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h3>In progress</h3>
|
||||
<ul>
|
||||
{% for collection in in_progress_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-warning text-dark">In progress</span>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No in-progress collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h3>Finished</h3>
|
||||
<ul>
|
||||
{% for collection in finished_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No finished collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{# Reuse the HTMX-friendly partial so the same markup is used for full-page and HTMX responses #}
|
||||
{% include 'atlas/partials/_user_collections.html' %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
<body>
|
||||
<p>Awating load request</p>
|
||||
This page will display series when loaded from other pages.
|
||||
<div id="viewerContainer" style="width:100%; height:100%; position:relative;">
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe>
|
||||
<div id="resourceDisplay" style="display:none; position:absolute; inset:0; background:#000; align-items:center; justify-content:center; z-index:100000;">
|
||||
<div id="resourceInner" style="position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
|
||||
</div>
|
||||
<button id="resourceClose" style="position:absolute; top:8px; right:8px; z-index:9999;" class="btn btn-sm btn-light">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
@@ -27,12 +34,151 @@
|
||||
document.title = "Case: {{current_case.title}}";
|
||||
console.log('ready');
|
||||
const bc = new BroadcastChannel("viva_load");
|
||||
function isMediaUrl(url) {
|
||||
if (!url) return false;
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
|
||||
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
|
||||
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
|
||||
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
|
||||
return false;
|
||||
}
|
||||
|
||||
function showResource(url) {
|
||||
const type = isMediaUrl(url);
|
||||
const disp = document.getElementById('resourceDisplay');
|
||||
const inner = document.getElementById('resourceInner');
|
||||
inner.innerHTML = '';
|
||||
if (!type) {
|
||||
// fallback: open in iframe
|
||||
document.getElementById('viewer').src = url;
|
||||
disp.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// create a transform wrapper so we can pan/zoom the content uniformly
|
||||
const transformWrap = document.createElement('div');
|
||||
transformWrap.style.display = 'flex';
|
||||
transformWrap.style.alignItems = 'center';
|
||||
transformWrap.style.justifyContent = 'center';
|
||||
transformWrap.style.width = '100%';
|
||||
transformWrap.style.height = '100%';
|
||||
transformWrap.style.overflow = 'hidden';
|
||||
transformWrap.style.touchAction = 'none';
|
||||
transformWrap.style.position = 'relative';
|
||||
|
||||
if (type === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.style.maxWidth = '95%';
|
||||
img.style.maxHeight = '95%';
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.cursor = 'grab';
|
||||
img.draggable = false;
|
||||
transformWrap.appendChild(img);
|
||||
} else if (type === 'video') {
|
||||
const v = document.createElement('video');
|
||||
v.controls = true; v.autoplay = false; v.style.maxWidth = '95%'; v.style.maxHeight = '95%'; v.style.objectFit = 'contain';
|
||||
v.style.cursor = 'grab';
|
||||
const s = document.createElement('source'); s.src = url; v.appendChild(s); transformWrap.appendChild(v);
|
||||
} else if (type === 'embed') {
|
||||
let embed = url;
|
||||
try {
|
||||
const m = url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
|
||||
if (m && m[1]) embed = 'https://www.youtube.com/embed/' + m[1];
|
||||
else if (url.indexOf('vimeo.com') !== -1) {
|
||||
const m2 = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (m2 && m2[1]) embed = 'https://player.vimeo.com/video/' + m2[1];
|
||||
}
|
||||
} catch(e){}
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen','');
|
||||
iframe.style.width = '95%'; iframe.style.height = '85%'; iframe.style.maxWidth = '95%'; iframe.style.maxHeight = '95%';
|
||||
transformWrap.appendChild(iframe);
|
||||
}
|
||||
|
||||
inner.appendChild(transformWrap);
|
||||
|
||||
// Setup zoom/pan controls for the transformWrap
|
||||
(function enablePanZoom(target){
|
||||
let scale = 1; let minScale = 0.5; let maxScale = 6; let startX=0, startY=0; let translateX=0, translateY=0; let dragging=false;
|
||||
const content = target.firstElementChild;
|
||||
|
||||
function updateTransform(){
|
||||
if (content) content.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||||
}
|
||||
|
||||
// Wheel to zoom (allow wheel-only zoom while overlay is shown)
|
||||
target.addEventListener('wheel', function(e){
|
||||
// require overlay active (safety) - if not active, ignore
|
||||
const dispEl = document.getElementById('resourceDisplay');
|
||||
if (!dispEl || dispEl.style.display === 'none') return;
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY || e.wheelDelta;
|
||||
const zoomFactor = delta > 0 ? 1.12 : 0.9;
|
||||
const newScale = Math.min(maxScale, Math.max(minScale, scale * zoomFactor));
|
||||
// adjust translate to zoom to mouse position
|
||||
const rect = content.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left; const my = e.clientY - rect.top;
|
||||
const dx = (mx - rect.width/2); const dy = (my - rect.height/2);
|
||||
// compute scale ratio
|
||||
const ratio = newScale/scale - 1;
|
||||
translateX -= dx * ratio;
|
||||
translateY -= dy * ratio;
|
||||
scale = newScale; updateTransform();
|
||||
}, {passive:false});
|
||||
|
||||
// Pointer drag to pan
|
||||
target.addEventListener('pointerdown', function(e){
|
||||
dragging = true; startX = e.clientX; startY = e.clientY; target.setPointerCapture(e.pointerId); content.style.cursor = 'grabbing';
|
||||
});
|
||||
target.addEventListener('pointermove', function(e){
|
||||
if (!dragging) return; e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; startX = e.clientX; startY = e.clientY; translateX += dx; translateY += dy; updateTransform();
|
||||
});
|
||||
target.addEventListener('pointerup', function(e){ dragging = false; try{ target.releasePointerCapture(e.pointerId);}catch(_){} if (content) content.style.cursor = 'grab'; });
|
||||
target.addEventListener('pointercancel', function(e){ dragging=false; if (content) content.style.cursor='grab'; });
|
||||
|
||||
// double click to reset
|
||||
target.addEventListener('dblclick', function(e){ scale = 1; translateX = 0; translateY = 0; updateTransform(); });
|
||||
|
||||
// Escape to close
|
||||
document.addEventListener('keydown', function(ev){ if (ev.key === 'Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
|
||||
})(transformWrap);
|
||||
}
|
||||
disp.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Centralized hide helper
|
||||
function hideResource() {
|
||||
const disp = document.getElementById('resourceDisplay');
|
||||
const inner = document.getElementById('resourceInner');
|
||||
if (disp) disp.style.display = 'none';
|
||||
if (inner) inner.innerHTML = '';
|
||||
}
|
||||
|
||||
// Robust close: delegate clicks on the overlay so pointer capture doesn't block the button
|
||||
(function(){
|
||||
const disp = document.getElementById('resourceDisplay');
|
||||
if (!disp) return;
|
||||
disp.addEventListener('click', function(e){
|
||||
// if click was on the close button (or inside it), hide
|
||||
const close = e.target.closest && e.target.closest('#resourceClose');
|
||||
if (close) { hideResource(); }
|
||||
});
|
||||
})();
|
||||
|
||||
bc.onmessage = (event) => {
|
||||
console.log("message received");
|
||||
console.log(event);
|
||||
// Always hide any existing resource overlay on a new load
|
||||
try { hideResource(); } catch (e) { /* ignore if not available */ }
|
||||
if (event.data.type == "open") {
|
||||
// If URL looks like an image/video/embed, render in the resource panel
|
||||
if (event.data.url && isMediaUrl(event.data.url)) {
|
||||
showResource(event.data.url);
|
||||
bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
||||
return;
|
||||
}
|
||||
document.getElementById("viewer").src = event.data.url;
|
||||
//document.querySelectorAll("[data-cy='seriesList-btn']")[0].click()
|
||||
bc.postMessage({"type": "open-complete", "url" : event.data.url});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
data-auto-cache-stack="true"
|
||||
></div>
|
||||
|
||||
<!-- Overlay for non-DICOM resources (images/videos/embeds) -->
|
||||
<div id="resourceDisplay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.96); z-index:100000; align-items:center; justify-content:center;">
|
||||
<div id="resourceInner" style="max-width:95vw; max-height:95vh; display:flex; align-items:center; justify-content:center;"></div>
|
||||
<button id="resourceClose" style="position:fixed; top:12px; right:12px; z-index:2001;" class="btn btn-sm btn-light">Close</button>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var win2 = false;
|
||||
|
||||
@@ -80,10 +86,80 @@
|
||||
document.title = "Case: {{current_case.title}}";
|
||||
console.log('ready');
|
||||
const bc = new BroadcastChannel("viva_load");
|
||||
|
||||
// Centralized hide helper and delegated close handling for local viewer
|
||||
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
|
||||
(function(){ const disp=document.getElementById('resourceDisplay'); if(!disp) return; disp.addEventListener('click', function(e){ const close = e.target.closest && e.target.closest('#resourceClose'); if (close) hideResourceLocal(); }); })();
|
||||
|
||||
bc.onmessage = (event) => {
|
||||
// Ensure any existing overlay is closed on new load
|
||||
try { hideResourceLocal(); } catch(_) {}
|
||||
console.log("message received");
|
||||
console.log(event);
|
||||
if (event.data.type == "open") {
|
||||
// If a resource URL is provided (image/video/embed), show it in overlay
|
||||
function isMediaUrl(url) {
|
||||
if (!url) return false;
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
|
||||
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
|
||||
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
|
||||
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
|
||||
return false;
|
||||
}
|
||||
|
||||
function showResource(url) {
|
||||
const type = isMediaUrl(url);
|
||||
const disp = document.getElementById('resourceDisplay');
|
||||
const inner = document.getElementById('resourceInner');
|
||||
inner.innerHTML = '';
|
||||
if (!type) return false;
|
||||
|
||||
const transformWrap = document.createElement('div');
|
||||
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
|
||||
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
|
||||
|
||||
if (type === 'image') {
|
||||
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
|
||||
} else if (type === 'video') {
|
||||
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
|
||||
} else if (type === 'embed') {
|
||||
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
|
||||
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
|
||||
}
|
||||
|
||||
inner.appendChild(transformWrap);
|
||||
|
||||
// pan/zoom
|
||||
(function enablePanZoom(target){
|
||||
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
|
||||
const content=target.firstElementChild;
|
||||
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
|
||||
target.addEventListener('wheel', function(e){
|
||||
const dispEl = document.getElementById('resourceDisplay');
|
||||
if (!dispEl || dispEl.style.display === 'none') return;
|
||||
e.preventDefault();
|
||||
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
|
||||
}, {passive:false});
|
||||
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
|
||||
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
|
||||
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
|
||||
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
|
||||
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
|
||||
})(transformWrap);
|
||||
|
||||
disp.style.display='flex'; return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (event.data.url && isMediaUrl(event.data.url)) {
|
||||
// show resource overlay and do not attempt to mount DICOM viewer
|
||||
if (showResource(event.data.url)) {
|
||||
bc.postMessage({"type": "open-complete-local"});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Hide modal when data is loaded
|
||||
document.getElementById("awaiting-modal").style.display = "none";
|
||||
document.getElementById("root").dataset.images = event.data.images;
|
||||
|
||||
@@ -37,6 +37,7 @@ urlpatterns = [
|
||||
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("collection/related", views.user_related_collections, name="user_related_collections"),
|
||||
path("uploads", views.user_uploads, name="user_uploads"),
|
||||
path("uploads/<int:user_pk>/user", views.other_user_uploads, name="other_user_uploads"),
|
||||
path("uploads/all", views.all_uploads, name="all_uploads"),
|
||||
@@ -49,6 +50,9 @@ urlpatterns = [
|
||||
path("resource/<int:pk>/view", views.resource_view, name="resource_view"),
|
||||
path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"),
|
||||
path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"),
|
||||
path("resource-search-panel/", views.resource_search_panel, name="resource_search_panel"),
|
||||
path("resource-search-widget/", views.resource_search_widget, name="resource_search_widget"),
|
||||
|
||||
path(
|
||||
"resource/create",
|
||||
views.ResourceCreate.as_view(),
|
||||
@@ -177,6 +181,11 @@ urlpatterns = [
|
||||
views.collection_case_details,
|
||||
name="collection_case_details",
|
||||
),
|
||||
|
||||
# HTMX endpoints for attaching/detaching resources to a case
|
||||
path("case/<int:pk>/attach-resource/", views.case_attach_resource, name="case_attach_resource"),
|
||||
path("case/<int:pk>/detach-resource/", views.case_detach_resource, name="case_detach_resource"),
|
||||
path("case/<int:pk>/toggle-resource-pre/", views.case_toggle_resource_pre, name="case_toggle_resource_pre"),
|
||||
# Backwards-compatible redirect from legacy case_id-style URLs
|
||||
path(
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/details",
|
||||
@@ -218,6 +227,8 @@ urlpatterns = [
|
||||
views.collection_case_dicom_json_review,
|
||||
name="collection_case_dicom_json_review",
|
||||
),
|
||||
# Partial endpoint to return a single case field (history/discussion/report) for modal display
|
||||
|
||||
path(
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
|
||||
views.redirect_collection_case_dicom_json_review_by_id,
|
||||
@@ -354,6 +365,11 @@ urlpatterns = [
|
||||
views.GenericExamViews.exam_json_edit,
|
||||
name="exam_json_edit",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/cid_bulk_edit",
|
||||
views.GenericExamViews.exam_cid_bulk_edit,
|
||||
name="exam_cid_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/dicom_json",
|
||||
views.collection_dicom_json,
|
||||
@@ -364,6 +380,7 @@ urlpatterns = [
|
||||
path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"),
|
||||
path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"),
|
||||
path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"),
|
||||
path("<int:pk>/display_sets/modal", views.case_displaysets_modal, name="case_displaysets_modal"),
|
||||
path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"),
|
||||
path(
|
||||
"case/<int:pk>/dicom_json",
|
||||
@@ -407,6 +424,7 @@ urlpatterns = [
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
||||
path("search/cases/", views.case_search_partial, name="case_search_partial"),
|
||||
path("search/cases/page/", views.case_search_page, name="case_search_page"),
|
||||
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
|
||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||
path(
|
||||
@@ -457,6 +475,11 @@ urlpatterns = [
|
||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||
path(
|
||||
"series/<int:series_id>/truncate/<int:start>/<int:end>/",
|
||||
views.series_truncate_htmx,
|
||||
name="series_truncate",
|
||||
),
|
||||
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
@@ -524,6 +547,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"),
|
||||
@@ -621,4 +654,5 @@ urlpatterns = [
|
||||
views.linked_cases_overview,
|
||||
name="linked_cases_overview",
|
||||
),
|
||||
path("cases/by_size", views.cases_by_size, name="cases_by_size"),
|
||||
]
|
||||
|
||||
+588
-49
@@ -30,6 +30,8 @@ from django.views.generic.detail import DetailView
|
||||
import pydicom
|
||||
from pydicom.uid import generate_uid
|
||||
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||||
from django.views.generic import ListView
|
||||
@@ -46,7 +48,7 @@ from django.http import Http404, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from generic.models import CidUser, CidUserExam, CimarCase
|
||||
from generic.models import CidUser, CidUserExam, CimarCase, Site
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
|
||||
@@ -194,6 +196,43 @@ from helpers.cimar import CimarAPI
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def series_truncate_htmx(request, series_id, start, end):
|
||||
"""HTMX endpoint to truncate a series' images to the given inclusive
|
||||
start/end bounds (zero-based). Returns an HTML fragment with the result.
|
||||
"""
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
|
||||
|
||||
images_removed = []
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= int(start) and n <= int(end):
|
||||
# keep images within bounds
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
# Recalculate stored series size
|
||||
try:
|
||||
series.get_total_image_size(refresh=True)
|
||||
except Exception:
|
||||
# ignore errors when recalculating
|
||||
pass
|
||||
|
||||
# Render a simple message fragment for HTMX swap
|
||||
html = render_to_string(
|
||||
"atlas/partials/truncate_result.html",
|
||||
{"images_removed": images_removed, "series": series},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
@login_required
|
||||
def case_search_partial(request):
|
||||
"""Return an HTMX partial listing cases matching the query parameter `q`.
|
||||
@@ -343,6 +382,104 @@ def case_displaysets(request, pk):
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def case_search_page(request):
|
||||
"""Full page case search with quick filters.
|
||||
|
||||
Supports GET params:
|
||||
- q: query string
|
||||
- field: one of ['all','title','description','history','author']
|
||||
- my: '1' to limit to cases authored by current user
|
||||
- open: '1' to limit to open_access cases
|
||||
- has_images: '1' to require series images
|
||||
- subspecialty: id of subspecialty to filter
|
||||
- page: pagination page
|
||||
"""
|
||||
q = request.GET.get("q", "").strip()
|
||||
field = request.GET.get("field", "all")
|
||||
my_cases = request.GET.get("my", "0") == "1"
|
||||
open_access = request.GET.get("open", "0") == "1"
|
||||
has_images = request.GET.get("has_images", "0") == "1"
|
||||
subspecialty_id = request.GET.get("subspecialty")
|
||||
|
||||
qs = get_cases_available_to_user(request.user)
|
||||
|
||||
if my_cases:
|
||||
qs = qs.filter(author=request.user)
|
||||
|
||||
if open_access:
|
||||
qs = qs.filter(open_access=True)
|
||||
|
||||
if subspecialty_id:
|
||||
try:
|
||||
sid = int(subspecialty_id)
|
||||
qs = qs.filter(subspecialty__id=sid)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if q:
|
||||
if field == "title":
|
||||
qs = qs.filter(title__icontains=q)
|
||||
elif field == "description":
|
||||
qs = qs.filter(description__icontains=q)
|
||||
elif field == "history":
|
||||
qs = qs.filter(history__icontains=q)
|
||||
elif field == "author":
|
||||
qs = qs.filter(
|
||||
Q(author__first_name__icontains=q)
|
||||
| Q(author__last_name__icontains=q)
|
||||
| Q(author__username__icontains=q)
|
||||
)
|
||||
else:
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=q)
|
||||
| Q(description__icontains=q)
|
||||
| Q(history__icontains=q)
|
||||
| Q(discussion__icontains=q)
|
||||
| Q(report__icontains=q)
|
||||
| Q(condition__name__icontains=q)
|
||||
| Q(presentation__name__icontains=q)
|
||||
| Q(subspecialty__name__icontains=q)
|
||||
| Q(series__description__icontains=q)
|
||||
| Q(series__modality__modality__icontains=q)
|
||||
| Q(series__examination__examination__icontains=q)
|
||||
| Q(author__first_name__icontains=q)
|
||||
| Q(author__last_name__icontains=q)
|
||||
| Q(author__username__icontains=q)
|
||||
).distinct()
|
||||
|
||||
if has_images:
|
||||
qs = qs.filter(series__images__isnull=False).distinct()
|
||||
|
||||
qs = qs.order_by("-created_date")
|
||||
|
||||
# Paginate results
|
||||
paginator = Paginator(qs, 25)
|
||||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Recent cases (authored by current user)
|
||||
recent_cases = get_cases_available_to_user(request.user).filter(author=request.user).order_by("-created_date")[:5]
|
||||
|
||||
subspecialty_queryset = Subspecialty.objects.all().order_by("name")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/case_search_page.html",
|
||||
{
|
||||
"cases": page_obj,
|
||||
"q": q,
|
||||
"field": field,
|
||||
"my": my_cases,
|
||||
"open": open_access,
|
||||
"has_images": has_images,
|
||||
"subspecialty_queryset": subspecialty_queryset,
|
||||
"selected_subspecialty": subspecialty_id,
|
||||
"recent_cases": recent_cases,
|
||||
},
|
||||
)
|
||||
|
||||
@login_required
|
||||
@user_has_case_view_access
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@@ -403,6 +540,18 @@ def case_displaysets_detail(request, pk):
|
||||
# Redirect to the case_displaysets page with ?displayset=<pk>
|
||||
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
|
||||
|
||||
|
||||
@login_required
|
||||
def case_displaysets_modal(request, pk):
|
||||
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
|
||||
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||||
can_edit = False
|
||||
try:
|
||||
can_edit = displayset.case.check_user_can_edit(request.user)
|
||||
except Exception:
|
||||
can_edit = False
|
||||
return render(request, 'atlas/case_displayset_modal.html', {'ds': displayset, 'can_edit': can_edit})
|
||||
|
||||
def case_displaysets_delete(request, pk):
|
||||
try:
|
||||
displayset = CaseDisplaySet.objects.get(pk=pk)
|
||||
@@ -464,9 +613,6 @@ def case_detail(request, pk):
|
||||
case = get_case_for_case_detail(pk)
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -475,9 +621,9 @@ def case_detail(request, pk):
|
||||
"case": case,
|
||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||
"can_edit": can_edit,
|
||||
"is_atlas_editor": is_atlas_editor,
|
||||
"is_case_author": is_case_author,
|
||||
"can_mark_normal": can_mark_normal,
|
||||
# Provide helper lists for resource search filters
|
||||
"site_queryset": Site.objects.all(),
|
||||
"subspecialty_queryset": Subspecialty.objects.all(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -493,11 +639,9 @@ def toggle_case_normal(request, pk):
|
||||
"""
|
||||
case = get_case_for_case_detail(pk)
|
||||
|
||||
# Permission: atlas editors, case authors, or superusers
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
if not can_mark_normal:
|
||||
# Permission: rely on `can_edit` check on the Case model
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
if not can_edit:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
# If already marked normal, unmark (delete the NormalCase)
|
||||
@@ -581,10 +725,10 @@ def toggle_case_normal(request, pk):
|
||||
# Recompute flags for the fragment render
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
html = render_to_string(
|
||||
"atlas/partials/_normal_toggle.html",
|
||||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
|
||||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(html)
|
||||
@@ -595,11 +739,9 @@ def case_normal_form(request, pk):
|
||||
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
|
||||
case = get_case_for_case_detail(pk)
|
||||
|
||||
# Permission check: allow atlas editors, case authors, or superusers
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
if not can_mark_normal:
|
||||
# Permission check: rely on `can_edit` helper on the Case model
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
if not can_edit:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
# Extract defaults
|
||||
@@ -691,11 +833,9 @@ def case_normal_form(request, pk):
|
||||
def create_case_normal(request, pk):
|
||||
case = get_case_for_case_detail(pk)
|
||||
|
||||
# Permission check: allow atlas editors, case authors, or superusers
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
if not can_mark_normal:
|
||||
# Permission check: rely on `can_edit` helper on the Case model
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
if not can_edit:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
from .forms import NormalCaseForm
|
||||
@@ -721,10 +861,10 @@ def create_case_normal(request, pk):
|
||||
# Return the updated toggle fragment and close modal via inline script
|
||||
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
toggle_html = render_to_string(
|
||||
"atlas/partials/_normal_toggle.html",
|
||||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
|
||||
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
|
||||
request=request,
|
||||
)
|
||||
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
|
||||
@@ -1090,10 +1230,12 @@ def author_list(request):
|
||||
return render(request, "atlas/author_list.html", {"authors": authors})
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
return render(request, "atlas/index.html", {})
|
||||
|
||||
|
||||
@login_required
|
||||
def user_collections(request):
|
||||
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
||||
available_collections = request.user.user_casecollection_exams.all()
|
||||
@@ -1126,16 +1268,59 @@ def user_collections(request):
|
||||
# Finished (completed)
|
||||
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/user_collections.html",
|
||||
{
|
||||
context = {
|
||||
"available_collections": available_qs,
|
||||
"in_progress_collections": in_progress_qs,
|
||||
"finished_collections": finished_qs,
|
||||
},
|
||||
}
|
||||
|
||||
# If called via HTMX return a small fragment suitable for swapping
|
||||
# into the index page. Otherwise render the full page.
|
||||
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
|
||||
if is_htmx:
|
||||
html = render_to_string("atlas/partials/_user_collections.html", context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
return render(request, "atlas/user_collections.html", context)
|
||||
|
||||
|
||||
# Note: keep the above full-page render for normal requests. If this
|
||||
# endpoint is called by HTMX we prefer returning a small fragment so it
|
||||
# can be swapped into the index page without duplicating layout.
|
||||
|
||||
|
||||
@login_required
|
||||
def user_related_collections(request):
|
||||
"""Return up to 10 most recently modified collections where the user
|
||||
is an author or a marker. If called via HTMX return a fragment suitable
|
||||
for swapping into the index page.
|
||||
"""
|
||||
user = request.user
|
||||
qs = (
|
||||
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
|
||||
.distinct()
|
||||
.order_by("-updated")[:10]
|
||||
)
|
||||
|
||||
related = []
|
||||
for c in qs:
|
||||
related.append(
|
||||
{
|
||||
"collection": c,
|
||||
"is_author": c.author.filter(id=user.id).exists(),
|
||||
"is_marker": c.markers.filter(id=user.id).exists(),
|
||||
}
|
||||
)
|
||||
|
||||
context = {"related_collections": related}
|
||||
|
||||
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
|
||||
if is_htmx:
|
||||
html = render_to_string("atlas/partials/_user_related_collections.html", context, request=request)
|
||||
return HttpResponse(html)
|
||||
|
||||
return render(request, "atlas/user_collections.html", context)
|
||||
|
||||
@login_required
|
||||
def collection_options(request):
|
||||
if not request.htmx:
|
||||
@@ -1566,8 +1751,10 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
|
||||
|
||||
if self.request.POST:
|
||||
# Only bind the case formset when the POST actually contains formset data.
|
||||
# We don't require adding cases on create, so if the template doesn't
|
||||
# include the casedetail management fields we should skip validation.
|
||||
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
|
||||
context["case_formset"] = CaseCollectionCaseFormSet(
|
||||
self.request.POST,
|
||||
self.request.FILES,
|
||||
@@ -1587,14 +1774,19 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
|
||||
context = self.get_context_data(form=form)
|
||||
case_formset = context["case_formset"]
|
||||
# If the POST included case formset data, validate and save it. Otherwise
|
||||
# skip formset processing so creating a collection without cases redirects
|
||||
# normally to the detail view.
|
||||
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
|
||||
if case_formset.is_valid():
|
||||
response = super().form_valid(form)
|
||||
case_formset.instance = self.object
|
||||
case_formset.save()
|
||||
return response
|
||||
|
||||
else:
|
||||
return super().form_invalid(form)
|
||||
# No formset data present: proceed with normal create redirect.
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
"""Redirect to the collection detail page after successful creation."""
|
||||
@@ -2143,6 +2335,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",
|
||||
@@ -2153,6 +2374,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"
|
||||
@@ -2249,6 +2548,66 @@ class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableM
|
||||
filterset_class = CaseFilter
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def cases_by_size(request):
|
||||
"""Admin view: list cases ordered by total images size to find large cases.
|
||||
|
||||
Shows case id, title, number of series, number of images and total size (bytes).
|
||||
Optional GET parameter `min_size` (bytes) to filter cases larger than that.
|
||||
"""
|
||||
min_size = request.GET.get("min_size")
|
||||
try:
|
||||
min_size = int(min_size) if min_size else 0
|
||||
except Exception:
|
||||
min_size = 0
|
||||
|
||||
cases_qs = Case.objects.prefetch_related("series__images")
|
||||
|
||||
def human(n):
|
||||
# simple human readable bytes
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024.0:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024.0
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
rows = []
|
||||
for c in cases_qs:
|
||||
try:
|
||||
total = c.get_total_series_images_size()
|
||||
except Exception:
|
||||
total = 0
|
||||
series_count = c.series.count()
|
||||
image_count = sum(s.images.filter(removed=False).count() for s in c.series.all())
|
||||
if total >= min_size:
|
||||
rows.append({
|
||||
"case": c,
|
||||
"total": total,
|
||||
"series_count": series_count,
|
||||
"image_count": image_count,
|
||||
"human_total": human(total),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: r["total"], reverse=True)
|
||||
|
||||
rows.sort(key=lambda r: r["total"], reverse=True)
|
||||
|
||||
paginator = Paginator(rows, 50)
|
||||
page_num = request.GET.get("page")
|
||||
page = paginator.get_page(page_num)
|
||||
|
||||
def human(n):
|
||||
# simple human readable bytes
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024.0:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024.0
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
context = {"page": page, "human": human, "min_size": min_size}
|
||||
return render(request, "atlas/cases_by_size.html", context)
|
||||
|
||||
|
||||
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||
model = Series
|
||||
table_class = SeriesTable
|
||||
@@ -2381,7 +2740,7 @@ def case_order_dicom(request, pk):
|
||||
# TODO: don't try to order non stack series
|
||||
for series in case.series.all():
|
||||
try:
|
||||
if request.user in series.get_author_objects():
|
||||
if series.check_user_can_edit(request.user):
|
||||
series.order_by_dicom()
|
||||
else:
|
||||
fail.append(f"{series.pk}: invalid permission")
|
||||
@@ -2647,6 +3006,21 @@ class StructureAutocomplete(autocomplete.Select2QuerySetView):
|
||||
|
||||
return qs
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Allow any authenticated user to create new Structure entries.
|
||||
|
||||
By default DAL requires the user to have the model add permission.
|
||||
For Anatomy question creation we want any registered user to be
|
||||
able to create structures, so relax that check to only require
|
||||
authentication.
|
||||
"""
|
||||
try:
|
||||
auth = request.user.is_authenticated
|
||||
except Exception:
|
||||
auth = False
|
||||
|
||||
return bool(auth)
|
||||
|
||||
|
||||
class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
@@ -2752,20 +3126,32 @@ def collection_viva(request, pk):
|
||||
# )
|
||||
#)
|
||||
|
||||
# Prefetch and annotate aggressively to avoid N+1 queries in the viva view.
|
||||
# - Prefetch `case__series` using the ordering from SeriesDetail
|
||||
# - Prefetch only non-removed images for those series (ordered by position)
|
||||
# - Prefetch case resources and display sets
|
||||
from django.db.models import Prefetch as _Prefetch
|
||||
|
||||
series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
|
||||
series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
|
||||
_Prefetch("images", queryset=series_images_qs)
|
||||
)
|
||||
|
||||
casedetails = (
|
||||
CaseDetail.objects
|
||||
.filter(collection=collection)
|
||||
.select_related("case")
|
||||
.order_by("sort_order")
|
||||
.prefetch_related(
|
||||
# Prefetch series and their images directly on the case
|
||||
"case__series",
|
||||
"case__series__modality",
|
||||
"case__series__examination",
|
||||
"case__series__plane",
|
||||
"case__series__contrast",
|
||||
"case__series__images",
|
||||
_Prefetch("case__series", queryset=series_qs),
|
||||
_Prefetch("case__series__modality"),
|
||||
_Prefetch("case__series__examination"),
|
||||
_Prefetch("case__series__plane"),
|
||||
_Prefetch("case__series__contrast"),
|
||||
_Prefetch("case__caseresource_set"),
|
||||
_Prefetch("case__display_sets"),
|
||||
)
|
||||
.annotate(series_count=Count("case__seriesdetail"))
|
||||
)
|
||||
|
||||
|
||||
@@ -3968,18 +4354,15 @@ def use_dates_as_descriptions(request, pk):
|
||||
|
||||
return HttpResponse("Done")
|
||||
|
||||
def collection_case_dicom_json_review(request, exam_id, case_id):
|
||||
return collection_case_dicom_json(request, exam_id, case_id, review=True)
|
||||
def collection_case_dicom_json_review(request, exam_id, case_number):
|
||||
return collection_case_dicom_json(request, exam_id, case_number, review=True)
|
||||
|
||||
|
||||
def collection_case_dicom_json(request, exam_id, case_id, review=False):
|
||||
def collection_case_dicom_json(request, exam_id, case_number, review=False):
|
||||
# Support either case_number (index) or case_id (case PK)
|
||||
try:
|
||||
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
||||
except Exception:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
try:
|
||||
case_obj = collection.get_case_by_index(int(case_id))
|
||||
case_obj = collection.get_case_by_index(int(case_number))
|
||||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||||
except Exception:
|
||||
raise Http404("Case not found in collection")
|
||||
@@ -3998,6 +4381,162 @@ def case_dicom_json(request, pk):
|
||||
return JsonResponse(case.get_case_dicom_json())
|
||||
|
||||
|
||||
@login_required
|
||||
def resource_search_widget(request):
|
||||
"""HTMX endpoint: search existing `Resource` objects with simple filters.
|
||||
|
||||
GET params:
|
||||
- q: query string (name/description)
|
||||
- site: site id (optional)
|
||||
- subspecialty: subspecialty id (optional)
|
||||
- open_access: '1' or '0' (optional)
|
||||
|
||||
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
|
||||
"""
|
||||
|
||||
q = request.GET.get("q", "").strip()
|
||||
site = request.GET.get("site")
|
||||
subspecialty = request.GET.get("subspecialty")
|
||||
open_access = request.GET.get("open_access")
|
||||
|
||||
qs = Resource.objects.all()
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q))
|
||||
if site:
|
||||
try:
|
||||
qs = qs.filter(sites__id=int(site))
|
||||
except Exception:
|
||||
pass
|
||||
if subspecialty:
|
||||
try:
|
||||
qs = qs.filter(subspecialty__id=int(subspecialty))
|
||||
except Exception:
|
||||
pass
|
||||
if open_access in ["0", "1"]:
|
||||
qs = qs.filter(open_access=(open_access == "1"))
|
||||
|
||||
# Restrict to what the user may see: open_access or authored (unless editor)
|
||||
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
|
||||
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()
|
||||
|
||||
results = qs.order_by("name")[:50]
|
||||
|
||||
field = request.GET.get("field", "resource_search")
|
||||
case_id = request.GET.get("case_id")
|
||||
return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id})
|
||||
|
||||
|
||||
@login_required
|
||||
def resource_search_panel(request):
|
||||
"""HTMX endpoint: return the full resource search panel HTML fragment.
|
||||
|
||||
Expects optional GET param `case_id` to prefill the hidden field.
|
||||
"""
|
||||
case_id = request.GET.get("case_id")
|
||||
return render(request, "atlas/partials/resource_search_panel.html", {
|
||||
"case": (Case.objects.filter(pk=case_id).first() if case_id else None),
|
||||
"site_queryset": Site.objects.all(),
|
||||
"subspecialty_queryset": Subspecialty.objects.all(),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def case_attach_resource(request, pk):
|
||||
"""Attach an existing resource to the case (HTMX POST).
|
||||
|
||||
POST params:
|
||||
- resource_id: integer
|
||||
- pre_review: '1' or '0' (optional)
|
||||
|
||||
Returns the updated `atlas/partials/case_resources_list.html` fragment.
|
||||
"""
|
||||
case = get_case_for_case_detail(pk)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
resource_id = request.POST.get("resource_id")
|
||||
pre_review = request.POST.get("pre_review", "0") == "1"
|
||||
try:
|
||||
resource = Resource.objects.get(pk=int(resource_id))
|
||||
except Exception:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
# create if not exists
|
||||
cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review})
|
||||
if not created:
|
||||
# update flag
|
||||
cr.pre_review = pre_review
|
||||
cr.save()
|
||||
# Refresh case from DB to ensure related sets reflect changes and render updated list
|
||||
try:
|
||||
case = Case.objects.get(pk=case.pk)
|
||||
except Exception:
|
||||
pass
|
||||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
# Notify any HTMX listeners that the case resources have changed
|
||||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def case_detach_resource(request, pk):
|
||||
case = get_case_for_case_detail(pk)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse(status=403)
|
||||
resource_id = request.POST.get("resource_id")
|
||||
try:
|
||||
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
|
||||
cr.delete()
|
||||
except Exception:
|
||||
pass
|
||||
# Refresh case from DB and return updated partial
|
||||
try:
|
||||
case = Case.objects.get(pk=case.pk)
|
||||
except Exception:
|
||||
pass
|
||||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def case_toggle_resource_pre(request, pk):
|
||||
"""Toggle the pre_review flag on a CaseResource (HTMX POST).
|
||||
|
||||
POST params:
|
||||
- resource_id: integer
|
||||
|
||||
Returns the updated `atlas/partials/case_resources_list.html` fragment.
|
||||
"""
|
||||
case = get_case_for_case_detail(pk)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse(status=403)
|
||||
|
||||
resource_id = request.POST.get("resource_id")
|
||||
try:
|
||||
cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
|
||||
except Exception:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
cr.pre_review = not bool(cr.pre_review)
|
||||
cr.save()
|
||||
|
||||
# Refresh case from DB to ensure related sets reflect changes and render updated list
|
||||
try:
|
||||
case = Case.objects.get(pk=case.pk)
|
||||
except Exception:
|
||||
pass
|
||||
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
|
||||
resp = HttpResponse(html)
|
||||
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
|
||||
return resp
|
||||
|
||||
|
||||
def series_dicom_json(request, pk):
|
||||
series = get_object_or_404(Series, pk=pk)
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ server {
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Write logs to the host-mounted folder so Promtail can scrape them
|
||||
access_log /var/log/rad/nginx.access.log combined;
|
||||
error_log /var/log/rad/nginx.error.log warn;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
location /static/ {
|
||||
|
||||
@@ -8,6 +8,10 @@ server {
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Write logs to the host-mounted folder so Promtail can scrape them
|
||||
access_log /var/log/rad/nginx.access.log combined;
|
||||
error_log /var/log/rad/nginx.error.log warn;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Serve static files from the static volume
|
||||
@@ -112,6 +116,10 @@ server {
|
||||
|
||||
client_max_body_size 4G;
|
||||
|
||||
# Write logs to the host-mounted folder so Promtail can scrape them
|
||||
access_log /var/log/rad/nginx.access.log combined;
|
||||
error_log /var/log/rad/nginx.error.log warn;
|
||||
|
||||
# Same static/media handling as above
|
||||
location /static/ { alias /usr/src/app/static/; }
|
||||
location /media { alias /usr/src/app/media; }
|
||||
|
||||
@@ -43,7 +43,11 @@ DEBUG_TOOLBAR_CONFIG = {
|
||||
# deployment README. Adjust as needed.
|
||||
STATIC_ROOT = '/usr/src/app/static/'
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
import os
|
||||
|
||||
# Allow environment to override the email backend (so dev .env can select
|
||||
# a file-based backend while production keeps SMTP).
|
||||
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
||||
if not DEBUG:
|
||||
LOGGING = {
|
||||
@@ -117,6 +121,10 @@ SECURE_HSTS_SECONDS = 0
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080/",
|
||||
"http://localhost:8080/",
|
||||
]
|
||||
|
||||
REMOTE_URL = "http://127.0.0.1:8000"
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
# Development: run Django's autoreloading development server instead of
|
||||
# the production entrypoint/gunicorn. Mount the repository into the
|
||||
# container so code edits on the host trigger Django's autoreload.
|
||||
entrypoint: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
entrypoint: ["sh", "-c", "python manage.py collectstatic --noinput && python manage.py runserver 0.0.0.0:8000 --nothreading"]
|
||||
# Clear any command set by the prod compose file (which would be passed
|
||||
# as an extra argument to manage.py). Setting an empty command prevents
|
||||
# the image `command` from being appended to the entrypoint.
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
image: nginx:1.25-alpine
|
||||
volumes:
|
||||
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ../logs:/var/log/rad:rw
|
||||
|
||||
loki:
|
||||
image: grafana/loki:2.8.2
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
# lives at rad/Dockerfile inside the context.
|
||||
context: ..
|
||||
dockerfile: rad/Dockerfile
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
command: python manage.py runserver 0.0.0.0:8000 --nothreading
|
||||
#command: pip install -r requirements.txt
|
||||
volumes:
|
||||
# Mount the repository root into the container WORKDIR so manage.py and
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
- web
|
||||
volumes:
|
||||
- ../deploy/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ../logs:/var/log/rad:rw
|
||||
- static_volume:/usr/src/app/static:ro
|
||||
- media_volume:/usr/src/app/media:ro
|
||||
- ../deploy/nginx/certs:/etc/letsencrypt:ro
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"UID":"f8cade59-3cf9-4ae3-965d-8b0a57007d58","created_at":"2025-12-01T17:14:05.101735276Z","version":{"version":"2.8.2","revision":"9f809eda7","branch":"HEAD","buildUser":"root@e401cfcb874f","buildDate":"2023-05-03T11:07:54Z","goVersion":"go1.20.4"}}
|
||||
+34
-290
@@ -20,6 +20,7 @@ from django.contrib.auth.models import User
|
||||
|
||||
from generic.models import (
|
||||
CidUser,
|
||||
CidUserExam,
|
||||
CidUserGroup,
|
||||
Examination,
|
||||
ExamCollection,
|
||||
@@ -81,6 +82,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
|
||||
|
||||
|
||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||
@@ -240,7 +242,7 @@ class ExamAuthorFormMixin(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
self.fields["author"] = ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False),
|
||||
widget=UserSearchWidget(),
|
||||
)
|
||||
|
||||
|
||||
@@ -252,7 +254,7 @@ class ExamMarkerFormMixin(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
self.fields["markers"] = ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
|
||||
widget=UserSearchWidget(),
|
||||
)
|
||||
self.fields["markers"].required = False
|
||||
|
||||
@@ -425,6 +427,7 @@ class CidUserForm(ModelForm):
|
||||
"internal_candidate",
|
||||
"name",
|
||||
"email",
|
||||
"notes",
|
||||
"supervisor",
|
||||
"login_email_sent",
|
||||
"results_email_sent",
|
||||
@@ -540,6 +543,19 @@ class CidUserExamForm(ModelForm):
|
||||
fields = []
|
||||
|
||||
|
||||
class CidUserExamRecordForm(ModelForm):
|
||||
class Meta:
|
||||
model = CidUserExam
|
||||
fields = [
|
||||
"start_time",
|
||||
"end_time",
|
||||
"completed",
|
||||
"manual_submission",
|
||||
"share_with_supervisor",
|
||||
"results_emailed_status",
|
||||
]
|
||||
|
||||
|
||||
class CidUserGroupModelChoiceField(ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return f"{obj.cid} [{obj.email}]"
|
||||
@@ -556,219 +572,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
||||
|
||||
|
||||
class UserSearchSelectMultipleWidget:
|
||||
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
||||
|
||||
Usage: instantiate with (name, value) where value is an iterable of user ids
|
||||
and call .render() to get the HTML fragment. This mirrors the previous
|
||||
nested widget but is available module-wide for reuse.
|
||||
"""
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"user_search_{self.name}"
|
||||
results_id = f"user_search_results_{self.name}"
|
||||
selected_list_id = f"selected_users_{self.name}"
|
||||
|
||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
grade_text = ""
|
||||
try:
|
||||
up = getattr(u, "userprofile", None)
|
||||
if up and getattr(up, "grade", None):
|
||||
g = up.grade
|
||||
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
||||
except Exception:
|
||||
grade_text = ""
|
||||
|
||||
selected_html += (
|
||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||
+ '</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
|
||||
url = reverse("generic:user_search_widget")
|
||||
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
try:
|
||||
from generic.models import UserGrades
|
||||
|
||||
grades_qs = UserGrades.objects.all()
|
||||
for g in grades_qs:
|
||||
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
||||
except Exception:
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
|
||||
html = f"""
|
||||
<div class="user-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
||||
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
||||
{grades_options}
|
||||
</select>
|
||||
<!-- Help button for advanced search features -->
|
||||
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
||||
</div>
|
||||
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
|
||||
<!-- Collapsible help panel (hidden by default) -->
|
||||
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
||||
<strong>Advanced search tips</strong>
|
||||
<ul class="mb-0 mt-1">
|
||||
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
||||
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
||||
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
|
||||
<ul class="mb-0 mt-1">
|
||||
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
||||
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
||||
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
||||
<li><code>email:</code> — matches email address.</li>
|
||||
<li><code>username:</code> — matches username.</li>
|
||||
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
||||
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
||||
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
||||
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected users</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const search = document.getElementById('{search_id}');
|
||||
const results = document.getElementById('{results_id}');
|
||||
const select = document.getElementById('{field_id}');
|
||||
const selectedList = document.getElementById('{selected_list_id}');
|
||||
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
||||
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
||||
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
const grade = gradeSelect ? gradeSelect.value : '';
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
||||
fetch(url)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
||||
// re-run search with same query when grade changes
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 50);
|
||||
}});
|
||||
|
||||
// Toggle help panel visibility
|
||||
if (helpBtn && helpPanel) {{
|
||||
helpBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
helpPanel.classList.toggle('d-none');
|
||||
const expanded = !helpPanel.classList.contains('d-none');
|
||||
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}});
|
||||
}}
|
||||
|
||||
// fieldName for this widget instance
|
||||
const widgetFieldName = '{self.name}';
|
||||
|
||||
// Add button click delegation: results list buttons have data attributes
|
||||
results.addEventListener('click', function(e) {{
|
||||
const btn = e.target.closest('.user-add-btn');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-user-id');
|
||||
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
||||
const grade = btn.getAttribute('data-user-grade') || '';
|
||||
addUserToField(widgetFieldName, id, text, grade);
|
||||
}});
|
||||
|
||||
// Wire up the "Add all results" button if present
|
||||
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
||||
if (addAllBtn) {{
|
||||
addAllBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
addAllFromResults(widgetFieldName);
|
||||
}});
|
||||
}}
|
||||
|
||||
window.addUserToField = function(fieldName, id, text, grade) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
// prevent duplicate
|
||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||
sel.appendChild(opt);
|
||||
|
||||
// add to visible list (include grade if provided)
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn);
|
||||
selectedList.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeUserFromField = function(fieldName, id) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
}}
|
||||
|
||||
window.addAllFromResults = function(fieldName) {{
|
||||
const list = document.getElementById('user_search_results_list_' + fieldName);
|
||||
if (!list) return;
|
||||
const items = list.querySelectorAll('li[data-user-id]');
|
||||
items.forEach(function(it) {{
|
||||
const id = it.getAttribute('data-user-id');
|
||||
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
||||
const grade = it.getAttribute('data-user-grade') || '';
|
||||
addUserToField(fieldName, id, text, grade);
|
||||
}});
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
|
||||
@@ -789,61 +593,9 @@ class UserUserGroupForm(ModelForm):
|
||||
if 'users' in self.fields:
|
||||
orig_field = self.fields['users']
|
||||
|
||||
class RenderWrapper:
|
||||
def __init__(self, field):
|
||||
self.field = field
|
||||
|
||||
# Minimal widget-compatible wrapper used by Django templates and
|
||||
# form machinery. We implement the small subset of the Widget
|
||||
# API that the templates/checks expect: `is_hidden`,
|
||||
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
|
||||
attrs = {}
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
# store attrs so template helpers can inspect them
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = UserSearchSelectMultipleWidget(name, value)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
# data is usually QueryDict with getlist
|
||||
if hasattr(data, 'getlist'):
|
||||
return data.getlist(name)
|
||||
# fallback
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
# Called by BoundField.id_for_label; prefer explicit id in attrs
|
||||
try:
|
||||
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
||||
return self.attrs.get('id')
|
||||
except Exception:
|
||||
pass
|
||||
return id_
|
||||
|
||||
def use_required_attribute(self, initial):
|
||||
# Called by Django to decide whether to add the required HTML attribute.
|
||||
# We defer to the underlying field/widget if available; otherwise
|
||||
# return False to avoid unexpected 'required' attributes.
|
||||
try:
|
||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
||||
# If original field/widget had such method, prefer it.
|
||||
orig_widget = getattr(self.field, 'widget', None)
|
||||
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
||||
return orig_widget.use_required_attribute(initial)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
self.fields['users'].widget = RenderWrapper(orig_field)
|
||||
# Use the reusable UserSearchWidget wrapper so templates and other
|
||||
# modules can reuse the same behaviour consistently.
|
||||
self.fields['users'].widget = UserSearchWidget(orig_field)
|
||||
|
||||
class Meta:
|
||||
model = UserUserGroup
|
||||
@@ -886,36 +638,32 @@ class UserGroupExamForm(ModelForm):
|
||||
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=AnatomyExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(
|
||||
verbose_name="Anatomy Exams", is_stacked=False
|
||||
),
|
||||
widget=ExamSearchWidget(exam_model=AnatomyExam),
|
||||
)
|
||||
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=RapidsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=RapidsExam),
|
||||
)
|
||||
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=ShortsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=ShortsExam),
|
||||
)
|
||||
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=LongsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=LongsExam),
|
||||
)
|
||||
self.fields["physics_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=PhysicsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(
|
||||
verbose_name="Physics Exams", is_stacked=False
|
||||
),
|
||||
widget=ExamSearchWidget(exam_model=PhysicsExam),
|
||||
)
|
||||
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=SbasExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=SbasExam),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -983,31 +731,27 @@ class CidGroupExamForm(ModelForm):
|
||||
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=AnatomyExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(
|
||||
verbose_name="Anatomy Exams", is_stacked=False
|
||||
),
|
||||
widget=ExamSearchWidget(exam_model=AnatomyExam),
|
||||
)
|
||||
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=RapidsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=RapidsExam),
|
||||
)
|
||||
self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=LongsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=LongsExam),
|
||||
)
|
||||
self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=PhysicsExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(
|
||||
verbose_name="Physics Exams", is_stacked=False
|
||||
),
|
||||
widget=ExamSearchWidget(exam_model=PhysicsExam),
|
||||
)
|
||||
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=SbasExam.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=SbasExam),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1132,12 +876,12 @@ class ExamCollectionForm(ModelForm):
|
||||
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=exam_obj.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=exam_obj),
|
||||
)
|
||||
|
||||
self.fields["author"] = ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
|
||||
widget=UserSearchWidget(), required=False,
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-29 10:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ciduser',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Internal notes about the candidate'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-05 12:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('generic', '0030_ciduser_notes'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Flag',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('note', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -65,8 +65,14 @@ class AuthorMixin():
|
||||
|
||||
def is_author(self, user: User) -> bool:
|
||||
"""Returns True if the user is an author of the object"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
return self.author.filter(id=user.id).exists()
|
||||
|
||||
def get_author_display(self):
|
||||
return ", ".join([i.username for i in self.get_author_objects()])
|
||||
|
||||
|
||||
class UserConfigurablePaginationMixin:
|
||||
default_per_page = 25
|
||||
|
||||
+86
-34
@@ -387,7 +387,7 @@ class SeriesImageBase(models.Model):
|
||||
super().save(*args, **kwargs) # Call the "real" save() method.
|
||||
|
||||
|
||||
class SeriesBase(models.Model):
|
||||
class SeriesBase(models.Model, AuthorMixin):
|
||||
info = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
||||
@@ -444,16 +444,6 @@ class SeriesBase(models.Model):
|
||||
|
||||
return False
|
||||
|
||||
def get_author_objects(self):
|
||||
"""Returns a list of authors"""
|
||||
if self.author:
|
||||
return self.author.all()
|
||||
else:
|
||||
return ["None"]
|
||||
|
||||
def get_author_display(self):
|
||||
return ", ".join([i.username for i in self.get_author_objects()])
|
||||
|
||||
def get_examination(self):
|
||||
"""Returns a comma seperated text list of regions"""
|
||||
return str(self.examination)
|
||||
@@ -667,6 +657,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
|
||||
name = models.CharField(max_length=200, help_text="Name of the exam/collection")
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True, help_text="Creation timestamp")
|
||||
updated = models.DateTimeField(auto_now=True, help_text="Last updated timestamp")
|
||||
|
||||
start_date = models.DateTimeField(
|
||||
help_text="Date (and time) the exam/collection starts", null=True, blank=True
|
||||
)
|
||||
@@ -780,10 +773,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
if self.start_date >= timezone.now() or (
|
||||
self.end_date is not None and self.end_date <= timezone.now()
|
||||
):
|
||||
raise Http404("Exam not found")
|
||||
# Raise with an explanatory message so the frontend can
|
||||
# surface the reason to the end user.
|
||||
raise Http404("Exam not currently available (outside allowed dates)")
|
||||
# If it is not active no one can take
|
||||
elif active_only and not self.active:
|
||||
raise Http404("Exam not found")
|
||||
raise Http404("Exam currently inactive")
|
||||
|
||||
# If candidates only check they have access
|
||||
if self.candidates_only:
|
||||
@@ -1442,6 +1437,8 @@ class CidUser(models.Model):
|
||||
"CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
|
||||
notes = models.TextField(blank=True, help_text="Internal notes about the candidate")
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=["cid"])]
|
||||
|
||||
@@ -1552,27 +1549,9 @@ class CidUser(models.Model):
|
||||
if self.login_email_sent and not resend:
|
||||
return False, "Already sent."
|
||||
|
||||
login_url = reverse("cid_selector")
|
||||
|
||||
msg = f"""
|
||||
See below for your details for: {self.group}
|
||||
|
||||
---------
|
||||
CID: {self.cid}
|
||||
Passcode: {self.passcode}
|
||||
---------
|
||||
|
||||
You will need these to access to the test system.
|
||||
|
||||
Direct link to login:
|
||||
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
|
||||
|
||||
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
|
||||
|
||||
|
||||
|
||||
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
|
||||
"""
|
||||
# Build the message payload. Exposed as a separate method so callers
|
||||
# can preview the email without actually sending it.
|
||||
msg = self.generate_email_details_msg()
|
||||
|
||||
try:
|
||||
# send_mail(
|
||||
@@ -1597,6 +1576,36 @@ class CidUser(models.Model):
|
||||
self.save()
|
||||
return True, ""
|
||||
|
||||
def generate_email_details_msg(self) -> str:
|
||||
"""Generate the candidate details email body without sending it.
|
||||
|
||||
Returns:
|
||||
str: The plaintext email body that would be sent.
|
||||
"""
|
||||
login_url = reverse("cid_selector")
|
||||
|
||||
msg = f"""
|
||||
See below for your details for: {self.group}
|
||||
|
||||
---------
|
||||
CID: {self.cid}
|
||||
Passcode: {self.passcode}
|
||||
---------
|
||||
|
||||
You will need these to access to the test system.
|
||||
|
||||
Direct link to login:
|
||||
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
|
||||
|
||||
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
|
||||
|
||||
|
||||
|
||||
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
|
||||
"""
|
||||
|
||||
return msg
|
||||
|
||||
def get_email_details_url(self):
|
||||
return reverse("generic:candidate_email_details", kwargs={"cid": self.cid})
|
||||
|
||||
@@ -1686,9 +1695,52 @@ class CidUserExam(models.Model):
|
||||
# TODO add examuserstatus?
|
||||
self.end_time = timezone.now()
|
||||
self.completed = True
|
||||
self.manual_submission = True
|
||||
self.save()
|
||||
|
||||
|
||||
class Flag(models.Model):
|
||||
"""A reusable flag model that can be attached to any object via a
|
||||
GenericForeignKey. Useful for marking questions, items or other objects
|
||||
across apps.
|
||||
"""
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Optional actor who created the flag (either a user or a CID user)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="flags",
|
||||
)
|
||||
cid_user = models.ForeignKey(
|
||||
"CidUser",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="flags",
|
||||
)
|
||||
|
||||
note = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
actor = None
|
||||
if self.user:
|
||||
actor = f"user:{self.user.username}"
|
||||
elif self.cid_user:
|
||||
actor = f"cid:{self.cid_user.cid}"
|
||||
else:
|
||||
actor = "anon"
|
||||
return f"Flag [{actor}] -> {self.content_type}({self.object_id})"
|
||||
|
||||
|
||||
CID_GROUP_EXAMS = (
|
||||
("SBAs", "sba_cid_user_groups"),
|
||||
("Physics", "physics_cid_user_groups"),
|
||||
|
||||
+7
-8
@@ -3,6 +3,7 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import A
|
||||
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
@@ -85,7 +86,7 @@ class SelectionTable(tables.Table):
|
||||
|
||||
# Also include a small data attribute so client scripts can locate the
|
||||
# controls by selection name if they prefer.
|
||||
return format_html(html)
|
||||
return mark_safe(html)
|
||||
|
||||
class SingleImageColumn(tables.Column):
|
||||
def render(self, value):
|
||||
@@ -100,7 +101,7 @@ class BlockImageColumn(tables.Column):
|
||||
blocks = []
|
||||
for obj in value.all():
|
||||
blocks.append(obj.get_block())
|
||||
return format_html(
|
||||
return mark_safe(
|
||||
"""<span class='multi-image-block'>
|
||||
{}
|
||||
</span>""".format("".join(blocks))
|
||||
@@ -112,9 +113,7 @@ class SeriesImageColumn(tables.Column):
|
||||
images = value.all()
|
||||
|
||||
if not images:
|
||||
return format_html(
|
||||
'<span>None<span><br/>',
|
||||
)
|
||||
return mark_safe('<span>None</span><br/>')
|
||||
|
||||
obj = findMiddle(images)
|
||||
|
||||
@@ -141,7 +140,7 @@ class FirstImageColumn(tables.Column):
|
||||
obj = value.all()
|
||||
|
||||
if not obj:
|
||||
return format_html('<span>No image<span>')
|
||||
return mark_safe('<span>No image</span>')
|
||||
|
||||
image_object = obj[0].image
|
||||
try:
|
||||
@@ -196,7 +195,7 @@ class CidUserTable(SelectionTable):
|
||||
if record.supervisor is not None:
|
||||
return format_html("""{}\n[{}]""", record.email, record.supervisor.email)
|
||||
else:
|
||||
return format_html(record.email)
|
||||
return record.email
|
||||
|
||||
|
||||
class CidUserExamTable(SelectionTable):
|
||||
@@ -249,7 +248,7 @@ class CidUserExamTable(SelectionTable):
|
||||
if record.supervisor is not None:
|
||||
return format_html("""{}\n[{}]""", record.email, record.supervisor.email)
|
||||
else:
|
||||
return format_html(record.email)
|
||||
return record.email
|
||||
|
||||
|
||||
# def render_cid(self, value, record):
|
||||
|
||||
@@ -15,16 +15,43 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% block navigation %}
|
||||
Candidates:
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#">Candidates</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#genericNavbar" aria-controls="genericNavbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="genericNavbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'generic:manage_cids' %}">Cids</a> /
|
||||
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> /
|
||||
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
|
||||
<a href="{% url 'generic:user_group_view' %}">User Groups</a> /
|
||||
<a href="{% url 'trainees' %}">Trainees</a> /
|
||||
<a href="{% url 'accounts_list' %}">Manage Users</a> /
|
||||
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
|
||||
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navCids" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-journal-text me-1"></i> Cids</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="navCids">
|
||||
<li><a class="dropdown-item" href="{% url 'generic:manage_cids' %}"><i class="bi bi-file-earmark-text me-1"></i> All Cids</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'generic:manage_cid_exams' %}"><i class="bi bi-mortarboard me-1"></i> Cids (exams)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navGroups" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-people me-1"></i> Groups</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="navGroups">
|
||||
<li><a class="dropdown-item" href="{% url 'generic:cid_group_view' %}"><i class="bi bi-people-fill me-1"></i> Cid Groups</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'generic:user_group_view' %}"><i class="bi bi-person-lines-fill me-1"></i> User Groups</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navUsers" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-person-badge me-1"></i> People</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="navUsers">
|
||||
<li><a class="dropdown-item" href="{% url 'trainees' %}"><i class="bi bi-people me-1"></i> Trainees</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'accounts_list' %}"><i class="bi bi-gear me-1"></i> Manage Users</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'generic:supervisor' %}"><i class="bi bi-person-workspace me-1"></i> Manage Supervisors</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,120 +1,124 @@
|
||||
{% extends 'generic/cid_group_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Group: {{group.name}}</h2>
|
||||
|
||||
<details>
|
||||
<summary>Help</summary>
|
||||
<p>This page shows an overview of the candidates and exams that are associated with the group.</p>
|
||||
|
||||
<p>Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Please note: simply adding the group to the exam will not automatically give the group candidates access to that exam, they will have to be added individually/as a group.</p>
|
||||
</details>
|
||||
|
||||
<h3>Users</h3>
|
||||
<p title="The number of users that have been added to this group">Count: {{users|length}}</p>
|
||||
|
||||
{% if users %}
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
{% if group_type == "cid" %}
|
||||
<tr><th>Name</th><th>Email</th><th>CID</th><th>Passcode</th></tr>
|
||||
{% else %}
|
||||
<tr><th>User</th></tr>
|
||||
{% endif %}
|
||||
</thead>
|
||||
{% for user in users %}
|
||||
{% if group_type == "cid" %}
|
||||
<tr><td>{{user.name}}</td><td>{{user.email}}</td><td>{{user.cid}}</td><td>{{user.passcode}}</td></tr>
|
||||
{% else %}
|
||||
<tr><td>{{user.username}}</td></tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
This group has no users.
|
||||
|
||||
{% if group_type == "cid" %}
|
||||
They can be created (or added) <a href="{% url 'generic:manage_cids' %}">here</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-secondary">
|
||||
<details>
|
||||
<summary >Add users by email</summary>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="emails" placeholder="Email addresses (one per line)"></textarea>
|
||||
<button type="submit"
|
||||
hx-post="{% url 'generic:user_group_add_by_email_user' group.pk %}"
|
||||
hx-target="#add-users-by-email-results"
|
||||
>Add</button>
|
||||
</form>
|
||||
<div id="add-users-by-email-results"></div>
|
||||
<h2 class="card-title mb-1">Group: {{ group.name }}</h2>
|
||||
<div class="small text-muted">This page shows an overview of the candidates and exams associated with the group.</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="small text-muted">Users in group</div>
|
||||
<div class="fw-bold">{{ users|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small">Help</summary>
|
||||
<div class="small text-muted mt-2">
|
||||
Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Adding a group to an exam does not automatically give group members access — they must be added.
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Exams</h3>
|
||||
The group is currently associated with the following exams. This allows the users within the group to be easily added to the exam. It will not automatically give members of the group access to the exam.
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-2">Users</h3>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-auto small text-muted">Sort by:</div>
|
||||
<div class="btn-group btn-group-sm user-sort-group" role="group" aria-label="Sort">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="username">{% if group_type == 'cid' %}CID{% else %}Username{% endif %} <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if group_type == 'cid' %}
|
||||
<div class="mb-2">
|
||||
<form method="post" hx-post="{% url 'generic:cid_group_generate_preview' group.pk %}" hx-target="#cid-generate-preview" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<label class="small text-muted">Quick generate users (one per line: name,email)</label>
|
||||
<textarea name="rows" class="form-control form-control-sm mb-2" rows="5" placeholder="John Smith,j.smith@example.org\nJane Doe,jane@example.org"></textarea>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Preview</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="cid-generate-preview"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="group-users-wrap">
|
||||
{% if users %}
|
||||
{% include 'generic/partials/group_users_list.html' with users=users group_type=group_type %}
|
||||
{% else %}
|
||||
<p class="small text-muted">This group has no users.
|
||||
{% if group_type == "cid" %}
|
||||
You can create or add users <a href="{% url 'generic:manage_cids' %}">here</a>.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-2">Exams</h3>
|
||||
<div class="small text-muted mb-2">The group is associated with these exams. Adding the group to an exam helps add members quickly but does not grant automatic access.</div>
|
||||
<div class="mb-2">
|
||||
{% if group_type == "cid" %}
|
||||
Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>.
|
||||
{% else %}
|
||||
Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>.
|
||||
{% endif %}
|
||||
<p>It is possible to quickly add or remove all group candidates from an exam by clicking the buttons below. This will only affect the <b>{{users|length}}</b> candidates in this group. You will need to refresh the page to see updated candidate counts.</p>
|
||||
</div>
|
||||
|
||||
{% with group.GetGroupExams as exam_map %}
|
||||
{% for key, value in exam_map.items %}
|
||||
<h4>{{key}}</h4>
|
||||
<div>
|
||||
<h5 class="mt-3">{{ key }}</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for exam in value %}
|
||||
<div class="d-flex">
|
||||
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
|
||||
<div class="list-group-item bg-transparent border-0 py-2 d-flex align-items-center">
|
||||
<div>
|
||||
<a href="{{ exam.get_absolute_url }}" class="fw-bold">{{ exam }}</a>
|
||||
<div class="small text-muted">{{ exam }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-3">
|
||||
{% if group_type == "cid" %}
|
||||
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
|
||||
<form>
|
||||
<a href="{{ exam.get_cid_edit_url }}" class="edit-link small">Edit candidates</a>
|
||||
{% else %}
|
||||
<a href="{{ exam.get_user_edit_url }}" class="edit-link small">Edit candidates</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ms-auto">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if group_type == "cid" %}
|
||||
<form class="m-0 d-inline">
|
||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
||||
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
||||
<button
|
||||
class="btn btn-sm px-2"
|
||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Add all group candidates to this exam"
|
||||
name="action"
|
||||
value="add"
|
||||
>Add group candidates</button>
|
||||
<button
|
||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Remove all group candidates to this exam"
|
||||
class="btn btn-sm px-2"
|
||||
name="action"
|
||||
value="remove"
|
||||
>Remove group candidates</button>
|
||||
<button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
|
||||
<button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
|
||||
<form>
|
||||
<form class="m-0 d-inline">
|
||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
||||
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
||||
<button
|
||||
class="btn btn-sm px-2"
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Add all group candidates to this exam"
|
||||
name="action"
|
||||
value="add"
|
||||
>Add group candidates</button>
|
||||
<button
|
||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
||||
title="Remove all group candidates to this exam"
|
||||
class="btn btn-sm px-2"
|
||||
name="action"
|
||||
value="remove"
|
||||
>Remove group candidates</button>
|
||||
<button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
|
||||
<button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="px-2 ms-auto ">
|
||||
<div class="px-3 ms-2 small text-muted">
|
||||
{% if group_type == "cid" %}
|
||||
(Cid candidate count: {{ exam.valid_cid_users.count }})
|
||||
{% else %}
|
||||
@@ -126,17 +130,122 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<style>
|
||||
td, th { padding-left: 10px }
|
||||
|
||||
.edit-link {
|
||||
font-size: smaller;
|
||||
opacity: 60%;
|
||||
}
|
||||
.edit-link { font-size: 0.85rem; opacity: 0.7; }
|
||||
.list-group-item .fw-bold { font-size: 0.95rem; }
|
||||
.card-title { font-size: 1rem; }
|
||||
/* keep existing small visual tweaks */
|
||||
.sort-caret { font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
(function(){
|
||||
const sortGroup = document.querySelector('.user-sort-group');
|
||||
if(!sortGroup) return;
|
||||
let sortState = { key: 'username', dir: 'asc' };
|
||||
function updateCarets(){
|
||||
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){
|
||||
const key = b.dataset.key;
|
||||
const caret = b.querySelector('.sort-caret');
|
||||
if(!caret) return;
|
||||
caret.textContent = (sortState.key === key) ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
});
|
||||
}
|
||||
function getVal(li, key){
|
||||
if(!li) return '';
|
||||
return (li.dataset[key] || '').toString().toLowerCase();
|
||||
}
|
||||
function sortList(key){
|
||||
const ul = document.getElementById('group-users-list'); if(!ul) return;
|
||||
const items = Array.from(ul.children);
|
||||
const dir = (sortState.key === key && sortState.dir === 'asc') ? 'desc' : 'asc';
|
||||
items.sort(function(a,b){ const va = getVal(a,key); const vb = getVal(b,key); if(va < vb) return dir === 'asc' ? -1 : 1; if(va > vb) return dir === 'asc' ? 1 : -1; return 0; });
|
||||
items.forEach(function(it){ ul.appendChild(it); });
|
||||
sortState = { key: key, dir: dir };
|
||||
updateCarets();
|
||||
}
|
||||
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
|
||||
updateCarets();
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Send/resend login email handlers for CID users
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.body.addEventListener('click', function(e){
|
||||
const btn = e.target.closest && e.target.closest('.send-login-email-btn');
|
||||
if(!btn) return;
|
||||
e.preventDefault();
|
||||
const url = btn.dataset.url + '?resend=1';
|
||||
const cid = btn.dataset.cid;
|
||||
// show spinner on button
|
||||
btn.disabled = true;
|
||||
const spinner = document.createElement('span'); spinner.className = 'spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);
|
||||
fetch(url, { method: 'GET', credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const li = btn.closest('li');
|
||||
if(!li) return;
|
||||
const status = li.querySelector('.login-email-status');
|
||||
if(data.sent){
|
||||
if(status){ status.textContent = 'Email sent'; status.className = 'badge bg-success ms-1 login-email-status'; }
|
||||
} else {
|
||||
if(status){ status.textContent = 'Not sent'; status.className = 'badge bg-secondary ms-1 login-email-status'; }
|
||||
if(data.comment){ console.error('Email send comment:', data.comment); }
|
||||
}
|
||||
}).catch(err => { console.error('Failed to send login email', err); })
|
||||
.finally(()=>{ if(btn){ const s = btn.querySelector('.spinner-border'); if(s) s.remove(); btn.disabled = false; } });
|
||||
});
|
||||
});
|
||||
|
||||
// Visual indicators for HTMX add/remove actions
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt){
|
||||
try{
|
||||
const elt = evt.detail.elt;
|
||||
if(!elt) return;
|
||||
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
|
||||
if(!btn) return;
|
||||
// stash original HTML for restore
|
||||
btn.dataset._origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true');
|
||||
spinner.dataset._hx_spinner = '1';
|
||||
btn.appendChild(spinner);
|
||||
}catch(e){ console.error(e); }
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt){
|
||||
try{
|
||||
const elt = evt.detail.elt;
|
||||
if(!elt) return;
|
||||
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
|
||||
if(!btn) return;
|
||||
// remove spinner(s)
|
||||
const sp = btn.querySelectorAll('span[data-_hx_spinner]');
|
||||
sp.forEach(s => s.remove());
|
||||
// show transient status
|
||||
const status = document.createElement('span');
|
||||
status.className = 'badge bg-success ms-2 hx-action-status';
|
||||
status.textContent = 'Done';
|
||||
btn.appendChild(status);
|
||||
// restore after short delay
|
||||
setTimeout(function(){
|
||||
if(btn.dataset._origHtml){ btn.innerHTML = btn.dataset._origHtml; delete btn.dataset._origHtml; }
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}catch(e){ console.error(e); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,12 +4,84 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Simple modal for showing JSON debug info */
|
||||
.hx-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
.hx-modal {
|
||||
background: #fff;
|
||||
max-width: 90%;
|
||||
max-height: 80%;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
|
||||
font-family: monospace;
|
||||
}
|
||||
.hx-modal pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.hx-modal .hx-close { float: right; cursor: pointer; margin-left: 0.5rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{form.media}}
|
||||
|
||||
<!-- htmx for making the request; include if not already present -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
// Listen for HTMX requests and show the JSON response inside our modal.
|
||||
document.addEventListener('htmx:afterRequest', function(evt){
|
||||
try {
|
||||
// We only care about requests triggered by our debug button
|
||||
var trigger = evt.detail.elt;
|
||||
if(!trigger) return;
|
||||
if(trigger.id !== 'show-exam-json') return;
|
||||
|
||||
var xhr = evt.detail.xhr;
|
||||
if(!xhr) return;
|
||||
|
||||
var txt = xhr.responseText || '';
|
||||
var json = null;
|
||||
try { json = JSON.parse(txt); }
|
||||
catch(e) { json = null; }
|
||||
|
||||
var out = '';
|
||||
if(json !== null) {
|
||||
out = JSON.stringify(json, null, 2);
|
||||
} else {
|
||||
// If not valid JSON, just show raw text
|
||||
out = txt;
|
||||
}
|
||||
|
||||
var overlay = document.getElementById('hx-debug-overlay');
|
||||
var pre = document.getElementById('hx-debug-pre');
|
||||
pre.textContent = out;
|
||||
overlay.style.display = 'flex';
|
||||
} catch (e) {
|
||||
console.error('hx debug modal error', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Close button and overlay click
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
var overlay = document.getElementById('hx-debug-overlay');
|
||||
if(!overlay) return;
|
||||
overlay.addEventListener('click', function(ev){
|
||||
if(ev.target === overlay) overlay.style.display = 'none';
|
||||
});
|
||||
var close = document.getElementById('hx-debug-close');
|
||||
if(close) close.addEventListener('click', function(){ overlay.style.display = 'none'; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
@@ -48,4 +120,23 @@
|
||||
These should be edited via the exam candidate page. If you need to manually toggle you can do so <a href="{% url 'generic:update_cid_exams' object.pk %}">here</a> (you shouldn't)
|
||||
</p>
|
||||
|
||||
<!-- HTMX debug button to show what the /exam/json/<cid>/<passcode> response will be -->
|
||||
<p>
|
||||
<button id="show-exam-json" class="button"
|
||||
hx-get="{% url 'active_exams_cid' ciduser.cid ciduser.passcode %}"
|
||||
hx-trigger="click"
|
||||
hx-swap="none">
|
||||
Show exam JSON (debug)
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<!-- Modal overlay for JSON output -->
|
||||
<div id="hx-debug-overlay" class="hx-modal-overlay" role="dialog" aria-hidden="true">
|
||||
<div class="hx-modal" role="document">
|
||||
<button id="hx-debug-close" class="hx-close">Close</button>
|
||||
<h3>Exam JSON (debug)</h3>
|
||||
<pre id="hx-debug-pre">(loading...)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "generic/base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Edit CidUserExam record</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -23,28 +23,46 @@
|
||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
{% for field in form.visible_fields %}
|
||||
<div class="mb-3">
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text text-muted">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="text-danger small">{{ field.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save group</button>
|
||||
<a href="{% url 'generic:manage_cids' %}" class="btn btn-link btn-sm ms-2">Manage CIDs</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
{% if cidusergroup %}
|
||||
<p>Users can be added to the group <a href="{% url 'generic:manage_cids' %}">here</a></p>
|
||||
|
||||
<h3>Users</h3>
|
||||
|
||||
<ul>
|
||||
<div class="small text-muted mb-2">Users currently in this group</div>
|
||||
<ul class="list-group list-group-flush small">
|
||||
{% for user in cidusergroup.ciduser_set.all %}
|
||||
<li><a href="{% url 'generic:update_cid' user.pk %}">{{user.cid}}</a>: {{user.name}} [{{user.email}} {{user.last_name}}] {{user.userprofile.grade}}</li>
|
||||
|
||||
<li class="list-group-item bg-transparent border-0 px-0 py-1">
|
||||
<a href="{% url 'generic:update_cid' user.pk %}">{{ user.cid }}</a>
|
||||
<div class="text-muted">{{ user.name }} · {{ user.email }}</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>Group currently has no users.</li>
|
||||
<li class="list-group-item bg-transparent border-0 px-0 py-1 text-muted">Group currently has no users.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
Count: {{cidusergroup.ciduser_set.count}}
|
||||
<div class="small text-muted mt-2">Count: {{ cidusergroup.ciduser_set.count }}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,147 +1,321 @@
|
||||
<div id="history-modal"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<h2>Exam user history:
|
||||
<span id="history-user"></span>
|
||||
</h2>
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card text-white bg-dark">
|
||||
<h3>CID candidates</h3>
|
||||
{% if cid_users %}
|
||||
<p>{{cid_user_count}} CID candidates.</p>
|
||||
|
||||
<div id="htmx-info"></div>
|
||||
|
||||
<ol class="cid-candidate-list">
|
||||
{% for cid in cid_users %}
|
||||
|
||||
<li class="cid-user" data-cid={{cid.cid}}><div class="cid-data">
|
||||
<i class="bi bi-clock-history"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
<a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}]</div><div class="cid-name">{{cid.name}}</div> <div class="cid-email">Email: {{cid.email}}</div>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<p>This exam has no CID candidates. Add some with the below link.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<a href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<div class="card text-white bg-dark">
|
||||
<h3>User candidates</h3>
|
||||
{% if user_users %}
|
||||
<p>{{user_user_count}} User candidates.</p>
|
||||
|
||||
<ol class="user-candidate-list">
|
||||
{% for user in user_users %}
|
||||
|
||||
<li class="user-user" data-user="{{user.username}}">
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||
<h3 class="mb-0">Candidates for {{ exam.get_exam_name }}</h3>
|
||||
<div>
|
||||
<i class="bi bi-clock-history"
|
||||
title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
></i>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
{{user.username}}</div>
|
||||
<div>Email: {{user.email}}</div>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<p>This exam has no User candidates. Add some with the below link.</p>
|
||||
{% endif %}
|
||||
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
|
||||
|
||||
<a class="btn btn-sm btn-outline-light" href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
|
||||
<a class="btn btn-sm btn-outline-light ms-2" href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
{% if user_exam_data %}
|
||||
<div class="card text-white bg-dark">
|
||||
<details>
|
||||
<summary>
|
||||
<h3>Submitted candidates</h3>
|
||||
</summary>
|
||||
The following candidate submissions have been received. Candidates with submissions will be shown in green above.
|
||||
<ul id="submitted-candidates">
|
||||
{% for user_data in user_exam_data %}
|
||||
<li data-cid="{{user_data.cid_user}}" data-user="{{user_data.user_user}}">
|
||||
CID: {{user_data.cid_user}} /
|
||||
User: {{user_data.user_user}} /
|
||||
Start time: {{user_data.start_time}} /
|
||||
End time: {{user_data.end_time}} /
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">CID candidates <small class="text-muted">({{ cid_user_count }})</small></h5>
|
||||
<div class="mb-2 input-group input-group-sm">
|
||||
<input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
|
||||
<button type="button" id="cid-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
|
||||
<span id="cid-filter-indicator" class="input-group-text d-none">Filtered</span>
|
||||
</div>
|
||||
<div id="htmx-info"></div>
|
||||
<div id="cid-list" hx-get="{% url exam.app_name|add:':exam_cids_cid_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center placeholder-glow">
|
||||
<span class="placeholder col-2 me-2"></span>
|
||||
<span class="placeholder col-4 me-2"></span>
|
||||
<span class="placeholder col-3"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center placeholder-glow">
|
||||
<span class="placeholder col-2 me-2"></span>
|
||||
<span class="placeholder col-4 me-2"></span>
|
||||
<span class="placeholder col-3"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center placeholder-glow">
|
||||
<span class="placeholder col-2 me-2"></span>
|
||||
<span class="placeholder col-4 me-2"></span>
|
||||
<span class="placeholder col-3"></span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<br/>
|
||||
{% endif %}
|
||||
<span
|
||||
title="View all user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
||||
>
|
||||
Exam history <i class="bi bi-clock-history"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">User candidates <small class="text-muted">({{ user_user_count }})</small></h5>
|
||||
<div class="mb-2 input-group input-group-sm">
|
||||
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
|
||||
<button type="button" id="user-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
|
||||
<span id="user-filter-indicator" class="input-group-text d-none">Filtered</span>
|
||||
</div>
|
||||
<div id="user-list" hx-get="{% url exam.app_name|add:':exam_cids_user_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center placeholder-glow">
|
||||
<span class="placeholder col-3 me-2"></span>
|
||||
<span class="placeholder col-4"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center placeholder-glow">
|
||||
<span class="placeholder col-3 me-2"></span>
|
||||
<span class="placeholder col-4"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if has_submissions %}
|
||||
<div id="submitted-list" hx-get="{% url exam.app_name|add:':exam_cids_submitted_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="placeholder-glow">
|
||||
<h5 class="card-title placeholder col-4"></h5>
|
||||
<p class="placeholder col-8"></p>
|
||||
<ul class="list-unstyled small">
|
||||
<li class="placeholder col-6 mb-2"></li>
|
||||
<li class="placeholder col-6 mb-2"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="View all user exam history" hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal">Exam history <i class="bi bi-clock-history"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTMX modal for history / partials -->
|
||||
<div id="history-modal" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Exam user history: <span id="history-user"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Highlight users that have submitted answers
|
||||
$("#submitted-candidates li").each(function(n, el) {
|
||||
console.log(el);
|
||||
if (el.dataset.cid != "None") {
|
||||
$(`.cid-user[data-cid=${el.dataset.cid}]`).addClass("submitted")
|
||||
}
|
||||
if (el.dataset.user != "None") {
|
||||
$(`.user-user[data-user="${el.dataset.user}"]`).addClass("submitted")
|
||||
(function(){
|
||||
// Utilities to run both on initial load and after HTMX swaps
|
||||
|
||||
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
|
||||
|
||||
function filterList(inputElem, listSelector, indicatorId, clearBtnId){
|
||||
const q = normalizeText(inputElem.value.trim());
|
||||
document.querySelectorAll(listSelector).forEach(function(li){
|
||||
const text = normalizeText(li.textContent || '');
|
||||
if(!q || text.indexOf(q) !== -1){
|
||||
try{ li.style.removeProperty('display'); }catch(e){}
|
||||
li.classList.remove('d-none');
|
||||
} else {
|
||||
try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; }
|
||||
li.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
// Toggle indicator & clear button
|
||||
try{
|
||||
const ind = document.getElementById(indicatorId);
|
||||
const clearBtn = document.getElementById(clearBtnId);
|
||||
if(ind) ind.classList.toggle('d-none', !q);
|
||||
if(clearBtn) clearBtn.classList.toggle('d-none', !q);
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function initTooltips(){
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
|
||||
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
|
||||
});
|
||||
}
|
||||
|
||||
// Exam-level state exposed to the script so we can show Reviewed badges
|
||||
const examResultsPublished = {{ exam.publish_results|yesno:'true,false' }};
|
||||
|
||||
function highlightSubmitted(){
|
||||
// Find any submitted rows (table rows or list items) that include
|
||||
// data attributes for cid/user. Then insert a small badge on matching list items.
|
||||
// Collect submitted identifiers first to avoid duplicate processing
|
||||
const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]');
|
||||
// Build maps of submitted identifiers -> their timestamps so we
|
||||
// can attach tooltip text to the badges we create below.
|
||||
const submittedCids = new Map();
|
||||
const submittedUsers = new Map();
|
||||
submittedEls.forEach(function(el){
|
||||
try{
|
||||
const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null;
|
||||
const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null;
|
||||
const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : '';
|
||||
const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : '';
|
||||
if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last});
|
||||
if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last});
|
||||
}catch(e){ console.error('highlightSubmitted collect error', e); }
|
||||
});
|
||||
|
||||
// Helper to ensure a single badge exists inside an li and update its tooltip
|
||||
function ensureBadge(li, selector, className, text, title){
|
||||
// Prefer the explicit badge container if present
|
||||
const container = li.querySelector('.badge-container') || li;
|
||||
let b = container.querySelector(selector);
|
||||
if(!b){
|
||||
b = document.createElement('span');
|
||||
b.className = className;
|
||||
b.textContent = text;
|
||||
container.appendChild(b);
|
||||
}
|
||||
if(title){
|
||||
b.setAttribute('title', title);
|
||||
b.setAttribute('data-bs-toggle', 'tooltip');
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// Add/update badges for CID list items
|
||||
if(submittedCids.size){
|
||||
document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){
|
||||
try{
|
||||
const link = li.querySelector && li.querySelector('.cid-link');
|
||||
const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim();
|
||||
if(submittedCids.has(text)){
|
||||
const info = submittedCids.get(text) || {first:'', last:''};
|
||||
// add Started badge if start time exists
|
||||
if(info.first){
|
||||
const t = `Started\nFirst: ${info.first}`;
|
||||
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
|
||||
}
|
||||
// Only show a 'Completed' badge when the candidate has an end time
|
||||
if(info.last){
|
||||
const tComp = `Completed\nFirst: ${info.first}\nLast: ${info.last}`;
|
||||
ensureBadge(li, '.completed-badge', 'completed-badge badge bg-primary ms-2', 'Completed', tComp);
|
||||
|
||||
// If the exam results have been published (reviewed), show Reviewed/Submitted badge in green
|
||||
if(examResultsPublished){
|
||||
const t = `Reviewed\nFirst: ${info.first}\nLast: ${info.last}`;
|
||||
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Reviewed', t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){ console.error('highlightSubmitted CID item error', e); }
|
||||
});
|
||||
}
|
||||
|
||||
// Add/update badges for User list items
|
||||
if(submittedUsers.size){
|
||||
document.querySelectorAll('#user-list .list-group-item').forEach(function(li){
|
||||
try{
|
||||
const txt = (li.textContent||'').trim();
|
||||
for(const user of submittedUsers.keys()){
|
||||
if(txt.indexOf(user) !== -1){
|
||||
const info = submittedUsers.get(user) || {first:'', last:''};
|
||||
if(info.first){
|
||||
const t = `Started\nFirst: ${info.first}`;
|
||||
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
|
||||
}
|
||||
if(info.last){
|
||||
const tComp = `Completed\nFirst: ${info.first}\nLast: ${info.last}`;
|
||||
ensureBadge(li, '.completed-badge', 'completed-badge badge bg-primary ms-2', 'Completed', tComp);
|
||||
if(examResultsPublished){
|
||||
const t = `Reviewed\nFirst: ${info.first}\nLast: ${info.last}`;
|
||||
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Reviewed', t);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}catch(e){ console.error('highlightSubmitted User item error', e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Wire filters
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const cidFilter = document.getElementById('cid-filter');
|
||||
if(cidFilter && !cidFilter.dataset.bound){
|
||||
cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); });
|
||||
cidFilter.dataset.bound = '1';
|
||||
}
|
||||
|
||||
const userFilter = document.getElementById('user-filter');
|
||||
if(userFilter && !userFilter.dataset.bound){
|
||||
userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); });
|
||||
userFilter.dataset.bound = '1';
|
||||
}
|
||||
|
||||
// Clear buttons
|
||||
const cidClear = document.getElementById('cid-filter-clear');
|
||||
if(cidClear && !cidClear.dataset.bound){
|
||||
cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }});
|
||||
cidClear.dataset.bound = '1';
|
||||
}
|
||||
|
||||
const userClear = document.getElementById('user-filter-clear');
|
||||
if(userClear && !userClear.dataset.bound){
|
||||
userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }});
|
||||
userClear.dataset.bound = '1';
|
||||
}
|
||||
|
||||
// initial runs (may be no partials yet)
|
||||
initTooltips();
|
||||
highlightSubmitted();
|
||||
});
|
||||
|
||||
// After HTMX swaps, re-run highlight and tooltips when our partials load
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||
if(!target) return;
|
||||
|
||||
const id = target.id || (target.closest && target.closest('[id]') ? target.closest('[id]').id : null);
|
||||
if(id === 'cid-list' || id === 'user-list' || id === 'submitted-list'){
|
||||
initTooltips();
|
||||
// submitted-list may have loaded now; re-run highlighting
|
||||
highlightSubmitted();
|
||||
|
||||
// Re-run current filters for newly-loaded partials
|
||||
try{
|
||||
const cf = document.getElementById('cid-filter');
|
||||
if(cf) filterList(cf, '#cid-list .list-group-item');
|
||||
const uf = document.getElementById('user-filter');
|
||||
if(uf) filterList(uf, '#user-list .list-group-item');
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
}catch(e){ console.error(e); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock js %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.submitted {
|
||||
color: green;
|
||||
}
|
||||
.cid-name { margin-top: 0.25rem; }
|
||||
.cid-link { font-size: 1.05rem; }
|
||||
.submitted { color: green; }
|
||||
.cid-notes { font-style: italic; color: #6c757d; }
|
||||
</style>
|
||||
{% endblock css %}
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,97 +1,280 @@
|
||||
{% include "generic/exam_candidate_headers.html" %}
|
||||
<h3>CID Users</h3>
|
||||
|
||||
<div class="card mb-2 bg-dark text-light border-0">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">CID Users</h5>
|
||||
{% if groups %}
|
||||
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
|
||||
<div class="small text-muted mt-1">Users from the following groups are available:
|
||||
{% for group in groups %}
|
||||
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
|
||||
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
|
||||
{% endif %}
|
||||
<ol>
|
||||
{% for cid in current_cid_users %}
|
||||
|
||||
<li class="current"><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
|
||||
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
{% for cid in available_cid_users %}
|
||||
|
||||
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
|
||||
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
|
||||
<p>{% if current_cid_users or available_cid_users %}
|
||||
<button class="toggle-all-btn">Toggle all</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if current_cid_users or available_cid_users %}
|
||||
<button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="add-all-btn">Add all</button>
|
||||
<button class="remove-all-btn">Remove all</button>
|
||||
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Add all</button>
|
||||
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</p>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-auto small text-muted">Sort by:</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Sort">
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">CID <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'generic/partials/cid_user_list.html' with exam=exam current_cid_users=current_cid_users available_cid_users=available_cid_users %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".current .toggle-btn").click();
|
||||
});
|
||||
$(".add-all-btn").click((el) => {
|
||||
$(":not(.current) .toggle-btn").click();
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").click();
|
||||
});
|
||||
$(".toggle-btn").click((el) => {
|
||||
let parent = $(el.target).parent()
|
||||
$.ajax({
|
||||
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{csrf_token}}",
|
||||
edit_cid_user: el.target.dataset.pk,
|
||||
add: !parent.hasClass("current"),
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log("done", data);
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Bulk Add / Remove using a single HTMX POST
|
||||
const bulkForm = document.createElement('form');
|
||||
bulkForm.id = 'bulk-cid-form';
|
||||
bulkForm.className = 'd-none';
|
||||
bulkForm.setAttribute('hx-target', '#cid-users-wrap');
|
||||
bulkForm.setAttribute('hx-swap', 'outerHTML');
|
||||
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input'; bulkForm.appendChild(bulkInput);
|
||||
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input'; bulkForm.appendChild(bulkAddInput);
|
||||
// include csrf token
|
||||
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
|
||||
document.body.appendChild(bulkForm);
|
||||
|
||||
if (data.status == "success") {
|
||||
if (data.added) {
|
||||
parent.addClass("current");
|
||||
toastr.info(`User ${el.target.dataset.cid} added to exam`)
|
||||
function setLoading(btn, loading){
|
||||
if(!btn) return;
|
||||
try{
|
||||
if(loading){
|
||||
btn.dataset._origDisabled = btn.disabled ? '1' : '0';
|
||||
btn.disabled = true;
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'btn-spinner spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role','status');
|
||||
spinner.setAttribute('aria-hidden','true');
|
||||
btn.appendChild(spinner);
|
||||
} else {
|
||||
parent.removeClass("current");
|
||||
toastr.info(`User ${el.target.dataset.cid} removed from exam`)
|
||||
|
||||
}
|
||||
if(typeof btn.dataset._origDisabled !== 'undefined'){
|
||||
btn.disabled = btn.dataset._origDisabled === '1';
|
||||
delete btn.dataset._origDisabled;
|
||||
} else {
|
||||
toastr.error(data.status)
|
||||
btn.disabled = false;
|
||||
}
|
||||
const s = btn.querySelector('.btn-spinner'); if(s) s.remove();
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
})
|
||||
.fail(function (data) {
|
||||
console.log("fail", data);
|
||||
toastr.error(data.responseJSON.status)
|
||||
|
||||
})
|
||||
.always(function () {
|
||||
})
|
||||
function submitBulk(url, pks, add, triggeringButton){
|
||||
// prefer HTMX if available
|
||||
if (window.htmx && typeof htmx.submit === 'function'){
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
bulkForm.setAttribute('hx-post', url);
|
||||
bulkInput.value = JSON.stringify(pks);
|
||||
if (add === null) {
|
||||
bulkAddInput.disabled = true;
|
||||
} else {
|
||||
bulkAddInput.disabled = false;
|
||||
bulkAddInput.value = add ? 'true' : 'false';
|
||||
}
|
||||
htmx.submit(bulkForm);
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback: use fetch and swap the list manually
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
const fd = new FormData();
|
||||
fd.append('bulk_pks', JSON.stringify(pks));
|
||||
if (add !== null) fd.append('add', add ? 'true' : 'false');
|
||||
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
const headers = { 'X-CSRFToken': csrf };
|
||||
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
|
||||
.then(resp => resp.text().then(text => ({ resp, text })))
|
||||
.then(({ resp, text }) => {
|
||||
// swap the list
|
||||
try{
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, 'text/html');
|
||||
// Prefer replacing the whole wrapper (contains counts + list)
|
||||
const newWrap = doc.querySelector('#cid-users-wrap');
|
||||
if (newWrap){
|
||||
const oldWrap = document.querySelector('#cid-users-wrap');
|
||||
if (oldWrap) oldWrap.replaceWith(newWrap);
|
||||
} else {
|
||||
const newList = doc.querySelector('#cid-users-list');
|
||||
if (newList){
|
||||
const old = document.querySelector('#cid-users-list');
|
||||
if (old) old.replaceWith(newList);
|
||||
}
|
||||
}
|
||||
}catch(e){ console.error('bulk swap failed', e); }
|
||||
// dispatch HX-Trigger equivalent if present
|
||||
const trigger = resp.headers.get('HX-Trigger');
|
||||
if (trigger){
|
||||
try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){}
|
||||
}
|
||||
}).catch(err => { console.error('bulk request failed', err); })
|
||||
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
|
||||
}
|
||||
|
||||
document.querySelectorAll('.add-all-btn').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const url = btn.dataset.bulkUrl;
|
||||
const items = document.querySelectorAll('#cid-users-list li:not(.current)');
|
||||
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
|
||||
if (!pks.length) return;
|
||||
submitBulk(url, pks, true, btn);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.remove-all-btn').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
if(!confirm('Remove all selected users from the exam?')) return;
|
||||
const url = btn.dataset.bulkUrl;
|
||||
const items = document.querySelectorAll('#cid-users-list li.current');
|
||||
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
|
||||
if (!pks.length) return;
|
||||
submitBulk(url, pks, false, btn);
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
// Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
|
||||
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
|
||||
// build mixed list: for each li include {pk, add}
|
||||
const items = Array.from(document.querySelectorAll('#cid-users-list li')).map(function(li){
|
||||
return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') };
|
||||
});
|
||||
if (!items.length) return;
|
||||
submitBulk(url, items, null, btn);
|
||||
});
|
||||
});
|
||||
|
||||
// HTMX: listen for server HX-Trigger to show toasts for toggles and update counts
|
||||
document.body.addEventListener('cid_toggled', function(evt){
|
||||
try {
|
||||
const detail = evt.detail || {};
|
||||
const added = detail.added;
|
||||
if (window.toastr) {
|
||||
if (added) toastr.info(`User added to exam`);
|
||||
else toastr.info(`User removed from exam`);
|
||||
}
|
||||
// Update counts for single-item toggles (bulk swaps replace the whole wrapper)
|
||||
const addedEl = document.getElementById('cid-count-added');
|
||||
const availEl = document.getElementById('cid-count-available');
|
||||
if (addedEl && availEl){
|
||||
let addedCount = parseInt(addedEl.textContent || '0') || 0;
|
||||
let availCount = parseInt(availEl.textContent || '0') || 0;
|
||||
if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); }
|
||||
else { addedCount = Math.max(0, addedCount - 1); availCount += 1; }
|
||||
addedEl.textContent = String(addedCount);
|
||||
availEl.textContent = String(availCount);
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
});
|
||||
|
||||
// HTMX: clear loading indicators when requests finish
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt){
|
||||
try{
|
||||
const elt = (evt.detail && evt.detail.elt) || null;
|
||||
let btn = null;
|
||||
if(elt){
|
||||
if(elt.tagName === 'BUTTON') btn = elt;
|
||||
else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
|
||||
else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
|
||||
}
|
||||
if(btn) setLoading(btn, false);
|
||||
}catch(e){ }
|
||||
});
|
||||
|
||||
// Delegate toggle button clicks so newly-inserted items work
|
||||
document.body.addEventListener('click', function(e){
|
||||
const btn = e.target.closest && e.target.closest('.toggle-btn');
|
||||
if(!btn) return;
|
||||
// find enclosing form
|
||||
const form = btn.closest('form');
|
||||
if(!form) return;
|
||||
e.preventDefault();
|
||||
setLoading(btn, true);
|
||||
if(window.htmx && typeof htmx.submit === 'function'){
|
||||
htmx.submit(form);
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback: send via fetch and swap the nearest li with returned HTML
|
||||
(async function(){
|
||||
try{
|
||||
const url = form.action || form.getAttribute('hx-post');
|
||||
const fd = new FormData(form);
|
||||
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value;
|
||||
const headers = { 'HX-Request': 'true' };
|
||||
if(csrf) headers['X-CSRFToken'] = csrf;
|
||||
|
||||
const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' });
|
||||
const text = await resp.text();
|
||||
// try to parse HTML and replace the closest li
|
||||
try{
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, 'text/html');
|
||||
// the server should return the li outerHTML
|
||||
const newLi = doc.querySelector('li');
|
||||
const oldLi = form.closest('li');
|
||||
if(newLi && oldLi){ oldLi.replaceWith(newLi); }
|
||||
// dispatch any HX-Trigger header events
|
||||
const trigger = resp.headers.get('HX-Trigger');
|
||||
if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} }
|
||||
}catch(err){ console.error('toggle parse/swap failed', err); }
|
||||
}catch(err){ console.error('toggle request failed', err); }
|
||||
finally{ setLoading(btn, false); }
|
||||
})();
|
||||
});
|
||||
|
||||
// Sorting: pure JS implementation
|
||||
let currentSort = { key: 'cid', dir: 'asc' };
|
||||
function updateCarets(){
|
||||
document.querySelectorAll('.sort-btn').forEach(function(b){
|
||||
const key = b.dataset.key;
|
||||
const caret = b.querySelector('.sort-caret');
|
||||
if (caret) caret.textContent = (currentSort.key === key) ? (currentSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
});
|
||||
}
|
||||
function sortList(key){
|
||||
const ul = document.getElementById('cid-users-list');
|
||||
const items = Array.from(ul.children);
|
||||
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
|
||||
items.sort(function(a,b){
|
||||
const va = (a.dataset[key] || '').toString().toLowerCase();
|
||||
const vb = (b.dataset[key] || '').toString().toLowerCase();
|
||||
if (va < vb) return dir === 'asc' ? -1 : 1;
|
||||
if (va > vb) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
items.forEach(function(it){ ul.appendChild(it); });
|
||||
currentSort = { key: key, dir: dir };
|
||||
updateCarets();
|
||||
}
|
||||
document.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
|
||||
updateCarets();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.current {
|
||||
color: lightgray;
|
||||
}
|
||||
.current::before {
|
||||
content: "[ADDED TO EXAM] - "
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
}
|
||||
<style>
|
||||
/* Compact, dark-mode adjustments */
|
||||
.card.bg-dark { background-color: #111 !important; }
|
||||
.card-body { padding: .5rem !important; }
|
||||
.list-group-item { padding: .35rem .5rem; font-size: .88rem; border-left: 0; border-right: 0; }
|
||||
.list-group-item.current { background: rgba(255,255,255,0.03); border-left: 4px solid rgba(13,110,253,0.25); }
|
||||
.list-group-item .small { display:block; color: #bfc6cf; }
|
||||
.btn-group .btn { padding: .25rem .45rem; font-size: .8rem; }
|
||||
.btn-outline-light { color: #e9ecef; border-color: rgba(233,236,239,0.12); }
|
||||
.btn-outline-light:hover { color:#fff; background: rgba(233,236,239,0.06); }
|
||||
</style>
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="mt-2 small text-muted">
|
||||
|
||||
This exam has <strong>{{ question_number }}</strong> questions •
|
||||
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
|
||||
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">1Q</span>)
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div title="Defines if this exam is freely available to all users.">
|
||||
Open access:
|
||||
<span class="fw-bold">{{ exam.open_access }}</span>
|
||||
</div>
|
||||
@@ -121,19 +121,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header p-2">
|
||||
<div class="card-header p-2" title="Allow bulk setting of question open access status. Note: questions and exams have different open access settings.">
|
||||
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #}
|
||||
<a class="d-block d-md-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
|
||||
<span>Open access (questions)</span>
|
||||
<a class="d-block d-md-flex justify-content-between align-items-center text-decoration-none text-reset" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
|
||||
<small>Open access (questions)</small>
|
||||
<small class="text-muted">Expand to change selected questions open access status</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="collapse" id="open-access-bulk">
|
||||
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="type" value="sbas" />
|
||||
<input type="hidden" name="type" value="{{exam.app_name}}" />
|
||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}" />
|
||||
<div class="col-12 small text-muted">Select questions on the list above (checkboxes) then click one of the actions below.</div>
|
||||
<div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end">
|
||||
<button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button>
|
||||
|
||||
@@ -39,17 +39,17 @@
|
||||
|
||||
</div>
|
||||
<div id="stats-plot">{{plot|safe}}</div>
|
||||
<div id="stats-block" title="low scores (<20% max) are excluded from statistics">
|
||||
<h3>Stats</h3>
|
||||
Candidates: {{cids|length}}<br />
|
||||
Questions: <span id="question-number">{{question_number}}</span><br />
|
||||
<span title="Max score">Available marks: {{max_score}}</span><br />
|
||||
Mean: {{mean}}, Median {{median}}, Mode {{mode}}<br />
|
||||
Top score: {{exam.stats_max}}
|
||||
|
||||
<div id="stats-block" title="low scores (<20% max) are excluded from statistics" class="mb-2 small">
|
||||
<h5 class="mb-1">Stats</h5>
|
||||
<div>Candidates: <span class="fw-semibold">{{cids|length}}</span></div>
|
||||
<div>Questions: <span id="question-number">{{question_number}}</span></div>
|
||||
<div title="Max score">Available marks: {{max_score}}</div>
|
||||
<div>Mean: {{mean}}, Median {{median}}, Mode {{mode}}</div>
|
||||
<div>Top score: {{exam.stats_max}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable">
|
||||
<div class="mb-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small">
|
||||
<tr>
|
||||
<th title="click to sort by candidate / user id">Candidate ID</th>
|
||||
<th title="click to sort by score">Score</th>
|
||||
@@ -80,9 +80,17 @@
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h5 class="mb-0">Answers as a table</h5>
|
||||
<div>
|
||||
<h3>Answers as a table</h3>
|
||||
<table class="table table-dark table-striped table-hover table-sm col-sm">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table" aria-expanded="true">Toggle answers</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse show" id="answers-table">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover table-sm col-sm small">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>Candidate</th>
|
||||
@@ -128,6 +136,8 @@
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block breakdown %}{% endblock breakdown %}
|
||||
|
||||
@@ -192,6 +202,12 @@
|
||||
display: inline-block;
|
||||
color: darkblue;
|
||||
}
|
||||
/* Reduce vertical spacing for dense score tables */
|
||||
.cid-score-table td, .cid-score-table th, table.table-sm td, table.table-sm th {
|
||||
padding: .25rem .4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
#stats-block { font-size: .9rem; }
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,103 +1,120 @@
|
||||
{% include "generic/exam_candidate_headers.html" %}
|
||||
<h3>Users</h3>
|
||||
<div class="card mb-2 bg-dark text-light border-0">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">Users</h5>
|
||||
{% if groups %}
|
||||
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
|
||||
<div class="small text-muted mt-1">Users from the following groups are available:
|
||||
{% for group in groups %}
|
||||
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
|
||||
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
|
||||
{% endif %}
|
||||
<ol>
|
||||
{% for user in current_user_users %}
|
||||
|
||||
<li class="current">{{user.username}} /
|
||||
Email: {{user.email}}
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
{% for user in available_user_users %}
|
||||
|
||||
<li>{{user.username}} /
|
||||
Email: {{user.email}}
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
|
||||
<p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if current_user_users or available_user_users %}
|
||||
<button class="toggle-all-btn">Toggle all</button>
|
||||
<button class="btn btn-sm btn-outline-light toggle-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="add-all-btn">Add all</button>
|
||||
<button class="remove-all-btn">Remove all</button>
|
||||
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Add all</button>
|
||||
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Remove all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</p>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-auto small text-muted">Sort by:</div>
|
||||
<div class="btn-group btn-group-sm user-sort-group" role="group" aria-label="Sort">
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">Username <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'generic/partials/user_user_list.html' with exam=exam current_user_users=current_user_users available_user_users=available_user_users %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".current .toggle-btn").click();
|
||||
});
|
||||
$(".add-all-btn").click((el) => {
|
||||
$("li:not(.current) .toggle-btn").click();
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").click();
|
||||
});
|
||||
$(".toggle-btn").click((el) => {
|
||||
parent = $(el.target).parent()
|
||||
$.ajax({
|
||||
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{csrf_token}}",
|
||||
edit_user_user: el.target.dataset.pk,
|
||||
add: !parent.hasClass("current"),
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
// Mirror the HTMX-enabled behavior used for CID users.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Bulk form for users
|
||||
const bulkForm = document.createElement('form');
|
||||
bulkForm.id = 'bulk-user-form';
|
||||
bulkForm.className = 'd-none';
|
||||
bulkForm.setAttribute('hx-target', '#user-users-wrap');
|
||||
bulkForm.setAttribute('hx-swap', 'outerHTML');
|
||||
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input_user'; bulkForm.appendChild(bulkInput);
|
||||
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input_user'; bulkForm.appendChild(bulkAddInput);
|
||||
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
|
||||
document.body.appendChild(bulkForm);
|
||||
|
||||
if (data.status == "success") {
|
||||
if (data.added) {
|
||||
parent.addClass("current");
|
||||
toastr.info(`User ${el.target.dataset.user} added to exam`)
|
||||
} else {
|
||||
parent.removeClass("current");
|
||||
toastr.info(`User ${el.target.dataset.user} removed from exam`)
|
||||
function setLoading(btn, loading){ if(!btn) return; try{ if(loading){ btn.dataset._origDisabled = btn.disabled ? '1':'0'; btn.disabled = true; const spinner = document.createElement('span'); spinner.className='btn-spinner spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);} else { if(typeof btn.dataset._origDisabled !== 'undefined'){ btn.disabled = btn.dataset._origDisabled === '1'; delete btn.dataset._origDisabled;} else { btn.disabled = false;} const s = btn.querySelector('.btn-spinner'); if(s) s.remove(); } }catch(e){} }
|
||||
|
||||
function submitBulk(url, pks, add, triggeringButton){
|
||||
if (window.htmx && typeof htmx.submit === 'function'){
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
bulkForm.setAttribute('hx-post', url);
|
||||
bulkInput.value = JSON.stringify(pks);
|
||||
if (add === null) bulkAddInput.disabled = true; else { bulkAddInput.disabled = false; bulkAddInput.value = add ? 'true' : 'false'; }
|
||||
htmx.submit(bulkForm); return;
|
||||
}
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
const fd = new FormData(); fd.append('bulk_pks', JSON.stringify(pks)); if (add !== null) fd.append('add', add ? 'true' : 'false'); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value; const headers = { 'X-CSRFToken': csrf };
|
||||
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
|
||||
.then(resp => resp.text().then(text => ({ resp, text })))
|
||||
.then(({ resp, text }) => { try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newWrap = doc.querySelector('#user-users-wrap'); if (newWrap){ const oldWrap = document.querySelector('#user-users-wrap'); if (oldWrap) oldWrap.replaceWith(newWrap); } else { const newList = doc.querySelector('#user-users-list'); if (newList){ const old = document.querySelector('#user-users-list'); if (old) old.replaceWith(newList); } } }catch(e){ console.error('bulk swap failed', e);} const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } })
|
||||
.catch(err => console.error('bulk request failed', err))
|
||||
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
|
||||
}
|
||||
})
|
||||
.always(function () {
|
||||
})
|
||||
|
||||
const defaultBulkUrl = "{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}";
|
||||
document.querySelectorAll('.add-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li:not(.current)'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, true, btn); }); });
|
||||
document.querySelectorAll('.remove-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ if(!confirm('Remove all selected users from the exam?')) return; const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li.current'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, false, btn); }); });
|
||||
|
||||
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl || defaultBulkUrl; const items = Array.from(document.querySelectorAll('#user-users-list li')).map(function(li){ return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') }; }); if (!items.length) return; submitBulk(url, items, null, btn); }); });
|
||||
|
||||
// Listen for single-item toggle events to update counts and show toasts
|
||||
document.body.addEventListener('user_toggled', function(evt){ try { const detail = evt.detail || {}; const added = detail.added; if (window.toastr) { if (added) toastr.info('User added to exam'); else toastr.info('User removed from exam'); } const addedEl = document.getElementById('user-count-added'); const availEl = document.getElementById('user-count-available'); if (addedEl && availEl){ let addedCount = parseInt(addedEl.textContent || '0') || 0; let availCount = parseInt(availEl.textContent || '0') || 0; if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); } else { addedCount = Math.max(0, addedCount - 1); availCount += 1; } addedEl.textContent = String(addedCount); availEl.textContent = String(availCount); } } catch(e){} });
|
||||
|
||||
// Delegated click handler for per-item toggle buttons (HTMX preferred)
|
||||
document.body.addEventListener('click', function(e){ const btn = e.target.closest && e.target.closest('.toggle-btn'); if(!btn) return; const form = btn.closest('form'); if(!form) return; e.preventDefault(); setLoading(btn, true); if(window.htmx && typeof htmx.submit === 'function'){ htmx.submit(form); return; } (async function(){ try{ const url = form.action || form.getAttribute('hx-post'); const fd = new FormData(form); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value; const headers = { 'HX-Request': 'true' }; if(csrf) headers['X-CSRFToken'] = csrf; const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' }); const text = await resp.text(); try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newLi = doc.querySelector('li'); const oldLi = form.closest('li'); if(newLi && oldLi){ oldLi.replaceWith(newLi); } const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } }catch(err){ console.error('toggle parse/swap failed', err); } }catch(err){ console.error('toggle request failed', err); } finally{ setLoading(btn, false); } })(); });
|
||||
|
||||
// Sorting for user list (scoped to this card)
|
||||
(function(){
|
||||
const sortGroup = document.querySelector('.user-sort-group');
|
||||
if(!sortGroup) return;
|
||||
let userSort = { key: 'cid', dir: 'asc' };
|
||||
function updateUserCarets(){
|
||||
const buttons = sortGroup.querySelectorAll('.sort-btn');
|
||||
buttons.forEach(function(b){ const key = b.dataset.key; const caret = b.querySelector('.sort-caret'); if(caret) caret.textContent = (userSort.key === key) ? (userSort.dir === 'asc' ? ' ▲' : ' ▼') : ''; });
|
||||
}
|
||||
function getUserValue(li, key){
|
||||
if(!li) return '';
|
||||
if(key === 'cid') return (li.dataset.username || '').toString().toLowerCase();
|
||||
return (li.dataset[key] || '').toString().toLowerCase();
|
||||
}
|
||||
function sortUserList(key){
|
||||
const ul = document.getElementById('user-users-list'); if(!ul) return;
|
||||
const items = Array.from(ul.children);
|
||||
const dir = (userSort.key === key && userSort.dir === 'asc') ? 'desc' : 'asc';
|
||||
items.sort(function(a,b){ const va = getUserValue(a,key); const vb = getUserValue(b,key); if(va < vb) return dir === 'asc' ? -1 : 1; if(va > vb) return dir === 'asc' ? 1 : -1; return 0; });
|
||||
items.forEach(function(it){ ul.appendChild(it); });
|
||||
userSort = { key: key, dir: dir };
|
||||
updateUserCarets();
|
||||
}
|
||||
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortUserList(b.dataset.key); }); });
|
||||
updateUserCarets();
|
||||
})();
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.current {
|
||||
color: lightgray;
|
||||
}
|
||||
.current::after {
|
||||
content: "[ADDED TO EXAM]"
|
||||
}
|
||||
/* removed decorative pseudo-element that showed [ADDED TO EXAM] */
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
{% extends 'generic/base.html' %}
|
||||
{% extends 'base.html' %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
Exam Collections
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
{% comment %} <a href="{% url 'generic:cid_group_detail' cidusergroup.pk %}">Collections</a> / {% endcomment %}
|
||||
<a href="{% url 'generic:examcollection_create' %}">Create Exam Collection</a>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'generic:examcollection_list' %}">Collections</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examCollectionNavbar" aria-controls="examCollectionNavbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="examCollectionNavbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'generic:examcollection_list' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i> View Collections</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'generic:examcollection_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i> Create Collection</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% endblock %}
|
||||
@@ -2,106 +2,154 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>
|
||||
{{ object.name }}
|
||||
{% if object.date %}
|
||||
[{{object.date}}]
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div class="container py-4">
|
||||
<div class="row align-items-start mb-3">
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-0">{{ object.name }} {% if object.date %}<small class="text-muted">[{{ object.date }}]</small>{% endif %}</h1>
|
||||
<div class="small text-muted mt-1">Authors: {{ object.get_authors }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group" aria-label="collection-actions">
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'generic:examcollection_edit' object.pk %}"><i class="bi bi-pencil"></i> Edit</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'generic:examcollection_clone' object.pk %}"><i class="bi bi-files"></i> Clone</a>
|
||||
<a class="btn btn-outline-danger btn-sm" href="{% url 'generic:examcollection_delete' object.pk %}"><i class="bi bi-trash"></i> Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a>
|
||||
<a href="{% url 'generic:examcollection_clone' object.pk %}">Clone</a>
|
||||
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
|
||||
</p>
|
||||
|
||||
<div>Authors: {{ object.get_authors }}</div>
|
||||
|
||||
<p>This collection contains the following exams<p>
|
||||
<p class="lead small text-muted">This collection contains the following exams</p>
|
||||
|
||||
|
||||
|
||||
|
||||
{% if object.anatomy_exams.all %}
|
||||
<h2>Anatomy Exams:</h2>
|
||||
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
|
||||
<a href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
|
||||
|
||||
|
||||
<div class="add-marker">
|
||||
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Anatomy Exams</button>
|
||||
{% if object.anatomy_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Anatomy Exams</strong>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'anatomy' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading anatomy exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer add-marker small"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.longs_exams.all %}
|
||||
<h2>Longs Exams:</h2>
|
||||
{% with exams=object.longs_exams.all app_name="longs" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<div class="add-marker">
|
||||
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Longs Exams</button>
|
||||
{% if object.longs_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Longs Exams</strong>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'longs' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading longs exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer add-marker small"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.rapids_exams.all %}
|
||||
<h2>Rapids Exams:</h2>
|
||||
{% with exams=object.rapids_exams.all app_name="rapids" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<div class="add-marker">
|
||||
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Rapids Exams</button>
|
||||
{% if object.rapids_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Rapids Exams</strong>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'rapids' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading rapids exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer add-marker small"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.shorts_exams.all %}
|
||||
<h2>Shorts Exams:</h2>
|
||||
{% with exams=object.shorts_exams.all app_name="shorts" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<div class="add-marker">
|
||||
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Shorts Exams</button>
|
||||
{% if object.shorts_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Shorts Exams</strong>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
|
||||
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'shorts' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading shorts exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer add-marker small"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.physics_exams.all %}
|
||||
<h2>Physics Exams:</h2>
|
||||
{% with exams=object.physics_exams.all app_name="physics" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
|
||||
{% if object.physics_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Physics Exams</strong>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'physics' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading physics exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.sbas_exams.all %}
|
||||
<h2>SBAs Exams:</h2>
|
||||
{% with exams=object.sbas_exams.all app_name="sbas" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
<a href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
|
||||
{% if object.sbas_count %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>SBAs Exams</strong>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
|
||||
</div>
|
||||
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'sbas' %}" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="placeholder small text-muted">Loading SBAs exams…</div>
|
||||
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<div id="bulk-add-groups">
|
||||
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
|
||||
</div>
|
||||
|
||||
<div id="add-user">
|
||||
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
|
||||
</div> {# .container end #}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.add-marker-button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* subtle visual tweaks for the new layout */
|
||||
.card-body .exam-list { margin: 0; }
|
||||
.card .card-footer.add-marker { min-height: 1.5rem; }
|
||||
.card-header strong { font-weight: 600; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "generic/base.html" %}
|
||||
{% extends "generic/examcollection_base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
@@ -16,11 +16,135 @@
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2 class="mb-4">Edit Exam Collection <small class="text-muted">{{ object.name }}</small></h2>
|
||||
|
||||
<h2>Edit Exam Collection {{object.name}}</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Improve visual separation of form sections for this collection form */
|
||||
#examcollection-form .mb-3 {
|
||||
padding: .75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: .375rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-left: 3px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
#examcollection-form .mb-3:nth-child(odd) {
|
||||
background: rgba(255,255,255,0.015);
|
||||
}
|
||||
#examcollection-form .form-section-divider {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
margin: .5rem 0 1rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#examcollection-form .form-section-title {
|
||||
font-size: .95rem;
|
||||
margin-bottom: .5rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="row gy-3">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label for="id_name" class="form-label requiredField">Name <span class="text-danger">*</span></label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="mb-2">
|
||||
<label for="id_date" class="form-label">Date</label>
|
||||
{{ form.date }}
|
||||
{% if form.date.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.date.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
<div class="form-check mb-2">
|
||||
{{ form.archive }}
|
||||
<label for="id_archive" class="form-check-label ms-2">Archive</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
|
||||
<div class="mb-3">
|
||||
<h5 class="mb-2">Authors</h5>
|
||||
<div>
|
||||
{{ form.author }}
|
||||
{% if form.author.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.author.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
{% for label, field_name, bound in exam_groups %}
|
||||
<div class="col">
|
||||
<div class="p-3 border rounded h-100">
|
||||
<h6 class="mb-2">{{ label }}</h6>
|
||||
<div>
|
||||
{{ bound }}
|
||||
{% if bound.errors %}
|
||||
<div class="invalid-feedback d-block">{{ bound.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Insert subtle dividers between logical groups (.mb-3 blocks)
|
||||
(function(){
|
||||
var form = document.getElementById('examcollection-form');
|
||||
if (!form) return;
|
||||
// find direct .mb-3 children inside the form
|
||||
var groups = Array.from(form.querySelectorAll(':scope > .mb-3'));
|
||||
if (groups.length <= 1) return;
|
||||
for (var i = 1; i < groups.length; i++) {
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'form-section-divider';
|
||||
groups[i].parentNode.insertBefore(divider, groups[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i> Save</button>
|
||||
<a href="{% url 'generic:examcollection_list' %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i> Back to collections</a>
|
||||
<button type="reset" class="btn btn-light">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// Simple client-side bootstrap validation
|
||||
(function () {
|
||||
'use strict'
|
||||
var forms = document.querySelectorAll('.needs-validation')
|
||||
Array.prototype.slice.call(forms).forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
form.classList.add('was-validated')
|
||||
}, false)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,13 @@
|
||||
{% extends app_name|add:'/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h3>Questions </h3>
|
||||
<a href="{% url app_name|add:':question_view' %}?author={{ request.user.id }}" class="btn btn-sm btn-outline-secondary">My questions</a>
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a href="{% url app_name|add:':question_create' %}" class="btn btn-sm btn-primary">Create question</a>
|
||||
</div>
|
||||
|
||||
<h3>Exams</h3>
|
||||
<div id="exam-list-wrapper">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% comment %} Preview partial for parsed rows; includes a form to create users {% endcomment %}
|
||||
<div id="cid-generate-preview" class="mt-2">
|
||||
<h6 class="small">Preview ({{ rows|length }} rows)</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-borderless">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Email</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr><td>{{ r.name }}</td><td>{{ r.email }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" hx-post="{% url 'generic:cid_group_create_bulk' group.pk %}" hx-target="#group-users-wrap" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<textarea name="rows" class="d-none">{{ original|escape }}</textarea>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Create users and add to group</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}">
|
||||
<div class="me-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></div>
|
||||
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{cid.name}} · {{cid.email}}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center ms-2">
|
||||
{% if cid.notes %}
|
||||
<div class="small text-muted me-2">Notes:
|
||||
<span class="cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">{{ cid.notes|truncatechars:60 }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cid.group and cid.group in exam.cid_user_groups.all %}
|
||||
<div class="small text-muted me-2"><span class="badge bg-info text-dark">{{ cid.group.name }}</span></div>
|
||||
{% endif %}
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
|
||||
<form method="post" class="d-inline m-0 p-0"
|
||||
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="edit_cid_user" value="{{cid.pk}}">
|
||||
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div id="cid-users-wrap">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
|
||||
<div>Added: <span id="cid-count-added">{{ current_cid_users|length }}</span></div>
|
||||
<div>Available: <span id="cid-count-available">{{ available_cid_users|length }}</span></div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="cid-users-list">
|
||||
{% for cid in current_cid_users %}
|
||||
{% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
|
||||
{% endfor %}
|
||||
{% for cid in available_cid_users %}
|
||||
{% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="p-3">
|
||||
<h4>CidUserExam: {% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% elif cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</h4>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr><th>CID</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% else %}-{% endif %}</td></tr>
|
||||
<tr><th>User</th><td>{% if cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</td></tr>
|
||||
<tr><th>Start time</th><td>{{ cid_user_exam.start_time }}</td></tr>
|
||||
<tr><th>End time</th><td>{{ cid_user_exam.end_time }}</td></tr>
|
||||
<tr><th>Completed</th><td>{{ cid_user_exam.completed }}</td></tr>
|
||||
<tr><th>Manual submission</th><td>{{ cid_user_exam.manual_submission }}</td></tr>
|
||||
<tr><th>Share with supervisor</th><td>{{ cid_user_exam.share_with_supervisor }}</td></tr>
|
||||
<tr><th>Results emailed status</th><td>{{ cid_user_exam.results_emailed_status }}</td></tr>
|
||||
<tr><th>CID User email</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.email }}{% else %}-{% endif %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex justify-content-end">
|
||||
{% if cid_user_exam %}
|
||||
<a class="btn btn-sm btn-outline-primary me-2" href="{% url 'generic:update_cid_user_exam' cid_user_exam.pk %}">Edit</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
{% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %}
|
||||
{% if cid_users %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for cid in cid_users %}
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
></i>
|
||||
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
|
||||
<span class="text-muted">[{{ cid.passcode }}]</span>
|
||||
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">{{ cid.name }}</div>
|
||||
<div class="small">Email: {{ cid.email }}</div>
|
||||
{% if cid.notes %}
|
||||
<div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="badge-container d-flex align-items-center ms-3"></div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">This exam has no CID candidates.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% comment %} Partial: submitted candidates fragment for HTMX swap {% endcomment %}
|
||||
{% if user_exam_data %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">Submitted candidates</h5>
|
||||
<div class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="small text-muted">Count: <span class="fw-semibold">{{ user_exam_data|length }}</span></div>
|
||||
{% if request.user.is_superuser or request.user.is_staff %}
|
||||
<div class="ms-3">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'admin:generic_ciduserexam_changelist' %}" target="_blank" rel="noopener">View submissions (admin)</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">CID</th>
|
||||
<th scope="col">User</th>
|
||||
<th scope="col">Start</th>
|
||||
<th scope="col">End</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submitted-candidates">
|
||||
{% for user_data in user_exam_data %}
|
||||
<tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}" data-first="{{ user_data.start_time|default_if_none:'' }}" data-last="{{ user_data.end_time|default_if_none:'' }}" {% if not user_data.in_exam %}class="table-warning"{% endif %}>
|
||||
<td class="fw-bold">{{ user_data.cid_user }}{% if not user_data.in_exam %} <span class="badge bg-danger ms-2" title="Candidate/User not added to exam">Not added</span>{% endif %}</td>
|
||||
<td>{{ user_data.user_user }}</td>
|
||||
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.start_time }}</td>
|
||||
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.end_time }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="View details" hx-get="{% url exam.get_app_name|add:':exam_user_status' exam.id %}?cid={{ user_data.cid_user }}" hx-target="#history-modal .modal-body" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-eye"></i></button>
|
||||
{% if request.user.is_superuser or request.user.is_staff %}
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'admin:generic_ciduserexam_change' user_data.pk %}" target="_blank" rel="noopener" title="Edit submission in admin"><i class="bi bi-pencil-square"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="mb-0 small text-muted">No submissions yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% comment %} Partial: list of User candidates for HTMX swap {% endcomment %}
|
||||
{% if user_users %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for user in user_users %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
|
||||
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
|
||||
hx-target=".modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#history-modal"
|
||||
></i>
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<span class="fw-bold">{{ user.username }}</span>
|
||||
</div>
|
||||
<div class="small">Email: {{ user.email }}</div>
|
||||
</div>
|
||||
<div class="badge-container d-flex align-items-center ms-3"></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">This exam has no User candidates.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
{% comment %} Minimal fragment rendering the exam-list partialdef for HTMX swaps. {% endcomment %}
|
||||
{% with exams=exams app_name=app_name marking=marking collection=collection %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center"
|
||||
data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}"
|
||||
data-exam-archive="{{ item.archive|yesno:'1,0' }}" data-exam-open-access="{{ item.open_access|yesno:'1,0' }}"
|
||||
data-exam-exam-mode="{{ item.exam_mode|yesno:'1,0' }}" data-exam-candidates-only="{{ item.candidates_only|yesno:'1,0' }}">
|
||||
<div class="flex-grow-1">
|
||||
{{ item.text|escape }}
|
||||
{% if item.archive %}<span class="badge bg-secondary ms-2">Archived</span>{% endif %}
|
||||
{% if item.open_access %}<span class="badge bg-success ms-2">Open</span>{% endif %}
|
||||
{% if item.exam_mode %}<span class="badge bg-info text-dark ms-2">Exam mode</span>{% endif %}
|
||||
{% if item.candidates_only %}<span class="badge bg-warning text-dark ms-2">Candidates only</span>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if results %}
|
||||
<div class="mt-2 text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-success exam-add-all-btn" data-field="{{ field }}">Add all results</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div><em>No exams found</em></div>
|
||||
{% endif %}
|
||||
@@ -1,30 +1,31 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef publish-results %}
|
||||
<div id="publish-results-status" class="d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Publish results</div>
|
||||
<div class="exam-status-container d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Results</div>
|
||||
<div>
|
||||
{% if exam.publish_results %}
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
class="btn btn-sm btn-success published-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#publish-results-status"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to unpublish results"
|
||||
aria-label="Unpublish results"
|
||||
hx-disabled-elt="this"
|
||||
|
||||
>Published</button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
class="btn btn-sm btn-secondary published-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#publish-results-status"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to publish results"
|
||||
aria-label="Publish results"
|
||||
hx-disabled-elt="this"
|
||||
>Unpublished</button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,30 +33,60 @@
|
||||
|
||||
|
||||
{% partialdef exam-active %}
|
||||
<div id="exam-active-status" class="d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Exam active</div>
|
||||
<div class="exam-status-container d-flex align-items-center gap-2">
|
||||
<div class="me-2 small text-muted">Exam status:</div>
|
||||
<div>
|
||||
{% if exam.active %}
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
class="btn btn-sm btn-success active-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#exam-active-status"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to deactivate this exam"
|
||||
aria-label="Deactivate exam"
|
||||
hx-disabled-elt="this"
|
||||
>Active</button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
class="btn btn-sm btn-secondary active-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#exam-active-status"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to activate this exam"
|
||||
aria-label="Activate exam"
|
||||
hx-disabled-elt="this"
|
||||
>Inactive</button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endpartialdef exam-active %}
|
||||
|
||||
{% partialdef exam-archived %}
|
||||
<div class="exam-status-container d-flex align-items-center gap-2">
|
||||
<div>
|
||||
{% if exam.archive %}
|
||||
<button
|
||||
class="btn btn-sm btn-warning archived-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to unarchive this exam"
|
||||
aria-label="Unarchive exam"
|
||||
><i class="bi bi-archive"></i></button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-sm btn-secondary archived-icon"
|
||||
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="closest .exam-status-container"
|
||||
title="Click to archive this exam"
|
||||
aria-label="Archive exam"
|
||||
><i class="bi bi-archive"></i></button>
|
||||
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endpartialdef %}
|
||||
@@ -0,0 +1,140 @@
|
||||
{% comment %} Partial: renders the users <ul> for group detail pages {% endcomment %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="me-auto"></div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary show-group-table-btn">Show table</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="group-users-list" class="list-group list-group-flush">
|
||||
{% for user in users %}
|
||||
<li class="list-group-item bg-transparent border-0 py-2"
|
||||
data-username="{% if group_type == 'cid' %}{{ user.cid|default:''|escape }}{% else %}{{ user.username|default:''|escape }}{% endif %}"
|
||||
data-name="{% if group_type == 'cid' %}{{ user.name|default:''|escape }}{% else %}{{ user.get_full_name|default:user.username|escape }}{% endif %}"
|
||||
data-email="{{ user.email|default:''|escape }}"
|
||||
{% if group_type == 'cid' %}data-cid="{{ user.cid|default:''|escape }}" data-passcode="{{ user.passcode|default:''|escape }}"{% endif %}>
|
||||
{% if group_type == "cid" %}
|
||||
<div class="d-flex align-items-start">
|
||||
<div>
|
||||
<div class="fw-bold">{{ user.name }}</div>
|
||||
<div class="small text-muted">{{ user.email }}</div>
|
||||
{% if user.notes %}
|
||||
<div class="small text-muted mt-1">Notes: {{ user.notes|truncatechars:120 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ms-auto text-end small text-muted">
|
||||
<div>CID: {{ user.cid }}</div>
|
||||
<div>Passcode: {{ user.passcode }}</div>
|
||||
<div class="mt-1">
|
||||
{% if user.login_email_sent %}
|
||||
<span class="badge bg-success ms-1 login-email-status">Email sent</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1 login-email-status">Not sent</span>
|
||||
{% endif %}
|
||||
{% if user.email %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-2 send-login-email-btn" data-cid="{{ user.cid }}" data-url="{% url 'generic:candidate_email_details' user.cid %}">Send Login Email</button>
|
||||
<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ user.get_email_details_url }}?preview=1" target="_blank">View Login Email</a>
|
||||
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'generic:update_cid' pk=user.pk %}">Edit Cid User</a>
|
||||
{% else %}
|
||||
<span class="small text-muted ms-2">No email</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold">{{ user.username }}</div>
|
||||
<div class="ms-2 small text-muted">{{ user.email }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Modal: Group users table / CSV export -->
|
||||
<div class="modal fade" id="groupUsersModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Group users</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="group-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Username / CID</th>
|
||||
<th>Passcode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="download-group-csv-btn">Download CSV</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const showBtn = document.querySelector('.show-group-table-btn');
|
||||
const modalEl = document.getElementById('groupUsersModal');
|
||||
if(!showBtn || !modalEl) return;
|
||||
const bsModal = new bootstrap.Modal(modalEl);
|
||||
|
||||
function buildTable(){
|
||||
const tbody = modalEl.querySelector('#group-users-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
const items = document.querySelectorAll('#group-users-list > li');
|
||||
items.forEach(function(li){
|
||||
const name = li.dataset.name || '';
|
||||
const email = li.dataset.email || '';
|
||||
const username = li.dataset.username || '';
|
||||
const cid = li.dataset.cid || '';
|
||||
const passcode = li.dataset.passcode || '';
|
||||
const userCell = cid ? cid : username;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${escapeHtml(name)}</td><td>${escapeHtml(email)}</td><td>${escapeHtml(userCell)}</td><td>${escapeHtml(passcode)}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
if(!s) return '';
|
||||
return s.toString().replace(/&/g, '&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
showBtn.addEventListener('click', function(){
|
||||
buildTable();
|
||||
bsModal.show();
|
||||
});
|
||||
|
||||
document.getElementById('download-group-csv-btn').addEventListener('click', function(){
|
||||
const rows = [];
|
||||
const header = ['Name','Email','Username/CID','Passcode'];
|
||||
rows.push(header);
|
||||
document.querySelectorAll('#group-users-table tbody tr').forEach(function(tr){
|
||||
const cols = Array.from(tr.children).map(td => td.textContent || '');
|
||||
rows.push(cols);
|
||||
});
|
||||
const csv = rows.map(r => r.map(cell => `"${(cell||'').toString().replace(/"/g,'""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g,'-');
|
||||
a.download = `group-users-${ts}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
{% load static %}
|
||||
{% if user_obj %}
|
||||
<div class="card p-2">
|
||||
<h5>Content by {{ user_obj.get_full_name }} ({{ user_obj.username }})</h5>
|
||||
|
||||
{% for app_key, info in content.items %}
|
||||
<div class="mb-2">
|
||||
<strong>{{ info.label }}</strong>
|
||||
<div class="small">
|
||||
Questions: {{ info.questions|length }} Exams: {{ info.exams|length }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{% if info.questions %}
|
||||
<ul class="list-unstyled ms-3">
|
||||
{% for q in info.questions %}
|
||||
<li>
|
||||
{% comment %} Try to link to app question detail if available {% endcomment %}
|
||||
<a href="{% url app_key|add:':question_detail' q.pk %}" target="_blank">{{ q }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if info.exams %}
|
||||
<ul class="list-unstyled ms-3">
|
||||
{% for ex in info.exams %}
|
||||
<li>{{ ex }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Atlas Cases</strong>
|
||||
<div class="small">Cases: {{ atlas_cases|length }}</div>
|
||||
{% if atlas_cases %}
|
||||
<ul class="list-unstyled ms-3">
|
||||
{% for case in atlas_cases %}
|
||||
<li><a href="{% url 'atlas:case_detail' case.pk %}" target="_blank">{{ case }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Atlas Collections</strong>
|
||||
<div class="small">Collections: {{ atlas_collections|length }}</div>
|
||||
{% if atlas_collections %}
|
||||
<ul class="list-unstyled ms-3">
|
||||
{% for coll in atlas_collections %}
|
||||
<li><a href="{% url 'atlas:collection_detail' coll.pk %}" target="_blank">{{ coll }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div>No user found</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% comment %}Partial for a single user group card. Rendered by HTMX after archive toggles.{% endcomment %}
|
||||
<div class="card mb-3" id="group-card-{{ group.pk }}">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-0"><a href="{% url 'generic:user_group_detail' group.pk %}">{{ group.name }}</a></h5>
|
||||
{% if group.description %}
|
||||
<p class="mb-0 text-muted small">{{ group.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group" aria-label="Group actions">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'generic:user_group_update' pk=group.pk %}">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'generic:user_group_delete' pk=group.pk %}">Delete</a>
|
||||
|
||||
<form hx-post="{% url 'generic:user_group_toggle_archive' pk=group.pk %}" hx-swap="outerHTML" method="post" class="d-inline-block ms-1">
|
||||
{% csrf_token %}
|
||||
{% if view_all %}
|
||||
<input type="hidden" name="view_all" value="1">
|
||||
{% endif %}
|
||||
{% if group.archive %}
|
||||
<input type="hidden" name="archive" value="false">
|
||||
<button class="btn btn-sm btn-outline-success">Unarchive</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="archive" value="true">
|
||||
<button class="btn btn-sm btn-outline-secondary">Archive</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +1,15 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">
|
||||
<div class="flex-grow-1">
|
||||
{{ item.text|escape }}
|
||||
{% if item.grade %}
|
||||
<small class="text-muted ms-2">{{ item.grade|escape }}</small>
|
||||
<span class="badge bg-secondary ms-2">{{ item.grade|escape }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button>
|
||||
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{ user.pk }}" data-username="{{ user.username }}" data-name="{{ user.get_full_name|default:user.username|escape }}" data-email="{{ user.email|escape }}">
|
||||
<div class="me-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold small mb-0">{{ user.username }}</div>
|
||||
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ user.get_full_name|default:user.username }} · {{ user.email }}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center ms-2">
|
||||
{% comment %} show any exam user-groups this user belongs to as badges {% endcomment %}
|
||||
<div class="me-2">
|
||||
{% for g in exam.user_user_groups.all %}
|
||||
{% if user in g.users.all %}
|
||||
<span class="badge bg-info text-dark me-1">{{ g.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}' class="btn btn-sm btn-outline-light">Profile</a>
|
||||
{% endif %}
|
||||
<form method="post" class="d-inline m-0 p-0"
|
||||
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="edit_user_user" value="{{ user.pk }}">
|
||||
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div id="user-users-wrap">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
|
||||
<div>Added: <span id="user-count-added">{{ current_user_users|length }}</span></div>
|
||||
<div>Available: <span id="user-count-available">{{ available_user_users|length }}</span></div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="user-users-list">
|
||||
{% for user in current_user_users %}
|
||||
{% include 'generic/partials/user_user_li.html' with user=user current=True exam=exam %}
|
||||
{% endfor %}
|
||||
{% for user in available_user_users %}
|
||||
{% include 'generic/partials/user_user_li.html' with user=user current=False exam=exam %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends 'generic/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>User content</h2>
|
||||
|
||||
<p>Click "Show content" to load items created by a user.</p>
|
||||
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Username</th>
|
||||
<th>Content count</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.first_name }} {{ user.last_name }}</td>
|
||||
<td><a href="{% url 'account_profile' user.username %}">{{ user.username }}</a></td>
|
||||
<td>{{ user.content_count }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'generic:user_content_review' user.pk %}"
|
||||
hx-target="closest tr"
|
||||
hx-swap="afterend">Show content</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,38 +1,25 @@
|
||||
{% extends 'generic/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>User Groups</h2>
|
||||
<p>
|
||||
<a href="{% url 'generic:user_group_create' %}">Create new group</a>
|
||||
</p>
|
||||
{% for group in groups %}
|
||||
<p><a href="{% url 'generic:user_group_detail' group.pk %}"><h4>{{group.name}}</h4></a>
|
||||
{% comment %} <button class="small-url-button" data-url="{% url 'generic:group_email' pk=group.pk %}
|
||||
">Email candidate details</button>
|
||||
<button class="small-url-button" data-url="{% url 'generic:group_email_resend' pk=group.pk %}
|
||||
">Resend candidate details</button>
|
||||
<button class="small-url-button" data-url="{% url 'generic:group_email_results' pk=group.pk %}
|
||||
">Email candidate results</button>
|
||||
<button class="small-url-button" data-url="{% url 'generic:group_email_results_resend' pk=group.pk %}
|
||||
">Resend candidate results</button> {% endcomment %}
|
||||
<button class="small-url-button" data-url="{% url 'generic:user_group_update' pk=group.pk %}
|
||||
">Edit</button>
|
||||
<button class="small-url-button" data-url="{% url 'generic:user_group_delete' pk=group.pk %}
|
||||
">Delete</button>
|
||||
</p>
|
||||
{% endfor %}
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
$(".small-url-button").click((evt) => {
|
||||
window.location = evt.currentTarget.dataset.url;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0">User Groups</h2>
|
||||
<div>
|
||||
<a class="btn btn-sm btn-primary me-2" href="{% url 'generic:user_group_create' %}">Create new group</a>
|
||||
{% if view_all %}
|
||||
<a href="{% url 'generic:user_group_view' %}">View active</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view' %}">View active</a>
|
||||
{% else %}
|
||||
<a href="{% url 'generic:user_group_view_all' %}">View all</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view_all' %}">View all</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="groups-list">
|
||||
{% for group in groups %}
|
||||
{% include 'generic/partials/user_group_card.html' with group=group view_all=view_all %}
|
||||
{% empty %}
|
||||
<div class="alert alert-info">No groups found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+46
-1
@@ -8,6 +8,9 @@ from generic.views import ExamViews as GenericExamViews, GenericViewBase
|
||||
app_name = "generic"
|
||||
|
||||
urlpatterns = [
|
||||
# User content review views
|
||||
path("user-content/", views.user_content_list, name="user_content_list"),
|
||||
path("user-content/<int:user_id>/", views.user_content_review, name="user_content_review"),
|
||||
path("examination/", views.ExaminationView.as_view(), name="examination_view"),
|
||||
path("examination/<int:pk>", views.examination_detail, name="examination_detail"),
|
||||
path(
|
||||
@@ -59,6 +62,7 @@ urlpatterns = [
|
||||
),
|
||||
path("user-search/", views.user_search, name="user_search"),
|
||||
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
|
||||
path("exam-search-widget/", views.exam_search_widget, name="exam_search_widget"),
|
||||
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||
path(
|
||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
||||
@@ -87,6 +91,16 @@ urlpatterns = [
|
||||
views.cid_group_view_detail,
|
||||
name="cid_group_detail",
|
||||
),
|
||||
path(
|
||||
"cids/group/<int:group_id>/generate_preview",
|
||||
views.cid_group_generate_preview,
|
||||
name="cid_group_generate_preview",
|
||||
),
|
||||
path(
|
||||
"cids/group/<int:group_id>/create_bulk",
|
||||
views.cid_group_create_bulk,
|
||||
name="cid_group_create_bulk",
|
||||
),
|
||||
path("cids/group/<int:pk>/email", views.group_email, name="group_email"),
|
||||
path(
|
||||
"cids/group/<int:pk>/exams",
|
||||
@@ -113,6 +127,18 @@ urlpatterns = [
|
||||
views.CidUserGroupUpdate.as_view(),
|
||||
name="cid_group_update",
|
||||
),
|
||||
|
||||
# HTMX partial to show a CidUserExam record for an exam (used from exam lists)
|
||||
path(
|
||||
"exam/<str:app_name>/<int:exam_id>/cid/<int:cid>/ciduserexam",
|
||||
views.cid_user_exam_partial,
|
||||
name="cid_user_exam_partial",
|
||||
),
|
||||
path(
|
||||
"cids/ciduserexam/<int:pk>/update",
|
||||
views.CidUserExamRecordUpdate.as_view(),
|
||||
name="update_cid_user_exam",
|
||||
),
|
||||
path(
|
||||
"cids/group/<int:pk>/delete",
|
||||
views.CidUserGroupDelete.as_view(),
|
||||
@@ -178,6 +204,11 @@ urlpatterns = [
|
||||
views.UserUserGroupDelete.as_view(),
|
||||
name="user_group_delete",
|
||||
),
|
||||
path(
|
||||
"user/group/<int:pk>/archive",
|
||||
views.user_group_toggle_archive,
|
||||
name="user_group_toggle_archive",
|
||||
),
|
||||
path(
|
||||
"user/group/create",
|
||||
views.UserUserGroupCreate.as_view(),
|
||||
@@ -258,6 +289,7 @@ urlpatterns = [
|
||||
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
|
||||
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
|
||||
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
|
||||
path("collection/<int:collection_id>/exams/<str:exam_type>/partial", views.exam_collection_exams_partial, name="exam_collection_exams_partial"),
|
||||
path(
|
||||
"share_with_supervisor_toggle/<int:pk>/",
|
||||
views.toggle_share_with_supervisor,
|
||||
@@ -416,7 +448,17 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
name="exam_json_edit",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/scores",
|
||||
"exam/<int:pk>/cid_bulk_edit",
|
||||
generic_exam_view.exam_cid_bulk_edit,
|
||||
name="exam_cid_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/user_bulk_edit",
|
||||
generic_exam_view.exam_user_bulk_edit,
|
||||
name="exam_user_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/scores/all",
|
||||
generic_exam_view.exam_scores_all,
|
||||
name="exam_scores_all",
|
||||
),
|
||||
@@ -485,6 +527,9 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
name="order_questions",
|
||||
),
|
||||
path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"),
|
||||
path("exam/<int:exam_id>/cids/cid_list", generic_exam_view.exam_cids_cid_list, name="exam_cids_cid_list"),
|
||||
path("exam/<int:exam_id>/cids/user_list", generic_exam_view.exam_cids_user_list, name="exam_cids_user_list"),
|
||||
path("exam/<int:exam_id>/cids/submitted_list", generic_exam_view.exam_cids_submitted_list, name="exam_cids_submitted_list"),
|
||||
# path("exam/<int:exam_id>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"),
|
||||
path(
|
||||
"exam/<int:exam_id>/reset_answers",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user