504 lines
16 KiB
Python
504 lines
16 KiB
Python
from collections import defaultdict
|
|
from typing import List, Tuple
|
|
from django.shortcuts import get_object_or_404
|
|
from django.urls import reverse
|
|
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
|
|
|
|
from ninja import NinjaAPI, File
|
|
from ninja.files import UploadedFile
|
|
import hashlib
|
|
import secrets
|
|
from django.utils import timezone
|
|
|
|
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 TokenAuth:
|
|
"""Simple bearer token auth for Ninja endpoints.
|
|
|
|
Integrates with `APIToken` model; use as `auth=TokenAuth()` on router
|
|
endpoints that should accept tokens in `Authorization: Bearer <token>`.
|
|
"""
|
|
def __call__(self, request):
|
|
# Extract header
|
|
auth = request.META.get("HTTP_AUTHORIZATION")
|
|
if not auth:
|
|
return None
|
|
parts = auth.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
return None
|
|
token = parts[1]
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
try:
|
|
api_token = APIToken.objects.get(token_hash=token_hash, revoked=False)
|
|
except APIToken.DoesNotExist:
|
|
return None
|
|
|
|
if api_token.expires and api_token.expires < timezone.now():
|
|
return None
|
|
|
|
# mark last used (best-effort)
|
|
try:
|
|
api_token.mark_used()
|
|
except Exception:
|
|
pass
|
|
|
|
# Attach token object to request for handlers
|
|
request.api_token = api_token
|
|
return api_token.user
|
|
|
|
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
|
|
)
|
|
def import_dicoms(request):
|
|
return import_dicoms_helper(request)
|
|
|
|
|
|
@router.post(
|
|
"/import_dicoms/{case_id}",
|
|
auth=TokenAuth(),
|
|
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=TokenAuth(), 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=TokenAuth(), response=List[SeriesSchema])
|
|
def orphan_series(request):
|
|
return request.user.series.filter(case=None)
|
|
|
|
|
|
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
|
|
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=TokenAuth(), response=List[CaseSchema])
|
|
def get_cases_user(request):
|
|
return Case.objects.filter(author=request.user)
|
|
|
|
|
|
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
|
|
def get_cases_available(request):
|
|
"""Return cases available to the authenticated user (via get_cases_available_to_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=TokenAuth(), 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")
|
|
|
|
|
|
@router.post("/api_tokens", auth=TokenAuth())
|
|
def create_api_token(request, payload: CreateTokenIn):
|
|
expires = None
|
|
if payload.expires_days:
|
|
expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
|
|
|
|
token, obj = APIToken.create_token(request.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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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=TokenAuth())
|
|
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}"]
|