Skip to content

Commit

Permalink
Merge pull request #595 from jbernal0019/master
Browse files Browse the repository at this point in the history
Implement PACS query API endpoint
  • Loading branch information
jbernal0019 authored Nov 22, 2024
2 parents 28ff8a7 + 121454c commit ad8da7f
Show file tree
Hide file tree
Showing 10 changed files with 780 additions and 23 deletions.
19 changes: 18 additions & 1 deletion chris_backend/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
path('v1/users/<int:pk>/groups/',
user_views.UserGroupList.as_view(), name='user-group-list'),


path('v1/groups/',
user_views.GroupList.as_view(),
name='group-list'),
Expand Down Expand Up @@ -373,6 +374,22 @@
pacsfile_views.PACSDetail.as_view(),
name='pacs-detail'),

path('v1/pacs/<int:pk>/queries/',
pacsfile_views.PACSQueryList.as_view(),
name='pacsquery-list'),

path('v1/pacs/queries/',
pacsfile_views.AllPACSQueryList.as_view(),
name='allpacsquery-list'),

path('v1/pacs/queries/search/',
pacsfile_views.AllPACSQueryListQuerySearch.as_view(),
name='allpacsquery-list-query-search'),

path('v1/pacs/queries/<int:pk>/',
pacsfile_views.PACSQueryDetail.as_view(),
name='pacsquery-detail'),

path('v1/pacs/<int:pk>/series/',
pacsfile_views.PACSSpecificSeriesList.as_view(),
name='pacs-specific-series-list'),
Expand Down Expand Up @@ -523,4 +540,4 @@
# Login and logout views for Djangos' browsable API
urlpatterns += [
path('v1/auth/', include('rest_framework.urls', namespace='rest_framework')),
]
]
1 change: 1 addition & 0 deletions chris_backend/feeds/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def list(self, request, *args, **kwargs):
request=request),
'userfiles': reverse('userfile-list', request=request),
'pacs': reverse('pacs-list', request=request),
'pacsqueries': reverse('allpacsquery-list', request=request),
'pacsfiles': reverse('pacsfile-list', request=request),
'pacsseries': reverse('pacsseries-list', request=request),
'filebrowser': reverse('chrisfolder-list', request=request)}
Expand Down
33 changes: 33 additions & 0 deletions chris_backend/pacsfiles/migrations/0003_pacsquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.5 on 2024-11-05 21:13

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pacsfiles', '0002_pacs_active'),
]

operations = [
migrations.CreateModel(
name='PACSQuery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(auto_now_add=True)),
('title', models.CharField(db_index=True, max_length=300)),
('query', models.JSONField()),
('description', models.CharField(blank=True, max_length=700)),
('result', models.TextField(blank=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('pacs', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='query_list', to='pacsfiles.pacs')),
],
options={
'ordering': ('pacs', 'owner', '-creation_date'),
'unique_together': {('pacs', 'owner', 'title')},
},
),
]
37 changes: 37 additions & 0 deletions chris_backend/pacsfiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,43 @@ class Meta:
fields = ['id', 'identifier', 'active']


class PACSQuery(models.Model):
creation_date = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=300, db_index=True)
query = models.JSONField()
description = models.CharField(max_length=700, blank=True)
result = models.TextField(blank=True)
pacs = models.ForeignKey(PACS, on_delete=models.CASCADE, related_name='query_list')
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)

class Meta:
ordering = ('pacs', 'owner', '-creation_date',)
unique_together = ('pacs', 'owner', 'title',)

def __str__(self):
return self.query


class PACSQueryFilter(FilterSet):
min_creation_date = django_filters.IsoDateTimeFilter(field_name='creation_date',
lookup_expr='gte')
max_creation_date = django_filters.IsoDateTimeFilter(field_name='creation_date',
lookup_expr='lte')
title_exact = django_filters.CharFilter(field_name='title', lookup_expr='exact')
title = django_filters.CharFilter(field_name='title', lookup_expr='icontains')
description = django_filters.CharFilter(field_name='description',
lookup_expr='icontains')
pacs_identifier = django_filters.CharFilter(field_name='pacs__identifier',
lookup_expr='exact')
owner_username = django_filters.CharFilter(field_name='owner__username',
lookup_expr='exact')

class Meta:
model = PACSQuery
fields = ['id', 'min_creation_date', 'max_creation_date', 'title_exact',
'title', 'description', 'pacs_identifier', 'owner_username']


class PACSSeries(models.Model):
creation_date = models.DateTimeField(auto_now_add=True)
PatientID = models.CharField(max_length=100, db_index=True)
Expand Down
31 changes: 31 additions & 0 deletions chris_backend/pacsfiles/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,34 @@ def has_permission(self, request, view):

return (request.method in permissions.SAFE_METHODS and user.groups.filter(
name='pacs_users').exists())


class IsChrisOrIsPACSUserOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow superuser 'chris' and other users in the pacs_users
group to create objects. Read only is allowed to all other users.
"""

def has_permission(self, request, view):
user = request.user

if request.method in permissions.SAFE_METHODS:
return True

return user.username == 'chris' or user.groups.filter(name='pacs_users').exists()


class IsChrisOrOwnerOrIsPACSUserReadOnly(permissions.BasePermission):
"""
Custom permission to only allow superuser 'chris' to create it.
Read only is allowed to other users in the pacs_users group.
"""

def has_object_permission(self, request, view, obj):
user = request.user

if user.username == 'chris' or user == obj.owner:
return True

return (request.method in permissions.SAFE_METHODS and user.groups.filter(
name='pacs_users').exists())
74 changes: 70 additions & 4 deletions chris_backend/pacsfiles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
import os
import time

from django.db.utils import IntegrityError
from django.contrib.auth.models import Group
from django.conf import settings
from rest_framework import serializers

from core.models import ChrisFolder
from core.storage import connect_storage
from core.serializers import ChrisFileSerializer
from core.utils import json_zip2str

from .models import PACS, PACSSeries, PACSFile
from .models import PACS, PACSQuery, PACSSeries, PACSFile
from .services import PfdcmClient


logger = logging.getLogger(__name__)
Expand All @@ -21,13 +24,76 @@ class PACSSerializer(serializers.HyperlinkedModelSerializer):
folder_path = serializers.ReadOnlyField(source='folder.path')
folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail',
read_only=True)
pacs_series_list = serializers.HyperlinkedIdentityField(
view_name='pacs-specific-series-list')
query_list = serializers.HyperlinkedIdentityField(view_name='pacsquery-list')
series_list = serializers.HyperlinkedIdentityField(view_name='pacs-specific-series-list')

class Meta:
model = PACS
fields = ('url', 'id', 'identifier', 'active', 'folder_path', 'folder',
'pacs_series_list')
'query_list', 'series_list')


class PACSQuerySerializer(serializers.HyperlinkedModelSerializer):
query = serializers.JSONField(binary=True, required=False)
result = serializers.ReadOnlyField()
pacs_identifier = serializers.ReadOnlyField(source='pacs.identifier')
owner_username = serializers.ReadOnlyField(source='owner.username')

class Meta:
model = PACSQuery
fields = ('url', 'id', 'creation_date', 'title', 'query', 'description',
'result', 'pacs_identifier', 'owner_username')

def create(self, validated_data):
"""
Overriden to rise a serializer error when attempting to create a PACSQuery
object that results in a DB conflict. Then a query is made to the PFDCM service.
"""
title = validated_data['title']
query = validated_data['query']
pacs_name = validated_data['pacs'].identifier

try:
pacs_query = super(PACSQuerySerializer, self).create(validated_data)
except IntegrityError:
error_msg = (f'You have already registered a PACS query with title={title} '
f'for pacs {pacs_name}')
raise serializers.ValidationError([error_msg])

pfdcm_cl = PfdcmClient()
result = pfdcm_cl.query(pacs_name, query)

if result:
pacs_query.result = json_zip2str(result)
pacs_query.save()
return pacs_query

def update(self, instance, validated_data):
"""
Overriden to rise a serializer error when attempting to update a PACSQuery
object that results in a DB conflict.
"""
pacs = instance.pacs
title = validated_data.get('title')

if title is None:
title = instance.title
try:
return super(PACSQuerySerializer, self).update(instance, validated_data)
except IntegrityError:
error_msg = (f'You have already registered a PACS query with title={title} '
f'for pacs {pacs.identifier}')
raise serializers.ValidationError([error_msg])

def validate(self, data):
"""
Overriden to validate that the query field is in data when creating a new query.
"""
if not self.instance: # on create
if 'query' not in data:
raise serializers.ValidationError(
{'query': ["This field is required."]})
return data


class PACSSeriesSerializer(serializers.HyperlinkedModelSerializer):
Expand Down
7 changes: 6 additions & 1 deletion chris_backend/pacsfiles/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ def query(self, pacs_name, query, timeout=30):
raise
time.sleep(0.4)
else:
return resp.json()
result = resp.json()
if result.get('status'):
pypx = result.get('pypx')
if pypx and 'data' in pypx:
return pypx['data']
return []

def retrieve(self, pacs_name, query, timeout=30):
"""
Expand Down
97 changes: 92 additions & 5 deletions chris_backend/pacsfiles/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,114 @@
from unittest import mock
from rest_framework import serializers

from pacsfiles.serializers import PACSSeriesSerializer
from core.models import ChrisFolder
from core.utils import json_zip2str
from pacsfiles.models import PACS, PACSQuery
from pacsfiles.serializers import PACSQuerySerializer, PACSSeriesSerializer


CHRIS_SUPERUSER_PASSWORD = settings.CHRIS_SUPERUSER_PASSWORD


class PACSSeriesSerializerTests(TestCase):

class SerializerTests(TestCase):
def setUp(self):
# avoid cluttered console output (for instance logging all the http requests)
logging.disable(logging.WARNING)

# create superuser chris (owner of root folders)
# superuser chris (owner of root folders)
self.chris_username = 'chris'
self.chris_password = CHRIS_SUPERUSER_PASSWORD
chris_user = User.objects.get(username=self.chris_username)

# create normal user
self.username = 'foo'
self.password = 'bar'
User.objects.create_user(username=self.username, password=self.password)

# create a PACS
self.pacs_name = 'myPACS'
folder_path = f'SERVICES/PACS/{self.pacs_name}'
(pacs_folder, tf) = ChrisFolder.objects.get_or_create(path=folder_path,
owner=chris_user)
PACS.objects.get_or_create(folder=pacs_folder, identifier=self.pacs_name)

def tearDown(self):
# re-enable logging
logging.disable(logging.NOTSET)


class PACSQuerySerializerTests(SerializerTests):

def test_create_success(self):
"""
Test whether overriden 'create' method successfully creates a new PACS query.
"""
user = User.objects.get(username=self.username)
pacs = PACS.objects.get(identifier=self.pacs_name)
query = {'SeriesInstanceUID': '2.3.15.2.1057'}
data = {'title': 'query1', 'query': query, 'owner': user, 'pacs': pacs}

with mock.patch('pacsfiles.serializers.PfdcmClient.query') as pfdcm_query_mock:
result = {'mock': 'mock'}
pfdcm_query_mock.return_value = result
pacs_query_serializer = PACSQuerySerializer(data=data)
pacs_query = pacs_query_serializer.create(data)
pfdcm_query_mock.assert_called_with(self.pacs_name, query)
self.assertEqual(pacs_query.result, json_zip2str(result))


def test_create_failure_pacs_user_title_combination_already_exists(self):
"""
Test whether overriden 'create' method raises a ValidationError when a user has
already registered a PACS query with the same title and pacs.
"""
user = User.objects.get(username=self.username)
pacs = PACS.objects.get(identifier=self.pacs_name)
query = {'SeriesInstanceUID': '1.3.12.2.1107'}

PACSQuery.objects.get_or_create(title='query2', query=query, owner=user, pacs=pacs)

data = {'title': 'query2', 'query': query, 'owner': user, 'pacs': pacs}
pacs_query_serializer = PACSQuerySerializer(data=data)
with self.assertRaises(serializers.ValidationError):
pacs_query_serializer.create(data)

def test_update_success(self):
"""
Test whether overriden 'update' method successfully updates an existing PACS query.
"""
user = User.objects.get(username=self.username)
pacs = PACS.objects.get(identifier=self.pacs_name)
query = {'SeriesInstanceUID': '2.3.15.2.1057'}

pacs_query, _ = PACSQuery.objects.get_or_create(title='query2', query=query,
owner=user, pacs=pacs)

data = {'title': 'query4'}
pacs_query_serializer = PACSQuerySerializer(pacs_query, data)
pacs_query = pacs_query_serializer.update(pacs_query, data)
self.assertEqual(pacs_query.title, 'query4')

def test_update_failure_pacs_user_title_combination_already_exists(self):
"""
Test whether overriden 'update' method raises a ValidationError when a user has
already registered a PACS query with the same title and pacs.
"""
user = User.objects.get(username=self.username)
pacs = PACS.objects.get(identifier=self.pacs_name)
query = {'SeriesInstanceUID': '1.3.12.2.1107'}

pacs_query, _ = PACSQuery.objects.get_or_create(title='query2', query=query,
owner=user, pacs=pacs)
PACSQuery.objects.get_or_create(title='query3', query=query, owner=user, pacs=pacs)

data = {'title': 'query3'}
pacs_query_serializer = PACSQuerySerializer(pacs_query, data)
with self.assertRaises(serializers.ValidationError):
pacs_query_serializer.update(pacs_query, data)


class PACSSeriesSerializerTests(SerializerTests):

def test_validate_ndicom_failure_not_positive(self):
"""
Test whether overriden validate_ndicom method validates submitted ndicom must
Expand Down
Loading

0 comments on commit ad8da7f

Please sign in to comment.