Merge branch 'dockerprod' into master
This commit is contained in:
@@ -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
|
||||
+7
-1
@@ -11,4 +11,10 @@ venv
|
||||
|
||||
*.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()
|
||||
|
||||
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):
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -1,337 +1,361 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
|
||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
|
||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||
</div>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
||||
{% if can_edit %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% else %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
|
||||
{% if can_edit and editing_finding < 1 %}
|
||||
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
|
||||
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
|
||||
title="Clone an existing finding"
|
||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||
hx-target="#clone-findings-modal"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#clone-findings-modal">Clone finding</button>
|
||||
{% endif %}
|
||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<!-- Finding form moved below the viewport -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Finding form</div>
|
||||
<div class="card-body">
|
||||
<div id="finding-form">
|
||||
<div class="hide" id="hidden-form">
|
||||
{% if editing_finding > 0 %}
|
||||
<h5>Editing Finding</h5>
|
||||
<p class="small">Editing finding with ID: {{editing_finding}}</p>
|
||||
{% else %}
|
||||
<h5>Add Finding</h5>
|
||||
<p class="small">Click the button below to add a new finding.</p>
|
||||
{% endif %}
|
||||
<form method="post" id="series_finding_form">
|
||||
{% csrf_token %}
|
||||
{{series_finding_form|crispy}}
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<input type="submit" value="Submit" class="btn btn-success btn-sm">
|
||||
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="styled-detail open">
|
||||
<summary>Findings</summary>
|
||||
<div class="mt-2">
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||
<div>
|
||||
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
|
||||
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
|
||||
<strong>Finding(s):</strong>
|
||||
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<div class="mt-2">{{finding.description}}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if request.user.is_superuser %}
|
||||
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
|
||||
<div class="collapse" id="extra-{{finding.pk}}">
|
||||
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
|
||||
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<div class="d-flex flex-column">
|
||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
|
||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No findings available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</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 class="col-md-4 mb-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Cases</div>
|
||||
<div class="card-body">
|
||||
<div id="cases-list">
|
||||
{% include 'atlas/partials/series_cases_list.html' %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% else %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
<details id="add-series-to-case-details" class="mt-2">
|
||||
<summary>Add Series to Case</summary>
|
||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label for="case-select" class="form-label">Select Case</label>
|
||||
<select name="case_id" id="case-select" class="form-select" required>
|
||||
{% for case in available_cases %}
|
||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Actions</div>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
|
||||
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card">
|
||||
<div class="card-header">Truncate series</div>
|
||||
<div class="card-body">
|
||||
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Start</label>
|
||||
<div class="input-group">
|
||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
||||
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">End</label>
|
||||
<div class="input-group">
|
||||
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
||||
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
||||
</div>
|
||||
<div id="truncate-output" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
|
||||
{% if can_edit and editing_finding < 1 %}
|
||||
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
|
||||
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
|
||||
title="Clone an existing finding"
|
||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||
hx-target="#clone-findings-modal"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#clone-findings-modal">Clone finding</button>
|
||||
{% endif %}
|
||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<!-- Finding form moved below the viewport -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Finding form</div>
|
||||
<div class="card-body">
|
||||
<div id="finding-form">
|
||||
<div class="hide" id="hidden-form">
|
||||
{% if editing_finding > 0 %}
|
||||
<h5>Editing Finding</h5>
|
||||
<p class="small">Editing finding with ID: {{editing_finding}}</p>
|
||||
{% else %}
|
||||
<h5>Add Finding</h5>
|
||||
<p class="small">Click the button below to add a new finding.</p>
|
||||
{% endif %}
|
||||
<form method="post" id="series_finding_form">
|
||||
{% csrf_token %}
|
||||
{{series_finding_form|crispy}}
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<input type="submit" value="Submit" class="btn btn-success btn-sm">
|
||||
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="styled-detail open">
|
||||
<summary>Findings</summary>
|
||||
<div class="mt-2">
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||
<div>
|
||||
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
|
||||
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
|
||||
<strong>Finding(s):</strong>
|
||||
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<div class="mt-2">{{finding.description}}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if request.user.is_superuser %}
|
||||
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
|
||||
<div class="collapse" id="extra-{{finding.pk}}">
|
||||
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
|
||||
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<div class="d-flex flex-column">
|
||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
|
||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No findings available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Cases</div>
|
||||
<div class="card-body">
|
||||
<div id="cases-list">
|
||||
{% include 'atlas/partials/series_cases_list.html' %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<details id="add-series-to-case-details" class="mt-2">
|
||||
<summary>Add Series to Case</summary>
|
||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label for="case-select" class="form-label">Select Case</label>
|
||||
<select name="case_id" id="case-select" class="form-select" required>
|
||||
{% for case in available_cases %}
|
||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Actions</div>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
|
||||
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card">
|
||||
<div class="card-header">Truncate series</div>
|
||||
<div class="card-body">
|
||||
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Start</label>
|
||||
<div class="input-group">
|
||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
||||
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">End</label>
|
||||
<div class="input-group">
|
||||
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
||||
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
||||
</div>
|
||||
<div id="truncate-output" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const apiKey = "root"
|
||||
|
||||
$("#set-lower-truncation-bound-button").click(() => {
|
||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#set-upper-truncation-bound-button").click(() => {
|
||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#truncate-test-button").click(() => {
|
||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
||||
if (ok) {
|
||||
$("#truncate-output").text("The truncated images are shown above.");
|
||||
}
|
||||
});
|
||||
$("#truncate-button").click(() => {
|
||||
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
|
||||
if (lower_bound >= upper_bound) {
|
||||
alert("The lower bound must be less than the upper bound.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
||||
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
|
||||
|
||||
}
|
||||
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const apiKey = "root"
|
||||
})
|
||||
|
||||
$("#set-lower-truncation-bound-button").click(() => {
|
||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#set-upper-truncation-bound-button").click(() => {
|
||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||
});
|
||||
$("#truncate-test-button").click(() => {
|
||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
||||
if (ok) {
|
||||
$("#truncate-output").text("The truncated images are shown above.");
|
||||
}
|
||||
});
|
||||
$("#truncate-button").click(() => {
|
||||
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||
|
||||
if (lower_bound >= upper_bound) {
|
||||
alert("The lower bound must be less than the upper bound.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
||||
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
|
||||
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
$("#add-finding-button").click(() => {
|
||||
$("#hidden-form").show()
|
||||
$("#add-finding-button").click(() => {
|
||||
$("#hidden-form").show()
|
||||
//dicom_element = $(".cornerstone-element").get(0);
|
||||
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
//cornerstone.reset(dicom_element);
|
||||
////cornerstone.resize(dicom_element, true);
|
||||
$("#add-finding-button").hide()
|
||||
$("#clone-finding-button").hide()
|
||||
$("#add-finding-button").hide()
|
||||
$("#clone-finding-button").hide()
|
||||
//$("#finding-form").empty().append(
|
||||
// $("#hidden-form form").clone()
|
||||
//);
|
||||
|
||||
});
|
||||
$("#cancel-add-finding-button").click((e) => {
|
||||
$("#hidden-form").hide()
|
||||
$("#add-finding-button").show()
|
||||
$("#clone-finding-button").show()
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
$("#reset-viewport-button").click(() => {
|
||||
resetViewer_root();
|
||||
|
||||
});
|
||||
$(".view-finding-button-3d").each((n, el) => {
|
||||
$(el).click((e) => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
|
||||
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
|
||||
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
|
||||
importAnnotations_root(annotationjson3d);
|
||||
importViewerState_root(viewer_state_json);
|
||||
$(el).addClass("active");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% if editing_finding > 0 %}
|
||||
$("#hidden-form").show()
|
||||
setTimeout(function() {
|
||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||
}, 1000)
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if request.GET.show_finding %}
|
||||
function show_finding() {
|
||||
console.log("show finding")
|
||||
$("#finding-{{ request.GET.show_finding }}").trigger("click");
|
||||
}
|
||||
setTimeout(show_finding, 500);
|
||||
{% endif %}
|
||||
});
|
||||
$("#cancel-add-finding-button").click((e) => {
|
||||
$("#hidden-form").hide()
|
||||
$("#add-finding-button").show()
|
||||
$("#clone-finding-button").show()
|
||||
|
||||
$(document).on('submit', '#series_finding_form', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
$("#reset-viewport-button").click(() => {
|
||||
resetViewer_root();
|
||||
|
||||
});
|
||||
$(".view-finding-button-3d").each((n, el) => {
|
||||
$(el).click((e) => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
|
||||
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
|
||||
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
|
||||
importAnnotations_root(annotationjson3d);
|
||||
importViewerState_root(viewer_state_json);
|
||||
$(el).addClass("active");
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '{% url "atlas:add_finding" %}',
|
||||
data: {
|
||||
series_finding_id: {{ editing_finding }},
|
||||
description: $('#id_description').val(),
|
||||
series: {{ series.pk }},
|
||||
annotation_json_3d: window.exportAnnotations_root(),
|
||||
viewer_state_3d: window.exportViewerState_root(),
|
||||
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
|
||||
":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
|
||||
":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
|
||||
action: 'post'
|
||||
},
|
||||
success: function (json) {
|
||||
$("#finding-form").empty();
|
||||
toastr.info('Finding added.');
|
||||
location.href='{{series.get_absolute_url}}';
|
||||
},
|
||||
error: function (xhr, errmsg, err) {
|
||||
console.log(xhr.status + ": " + xhr
|
||||
.responseText); // provide a bit more info about the error to the console
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% if editing_finding > 0 %}
|
||||
$("#hidden-form").show()
|
||||
setTimeout(function() {
|
||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||
}, 1000)
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if request.GET.show_finding %}
|
||||
function show_finding() {
|
||||
console.log("show finding")
|
||||
$("#finding-{{ request.GET.show_finding }}").trigger("click");
|
||||
}
|
||||
setTimeout(show_finding, 500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
$(document).on('submit', '#series_finding_form', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '{% url "atlas:add_finding" %}',
|
||||
data: {
|
||||
series_finding_id: {{ editing_finding }},
|
||||
description: $('#id_description').val(),
|
||||
series: {{ series.pk }},
|
||||
annotation_json_3d: window.exportAnnotations_root(),
|
||||
viewer_state_3d: window.exportViewerState_root(),
|
||||
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
|
||||
":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
|
||||
":selected")
|
||||
.map((i, el) => {
|
||||
return $(el).val()
|
||||
}).toArray()),
|
||||
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
|
||||
action: 'post'
|
||||
},
|
||||
success: function (json) {
|
||||
$("#finding-form").empty();
|
||||
toastr.info('Finding added.');
|
||||
location.href='{{series.get_absolute_url}}';
|
||||
},
|
||||
error: function (xhr, errmsg, err) {
|
||||
console.log(xhr.status + ": " + xhr
|
||||
.responseText); // provide a bit more info about the error to the console
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
{% 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;
|
||||
|
||||
@@ -457,6 +457,7 @@ urlpatterns = [
|
||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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 = "<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",
|
||||
]
|
||||
|
||||
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
|
||||
- 3459:3459
|
||||
env_file:
|
||||
- ./.env.dev
|
||||
- ./.env.dev.local
|
||||
db:
|
||||
image: postgres:14.2-alpine
|
||||
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 @@
|
||||
{"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"}}
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div title="Defines if this exam is freely available to all users.">
|
||||
Open access:
|
||||
<span class="fw-bold">{{ exam.open_access }}</span>
|
||||
</div>
|
||||
@@ -121,10 +121,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header p-2">
|
||||
<div class="card-header p-2" title="Allow bulk setting of question open access status. Note: questions and exams have different open access settings.">
|
||||
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #}
|
||||
<a class="d-block d-md-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
|
||||
<span>Open access (questions)</span>
|
||||
<a class="d-block d-md-flex justify-content-between align-items-center text-decoration-none text-reset" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
|
||||
<small>Open access (questions)</small>
|
||||
<small class="text-muted">Expand to change selected questions open access status</small>
|
||||
</a>
|
||||
</div>
|
||||
@@ -133,7 +133,6 @@
|
||||
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="type" value="sbas" />
|
||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}" />
|
||||
<div class="col-12 small text-muted">Select questions on the list above (checkboxes) then click one of the actions below.</div>
|
||||
<div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end">
|
||||
<button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button>
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+7
-6
@@ -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):
|
||||
|
||||
+6
-6
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
+30
@@ -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")
|
||||
|
||||
|
||||
+9
-9
@@ -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):
|
||||
|
||||
+5
-6
@@ -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):
|
||||
|
||||
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
+44
@@ -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.<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
|
||||
+6
-6
@@ -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):
|
||||
|
||||
+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"
|
||||
Reference in New Issue
Block a user