diff --git a/.env.dev b/.env.dev new file mode 100644 index 00000000..e7717e1f --- /dev/null +++ b/.env.dev @@ -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 \ No newline at end of file diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 00000000..ee1da750 --- /dev/null +++ b/.env.prod.example @@ -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 diff --git a/.gitignore b/.gitignore index 8d92a891..5d244eab 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,10 @@ venv *.log -temp/ \ No newline at end of file +temp/ + +# Loki runtime data +/docker/loki-data/ +/docker/loki-wal/ +# Local runtime logs collected by Promtail +/logs/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..f10b7633 --- /dev/null +++ b/README.md @@ -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). diff --git a/anatomy/api.py b/anatomy/api.py index 251d802f..289c9a70 100644 --- a/anatomy/api.py +++ b/anatomy/api.py @@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model router = Router() class CidUserSchema(ModelSchema): - class Config: + class Meta: model = CidUser - model_fields = ["cid", "name", "email"] + fields = ["cid", "name", "email"] class UserUserSchema(ModelSchema): - class Config: + class Meta: model = get_user_model() - model_fields = ["id", "email"] + fields = ["id", "email"] class CidUserExamSchema(ModelSchema): cid_user : CidUserSchema | None user_user : UserUserSchema | None - class Config: + class Meta: model = CidUserExam - model_fields = ["id"] + fields = ["id"] class ExamStatusSchema(ModelSchema): - class Config: + class Meta: model = ExamUserStatus - model_fields = ["id", "datetime", "status", "extra"] + fields = ["id", "datetime", "status", "extra"] class ExamUserStatusSchema(ModelSchema): #cid_user : CidUserSchema | None #user_user : UserUserSchema | None cid_user_exam : CidUserExamSchema | None - class Config: + class Meta: model = ExamUserStatus - model_fields = ["id", "datetime", "status", "extra"] + fields = ["id", "datetime", "status", "extra"] class QuestionSchema(ModelSchema): - class Config: + class Meta: model = Question - #model_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" + fields = "__all__" - #model_exclude = ["answers"] + #exclude = ["answers"] class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_questions(request): diff --git a/atlas/api.py b/atlas/api.py index 5dec2945..f2c8b5d3 100644 --- a/atlas/api.py +++ b/atlas/api.py @@ -32,9 +32,9 @@ router = Router() class SeriesSchema(ModelSchema): case_id: List[int] = [] - class Config: + class Meta: model = Series - model_fields = [ + fields = [ "id", "modality", "examination", @@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema): class CaseSchema(ModelSchema): - class Config: + class Meta: model = Case - model_fields = ["id", "title"] + fields = ["id", "title"] @router.post("/upload_dicom", auth=django_auth) diff --git a/atlas/templates/atlas/partials/series_images.html b/atlas/templates/atlas/partials/series_images.html new file mode 100644 index 00000000..20eb59b1 --- /dev/null +++ b/atlas/templates/atlas/partials/series_images.html @@ -0,0 +1,31 @@ +{% load static %} + +
+ {% for image in images %} +
+ {% if image.image %} + + image-{{ image.pk }} + + {% else %} +
+ {% endif %} + +
+
{{ image.image.name|default:"(no file)" }}
+ {% if image.image %} +
{{ image.image.size|filesizeformat }}
+ {% endif %} + {% if image.image_blake3_hash %} +
hash: {{ image.image_blake3_hash|truncatechars:12 }}
+ {% endif %} +
+ + +
+ {% empty %} +

No images found for this series.

+ {% endfor %} +
diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index e8e4f133..7a0d2c3e 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -1,337 +1,361 @@ {% load crispy_forms_tags %} {% with image_url_array_and_count=series.get_image_url_array_and_count %} -
-
-
-
-
-
-

Series: {{ series.modality }} - {{ series.examination }}

-
{{ series.plane }}, {{ series.contrast }}
-
Description: {{ series.description }}
-
Author: {{ series.get_author_display }}
+
+
+
+
+
+
+

Series: {{ series.modality }} - {{ series.examination }}

+
{{ series.plane }}, {{ series.contrast }}
+
Description: {{ series.description }}
+
Author: {{ series.get_author_display }}
+
+
+ View series + {% if can_edit %} + + {% else %} + + {% endif %} +
- +
+
+ +
+
+
+
+ {% with image_url_array_and_count=series.get_image_url_array_and_count %} +
+
+ {% endwith %} +
+ +
+
+ +
+
Finding form
+
+
+
+ {% if editing_finding > 0 %} +
Editing Finding
+

Editing finding with ID: {{editing_finding}}

+ {% else %} +
Add Finding
+

Click the button below to add a new finding.

+ {% endif %} +
+ {% csrf_token %} + {{series_finding_form|crispy}} +
+ + +
+
+
+
+
+
+
+ Findings +
+ {% for finding in series.findings.all %} +
+
+
+ + Finding(s): + {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}
+ Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}
+ Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}
+
{{finding.description}}
+
+
+ {% if request.user.is_superuser %} + +
+
{{finding.annotation_json}}
+
{{finding.viewport_json}}
+
+ {% endif %} + {% if can_edit %} +
+ Edit + Delete +
+ {% endif %} +
+
+
+ {% empty %} +

No findings available.

+ {% endfor %} +
+
+
+ + +
+
+ Images +
+ + Download + +
+
+
+
+

Click 'Load images' to fetch.

+
+
+
+ +
+ +
+
+
Cases
+
+
+ {% include 'atlas/partials/series_cases_list.html' %} +
{% if can_edit %} - - {% else %} - +
+ Add Series to Case +
+ {% csrf_token %} +
+ + +
+
+ + +
+
+
{% endif %}
+ + + + + + {% if can_edit %} +
+
Truncate series
+
+

Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.

+
Warning: If you have reordered the series this may remove the wrong images.
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+
+
+ {% endif %}
-
-
-
-
- {% with image_url_array_and_count=series.get_image_url_array_and_count %} -
-
- {% endwith %} -
- -
-
- -
-
Finding form
-
-
-
- {% if editing_finding > 0 %} -
Editing Finding
-

Editing finding with ID: {{editing_finding}}

- {% else %} -
Add Finding
-

Click the button below to add a new finding.

- {% endif %} -
- {% csrf_token %} - {{series_finding_form|crispy}} -
- - -
-
-
-
-
-
-
- Findings -
- {% for finding in series.findings.all %} -
-
-
- - Finding(s): - {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}
- Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}
- Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}
-
{{finding.description}}
-
-
- {% if request.user.is_superuser %} - -
-
{{finding.annotation_json}}
-
{{finding.viewport_json}}
-
- {% endif %} - {% if can_edit %} -
- Edit - Delete -
- {% endif %} -
-
-
- {% empty %} -

No findings available.

- {% endfor %} -
-
-
-
- -
-
-
Cases
-
-
- {% include 'atlas/partials/series_cases_list.html' %} -
- {% if can_edit %} -
- Add Series to Case -
- {% csrf_token %} -
- - -
-
- - -
-
-
- {% endif %} -
-
- - - - - - {% if can_edit %} -
-
Truncate series
-
-

Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.

-
Warning: If you have reordered the series this may remove the wrong images.
-
- -
- - -
-
-
- -
- - -
-
-
- - -
-
-
-
- {% endif %} + -
- + + {% endwith %} @@ -402,14 +426,14 @@ transition: background 0.2s, font-size 0.2s; } -details.styled-detail:not([open]) { - padding-bottom: 0; - min-height: 0; -} + details.styled-detail:not([open]) { + padding-bottom: 0; + min-height: 0; + } -details.styled-detail:not([open]) > *:not(summary) { - display: none; -} + details.styled-detail:not([open]) > *:not(summary) { + display: none; + } details.styled-detail:not([open]) > summary { padding: 0.7em 1.2em 0.3em 1.2em; diff --git a/atlas/urls.py b/atlas/urls.py index 868399fc..2097dc8d 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -457,6 +457,7 @@ urlpatterns = [ # TODO: case context series viewing (so that we can view series in the context of a case) path("series/", views.series_detail, name="series_detail"), path("series//viewer", views.series_viewer, name="series_viewer"), + path("series//images/", views.series_images_partial, name="series_images"), path("series//authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"), path("series//finding/related", views.series_finding_related, name="series_finding_related"), path("series/", views.SeriesView.as_view(), name="series_view"), diff --git a/atlas/views.py b/atlas/views.py index 7d03c3d6..25709c5a 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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 # @user_is_atlas_editor def question_schema_detail(request, pk: int): diff --git a/deploy/nginx/dev.conf b/deploy/nginx/dev.conf new file mode 100644 index 00000000..6eb39ef9 --- /dev/null +++ b/deploy/nginx/dev.conf @@ -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; + } +} diff --git a/deploy/nginx/prod.conf b/deploy/nginx/prod.conf new file mode 100644 index 00000000..a4c7fea8 --- /dev/null +++ b/deploy/nginx/prod.conf @@ -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; + } +} diff --git a/deploy/settings_local.py b/deploy/settings_local.py new file mode 100644 index 00000000..66c13638 --- /dev/null +++ b/deploy/settings_local.py @@ -0,0 +1,122 @@ +""" +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 = "" + +# 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", +] + +REMOTE_URL = "http://127.0.0.1:8000" \ No newline at end of file diff --git a/deploy/settings_local.py.example b/deploy/settings_local.py.example new file mode 100644 index 00000000..2d4204a0 --- /dev/null +++ b/deploy/settings_local.py.example @@ -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 = "" + +# 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" diff --git a/docker/.env.dev b/docker/.env.dev.local similarity index 100% rename from docker/.env.dev rename to docker/.env.dev.local diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 00000000..598c2634 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -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: + diff --git a/docker/docker-compose.yml b/docker/docker-compose.local.yml similarity index 98% rename from docker/docker-compose.yml rename to docker/docker-compose.local.yml index ca9f6bb4..f73ef645 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.local.yml @@ -29,7 +29,7 @@ services: - 8000:8000 - 3459:3459 env_file: - - ./.env.dev + - ./.env.dev.local db: image: postgres:14.2-alpine volumes: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 00000000..68f0e361 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -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 diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 00000000..11ba476e --- /dev/null +++ b/docker/docker-compose.test.yml @@ -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: diff --git a/docker/loki-config/local-config.yaml b/docker/loki-config/local-config.yaml new file mode 100644 index 00000000..8b3b7b2f --- /dev/null +++ b/docker/loki-config/local-config.yaml @@ -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 diff --git a/docker/loki-data/chunks/loki_cluster_seed.json b/docker/loki-data/chunks/loki_cluster_seed.json new file mode 100644 index 00000000..037f8b98 --- /dev/null +++ b/docker/loki-data/chunks/loki_cluster_seed.json @@ -0,0 +1 @@ +{"UID":"f8cade59-3cf9-4ae3-965d-8b0a57007d58","created_at":"2025-12-01T17:14:05.101735276Z","version":{"version":"2.8.2","revision":"9f809eda7","branch":"HEAD","buildUser":"root@e401cfcb874f","buildDate":"2023-05-03T11:07:54Z","goVersion":"go1.20.4"}} \ No newline at end of file diff --git a/docker/promtail-config.yml b/docker/promtail-config.yml new file mode 100644 index 00000000..43135394 --- /dev/null +++ b/docker/promtail-config.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..07db988d --- /dev/null +++ b/entrypoint.sh @@ -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} diff --git a/generic/mixins.py b/generic/mixins.py index 99765d4f..75898e4e 100644 --- a/generic/mixins.py +++ b/generic/mixins.py @@ -65,6 +65,9 @@ class AuthorMixin(): def is_author(self, user: User) -> bool: """Returns True if the user is an author of the object""" + if user.is_superuser: + return True + return self.author.filter(id=user.id).exists() diff --git a/generic/templates/generic/exam_overview_headers.html b/generic/templates/generic/exam_overview_headers.html index 0c67cf8b..67b1850f 100644 --- a/generic/templates/generic/exam_overview_headers.html +++ b/generic/templates/generic/exam_overview_headers.html @@ -93,7 +93,7 @@
-
+
Open access: {{ exam.open_access }}
@@ -121,10 +121,10 @@
-
+
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #} -
@@ -133,7 +133,6 @@
-
Select questions on the list above (checkboxes) then click one of the actions below.
diff --git a/generic/views.py b/generic/views.py index a7b14ffe..223dae71 100644 --- a/generic/views.py +++ b/generic/views.py @@ -1774,7 +1774,7 @@ class ExamViews(View, LoginRequiredMixin): def exam_list_collection(self, request, collection_id): collection = get_object_or_404(ExamCollection, pk=collection_id) - if not request.user in collection.author.all(): + if not collection.is_author(request.user): raise PermissionDenied return self.exam_list(request, all=True, collection=collection) diff --git a/longs/api.py b/longs/api.py index 6c1bc513..75a5604b 100644 --- a/longs/api.py +++ b/longs/api.py @@ -14,19 +14,20 @@ router = Router() class QuestionSchema(ModelSchema): - class Config: + class Meta: model = Question - #model_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" + # Use `fields` per newer ninja ModelSchema API + #fields = ["question", "history", "feedback", "normal", "laterality"] + fields = "__all__" - #model_exclude = ["answers"] + #exclude = ["answers"] class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_questions(request): diff --git a/physics/api.py b/physics/api.py index f46306a1..abba04ea 100644 --- a/physics/api.py +++ b/physics/api.py @@ -15,19 +15,19 @@ router = Router() class QuestionSchema(ModelSchema): - class Config: + class Meta: model = Question - #model_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" + # Use `fields` per newer ninja ModelSchema API + fields = "__all__" - #model_exclude = ["answers"] + #exclude = ["answers"] class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_questions(request): diff --git a/rad.code-workspace b/rad.code-workspace new file mode 100644 index 00000000..24a0bd0b --- /dev/null +++ b/rad.code-workspace @@ -0,0 +1,15 @@ +{ + "folders": [ + { + "name": "rad", + "path": "." + } + ], + "settings": { + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true + } +} \ No newline at end of file diff --git a/rad/Dockerfile.prod b/rad/Dockerfile.prod new file mode 100644 index 00000000..f65b66f8 --- /dev/null +++ b/rad/Dockerfile.prod @@ -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"] diff --git a/rad/wsgi.py b/rad/wsgi.py index 23078214..78186fbb 100644 --- a/rad/wsgi.py +++ b/rad/wsgi.py @@ -10,6 +10,36 @@ https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ import os 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") diff --git a/rapids/api.py b/rapids/api.py index b0f4875c..2d78ddcd 100644 --- a/rapids/api.py +++ b/rapids/api.py @@ -15,19 +15,19 @@ router = Router() 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_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" - - #model_exclude = ["answers"] + #model_fields (old API) -> use `fields` per newer ninja ModelSchema API + #fields can be a list of field names or the string "__all__". + #fields = ["question", "history", "feedback", "normal", "laterality"] + fields = "__all__" class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_rapids(request): diff --git a/sbas/api.py b/sbas/api.py index 2da50baf..c2b3c568 100644 --- a/sbas/api.py +++ b/sbas/api.py @@ -15,19 +15,18 @@ router = Router() class QuestionSchema(ModelSchema): - class Config: + class Meta: model = Question - #model_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" + fields = "__all__" - #model_exclude = ["answers"] + #exclude = ["answers"] class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_questions(request): diff --git a/scripts/local-down.sh b/scripts/local-down.sh new file mode 100755 index 00000000..c29fb5f8 --- /dev/null +++ b/scripts/local-down.sh @@ -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 diff --git a/scripts/local-up.sh b/scripts/local-up.sh new file mode 100755 index 00000000..c8275443 --- /dev/null +++ b/scripts/local-up.sh @@ -0,0 +1,44 @@ +#!/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. 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 + +COMPOSE_ENV=$COMPOSE_ENV docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up --build diff --git a/shorts/api.py b/shorts/api.py index 49736127..79b0fd56 100644 --- a/shorts/api.py +++ b/shorts/api.py @@ -15,19 +15,19 @@ router = Router() class QuestionSchema(ModelSchema): - class Config: + class Meta: model = Question - #model_fields = ["question", "history", "feedback", "normal", "laterality"] - model_fields = "__all__" + # Use `fields` per newer ninja ModelSchema API + fields = "__all__" - #model_exclude = ["answers"] + #exclude = ["answers"] class ExamSchema(ModelSchema): - class Config: + class Meta: model = Exam - model_fields = ["id", "name", "active", "publish_results"] + fields = ["id", "name", "active", "publish_results"] @router.get('/') def list_questions(request): diff --git a/temp1/default b/temp1/default new file mode 100644 index 00000000..908c9e88 --- /dev/null +++ b/temp1/default @@ -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 + + +} diff --git a/temp1/settings_local.py b/temp1/settings_local.py new file mode 100644 index 00000000..ef40fa06 --- /dev/null +++ b/temp1/settings_local.py @@ -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"