Add API token management: implement APIToken model, views, and templates for creating and revoking tokens
This commit is contained in:
@@ -22,6 +22,7 @@ from .models import (
|
|||||||
CaseDisplaySet,
|
CaseDisplaySet,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
CidReportAnswer,
|
CidReportAnswer,
|
||||||
|
APIToken,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
@@ -142,3 +143,12 @@ class SeriesAdmin(VersionAdmin):
|
|||||||
|
|
||||||
|
|
||||||
admin.site.register(Series, SeriesAdmin)
|
admin.site.register(Series, SeriesAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class APITokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
|
||||||
|
search_fields = ("user__username", "name")
|
||||||
|
readonly_fields = ("token_hash", "created", "last_used")
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(APIToken, APITokenAdmin)
|
||||||
|
|||||||
+112
-17
@@ -18,10 +18,13 @@ from ninja.security import django_auth
|
|||||||
|
|
||||||
from ninja import NinjaAPI, File
|
from ninja import NinjaAPI, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from generic.models import Examination, Modality
|
from generic.models import Examination, Modality
|
||||||
|
|
||||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
|
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
|
||||||
from atlas.helpers import get_cases_available_to_user
|
from atlas.helpers import get_cases_available_to_user
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -30,6 +33,41 @@ from loguru import logger
|
|||||||
router = Router()
|
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):
|
class SeriesSchema(ModelSchema):
|
||||||
case_id: List[int] = []
|
case_id: List[int] = []
|
||||||
|
|
||||||
@@ -54,7 +92,7 @@ class CaseSchema(ModelSchema):
|
|||||||
fields = ["id", "title"]
|
fields = ["id", "title"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload_dicom", auth=django_auth)
|
@router.post("/upload_dicom", auth=TokenAuth())
|
||||||
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||||
uploaded = []
|
uploaded = []
|
||||||
duplicate = []
|
duplicate = []
|
||||||
@@ -87,7 +125,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate_image_hash", auth=django_auth)
|
@router.post("/generate_image_hash", auth=TokenAuth())
|
||||||
def generate_image_hash(request, id: int):
|
def generate_image_hash(request, id: int):
|
||||||
s = SeriesImage.objects.get(pk=id)
|
s = SeriesImage.objects.get(pk=id)
|
||||||
s.generate_hashes()
|
s.generate_hashes()
|
||||||
@@ -99,7 +137,7 @@ def generate_image_hash(request, id: int):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clear_dicoms", auth=django_auth)
|
@router.post("/clear_dicoms", auth=TokenAuth())
|
||||||
def clear_dicoms(request):
|
def clear_dicoms(request):
|
||||||
if "selection" in request.POST:
|
if "selection" in request.POST:
|
||||||
dicoms = UncategorisedDicom.objects.filter(
|
dicoms = UncategorisedDicom.objects.filter(
|
||||||
@@ -114,7 +152,7 @@ def clear_dicoms(request):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@router.get("/uncategorised_dicoms", auth=django_auth)
|
@router.get("/uncategorised_dicoms", auth=TokenAuth())
|
||||||
def uncategorised_dicoms(request):
|
def uncategorised_dicoms(request):
|
||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
|
|
||||||
@@ -213,7 +251,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
|
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
|
||||||
)
|
)
|
||||||
def import_dicoms(request):
|
def import_dicoms(request):
|
||||||
return import_dicoms_helper(request)
|
return import_dicoms_helper(request)
|
||||||
@@ -221,14 +259,14 @@ def import_dicoms(request):
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/import_dicoms/{case_id}",
|
"/import_dicoms/{case_id}",
|
||||||
auth=django_auth,
|
auth=TokenAuth(),
|
||||||
response=List[Tuple[SeriesSchema, str]],
|
response=List[Tuple[SeriesSchema, str]],
|
||||||
)
|
)
|
||||||
def import_dicoms_case(request, case_id: int):
|
def import_dicoms_case(request, case_id: int):
|
||||||
return import_dicoms_helper(request, case_id=case_id)
|
return import_dicoms_helper(request, case_id=case_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload_dicom_case/{case_id}", auth=django_auth, response=List[Tuple[SeriesSchema, str]])
|
@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(...)):
|
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
|
||||||
"""Upload DICOM files and immediately import them into the given case.
|
"""Upload DICOM files and immediately import them into the given case.
|
||||||
|
|
||||||
@@ -248,12 +286,12 @@ def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(..
|
|||||||
return import_dicoms_helper(request, case_id=case_id)
|
return import_dicoms_helper(request, case_id=case_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
|
||||||
def orphan_series(request):
|
def orphan_series(request):
|
||||||
return request.user.series.filter(case=None)
|
return request.user.series.filter(case=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/series_remove_duplicate_images", auth=django_auth)
|
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
|
||||||
def series_remove_duplicate_images(request, series_id: int):
|
def series_remove_duplicate_images(request, series_id: int):
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
|
||||||
@@ -270,19 +308,76 @@ def series_remove_duplicate_images(request, series_id: int):
|
|||||||
return len(dupes)
|
return len(dupes)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
|
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
|
||||||
def get_cases_user(request):
|
def get_cases_user(request):
|
||||||
return Case.objects.filter(author=request.user)
|
return Case.objects.filter(author=request.user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get_cases_available", auth=django_auth, response=List[CaseSchema])
|
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
|
||||||
def get_cases_available(request):
|
def get_cases_available(request):
|
||||||
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
||||||
qs = get_cases_available_to_user(request.user)
|
qs = get_cases_available_to_user(request.user)
|
||||||
return qs.order_by('-created_date')[:200]
|
return qs.order_by('-created_date')[:200]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
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):
|
def check_image_hash(request, hash: str):
|
||||||
try:
|
try:
|
||||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||||
@@ -297,7 +392,7 @@ def check_image_hash(request, hash: str):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check_image_hashes/", auth=django_auth)
|
@router.post("/check_image_hashes/", auth=TokenAuth())
|
||||||
def check_images_hashes(request, hashes: List[str]):
|
def check_images_hashes(request, hashes: List[str]):
|
||||||
"""Checks a list of image hashes and returns the series id / url if found
|
"""Checks a list of image hashes and returns the series id / url if found
|
||||||
|
|
||||||
@@ -345,13 +440,13 @@ def check_images_hashes(request, hashes: List[str]):
|
|||||||
# print(series_image)
|
# print(series_image)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
|
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
|
||||||
def view_dicom_tags(request, hash: str):
|
def view_dicom_tags(request, hash: str):
|
||||||
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||||
return item.get_dicom_json()
|
return item.get_dicom_json()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
@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):
|
def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
|
||||||
@@ -399,7 +494,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
|||||||
return new_series
|
return new_series
|
||||||
|
|
||||||
|
|
||||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
@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):
|
def series_order_by_tag(request, series_id: int, dicom_tag: str):
|
||||||
series = get_object_or_404(Series, pk=series_id)
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-23 11:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0093_procedure_case_procedures'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='APIToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
|
||||||
|
('token_hash', models.CharField(db_index=True, max_length=64)),
|
||||||
|
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('expires', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('revoked', models.BooleanField(default=False)),
|
||||||
|
('last_used', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'API token',
|
||||||
|
'verbose_name_plural': 'API tokens',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -230,6 +230,44 @@ class SynMixin(object):
|
|||||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class APIToken(models.Model):
|
||||||
|
"""Personal Access Token for API access.
|
||||||
|
|
||||||
|
Stores only a hash of the token. The raw token is returned once at
|
||||||
|
creation and never stored in plaintext.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
|
||||||
|
name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
|
||||||
|
token_hash = models.CharField(max_length=64, db_index=True)
|
||||||
|
scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
expires = models.DateTimeField(null=True, blank=True)
|
||||||
|
revoked = models.BooleanField(default=False)
|
||||||
|
last_used = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "API token"
|
||||||
|
verbose_name_plural = "API tokens"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"APIToken(user={self.user}, name={self.name})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_token(cls, user, name="", scopes="", expires=None):
|
||||||
|
import secrets, hashlib
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
|
||||||
|
return token, obj
|
||||||
|
|
||||||
|
def mark_used(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.last_used = timezone.now()
|
||||||
|
self.save(update_fields=["last_used"])
|
||||||
|
|
||||||
|
|
||||||
class Finding(SynMixin, models.Model):
|
class Finding(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
# New canonical/alias field: if set, this Finding is an alias and points
|
# New canonical/alias field: if set, this Finding is an alias and points
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-4">
|
||||||
|
<h3>API Tokens</h3>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if created_token %}
|
||||||
|
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
|
||||||
|
<pre class="mt-2">{{ created_token }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Your tokens</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.name }}</td>
|
||||||
|
<td>{{ t.scopes }}</td>
|
||||||
|
<td>{{ t.created }}</td>
|
||||||
|
<td>{{ t.expires }}</td>
|
||||||
|
<td>{{ t.revoked }}</td>
|
||||||
|
<td>
|
||||||
|
{% if not t.revoked %}
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -520,6 +520,7 @@ urlpatterns = [
|
|||||||
views.SeriesImagesZipView.as_view(),
|
views.SeriesImagesZipView.as_view(),
|
||||||
name="series_download",
|
name="series_download",
|
||||||
),
|
),
|
||||||
|
path("api_tokens/", views.api_tokens_page, name="api_tokens"),
|
||||||
path(
|
path(
|
||||||
"series/<int:pk>/order_dicom_instance",
|
"series/<int:pk>/order_dicom_instance",
|
||||||
views.series_order_dicom_instance,
|
views.series_order_dicom_instance,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ from .models import (
|
|||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
PrerequisiteRequired
|
PrerequisiteRequired
|
||||||
|
, APIToken
|
||||||
)
|
)
|
||||||
from .models import Procedure
|
from .models import Procedure
|
||||||
from .models import NormalCase
|
from .models import NormalCase
|
||||||
@@ -519,6 +520,52 @@ def case_search_page(request):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_tokens_page(request):
|
||||||
|
"""Simple UI to create and revoke API tokens for the logged-in user."""
|
||||||
|
created_token = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.POST.get("action")
|
||||||
|
if action == "create":
|
||||||
|
name = request.POST.get("name", "")
|
||||||
|
scopes = request.POST.get("scopes", "")
|
||||||
|
expires_days = request.POST.get("expires_days")
|
||||||
|
expires = None
|
||||||
|
if expires_days:
|
||||||
|
try:
|
||||||
|
expires = timezone.now() + timezone.timedelta(days=int(expires_days))
|
||||||
|
except Exception:
|
||||||
|
expires = None
|
||||||
|
|
||||||
|
token, obj = APIToken.create_token(request.user, name=name, scopes=scopes, expires=expires)
|
||||||
|
created_token = token
|
||||||
|
elif action == "revoke":
|
||||||
|
revoke_id = request.POST.get("revoke_id")
|
||||||
|
try:
|
||||||
|
t = APIToken.objects.get(pk=revoke_id, user=request.user)
|
||||||
|
t.revoked = True
|
||||||
|
t.save(update_fields=["revoked"])
|
||||||
|
except APIToken.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tokens_qs = APIToken.objects.filter(user=request.user).order_by("-created")
|
||||||
|
tokens = [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"scopes": t.scopes,
|
||||||
|
"created": t.created,
|
||||||
|
"expires": t.expires,
|
||||||
|
"revoked": t.revoked,
|
||||||
|
}
|
||||||
|
for t in tokens_qs
|
||||||
|
]
|
||||||
|
|
||||||
|
return render(request, "atlas/api_tokens.html", {"tokens": tokens, "created_token": created_token})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_has_case_view_access
|
@user_has_case_view_access
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
|
|||||||
@@ -90,6 +90,9 @@
|
|||||||
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
|
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
|
||||||
|
{% if request.user == user %}
|
||||||
|
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'atlas:api_tokens' %}">API tokens</a>
|
||||||
|
{% endif %}
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<a class="btn btn-outline-danger d-block" href="{% url 'admin:auth_user_change' user.pk %}" target="_blank" rel="noopener">Admin edit</a>
|
<a class="btn btn-outline-danger d-block" href="{% url 'admin:auth_user_change' user.pk %}" target="_blank" rel="noopener">Admin edit</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user