Skip to content

Commit ad8da7f

Browse files
authored
Merge pull request #595 from jbernal0019/master
Implement PACS query API endpoint
2 parents 28ff8a7 + 121454c commit ad8da7f

File tree

10 files changed

+780
-23
lines changed

10 files changed

+780
-23
lines changed

chris_backend/core/api.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
path('v1/users/<int:pk>/groups/',
3232
user_views.UserGroupList.as_view(), name='user-group-list'),
3333

34+
3435
path('v1/groups/',
3536
user_views.GroupList.as_view(),
3637
name='group-list'),
@@ -373,6 +374,22 @@
373374
pacsfile_views.PACSDetail.as_view(),
374375
name='pacs-detail'),
375376

377+
path('v1/pacs/<int:pk>/queries/',
378+
pacsfile_views.PACSQueryList.as_view(),
379+
name='pacsquery-list'),
380+
381+
path('v1/pacs/queries/',
382+
pacsfile_views.AllPACSQueryList.as_view(),
383+
name='allpacsquery-list'),
384+
385+
path('v1/pacs/queries/search/',
386+
pacsfile_views.AllPACSQueryListQuerySearch.as_view(),
387+
name='allpacsquery-list-query-search'),
388+
389+
path('v1/pacs/queries/<int:pk>/',
390+
pacsfile_views.PACSQueryDetail.as_view(),
391+
name='pacsquery-detail'),
392+
376393
path('v1/pacs/<int:pk>/series/',
377394
pacsfile_views.PACSSpecificSeriesList.as_view(),
378395
name='pacs-specific-series-list'),
@@ -523,4 +540,4 @@
523540
# Login and logout views for Djangos' browsable API
524541
urlpatterns += [
525542
path('v1/auth/', include('rest_framework.urls', namespace='rest_framework')),
526-
]
543+
]

chris_backend/feeds/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ def list(self, request, *args, **kwargs):
329329
request=request),
330330
'userfiles': reverse('userfile-list', request=request),
331331
'pacs': reverse('pacs-list', request=request),
332+
'pacsqueries': reverse('allpacsquery-list', request=request),
332333
'pacsfiles': reverse('pacsfile-list', request=request),
333334
'pacsseries': reverse('pacsseries-list', request=request),
334335
'filebrowser': reverse('chrisfolder-list', request=request)}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.5 on 2024-11-05 21:13
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('pacsfiles', '0002_pacs_active'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='PACSQuery',
18+
fields=[
19+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('creation_date', models.DateTimeField(auto_now_add=True)),
21+
('title', models.CharField(db_index=True, max_length=300)),
22+
('query', models.JSONField()),
23+
('description', models.CharField(blank=True, max_length=700)),
24+
('result', models.TextField(blank=True)),
25+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
26+
('pacs', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='query_list', to='pacsfiles.pacs')),
27+
],
28+
options={
29+
'ordering': ('pacs', 'owner', '-creation_date'),
30+
'unique_together': {('pacs', 'owner', 'title')},
31+
},
32+
),
33+
]

chris_backend/pacsfiles/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,43 @@ class Meta:
4343
fields = ['id', 'identifier', 'active']
4444

4545

46+
class PACSQuery(models.Model):
47+
creation_date = models.DateTimeField(auto_now_add=True)
48+
title = models.CharField(max_length=300, db_index=True)
49+
query = models.JSONField()
50+
description = models.CharField(max_length=700, blank=True)
51+
result = models.TextField(blank=True)
52+
pacs = models.ForeignKey(PACS, on_delete=models.CASCADE, related_name='query_list')
53+
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
54+
55+
class Meta:
56+
ordering = ('pacs', 'owner', '-creation_date',)
57+
unique_together = ('pacs', 'owner', 'title',)
58+
59+
def __str__(self):
60+
return self.query
61+
62+
63+
class PACSQueryFilter(FilterSet):
64+
min_creation_date = django_filters.IsoDateTimeFilter(field_name='creation_date',
65+
lookup_expr='gte')
66+
max_creation_date = django_filters.IsoDateTimeFilter(field_name='creation_date',
67+
lookup_expr='lte')
68+
title_exact = django_filters.CharFilter(field_name='title', lookup_expr='exact')
69+
title = django_filters.CharFilter(field_name='title', lookup_expr='icontains')
70+
description = django_filters.CharFilter(field_name='description',
71+
lookup_expr='icontains')
72+
pacs_identifier = django_filters.CharFilter(field_name='pacs__identifier',
73+
lookup_expr='exact')
74+
owner_username = django_filters.CharFilter(field_name='owner__username',
75+
lookup_expr='exact')
76+
77+
class Meta:
78+
model = PACSQuery
79+
fields = ['id', 'min_creation_date', 'max_creation_date', 'title_exact',
80+
'title', 'description', 'pacs_identifier', 'owner_username']
81+
82+
4683
class PACSSeries(models.Model):
4784
creation_date = models.DateTimeField(auto_now_add=True)
4885
PatientID = models.CharField(max_length=100, db_index=True)

chris_backend/pacsfiles/permissions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,34 @@ def has_permission(self, request, view):
1616

1717
return (request.method in permissions.SAFE_METHODS and user.groups.filter(
1818
name='pacs_users').exists())
19+
20+
21+
class IsChrisOrIsPACSUserOrReadOnly(permissions.BasePermission):
22+
"""
23+
Custom permission to only allow superuser 'chris' and other users in the pacs_users
24+
group to create objects. Read only is allowed to all other users.
25+
"""
26+
27+
def has_permission(self, request, view):
28+
user = request.user
29+
30+
if request.method in permissions.SAFE_METHODS:
31+
return True
32+
33+
return user.username == 'chris' or user.groups.filter(name='pacs_users').exists()
34+
35+
36+
class IsChrisOrOwnerOrIsPACSUserReadOnly(permissions.BasePermission):
37+
"""
38+
Custom permission to only allow superuser 'chris' to create it.
39+
Read only is allowed to other users in the pacs_users group.
40+
"""
41+
42+
def has_object_permission(self, request, view, obj):
43+
user = request.user
44+
45+
if user.username == 'chris' or user == obj.owner:
46+
return True
47+
48+
return (request.method in permissions.SAFE_METHODS and user.groups.filter(
49+
name='pacs_users').exists())

chris_backend/pacsfiles/serializers.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
import os
44
import time
55

6+
from django.db.utils import IntegrityError
67
from django.contrib.auth.models import Group
78
from django.conf import settings
89
from rest_framework import serializers
910

1011
from core.models import ChrisFolder
1112
from core.storage import connect_storage
1213
from core.serializers import ChrisFileSerializer
14+
from core.utils import json_zip2str
1315

14-
from .models import PACS, PACSSeries, PACSFile
16+
from .models import PACS, PACSQuery, PACSSeries, PACSFile
17+
from .services import PfdcmClient
1518

1619

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

2730
class Meta:
2831
model = PACS
2932
fields = ('url', 'id', 'identifier', 'active', 'folder_path', 'folder',
30-
'pacs_series_list')
33+
'query_list', 'series_list')
34+
35+
36+
class PACSQuerySerializer(serializers.HyperlinkedModelSerializer):
37+
query = serializers.JSONField(binary=True, required=False)
38+
result = serializers.ReadOnlyField()
39+
pacs_identifier = serializers.ReadOnlyField(source='pacs.identifier')
40+
owner_username = serializers.ReadOnlyField(source='owner.username')
41+
42+
class Meta:
43+
model = PACSQuery
44+
fields = ('url', 'id', 'creation_date', 'title', 'query', 'description',
45+
'result', 'pacs_identifier', 'owner_username')
46+
47+
def create(self, validated_data):
48+
"""
49+
Overriden to rise a serializer error when attempting to create a PACSQuery
50+
object that results in a DB conflict. Then a query is made to the PFDCM service.
51+
"""
52+
title = validated_data['title']
53+
query = validated_data['query']
54+
pacs_name = validated_data['pacs'].identifier
55+
56+
try:
57+
pacs_query = super(PACSQuerySerializer, self).create(validated_data)
58+
except IntegrityError:
59+
error_msg = (f'You have already registered a PACS query with title={title} '
60+
f'for pacs {pacs_name}')
61+
raise serializers.ValidationError([error_msg])
62+
63+
pfdcm_cl = PfdcmClient()
64+
result = pfdcm_cl.query(pacs_name, query)
65+
66+
if result:
67+
pacs_query.result = json_zip2str(result)
68+
pacs_query.save()
69+
return pacs_query
70+
71+
def update(self, instance, validated_data):
72+
"""
73+
Overriden to rise a serializer error when attempting to update a PACSQuery
74+
object that results in a DB conflict.
75+
"""
76+
pacs = instance.pacs
77+
title = validated_data.get('title')
78+
79+
if title is None:
80+
title = instance.title
81+
try:
82+
return super(PACSQuerySerializer, self).update(instance, validated_data)
83+
except IntegrityError:
84+
error_msg = (f'You have already registered a PACS query with title={title} '
85+
f'for pacs {pacs.identifier}')
86+
raise serializers.ValidationError([error_msg])
87+
88+
def validate(self, data):
89+
"""
90+
Overriden to validate that the query field is in data when creating a new query.
91+
"""
92+
if not self.instance: # on create
93+
if 'query' not in data:
94+
raise serializers.ValidationError(
95+
{'query': ["This field is required."]})
96+
return data
3197

3298

3399
class PACSSeriesSerializer(serializers.HyperlinkedModelSerializer):

chris_backend/pacsfiles/services.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ def query(self, pacs_name, query, timeout=30):
6363
raise
6464
time.sleep(0.4)
6565
else:
66-
return resp.json()
66+
result = resp.json()
67+
if result.get('status'):
68+
pypx = result.get('pypx')
69+
if pypx and 'data' in pypx:
70+
return pypx['data']
71+
return []
6772

6873
def retrieve(self, pacs_name, query, timeout=30):
6974
"""

chris_backend/pacsfiles/tests/test_serializers.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,114 @@
77
from unittest import mock
88
from rest_framework import serializers
99

10-
from pacsfiles.serializers import PACSSeriesSerializer
10+
from core.models import ChrisFolder
11+
from core.utils import json_zip2str
12+
from pacsfiles.models import PACS, PACSQuery
13+
from pacsfiles.serializers import PACSQuerySerializer, PACSSeriesSerializer
1114

1215

1316
CHRIS_SUPERUSER_PASSWORD = settings.CHRIS_SUPERUSER_PASSWORD
1417

1518

16-
class PACSSeriesSerializerTests(TestCase):
17-
19+
class SerializerTests(TestCase):
1820
def setUp(self):
1921
# avoid cluttered console output (for instance logging all the http requests)
2022
logging.disable(logging.WARNING)
2123

22-
# create superuser chris (owner of root folders)
24+
# superuser chris (owner of root folders)
2325
self.chris_username = 'chris'
24-
self.chris_password = CHRIS_SUPERUSER_PASSWORD
26+
chris_user = User.objects.get(username=self.chris_username)
27+
28+
# create normal user
29+
self.username = 'foo'
30+
self.password = 'bar'
31+
User.objects.create_user(username=self.username, password=self.password)
32+
33+
# create a PACS
34+
self.pacs_name = 'myPACS'
35+
folder_path = f'SERVICES/PACS/{self.pacs_name}'
36+
(pacs_folder, tf) = ChrisFolder.objects.get_or_create(path=folder_path,
37+
owner=chris_user)
38+
PACS.objects.get_or_create(folder=pacs_folder, identifier=self.pacs_name)
2539

2640
def tearDown(self):
2741
# re-enable logging
2842
logging.disable(logging.NOTSET)
2943

3044

45+
class PACSQuerySerializerTests(SerializerTests):
46+
47+
def test_create_success(self):
48+
"""
49+
Test whether overriden 'create' method successfully creates a new PACS query.
50+
"""
51+
user = User.objects.get(username=self.username)
52+
pacs = PACS.objects.get(identifier=self.pacs_name)
53+
query = {'SeriesInstanceUID': '2.3.15.2.1057'}
54+
data = {'title': 'query1', 'query': query, 'owner': user, 'pacs': pacs}
55+
56+
with mock.patch('pacsfiles.serializers.PfdcmClient.query') as pfdcm_query_mock:
57+
result = {'mock': 'mock'}
58+
pfdcm_query_mock.return_value = result
59+
pacs_query_serializer = PACSQuerySerializer(data=data)
60+
pacs_query = pacs_query_serializer.create(data)
61+
pfdcm_query_mock.assert_called_with(self.pacs_name, query)
62+
self.assertEqual(pacs_query.result, json_zip2str(result))
63+
64+
65+
def test_create_failure_pacs_user_title_combination_already_exists(self):
66+
"""
67+
Test whether overriden 'create' method raises a ValidationError when a user has
68+
already registered a PACS query with the same title and pacs.
69+
"""
70+
user = User.objects.get(username=self.username)
71+
pacs = PACS.objects.get(identifier=self.pacs_name)
72+
query = {'SeriesInstanceUID': '1.3.12.2.1107'}
73+
74+
PACSQuery.objects.get_or_create(title='query2', query=query, owner=user, pacs=pacs)
75+
76+
data = {'title': 'query2', 'query': query, 'owner': user, 'pacs': pacs}
77+
pacs_query_serializer = PACSQuerySerializer(data=data)
78+
with self.assertRaises(serializers.ValidationError):
79+
pacs_query_serializer.create(data)
80+
81+
def test_update_success(self):
82+
"""
83+
Test whether overriden 'update' method successfully updates an existing PACS query.
84+
"""
85+
user = User.objects.get(username=self.username)
86+
pacs = PACS.objects.get(identifier=self.pacs_name)
87+
query = {'SeriesInstanceUID': '2.3.15.2.1057'}
88+
89+
pacs_query, _ = PACSQuery.objects.get_or_create(title='query2', query=query,
90+
owner=user, pacs=pacs)
91+
92+
data = {'title': 'query4'}
93+
pacs_query_serializer = PACSQuerySerializer(pacs_query, data)
94+
pacs_query = pacs_query_serializer.update(pacs_query, data)
95+
self.assertEqual(pacs_query.title, 'query4')
96+
97+
def test_update_failure_pacs_user_title_combination_already_exists(self):
98+
"""
99+
Test whether overriden 'update' method raises a ValidationError when a user has
100+
already registered a PACS query with the same title and pacs.
101+
"""
102+
user = User.objects.get(username=self.username)
103+
pacs = PACS.objects.get(identifier=self.pacs_name)
104+
query = {'SeriesInstanceUID': '1.3.12.2.1107'}
105+
106+
pacs_query, _ = PACSQuery.objects.get_or_create(title='query2', query=query,
107+
owner=user, pacs=pacs)
108+
PACSQuery.objects.get_or_create(title='query3', query=query, owner=user, pacs=pacs)
109+
110+
data = {'title': 'query3'}
111+
pacs_query_serializer = PACSQuerySerializer(pacs_query, data)
112+
with self.assertRaises(serializers.ValidationError):
113+
pacs_query_serializer.update(pacs_query, data)
114+
115+
116+
class PACSSeriesSerializerTests(SerializerTests):
117+
31118
def test_validate_ndicom_failure_not_positive(self):
32119
"""
33120
Test whether overriden validate_ndicom method validates submitted ndicom must

0 commit comments

Comments
 (0)