Skip to content

Commit

Permalink
Merge pull request #589 from jbernal0019/master
Browse files Browse the repository at this point in the history
Implement PACS list API endpoint based on a new PFDCM microservice
  • Loading branch information
jbernal0019 authored Oct 24, 2024
2 parents 747d985 + ec44fa6 commit 9e908c9
Show file tree
Hide file tree
Showing 25 changed files with 1,305 additions and 70 deletions.
4 changes: 4 additions & 0 deletions chris_backend/config/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@
# NATS settings
NATS_ADDRESS = 'nats://nats:4222'

# PFDCM settings
PFDCM_ADDRESS = 'http://pfdcm:4005'


# Celery settings

#CELERY_BROKER_URL = 'amqp://guest:guest@localhost'
Expand Down
5 changes: 5 additions & 0 deletions chris_backend/config/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ def get_secret(setting, secret_type=env):
NATS_ADDRESS = get_secret('NATS_ADDRESS')


# PFDCM SETTINGS
# ------------------------------------------------------------------------------
PFDCM_ADDRESS = get_secret('PFDCM_ADDRESS')


# CELERY SETTINGS
# ------------------------------------------------------------------------------
CELERY_BROKER_URL = get_secret('CELERY_BROKER_URL')
Expand Down
16 changes: 16 additions & 0 deletions chris_backend/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,22 @@
name='userfile-resource'),


path('v1/pacs/',
pacsfile_views.PACSList.as_view(),
name='pacs-list'),

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

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

path('v1/pacs/<int:pk>/series/',
pacsfile_views.PACSSpecificSeriesList.as_view(),
name='pacs-specific-series-list'),

path('v1/pacs/series/',
pacsfile_views.PACSSeriesList.as_view(),
name='pacsseries-list'),
Expand Down
48 changes: 0 additions & 48 deletions chris_backend/core/file_serializer.py

This file was deleted.

49 changes: 48 additions & 1 deletion chris_backend/core/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@

from rest_framework import serializers

from .models import ChrisInstance, FileDownloadToken
from drf_spectacular.utils import OpenApiTypes, extend_schema_field

from collectionjson.fields import ItemLinkField
from .models import ChrisInstance, FileDownloadToken, ChrisFile, ChrisLinkFile
from .utils import get_file_resource_link


class ChrisInstanceSerializer(serializers.HyperlinkedModelSerializer):
Expand All @@ -18,3 +23,45 @@ class FileDownloadTokenSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = FileDownloadToken
fields = ('url', 'id', 'creation_date', 'token', 'owner_username', 'owner')


class _ChrisFileSerializerMetaclass(serializers.SerializerMetaclass):
"""
A metaclass to require that the Meta inner class' model attribute is ChrisFile.
"""
def __new__(cls, name, bases, dct):
if set(bases) == {serializers.HyperlinkedModelSerializer}:
# skip validation of ChrisFileSerializer, which is a
# direct subclass of HyperlinkedModelSerializer
return super().__new__(cls, name, bases, dct)
if (Meta := dct.get('Meta', None)) is None:
raise TypeError(f'{name} must have an inner class called Meta')
if not issubclass(getattr(Meta, 'model', None), (ChrisFile, ChrisLinkFile)):
raise TypeError(f'{name}.Meta.model must be ChrisFile or ChrisLinkFile')
return super().__new__(cls, name, bases, dct)


class ChrisFileSerializer(serializers.HyperlinkedModelSerializer, metaclass=_ChrisFileSerializerMetaclass):
"""
A superclass for serializers of ``ChrisFile`` or similar.
"""

fname = serializers.FileField(use_url=False, required=False)
fsize = serializers.SerializerMethodField()
file_resource = ItemLinkField('get_file_link')
owner_username = serializers.ReadOnlyField(source='owner.username')
parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', read_only=True)
owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True)

def get_fsize(self, obj) -> int:
"""
Get the size of the file in bytes.
"""
return obj.fname.size

@extend_schema_field(OpenApiTypes.URI)
def get_file_link(self, obj):
"""
Custom method to get the hyperlink to the actual file resource.
"""
return get_file_resource_link(self, obj)
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

from django.test import TestCase

from core.file_serializer import ChrisFileSerializer
from core.serializers import ChrisFileSerializer


class ChrisFileSerializerTests(TestCase):
Expand Down
1 change: 1 addition & 0 deletions chris_backend/feeds/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ def list(self, request, *args, **kwargs):
'pipelinesourcefiles': reverse('pipelinesourcefile-list',
request=request),
'userfiles': reverse('userfile-list', request=request),
'pacs': reverse('pacs-list', request=request),
'pacsfiles': reverse('pacsfile-list', request=request),
'pacsseries': reverse('pacsseries-list', request=request),
'filebrowser': reverse('chrisfolder-list', request=request)}
Expand Down
2 changes: 1 addition & 1 deletion chris_backend/filebrowser/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from core.models import (ChrisFolder, ChrisFile, ChrisLinkFile, FolderGroupPermission,
FolderUserPermission, FileGroupPermission, FileUserPermission,
LinkFileGroupPermission, LinkFileUserPermission)
from core.file_serializer import ChrisFileSerializer
from core.serializers import ChrisFileSerializer


class FileBrowserFolderSerializer(serializers.HyperlinkedModelSerializer):
Expand Down
18 changes: 18 additions & 0 deletions chris_backend/pacsfiles/migrations/0002_pacs_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2024-10-22 18:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('pacsfiles', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='pacs',
name='active',
field=models.BooleanField(blank=True, default=True),
),
]
8 changes: 8 additions & 0 deletions chris_backend/pacsfiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

class PACS(models.Model):
identifier = models.CharField(max_length=20, unique=True)
active = models.BooleanField(blank=True, default=True)
# pacs top folder
folder = models.OneToOneField(ChrisFolder, on_delete=models.CASCADE,
related_name='pacs')
Expand All @@ -35,6 +36,13 @@ def auto_delete_pacs_folder_with_pacs(sender, instance, **kwargs):
pass


class PACSFilter(FilterSet):

class Meta:
model = PACS
fields = ['id', 'identifier', 'active']


class PACSSeries(models.Model):
creation_date = models.DateTimeField(auto_now_add=True)
PatientID = models.CharField(max_length=100, db_index=True)
Expand Down
20 changes: 13 additions & 7 deletions chris_backend/pacsfiles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from core.models import ChrisFolder
from core.storage import connect_storage
from core.file_serializer import ChrisFileSerializer
from core.serializers import ChrisFileSerializer

from .models import PACS, PACSSeries, PACSFile

Expand All @@ -18,16 +18,21 @@


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')

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


class PACSSeriesSerializer(serializers.HyperlinkedModelSerializer):
path = serializers.CharField(max_length=1024, write_only=True)
folder_path = serializers.ReadOnlyField(source='folder.path')
ndicom = serializers.IntegerField(write_only=True)
pacs_name = serializers.CharField(max_length=20, write_only=True)
pacs_identifier = serializers.ReadOnlyField(source='pacs.identifier')
Expand All @@ -36,11 +41,12 @@ class PACSSeriesSerializer(serializers.HyperlinkedModelSerializer):

class Meta:
model = PACSSeries
fields = ('url', 'id', 'creation_date', 'path', 'ndicom', 'PatientID',
'PatientName', 'PatientBirthDate', 'PatientAge', 'PatientSex',
'StudyDate', 'AccessionNumber', 'Modality', 'ProtocolName',
'StudyInstanceUID', 'StudyDescription', 'SeriesInstanceUID',
'SeriesDescription', 'pacs_name', 'pacs_identifier', 'folder')
fields = ('url', 'id', 'creation_date', 'path', 'folder_path', 'ndicom',
'PatientID', 'PatientName', 'PatientBirthDate', 'PatientAge',
'PatientSex', 'StudyDate', 'AccessionNumber', 'Modality',
'ProtocolName', 'StudyInstanceUID', 'StudyDescription',
'SeriesInstanceUID', 'SeriesDescription', 'pacs_name',
'pacs_identifier', 'folder')

def create(self, validated_data):
"""
Expand Down
91 changes: 91 additions & 0 deletions chris_backend/pacsfiles/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@

import logging
import time

from django.conf import settings

from requests import get, post, exceptions


logger = logging.getLogger(__name__)


class PfdcmClient(object):
"""
A pfcdm API client.
"""

def __init__(self):
self._pfdcm_address = settings.PFDCM_ADDRESS.rstrip('/')
self.content_type = 'application/json'

# urls of the high level API resources
self.pacs_list_url = self._pfdcm_address + '/api/v1/PACSservice/list/'
self.pacs_query_url = self._pfdcm_address + '/api/v1/PACS/sync/pypx/'
self.pacs_retrieve_url = self._pfdcm_address + '/api/v1/PACS/thread/pypx/'

def get_pacs_list(self, timeout=30):
"""
Get a list of PACS names.
"""
headers = {'Content-Type': self.content_type, 'Accept': self.content_type}

for i in range(5):
try:
resp = get(self.pacs_list_url, timeout=timeout, headers=headers)
except (exceptions.Timeout, exceptions.RequestException) as e:
logger.error(f'Error while retrieving data from pfdcm url '
f'-->{self.pacs_list_url}<--, detail: {str(e)}')
if i == 4:
raise
time.sleep(0.4)
else:
return resp.json()

def query(self, pacs_name, query, timeout=30):
"""
Send a PACS query dictionary to pfdcm.
"""
headers = {'Content-Type': self.content_type, 'Accept': self.content_type}
data = {
'PACSservice' : {'value': pacs_name},
'listenerService' : {'value': 'default'},
'PACSdirective' : query
}
for i in range(5):
try:
resp = post(self.pacs_query_url, json=data, timeout=timeout,
headers=headers)
except (exceptions.Timeout, exceptions.RequestException) as e:
logger.error(f'Error while querying pfdcm url '
f'-->{self.pacs_query_url}<--, detail: {str(e)}')
if i == 4:
raise
time.sleep(0.4)
else:
return resp.json()

def retrieve(self, pacs_name, query, timeout=30):
"""
Send a PACS query dictionary to pfdcm to initiate a PACS retrieve.
"""
headers = {'Content-Type': self.content_type, 'Accept': self.content_type}
data = {
'PACSservice' : {'value': pacs_name},
'listenerService' : {'value': 'default'},
'PACSdirective' : query,
'withFeedBack': True,
'then': 'retrieve'
}
for i in range(5):
try:
resp = post(self.pacs_retrieve_url, json=data, timeout=timeout,
headers=headers)
except (exceptions.Timeout, exceptions.RequestException) as e:
logger.error(f'Error while querying pfdcm url '
f'-->{self.pacs_retrieve_url}<--, detail: {str(e)}')
if i == 4:
raise
time.sleep(0.4)
else:
return resp.json()
Loading

0 comments on commit 9e908c9

Please sign in to comment.