Skip to content

Commit

Permalink
Merge branch 'v0.23.0' into v0.22.2-merge-branch
Browse files Browse the repository at this point in the history
  • Loading branch information
fredkingham committed Sep 6, 2022
2 parents c51c31f + 96c86fb commit 057dfe5
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 105 deletions.
7 changes: 7 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
### 0.23.0 (Major Release)

#### Enhanced customisation of search results

Provides a new API for applications to customize the data serialized for each patient in a
set of search results and then use this data in the front end.

Applications should use a custom search backend with their own `PatientSummary` class.


### 0.22.2 (Minor Release)

Expand Down
36 changes: 36 additions & 0 deletions doc/docs/guides/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,42 @@ The backend takes in a dictionary with the following fields
}
```

## Customisation of search results

Search results are rendered with the template `partials/_patient_summary_list.html`. To
improve performance of serializing multiple patients, the backend returns a patient summary
for the current page of search results.

To add data to these results the application can implement a `PatientSummary` class.

The following example adds the title to the data returned to the front end.

```python
from opal.core.search.queries import PatientSummary

class MyPatientSummary(PatientSummary):
def __init__(self, patient, episodes):
super()__init__(patient, episodes)
self.patient_title = patient.demographics().title

def to_json(self):
as_json = super().to_json()
as_json['title'] = self.patient_title
return as_json


class MyCustomBackend(DatabaseQueryBackend):
patient_summary_class = PatientSummary

# change settings.py to include OPAL_SEARCH_BACKEND='{path to my backend}.MyCustomBackend'
```

The raw serialised data is available to the front end in a `.data` property.

```
[[ result.data.title ]] [[ result.first_name ]] [[ result.surname ]] [[ result.hospitalNumber ]]
```

## The Advanced search interface

The Opal advanced search interface at `/#/extract` allows users to specify rules
Expand Down
20 changes: 17 additions & 3 deletions doc/docs/reference/javascript/patient_summary_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,22 @@ functionality related to interacting with patient search results in the client.

### Constructor

The PatientSummary service is instantiated with the Patient search result data
that comes back from the Patient search JSON API.
The PatientSummary service is instantiated with the patient search result data
that comes back from the patient search JSON API.

var patient_summary = new PatientSummary(json_data);
```javascript
var patientSummary = new PatientSummary(json_data);
```

The result object has the following properties by default:

* `hospitalNumber`
* `patientId`
* `link` A link to the patients patient detail page
* `dateOfBirth` The patient's date of birth cast to a moment
* `startDate` The patient's earliest episode.start cast to a moment
* `endDate` The patient's last episdoe.end cast to a moment
* `years` The span of years between startDate and EndDate e.g. "2020-2023"
* `categories` A comma joined string of the patient's episode categories, e.g. "Inpatient, ICU"
* `data` the raw JSON data from the API

93 changes: 37 additions & 56 deletions opal/core/search/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import datetime
import operator
from collections import defaultdict
from functools import reduce

from django.contrib.contenttypes.models import ContentType
Expand All @@ -28,36 +29,35 @@ def get_model_from_api_name(column_name):


class PatientSummary(object):
def __init__(self, episode):
self.start = episode.start
self.end = episode.end
self.episode_ids = set([episode.id])
self.patient_id = episode.patient.id
self.categories = set([episode.category_name])

def update(self, episode):
if not self.start:
self.start = episode.start
elif episode.start:
if self.start > episode.start:
self.start = episode.start

if not self.end:
self.end = episode.end
elif episode.end:
if self.end < episode.end:
self.end = episode.end

self.episode_ids.add(episode.id)
self.categories.add(episode.category_name)
def __init__(self, patient, episodes):
start_dates = [i.start for i in episodes if i.start]
self.start = None
if len(start_dates) > 0:
self.start = min(start_dates)

end_dates = [i.end for i in episodes if i.end]
self.end = None
if len(end_dates) > 0:
self.end = max(end_dates)

self.patient_id = patient.id
demographics = patient.demographics_set.all()[0]
self.first_name = demographics.first_name
self.surname = demographics.surname
self.hospital_number = demographics.hospital_number
self.date_of_birth = demographics.date_of_birth
self.categories = list(sorted(set([
episode.category_name for episode in episodes
])))
self.count = len(episodes)

def to_dict(self):
result = {k: getattr(self, k) for k in [
"patient_id", "start", "end"
]}
result["categories"] = sorted(self.categories)
result["count"] = len(self.episode_ids)
return result
keys = [
"patient_id", "start", "end", "first_name",
"surname", "hospital_number", "date_of_birth",
"categories", "count"
]
return {key: getattr(self, key) for key in keys}


def episodes_for_user(episodes, user):
Expand All @@ -73,6 +73,7 @@ class QueryBackend(object):
"""
Base class for search implementations to inherit from
"""

def __init__(self, user, query):
self.user = user
self.query = query
Expand Down Expand Up @@ -109,6 +110,7 @@ class DatabaseQuery(QueryBackend):
Finally we filter based on episode type level restrictions.
"""
patient_summary_class = PatientSummary

def fuzzy_query(self):
"""
Expand Down Expand Up @@ -403,37 +405,16 @@ def episodes_for_criteria(self, criteria):
return eps

def get_aggregate_patients_from_episodes(self, episodes):
# at the moment we use start/end only
patient_summaries = {}
patient_to_episodes = defaultdict(set)
result = []

for episode in episodes:
patient_id = episode.patient_id
if patient_id in patient_summaries:
patient_summaries[patient_id].update(episode)
else:
patient_summaries[patient_id] = PatientSummary(episode)

patients = models.Patient.objects.filter(
id__in=list(patient_summaries.keys())
)
patients = patients.prefetch_related("demographics_set")

results = []
patient_to_episodes[episode.patient].add(episode)

for patient_id, patient_summary in patient_summaries.items():
patient = next(p for p in patients if p.id == patient_id)
# Explicitly not using the .demographics property for performance
# note that we prefetch demographics_set a few lines earlier
demographic = patient.demographics_set.get()

result = {k: getattr(demographic, k) for k in [
"first_name", "surname", "hospital_number", "date_of_birth"
]}

result.update(patient_summary.to_dict())
results.append(result)

return results
for patient, episodes in patient_to_episodes.items():
patient_summary = self.patient_summary_class(patient, episodes)
result.append(patient_summary.to_dict())
return result

def _episodes_without_restrictions(self):
all_matches = [
Expand Down
67 changes: 41 additions & 26 deletions opal/core/search/static/js/search/services/patient_summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,47 @@
// This is the main PatientSummary class for OPAL.
//
angular.module('opal.services').factory('PatientSummary', function() {
var PatientSummary = function(jsonResponse){
var startYear, endYear;

if(jsonResponse.start_date){
startYear= moment(jsonResponse.start_date, 'DD/MM/YYYY').format("YYYY");
}

if(jsonResponse.end_date){
endYear = moment(jsonResponse.end_date, 'DD/MM/YYYY').format("YYYY");
}

if(startYear && endYear && startYear !== endYear){
this.years = startYear + "-" + endYear;
}
else if(startYear){
this.years = startYear;
}
this.first_name = jsonResponse.first_name;
this.surname = jsonResponse.surname;
this.patientId = jsonResponse.patient_id;
this.count = jsonResponse.count;
"use strict";
var PatientSummary = function(jsonResponse){
this.data = jsonResponse;
var startYear, endYear

if(jsonResponse.start_date){
this.startDate = moment(jsonResponse.start_date, 'DD/MM/YYYY');
startYear= this.startDate.format("YYYY");
}

if(jsonResponse.end_date){
this.endDate = moment(jsonResponse.end_date, 'DD/MM/YYYY');
endYear = this.endDate.format("YYYY");
}

if(startYear && endYear && startYear !== endYear){
this.years = startYear + "-" + endYear;
}
else if(startYear){
this.years = startYear;
}

if(jsonResponse.date_of_birth){
this.dateOfBirth = moment(jsonResponse.date_of_birth, 'DD/MM/YYYY');
this.categories = jsonResponse.categories.join(", ");
}

if(jsonResponse.patient_id){
this.link = "/#/patient/" + jsonResponse.patient_id;
this.hospitalNumber = jsonResponse.hospital_number;
};
this.patientId = jsonResponse.patient_id;
}

if(jsonResponse.categories){
this.categories = jsonResponse.categories.join(", ");
}

this.first_name = jsonResponse.first_name;
this.surname = jsonResponse.surname;
this.count = jsonResponse.count;
this.dateOfBirth = moment(jsonResponse.date_of_birth, 'DD/MM/YYYY');
this.hospitalNumber = jsonResponse.hospital_number;
};

return PatientSummary;
});
return PatientSummary;
});
88 changes: 70 additions & 18 deletions opal/core/search/tests/test_search_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,46 @@

class PatientSummaryTestCase(OpalTestCase):

def test_update_sets_start(self):
patient, episode = self.new_patient_and_episode_please()
summary = queries.PatientSummary(episode)
self.assertEqual(None, summary.start)
the_date = date(day=27, month=1, year=1972)
episode2 = patient.create_episode(start=the_date)
summary.update(episode2)
self.assertEqual(summary.start, the_date)

def test_update_sets_end(self):
patient, episode = self.new_patient_and_episode_please()
summary = queries.PatientSummary(episode)
self.assertEqual(None, summary.start)
the_date = date(day=27, month=1, year=1972)
episode2 = patient.create_episode(end=the_date)
summary.update(episode2)
self.assertEqual(summary.end, the_date)
def setUp(self):
self.patient, self.episode_1 = self.new_patient_and_episode_please()
self.patient.demographics_set.update(
first_name='Sarah',
surname='Wilson',
hospital_number="2342232323",
date_of_birth=date(1984, 1, 2)
)
self.episode_1.category_name = 'Inpatient'
self.episode_1.start = date(2022, 10, 1)
self.episode_1.end = date(2022, 10, 30)

self.episode_2 = self.patient.episode_set.create(
category_name='Inpatient',
start=date(2022, 9, 10),
end=date(2022, 10, 10),
)

self.episode_3 = self.patient.episode_set.create(
category_name='Inpatient',
start=date(2022, 10, 12),
end=date(2022, 10, 13),
)

def test_patient_summary(self):
summary = queries.PatientSummary(
self.patient, [self.episode_1, self.episode_2, self.episode_3]
)
expected = {
"patient_id": self.patient.id,
"start": date(2022, 9, 10),
"end": date(2022, 10, 30),
"first_name": 'Sarah',
"surname": 'Wilson',
"hospital_number": "2342232323",
"date_of_birth": date(1984, 1, 2),
"categories": ['Inpatient'],
"count": 3
}
self.assertEqual(summary.to_dict(), expected)


class QueryBackendTestCase(OpalTestCase):
Expand Down Expand Up @@ -522,7 +545,10 @@ def test_episode_fkorft_for_contains_synonym_name_and_ft(self):
hound_owner.dog = "Dalwinion"
hound_owner.save()
query = queries.DatabaseQuery(self.user, [criteria])
self.assertEqual([self.episode, episode_2], query.get_episodes())
self.assertEqual(
set([self.episode, episode_2]),
set(query.get_episodes())
)

def test_episode_fkorft_contains_distinct(self):
criteria = dict(
Expand Down Expand Up @@ -782,6 +808,32 @@ def test_update_patient_summaries(self):
}]
self.assertEqual(expected, summaries)

def test_allows_override_of_patient_summary_class(self):
class MyPatientSummary(queries.PatientSummary):
def to_dict(self):
result = super().to_dict()
result['overridden'] = True
return result

class MySearchBackEnd(queries.DatabaseQuery):
patient_summary_class = MyPatientSummary

query = MySearchBackEnd(self.user, self.name_criteria)
summaries = query.get_patient_summaries()
expected = [{
'count': 1,
'hospital_number': u'0',
'date_of_birth': self.DATE_OF_BIRTH,
'first_name': u'Sally',
'surname': u'Stevens',
'end': self.DATE_OF_EPISODE,
'start': self.DATE_OF_EPISODE,
'patient_id': self.patient.id,
'overridden': True,
'categories': [u'Inpatient']
}]
self.assertEqual(expected, summaries)


class CreateQueryTestCase(OpalTestCase):

Expand Down

0 comments on commit 057dfe5

Please sign in to comment.