Compare commits
51
Commits
8a871d6332
...
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 | ||
|
|
02a9118faf | ||
|
|
63c6a648d7 | ||
|
|
517c30ba0e | ||
|
|
590e0b76ed | ||
|
|
e9a04e823c | ||
|
|
9ed789ba90 | ||
|
|
fd6e25d55e | ||
|
|
f074e5b086 | ||
|
|
2157b6f781 | ||
|
|
359682a0cd | ||
|
|
78577076c1 | ||
|
|
3635275573 | ||
|
|
0c412dfff6 | ||
|
|
3f00311136 | ||
|
|
e8c35efd83 | ||
|
|
0badca4c4f | ||
|
|
25f4e478b3 | ||
|
|
33cba3d005 | ||
|
|
efca426359 | ||
|
|
f713632fdd | ||
|
|
785638ee23 | ||
|
|
e8020c5d8f | ||
|
|
5ccf755e72 | ||
|
|
6308fbfcd7 |
@@ -0,0 +1,23 @@
|
|||||||
|
# Django
|
||||||
|
DEBUG=1
|
||||||
|
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||||
|
|
||||||
|
# External DB (you mentioned production uses an external DB)
|
||||||
|
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
|
||||||
|
DB_PORT=25060
|
||||||
|
DB_NAME=testing
|
||||||
|
DB_USER=django
|
||||||
|
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_LOGLEVEL=info
|
||||||
|
|
||||||
|
# Nginx host ports for local development (override prod defaults)
|
||||||
|
NGINX_HTTP_PORT=8080
|
||||||
|
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
|
||||||
|
NGINX_HTTPS_PORT=8444
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
|
||||||
|
|
||||||
|
# Django
|
||||||
|
SECRET_KEY=replace-me-with-a-secure-random-string
|
||||||
|
DEBUG=0
|
||||||
|
ALLOWED_HOSTS=example.com
|
||||||
|
|
||||||
|
# External DB (you mentioned production uses an external DB)
|
||||||
|
DATABASE_HOST=your.db.host
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_NAME=rad
|
||||||
|
DATABASE_USER=django
|
||||||
|
DATABASE_PASSWORD=strong-db-password
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_LOGLEVEL=info
|
||||||
@@ -12,3 +12,9 @@ venv
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Loki runtime data
|
||||||
|
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).
|
||||||
+15
-16
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
|
|||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
class CidUserSchema(ModelSchema):
|
class CidUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUser
|
model = CidUser
|
||||||
|
|
||||||
model_fields = ["cid", "name", "email"]
|
fields = ["cid", "name", "email"]
|
||||||
|
|
||||||
class UserUserSchema(ModelSchema):
|
class UserUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = get_user_model()
|
model = get_user_model()
|
||||||
model_fields = ["id", "email"]
|
fields = ["id", "email"]
|
||||||
|
|
||||||
class CidUserExamSchema(ModelSchema):
|
class CidUserExamSchema(ModelSchema):
|
||||||
cid_user : CidUserSchema | None
|
cid_user : CidUserSchema | None
|
||||||
user_user : UserUserSchema | None
|
user_user : UserUserSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUserExam
|
model = CidUserExam
|
||||||
model_fields = ["id"]
|
fields = ["id"]
|
||||||
|
|
||||||
class ExamStatusSchema(ModelSchema):
|
class ExamStatusSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class ExamUserStatusSchema(ModelSchema):
|
class ExamUserStatusSchema(ModelSchema):
|
||||||
|
|
||||||
#cid_user : CidUserSchema | None
|
#cid_user : CidUserSchema | None
|
||||||
#user_user : UserUserSchema | None
|
#user_user : UserUserSchema | None
|
||||||
cid_user_exam : CidUserExamSchema | None
|
cid_user_exam : CidUserExamSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
fields = "__all__"
|
||||||
model_fields = "__all__"
|
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
+4
-4
@@ -32,9 +32,9 @@ router = Router()
|
|||||||
class SeriesSchema(ModelSchema):
|
class SeriesSchema(ModelSchema):
|
||||||
case_id: List[int] = []
|
case_id: List[int] = []
|
||||||
|
|
||||||
class Config:
|
class Meta:
|
||||||
model = Series
|
model = Series
|
||||||
model_fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"modality",
|
"modality",
|
||||||
"examination",
|
"examination",
|
||||||
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
|
|||||||
|
|
||||||
|
|
||||||
class CaseSchema(ModelSchema):
|
class CaseSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Case
|
model = Case
|
||||||
model_fields = ["id", "title"]
|
fields = ["id", "title"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload_dicom", auth=django_auth)
|
@router.post("/upload_dicom", auth=django_auth)
|
||||||
|
|||||||
+39
-2
@@ -209,9 +209,15 @@ class ConditionFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class FindingFilter(django_filters.FilterSet):
|
class FindingFilter(django_filters.FilterSet):
|
||||||
|
# Replace legacy `primary`/`synonym` with canonical-based filters
|
||||||
|
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||||
|
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Finding
|
model = Finding
|
||||||
fields = ("name", "primary", "synonym")
|
fields = {
|
||||||
|
"name": ["icontains"],
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -227,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def filter_is_canonical(self, queryset, name, value):
|
||||||
|
if value in (True, "True", "true", 1, "1"):
|
||||||
|
return queryset.filter(canonical__isnull=True)
|
||||||
|
if value in (False, "False", "false", 0, "0"):
|
||||||
|
return queryset.filter(canonical__isnull=False)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def filter_synonym(self, queryset, name, value):
|
||||||
|
# value is a Finding instance selected in the filter. Return all
|
||||||
|
# Findings that belong to the same canonical group as `value`.
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
master = value.canonical if value.canonical else value
|
||||||
|
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||||
|
|
||||||
|
|
||||||
class StructureFilter(django_filters.FilterSet):
|
class StructureFilter(django_filters.FilterSet):
|
||||||
|
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||||
|
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Structure
|
model = Structure
|
||||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
fields = {"name": ["icontains"]}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -247,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def filter_is_canonical(self, queryset, name, value):
|
||||||
|
if value in (True, "True", "true", 1, "1"):
|
||||||
|
return queryset.filter(canonical__isnull=True)
|
||||||
|
if value in (False, "False", "false", 0, "0"):
|
||||||
|
return queryset.filter(canonical__isnull=False)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def filter_synonym(self, queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
master = value.canonical if value.canonical else value
|
||||||
|
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||||
|
|
||||||
|
|
||||||
class PresentationFilter(django_filters.FilterSet):
|
class PresentationFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
+23
-9
@@ -56,7 +56,8 @@ from generic.models import Examination # , Sign
|
|||||||
|
|
||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
||||||
|
from django.urls import reverse
|
||||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||||
|
|
||||||
from tinymce.widgets import TinyMCE
|
from tinymce.widgets import TinyMCE
|
||||||
@@ -118,11 +119,10 @@ class ConditionForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
exclude = ["rcr_curriculum"]
|
exclude = ["rcr_curriculum"]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
# Use an AJAX/autocomplete single-select for canonical so users
|
||||||
url="atlas:condition-autocomplete"
|
# can search for existing canonical Conditions by name.
|
||||||
),
|
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||||
"parent": autocomplete.ModelSelect2Multiple(
|
"parent": autocomplete.ModelSelect2Multiple(
|
||||||
url="atlas:condition-autocomplete"
|
url="atlas:condition-autocomplete"
|
||||||
),
|
),
|
||||||
@@ -138,7 +138,21 @@ class ConditionForm(ModelForm):
|
|||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
self.helper.form_tag = True
|
self.helper.form_tag = True
|
||||||
|
# Primary submit
|
||||||
self.helper.add_input(Submit("submit", "Submit"))
|
self.helper.add_input(Submit("submit", "Submit"))
|
||||||
|
# Cancel as a button that redirects back to the condition list
|
||||||
|
try:
|
||||||
|
cancel_url = reverse("atlas:condition_view")
|
||||||
|
except Exception:
|
||||||
|
cancel_url = "#"
|
||||||
|
self.helper.add_input(
|
||||||
|
Button(
|
||||||
|
"cancel",
|
||||||
|
"Cancel",
|
||||||
|
css_class="btn btn-secondary ms-2",
|
||||||
|
onclick=f"window.location='{cancel_url}'",
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If crispy not available or something goes wrong, don't break form
|
# If crispy not available or something goes wrong, don't break form
|
||||||
pass
|
pass
|
||||||
@@ -327,9 +341,10 @@ class FindingForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Finding
|
model = Finding
|
||||||
exclude = []
|
exclude = []
|
||||||
|
# Use the canonical FK in forms (if users want to mark this Finding
|
||||||
|
# as an alias of another). The old `synonym` M2M has been removed.
|
||||||
widgets = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
"canonical": autocomplete.ModelSelect2(
|
||||||
url="atlas:finding-autocomplete"
|
url="atlas:finding-autocomplete"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -339,9 +354,8 @@ class StructureForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Structure
|
model = Structure
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
"canonical": autocomplete.ModelSelect2(
|
||||||
url="atlas:structure-autocomplete"
|
url="atlas:structure-autocomplete"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 10:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0087_alter_condition_canonical'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='finding',
|
||||||
|
name='primary',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='finding',
|
||||||
|
name='synonym',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='structure',
|
||||||
|
name='primary',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='structure',
|
||||||
|
name='synonym',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='finding',
|
||||||
|
name='canonical',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='structure',
|
||||||
|
name='canonical',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 10:31
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='condition',
|
||||||
|
name='primary',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='condition',
|
||||||
|
name='synonym',
|
||||||
|
),
|
||||||
|
]
|
||||||
+115
-34
@@ -110,10 +110,24 @@ class SynMixin(object):
|
|||||||
# abstract = True
|
# abstract = True
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.primary:
|
# Determine whether this record should be displayed as the
|
||||||
|
# canonical/primary entry. Prefer the new canonical FK pattern
|
||||||
|
# (`canonical is None` means this object is the master). Fallback
|
||||||
|
# to legacy `primary` boolean when present.
|
||||||
|
# If the model supports canonical FK, consider canonical==None as
|
||||||
|
# the primary/master entry. For models without canonical (legacy
|
||||||
|
# edge-cases), treat the instance as primary by default.
|
||||||
|
if hasattr(self, "canonical"):
|
||||||
|
try:
|
||||||
|
if self.canonical is None or self.canonical == self:
|
||||||
return self.name
|
return self.name
|
||||||
else:
|
|
||||||
return f"{self.name} [syn]"
|
return f"{self.name} [syn]"
|
||||||
|
except Exception:
|
||||||
|
# defensive fallback
|
||||||
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
|
# No canonical support: assume primary
|
||||||
|
return self.name
|
||||||
|
|
||||||
def get_old_str(self) -> str:
|
def get_old_str(self) -> str:
|
||||||
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
|
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
|
||||||
@@ -121,19 +135,30 @@ class SynMixin(object):
|
|||||||
syns = self.get_synonyms()
|
syns = self.get_synonyms()
|
||||||
except Exception:
|
except Exception:
|
||||||
syns = None
|
syns = None
|
||||||
|
# If the model supports canonical, only canonical==None entries
|
||||||
|
# should be rendered as the primary name. For models without
|
||||||
|
# canonical, treat instance as primary.
|
||||||
|
if hasattr(self, "canonical"):
|
||||||
|
try:
|
||||||
|
is_primary = self.canonical is None or self.canonical == self
|
||||||
|
except Exception:
|
||||||
|
is_primary = False
|
||||||
|
else:
|
||||||
|
is_primary = True
|
||||||
|
|
||||||
if self.primary:
|
if is_primary:
|
||||||
if syns is None:
|
if syns is None:
|
||||||
if getattr(self, "synonym", None) is None or self.synonym.count() == 0:
|
synonyms_field = getattr(self, "synonym", None)
|
||||||
|
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
|
||||||
return self.name
|
return self.name
|
||||||
synonyms = ",".join([i.name for i in self.synonym.all()])
|
synonyms = ",".join([i.name for i in synonyms_field.all()])
|
||||||
return f"{self.name} ({synonyms})"
|
return f"{self.name} ({synonyms})"
|
||||||
else:
|
else:
|
||||||
if not syns:
|
if not syns:
|
||||||
return self.name
|
return self.name
|
||||||
synonyms = ",".join([i.name for i in syns])
|
synonyms = ",".join([i.name for i in syns])
|
||||||
return f"{self.name} ({synonyms})"
|
return f"{self.name} ({synonyms})"
|
||||||
else:
|
|
||||||
return f"{self.name} [syn]"
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
def get_synonym(self):
|
def get_synonym(self):
|
||||||
@@ -143,16 +168,33 @@ class SynMixin(object):
|
|||||||
syns = self.get_synonyms()
|
syns = self.get_synonyms()
|
||||||
except Exception:
|
except Exception:
|
||||||
syns = None
|
syns = None
|
||||||
|
# If canonical is present and this instance is the master, mark Primary
|
||||||
if getattr(self, "primary", False):
|
if hasattr(self, "canonical"):
|
||||||
|
try:
|
||||||
|
if self.canonical is None or self.canonical == self:
|
||||||
return "[Primary]"
|
return "[Primary]"
|
||||||
else:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if syns is None:
|
if syns is None:
|
||||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
synonyms_field = getattr(self, "synonym", None)
|
||||||
|
if synonyms_field is None:
|
||||||
|
return ""
|
||||||
|
# If the old M2M exists, prefer those marked primary if such a
|
||||||
|
# attribute exists on the related objects.
|
||||||
|
try:
|
||||||
|
# Select all synonym names; legacy `primary` flag removed.
|
||||||
|
s = synonyms_field.values_list("name", flat=True)
|
||||||
|
except Exception:
|
||||||
|
s = []
|
||||||
return ", ".join(s)
|
return ", ".join(s)
|
||||||
else:
|
|
||||||
# prefer primary names among synonyms if present
|
# prefer primary names among synonyms if present
|
||||||
primary_names = [i.name for i in syns if getattr(i, "primary", False)]
|
primary_names = []
|
||||||
|
for i in syns:
|
||||||
|
# Prefer items that are canonical/master in the group
|
||||||
|
if getattr(i, "canonical", None) is None:
|
||||||
|
primary_names.append(i.name)
|
||||||
if primary_names:
|
if primary_names:
|
||||||
return ", ".join(primary_names)
|
return ", ".join(primary_names)
|
||||||
return ", ".join([i.name for i in syns])
|
return ", ".join([i.name for i in syns])
|
||||||
@@ -162,17 +204,24 @@ class SynMixin(object):
|
|||||||
syns = self.get_synonyms()
|
syns = self.get_synonyms()
|
||||||
except Exception:
|
except Exception:
|
||||||
syns = None
|
syns = None
|
||||||
|
# Prefer canonical/master check
|
||||||
if getattr(self, "primary", False):
|
if hasattr(self, "canonical"):
|
||||||
|
try:
|
||||||
|
if self.canonical is None or self.canonical == self:
|
||||||
return "[Primary]"
|
return "[Primary]"
|
||||||
else:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if syns is None:
|
if syns is None:
|
||||||
# fall back to all M2M synonyms (not just primary) for models
|
# fall back to all M2M synonyms (not just primary) for models
|
||||||
# that still use the old synonym field (e.g. Finding, Structure)
|
# that still use the old synonym field (e.g. Finding, Structure)
|
||||||
syns_qs = self.synonym.all()
|
synonyms_field = getattr(self, "synonym", None)
|
||||||
|
if synonyms_field is None:
|
||||||
|
return ""
|
||||||
|
syns_qs = synonyms_field.all()
|
||||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
|
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
|
||||||
return ", ".join(items)
|
return ", ".join(items)
|
||||||
else:
|
|
||||||
# render links for synonyms/canonical group
|
# render links for synonyms/canonical group
|
||||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||||
return ", ".join(items)
|
return ", ".join(items)
|
||||||
@@ -183,22 +232,40 @@ class SynMixin(object):
|
|||||||
|
|
||||||
class Finding(SynMixin, models.Model):
|
class Finding(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
synonym = models.ManyToManyField("self", blank=True)
|
# New canonical/alias field: if set, this Finding is an alias and points
|
||||||
|
# to the canonical/master Finding. We remove the old M2M `synonym` and
|
||||||
primary = models.BooleanField(default="True")
|
# `primary` boolean in favour of this single canonical FK.
|
||||||
|
canonical = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="aliases",
|
||||||
|
help_text="If set, this Finding is an alias and points to the canonical Finding.",
|
||||||
|
)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
|
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def canonical_finding(self):
|
||||||
|
return self.canonical if self.canonical else self
|
||||||
|
|
||||||
|
def get_synonyms(self):
|
||||||
|
"""Return other Findings that are aliases/synonyms for this concept.
|
||||||
|
|
||||||
|
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
|
||||||
|
(has canonical set) return the canonical and other aliases (excluding
|
||||||
|
self). If this Finding is canonical, return all aliases (excluding
|
||||||
|
self).
|
||||||
|
"""
|
||||||
|
master = self.canonical_finding
|
||||||
|
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class Condition(SynMixin, models.Model):
|
class Condition(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
|
||||||
synonym = models.ManyToManyField(
|
|
||||||
"self",
|
|
||||||
blank=True,
|
|
||||||
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
|
|
||||||
)
|
|
||||||
# New canonical/alias field (Option B): if set, this Condition is an alias
|
# New canonical/alias field (Option B): if set, this Condition is an alias
|
||||||
# and points to the canonical/master Condition.
|
# and points to the canonical/master Condition.
|
||||||
canonical = models.ForeignKey(
|
canonical = models.ForeignKey(
|
||||||
@@ -218,10 +285,10 @@ class Condition(SynMixin, models.Model):
|
|||||||
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
|
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
primary = models.BooleanField(
|
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
|
||||||
default="True",
|
# Canonical/aliases are represented by the `canonical` ForeignKey and
|
||||||
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
|
# the reverse `aliases` related_name. Use `get_synonyms()` /
|
||||||
)
|
# `canonical_condition` to access canonical groups.
|
||||||
|
|
||||||
subspecialty = models.ManyToManyField(
|
subspecialty = models.ManyToManyField(
|
||||||
"subspecialty",
|
"subspecialty",
|
||||||
@@ -339,14 +406,28 @@ class Differential(models.Model):
|
|||||||
|
|
||||||
class Structure(SynMixin, models.Model):
|
class Structure(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
# Migrate to canonical/aliases model like Condition and Finding
|
||||||
synonym = models.ManyToManyField("self", blank=True)
|
canonical = models.ForeignKey(
|
||||||
|
"self",
|
||||||
primary = models.BooleanField(default="True")
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="aliases",
|
||||||
|
help_text="If set, this Structure is an alias and points to the canonical Structure.",
|
||||||
|
)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
|
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def canonical_structure(self):
|
||||||
|
return self.canonical if self.canonical else self
|
||||||
|
|
||||||
|
def get_synonyms(self):
|
||||||
|
master = self.canonical_structure
|
||||||
|
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
@reversion.register
|
@reversion.register
|
||||||
class Case(models.Model, AuthorMixin, QuestionMixin):
|
class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||||
|
|||||||
+3
-3
@@ -276,7 +276,7 @@ class ConditionTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Condition
|
model = Condition
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ class FindingTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Finding
|
model = Finding
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ class StructureTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Structure
|
model = Structure
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,29 +72,6 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% crispy form form.helper %}
|
{% 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 %}
|
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number=case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number=case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<span class="collection-name-blend">Collection: {{collection}}</span>
|
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
|
||||||
<h2>Case {{case_number|add:1}}
|
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-0">Case {{case_number|add:1}}
|
||||||
|
|
||||||
{% if show_title %}
|
{% if show_title %}
|
||||||
: {{case.title}}
|
: {{case.title}}
|
||||||
@@ -11,19 +14,36 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<span class="stamp-white">REVIEW</span>
|
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center ms-3">
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm dropdown-toggle" href="#" role="button" id="caseJump" data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
hx-get="{% url 'atlas:collection_case_jump_partial' pk=collection.pk %}?current={{case_number}}{% if cid %}&cid={{cid}}&passcode={{passcode}}{% endif %}"
|
||||||
|
hx-target="#case-jump-menu"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="click">
|
||||||
|
Jump
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end" id="case-jump-menu">
|
||||||
|
<div class="px-3 py-2 text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not question_completed and collection.question_time_limit is not None %}
|
{% if not question_completed and collection.question_time_limit is not None %}
|
||||||
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||||
<strong>Time remaining:</strong>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span id="question-timer" aria-live="polite"> </span>
|
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
||||||
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
|
<div style="flex:1; max-width:320px;">
|
||||||
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
|
<div class="progress" style="height:10px;">
|
||||||
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
|
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="timer-htmx-target" style="display:none;"></div>
|
<div id="timer-htmx-target" style="display:none;"></div>
|
||||||
@@ -38,12 +58,10 @@
|
|||||||
|
|
||||||
{% if not question_completed %}
|
{% if not question_completed %}
|
||||||
{% if resources %}
|
{% if resources %}
|
||||||
<h5>Resources</h4>
|
<h5 class="mt-3">Resources</h5>
|
||||||
<ul class="no-list-style">
|
<ul class="list-group list-group-flush mb-3">
|
||||||
{% for caseresource in resources %}
|
{% for caseresource in resources %}
|
||||||
<li>
|
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
|
||||||
{{caseresource.resource.get_display}}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -51,14 +69,21 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if show_description and case.description %}
|
{% if show_description and case.description %}
|
||||||
<div>
|
<div class="mb-3">
|
||||||
Description: {{case.description}}
|
<h6 class="mb-1">Description</h6>
|
||||||
|
<p class="mb-0">{{case.description}}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_history %}
|
{% if show_history %}
|
||||||
<div>
|
<div class="card mb-3">
|
||||||
History: {{casedetail.get_history_pre|linebreaks}}
|
<div class="card-body p-2 d-flex justify-content-between align-items-start">
|
||||||
|
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
|
||||||
|
{% if has_priors %}
|
||||||
|
<div class="ms-2">
|
||||||
|
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -66,9 +91,9 @@
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||||
{% else %}
|
{% else %}
|
||||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -78,9 +103,9 @@
|
|||||||
{% if collection.show_ohif_viewer_link %}
|
{% if collection.show_ohif_viewer_link %}
|
||||||
<div>
|
<div>
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number=case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -131,28 +156,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_discussion and case.discussion %}
|
{% if show_discussion and case.discussion %}
|
||||||
<p>
|
<details class="card mb-3">
|
||||||
<details>
|
<summary class="card-header p-2" style="cursor: pointer;">
|
||||||
<summary>
|
<strong>Discussion</strong>
|
||||||
Discussion:
|
|
||||||
</summary>
|
</summary>
|
||||||
<div>
|
<div class="card-body p-2 small">
|
||||||
{{case.discussion|linebreaks}}
|
{{case.discussion|linebreaks}}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_report and case.report %}
|
{% if show_report and case.report %}
|
||||||
<p>
|
<details class="card mb-3">
|
||||||
<details>
|
<summary class="card-header p-2" style="cursor: pointer;">
|
||||||
<summary>
|
<strong>Report</strong>
|
||||||
Report:
|
|
||||||
</summary>
|
</summary>
|
||||||
<div>
|
<div class="card-body p-2 small">
|
||||||
{{case.report|linebreaks}}
|
{{case.report|linebreaks}}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
@@ -246,25 +267,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="mb-2">
|
||||||
{% if previous %}
|
{% if previous %}
|
||||||
<button type="submit" name="previous" class="save btn btn-default">Previous</button>
|
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if next %}
|
{% if next %}
|
||||||
<button type="submit" name="next" class="save btn btn-default">Next</button>
|
<button type="submit" name="next" class="btn btn-primary btn-sm me-2">Next</button>
|
||||||
{% elif collection.collection_type == "REP" %}
|
{% elif collection.collection_type == "REP" %}
|
||||||
{% if not exam.publish_results %}
|
{% if not exam.publish_results %}
|
||||||
<button type="submit" name="save" class="save btn btn-default">Save</button>
|
<button type="submit" name="save" class="btn btn-primary btn-sm me-2">Save</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
||||||
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
|
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<br />
|
<div class="mt-3">
|
||||||
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
|
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
|
||||||
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<style>
|
<style>
|
||||||
label {
|
label {
|
||||||
|
|||||||
@@ -15,11 +15,6 @@
|
|||||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% crispy form %}
|
{% crispy form %}
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
|
||||||
<a class="btn btn-secondary ms-2" href="{% url 'atlas:condition_view' %}">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">My cases / collections</h5>
|
<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>
|
<p>
|
||||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
<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: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>
|
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% for cd in casedetails %}
|
||||||
|
{% with idx=forloop.counter0 %}
|
||||||
|
{% if cid %}
|
||||||
|
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
|
||||||
|
{% else %}
|
||||||
|
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
|
||||||
|
{% endif %}
|
||||||
|
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
|
||||||
|
</a>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{% if not added %}
|
{% if not added %}
|
||||||
<form class="row g-2"
|
<form class="row g-2"
|
||||||
{% if case_number %}
|
{% if case_number %}
|
||||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number=case_number %}"
|
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||||
{% else %}
|
{% else %}
|
||||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form
|
<form
|
||||||
{% if case_number %}
|
{% if case_number %}
|
||||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number=case_number %}"
|
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||||
{% else %}
|
{% else %}
|
||||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="series-images-list">
|
||||||
|
{% for image in images %}
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
{% if image.image %}
|
||||||
|
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
|
||||||
|
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
|
||||||
|
{% if image.image %}
|
||||||
|
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if image.image_blake3_hash %}
|
||||||
|
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-auto text-end">
|
||||||
|
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No images found for this series.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<!-- Finding form moved below the viewport -->
|
<!-- Finding form moved below the viewport -->
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
@@ -114,6 +113,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Images (loaded on demand via HTMX) -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Images</span>
|
||||||
|
<div>
|
||||||
|
<button id="load-images-button" class="btn btn-sm btn-outline-primary"
|
||||||
|
hx-get="{% url 'atlas:series_images' series.pk %}"
|
||||||
|
hx-target="#series-images-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-on="htmx:beforeRequest: document.getElementById('images-loading-indicator').style.display='inline-block'; htmx:afterSwap: document.getElementById('images-loading-indicator').style.display='none'; document.getElementById('load-images-button').disabled = true">
|
||||||
|
Load images
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'atlas:series_download' series.pk %}" class="btn btn-sm btn-outline-success ms-2" target="_blank" rel="noopener">Download</a>
|
||||||
|
<span id="images-loading-indicator" class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true" style="display:none"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="series-images-container">
|
||||||
|
<p class="text-muted small">Click 'Load images' to fetch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ urlpatterns = [
|
|||||||
views.CollectionCaseUpdate.as_view(),
|
views.CollectionCaseUpdate.as_view(),
|
||||||
name="collection_case_update",
|
name="collection_case_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"collection/<int:pk>/case_jump",
|
||||||
|
views.collection_case_jump_partial,
|
||||||
|
name="collection_case_jump_partial",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"collection/add_cases",
|
"collection/add_cases",
|
||||||
views.add_cases_to_collection,
|
views.add_cases_to_collection,
|
||||||
@@ -452,6 +457,7 @@ urlpatterns = [
|
|||||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||||
|
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
||||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||||
|
|||||||
+78
-10
@@ -796,6 +796,17 @@ def series_detail(request, pk, finding_pk=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
|
def series_images_partial(request, pk):
|
||||||
|
"""Return a small fragment that lists images for a series. Intended for HTMX."""
|
||||||
|
series = get_object_or_404(Series, pk=pk)
|
||||||
|
logger.debug(series)
|
||||||
|
images = series.images.filter(removed=False).order_by("pk")
|
||||||
|
logger.debug(images)
|
||||||
|
return render(request, "atlas/partials/series_images.html", {"series": series, "images": images})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
# @user_is_atlas_editor
|
# @user_is_atlas_editor
|
||||||
def question_schema_detail(request, pk: int):
|
def question_schema_detail(request, pk: int):
|
||||||
@@ -1079,10 +1090,12 @@ def author_list(request):
|
|||||||
return render(request, "atlas/author_list.html", {"authors": authors})
|
return render(request, "atlas/author_list.html", {"authors": authors})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
return render(request, "atlas/index.html", {})
|
return render(request, "atlas/index.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def user_collections(request):
|
def user_collections(request):
|
||||||
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
||||||
available_collections = request.user.user_casecollection_exams.all()
|
available_collections = request.user.user_casecollection_exams.all()
|
||||||
@@ -1134,6 +1147,46 @@ def collection_options(request):
|
|||||||
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
|
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def collection_case_jump_partial(request, pk: int):
|
||||||
|
"""Return an HTMX partial containing a jump list for cases in the collection.
|
||||||
|
|
||||||
|
Expects optional GET params:
|
||||||
|
- current: the current case index (0-based) to mark active
|
||||||
|
- cid, passcode: optional candidate identifiers so links include them when present
|
||||||
|
"""
|
||||||
|
# Allow non-HTMX callers to still get the fragment (useful for testing)
|
||||||
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
|
casedetails = (
|
||||||
|
CaseDetail.objects.filter(collection=collection)
|
||||||
|
.select_related('case')
|
||||||
|
.order_by('sort_order')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = int(request.GET.get('current', 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
cid = request.GET.get('cid')
|
||||||
|
passcode = request.GET.get('passcode')
|
||||||
|
|
||||||
|
html = render_to_string(
|
||||||
|
'atlas/partials/_collection_case_jump.html',
|
||||||
|
{
|
||||||
|
'collection': collection,
|
||||||
|
'casedetails': casedetails,
|
||||||
|
'current': current,
|
||||||
|
'cid': cid,
|
||||||
|
'passcode': passcode,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def remove_cases_from_collection(request):
|
def remove_cases_from_collection(request):
|
||||||
if not request.htmx:
|
if not request.htmx:
|
||||||
@@ -1515,8 +1568,10 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
|
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
|
||||||
|
# Only bind the case formset when the POST actually contains formset data.
|
||||||
if self.request.POST:
|
# 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(
|
context["case_formset"] = CaseCollectionCaseFormSet(
|
||||||
self.request.POST,
|
self.request.POST,
|
||||||
self.request.FILES,
|
self.request.FILES,
|
||||||
@@ -1536,14 +1591,19 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
context = self.get_context_data(form=form)
|
context = self.get_context_data(form=form)
|
||||||
case_formset = context["case_formset"]
|
case_formset = context["case_formset"]
|
||||||
|
# If the POST included case formset data, validate and save it. Otherwise
|
||||||
|
# skip formset processing so creating a collection without cases redirects
|
||||||
|
# normally to the detail view.
|
||||||
|
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
|
||||||
if case_formset.is_valid():
|
if case_formset.is_valid():
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
case_formset.instance = self.object
|
case_formset.instance = self.object
|
||||||
case_formset.save()
|
case_formset.save()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
# No formset data present: proceed with normal create redirect.
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
"""Redirect to the collection detail page after successful creation."""
|
"""Redirect to the collection detail page after successful creation."""
|
||||||
@@ -3104,6 +3164,9 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
|
|
||||||
previous = collection.get_previous_case(casedetail.case)
|
previous = collection.get_previous_case(casedetail.case)
|
||||||
next = collection.get_next_case(casedetail.case)
|
next = collection.get_next_case(casedetail.case)
|
||||||
|
# Determine whether the current user can edit this collection/case so
|
||||||
|
# included fragments which rely on `can_edit` have the value available.
|
||||||
|
can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -3121,20 +3184,19 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"added_priors": added_priors,
|
"added_priors": added_priors,
|
||||||
"available_priors": available_priors,
|
"available_priors": available_priors,
|
||||||
|
"can_edit": can_edit,
|
||||||
|
# Provide a default `error` value so `{% if error %}` checks in included
|
||||||
|
# partials won't raise during variable resolution when the key is absent.
|
||||||
|
"error": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_is_collection_author_or_atlas_editor
|
@user_is_collection_author_or_atlas_editor
|
||||||
def collection_case_questions(request, exam_id, case_id):
|
def collection_case_questions(request, exam_id, case_number):
|
||||||
# Support either case_number (index) or case_id (case PK)
|
|
||||||
try:
|
|
||||||
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
|
||||||
collection = casedetail.collection
|
|
||||||
except Exception:
|
|
||||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||||
try:
|
try:
|
||||||
case_obj = collection.get_case_by_index(int(case_id))
|
case_obj = collection.get_case_by_index(case_number)
|
||||||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
raise Http404("Case not found in collection")
|
||||||
@@ -3800,6 +3862,10 @@ def collection_case_view_take(
|
|||||||
case _:
|
case _:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Determine whether any prior series will be shown to the candidate.
|
||||||
|
has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
|
||||||
|
prior_count = prior_cases.count()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/collection_case_view_take.html",
|
"atlas/collection_case_view_take.html",
|
||||||
@@ -3810,6 +3876,8 @@ def collection_case_view_take(
|
|||||||
"casedetail": casedetail,
|
"casedetail": casedetail,
|
||||||
"series_list": series_list,
|
"series_list": series_list,
|
||||||
"series_to_load": series_to_load,
|
"series_to_load": series_to_load,
|
||||||
|
"has_priors": has_priors,
|
||||||
|
"prior_count": prior_count,
|
||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"previous": previous,
|
"previous": previous,
|
||||||
"next": next,
|
"next": next,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
upstream app_server {
|
||||||
|
server web:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
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/ {
|
||||||
|
alias /usr/src/app/static/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
alias /usr/src/app/media;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
upstream app_server {
|
||||||
|
# In production we proxy to the 'web' service (gunicorn) listening on 8000
|
||||||
|
server web:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
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
|
||||||
|
location /static/ {
|
||||||
|
alias /usr/src/app/static/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve media files from the media volume. CORS handling preserved.
|
||||||
|
set $cors "";
|
||||||
|
if ($http_origin ~* (https?://(localhost:5173|viewer\.penracourses\.org\.uk))) {
|
||||||
|
set $cors $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
if ($request_method = OPTIONS ) {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' "Origin, X-Requested-With, Content-Type, Accept" always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
alias /usr/src/app/media;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Viewer / OHIF / other static frontends - mount these into the nginx container at deploy time
|
||||||
|
location /viewer {
|
||||||
|
alias /srv/www/viewer;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /viewer/index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'cross-origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ohif {
|
||||||
|
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
|
||||||
|
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
alias /srv/www/ohif;
|
||||||
|
try_files $uri /ohif/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# rts and other host-mounted folders
|
||||||
|
location /rts {
|
||||||
|
alias /srv/www/rts;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /rota {
|
||||||
|
alias /srv/proc/rota;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploader {
|
||||||
|
alias /srv/uploader;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reporter proxy - if you run a reporter service, set it up on the 'reporter' network name
|
||||||
|
location ~ ^/reporter/(.*)$ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_pass_header Authorization;
|
||||||
|
|
||||||
|
proxy_pass http://reporter:5129/$1?$args;
|
||||||
|
proxy_set_header X-Forwarded-Prefix /reporter;
|
||||||
|
proxy_redirect http://reporter:5129/ /reporter/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default proxy to the Django/gunicorn upstream
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default dev / health server block; keep 443 managed outside of docker unless you mount certs
|
||||||
|
}
|
||||||
|
|
||||||
|
# A minimal HTTPS server block that expects certs to be mounted at /etc/letsencrypt
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name www.penracourses.org.uk penracourses.org.uk viewer.penracourses.org.uk;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/www.penracourses.org.uk/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/www.penracourses.org.uk/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Example `settings_local.py` derived from the production server file.
|
||||||
|
This file is a template only — DO NOT commit real secrets. Copy it to
|
||||||
|
`rad/deploy/settings_local.py` on your server (or provide your own) and
|
||||||
|
ensure the real file is excluded from git.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96", "localhost", "127.0.0.1"]
|
||||||
|
|
||||||
|
# When running in Docker (dev), requests will often come from an internal
|
||||||
|
# container/network address. Add any discovered host/container IPs (and a
|
||||||
|
# likely gateway address) to INTERNAL_IPS so the debug toolbar can render.
|
||||||
|
if DEBUG:
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||||
|
for ip in ips:
|
||||||
|
if ip not in INTERNAL_IPS:
|
||||||
|
INTERNAL_IPS.append(ip)
|
||||||
|
# common docker gateway pattern: replace last octet with '1'
|
||||||
|
parts = ip.split('.')
|
||||||
|
if len(parts) == 4:
|
||||||
|
parts[-1] = '1'
|
||||||
|
gw = '.'.join(parts)
|
||||||
|
if gw not in INTERNAL_IPS:
|
||||||
|
INTERNAL_IPS.append(gw)
|
||||||
|
except Exception:
|
||||||
|
# best-effort only; don't fail startup if this lookup doesn't work
|
||||||
|
pass
|
||||||
|
|
||||||
|
# In local/dev enable the toolbar unconditionally to avoid IP/proxy
|
||||||
|
# headaches while developing inside Docker/behind nginx.
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
|
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Paths inside the nginx container / host-mounted deploy directories.
|
||||||
|
# On the server, these paths are mounted into nginx as described in the
|
||||||
|
# deployment README. Adjust as needed.
|
||||||
|
STATIC_ROOT = '/usr/src/app/static/'
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'verbose': {
|
||||||
|
'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
|
||||||
|
'datefmt': "%d/%b/%Y %H:%M:%S",
|
||||||
|
},
|
||||||
|
'simple': {
|
||||||
|
'format': '%(levelname)s %(message)s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'file': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': 'log.txt',
|
||||||
|
'formatter': 'verbose',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers': ['file'],
|
||||||
|
'propagate': True,
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
'atlas': {
|
||||||
|
'handlers': ['file'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# External service credentials (placeholders)
|
||||||
|
CIMAR_USERNAME = "your.username@example.com"
|
||||||
|
CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
|
||||||
|
|
||||||
|
# Celery settings — when running in docker the redis service is
|
||||||
|
# reachable at the `redis` hostname provided by docker compose.
|
||||||
|
CELERY_BROKER_URL = "redis://redis:6379"
|
||||||
|
CELERY_RESULT_BACKEND = "redis://redis:6379"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||||
|
"NAME": os.environ.get("DB_NAME", "django"),
|
||||||
|
"USER": os.environ.get("DB_USER", "django"),
|
||||||
|
"PASSWORD": os.environ.get("DB_PASSWORD", "AVNS_NG_s4i7SMMobWLO"),
|
||||||
|
#"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-do-user-8165014-0.b.db.ondigitalocean.com"),
|
||||||
|
"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com"),
|
||||||
|
"PORT": os.environ.get("DB_PORT", "25060"),
|
||||||
|
#"NAME": os.environ.get("DB_NAME", "django"),
|
||||||
|
#"USER": os.environ.get("DB_USER", "django"),
|
||||||
|
#"PASSWORD": os.environ.get("DB_PASSWORD", "f7bf31dc9bda1256ea827953480d1917"),
|
||||||
|
#"HOST": os.environ.get("DB_HOST", "161.35.163.87"),
|
||||||
|
#"PORT": os.environ.get("DB_PORT", "5432"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Development overrides to avoid forcing HTTPS and secure cookies in local dev
|
||||||
|
# (production should keep these True).
|
||||||
|
SECURE_SSL_REDIRECT = False
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
CSRF_COOKIE_SECURE = False
|
||||||
|
SECURE_HSTS_SECONDS = 0
|
||||||
|
|
||||||
|
# Allow requests from the local dev nginx/dev server and direct runserver
|
||||||
|
# (include scheme+host+port for Django's origin checking).
|
||||||
|
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"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Example `settings_local.py` derived from the production server file.
|
||||||
|
This file is a template only — DO NOT commit real secrets. Copy it to
|
||||||
|
`rad/deploy/settings_local.py` on your server (or provide your own) and
|
||||||
|
ensure the real file is excluded from git.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96"]
|
||||||
|
|
||||||
|
# Paths inside the nginx container / host-mounted deploy directories.
|
||||||
|
# On the server, these paths are mounted into nginx as described in the
|
||||||
|
# deployment README. Adjust as needed.
|
||||||
|
STATIC_ROOT = '/usr/src/app/static/'
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'verbose': {
|
||||||
|
'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
|
||||||
|
'datefmt': "%d/%b/%Y %H:%M:%S",
|
||||||
|
},
|
||||||
|
'simple': {
|
||||||
|
'format': '%(levelname)s %(message)s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'file': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': 'log.txt',
|
||||||
|
'formatter': 'verbose',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers': ['file'],
|
||||||
|
'propagate': True,
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
'atlas': {
|
||||||
|
'handlers': ['file'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# External service credentials (placeholders)
|
||||||
|
CIMAR_USERNAME = "your.username@example.com"
|
||||||
|
CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
|
||||||
|
|
||||||
|
# Celery settings — when running in docker the redis service is
|
||||||
|
# reachable at the `redis` hostname provided by docker compose.
|
||||||
|
CELERY_BROKER_URL = "redis://redis:6379"
|
||||||
|
CELERY_RESULT_BACKEND = "redis://redis:6379"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Development override: point the web service to rad/.env.dev and map nginx to
|
||||||
|
# non-privileged host ports so you can run without sudo.
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
env_file:
|
||||||
|
- ../.env.dev
|
||||||
|
# Development: run Django's autoreloading development server instead of
|
||||||
|
# the production entrypoint/gunicorn. Mount the repository into the
|
||||||
|
# container so code edits on the host trigger Django's autoreload.
|
||||||
|
entrypoint: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
# Clear any command set by the prod compose file (which would be passed
|
||||||
|
# as an extra argument to manage.py). Setting an empty command prevents
|
||||||
|
# the image `command` from being appended to the entrypoint.
|
||||||
|
command: []
|
||||||
|
volumes:
|
||||||
|
- ../:/usr/src/app:cached
|
||||||
|
# Mount the app log directory so logs are visible on the host at ./logs
|
||||||
|
- ../logs:/var/log/rad
|
||||||
|
nginx:
|
||||||
|
# Development nginx override: mount a simplified config that does not
|
||||||
|
# reference LetsEncrypt certs so the container can start without real
|
||||||
|
# certificates present on the host.
|
||||||
|
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
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
|
||||||
|
- ./loki-data:/loki
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:2.8.2
|
||||||
|
volumes:
|
||||||
|
- ../logs:/var/log/rad:ro
|
||||||
|
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
||||||
|
command: -config.file=/etc/promtail/promtail-config.yml
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.2.0
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grafana-data:
|
||||||
|
loki-data:
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- 8000:8000
|
- 8000:8000
|
||||||
- 3459:3459
|
- 3459:3459
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env.dev
|
- ./.env.dev.local
|
||||||
db:
|
db:
|
||||||
image: postgres:14.2-alpine
|
image: postgres:14.2-alpine
|
||||||
volumes:
|
volumes:
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7.4.2-alpine
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD","redis-cli","ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
# build context is the repository root (one level up from this file)
|
||||||
|
context: ..
|
||||||
|
dockerfile: rad/Dockerfile.prod
|
||||||
|
restart: always
|
||||||
|
# Use COMPOSE_ENV to select which env file to load (defaults to 'prod').
|
||||||
|
# Example: COMPOSE_ENV=dev docker compose -f rad/docker/docker-compose.prod.yml -f rad/docker/docker-compose.dev.yml up -d
|
||||||
|
env_file:
|
||||||
|
- ../.env.${COMPOSE_ENV:-prod}
|
||||||
|
environment:
|
||||||
|
- DJANGO_SETTINGS_MODULE=rad.settings
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- static_volume:/usr/src/app/static
|
||||||
|
- media_volume:/usr/src/app/media
|
||||||
|
# Mount the server-specific local settings if present (DO NOT commit secrets).
|
||||||
|
# On the server create: rad/deploy/settings_local.py and it will be mounted into the container.
|
||||||
|
- ../deploy/settings_local.py:/usr/src/app/rad/settings_local.py:ro
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
command: ["/usr/src/app/entrypoint.sh"]
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:stable-alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${NGINX_HTTP_PORT:-80}:80"
|
||||||
|
- "${NGINX_HTTPS_PORT:-443}:443"
|
||||||
|
depends_on:
|
||||||
|
- 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
|
||||||
|
# Host-provided front-end bundles and static folders. Populate these under rad/deploy on the server
|
||||||
|
- ../deploy/www/viewer:/srv/www/viewer:ro
|
||||||
|
- ../deploy/www/ohif:/srv/www/ohif:ro
|
||||||
|
- ../deploy/www/rts:/srv/www/rts:ro
|
||||||
|
- ../deploy/nicereporter:/srv/nicereporter:rw
|
||||||
|
- ../deploy/proc-rota:/srv/proc/rota:ro
|
||||||
|
- ../deploy/uploader:/srv/uploader:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL","nginx -t" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Optional local observability stack (can be enabled on servers that
|
||||||
|
# should run Loki/Grafana). These services use configs found under
|
||||||
|
# the repository `docker/` directory so the same config works in dev.
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:2.8.2
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
|
||||||
|
- ../docker/loki-data:/loki
|
||||||
|
# WAL directory: map a host folder so Loki (running as UID 10001)
|
||||||
|
# can create the write-ahead log. Host folder created under
|
||||||
|
# `docker/loki-wal` and owned by UID 10001.
|
||||||
|
- ../docker/loki-wal:/wal
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:2.8.2
|
||||||
|
volumes:
|
||||||
|
# In prod you may want to point this at the host system log directory.
|
||||||
|
- ../logs:/var/log/rad:ro
|
||||||
|
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
||||||
|
command: -config.file=/etc/promtail/promtail-config.yml
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.2.0
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
static_volume:
|
||||||
|
media_volume:
|
||||||
|
grafana-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Local test compose overlay — adds a Postgres service so you can run the
|
||||||
|
# full stack locally without an external DB. Use it together with
|
||||||
|
# docker-compose.prod.yml:
|
||||||
|
#
|
||||||
|
# docker compose -f rad/docker/docker-compose.prod.yml -f rad/docker/docker-compose.test.yml up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-django}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-rad}
|
||||||
|
volumes:
|
||||||
|
- postgres_test_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL","pg_isready -U ${POSTGRES_USER:-django}" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_test_data:
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
grpc_listen_port: 9095
|
||||||
|
|
||||||
|
ingester:
|
||||||
|
lifecycler:
|
||||||
|
address: 127.0.0.1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
replication_factor: 1
|
||||||
|
chunk_idle_period: 5m
|
||||||
|
chunk_retain_period: 30s
|
||||||
|
max_transfer_retries: 0
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2020-10-24
|
||||||
|
store: boltdb-shipper
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v11
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
storage_config:
|
||||||
|
boltdb_shipper:
|
||||||
|
active_index_directory: /loki/index
|
||||||
|
cache_location: /loki/cache
|
||||||
|
shared_store: filesystem
|
||||||
|
filesystem:
|
||||||
|
directory: /loki/chunks
|
||||||
|
|
||||||
|
# Compactor settings: ensure compactor working directory is inside the mounted
|
||||||
|
# `/loki` path so the process (running as UID 10001) can create files.
|
||||||
|
compactor:
|
||||||
|
working_directory: /loki/compactor
|
||||||
|
shared_store: filesystem
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
enforce_metric_name: false
|
||||||
|
|
||||||
|
auth_enabled: false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
server:
|
||||||
|
http_listen_port: 9080
|
||||||
|
grpc_listen_port: 0
|
||||||
|
|
||||||
|
positions:
|
||||||
|
filename: /tmp/positions.yaml
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- url: http://loki:3100/loki/api/v1/push
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: rad_app_logs
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- localhost
|
||||||
|
labels:
|
||||||
|
job: rad_app
|
||||||
|
__path__: /var/log/rad/*.log
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Lightweight wait-for-postgres using nc (expects DATABASE_HOST and DATABASE_PORT env vars)
|
||||||
|
if [ -n "${DATABASE_HOST}" ]; then
|
||||||
|
host="$DATABASE_HOST"
|
||||||
|
port="${DATABASE_PORT:-5432}"
|
||||||
|
echo "Waiting for database at ${host}:${port}..."
|
||||||
|
while ! nc -z "$host" "$port"; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying database migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "Collecting static files..."
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
echo "Starting gunicorn..."
|
||||||
|
exec gunicorn rad.wsgi:application \
|
||||||
|
--name rad_gunicorn \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers ${GUNICORN_WORKERS:-3} \
|
||||||
|
--log-level ${GUNICORN_LOGLEVEL:-info}
|
||||||
+19
-290
@@ -81,6 +81,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
|
||||||
|
|
||||||
|
|
||||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||||
@@ -240,7 +241,7 @@ class ExamAuthorFormMixin(ModelForm):
|
|||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
self.fields["author"] = ModelMultipleChoiceField(
|
self.fields["author"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
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)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
self.fields["markers"] = ModelMultipleChoiceField(
|
self.fields["markers"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
|
widget=UserSearchWidget(),
|
||||||
)
|
)
|
||||||
self.fields["markers"].required = False
|
self.fields["markers"].required = False
|
||||||
|
|
||||||
@@ -556,219 +557,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|||||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
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:
|
if 'users' in self.fields:
|
||||||
orig_field = self.fields['users']
|
orig_field = self.fields['users']
|
||||||
|
|
||||||
class RenderWrapper:
|
# Use the reusable UserSearchWidget wrapper so templates and other
|
||||||
def __init__(self, field):
|
# modules can reuse the same behaviour consistently.
|
||||||
self.field = field
|
self.fields['users'].widget = UserSearchWidget(orig_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)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserUserGroup
|
model = UserUserGroup
|
||||||
@@ -886,36 +623,32 @@ class UserGroupExamForm(ModelForm):
|
|||||||
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
|
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=AnatomyExam.objects.filter(archive=False),
|
queryset=AnatomyExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(
|
widget=ExamSearchWidget(exam_model=AnatomyExam),
|
||||||
verbose_name="Anatomy Exams", is_stacked=False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
|
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=RapidsExam.objects.filter(archive=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(
|
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=ShortsExam.objects.filter(archive=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(
|
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=LongsExam.objects.filter(archive=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(
|
self.fields["physics_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=PhysicsExam.objects.filter(archive=False),
|
queryset=PhysicsExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(
|
widget=ExamSearchWidget(exam_model=PhysicsExam),
|
||||||
verbose_name="Physics Exams", is_stacked=False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
|
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=SbasExam.objects.filter(archive=False),
|
queryset=SbasExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
widget=ExamSearchWidget(exam_model=SbasExam),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -983,31 +716,27 @@ class CidGroupExamForm(ModelForm):
|
|||||||
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
|
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=AnatomyExam.objects.filter(archive=False),
|
queryset=AnatomyExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(
|
widget=ExamSearchWidget(exam_model=AnatomyExam),
|
||||||
verbose_name="Anatomy Exams", is_stacked=False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
|
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=RapidsExam.objects.filter(archive=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(
|
self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=LongsExam.objects.filter(archive=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(
|
self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=PhysicsExam.objects.filter(archive=False),
|
queryset=PhysicsExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(
|
widget=ExamSearchWidget(exam_model=PhysicsExam),
|
||||||
verbose_name="Physics Exams", is_stacked=False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
|
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=SbasExam.objects.filter(archive=False),
|
queryset=SbasExam.objects.filter(archive=False),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
widget=ExamSearchWidget(exam_model=SbasExam),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -1132,12 +861,12 @@ class ExamCollectionForm(ModelForm):
|
|||||||
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=exam_obj.objects.filter(archive=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(
|
self.fields["author"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
|
widget=UserSearchWidget(), required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class AuthorMixin():
|
|||||||
|
|
||||||
def is_author(self, user: User) -> bool:
|
def is_author(self, user: User) -> bool:
|
||||||
"""Returns True if the user is an author of the object"""
|
"""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()
|
return self.author.filter(id=user.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,43 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
Candidates:
|
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Candidates</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#genericNavbar" aria-controls="genericNavbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="genericNavbar">
|
||||||
|
<ul class="navbar-nav">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<a href="{% url 'generic:manage_cids' %}">Cids</a> /
|
<li class="nav-item dropdown">
|
||||||
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> /
|
<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>
|
||||||
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
|
<ul class="dropdown-menu" aria-labelledby="navCids">
|
||||||
<a href="{% url 'generic:user_group_view' %}">User Groups</a> /
|
<li><a class="dropdown-item" href="{% url 'generic:manage_cids' %}"><i class="bi bi-file-earmark-text me-1"></i> All Cids</a></li>
|
||||||
<a href="{% url 'trainees' %}">Trainees</a> /
|
<li><a class="dropdown-item" href="{% url 'generic:manage_cid_exams' %}"><i class="bi bi-mortarboard me-1"></i> Cids (exams)</a></li>
|
||||||
<a href="{% url 'accounts_list' %}">Manage Users</a> /
|
</ul>
|
||||||
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
|
</li>
|
||||||
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
|
|
||||||
|
|
||||||
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<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:
|
Open access:
|
||||||
<span class="fw-bold">{{ exam.open_access }}</span>
|
<span class="fw-bold">{{ exam.open_access }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,10 +121,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<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 #}
|
{# 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">
|
<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">
|
||||||
<span>Open access (questions)</span>
|
<small>Open access (questions)</small>
|
||||||
<small class="text-muted">Expand to change selected questions open access status</small>
|
<small class="text-muted">Expand to change selected questions open access status</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
|
||||||
<input type="hidden" name="type" value="sbas" />
|
<input type="hidden" name="type" value="sbas" />
|
||||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}" />
|
<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">
|
<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-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>
|
<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 navigation %}
|
||||||
{{block.super}}
|
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
|
||||||
<br/>
|
<div class="container-fluid">
|
||||||
{% comment %} <a href="{% url 'generic:cid_group_detail' cidusergroup.pk %}">Collections</a> / {% endcomment %}
|
<a class="navbar-brand" href="{% url 'generic:examcollection_list' %}">Collections</a>
|
||||||
<a href="{% url 'generic:examcollection_create' %}">Create Exam Collection</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 %}
|
{% endblock %}
|
||||||
@@ -2,106 +2,148 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1>
|
<div class="container py-4">
|
||||||
{{ object.name }}
|
<div class="row align-items-start mb-3">
|
||||||
{% if object.date %}
|
<div class="col">
|
||||||
[{{object.date}}]
|
<h1 class="h3 mb-0">{{ object.name }} {% if object.date %}<small class="text-muted">[{{ object.date }}]</small>{% endif %}</h1>
|
||||||
{% endif %}
|
<div class="small text-muted mt-1">Authors: {{ object.get_authors }}</div>
|
||||||
</h1>
|
</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>
|
<p class="lead small text-muted">This collection contains the following exams</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>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if object.anatomy_exams.all %}
|
{% if object.anatomy_exams.all %}
|
||||||
<h2>Anatomy Exams:</h2>
|
<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" %}
|
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
</div>
|
||||||
<a href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
|
<div class="card-footer add-marker small"></div>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.longs_exams.all %}
|
{% if object.longs_exams.all %}
|
||||||
<h2>Longs Exams:</h2>
|
<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" %}
|
{% with exams=object.longs_exams.all app_name="longs" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<a href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
|
</div>
|
||||||
<div class="add-marker">
|
<div class="card-footer add-marker small"></div>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.rapids_exams.all %}
|
{% if object.rapids_exams.all %}
|
||||||
<h2>Rapids Exams:</h2>
|
<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" %}
|
{% with exams=object.rapids_exams.all app_name="rapids" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<a href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
|
</div>
|
||||||
<div class="add-marker">
|
<div class="card-footer add-marker small"></div>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.shorts_exams.all %}
|
{% if object.shorts_exams.all %}
|
||||||
<h2>Shorts Exams:</h2>
|
<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" %}
|
{% with exams=object.shorts_exams.all app_name="shorts" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
|
</div>
|
||||||
<div class="add-marker">
|
<div class="card-footer add-marker small"></div>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.physics_exams.all %}
|
{% if object.physics_exams.all %}
|
||||||
<h2>Physics Exams:</h2>
|
<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" %}
|
{% with exams=object.physics_exams.all app_name="physics" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<a href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.sbas_exams.all %}
|
{% if object.sbas_exams.all %}
|
||||||
<h2>SBAs Exams:</h2>
|
<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" %}
|
{% with exams=object.sbas_exams.all app_name="sbas" %}
|
||||||
{% include "exam_list.html#exam-list" %}
|
{% include "exam_list.html#exam-list" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<a href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
<div id="bulk-add-groups">
|
<div id="bulk-add-groups">
|
||||||
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
|
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="add-user">
|
<div id="add-user">
|
||||||
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
|
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
|
||||||
|
</div> {# .container end #}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
.add-marker-button {
|
/* subtle visual tweaks for the new layout */
|
||||||
opacity: 0.5;
|
.card-body .exam-list { margin: 0; }
|
||||||
}
|
.card .card-footer.add-marker { min-height: 1.5rem; }
|
||||||
|
.card-header strong { font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "generic/base.html" %}
|
{% extends "generic/examcollection_base.html" %}
|
||||||
<!-- {% load static from static %} -->
|
<!-- {% load static from static %} -->
|
||||||
|
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
@@ -16,11 +16,135 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% 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" class="needs-validation" novalidate>
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{% if form.non_field_errors %}
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
<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>
|
</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 %}
|
{% 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 %}
|
{% if results %}
|
||||||
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
|
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
|
||||||
{% for item in results %}
|
{% 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">
|
<div class="flex-grow-1">
|
||||||
{{ item.text|escape }}
|
{{ item.text|escape }}
|
||||||
{% if item.grade %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("user-search/", views.user_search, name="user_search"),
|
path("user-search/", views.user_search, name="user_search"),
|
||||||
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
|
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("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||||
path(
|
path(
|
||||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
"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):
|
def exam_list_collection(self, request, collection_id):
|
||||||
collection = get_object_or_404(ExamCollection, pk=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
|
raise PermissionDenied
|
||||||
|
|
||||||
return self.exam_list(request, all=True, collection=collection)
|
return self.exam_list(request, all=True, collection=collection)
|
||||||
@@ -5490,11 +5490,38 @@ class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
|
|||||||
model = ExamCollection
|
model = ExamCollection
|
||||||
form_class = ExamCollectionForm
|
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):
|
class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
|
||||||
model = ExamCollection
|
model = ExamCollection
|
||||||
form_class = ExamCollectionForm
|
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):
|
class ExamCollectionClone(CreateView, AuthorRequiredMixin):
|
||||||
model = ExamCollection
|
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
|
@login_required
|
||||||
def supervisor_search(request):
|
def supervisor_search(request):
|
||||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||||
@@ -6062,12 +6306,16 @@ def supervisor_search(request):
|
|||||||
if not q:
|
if not q:
|
||||||
return HttpResponse("")
|
return HttpResponse("")
|
||||||
|
|
||||||
supervisors_qs = Supervisor.objects.filter(
|
# Search Supervisor records by name or email and render a small partial
|
||||||
Q(name__icontains=q) | Q(email__icontains=q)
|
try:
|
||||||
).order_by("name")[:10]
|
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]
|
results = []
|
||||||
|
for s in qs:
|
||||||
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": row, "q": q})
|
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
|
||||||
+7
-6
@@ -14,19 +14,20 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
#fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
+6
-6
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "rad",
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
# Copy uv helper binary from the uv image so we can manage venvs and pip
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Install build deps required for common Python packages and netcat for health wait
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
gcc \
|
||||||
|
git \
|
||||||
|
libpq-dev \
|
||||||
|
pkg-config \
|
||||||
|
libcairo2-dev \
|
||||||
|
# some distributions provide libgdk-pixbuf under the xlib name
|
||||||
|
libgdk-pixbuf-xlib-2.0-0 \
|
||||||
|
libgdk-pixbuf-xlib-2.0-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
wget \
|
||||||
|
ca-certificates \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install python deps into a uv-managed venv for reproducible builds
|
||||||
|
COPY requirements.txt /usr/src/app/
|
||||||
|
# Prebuild wheels into /wheels so compiled artifacts are cached in a layer.
|
||||||
|
# This speeds rebuilds when `requirements.txt` hasn't changed. The first build
|
||||||
|
# still needs to compile any packages without prebuilt wheels.
|
||||||
|
RUN mkdir -p /wheels \
|
||||||
|
&& python -m venv /tmp/venvbuild \
|
||||||
|
&& /tmp/venvbuild/bin/pip install --upgrade pip setuptools wheel \
|
||||||
|
&& /tmp/venvbuild/bin/pip wheel --wheel-dir=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /tmp/venvbuild
|
||||||
|
|
||||||
|
# Create the final uv-managed venv and install from the cached wheels where
|
||||||
|
# possible. Using `--no-index --find-links=/wheels` makes pip prefer the
|
||||||
|
# prebuilt wheels, avoiding recompilation.
|
||||||
|
RUN uv venv /opt/venv \
|
||||||
|
&& export VIRTUAL_ENV=/opt/venv \
|
||||||
|
&& export PATH="/opt/venv/bin:$PATH" \
|
||||||
|
&& uv pip install --upgrade pip setuptools wheel \
|
||||||
|
&& uv pip install --no-index --find-links=/wheels -r requirements.txt
|
||||||
|
|
||||||
|
# Ensure the runtime environment uses the uv-managed venv by default.
|
||||||
|
# This persists the venv into image ENV so `python` and `gunicorn` point
|
||||||
|
# to the venv-installed binaries at container start.
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Create non-root user before copying files so we can use Docker's
|
||||||
|
# `--chown` flag during COPY and avoid an expensive recursive `chown -R`.
|
||||||
|
RUN useradd -m appuser
|
||||||
|
|
||||||
|
# Create directory for application logs and ensure ownership is correct.
|
||||||
|
# Do this as root before switching to `appuser` so the directory exists
|
||||||
|
# and can be mounted by the dev compose override.
|
||||||
|
RUN mkdir -p /var/log/rad \
|
||||||
|
&& chown -R appuser:appuser /var/log/rad
|
||||||
|
|
||||||
|
# Copy project files into image and set ownership during copy (faster than
|
||||||
|
# a separate `chown -R` step). This keeps layer reuse efficient.
|
||||||
|
COPY --chown=appuser:appuser . /usr/src/app
|
||||||
|
|
||||||
|
# Ensure the venv is usable by the non-root user; chown only the venv path
|
||||||
|
# (much smaller than the whole project tree) to avoid a long recursive chown.
|
||||||
|
RUN chown -R appuser:appuser /opt/venv
|
||||||
|
|
||||||
|
# Make the entrypoint executable (some files in the repo may not have the
|
||||||
|
# executable bit set). Do this as root before switching to the app user.
|
||||||
|
RUN [ -f /usr/src/app/entrypoint.sh ] && chmod +x /usr/src/app/entrypoint.sh || true
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]
|
||||||
+15
-1
@@ -31,6 +31,8 @@ DEBUG = int(os.environ.get("DEBUG", default=0))
|
|||||||
|
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
"localhost",
|
"localhost",
|
||||||
|
"localhost:8000",
|
||||||
|
"localhost:8080",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"161.35.163.87",
|
"161.35.163.87",
|
||||||
"46.101.13.46",
|
"46.101.13.46",
|
||||||
@@ -241,7 +243,7 @@ MEDIA_ROOT = "media/"
|
|||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGOUT_REDIRECT_URL = "/"
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
INTERNAL_IPS = ["localhost", "127.0.0.1", "localhost:8000", "localhost:8080"]
|
||||||
|
|
||||||
#LOGGING = {
|
#LOGGING = {
|
||||||
# "version": 1,
|
# "version": 1,
|
||||||
@@ -255,6 +257,18 @@ INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
|||||||
|
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
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 = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||||
|
|||||||
+30
@@ -10,6 +10,36 @@ https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
from loguru import logger
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configure Loguru sinks early so app imports see consistent logging.
|
||||||
|
# Log to stdout (so `docker logs` shows messages) and to a file at
|
||||||
|
# `/var/log/rad/app.log` for persistent collection / host tailing.
|
||||||
|
try:
|
||||||
|
# ensure directory exists
|
||||||
|
log_dir = Path("/var/log/rad")
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Add stdout sink (if not already added by other code)
|
||||||
|
logger.remove() # remove default handlers to avoid duplicate lines
|
||||||
|
logger.add(sys.stdout, level="INFO", enqueue=True, backtrace=False, diagnose=False)
|
||||||
|
# Add rotating file sink for persistence in container. Use JSON lines
|
||||||
|
# (serialize=True) to make logs easy to query in Loki/Grafana.
|
||||||
|
logger.add(
|
||||||
|
str(log_dir / "app.log"),
|
||||||
|
rotation="10 MB",
|
||||||
|
retention="10 days",
|
||||||
|
level="DEBUG",
|
||||||
|
enqueue=True,
|
||||||
|
serialize=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort only — don't crash WSGI startup if logging can't be configured
|
||||||
|
try:
|
||||||
|
logger.add(sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class RapidSchema(ModelSchema):
|
class RapidSchema(ModelSchema):
|
||||||
class Config:
|
# `ninja` newer versions require an inner `Meta` class instead of `Config`
|
||||||
|
# for ModelSchema. Replace `Config` with `Meta` to be compatible.
|
||||||
|
class Meta:
|
||||||
model = Rapid
|
model = Rapid
|
||||||
|
#model_fields (old API) -> use `fields` per newer ninja ModelSchema API
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
#fields can be a list of field names or the string "__all__".
|
||||||
model_fields = "__all__"
|
#fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||||
|
fields = "__all__"
|
||||||
#model_exclude = ["answers"]
|
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_rapids(request):
|
def list_rapids(request):
|
||||||
|
|||||||
+5
-6
@@ -15,19 +15,18 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
fields = "__all__"
|
||||||
model_fields = "__all__"
|
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.test.yml -f docker/docker-compose.dev.yml down --volumes --remove-orphans
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Simple local bring-up helper. Usage:
|
||||||
|
# ./rad/scripts/local-up.sh
|
||||||
|
# It requires rad/.env.dev to exist (do NOT auto-create from examples) and then
|
||||||
|
# runs the prod+dev compose stack using COMPOSE_ENV to select the env file.
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [ ! -f .env.dev ]; then
|
||||||
|
echo ".env.dev not found. Create .env.dev with your development values (pointing to the external dev DB) and re-run."
|
||||||
|
echo "You can copy .env.prod.example to start from a template, but DO NOT commit secrets."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Default to 'dev' but allow overriding with COMPOSE_ENV environment variable
|
||||||
|
COMPOSE_ENV=${COMPOSE_ENV:-dev}
|
||||||
|
|
||||||
|
echo "Starting compose with COMPOSE_ENV=$COMPOSE_ENV (using .env.$COMPOSE_ENV)"
|
||||||
|
# Load variables from .env.<env> into the environment so Compose variable
|
||||||
|
# substitution (e.g. ${NGINX_HTTP_PORT}) works. We export all variables from
|
||||||
|
# the file for the duration of the command.
|
||||||
|
ENV_FILE="$REPO_ROOT/.env.$COMPOSE_ENV"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# Export variables from the .env file safely without sourcing it. Some
|
||||||
|
# values include characters (parentheses, ampersands) that break POSIX
|
||||||
|
# shell parsing if the file is sourced directly. Read line-by-line,
|
||||||
|
# ignore comments/empty lines and export KEY=VALUE pairs.
|
||||||
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||||
|
line="$_line"
|
||||||
|
case "$line" in
|
||||||
|
''|\#*) continue ;;
|
||||||
|
esac
|
||||||
|
# Split on first '=' into key and value
|
||||||
|
key=${line%%=*}
|
||||||
|
val=${line#*=}
|
||||||
|
# Export directly (preserves special characters in the value)
|
||||||
|
export "$key=$val"
|
||||||
|
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
|
||||||
+6
-6
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
upstream app_server {
|
||||||
|
server unix:/run/gunicorn.sock fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
#listen 80;
|
||||||
|
|
||||||
|
root /home/ross/web/viewer;
|
||||||
|
#index index.html index.htm;
|
||||||
|
|
||||||
|
|
||||||
|
server_name viewer.penracourses.org.uk;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
|
||||||
|
|
||||||
|
#if ($request_method = 'OPTIONS') {
|
||||||
|
# add_header 'Access-Control-Allow-Origin' * always;
|
||||||
|
# add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
# add_header 'Access-Control-Allow-Headers' "Origin, X-Requested-With, Content-Type, Accept" always;
|
||||||
|
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
# add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
# add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
# add_header 'Content-Length' 0;
|
||||||
|
# return 204;
|
||||||
|
#}
|
||||||
|
|
||||||
|
#alias /home/ross/web/viewer;
|
||||||
|
|
||||||
|
#add_header Cross-Origin-Opener-Policy same-origin;
|
||||||
|
#add_header Cross-Origin-Embedder-Policy require-corp;
|
||||||
|
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'cross-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'credentialless' always;
|
||||||
|
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'cross-origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/viewer.penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/viewer.penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
|
||||||
|
return 301 https://www.penracourses.org.uk$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
return 301 https://www.penracourses.org.uk$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
#listen 80;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
client_max_body_size 4G;
|
||||||
|
#server_name _;
|
||||||
|
server_name www.penracourses.org.uk;
|
||||||
|
resolver 127.0.0.11;
|
||||||
|
|
||||||
|
keepalive_timeout 20;
|
||||||
|
|
||||||
|
set $cors "";
|
||||||
|
if ($http_origin ~* (https?://(localhost:5173|viewer\.penracourses\.org\.uk))) {
|
||||||
|
set $cors $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Your Django project's media files - amend as required
|
||||||
|
location /media {
|
||||||
|
if ($request_method = OPTIONS ) {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
#add_header 'Access-Control-Allow-Origin' 'https://viewer.penracourses.org.uk' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' "Origin, X-Requested-With, Content-Type, Accept" always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
#add_header 'Access-Control-Allow-Origin' 'https://viewer.penracourses.org.uk' always;
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
|
||||||
|
|
||||||
|
alias /home/ross/web/rad/media;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
# your Django project's static files - amend as required
|
||||||
|
location /static {
|
||||||
|
alias /home/ross/web/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
# rts
|
||||||
|
location /rts {
|
||||||
|
alias /home/ross/web/rts;
|
||||||
|
}
|
||||||
|
# OHIF
|
||||||
|
location /viewer {
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'cross-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'credentialless' always;
|
||||||
|
#add_header 'Cross-Origin-Resource-Policy' 'cross-origin' always;
|
||||||
|
alias /home/ross/web/viewer;
|
||||||
|
}
|
||||||
|
location /ohif {
|
||||||
|
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
|
||||||
|
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
alias /home/ross/web/ohif;
|
||||||
|
try_files $uri /ohif/index.html;
|
||||||
|
#autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# # Proxy the static assests for the Django Admin panel
|
||||||
|
# location /static/admin {
|
||||||
|
# alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# }
|
||||||
|
location /rota {
|
||||||
|
alias /home/ross/proc/proc-rota;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploader {
|
||||||
|
alias /home/ross/uploader;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/reporter/(.*)$ {
|
||||||
|
alias /home/ross/web/nicereporter;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_pass_header Authorization;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:5129/$1?$args;
|
||||||
|
proxy_set_header X-Forwarded-Prefix /reporter;
|
||||||
|
proxy_redirect http://127.0.0.1:5129/ /reporter/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'single-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
#add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
|
||||||
|
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
#add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# add_header Access-Control-Allow-Origin *;
|
||||||
|
#}
|
||||||
|
# rota
|
||||||
|
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#server {
|
||||||
|
#server_name 161.35.163.87;
|
||||||
|
#add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
#
|
||||||
|
#return 301 $scheme://penracourses.org.uk$request_uri;
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
#server {
|
||||||
|
# #listen 80;
|
||||||
|
# #server_name penracoureses.org.uk www.penracourses.org.uk;
|
||||||
|
#
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# index index.html index.htm;
|
||||||
|
#
|
||||||
|
# client_max_body_size 4G;
|
||||||
|
# #server_name _;
|
||||||
|
# server_name penracourses.org.uk; # managed by Certbot
|
||||||
|
#
|
||||||
|
# keepalive_timeout 5;
|
||||||
|
#
|
||||||
|
# # Your Django project's media files - amend as required
|
||||||
|
# location /media {
|
||||||
|
# alias /home/ross/web/rad/media;
|
||||||
|
# #add_header Access-Control-Allow-Origin *;
|
||||||
|
# #add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
# #add_header Vary Origin;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # your Django project's static files - amend as required
|
||||||
|
# location /static {
|
||||||
|
# alias /home/ross/web/rad/static;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # rts
|
||||||
|
# location /rts {
|
||||||
|
# alias /home/ross/web/rts;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # # Proxy the static assests for the Django Admin panel
|
||||||
|
# # location /static/admin {
|
||||||
|
# # alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# # }
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_redirect off;
|
||||||
|
# proxy_buffering off;
|
||||||
|
#
|
||||||
|
# proxy_pass http://app_server;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# #location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# # add_header Access-Control-Allow-Origin *;
|
||||||
|
# #}
|
||||||
|
#
|
||||||
|
# # rota
|
||||||
|
# location /rota {
|
||||||
|
# alias /home/ross/proc-rota;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /rota2 {
|
||||||
|
# alias /home/ross/neos;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||||
|
# listen 443 ssl; # managed by Certbot
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
#
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
root /home/ross/web/rts;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
client_max_body_size 4G;
|
||||||
|
#server_name _;
|
||||||
|
server_name dev.penracourses.org.uk www.dev.penracourses.org.uk;
|
||||||
|
|
||||||
|
keepalive_timeout 5;
|
||||||
|
|
||||||
|
# Your Django project's media files - amend as required
|
||||||
|
location /media {
|
||||||
|
# Simple requests
|
||||||
|
if ($request_method ~* "(GET|POST)") {
|
||||||
|
add_header "Access-Control-Allow-Origin" *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preflighted requests
|
||||||
|
if ($request_method = OPTIONS ) {
|
||||||
|
add_header "Access-Control-Allow-Origin" *;
|
||||||
|
add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD";
|
||||||
|
add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept";
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
alias /home/ross/web/rad/media;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
# your Django project's static files - amend as required
|
||||||
|
location /static {
|
||||||
|
alias /home/ross/web/rad/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
# rts
|
||||||
|
location /rts {
|
||||||
|
alias /home/ross/web/rts;
|
||||||
|
}
|
||||||
|
|
||||||
|
# # Proxy the static assests for the Django Admin panel
|
||||||
|
# location /static/admin {
|
||||||
|
# alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
#proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
#proxy_set_header Host $host;
|
||||||
|
#proxy_redirect off;
|
||||||
|
#proxy_buffering off;
|
||||||
|
|
||||||
|
#proxy_pass http://app_server;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# add_header Access-Control-Allow-Origin *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#listen [::]:443 ssl; # managed by Certbot
|
||||||
|
#listen 443 ssl; # managed by Certbot
|
||||||
|
#ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
#ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
#include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
#ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = penracourses.org.uk) {
|
||||||
|
return 301 https://www.$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80 ;
|
||||||
|
listen [::]:80 ;
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = www.penracourses.org.uk) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server ipv6only=on;
|
||||||
|
server_name www.penracourses.org.uk;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = viewer.penracourses.org.uk) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server_name viewer.penracourses.org.uk;
|
||||||
|
listen 80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
DEBUG = False
|
||||||
|
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96"]
|
||||||
|
#SECURE_SSL_REDIRECT = False
|
||||||
|
#SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'http')
|
||||||
|
#REMOTE_URL = "http://46.101.13.46:8123"
|
||||||
|
#
|
||||||
|
#MEDIA_ROOT = '/home/django/rad/media/'
|
||||||
|
STATIC_ROOT = '/home/ross/web/static/'
|
||||||
|
|
||||||
|
#EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'verbose': {
|
||||||
|
'format' : "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
|
||||||
|
'datefmt' : "%d/%b/%Y %H:%M:%S"
|
||||||
|
},
|
||||||
|
'simple': {
|
||||||
|
'format': '%(levelname)s %(message)s'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'file': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': 'log.txt',
|
||||||
|
'formatter': 'verbose'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers':['file'],
|
||||||
|
'propagate': True,
|
||||||
|
'level':'DEBUG',
|
||||||
|
},
|
||||||
|
'atlas': {
|
||||||
|
'handlers': ['file'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
#"django.core.mail": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||||
|
#"smtplib": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CIMAR_USERNAME = "ross.kruger@nhs.net"
|
||||||
|
CIMAR_PASSWORD = "[prdr32@cimar]"
|
||||||
|
|
||||||
|
# Celery settings
|
||||||
|
CELERY_BROKER_URL = "redis://localhost:6379"
|
||||||
|
CELERY_RESULT_BACKEND = "redis://localhost:6379"
|
||||||
+4
-1
@@ -224,7 +224,10 @@
|
|||||||
<a class="dropdown-item" href="{% url 'sbas:index' %}">SBAs</a>
|
<a class="dropdown-item" href="{% url 'sbas:index' %}">SBAs</a>
|
||||||
</li>
|
</li>
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
|
|
||||||
{% if collection %}
|
{% if collection %}
|
||||||
<h2>Collection: {{collection.name}}</h2>
|
<h2>Collection: {{collection.name}}</h2>
|
||||||
|
<p class="muted"><a href="{% url 'generic:examcollection_detail' collection.pk %}">← Back to collection</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<details class="help-text">
|
<details class="help-text">
|
||||||
|
|||||||
Reference in New Issue
Block a user