From 95701bfafca00cd015d56b316351dd578ca90011 Mon Sep 17 00:00:00 2001 From: Maximilian Gutzmann Date: Sun, 16 Nov 2025 15:48:52 +0000 Subject: [PATCH 1/5] added session model & fixed error with query image api --- adit/dicom_web/admin.py | 33 +- adit/dicom_web/apps.py | 6 + adit/dicom_web/migrations/0004_apisession.py | 41 ++ adit/dicom_web/models.py | 18 + adit/dicom_web/views.py | 459 +++++++++++-------- 5 files changed, 366 insertions(+), 191 deletions(-) create mode 100644 adit/dicom_web/migrations/0004_apisession.py diff --git a/adit/dicom_web/admin.py b/adit/dicom_web/admin.py index 27fe94a66..1a56b9f25 100644 --- a/adit/dicom_web/admin.py +++ b/adit/dicom_web/admin.py @@ -1,5 +1,36 @@ from django.contrib import admin -from .models import DicomWebSettings +from .models import APISession, DicomWebSettings admin.site.register(DicomWebSettings, admin.ModelAdmin) + + +class APISessionAdmin(admin.ModelAdmin): + list_display = ( + "get_owner", + "id", + "time_opened", + "get_size", + "transfer_size", + "request_type", + ) + list_filter = ("time_opened", "owner") + search_fields = ("owner__username",) + + def get_owner(self, obj): + return obj.owner.username + + get_owner.admin_order_field = "owner__username" + get_owner.short_description = "Owner" + + def get_size(self, obj): + size = obj.transfer_size + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024: + return f"{size:.2f} {unit}" + size /= 1024 + + get_size.short_description = "Transfer Size" + + +admin.site.register(APISession, APISessionAdmin) diff --git a/adit/dicom_web/apps.py b/adit/dicom_web/apps.py index edb56e9f5..950da7c75 100644 --- a/adit/dicom_web/apps.py +++ b/adit/dicom_web/apps.py @@ -14,3 +14,9 @@ def init_db(**kwargs): if not DicomWebSettings.objects.exists(): DicomWebSettings.objects.create() + + +def collect_top_sessions(): + from .models import APISession + + return list(APISession.objects.order_by("-time_opened"))[:5] diff --git a/adit/dicom_web/migrations/0004_apisession.py b/adit/dicom_web/migrations/0004_apisession.py new file mode 100644 index 000000000..4f58edeed --- /dev/null +++ b/adit/dicom_web/migrations/0004_apisession.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.8 on 2025-11-16 15:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dicom_web", "0003_add_dicom_web_permissions"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="APISession", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("time_opened", models.DateTimeField(auto_now_add=True)), + ("transfer_size", models.IntegerField(default=0)), + ("request_type", models.CharField(max_length=50)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_sessions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/adit/dicom_web/models.py b/adit/dicom_web/models.py index 0b74f47ba..d7672cdb3 100644 --- a/adit/dicom_web/models.py +++ b/adit/dicom_web/models.py @@ -1,3 +1,6 @@ +from django.conf import settings +from django.db import models + from adit.core.models import DicomAppSettings @@ -9,3 +12,18 @@ class Meta: ("can_retrieve", "Can retrieve"), ("can_store", "Can store"), ] + + +class APISession(models.Model): + time_opened = models.DateTimeField(auto_now_add=True) + transfer_size = models.IntegerField(default=0) + request_type = models.CharField(max_length=50) + owner_id: int + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="api_sessions", + ) + + def __str__(self) -> str: + return f"{self.__class__.__name__} [{self.pk}]" diff --git a/adit/dicom_web/views.py b/adit/dicom_web/views.py index e519b5dbf..0dc00c697 100644 --- a/adit/dicom_web/views.py +++ b/adit/dicom_web/views.py @@ -1,4 +1,5 @@ import logging +from contextlib import asynccontextmanager from typing import AsyncIterator, Literal, Union, cast from adit_radis_shared.common.types import AuthenticatedApiRequest, User @@ -15,6 +16,7 @@ from adit.core.models import DicomServer from adit.core.utils.dicom_dataset import QueryDataset +from adit.dicom_web.models import APISession from adit.dicom_web.utils.peekable import AsyncPeekable from adit.dicom_web.validators import ( validate_pseudonym, @@ -60,13 +62,38 @@ async def _get_dicom_server( f'Server with AE title "{ae_title}" does not exist or is not accessible.' ) + @database_sync_to_async + def _create_session(self, user: User, request_type: str) -> APISession: + return APISession.objects.create( + owner=user, + request_type=request_type, + ) + + @database_sync_to_async + def _finalize_session(self, session: APISession, transfer_size: int = 0) -> None: + if transfer_size is not None: + session.transfer_size = transfer_size + session.save(update_fields=["transfer_size"]) + + @asynccontextmanager + async def track_session(self, user: User, request_type: str): + """Context manager to track API session for the duration of a request.""" + session = await self._create_session(user, request_type) + try: + yield session + finally: + pass + # TODO: respect permission can_query class QueryAPIView(WebDicomAPIView): renderer_classes = [QidoApplicationDicomJsonRenderer] def _extract_query_parameters( - self, request: AuthenticatedApiRequest, study_uid: str | None = None + self, + request: AuthenticatedApiRequest, + study_uid: str | None = None, + series_uid: str | None = None, ) -> tuple[QueryDataset, Union[int, None]]: """ Filters a valid DICOMweb requests GET parameters and returns a QueryDataset and a @@ -83,6 +110,9 @@ def _extract_query_parameters( if study_uid is not None: query["StudyInstanceUID"] = study_uid + if series_uid is not None: + query["SeriesInstanceUID"] = series_uid + limit_results = None if "limit" in query: limit_results = int(query.pop("limit")) @@ -98,48 +128,57 @@ async def get( request: AuthenticatedApiRequest, ae_title: str, ) -> Response: - query_ds, limit_results = self._extract_query_parameters(request) - source_server = await self._get_dicom_server(request, ae_title, "source") + async with self.track_session(request.user, "QIDO_STUDIES") as session: + query_ds, limit_results = self._extract_query_parameters(request) + source_server = await self._get_dicom_server(request, ae_title, "source") - try: - results = await qido_find(source_server, query_ds, limit_results, "STUDY") - except ValueError as err: - logger.warning(f"Invalid DICOMweb study query - {err}") - raise ValidationError(str(err)) from err + try: + results = await qido_find(source_server, query_ds, limit_results, "STUDY") + except ValueError as err: + logger.warning(f"Invalid DICOMweb study query - {err}") + raise ValidationError(str(err)) from err - return Response([result.dataset.to_json_dict() for result in results]) + response_data = [result.dataset.to_json_dict() for result in results] + await self._finalize_session(session, transfer_size=len(str(response_data))) + return Response(response_data) class QuerySeriesAPIView(QueryAPIView): async def get( self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str ) -> Response: - query_ds, limit_results = self._extract_query_parameters(request, study_uid) - source_server = await self._get_dicom_server(request, ae_title, "source") + async with self.track_session(request.user, "QIDO_SERIES") as session: + query_ds, limit_results = self._extract_query_parameters(request, study_uid) + source_server = await self._get_dicom_server(request, ae_title, "source") - try: - results = await qido_find(source_server, query_ds, limit_results, "SERIES") - except ValueError as err: - logger.warning(f"Invalid DICOMweb series query - {err}") - raise ValidationError(str(err)) from err + try: + results = await qido_find(source_server, query_ds, limit_results, "SERIES") + except ValueError as err: + logger.warning(f"Invalid DICOMweb series query - {err}") + raise ValidationError(str(err)) from err - return Response([result.dataset.to_json_dict() for result in results]) + response_data = [result.dataset.to_json_dict() for result in results] + await self._finalize_session(session, transfer_size=len(str(response_data))) + return Response(response_data) class QueryImagesAPIView(QueryAPIView): async def get( - self, request: AuthenticatedApiRequest, ae_title: str, series_uid: str + self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str, series_uid: str ) -> Response: - query_ds, limit_results = self._extract_query_parameters(request, series_uid) - source_server = await self._get_dicom_server(request, ae_title, "source") + async with self.track_session(request.user, "QIDO_IMAGES") as session: + query_ds, limit_results = self._extract_query_parameters(request, study_uid, series_uid) + source_server = await self._get_dicom_server(request, ae_title, "source") - try: - results = await qido_find(source_server, query_ds, limit_results, "IMAGE") - except ValueError as err: - logger.warning(f"Invalid DICOMweb image query - {err}") - raise ValidationError(str(err)) from err + try: + results = await qido_find(source_server, query_ds, limit_results, "IMAGE") + except ValueError as err: + logger.warning(f"Invalid DICOMweb image query - {err}") + raise ValidationError(str(err)) from err - return Response([result.dataset.to_json_dict() for result in results]) + response_data = [result.dataset.to_json_dict() for result in results] + await self._finalize_session(session, transfer_size=len(str(response_data))) + return Response(response_data) # TODO: respect permission can_retrieve @@ -225,116 +264,147 @@ def _get_trial_protocol_name(self, request: AuthenticatedApiRequest) -> str | No return trial_protocol_name +class _StreamingSessionWrapper: + """Wraps streaming content to track transfer size asynchronously.""" + + def __init__(self, renderer_generator, session: APISession, finalize_func): + self.renderer_generator = renderer_generator + self.session = session + self.finalize_func = finalize_func + self.total_size = 0 + + async def __aiter__(self): + async for chunk in self.renderer_generator: + self.total_size += len(chunk) if isinstance(chunk, bytes) else len(chunk.encode()) + yield chunk + + await self.finalize_func(self.session, transfer_size=self.total_size) + + class RetrieveStudyAPIView(RetrieveAPIView): async def get( self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str ) -> StreamingHttpResponse: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - - images = wado_retrieve( - source_server, - query, - "STUDY", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - images = await self.peek_images(images) + async with self.track_session(request.user, "WADO_STUDY") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return StreamingHttpResponse( - streaming_content=renderer.render(images), - content_type=renderer.content_type, - ) + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + + images = wado_retrieve( + source_server, + query, + "STUDY", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + images = await self.peek_images(images) + + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return StreamingHttpResponse( + streaming_content=_StreamingSessionWrapper( + renderer.render(images), session, self._finalize_session + ), + content_type=renderer.content_type, + ) class RetrieveStudyMetadataAPIView(RetrieveStudyAPIView): async def get( self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str ) -> Response: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - - images = wado_retrieve( - source_server, - query, - "STUDY", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - metadata = await self.extract_metadata(images) + async with self.track_session(request.user, "WADO_STUDY_METADATA") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) + + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + + images = wado_retrieve( + source_server, + query, + "STUDY", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + metadata = await self.extract_metadata(images) + + response_data = {"metadata": metadata} + await self._finalize_session(session, transfer_size=len(str(response_data))) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return Response({"metadata": metadata}, content_type=renderer.content_type) + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return Response(response_data, content_type=renderer.content_type) class RetrieveSeriesAPIView(RetrieveAPIView): async def get( self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str, series_uid: str ) -> Response | StreamingHttpResponse: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - query["SeriesInstanceUID"] = series_uid - - images = wado_retrieve( - source_server, - query, - "SERIES", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - images = await self.peek_images(images) + async with self.track_session(request.user, "WADO_SERIES") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return StreamingHttpResponse( - streaming_content=renderer.render(images), - content_type=renderer.content_type, - ) + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + query["SeriesInstanceUID"] = series_uid + + images = wado_retrieve( + source_server, + query, + "SERIES", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + images = await self.peek_images(images) + + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return StreamingHttpResponse( + streaming_content=_StreamingSessionWrapper( + renderer.render(images), session, self._finalize_session + ), + content_type=renderer.content_type, + ) class RetrieveSeriesMetadataAPIView(RetrieveSeriesAPIView): async def get( self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str, series_uid: str ) -> Response: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - query["SeriesInstanceUID"] = series_uid - - images = wado_retrieve( - source_server, - query, - "SERIES", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - metadata = await self.extract_metadata(images) + async with self.track_session(request.user, "WADO_SERIES_METADATA") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return Response({"metadata": metadata}, content_type=renderer.content_type) + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + query["SeriesInstanceUID"] = series_uid + + images = wado_retrieve( + source_server, + query, + "SERIES", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + metadata = await self.extract_metadata(images) + + response_data = {"metadata": metadata} + await self._finalize_session(session, transfer_size=len(str(response_data))) + + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return Response(response_data, content_type=renderer.content_type) class RetrieveImageAPIView(RetrieveAPIView): @@ -346,31 +416,34 @@ async def get( series_uid: str, image_uid: str, ) -> Response | StreamingHttpResponse: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - query["SeriesInstanceUID"] = series_uid - query["SOPInstanceUID"] = image_uid - - images = wado_retrieve( - source_server, - query, - "IMAGE", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - images = await self.peek_images(images) + async with self.track_session(request.user, "WADO_IMAGE") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return StreamingHttpResponse( - streaming_content=renderer.render(images), - content_type=renderer.content_type, - ) + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + query["SeriesInstanceUID"] = series_uid + query["SOPInstanceUID"] = image_uid + + images = wado_retrieve( + source_server, + query, + "IMAGE", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + images = await self.peek_images(images) + + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return StreamingHttpResponse( + streaming_content=_StreamingSessionWrapper( + renderer.render(images), session, self._finalize_session + ), + content_type=renderer.content_type, + ) class RetrieveImageMetadataAPIView(RetrieveImageAPIView): @@ -382,28 +455,32 @@ async def get( series_uid: str, image_uid: str, ) -> Response: - source_server = await self._get_dicom_server(request, ae_title) - pseudonym = self._get_pseudonym(request) - trial_protocol_id = self._get_trial_protocol_id(request) - trial_protocol_name = self._get_trial_protocol_name(request) - - query = self.query.copy() - query["StudyInstanceUID"] = study_uid - query["SeriesInstanceUID"] = series_uid - query["SOPInstanceUID"] = image_uid - - images = wado_retrieve( - source_server, - query, - "IMAGE", - pseudonym=pseudonym, - trial_protocol_id=trial_protocol_id, - trial_protocol_name=trial_protocol_name, - ) - metadata = await self.extract_metadata(images) + async with self.track_session(request.user, "WADO_IMAGE_METADATA") as session: + source_server = await self._get_dicom_server(request, ae_title) + pseudonym = self._get_pseudonym(request) + trial_protocol_id = self._get_trial_protocol_id(request) + trial_protocol_name = self._get_trial_protocol_name(request) + + query = self.query.copy() + query["StudyInstanceUID"] = study_uid + query["SeriesInstanceUID"] = series_uid + query["SOPInstanceUID"] = image_uid + + images = wado_retrieve( + source_server, + query, + "IMAGE", + pseudonym=pseudonym, + trial_protocol_id=trial_protocol_id, + trial_protocol_name=trial_protocol_name, + ) + metadata = await self.extract_metadata(images) - renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) - return Response({"metadata": metadata}, content_type=renderer.content_type) + response_data = {"metadata": metadata} + await self._finalize_session(session, transfer_size=len(str(response_data))) + + renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer")) + return Response(response_data, content_type=renderer.content_type) # TODO: respect permission can_store @@ -416,43 +493,45 @@ async def post( ae_title: str, study_uid: str = "", ) -> Response: - content_type: str = request.content_type - if not media_type_matches("multipart/related; type=application/dicom", content_type): - raise UnsupportedMediaType(content_type) - - dest_server = await self._get_dicom_server(request, ae_title, "destination") + async with self.track_session(request.user, "STOW") as session: + content_type: str = request.content_type + if not media_type_matches("multipart/related; type=application/dicom", content_type): + raise UnsupportedMediaType(content_type) - results: Dataset = Dataset() - results.ReferencedSOPSequence = Sequence([]) - results.FailedSOPSequence = Sequence([]) - - async for ds in parse_request_in_chunks(request): - if ds is None: - continue + dest_server = await self._get_dicom_server(request, ae_title, "destination") - logger.debug(f"Received instance {ds.SOPInstanceUID} via STOW-RS") + results: Dataset = Dataset() + results.ReferencedSOPSequence = Sequence([]) + results.FailedSOPSequence = Sequence([]) - if study_uid: - results.RetrieveURL = reverse( - "wado_rs-study_with_study_uid", - args=[dest_server.ae_title, ds.StudyInstanceUID], - ) - if ds.StudyInstanceUID != study_uid: + async for ds in parse_request_in_chunks(request): + if ds is None: continue - logger.debug(f"Storing instance {ds.SOPInstanceUID} to {dest_server.ae_title}") - result_ds, failed = await stow_store(dest_server, ds) - - if failed: - logger.debug( - f"Storing instance {ds.SOPInstanceUID} to {dest_server.ae_title} failed" - ) - results.FailedSOPSequence.append(result_ds) - else: - results.ReferencedSOPSequence.append(result_ds) - logger.debug( - f"Successfully stored instance {ds.SOPInstanceUID} to {dest_server.ae_title}" - ) - - assert request.accepted_renderer is not None - return Response([results], content_type=request.accepted_renderer.media_type) + logger.debug(f"Received instance {ds.SOPInstanceUID} via STOW-RS") + + if study_uid: + results.RetrieveURL = reverse( + "wado_rs-study_with_study_uid", + args=[dest_server.ae_title, ds.StudyInstanceUID], + ) + if ds.StudyInstanceUID != study_uid: + continue + + logger.debug(f"Storing instance {ds.SOPInstanceUID} to {dest_server.ae_title}") + result_ds, failed = await stow_store(dest_server, ds) + + if failed: + logger.debug( + f"Storing instance {ds.SOPInstanceUID} to {dest_server.ae_title} failed" + ) + results.FailedSOPSequence.append(result_ds) + else: + results.ReferencedSOPSequence.append(result_ds) + logger.debug(f"Stored instance {ds.SOPInstanceUID} to {dest_server.ae_title}") + + response_data = [results] + await self._finalize_session(session, transfer_size=len(str(response_data))) + + assert request.accepted_renderer is not None + return Response(response_data, content_type=request.accepted_renderer.media_type) From ecd4dd9c8e9a41c72931c97e4f7b61e29ec6c1eb Mon Sep 17 00:00:00 2001 From: Maximilian Gutzmann Date: Sun, 30 Nov 2025 18:49:02 +0000 Subject: [PATCH 2/5] api sessions per user implemented --- adit/core/templates/core/admin_section.html | 21 ++++++++++ adit/core/views.py | 7 ++-- adit/dicom_web/admin.py | 13 +++---- adit/dicom_web/apps.py | 2 +- adit/dicom_web/migrations/0004_apisession.py | 8 ++-- adit/dicom_web/models.py | 6 +-- adit/dicom_web/views.py | 41 ++++++++++---------- 7 files changed, 59 insertions(+), 39 deletions(-) diff --git a/adit/core/templates/core/admin_section.html b/adit/core/templates/core/admin_section.html index ec7b4fc8f..407d5bcd4 100644 --- a/adit/core/templates/core/admin_section.html +++ b/adit/core/templates/core/admin_section.html @@ -28,6 +28,27 @@
Job Overview
{% endfor %} +
API Sessions
+ + + + + + + + + + + {% for session in api_stats %} + + + + + + + {% endfor %} + +
UserCreated atTransfer SizeTotal number of requests
{{ session.owner }}{{ session.time_last_accessed }}{{ session.total_transfer_size | filesizeformat }}{{ session.total_number_requests }}
Admin Tools