diff --git a/changelog.md b/changelog.md index 3321fd953..313c0f668 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,31 @@ ### 0.23.0 (Major Release) +#### Allows the patient summary class to be overridden. + +You can now override the default episode serialization in the serach functionality. + +Serialization is done by default with the PatientSummary class. This can be overridden in a custom search backend. + +e.g. adding title to the serialization + +``` +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 future filter now accepts strings The filter `future` now accepts a string as outputted by the Opal date serialization diff --git a/doc/docs/guides/search.md b/doc/docs/guides/search.md index 8adb22b86..28a395308 100644 --- a/doc/docs/guides/search.md +++ b/doc/docs/guides/search.md @@ -16,6 +16,36 @@ The backend takes in a dictionary with the following fields } ``` +## Overriding what is serialized in the search results +By default the class `opal.core.search.queries.PatientSummary` is used to serialize search results to the front end. This can be overridden by declaring a custom class on a custom backend. + +The class is initialised with the patient and the episodes and is then serialized with the `to_json` method. + +With a custom backend you can declare your own `patient_summary_class` allowing you to control how and what gets serialized. + + +e.g. adding title to the serialization + +``` +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 Advanced search interface The Opal advanced search interface at `/#/extract` allows users to specify rules diff --git a/doc/docs/reference/javascript/patient_summary_service.md b/doc/docs/reference/javascript/patient_summary_service.md index 0a49e19df..1ace55fb2 100644 --- a/doc/docs/reference/javascript/patient_summary_service.md +++ b/doc/docs/reference/javascript/patient_summary_service.md @@ -6,8 +6,18 @@ 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); + var patientSummary = new PatientSummary(json_data); +Whatever is on the JSON response is put onto patientSummary.data. The constructor adds in the below to the object itself: + + * `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" diff --git a/opal/core/search/queries.py b/opal/core/search/queries.py index ff8ae9a10..00a34ba15 100644 --- a/opal/core/search/queries.py +++ b/opal/core/search/queries.py @@ -3,6 +3,7 @@ """ import datetime import operator +from collections import defaultdict from functools import reduce from django.contrib.contenttypes.models import ContentType @@ -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): @@ -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 @@ -109,6 +110,7 @@ class DatabaseQuery(QueryBackend): Finally we filter based on episode type level restrictions. """ + patient_summary_class = PatientSummary def fuzzy_query(self): """ @@ -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 = [ diff --git a/opal/core/search/static/js/search/services/patient_summary.js b/opal/core/search/static/js/search/services/patient_summary.js index db264ead9..f66dda882 100644 --- a/opal/core/search/static/js/search/services/patient_summary.js +++ b/opal/core/search/static/js/search/services/patient_summary.js @@ -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; +}); diff --git a/opal/core/search/tests/test_search_query.py b/opal/core/search/tests/test_search_query.py index 5441b5fb3..d1a631943 100644 --- a/opal/core/search/tests/test_search_query.py +++ b/opal/core/search/tests/test_search_query.py @@ -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): @@ -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( @@ -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): diff --git a/opal/core/search/tests/test_views.py b/opal/core/search/tests/test_views.py index 72fa4e959..82df3b7c9 100644 --- a/opal/core/search/tests/test_views.py +++ b/opal/core/search/tests/test_views.py @@ -256,7 +256,7 @@ def test_number_of_queries(self): "James", "Bond", str(i) ) - with self.assertNumQueries(26): + with self.assertNumQueries(24): self.get_response('{}/?query=Bond'.format(self.url)) for i in range(20): @@ -264,7 +264,7 @@ def test_number_of_queries(self): "James", "Blofelt", str(i) ) - with self.assertNumQueries(26): + with self.assertNumQueries(24): self.get_response('{}/?query=Blofelt'.format(self.url)) def test_with_multiple_patient_episodes(self): diff --git a/opal/static/js/test/patient_summary.service.test.js b/opal/static/js/test/patient_summary.service.test.js index a106194de..f135541c4 100644 --- a/opal/static/js/test/patient_summary.service.test.js +++ b/opal/static/js/test/patient_summary.service.test.js @@ -1,4 +1,4 @@ -describe('PatientSummary', function (){ +fdescribe('PatientSummary', function (){ "use strict"; var PatientSummary; var testData; @@ -33,6 +33,20 @@ describe('PatientSummary', function (){ expect(patientSummary.patientId).toEqual(192); }); + it("should not error if the json object we use to initialize it is unexpected", function(){ + var testData = {"title": "Dr"}; + var patientSummary = new PatientSummary(testData); + expect(patientSummary.data.title).toBe("Dr") + }); + + it('should cast start date and end date to moments', function(){ + testData.start_date = "10/10/1973"; + testData.end_date = "10/10/1974"; + var patientSummary = new PatientSummary(testData); + expect(patientSummary.startDate.toDate()).toEqual(new Date(1973, 9, 10)); + expect(patientSummary.endDate.toDate()).toEqual(new Date(1974, 9, 10)); + }); + it("should populate years if they exist", function(){ testData.start_date = "10/10/1973"; testData.end_date = "10/10/1974";