Compare commits
27
Commits
dockerprod
...
7674f29124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 @@ 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
|
||||
+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).
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<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
|
||||
@@ -38,6 +38,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>
|
||||
|
||||
+19
-10
@@ -1090,10 +1090,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()
|
||||
@@ -1566,8 +1568,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 +1591,19 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
|
||||
context = self.get_context_data(form=form)
|
||||
case_formset = context["case_formset"]
|
||||
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)
|
||||
# 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."""
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -117,6 +117,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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
+19
-290
@@ -81,6 +81,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 +241,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 +253,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
|
||||
|
||||
@@ -556,219 +557,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 +578,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 +623,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 +716,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 +861,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):
|
||||
|
||||
@@ -65,6 +65,9 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -15,16 +15,43 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% block navigation %}
|
||||
Candidates:
|
||||
{% 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>
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
{% endif %}
|
||||
<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 %}
|
||||
|
||||
@@ -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,10 +121,10 @@
|
||||
</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>
|
||||
@@ -133,7 +133,6 @@
|
||||
<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="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>
|
||||
|
||||
@@ -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,148 @@
|
||||
|
||||
{% 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>
|
||||
<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">
|
||||
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</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>
|
||||
<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">
|
||||
{% with exams=object.longs_exams.all app_name="longs" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</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>
|
||||
<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">
|
||||
{% with exams=object.rapids_exams.all app_name="rapids" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</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>
|
||||
<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">
|
||||
{% with exams=object.shorts_exams.all app_name="shorts" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</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>
|
||||
<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">
|
||||
{% with exams=object.physics_exams.all app_name="physics" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</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>
|
||||
<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">
|
||||
{% with exams=object.sbas_exams.all app_name="sbas" %}
|
||||
{% include "exam_list.html#exam-list" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<div id="bulk-add-groups">
|
||||
<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>
|
||||
</div>
|
||||
<div id="add-user">
|
||||
<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' %}
|
||||
{% 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 %}
|
||||
@@ -7,20 +7,144 @@
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{form.media}}
|
||||
{{form.media}}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% 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">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
|
||||
{% csrf_token %}
|
||||
{% 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 %}
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -59,6 +59,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"
|
||||
|
||||
+255
-7
@@ -1774,7 +1774,7 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
def exam_list_collection(self, request, collection_id):
|
||||
collection = get_object_or_404(ExamCollection, pk=collection_id)
|
||||
|
||||
if not request.user in collection.author.all():
|
||||
if not collection.is_author(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
return self.exam_list(request, all=True, collection=collection)
|
||||
@@ -5490,11 +5490,38 @@ class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
form_class = ExamCollectionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Ensure we have a bound form to extract bound fields
|
||||
form = kwargs.get('form') or self.get_form()
|
||||
groups = []
|
||||
for label, field_name, model in getattr(self.form_class, 'GROUP_TYPES', []):
|
||||
try:
|
||||
bound = form[field_name]
|
||||
except Exception:
|
||||
bound = None
|
||||
groups.append((label, field_name, bound))
|
||||
context['exam_groups'] = groups
|
||||
return context
|
||||
|
||||
|
||||
class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
form_class = ExamCollectionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
form = kwargs.get('form') or self.get_form()
|
||||
groups = []
|
||||
for label, field_name, model in getattr(self.form_class, 'GROUP_TYPES', []):
|
||||
try:
|
||||
bound = form[field_name]
|
||||
except Exception:
|
||||
bound = None
|
||||
groups.append((label, field_name, bound))
|
||||
context['exam_groups'] = groups
|
||||
return context
|
||||
|
||||
|
||||
class ExamCollectionClone(CreateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
@@ -6045,6 +6072,223 @@ def user_search_widget(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_search_widget(request):
|
||||
"""HTMX/JS endpoint to search exams for the exam-selection widget.
|
||||
|
||||
GET params:
|
||||
- q: query string
|
||||
- field: the form field name to return with the results (so the
|
||||
template can call addExamToField(field, id, text)).
|
||||
- model: optional model label ("app_label.ModelName") to restrict
|
||||
search to a specific exam model.
|
||||
|
||||
Returns a small HTML fragment listing matching exams. Each result will
|
||||
include a button with class `.exam-add-btn` and `data-exam-id`/`data-exam-text`.
|
||||
"""
|
||||
import re
|
||||
from django.apps import apps
|
||||
|
||||
q_raw = (request.GET.get("q") or "").strip()
|
||||
field = request.GET.get("field") or request.GET.get("row")
|
||||
model_label = request.GET.get("model")
|
||||
if not q_raw:
|
||||
return HttpResponse("")
|
||||
|
||||
# Support wildcard queries
|
||||
q = q_raw
|
||||
|
||||
model = None
|
||||
if model_label:
|
||||
try:
|
||||
# model_label may be 'app_label.ModelName' or the model label
|
||||
model = apps.get_model(model_label)
|
||||
except Exception:
|
||||
try:
|
||||
# try splitting
|
||||
app_label, model_name = model_label.split(".")
|
||||
model = apps.get_model(app_label, model_name)
|
||||
except Exception:
|
||||
model = None
|
||||
|
||||
# Helper to parse optional boolean GET params
|
||||
def _get_bool_param(name):
|
||||
v = request.GET.get(name)
|
||||
if v is None:
|
||||
return None
|
||||
return str(v).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# Build queryset
|
||||
qs = None
|
||||
if model is not None:
|
||||
# Try common name-like fields for exams
|
||||
from django.db.models import Q
|
||||
|
||||
field_lookups = ["name__icontains", "title__icontains", "examination__icontains", "modality__icontains"]
|
||||
for lookup in field_lookups:
|
||||
try:
|
||||
qs = model.objects.filter(**{lookup: q})
|
||||
if qs.exists():
|
||||
break
|
||||
except Exception:
|
||||
qs = None
|
||||
# fallback: try icontains on str() via name if present
|
||||
if qs is None or not qs.exists():
|
||||
try:
|
||||
qs = model.objects.filter(Q(name__icontains=q) | Q(title__icontains=q))
|
||||
except Exception:
|
||||
try:
|
||||
qs = model.objects.all()
|
||||
except Exception:
|
||||
qs = model.objects.none()
|
||||
else:
|
||||
# If no model specified, try to search across several known exam models
|
||||
possible_models = [
|
||||
"anatomy.Exam",
|
||||
"longs.Exam",
|
||||
"rapids.Exam",
|
||||
"shorts.Exam",
|
||||
"physics.Exam",
|
||||
"sbas.Exam",
|
||||
]
|
||||
results = []
|
||||
|
||||
# gather optional filters from GET params
|
||||
extra_filters = {}
|
||||
for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
|
||||
val = _get_bool_param(pname)
|
||||
if val is not None:
|
||||
extra_filters[pname] = val
|
||||
|
||||
for ml in possible_models:
|
||||
try:
|
||||
m = apps.get_model(*ml.split("."))
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
# try common name-like fields and apply extra_filters when possible
|
||||
try:
|
||||
rqs = m.objects.filter(name__icontains=q, **extra_filters)[:5]
|
||||
except Exception:
|
||||
try:
|
||||
rqs = m.objects.filter(title__icontains=q, **extra_filters)[:5]
|
||||
except Exception:
|
||||
# fallback to unfiltered small slice
|
||||
rqs = m.objects.filter(name__icontains=q)[:5]
|
||||
except Exception:
|
||||
rqs = m.objects.none()
|
||||
for o in rqs:
|
||||
results.append(o)
|
||||
|
||||
# Render combined results, but restrict to exams the user can access
|
||||
def user_can_view_exam(exam, user):
|
||||
try:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if exam.open_access:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
authors = exam.get_author_objects()
|
||||
if user in authors:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(exam, "markers") and user in exam.markers.all():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
rendered_results = []
|
||||
for o in results[:50]:
|
||||
if user_can_view_exam(o, request.user):
|
||||
rendered_results.append({
|
||||
"id": getattr(o, "pk", None),
|
||||
"text": str(o),
|
||||
"archive": getattr(o, "archive", False),
|
||||
"open_access": getattr(o, "open_access", False),
|
||||
"exam_mode": getattr(o, "exam_mode", False),
|
||||
"candidates_only": getattr(o, "candidates_only", False),
|
||||
})
|
||||
return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw})
|
||||
|
||||
# Limit results
|
||||
limit = 50 if q in ("*", "%") else 10
|
||||
try:
|
||||
qs = qs.order_by("name")[:limit]
|
||||
except Exception:
|
||||
qs = qs[:limit]
|
||||
|
||||
# Restrict results to exams the requesting user can access
|
||||
def user_can_view_exam(exam, user):
|
||||
try:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if getattr(exam, "open_access", False) and getattr(exam, "active", False):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
authors = exam.get_author_objects()
|
||||
if user in authors:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(exam, "cid_user_exam") and exam.cid_user_exam.filter(user_user=user).exists():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(exam, "markers") and user in exam.markers.all():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Apply optional filters from GET params to the per-model queryset
|
||||
extra_filters = {}
|
||||
for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
|
||||
val = _get_bool_param(pname)
|
||||
if val is not None:
|
||||
extra_filters[pname] = val
|
||||
|
||||
results = []
|
||||
for e in qs:
|
||||
# apply any extra_filters defensively (models may not have fields)
|
||||
skip = False
|
||||
for k, v in extra_filters.items():
|
||||
try:
|
||||
if getattr(e, k, None) != v:
|
||||
skip = True
|
||||
break
|
||||
except Exception:
|
||||
# if attribute access fails, don't skip
|
||||
continue
|
||||
if skip:
|
||||
continue
|
||||
if user_can_view_exam(e, request.user):
|
||||
results.append({
|
||||
"id": e.pk,
|
||||
"text": str(e),
|
||||
"archive": getattr(e, "archive", False),
|
||||
"open_access": getattr(e, "open_access", False),
|
||||
"exam_mode": getattr(e, "exam_mode", False),
|
||||
"candidates_only": getattr(e, "candidates_only", False),
|
||||
})
|
||||
|
||||
return render(request, "generic/partials/exam_search_widget_results.html", {"results": results, "field": field, "q": q_raw})
|
||||
|
||||
|
||||
@login_required
|
||||
def supervisor_search(request):
|
||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||
@@ -6062,12 +6306,16 @@ def supervisor_search(request):
|
||||
if not q:
|
||||
return HttpResponse("")
|
||||
|
||||
supervisors_qs = Supervisor.objects.filter(
|
||||
Q(name__icontains=q) | Q(email__icontains=q)
|
||||
).order_by("name")[:10]
|
||||
# Search Supervisor records by name or email and render a small partial
|
||||
try:
|
||||
from django.db.models import Q
|
||||
qs = Supervisor.objects.filter(Q(name__icontains=q) | Q(email__icontains=q)).order_by("name")[:10]
|
||||
except Exception:
|
||||
qs = Supervisor.objects.none()
|
||||
|
||||
results = [{"id": s.pk, "text": f"{s.name} - {s.email or ''}"} for s in supervisors_qs]
|
||||
|
||||
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": row, "q": q})
|
||||
results = []
|
||||
for s in qs:
|
||||
results.append({"id": s.pk, "text": str(s)})
|
||||
|
||||
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": row})
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.conf import settings
|
||||
import json
|
||||
|
||||
# Default filters for exam search widget. Can be overridden in Django settings
|
||||
# as `GENERIC_EXAM_SEARCH_DEFAULT_FILTERS` mapping, e.g.:
|
||||
# GENERIC_EXAM_SEARCH_DEFAULT_FILTERS = { 'archive': False, 'exam_mode': True }
|
||||
DEFAULT_EXAM_SEARCH_FILTERS = getattr(
|
||||
settings, "GENERIC_EXAM_SEARCH_DEFAULT_FILTERS", {"archive": False, "exam_mode": True}
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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:
|
||||
# determine grade_text first, then include data-user-grade attribute on 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 = ""
|
||||
safe_grade_attr = (grade_text.replace('"', '"') if grade_text else '')
|
||||
options_html += f'<option value="{u.pk}" selected data-user-grade="{safe_grade_attr}">{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
|
||||
# Build the selected item HTML incrementally to avoid accidental
|
||||
# mixing of boolean values into string concatenation (was causing
|
||||
# a TypeError in some environments).
|
||||
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>'
|
||||
)
|
||||
if grade_text:
|
||||
selected_html += f' <span class="badge bg-light text-muted ms-2 small border">{grade_text}</span>'
|
||||
selected_html += (
|
||||
'</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||
'</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);
|
||||
}});
|
||||
|
||||
// Prevent Enter from submitting the surrounding form; run the search instead
|
||||
search.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearTimeout(timeout);
|
||||
fetchResults(search.value);
|
||||
}}
|
||||
}});
|
||||
|
||||
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();
|
||||
// Try button attribute, then list-item dataset, then visible badge text
|
||||
let grade = btn.getAttribute('data-user-grade') || '';
|
||||
if (!grade) {{
|
||||
const li = btn.closest('li[data-user-id]');
|
||||
if (li && li.dataset && li.dataset.userGrade) grade = li.dataset.userGrade;
|
||||
else {{
|
||||
const badge = btn.closest('li') ? btn.closest('li').querySelector('.badge') : null;
|
||||
if (badge) grade = badge.textContent.trim();
|
||||
}}
|
||||
}}
|
||||
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 listEl = document.getElementById('selected_users_' + fieldName);
|
||||
if (!listEl) return;
|
||||
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 ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
|
||||
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);
|
||||
listEl.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);
|
||||
}});
|
||||
}}
|
||||
|
||||
// Initialize visible selected-list from any pre-existing <option selected> entries
|
||||
(function initSelectedListFromOptions() {{
|
||||
try {{
|
||||
const sel = document.getElementById('{field_id}');
|
||||
const listEl = document.getElementById('{selected_list_id}');
|
||||
if (!sel || !listEl) return;
|
||||
for (let i=0;i<sel.options.length;i++) {{
|
||||
const opt = sel.options[i];
|
||||
const id = opt.value;
|
||||
if (!id) continue;
|
||||
const liId = 'selected_user_' + '{self.name}' + '_' + id;
|
||||
const existingLi = document.getElementById(liId);
|
||||
const grade = opt.getAttribute('data-user-grade') || '';
|
||||
|
||||
if (existingLi) {{
|
||||
// If server-rendered li exists but has no badge, add one from the option attribute
|
||||
const hasBadge = existingLi.querySelector('.badge');
|
||||
if (!hasBadge && grade) {{
|
||||
const span = existingLi.querySelector('span');
|
||||
if (span) {{
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-secondary ms-2';
|
||||
badge.textContent = grade;
|
||||
span.appendChild(badge);
|
||||
}}
|
||||
}}
|
||||
continue;
|
||||
}}
|
||||
|
||||
// build li from option when not rendered server-side
|
||||
const text = opt.textContent || opt.innerText || opt.text;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = liId;
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
|
||||
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('{self.name}', id); }});
|
||||
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
||||
}}
|
||||
}} catch (e) {{ /* ignore init errors */ }}
|
||||
}})();
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class UserSearchWidget:
|
||||
"""Django-widget-compatible lightweight wrapper around
|
||||
`UserSearchSelectMultipleWidget` so it can be reused as a field widget.
|
||||
|
||||
Implements the small subset of the Widget API that Django's forms
|
||||
and templates expect: `render`, `value_from_datadict`, `id_for_label`,
|
||||
`use_required_attribute`, and a couple of simple attributes.
|
||||
"""
|
||||
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
|
||||
def __init__(self, field=None):
|
||||
# keep a reference to the original field if available (used for
|
||||
# delegation such as `use_required_attribute`).
|
||||
self.field = field
|
||||
self.attrs = {}
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = UserSearchSelectMultipleWidget(name, value)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
# Most form backends provide getlist
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
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):
|
||||
try:
|
||||
if hasattr(self, "field") and getattr(self, "field") is not None:
|
||||
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
|
||||
|
||||
|
||||
class ExamSearchSelectMultipleWidget:
|
||||
"""Lightweight renderer for selecting exams across types.
|
||||
|
||||
Parameters
|
||||
- name, value: same as form widget usage
|
||||
- exam_model: optional Django model class for an exam type (used to
|
||||
adapt display and search behaviour)
|
||||
- display_fields: optional list of extra fields to show in results
|
||||
"""
|
||||
|
||||
def __init__(self, name, value, exam_model=None, display_fields=None, default_filters=None):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
# default filters for this widget instance; fall back to module defaults
|
||||
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"exam_search_{self.name}"
|
||||
results_id = f"exam_search_results_{self.name}"
|
||||
selected_list_id = f"selected_exams_{self.name}"
|
||||
|
||||
# Render currently selected exams as <option>s
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
try:
|
||||
if self.value:
|
||||
from django.apps import apps
|
||||
|
||||
# Accept either pks or model instances
|
||||
instances = []
|
||||
for v in self.value:
|
||||
if hasattr(v, "pk"):
|
||||
instances.append(v)
|
||||
else:
|
||||
# try to resolve by pk across possible models
|
||||
# when exam_model is provided prefer that
|
||||
if self.exam_model is not None:
|
||||
try:
|
||||
inst = self.exam_model.objects.filter(pk=v).first()
|
||||
if inst:
|
||||
instances.append(inst)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: try generic lookup across known exam models is expensive;
|
||||
# just attempt to treat v as a pk for display-less option
|
||||
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
|
||||
|
||||
for ex in instances:
|
||||
label = str(ex)
|
||||
pk = getattr(ex, "pk", "")
|
||||
options_html += f'<option value="{pk}" selected>{label}</option>'
|
||||
# badges for metadata where available
|
||||
# use smaller, less vibrant badges: low-opacity background + matching text color
|
||||
archive_badge = '<span class="badge small border bg-secondary bg-opacity-10 text-secondary ms-2">Archived</span>' if getattr(ex, 'archive', False) else ''
|
||||
open_badge = '<span class="badge small border bg-success bg-opacity-10 text-success ms-2">Open</span>' if getattr(ex, 'open_access', False) else ''
|
||||
mode_badge = '<span class="badge small border bg-info bg-opacity-10 text-info ms-2">Exam mode</span>' if getattr(ex, 'exam_mode', False) else ''
|
||||
cand_badge = '<span class="badge small border bg-warning bg-opacity-10 text-warning ms-2">Candidates only</span>' if getattr(ex, 'candidates_only', False) else ''
|
||||
selected_html += (
|
||||
f'<li id="selected_exam_{self.name}_{pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{label}{archive_badge}{open_badge}{mode_badge}{cand_badge}</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {pk})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
except Exception:
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
|
||||
url = reverse("generic:exam_search_widget") if reverse else ""
|
||||
|
||||
html = f"""
|
||||
<div class="exam-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 exams by name or code" />
|
||||
<!-- optional type indicator -->
|
||||
{f'<div class="ms-2"><span class="badge bg-primary text-white small fw-semibold" aria-hidden="true">{self.exam_model._meta.verbose_name.title()}</span><span class="visually-hidden">Exam type: {self.exam_model._meta.verbose_name.title()}</span></div>' if self.exam_model is not None else ''}
|
||||
<button type="button" id="exam_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="exam_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
||||
</div>
|
||||
<div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
||||
<strong>Advanced search tips</strong>
|
||||
<div class="mb-2 mt-2">
|
||||
<strong class="small">Filters</strong>
|
||||
<div class="d-flex gap-2 mt-1">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="filter_archive_{self.name}" />
|
||||
<label class="form-check-label small" for="filter_archive_{self.name}">Archived only</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="filter_open_access_{self.name}" />
|
||||
<label class="form-check-label small" for="filter_open_access_{self.name}">Open access only</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="filter_exam_mode_{self.name}" />
|
||||
<label class="form-check-label small" for="filter_exam_mode_{self.name}">Exam mode only</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="filter_candidates_only_{self.name}" />
|
||||
<label class="form-check-label small" for="filter_candidates_only_{self.name}">Candidates only</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="mb-0 mt-1">
|
||||
<li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</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> — matches the exam name.</li>
|
||||
<li><code>code:</code> — matches an exam code or short identifier.</li>
|
||||
<li><code>id:</code> or <code>pk:</code> — match by numeric id.</li>
|
||||
<li><code>date:</code> — match by date (YYYY-MM-DD) where available.</li>
|
||||
<li><code>type:</code> — narrow to an exam type (e.g. <code>type:anatomy</code>).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
|
||||
<li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected exams</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
<div class="mt-2 text-end">
|
||||
<button type="button" id="exam_remove_all_{self.name}" class="btn btn-sm btn-outline-danger exam-remove-all-btn d-none py-0 px-1 small" data-field="{self.name}">Remove all</button>
|
||||
</div>
|
||||
</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 helpBtn = document.getElementById('exam_search_help_btn_{self.name}');
|
||||
const helpPanel = document.getElementById('exam_search_help_panel_{self.name}');
|
||||
|
||||
const defaultModel = '{self.exam_model._meta.label if self.exam_model is not None else ''}';
|
||||
const defaultFilters = {json.dumps(self.default_filters)};
|
||||
const widgetFieldName = '{self.name}';
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
|
||||
const f_archive = document.getElementById('filter_archive_{self.name}');
|
||||
const f_open = document.getElementById('filter_open_access_{self.name}');
|
||||
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
|
||||
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
|
||||
let extra = '';
|
||||
|
||||
// Initialize checkbox states from defaults (if provided)
|
||||
try {{
|
||||
if (f_archive && defaultFilters.hasOwnProperty('archive')) f_archive.checked = !!defaultFilters.archive;
|
||||
if (f_open && defaultFilters.hasOwnProperty('open_access')) f_open.checked = !!defaultFilters.open_access;
|
||||
if (f_exam_mode && defaultFilters.hasOwnProperty('exam_mode')) f_exam_mode.checked = !!defaultFilters.exam_mode;
|
||||
if (f_candidates && defaultFilters.hasOwnProperty('candidates_only')) f_candidates.checked = !!defaultFilters.candidates_only;
|
||||
}} catch (e) {{ /* ignore */ }}
|
||||
|
||||
// For each supported filter, send the param if there's a default or the checkbox exists.
|
||||
function appendFilterParam(name, checkboxEl, defaultVal) {{
|
||||
if (typeof defaultVal !== 'undefined') {{
|
||||
const val = checkboxEl ? checkboxEl.checked : defaultVal;
|
||||
extra += '&' + encodeURIComponent(name) + '=' + (val ? '1' : '0');
|
||||
}} else if (checkboxEl && checkboxEl.checked) {{
|
||||
extra += '&' + encodeURIComponent(name) + '=1';
|
||||
}}
|
||||
}}
|
||||
|
||||
appendFilterParam('archive', f_archive, defaultFilters.archive);
|
||||
appendFilterParam('open_access', f_open, defaultFilters.open_access);
|
||||
appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode);
|
||||
appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only);
|
||||
|
||||
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra;
|
||||
fetch(url)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
// Prevent Enter from submitting the surrounding form; run the search instead
|
||||
search.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearTimeout(timeout);
|
||||
fetchResults(search.value);
|
||||
}}
|
||||
}});
|
||||
|
||||
// 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');
|
||||
}});
|
||||
}}
|
||||
|
||||
// Delegate clicks from results: add single or add-all (works for dynamically inserted content)
|
||||
results.addEventListener('click', function(e) {{
|
||||
const addBtn = e.target.closest('.exam-add-btn');
|
||||
if (addBtn) {{
|
||||
const id = addBtn.getAttribute('data-exam-id');
|
||||
const text = addBtn.getAttribute('data-exam-text') || addBtn.textContent.trim();
|
||||
addExamToField(widgetFieldName, id, text);
|
||||
return;
|
||||
}}
|
||||
const addAllBtnDynamic = e.target.closest('.exam-add-all-btn');
|
||||
if (addAllBtnDynamic) {{
|
||||
e.preventDefault();
|
||||
addAllExamsFromResults(widgetFieldName);
|
||||
return;
|
||||
}}
|
||||
}});
|
||||
|
||||
|
||||
// Wire up "Add all results" static button if present (fallback)
|
||||
const addAllBtn = results.parentElement.querySelector('.exam-add-all-btn');
|
||||
if (addAllBtn) {{
|
||||
addAllBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
addAllExamsFromResults(widgetFieldName);
|
||||
}});
|
||||
}}
|
||||
|
||||
// Initialize remove-all visibility based on any pre-populated selections
|
||||
try {{
|
||||
var initList = document.getElementById('selected_exams_' + widgetFieldName);
|
||||
var remBtnInit = document.getElementById('exam_remove_all_' + widgetFieldName);
|
||||
if (remBtnInit && initList) {{
|
||||
remBtnInit.classList.toggle('d-none', initList.querySelectorAll('li').length < 2);
|
||||
}}
|
||||
}} catch (e) {{ /* ignore in older browsers */ }}
|
||||
|
||||
// Wire up "Remove all" button for this widget instance
|
||||
const removeAllBtn = document.getElementById('exam_remove_all_' + widgetFieldName);
|
||||
if (removeAllBtn) {{
|
||||
removeAllBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
removeAllExamsFromField(widgetFieldName);
|
||||
}});
|
||||
}}
|
||||
|
||||
window.addExamToField = function(fieldName, id, text) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
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);
|
||||
const listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (!listEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_exam_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span'); span.innerHTML = text;
|
||||
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() {{ removeExamFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
||||
// update the remove-all button visibility
|
||||
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
||||
if (remBtn) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
||||
}}
|
||||
|
||||
window.removeExamFromField = 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_exam_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
// update the remove-all button visibility
|
||||
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
||||
var listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
||||
}}
|
||||
|
||||
window.addAllExamsFromResults = function(fieldName) {{
|
||||
const list = document.getElementById('exam_search_results_list_' + fieldName);
|
||||
if (!list) return;
|
||||
const items = list.querySelectorAll('li[data-exam-id]');
|
||||
items.forEach(function(it) {{
|
||||
const id = it.getAttribute('data-exam-id');
|
||||
const text = it.getAttribute('data-exam-text') || it.textContent.trim();
|
||||
addExamToField(fieldName, id, text);
|
||||
}});
|
||||
// ensure remove-all button is visible if more than one
|
||||
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
||||
var listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
||||
}}
|
||||
|
||||
window.removeAllExamsFromField = function(fieldName) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
// remove all options
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ sel.remove(i); }}
|
||||
// clear visible list
|
||||
const listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (listEl) {{ listEl.innerHTML = ''; }}
|
||||
// hide remove-all button
|
||||
const remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
||||
if (remBtn) {{ remBtn.classList.add('d-none'); }}
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class ExamSearchWidget:
|
||||
"""Widget wrapper that is compatible with Django form machinery.
|
||||
|
||||
Accepts `exam_model` to specialise behaviour/display.
|
||||
"""
|
||||
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
|
||||
def __init__(self, field=None, exam_model=None, display_fields=None, default_filters=None):
|
||||
self.field = field
|
||||
self.attrs = {}
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
# allow overriding defaults per widget instance
|
||||
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = ExamSearchSelectMultipleWidget(
|
||||
name,
|
||||
value,
|
||||
exam_model=self.exam_model,
|
||||
display_fields=self.display_fields,
|
||||
default_filters=self.default_filters,
|
||||
)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, 'getlist'):
|
||||
return data.getlist(name)
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
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):
|
||||
try:
|
||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
||||
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
|
||||
+15
-1
@@ -31,6 +31,8 @@ DEBUG = int(os.environ.get("DEBUG", default=0))
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"localhost",
|
||||
"localhost:8000",
|
||||
"localhost:8080",
|
||||
"127.0.0.1",
|
||||
"161.35.163.87",
|
||||
"46.101.13.46",
|
||||
@@ -241,7 +243,7 @@ MEDIA_ROOT = "media/"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
||||
INTERNAL_IPS = ["localhost", "127.0.0.1", "localhost:8000", "localhost:8080"]
|
||||
|
||||
#LOGGING = {
|
||||
# "version": 1,
|
||||
@@ -255,6 +257,18 @@ INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
# Development: trust local origins (including ports) so the dev nginx reverse
|
||||
# proxy and the Django runserver can POST/PUT without CSRF Origin errors.
|
||||
if DEBUG:
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
]
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
|
||||
@@ -41,4 +41,38 @@ if [ -f "$ENV_FILE" ]; then
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Ensure runtime directories exist for Loki and nginx logs so compose startup
|
||||
# doesn't fail with permission/missing-folder errors. We create the minimal
|
||||
# subfolders Loki expects and a host `logs/` for nginx/Promtail.
|
||||
echo "Ensuring loki and logs folders exist under $REPO_ROOT/docker and $REPO_ROOT/logs"
|
||||
mkdir -p "$REPO_ROOT/docker/loki-data/index" \
|
||||
"$REPO_ROOT/docker/loki-data/cache" \
|
||||
"$REPO_ROOT/docker/loki-data/chunks" \
|
||||
"$REPO_ROOT/docker/loki-data/compactor" \
|
||||
"$REPO_ROOT/docker/loki-wal" \
|
||||
"$REPO_ROOT/logs"
|
||||
|
||||
# Set permissive perms for logs so nginx/promtail can use it in development.
|
||||
chmod -R 0777 "$REPO_ROOT/logs" || true
|
||||
|
||||
# Ensure loki data directories exist and have reasonable perms. The Loki
|
||||
# container runs as numeric UID 10001; if possible try to chown the folders to
|
||||
# that UID so Loki can write to them. If sudo is available the script will
|
||||
# attempt it (you may be prompted). If it fails we'll continue and leave a
|
||||
# message for manual intervention.
|
||||
chmod -R 0755 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || true
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown -R 10001:10001 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || true
|
||||
else
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
echo "Attempting to chown loki folders to UID 10001 (may prompt for sudo password)"
|
||||
sudo chown -R 10001:10001 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || \
|
||||
echo "sudo chown failed or was cancelled; Loki may not start until these folders are owned by UID 10001"
|
||||
else
|
||||
echo "Note: sudo not available. Created loki folders but did not chown them."
|
||||
echo "If Loki fails with permission errors, run:"
|
||||
echo " sudo chown -R 10001:10001 $REPO_ROOT/docker/loki-data $REPO_ROOT/docker/loki-wal"
|
||||
fi
|
||||
fi
|
||||
|
||||
COMPOSE_ENV=$COMPOSE_ENV docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up --build
|
||||
|
||||
+172
-169
@@ -112,74 +112,74 @@
|
||||
}
|
||||
|
||||
/* Improve navbar tap targets and mobile dropdown behaviour */
|
||||
@media (max-width: 991.98px) {
|
||||
@media (max-width: 991.98px) {
|
||||
/* Make navbar links larger and easier to tap */
|
||||
.navbar-nav .nav-link {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
min-height: 44px; /* recommended minimum touch target */
|
||||
display: block;
|
||||
margin: .25rem 0;
|
||||
border-radius: .375rem;
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff !important;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.05) inset;
|
||||
}
|
||||
.navbar-nav .nav-link {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
min-height: 44px; /* recommended minimum touch target */
|
||||
display: block;
|
||||
margin: .25rem 0;
|
||||
border-radius: .375rem;
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff !important;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.05) inset;
|
||||
}
|
||||
|
||||
/* Make links look like outline buttons for secondary links */
|
||||
.navbar-nav .nav-link.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--bs-primary) !important;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.navbar-nav .nav-link.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--bs-primary) !important;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
/* Make dropdown items full-width and larger */
|
||||
.navbar-nav .dropdown-menu .dropdown-item {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: .25rem;
|
||||
margin: .125rem 0;
|
||||
}
|
||||
.navbar-nav .dropdown-menu .dropdown-item {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: .25rem;
|
||||
margin: .125rem 0;
|
||||
}
|
||||
|
||||
/* When the navbar is collapsed, render dropdown menus as static stacked blocks
|
||||
so nested items are easy to tap (prevents absolute-position overlap issues). */
|
||||
.navbar-collapse .dropdown-menu {
|
||||
position: static !important;
|
||||
float: none !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
.navbar-collapse .dropdown-menu {
|
||||
position: static !important;
|
||||
float: none !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* Ensure dropdown toggles are full-width when collapsed */
|
||||
.navbar-collapse .dropdown > .nav-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
.navbar-collapse .dropdown > .nav-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
html, body {
|
||||
html, body {
|
||||
/* Slightly increase root font-size so rem-based components scale */
|
||||
font-size: 182%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
font-size: 182%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Slightly larger headings for clear hierarchy */
|
||||
h1 { font-size: 1.8rem; }
|
||||
h2 { font-size: 1.4rem; }
|
||||
h3 { font-size: 1.15rem; }
|
||||
h1 { font-size: 1.8rem; }
|
||||
h2 { font-size: 1.4rem; }
|
||||
h3 { font-size: 1.15rem; }
|
||||
|
||||
/* Improve spacing for paragraphs and lists */
|
||||
p, li { font-size: 1rem; }
|
||||
p, li { font-size: 1rem; }
|
||||
|
||||
/* Make form controls and buttons a bit larger for touch */
|
||||
.form-control, .btn { font-size: 1rem; padding: .6rem .75rem; }
|
||||
.form-control, .btn { font-size: 1rem; padding: .6rem .75rem; }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
{% block css %}
|
||||
@@ -224,7 +224,10 @@
|
||||
<a class="dropdown-item" href="{% url 'sbas:index' %}">SBAs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'generic:examcollection_list' %}">Exam Collection</a>
|
||||
<a class="dropdown-item d-flex justify-content-between align-items-center" href="{% url 'generic:examcollection_list' %}">
|
||||
<span><i class="bi bi-collection me-1" aria-hidden="true"></i> Exam Collections</span>
|
||||
<span class="badge bg-secondary ms-2">Manage</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -267,12 +270,12 @@
|
||||
<div class="col-md-12">
|
||||
<div id="htmx-error"></div>
|
||||
|
||||
<span
|
||||
id="cimar-login-needed"
|
||||
hx-get='{% url "cimar_status" %}'
|
||||
hx-trigger="cimar-login-needed from:body"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
id="cimar-login-needed"
|
||||
hx-get='{% url "cimar_status" %}'
|
||||
hx-trigger="cimar-login-needed from:body"
|
||||
>
|
||||
</span>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
@@ -313,10 +316,10 @@
|
||||
});
|
||||
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wire up all row-selection control blocks to the nearest .js-row-selectable table.
|
||||
function findTableForControl(elem) {
|
||||
function findTableForControl(elem) {
|
||||
// Walk up ancestors and look for a table that either has the
|
||||
// `js-row-selectable` class or contains a checkbox named 'selection'.
|
||||
//var ancestor = elem.closest('div, section, main, article, form, body');
|
||||
@@ -336,151 +339,151 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// ancestor = ancestor.parentElement;
|
||||
//}
|
||||
// fallback to any table on the page that looks like it has selection
|
||||
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
|
||||
return any || null;
|
||||
}
|
||||
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
|
||||
return any || null;
|
||||
}
|
||||
|
||||
// Helper to build id with optional token
|
||||
function suffixed(idBase, token) {
|
||||
return token ? idBase + '-' + token : idBase;
|
||||
}
|
||||
function suffixed(idBase, token) {
|
||||
return token ? idBase + '-' + token : idBase;
|
||||
}
|
||||
|
||||
// Ensure every selectable table has a row-selection control block. If the
|
||||
// page author didn't render the controls, create them automatically and
|
||||
// insert them before the table so the behavior is available out-of-the-box.
|
||||
function ensureControlsForTable(table) {
|
||||
if (!table) return;
|
||||
function ensureControlsForTable(table) {
|
||||
if (!table) return;
|
||||
// detect an existing nearby toggle button
|
||||
var existing = table.previousElementSibling && table.previousElementSibling.querySelector && table.previousElementSibling.querySelector('[id^="toggle-row-selection"]');
|
||||
if (existing) return; // already present
|
||||
var existing = table.previousElementSibling && table.previousElementSibling.querySelector && table.previousElementSibling.querySelector('[id^="toggle-row-selection"]');
|
||||
if (existing) return; // already present
|
||||
|
||||
// generate a short token for unique IDs
|
||||
var token = (Date.now().toString(16) + Math.floor(Math.random()*0xffff).toString(16));
|
||||
var token = (Date.now().toString(16) + Math.floor(Math.random()*0xffff).toString(16));
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'row-selection-controls-wrapper';
|
||||
wrapper.innerHTML = '<div class="d-flex justify-content-between align-items-center mb-2">'
|
||||
+ ' <div>'
|
||||
+ ' <button class="btn btn-outline-secondary btn-sm" id="toggle-row-selection-' + token + '" type="button">Enable row selection</button>'
|
||||
+ ' <div id="selection-controls-' + token + '" class="btn-group ms-2" role="group" style="display:none;">'
|
||||
+ ' <button class="btn btn-sm btn-outline-primary" id="select-all-' + token + '">Select all</button>'
|
||||
+ ' <button class="btn btn-sm btn-outline-secondary" id="clear-selection-' + token + '">Clear</button>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ ' <div>'
|
||||
+ ' <small class="text-muted">Selected: <span id="selected-count-' + token + '">0</span></small>'
|
||||
+ ' </div>'
|
||||
+ '</div>';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'row-selection-controls-wrapper';
|
||||
wrapper.innerHTML = '<div class="d-flex justify-content-between align-items-center mb-2">'
|
||||
+ ' <div>'
|
||||
+ ' <button class="btn btn-outline-secondary btn-sm" id="toggle-row-selection-' + token + '" type="button">Enable row selection</button>'
|
||||
+ ' <div id="selection-controls-' + token + '" class="btn-group ms-2" role="group" style="display:none;">'
|
||||
+ ' <button class="btn btn-sm btn-outline-primary" id="select-all-' + token + '">Select all</button>'
|
||||
+ ' <button class="btn btn-sm btn-outline-secondary" id="clear-selection-' + token + '">Clear</button>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ ' <div>'
|
||||
+ ' <small class="text-muted">Selected: <span id="selected-count-' + token + '">0</span></small>'
|
||||
+ ' </div>'
|
||||
+ '</div>';
|
||||
|
||||
// Insert the controls immediately before the table (or its responsive wrapper)
|
||||
var container = table.closest('.table-responsive') || table;
|
||||
container.parentNode.insertBefore(wrapper, container);
|
||||
}
|
||||
var container = table.closest('.table-responsive') || table;
|
||||
container.parentNode.insertBefore(wrapper, container);
|
||||
}
|
||||
|
||||
// Create controls for any table that looks selectable but lacks a control block
|
||||
Array.from(document.querySelectorAll('table')).forEach(function(t){
|
||||
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
|
||||
ensureControlsForTable(t);
|
||||
}
|
||||
});
|
||||
Array.from(document.querySelectorAll('table')).forEach(function(t){
|
||||
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
|
||||
ensureControlsForTable(t);
|
||||
}
|
||||
});
|
||||
|
||||
// For every toggle button (supports both unsuffixed and suffixed IDs)
|
||||
document.querySelectorAll('button[id^="toggle-row-selection"]').forEach(function(toggleBtn) {
|
||||
document.querySelectorAll('button[id^="toggle-row-selection"]').forEach(function(toggleBtn) {
|
||||
// derive token (empty for unsuffixed)
|
||||
var token = '';
|
||||
if (toggleBtn.id !== 'toggle-row-selection') token = toggleBtn.id.replace('toggle-row-selection-', '');
|
||||
var token = '';
|
||||
if (toggleBtn.id !== 'toggle-row-selection') token = toggleBtn.id.replace('toggle-row-selection-', '');
|
||||
|
||||
var controlsEl = document.getElementById(suffixed('selection-controls', token));
|
||||
var selectAllBtn = document.getElementById(suffixed('select-all', token));
|
||||
var clearBtn = document.getElementById(suffixed('clear-selection', token));
|
||||
var selectedCountSpan = document.getElementById(suffixed('selected-count', token));
|
||||
var controlsEl = document.getElementById(suffixed('selection-controls', token));
|
||||
var selectAllBtn = document.getElementById(suffixed('select-all', token));
|
||||
var clearBtn = document.getElementById(suffixed('clear-selection', token));
|
||||
var selectedCountSpan = document.getElementById(suffixed('selected-count', token));
|
||||
|
||||
var table = findTableForControl(toggleBtn);
|
||||
if (!table) return; // nothing to do
|
||||
var table = findTableForControl(toggleBtn);
|
||||
if (!table) return; // nothing to do
|
||||
|
||||
var selectionEnabled = false;
|
||||
var selectionEnabled = false;
|
||||
|
||||
function findRowCheckboxes() {
|
||||
if (!table) return [];
|
||||
function findRowCheckboxes() {
|
||||
if (!table) return [];
|
||||
// prefer named selection inputs but fall back to any checkbox
|
||||
var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]'));
|
||||
if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]'));
|
||||
return checks;
|
||||
}
|
||||
var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]'));
|
||||
if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]'));
|
||||
return checks;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
if (!selectedCountSpan) return;
|
||||
var checks = findRowCheckboxes();
|
||||
var count = checks.filter(function(c){ return c.checked; }).length;
|
||||
selectedCountSpan.textContent = count;
|
||||
checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); });
|
||||
}
|
||||
function updateSelectedCount() {
|
||||
if (!selectedCountSpan) return;
|
||||
var checks = findRowCheckboxes();
|
||||
var count = checks.filter(function(c){ return c.checked; }).length;
|
||||
selectedCountSpan.textContent = count;
|
||||
checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); });
|
||||
}
|
||||
|
||||
function setCheckboxesDisabled(disabled) {
|
||||
findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; });
|
||||
}
|
||||
function setCheckboxesDisabled(disabled) {
|
||||
findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; });
|
||||
}
|
||||
|
||||
function hideSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; });
|
||||
}
|
||||
function hideSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; });
|
||||
}
|
||||
|
||||
function showSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; });
|
||||
}
|
||||
function showSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; });
|
||||
}
|
||||
|
||||
// initialise state
|
||||
setCheckboxesDisabled(true);
|
||||
hideSelectionColumn();
|
||||
|
||||
// row click toggling (attach to table to avoid global handlers)
|
||||
table.addEventListener('click', function(e){
|
||||
if (!selectionEnabled) return;
|
||||
var tag = (e.target.tagName || '').toLowerCase();
|
||||
if (['input','a','button','select','textarea','label'].indexOf(tag) !== -1) return;
|
||||
var tr = e.target.closest('tr');
|
||||
if (!tr || !table.contains(tr)) return;
|
||||
var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]');
|
||||
if (cb && !cb.disabled) { cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); }
|
||||
}, true);
|
||||
|
||||
// listen for changes within the table to keep counts in sync
|
||||
table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true);
|
||||
|
||||
// wire controls
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
selectionEnabled = !selectionEnabled;
|
||||
if (selectionEnabled) {
|
||||
toggleBtn.textContent = 'Disable row selection';
|
||||
if (controlsEl) controlsEl.style.display = '';
|
||||
setCheckboxesDisabled(false);
|
||||
showSelectionColumn();
|
||||
} else {
|
||||
toggleBtn.textContent = 'Enable row selection';
|
||||
if (controlsEl) controlsEl.style.display = 'none';
|
||||
setCheckboxesDisabled(true);
|
||||
hideSelectionColumn();
|
||||
findRowCheckboxes().forEach(function(cb){ cb.checked = false; });
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); });
|
||||
}
|
||||
// row click toggling (attach to table to avoid global handlers)
|
||||
table.addEventListener('click', function(e){
|
||||
if (!selectionEnabled) return;
|
||||
var tag = (e.target.tagName || '').toLowerCase();
|
||||
if (['input','a','button','select','textarea','label'].indexOf(tag) !== -1) return;
|
||||
var tr = e.target.closest('tr');
|
||||
if (!tr || !table.contains(tr)) return;
|
||||
var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]');
|
||||
if (cb && !cb.disabled) { cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); }
|
||||
}, true);
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); });
|
||||
}
|
||||
// listen for changes within the table to keep counts in sync
|
||||
table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true);
|
||||
|
||||
// wire controls
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
selectionEnabled = !selectionEnabled;
|
||||
if (selectionEnabled) {
|
||||
toggleBtn.textContent = 'Disable row selection';
|
||||
if (controlsEl) controlsEl.style.display = '';
|
||||
setCheckboxesDisabled(false);
|
||||
showSelectionColumn();
|
||||
} else {
|
||||
toggleBtn.textContent = 'Enable row selection';
|
||||
if (controlsEl) controlsEl.style.display = 'none';
|
||||
setCheckboxesDisabled(true);
|
||||
hideSelectionColumn();
|
||||
findRowCheckboxes().forEach(function(cb){ cb.checked = false; });
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); });
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); });
|
||||
}
|
||||
|
||||
// initial count
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
|
||||
{% if collection %}
|
||||
<h2>Collection: {{collection.name}}</h2>
|
||||
<p class="muted"><a href="{% url 'generic:examcollection_detail' collection.pk %}">← Back to collection</a></p>
|
||||
{% endif %}
|
||||
|
||||
<details class="help-text">
|
||||
|
||||
Reference in New Issue
Block a user