644 lines
20 KiB
Python
644 lines
20 KiB
Python
from collections import defaultdict
|
|
from typing import List, Tuple
|
|
from django.shortcuts import get_object_or_404
|
|
from django.urls import reverse
|
|
import json
|
|
from django.http import JsonResponse, HttpResponse
|
|
from ninja import ModelSchema, Router, Schema, Field
|
|
|
|
from django.db import transaction, IntegrityError
|
|
|
|
# from .decorators import user_is_author_or_rapid_checker
|
|
from django.core.exceptions import PermissionDenied
|
|
import pydicom
|
|
from pydicom.errors import InvalidDicomError
|
|
|
|
from generic.decorators import check_user_in_group
|
|
from generic.constants import Group
|
|
|
|
from ninja.security import django_auth, HttpBearer
|
|
from ninja.errors import HttpError
|
|
|
|
from ninja import NinjaAPI, File
|
|
from ninja.files import UploadedFile
|
|
import hashlib
|
|
import secrets
|
|
from django.utils import timezone
|
|
from django.contrib.auth import authenticate
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.middleware.csrf import CsrfViewMiddleware
|
|
|
|
from generic.models import Examination, Modality
|
|
|
|
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
|
|
from atlas.helpers import get_cases_available_to_user
|
|
|
|
from loguru import logger
|
|
|
|
|
|
router = Router()
|
|
|
|
|
|
class BearerAuth(HttpBearer):
|
|
"""Ninja HttpBearer auth that validates APIToken entries.
|
|
|
|
Returns the token owner user on success or raises HttpError(401) on failure.
|
|
"""
|
|
def authenticate(self, request, token: str):
|
|
if not token:
|
|
raise HttpError(status_code=401)
|
|
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
try:
|
|
api_token = APIToken.objects.get(token_hash=token_hash)
|
|
except APIToken.DoesNotExist:
|
|
raise HttpError(status_code=401)
|
|
|
|
if api_token.revoked:
|
|
raise HttpError(status_code=401)
|
|
|
|
if api_token.expires and api_token.expires < timezone.now():
|
|
raise HttpError(status_code=401)
|
|
|
|
try:
|
|
api_token.mark_used()
|
|
except Exception:
|
|
pass
|
|
|
|
# Attach for downstream handlers
|
|
request.api_token = api_token
|
|
return api_token.user
|
|
|
|
|
|
class SessionOrBearer:
|
|
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
|
|
|
|
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
|
|
"""
|
|
def __call__(self, request):
|
|
# Session-backed auth already populated by Django middleware
|
|
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
|
return request.user
|
|
|
|
# Try bearer token from header
|
|
auth = request.META.get("HTTP_AUTHORIZATION")
|
|
if not auth:
|
|
raise HttpError(status_code=401)
|
|
parts = auth.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
raise HttpError(status_code=401)
|
|
token = parts[1]
|
|
# reuse BearerAuth logic
|
|
bearer = BearerAuth()
|
|
return bearer.authenticate(request, token)
|
|
|
|
# Backwards-compatible alias used in existing decorators
|
|
TokenAuth = SessionOrBearer
|
|
|
|
|
|
class SeriesSchema(ModelSchema):
|
|
case_id: List[int] = []
|
|
|
|
class Meta:
|
|
model = Series
|
|
fields = [
|
|
"id",
|
|
"modality",
|
|
"examination",
|
|
"series_instance_uid",
|
|
"author",
|
|
]
|
|
|
|
@staticmethod
|
|
def resolve_case_id(obj):
|
|
return [i.id for i in obj.case.all()]
|
|
|
|
|
|
class CaseSchema(ModelSchema):
|
|
class Meta:
|
|
model = Case
|
|
fields = ["id", "title"]
|
|
|
|
|
|
@router.post("/upload_dicom", auth=BearerAuth())
|
|
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
|
uploaded = []
|
|
duplicate = []
|
|
failed = []
|
|
duplicate_series = set()
|
|
for file in files:
|
|
# data = file.read()
|
|
try:
|
|
ud = UncategorisedDicom(image=file, user=request.user)
|
|
|
|
ud.save()
|
|
|
|
uploaded.append((file.name, ud.image_blake3_hash))
|
|
except DuplicateDicom as e:
|
|
duplicate.append((file.name, ud.image_blake3_hash))
|
|
|
|
|
|
if type(e.duplicate) == SeriesImage:
|
|
duplicate_series.add(e.duplicate.series.get_absolute_url())
|
|
|
|
pass
|
|
except InvalidDicomError:
|
|
failed.append(file.name)
|
|
|
|
return {
|
|
"uploaded": uploaded,
|
|
"duplicates": duplicate,
|
|
"failed": failed,
|
|
"duplicate_series": list(duplicate_series),
|
|
}
|
|
|
|
|
|
@router.post("/generate_image_hash", auth=BearerAuth())
|
|
def generate_image_hash(request, id: int):
|
|
s = SeriesImage.objects.get(pk=id)
|
|
s.generate_hashes()
|
|
|
|
return {
|
|
"md5": s.image_md5_hash,
|
|
"blake3": s.image_blake3_hash,
|
|
"is_dicom": s.is_dicom,
|
|
}
|
|
|
|
|
|
@router.post("/clear_dicoms", auth=BearerAuth())
|
|
def clear_dicoms(request):
|
|
if "selection" in request.POST:
|
|
dicoms = UncategorisedDicom.objects.filter(
|
|
series_instance_uid__in=request.POST.getlist("selection")
|
|
)
|
|
|
|
else:
|
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
|
|
|
dicoms.delete()
|
|
|
|
return True
|
|
|
|
|
|
@router.get("/uncategorised_dicoms", auth=BearerAuth())
|
|
def uncategorised_dicoms(request):
|
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
|
|
|
data = defaultdict(list)
|
|
for d in dicoms:
|
|
tags = d.get_basic_dicom_tags()
|
|
|
|
data[tags["SeriesInstanceUID"]].append(tags)
|
|
data["image_hash"].append(d.image_blake3_hash)
|
|
|
|
return data
|
|
|
|
|
|
def import_dicoms_helper(request, case_id: int | None = None):
|
|
if "selection" in request.POST:
|
|
dicoms = UncategorisedDicom.objects.filter(
|
|
series_instance_uid__in=request.POST.getlist("selection")
|
|
)
|
|
else:
|
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
|
|
|
if "order-series" in request.POST:
|
|
order = request.POST["order-series"]
|
|
else:
|
|
order = None
|
|
|
|
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
|
|
series_map = {}
|
|
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
|
tags = d.basic_dicom_tags
|
|
series_uid = tags["SeriesInstanceUID"]
|
|
|
|
if series_uid not in series_map:
|
|
# Get or create Series and related objects as needed
|
|
try:
|
|
modality = Modality.objects.get(short_code=tags["Modality"])
|
|
except Modality.DoesNotExist:
|
|
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
|
|
|
|
series_tags = tags # Save the first tag for this series
|
|
try:
|
|
with transaction.atomic():
|
|
series, created = Series.objects.get_or_create(
|
|
series_instance_uid=series_uid,
|
|
defaults={
|
|
"modality": modality,
|
|
"description": tags.get("SeriesDescription", ""),
|
|
}
|
|
)
|
|
if created and tags.get("StudyDescription"):
|
|
examination, created_exam = Examination.objects.get_or_create(
|
|
examination=tags["StudyDescription"]
|
|
)
|
|
if created_exam:
|
|
examination.modality = modality
|
|
examination.save()
|
|
series.examination = examination
|
|
series.save()
|
|
except IntegrityError:
|
|
series = Series.objects.get(series_instance_uid=series_uid)
|
|
|
|
# Add author and case if needed
|
|
series.author.add(request.user)
|
|
if case_id is not None:
|
|
case_object = get_object_or_404(Case, pk=case_id)
|
|
try:
|
|
series.case.add(case_object)
|
|
except IntegrityError:
|
|
pass
|
|
|
|
series_map[series_uid] = series
|
|
else:
|
|
series = series_map[series_uid]
|
|
|
|
# Create and save SeriesImage immediately
|
|
series_image = SeriesImage(
|
|
image=d.image,
|
|
series=series,
|
|
is_dicom=True,
|
|
image_blake3_hash=d.image_blake3_hash,
|
|
)
|
|
series_image.save()
|
|
d.delete() # Remove UncategorisedDicom immediately
|
|
|
|
# Optionally, re-order series if needed
|
|
for series in series_map.values():
|
|
match order:
|
|
case "order-series-instance-number":
|
|
series.order_by_dicom("InstanceNumber")
|
|
case "order-series-slice-location":
|
|
series.order_by_dicom("SliceLocation")
|
|
case _:
|
|
pass
|
|
|
|
return [(series, series.get_link()) for series in series_map.values()]
|
|
|
|
|
|
@router.post(
|
|
"/import_dicoms", auth=BearerAuth(), response=List[Tuple[SeriesSchema, str]]
|
|
)
|
|
def import_dicoms(request):
|
|
return import_dicoms_helper(request)
|
|
|
|
|
|
@router.post(
|
|
"/import_dicoms/{case_id}",
|
|
auth=BearerAuth(),
|
|
response=List[Tuple[SeriesSchema, str]],
|
|
)
|
|
def import_dicoms_case(request, case_id: int):
|
|
return import_dicoms_helper(request, case_id=case_id)
|
|
|
|
|
|
@router.post("/upload_dicom_case/{case_id}", auth=BearerAuth(), response=List[Tuple[SeriesSchema, str]])
|
|
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
|
|
"""Upload DICOM files and immediately import them into the given case.
|
|
|
|
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
|
|
then calls the existing import helper which will create Series/SeriesImage
|
|
objects and attach them to the specified case.
|
|
"""
|
|
for file in files:
|
|
try:
|
|
ud = UncategorisedDicom(image=file, user=request.user)
|
|
ud.save()
|
|
except DuplicateDicom:
|
|
# skip duplicates
|
|
continue
|
|
|
|
# Now reuse import helper which will consume UncategorisedDicom for this user
|
|
return import_dicoms_helper(request, case_id=case_id)
|
|
|
|
|
|
@router.get("/orphan_series", auth=BearerAuth(), response=List[SeriesSchema])
|
|
def orphan_series(request):
|
|
return request.user.series.filter(case=None)
|
|
|
|
|
|
@router.get("/series_remove_duplicate_images", auth=BearerAuth())
|
|
def series_remove_duplicate_images(request, series_id: int):
|
|
series = get_object_or_404(Series, pk=series_id)
|
|
|
|
img_ids = set()
|
|
dupes = set()
|
|
for series_image in series.images.all():
|
|
if series_image.image_blake3_hash in img_ids:
|
|
dupes.add(series_image.id)
|
|
img_ids.add(series_image.image_blake3_hash)
|
|
|
|
if dupes:
|
|
SeriesImage.objects.filter(id__in=dupes).delete()
|
|
|
|
return len(dupes)
|
|
|
|
|
|
@router.get("/get_cases_user", auth=BearerAuth(), response=List[CaseSchema])
|
|
def get_cases_user(request):
|
|
return Case.objects.filter(author=request.user)
|
|
|
|
|
|
@router.get("/get_cases_available", auth=BearerAuth(), response=List[CaseSchema])
|
|
def get_cases_available(request):
|
|
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
|
logger.error(f"Getting cases available to user {request.user}")
|
|
qs = get_cases_available_to_user(request.user)
|
|
return qs.order_by('-created_date')[:200]
|
|
|
|
|
|
class APITokenOut(Schema):
|
|
id: int
|
|
name: str
|
|
scopes: str = ""
|
|
created: str
|
|
expires: str | None = None
|
|
revoked: bool = False
|
|
last_used: str | None = None
|
|
|
|
|
|
@router.get("/api_tokens", auth=BearerAuth(), response=List[APITokenOut])
|
|
def list_api_tokens(request):
|
|
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
|
|
out = []
|
|
for t in tokens:
|
|
out.append(
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"scopes": t.scopes,
|
|
"created": t.created.isoformat(),
|
|
"expires": t.expires.isoformat() if t.expires else None,
|
|
"revoked": t.revoked,
|
|
"last_used": t.last_used.isoformat() if t.last_used else None,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
class CreateTokenIn(Schema):
|
|
name: str = Field("", description="Friendly name for token")
|
|
scopes: str = Field("", description="Space-separated scopes")
|
|
expires_days: int | None = Field(None, description="Expire after N days")
|
|
|
|
|
|
class TokenAuthIn(Schema):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@csrf_exempt
|
|
@router.post("/create_api_token")
|
|
def token_auth(request, payload: TokenAuthIn):
|
|
"""Authenticate username/password and return a new API token.
|
|
|
|
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
|
|
"""
|
|
user = authenticate(request, username=payload.username, password=payload.password)
|
|
if user is None:
|
|
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
|
|
|
|
# Create a new token for this login (no scopes by default)
|
|
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
|
|
|
|
return {"token": token, "id": obj.id, "username": user.username}
|
|
|
|
|
|
@csrf_exempt
|
|
@router.post("/token_check")
|
|
def token_check(request):
|
|
"""Check whether a token is valid.
|
|
|
|
Accepts either `Authorization: Bearer <token>` header or POST JSON
|
|
body `{ "token": "<token>" }`.
|
|
|
|
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
|
|
`scopes`, `expires`, `revoked`.
|
|
"""
|
|
auth = request.META.get("HTTP_AUTHORIZATION")
|
|
token_obj = None
|
|
|
|
# If header present, validate bearer token explicitly
|
|
if auth:
|
|
parts = auth.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
return JsonResponse({"valid": False}, status=401)
|
|
token = parts[1]
|
|
try:
|
|
bearer = BearerAuth()
|
|
user = bearer.authenticate(request, token)
|
|
token_obj = getattr(request, "api_token", None)
|
|
except HttpError:
|
|
return JsonResponse({"valid": False}, status=401)
|
|
else:
|
|
# try JSON body
|
|
try:
|
|
data = json.loads(request.body.decode("utf-8")) if request.body else {}
|
|
except Exception:
|
|
data = {}
|
|
token = data.get("token")
|
|
if token:
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
try:
|
|
t = APIToken.objects.get(token_hash=token_hash)
|
|
token_obj = t
|
|
except APIToken.DoesNotExist:
|
|
token_obj = None
|
|
|
|
if not token_obj:
|
|
return JsonResponse({"valid": False}, status=401)
|
|
|
|
# Validate token state
|
|
valid = True
|
|
if token_obj.revoked:
|
|
valid = False
|
|
if token_obj.expires and token_obj.expires < timezone.now():
|
|
valid = False
|
|
|
|
if not valid:
|
|
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"valid": True,
|
|
"id": token_obj.id,
|
|
"username": token_obj.user.username,
|
|
"scopes": token_obj.scopes,
|
|
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
|
|
"revoked": token_obj.revoked,
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
|
|
#@csrf_exempt
|
|
#@router.post("/create_api_token")
|
|
#def create_api_token(request, payload: CreateTokenIn):
|
|
# """Create an API token.
|
|
#
|
|
# This endpoint accepts either normal session auth (`request.user`) or
|
|
# a bearer token. If no authenticated user is found a 403 is returned.
|
|
# """
|
|
# # If caller is not using Authorization header, enforce CSRF via middleware
|
|
# if not request.META.get("HTTP_AUTHORIZATION"):
|
|
# mw = CsrfViewMiddleware()
|
|
# resp = mw.process_view(request, create_api_token, (), {})
|
|
# if resp is not None:
|
|
# return resp
|
|
# # Determine authenticated user: prefer session auth, fall back to token auth
|
|
# user = None
|
|
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
|
# user = request.user
|
|
# else:
|
|
# user = TokenAuth()(request)
|
|
#
|
|
# if not user:
|
|
# raise PermissionDenied()
|
|
#
|
|
# expires = None
|
|
# if payload.expires_days:
|
|
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
|
|
#
|
|
# token, obj = APIToken.create_token(user, name=payload.name, scopes=payload.scopes, expires=expires)
|
|
# # Return raw token once — callers must store it
|
|
# return {"token": token, "id": obj.id}
|
|
|
|
|
|
@router.post("/api_tokens/{token_id}/revoke", auth=BearerAuth())
|
|
def revoke_api_token(request, token_id: int):
|
|
try:
|
|
t = APIToken.objects.get(pk=token_id, user=request.user)
|
|
except APIToken.DoesNotExist:
|
|
return {"status": "not found"}
|
|
t.revoked = True
|
|
t.save(update_fields=["revoked"])
|
|
return {"status": "revoked"}
|
|
|
|
|
|
@router.get("/check_image_hash/{hash}", auth=BearerAuth())
|
|
def check_image_hash(request, hash: str):
|
|
try:
|
|
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
|
data = {
|
|
"status": "success",
|
|
"id": series_image.pk,
|
|
"url": series_image.series.get_absolute_url(),
|
|
}
|
|
return data
|
|
except SeriesImage.DoesNotExist:
|
|
data = {"status": "success", "id": False}
|
|
return data
|
|
|
|
|
|
@router.post("/check_image_hashes/", auth=BearerAuth())
|
|
def check_images_hashes(request, hashes: List[str]):
|
|
"""Checks a list of image hashes and returns the series id / url if found
|
|
|
|
Return format
|
|
{ "hash_id": {"id": "series_id|false", "url": "series_url|false"}, ...}
|
|
"""
|
|
|
|
hash_status = {}
|
|
|
|
for hash in hashes:
|
|
try:
|
|
# TOOD also check against uncategorised dicoms?
|
|
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
|
data = {
|
|
"id": series_image.pk,
|
|
"url": series_image.series.get_absolute_url(),
|
|
"type": "series",
|
|
}
|
|
except SeriesImage.DoesNotExist:
|
|
try:
|
|
uncategorised_dicom = UncategorisedDicom.objects.get(
|
|
image_blake3_hash=hash
|
|
)
|
|
data = {
|
|
"id": uncategorised_dicom.pk,
|
|
"url": reverse("atlas:user_uploads"),
|
|
"type": "uncategorised",
|
|
}
|
|
except UncategorisedDicom.DoesNotExist:
|
|
data = {"id": False, "url": False}
|
|
|
|
hash_status[hash] = data
|
|
|
|
return hash_status
|
|
|
|
|
|
# @router.get("/generate_image_hash/{id}", auth=django_auth)
|
|
# def generate_image_hash(request, id: int):
|
|
# series_image = SeriesImage.objects.get(pk=id)
|
|
#
|
|
# series_image.image_blake3_hash = ""
|
|
#
|
|
# series_image.save()
|
|
#
|
|
# print(series_image)
|
|
|
|
|
|
@router.get("/view_dicom_tags/{hash}", auth=BearerAuth())
|
|
def view_dicom_tags(request, hash: str):
|
|
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
|
return item.get_dicom_json()
|
|
|
|
|
|
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=BearerAuth())
|
|
def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
|
series = get_object_or_404(Series, pk=series_id)
|
|
|
|
if not series.check_user_can_edit(request.user):
|
|
return {"status": "permission denied"}
|
|
|
|
if dicom_tag.startswith("(") and dicom_tag.endswith(")"):
|
|
dicom_tag = tuple([hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")])
|
|
|
|
image_map = defaultdict(list)
|
|
|
|
# Use iterator to avoid loading all images into memory
|
|
for image in series.images.iterator():
|
|
ds = image.get_dicom_data()
|
|
if dicom_tag in ds:
|
|
val = ds[dicom_tag].value
|
|
if isinstance(val, pydicom.multival.MultiValue):
|
|
key = ",".join(map(str, val))
|
|
else:
|
|
key = str(val)
|
|
image_map[key].append(image)
|
|
else:
|
|
return [f"{dicom_tag} not found {image}"]
|
|
|
|
case = list(series.case.all())
|
|
authors = list(series.author.all())
|
|
new_series = []
|
|
|
|
# Use a transaction for all DB changes
|
|
with transaction.atomic():
|
|
for n, (option, images) in enumerate(image_map.items()):
|
|
if n == 0:
|
|
# Update the original series
|
|
series.images.set(images)
|
|
new_series.append(series.pk)
|
|
else:
|
|
# Clone the series
|
|
series.pk = None
|
|
series.save()
|
|
series.case.set(case)
|
|
series.author.set(authors)
|
|
series.images.set(images)
|
|
new_series.append(series.pk)
|
|
|
|
return new_series
|
|
|
|
|
|
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=BearerAuth())
|
|
def series_order_by_tag(request, series_id: int, dicom_tag: str):
|
|
series = get_object_or_404(Series, pk=series_id)
|
|
|
|
series.order_by_dicom(dicom_tag)
|
|
|
|
return [f"{series} ordered by {dicom_tag}"]
|