Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ WHISPER_API_TOKEN=
WHISPER_CA_CERT=/app/certs/whisper-live.pem
# WHISPER_CONNECT_TIMEOUT=10

# Assisted segmentation (GPU worker). Server-side only; leave empty to hide the Assist tool.
# The worker's base URL, without a path: its public https:// address behind a reverse proxy,
# or its service address on a Docker network shared with web (docs/setup.md section 8).
# ASSIST_WORKER_TOKEN is the worker's WORKER_API_TOKEN.
ASSIST_WORKER_URL=
ASSIST_WORKER_TOKEN=
# ASSIST_CONNECT_TIMEOUT=10

# App server (entrypoint.sh)
# AUTO_MIGRATE=1 # set 0 to skip 'migrate' on container start
# RUN_DEV_SERVER=0 # set 1 for Django dev server with auto-reload
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,11 @@ Where the bundle fits in the system as a whole: [docs/architecture.md](docs/arch

### Third-party CDNs

CDNs are allowed. Today exactly one template uses one:
`templates/common/user_activity_stats.html` loads two pinned Chart.js scripts from
jsDelivr, both with SRI. `templates/base.html` loads **no** CDN scripts at all. Three
rules apply to anything new:
CDNs are allowed. Today two templates use one, both jsDelivr with SRI:
`templates/common/user_activity_stats.html` loads two pinned Chart.js scripts, and
`templates/laparoscopy/patient_detail_content.html` loads driver.js for the Tutorial (its
stylesheet is vendored under `static/vendor/`, because the CSP's `style-src` is `'self'`).
`templates/base.html` loads **no** CDN scripts at all. Three rules apply to anything new:

- **Webfonts stay self-hosted.** A font CDN sees every page view of every visitor,
which is a GDPR question a JavaScript library does not raise. IBM Plex and Font
Expand Down
57 changes: 52 additions & 5 deletions annotations/tests_image_segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from django.apps import apps
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase
from django.test import Client, SimpleTestCase, TestCase
from django.urls import reverse

from annotations.adapters import image_segmentation as codec
Expand Down Expand Up @@ -183,6 +183,11 @@ def setUp(self):
self.user = User.objects.create_user(username=f"u{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106
ProjectAccess.objects.create(user=self.user, project=self.project, role="annotator")
self.client.force_login(self.user)
# Labels are a project admin's to add, rename and retire.
admin = User.objects.create_user(username=f"a{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106
ProjectAccess.objects.create(user=admin, project=self.project, role="admin")
self.admin_client = Client()
self.admin_client.force_login(admin)
kw = {"patient_id": self.patient.patient_id}
self.save_url = reverse("laparoscopy:api_save_image_segmentation", kwargs=kw)
self.state_url = reverse("laparoscopy:api_image_segmentation_state", kwargs=kw)
Expand All @@ -194,12 +199,18 @@ def setUp(self):
# -- helpers ------------------------------------------------------------

def _label(self, name):
response = self.client.post(
response = self.admin_client.post(
self.labels_url, json.dumps({"name": name}), content_type="application/json"
)
self.assertEqual(response.status_code, 201, response.content)
return response.json()["label"]["code"]

def _label_url(self, code):
return reverse(
"laparoscopy:api_image_segmentation_label",
kwargs={"patient_id": self.patient.patient_id, "code": code},
)

def _state(self):
response = self.client.get(self.state_url)
self.assertEqual(response.status_code, 200)
Expand Down Expand Up @@ -233,7 +244,7 @@ def test_labels_get_stable_codes_and_distinct_colours(self):
self.assertNotEqual(labels[0]["color"], labels[1]["color"])

def test_a_duplicate_name_is_refused_case_insensitively(self):
response = self.client.post(
response = self.admin_client.post(
self.labels_url, json.dumps({"name": "liver"}), content_type="application/json"
)
self.assertEqual(response.status_code, 400)
Expand All @@ -243,7 +254,7 @@ def test_a_rename_changes_the_name_and_never_the_code_or_value(self):
"laparoscopy:api_image_segmentation_label",
kwargs={"patient_id": self.patient.patient_id, "code": self.liver},
)
response = self.client.patch(
response = self.admin_client.patch(
url, json.dumps({"name": "Hepar", "color": "#112233"}), content_type="application/json"
)
label = response.json()["label"]
Expand All @@ -254,11 +265,47 @@ def test_a_retired_label_is_kept_and_its_value_is_not_reused(self):
"laparoscopy:api_image_segmentation_label",
kwargs={"patient_id": self.patient.patient_id, "code": self.tumour},
)
self.assertFalse(self.client.delete(url).json()["label"]["active"])
self.assertFalse(self.admin_client.delete(url).json()["label"]["active"])
self.assertEqual(self._label("Spleen"), "l3", "retiring must not free a value")
labels = {label["code"]: label for label in self._state()["labels"]}
self.assertFalse(labels[self.tumour]["active"])

def test_only_a_project_admin_adds_renames_retires_or_restores_a_label(self):
url = self._label_url(self.liver)
refused = [
self.client.post(self.labels_url, json.dumps({"name": "Spleen"}), content_type="application/json"),
self.client.patch(url, json.dumps({"name": "Hepar"}), content_type="application/json"),
self.client.patch(url, json.dumps({"active": False}), content_type="application/json"),
self.client.patch(url, json.dumps({"name": "Hepar", "color": "#112233"}), content_type="application/json"),
self.client.delete(url),
]
self.assertEqual([response.status_code for response in refused], [403] * 5)
labels = self.admin_client.get(self.labels_url).json()["labels"]
self.assertEqual([(label["name"], label["active"]) for label in labels], [("Liver", True), ("Tumour", True)])
mine = {label["code"]: label["color"] for label in self._state()["labels"]}
self.assertNotEqual(mine[self.liver], "#112233", "a refused request writes nothing, colour included")

def test_a_colour_is_the_users_own(self):
shared = self.admin_client.get(self.labels_url).json()["labels"][0]["color"]
response = self.client.patch(
self._label_url(self.liver), json.dumps({"color": "#ABCDEF"}), content_type="application/json"
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["label"]["color"], "#abcdef")
mine = {label["code"]: label["color"] for label in self._state()["labels"]}
self.assertEqual(mine[self.liver], "#abcdef")
theirs = {label["code"]: label["color"] for label in self.admin_client.get(self.state_url).json()["labels"]}
self.assertEqual(theirs[self.liver], shared, "nobody else sees it")

# A viewer may pick colours too; a blank one goes back to the label's own.
ProjectAccess.objects.filter(user=self.user).update(role="viewer")
self.client.patch(self._label_url(self.liver), json.dumps({"color": ""}), content_type="application/json")
self.assertEqual({label["code"]: label["color"] for label in self._state()["labels"]}[self.liver], shared)
bad = self.client.patch(self._label_url(self.liver), json.dumps({"color": "red"}), content_type="application/json")
self.assertEqual(bad.status_code, 400)
missing = self.client.patch(self._label_url("l99"), json.dumps({"color": "#000000"}), content_type="application/json")
self.assertEqual(missing.status_code, 404)

# -- saving and reading -------------------------------------------------

def test_a_frame_round_trips_through_save_state_and_read(self):
Expand Down
35 changes: 31 additions & 4 deletions annotations/tests_quadrants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from django.apps import apps
from django.contrib.auth.models import User
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse

from annotations.models import AnnotationSet, LabelSchema
Expand Down Expand Up @@ -51,6 +51,11 @@ def setUp(self):
self.user = User.objects.create_user(username=f"u{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106
ProjectAccess.objects.create(user=self.user, project=self.project, role="annotator")
self.client.force_login(self.user)
# Quadrants are a project admin's to add, rename and retire.
admin = User.objects.create_user(username=f"a{uuid.uuid4().hex[:8]}", password="pw") # noqa: S106
ProjectAccess.objects.create(user=admin, project=self.project, role="admin")
self.admin_client = Client()
self.admin_client.force_login(admin)
kw = {"patient_id": self.patient.patient_id}
self.url = reverse("laparoscopy:api_quadrants", kwargs=kw)
self.labels_url = reverse("laparoscopy:api_quadrant_labels", kwargs=kw)
Expand All @@ -59,7 +64,7 @@ def setUp(self):
self.lower = self._label("Lower right")

def _label(self, name):
response = self.client.post(
response = self.admin_client.post(
self.labels_url, json.dumps({"name": name}), content_type="application/json"
)
self.assertEqual(response.status_code, 201, response.content)
Expand All @@ -72,7 +77,7 @@ def _put(self, markers, *, expected=None, file_id=None):
return self.client.put(self.url, json.dumps(body), content_type="application/json")

def test_the_vocabulary_is_separate_from_the_segmentation_labels(self):
self.client.post(
self.admin_client.post(
self.seg_labels_url, json.dumps({"name": "Liver"}), content_type="application/json"
)
quadrants = self.client.get(self.url).json()["labels"]
Expand Down Expand Up @@ -131,11 +136,33 @@ def test_a_retired_quadrant_keeps_its_markers(self):
"laparoscopy:api_quadrant_label",
kwargs={"patient_id": self.patient.patient_id, "code": self.upper},
)
self.assertFalse(self.client.delete(url).json()["label"]["active"])
self.assertFalse(self.admin_client.delete(url).json()["label"]["active"])
state = self.client.get(self.client.get(self.url).wsgi_request.path).json()
self.assertEqual(state["markers"], [{"timeMs": 0, "code": self.upper}])
self.assertEqual(self._put(state["markers"]).status_code, 200)

def test_quadrants_are_an_admins_but_their_colours_are_each_users(self):
url = reverse(
"laparoscopy:api_quadrant_label",
kwargs={"patient_id": self.patient.patient_id, "code": self.upper},
)
refused = [
self.client.post(self.labels_url, json.dumps({"name": "Middle"}), content_type="application/json"),
self.client.patch(url, json.dumps({"name": "Top"}), content_type="application/json"),
self.client.delete(url),
]
self.assertEqual([response.status_code for response in refused], [403] * 3)

shared = self.admin_client.get(self.url).json()["labels"][0]["color"]
response = self.client.patch(url, json.dumps({"color": "#123456"}), content_type="application/json")
self.assertEqual(response.status_code, 200, response.content)
mine = {label["code"]: label for label in self.client.get(self.url).json()["labels"]}
self.assertEqual((mine[self.upper]["name"], mine[self.upper]["color"]), ("Upper left", "#123456"))
self.assertEqual(self.admin_client.get(self.url).json()["labels"][0]["color"], shared)
# Saving markers sends the state back: still in this user's colours.
saved = self._put([{"timeMs": 0, "code": self.upper}]).json()
self.assertEqual(saved["labels"][0]["color"], "#123456")

def test_a_project_without_the_method_cannot_save(self):
self.project.annotation_methods.clear()
self.assertEqual(self._put([]).status_code, 403)
115 changes: 89 additions & 26 deletions annotations/views_image_segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,24 @@
import json

from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods, require_POST

from annotations.adapters import image_segmentation as codec
from annotations.constants import CoordinateSystem
from annotations.services import AnnotationConflict, AnnotationNotAllowed
from annotations.services import image_segmentation as service
from annotations.validators.image_segmentation import validate_time_ms
from annotations.validators.image_segmentation import validate_color, validate_time_ms
from annotations.views import (
_BadRequest,
_checked_file,
_first_message,
_patient_model,
)
from common.permissions import get_patient_for
from common.models import UserPreference
from common.permissions import get_patient_for, user_is_patient_admin


def _json_body(request):
Expand Down Expand Up @@ -58,13 +60,88 @@ def _project(patient):
return project


# --- Labels: admins manage them, every user picks their own colours ---------------------
#
# Adding, renaming, retiring and restoring a label change the project's vocabulary, so they
# are a project admin's. A colour is a viewing preference: it is stored per user in
# ``common.UserPreference.label_colors`` and swapped into every label list that user is
# sent, so the label's own colour is only the default.


def _color_key(project, vocabulary, code):
return f"{project.pk}:{vocabulary}:{code}"


def _personal_colors(user, project, vocabulary, labels):
"""``labels`` as ``user`` sees them: their own colour wherever they picked one."""
preference = UserPreference.objects.filter(user=user).first()
mine = preference.label_colors if preference else {}
if not mine or project is None:
return labels
return [
{**label, "color": mine.get(_color_key(project, vocabulary, label["code"]), label["color"])}
for label in labels
]


def _set_personal_color(user, project, vocabulary, code, color):
"""Store ``user``'s colour for one label; a blank colour goes back to the label's own."""
color = validate_color(color)
key = _color_key(project, vocabulary, code)
with transaction.atomic():
preference, _ = UserPreference.objects.select_for_update().get_or_create(user=user)
if color:
preference.label_colors[key] = color
else:
preference.label_colors.pop(key, None)
preference.save(update_fields=["label_colors", "updated_at"])


def label_detail_response(request, patient_id, code, labels, missing):
"""PATCH (rename/restore: admin; colour: anyone who can read) or DELETE (retire: admin).

``labels`` is the service that owns the vocabulary: ``create_label``, ``update_label``,
``list_labels`` and ``VOCABULARY``.
"""
patient = _patient(request, patient_id, "admin" if request.method == "DELETE" else "read")
try:
project = _project(patient)
if request.method == "DELETE":
changes, color = {"active": False}, None
else:
body = _json_body(request)
changes = {key: body[key] for key in ("name", "active") if key in body}
color = body.get("color")
if changes and not user_is_patient_admin(request.user, patient):
raise PermissionDenied("Only a project admin can rename, retire or restore labels.")
if changes:
label = labels.update_label(project, code, **changes)
else:
label = next((item for item in labels.list_labels(project) if item["code"] == code), None)
if label is None:
raise LookupError(code)
if color is not None:
_set_personal_color(request.user, project, labels.VOCABULARY, code, color)
except _BadRequest as exc:
return JsonResponse({"error": str(exc)}, status=exc.status)
except LookupError:
return JsonResponse({"error": missing}, status=404)
except ValidationError as exc:
return JsonResponse({"error": _first_message(exc)}, status=400)
[label] = _personal_colors(request.user, project, labels.VOCABULARY, [label])
return JsonResponse({"label": label})


@login_required
def image_segmentation_state_api(request, patient_id):
"""The vocabulary, the revision a save must quote, and which frames hold masks."""
patient = _patient(request, patient_id, "read")
state = service.image_segmentation_state(patient)
updated_at = state["updatedAt"]
return JsonResponse({**state, "updatedAt": updated_at.isoformat() if updated_at else None})
labels = _personal_colors(request.user, patient.project, service.VOCABULARY, state["labels"])
return JsonResponse(
{**state, "labels": labels, "updatedAt": updated_at.isoformat() if updated_at else None}
)


@login_required
Expand Down Expand Up @@ -173,12 +250,15 @@ def save_image_segmentation_api(request, patient_id):
@login_required
@require_http_methods(["GET", "POST"])
def image_segmentation_labels_api(request, patient_id):
"""List (read) or create (write) the project's labels."""
patient = _patient(request, patient_id, "read" if request.method == "GET" else "write")
"""List (read) or create (admin) the project's labels."""
patient = _patient(request, patient_id, "read" if request.method == "GET" else "admin")
try:
project = _project(patient)
if request.method == "GET":
return JsonResponse({"labels": service.list_labels(project)})
labels = service.list_labels(project)
return JsonResponse(
{"labels": _personal_colors(request.user, project, service.VOCABULARY, labels)}
)
body = _json_body(request)
label = service.create_label(
project, name=body.get("name"), color=body.get("color", "")
Expand All @@ -193,26 +273,9 @@ def image_segmentation_labels_api(request, patient_id):
@login_required
@require_http_methods(["PATCH", "DELETE"])
def image_segmentation_label_detail_api(request, patient_id, code):
"""Rename/recolour/restore (PATCH) or retire (DELETE) one label.
"""Rename/restore (PATCH, admin), recolour for yourself (PATCH) or retire (DELETE, admin).

DELETE retires rather than deletes: the label's pixels stay in every revision that has
them, and a deleted definition would leave planes that name nothing.
"""
patient = _patient(request, patient_id, "write")
try:
project = _project(patient)
if request.method == "DELETE":
changes = {"active": False}
else:
body = _json_body(request)
changes = {
key: body[key] for key in ("name", "color", "active") if key in body
}
label = service.update_label(project, code, **changes)
except _BadRequest as exc:
return JsonResponse({"error": str(exc)}, status=exc.status)
except LookupError:
return JsonResponse({"error": "No such label."}, status=404)
except ValidationError as exc:
return JsonResponse({"error": _first_message(exc)}, status=400)
return JsonResponse({"label": label})
return label_detail_response(request, patient_id, code, service, "No such label.")
Loading
Loading