diff --git a/.env.example b/.env.example
index d0e096b39..f726c2c8a 100644
--- a/.env.example
+++ b/.env.example
@@ -82,7 +82,6 @@ FLOWER_PASSWORD=Root123
# -----------------------------------------------------------------------------
# FHIR Validator
# -----------------------------------------------------------------------------
-FHIR_VALIDATOR_URL=http://fhir_validator:3500
# -----------------------------------------------------------------------------
# Error tracking (optional)
diff --git a/.pylintrc b/.pylintrc
index cc0e2c9ff..de482fed9 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,6 +1,7 @@
[MESSAGES CONTROL]
disable=missing-docstring,too-few-public-methods,no-member,too-many-ancestors,attribute-defined-outside-init,fixme,broad-except,import-outside-toplevel,too-many-instance-attributes,unsupported-membership-test,too-many-public-methods,too-many-lines,unused-private-member,consider-using-with,duplicate-code,unspecified-encoding,invalid-name,cyclic-import,missing-timeout,too-many-positional-arguments
[MASTER]
+extension-pkg-allow-list=lxml
ignore=migrations,fixtures,scripts,commands,documents.py,password_validation.py,v1_dump
[FORMAT]
max-line-length=120
diff --git a/core/bundles/serializers.py b/core/bundles/serializers.py
index 8fbd307cd..be9a7ee24 100644
--- a/core/bundles/serializers.py
+++ b/core/bundles/serializers.py
@@ -21,7 +21,7 @@ class BundleLinkSerializer(CommonSerializer):
class BundleMetaSerializer(CommonSerializer):
- lastUpdated = DateTimeField(read_only=True, default=datetime.now())
+ lastUpdated = DateTimeField(read_only=True, default=datetime.now)
class BundleExtensionSerializer(CommonSerializer):
@@ -33,7 +33,9 @@ class BundleExtensionSerializer(CommonSerializer):
class BundleEntrySerializer(CommonSerializer):
+ fullUrl = CharField(required=False)
resource = JSONField()
+ search = JSONField(required=False)
class FHIRBundleSerializer(CommonSerializer):
@@ -53,17 +55,29 @@ def get_resource_type(cls, _obj):
return 'Bundle'
@classmethod
- def convert_to_entry(cls, resources):
+ def convert_to_entry(cls, resources, base_url):
+ """
+ Searchset entries: each resource a match with its fullUrl (base_url is the base and resource type), and a
+ resource that couldn't be represented (an OperationOutcome) an outcome.
+ """
entry = []
for resource in resources:
- entry.append({'resource': resource})
+ if resource.get('resourceType') == 'OperationOutcome':
+ entry.append({'resource': resource, 'search': {'mode': 'outcome'}})
+ else:
+ entry.append(
+ {'fullUrl': f"{base_url}/{resource['id']}", 'resource': resource, 'search': {'mode': 'match'}})
return entry
def get_link(self, _):
+ """self, previous and next, for the page the paginator holds; only self without one (_count=0)"""
paginator = self.context.get('paginator')
+ if paginator is None:
+ return BundleLinkSerializer(
+ [{'relation': 'self', 'url': self.context['request'].build_absolute_uri()}], many=True).data
links = [{'relation': 'self', 'url': paginator.get_current_page_url()}]
if paginator.has_previous():
- links.append({'relation': 'prev', 'url': paginator.get_previous_page_url()})
+ links.append({'relation': 'previous', 'url': paginator.get_previous_page_url()})
if paginator.has_next():
links.append({'relation': 'next', 'url': paginator.get_next_page_url()})
return BundleLinkSerializer(links, many=True).data
diff --git a/core/code_systems/converter.py b/core/code_systems/converter.py
index c3ca2b2e0..d9cbe2872 100644
--- a/core/code_systems/converter.py
+++ b/core/code_systems/converter.py
@@ -1,4 +1,4 @@
-from fhir.resources.codesystem import CodeSystem
+from fhir.resources.R4B.codesystem import CodeSystem # R4B models are the R4 wire models
from core.settings import DEFAULT_LOCALE
from core.sources.models import Source
@@ -32,7 +32,7 @@ def to_fhir(source):
'language': source.default_locale,
'count': source.active_concepts,
}
- return CodeSystem(**code_system).dict()
+ return CodeSystem(**code_system).model_dump()
@staticmethod
def from_fhir(code_system):
diff --git a/core/code_systems/serializers.py b/core/code_systems/serializers.py
index 635d15261..c9164adea 100644
--- a/core/code_systems/serializers.py
+++ b/core/code_systems/serializers.py
@@ -13,23 +13,14 @@
from core.common.serializers import ReadSerializerMixin, StatusField, IdentifierSerializer
from core.concepts.models import Concept, ConceptName
from core.concepts.serializers import ConceptDetailSerializer
-from core.parameters.serializers import ParametersSerializer
+from core.fhir import search as fhir_search
+from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin, TitleField
from core.sources.models import Source
from core.sources.serializers import SourceCreateOrUpdateSerializer
logger = logging.getLogger('oclapi')
-class ValidateCodeParametersSerializer(ParametersSerializer):
- allowed_input_parameters = {
- 'url': 'valueUri',
- 'code': 'valueCode',
- 'display': 'valueString',
- 'version': 'valueString',
- 'systemVersion': 'valueString',
- 'system': 'valueUri'
- }
-
class CodeSystemConceptDesignationUseSerializer(serializers.Field):
def to_internal_value(self, data):
if 'code' in data:
@@ -89,10 +80,10 @@ def to_internal_value(self, data):
return {'descriptions': [{'description': data, 'locale': settings.DEFAULT_LOCALE, 'locale_preferred': True}]}
def to_representation(self, value):
- descriptions = value.descriptions_for_default_locale
- if descriptions and len(descriptions) > 0:
- return value.descriptions_for_default_locale[0]
- return None
+ """The first description in the default locale, from the descriptions a read prefetches."""
+ descriptions = [description.name for description in value.descriptions.all()
+ if description.locale == settings.DEFAULT_LOCALE and not description.retired]
+ return descriptions[0] if descriptions else None
class CodeSystemConceptSerializer(ReadSerializerMixin, serializers.Serializer):
@@ -149,13 +140,9 @@ def to_internal_value(self, data):
return {'concepts': concepts.validated_data}
def to_representation(self, value):
- # limit to 1000 concepts by default
- # TODO: support graphQL to go around the limit
- if self.context.get('has_many', False):
- limit = 25
- else:
- limit = 1000
- return CodeSystemConceptSerializer(value.concepts.order_by('id')[:limit], many=True).data
+ """Every concept, retired ones included, up to the limit; none above it (content not-present)."""
+ concepts = fhir_search.code_system_concepts(value)[1]
+ return CodeSystemConceptSerializer(concepts, many=True).data if concepts else []
class TextField(ReadSerializerMixin, serializers.Serializer):
@@ -178,11 +165,12 @@ def to_representation(self, instance):
return super().to_representation(obj)
-class CodeSystemDetailSerializer(serializers.ModelSerializer):
+class CodeSystemDetailSerializer(FhirRepositorySerializerMixin, serializers.ModelSerializer):
+ content_field = 'concept'
resourceType = SerializerMethodField(method_name='get_resource_type')
id = CharField(source='mnemonic')
- url = CharField(source='canonical_url', required=False)
- title = CharField(source='full_name', required=False)
+ url = CanonicalUrlField(source='canonical_url', required=False)
+ title = TitleField(source='full_name', required=False)
status = StatusField(source='*')
language = CharField(source='default_locale', required=False)
count = SerializerMethodField(method_name='get_total_count')
@@ -210,21 +198,20 @@ class Meta:
'copyright', 'revisionDate', 'experimental', 'caseSensitive', 'compositional', 'versionNeeded',
'collectionReference', 'hierarchyMeaning', 'concept', 'text', 'filters')
- def __new__(cls, *args, **kwargs):
- if kwargs.get('many', False):
- context = kwargs.get('context', {})
- context.update({'has_many': True})
- kwargs.update({'context': context})
-
- return super().__new__(cls, *args, **kwargs)
-
@staticmethod
def get_resource_type(_):
return RESOURCE_TYPE
+ def get_total_count(self, obj):
+ """A read's concept count; a search's summary has none (it only checks the limit)."""
+ return None if self.context.get('has_many') else fhir_search.code_system_concepts(obj)[0]
+
@staticmethod
- def get_total_count(obj):
- return obj.concepts.count()
+ def get_content(obj):
+ """complete (or the content mode the CodeSystem was saved with) up to the concept limit, else not-present"""
+ if fhir_search.concepts_over_limit(obj):
+ return 'not-present'
+ return obj.content_type if obj.content_type in ('example', 'fragment', 'supplement') else 'complete'
@staticmethod
def get_property(obj):
@@ -264,11 +251,11 @@ def get_meta(obj):
def to_representation(self, instance):
try:
- if not instance.content_type:
- instance.content_type = 'complete'
rep = super().to_representation(instance)
+ rep['content'] = self.get_content(instance)
delete_empty_fields(rep)
IdentifierSerializer.include_ocl_identifier(instance.uri, RESOURCE_TYPE, rep)
+ self.with_fhir_view(instance, rep)
except (Exception, ):
msg = f'Failed to represent "{instance.uri}" as {RESOURCE_TYPE}'
logger.exception(msg)
diff --git a/core/code_systems/tests.py b/core/code_systems/tests.py
index ff5384f11..517ec5d39 100644
--- a/core/code_systems/tests.py
+++ b/core/code_systems/tests.py
@@ -1,12 +1,11 @@
import json
-from fhir.resources.codesystem import CodeSystem
+from fhir.resources.R4B.codesystem import CodeSystem
from mock.mock import patch, Mock
from rest_framework.test import APIClient
from core.code_systems.converter import CodeSystemConverter
from core.code_systems.serializers import CodeSystemDetailSerializer
-from core.code_systems.views import CodeSystemLookupNotFoundError
from core.common.constants import ACCESS_TYPE_NONE
from core.common.tests import OCLTestCase
from core.concepts.models import Concept
@@ -14,17 +13,23 @@
from core.orgs.tests.factories import OrganizationFactory
from core.sources.models import Source
from core.sources.tests.factories import OrganizationSourceFactory, UserSourceFactory
+from core.url_registry.tests_resolution import set_registry_toggle
from core.users.tests.factories import UserProfileFactory
+SYSTEM = 'http://some/url'
+PRIVATE = 'http://private.example/url'
+SHARED = 'http://shared.example/url'
+
class CodeSystemTest(OCLTestCase):
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def setUp(self):
+ set_registry_toggle(False) # the operations resolve through core.fhir.resolve: no registry, as in production
super().setUp()
self.org = OrganizationFactory()
- self.org_source = OrganizationSourceFactory(organization=self.org, canonical_url='/some/url',
+ self.org_source = OrganizationSourceFactory(organization=self.org, canonical_url=SYSTEM,
full_name='source name')
self.org_source_v1 = OrganizationSourceFactory.build(
version='v1', mnemonic=self.org_source.mnemonic, organization=self.org_source.parent)
@@ -32,20 +37,20 @@ def setUp(self):
self.concept_1 = ConceptFactory(parent=self.org_source, names=1, names__name="concept_1_name")
self.concept_2 = ConceptFactory(parent=self.org_source)
self.org_source_v2 = OrganizationSourceFactory.build(
- version='v2', mnemonic=self.org_source.mnemonic, organization=self.org_source.parent)
+ version='v2', mnemonic=self.org_source.mnemonic, organization=self.org_source.parent, released=True)
Source.persist_new_version(self.org_source_v2, self.org_source.created_by)
self.user = UserProfileFactory()
self.user_token = self.user.get_token()
- self.user_source = UserSourceFactory(user=self.user, public_access='None', canonical_url='/some/url')
+ self.user_source = UserSourceFactory(user=self.user, public_access='None', canonical_url=SYSTEM)
self.user_source_v1 = UserSourceFactory.build(
- version='v1', mnemonic=self.user_source.mnemonic, user=self.user_source.parent)
+ version='v1', mnemonic=self.user_source.mnemonic, user=self.user_source.parent, released=True)
Source.persist_new_version(self.user_source_v1, self.user_source.created_by)
self.client = APIClient()
def test_public_can_view(self):
- response = self.client.get('/fhir/CodeSystem/?url=/some/url')
+ response = self.client.get(f'/fhir/CodeSystem/?url={SYSTEM}')
self.assertEqual(len(response.data['entry']), 1)
self.assertEqual(response.data['total'], 1)
@@ -56,28 +61,36 @@ def test_public_can_view(self):
'/orgs/' + self.org.mnemonic + '/CodeSystem/' + self.org_source.mnemonic + '/'
)
self.assertEqual(resource['version'], 'v2')
+ self.assertNotIn('concept', resource) # a search entry is a summary; the read has the concepts
+
+ resource = self.client.get(response.data['entry'][0]['fullUrl']).data
self.assertEqual(len(resource['concept']), 2)
self.assertEqual(resource['concept'][0]['code'], self.concept_1.mnemonic)
self.assertEqual(resource['concept'][1]['code'], self.concept_2.mnemonic)
def test_private_can_view(self):
- response = self.client.get('/fhir/CodeSystem/?url=/some/url', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ # the global base shows public repositories only, whoever asks; the owner base shows the owner its own
+ response = self.client.get(f'/fhir/CodeSystem/?url={SYSTEM}', HTTP_AUTHORIZATION='Token ' + self.user_token)
+
+ self.assertEqual(response.data['total'], 1)
+ resource = response.data['entry'][0]['resource']
+ self.assertEqual(
+ resource['identifier'][0]['value'],
+ '/orgs/' + self.org.mnemonic + '/CodeSystem/' + self.org_source.mnemonic + '/'
+ )
+ self.assertEqual(resource['version'], 'v2')
- self.assertEqual(len(response.data['entry']), 2)
- self.assertEqual(response.data['total'], 2)
+ response = self.client.get(
+ f'/users/{self.user.mnemonic}/CodeSystem/?url={SYSTEM}', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ self.assertEqual(response.data['total'], 1)
resource = response.data['entry'][0]['resource']
self.assertEqual(
resource['identifier'][0]['value'],
'/users/' + self.user.mnemonic + '/CodeSystem/' + self.user_source.mnemonic + '/'
)
self.assertEqual(resource['version'], 'v1')
- resource_2 = response.data['entry'][1]['resource']
- self.assertEqual(
- resource_2['identifier'][0]['value'],
- '/orgs/' + self.org.mnemonic + '/CodeSystem/' + self.org_source.mnemonic + '/'
- )
- self.assertEqual(resource_2['version'], 'v2')
+ self.assertEqual(self.client.get(f'/users/{self.user.mnemonic}/CodeSystem/?url={SYSTEM}').data['total'], 0)
def test_public_can_list(self):
response = self.client.get('/fhir/CodeSystem/')
@@ -132,19 +145,24 @@ def test_find_by_non_supported_param(self):
def test_private_can_list(self):
response = self.client.get('/fhir/CodeSystem/', HTTP_AUTHORIZATION='Token ' + self.user_token)
- self.assertEqual(len(response.data['entry']), 2)
+ self.assertEqual(len(response.data['entry']), 1)
resource = response.data['entry'][0]['resource']
self.assertEqual(
resource['identifier'][0]['value'],
- '/users/' + self.user.mnemonic + '/CodeSystem/' + self.user_source.mnemonic + '/'
+ '/orgs/' + self.org.mnemonic + '/CodeSystem/' + self.org_source.mnemonic + '/'
)
- self.assertEqual(resource['version'], 'v1')
- resource_2 = response.data['entry'][1]['resource']
+ self.assertEqual(resource['version'], 'v2')
+
+ response = self.client.get(
+ f'/users/{self.user.mnemonic}/CodeSystem/', HTTP_AUTHORIZATION='Token ' + self.user_token)
+
+ self.assertEqual(len(response.data['entry']), 1)
+ resource = response.data['entry'][0]['resource']
self.assertEqual(
- resource_2['identifier'][0]['value'],
- '/orgs/' + self.org.mnemonic + '/CodeSystem/' + self.org_source.mnemonic + '/'
+ resource['identifier'][0]['value'],
+ '/users/' + self.user.mnemonic + '/CodeSystem/' + self.user_source.mnemonic + '/'
)
- self.assertEqual(resource_2['version'], 'v2')
+ self.assertEqual(resource['version'], 'v1')
def test_get_code_system(self):
response = self.client.get(f'/orgs/{self.org.mnemonic}/CodeSystem/{self.org_source.mnemonic}/')
@@ -153,13 +171,29 @@ def test_get_code_system(self):
self.assertEqual(response.data['id'], self.org_source.mnemonic)
self.assertEqual(response.data['version'], 'v2')
+ def valid(self, concept):
+ """$validate-code's answer for a valid code of the org source, at the version resolved (v2)."""
+ return {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'result', 'valueBoolean': True},
+ {'name': 'display', 'valueString': concept.display_name},
+ {'name': 'code', 'valueCode': concept.mnemonic},
+ {'name': 'system', 'valueUri': SYSTEM},
+ {'name': 'version', 'valueString': 'v2'}]}
+
+ def assert_issues(self, response, *issues):
+ """The answer is result false, with these issues (code, tx-issue-type, expression)."""
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['parameter'][0], {'name': 'result', 'valueBoolean': False})
+ outcome = next(item['resource'] for item in response.data['parameter'] if item['name'] == 'issues')
+ self.assertEqual([(item['code'], item['details']['coding'][0]['code'], item['expression'])
+ for item in outcome['issue']], list(issues))
+
def test_validate_code_for_code_system(self):
response = self.client.get(f'/fhir/CodeSystem/$validate-code/'
f'?url={self.org_source.canonical_url}&code={self.concept_1.mnemonic}')
self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [{'name': 'result', 'valueBoolean': True}]}))
+ self.assertJSONEqual(json.dumps(response.data), json.dumps(self.valid(self.concept_1)))
def test_validate_code_for_code_system_via_post(self):
response = self.client.post(
@@ -175,28 +209,19 @@ def test_validate_code_for_code_system_via_post(self):
)
self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [{'name': 'result', 'valueBoolean': True}]}))
+ self.assertJSONEqual(json.dumps(response.data), json.dumps(self.valid(self.concept_1)))
def test_validate_code_for_code_system_negative(self):
response = self.client.get(f'/fhir/CodeSystem/$validate-code/'
f'?url={self.org_source.canonical_url}&code=non_existing_code')
- self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [
- {'name': 'result', 'valueBoolean': False}
- ]}))
+ self.assert_issues(response, ('code-invalid', 'invalid-code', ['code']))
def test_validate_code_for_code_system_with_invalid_url_negative(self):
response = self.client.get(f'/fhir/CodeSystem/$validate-code/'
f'?url=non_existing_url&code=${self.concept_1.mnemonic}')
- self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [
- {'name': 'result', 'valueBoolean': False}
- ]}))
+ self.assert_issues(response, ('not-found', 'not-found', ['system']))
def test_validate_code_with_display_for_code_system(self):
response = self.client.get(f'/fhir/CodeSystem/$validate-code/'
@@ -205,8 +230,7 @@ def test_validate_code_with_display_for_code_system(self):
f'&display={self.concept_1.display_name}')
self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [{'name': 'result', 'valueBoolean': True}]}))
+ self.assertJSONEqual(json.dumps(response.data), json.dumps(self.valid(self.concept_1)))
def test_validate_code_with_display_for_code_system_negative(self):
response = self.client.get(f'/fhir/CodeSystem/$validate-code/'
@@ -214,11 +238,7 @@ def test_validate_code_with_display_for_code_system_negative(self):
f'&code={self.concept_1.mnemonic}'
f'&display=wrong_display')
- self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [
- {'name': 'result', 'valueBoolean': False}
- ]}))
+ self.assert_issues(response, ('invalid', 'invalid-display', ['display']))
def test_lookup_for_code_system(self):
response = self.client.get(f'/fhir/CodeSystem/$lookup/'
@@ -228,97 +248,115 @@ def test_lookup_for_code_system(self):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(json.dumps(response.data), json.dumps(
{'resourceType': 'Parameters', 'parameter': [
- {'name': 'name', 'valueString': self.org_source.mnemonic},
+ {'name': 'name', 'valueString': Source.objects.get(id=self.org_source_v2.id).name},
{'name': 'version', 'valueString': self.org_source_v2.version},
- {'name': 'display', 'valueString': self.concept_1.display_name}]}))
+ {'name': 'display', 'valueString': self.concept_1.display_name},
+ {'name': 'code', 'valueCode': self.concept_1.mnemonic},
+ {'name': 'system', 'valueUri': SYSTEM},
+ {'name': 'abstract', 'valueBoolean': False},
+ {'name': 'designation', 'part': [
+ {'name': 'language', 'valueCode': 'en'},
+ {'name': 'use', 'valueCoding': {'code': 'FULLY_SPECIFIED'}},
+ {'name': 'value', 'valueString': 'concept_1_name'}]},
+ {'name': 'property', 'part': [
+ {'name': 'code', 'valueCode': 'inactive'}, {'name': 'value', 'valueBoolean': False}]}]}))
def test_lookup_for_empty_code_system(self):
response = self.client.get('/fhir/CodeSystem/$lookup/?system=&code=')
self.assertEqual(response.status_code, 400)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(CodeSystemLookupNotFoundError().detail))
+ self.assertEqual(response.data['issue'][0]['expression'], ['code'])
def test_lookup_for_code_system_with_new_canonical(self):
- self.org_source.canonical_url = '/new/url'
+ """A code system is found by its repository's current canonical URL, not by one a version recorded."""
+ self.org_source.canonical_url = 'http://new/url'
Source.save(self.org_source)
response = self.client.get(f'/fhir/CodeSystem/$lookup/'
- f'?system={self.org_source.canonical_url}'
+ f'?system={self.org_source_v1.canonical_url}'
f'&code={self.concept_1.mnemonic}')
- self.assertEqual(response.status_code, 400)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(CodeSystemLookupNotFoundError().detail))
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(response.data['issue'][0]['code'], 'not-found')
- response = self.client.get(f'/fhir/CodeSystem/$lookup/'
- f'?system={self.org_source_v1.canonical_url}'
- f'&code={self.concept_1.mnemonic}')
+ response = self.client.get(f'/fhir/CodeSystem/$lookup/?system=http://new/url&code={self.concept_1.mnemonic}')
self.assertEqual(response.status_code, 200)
- self.assertJSONEqual(json.dumps(response.data), json.dumps(
- {'resourceType': 'Parameters', 'parameter': [
- {'name': 'name', 'valueString': self.org_source.mnemonic},
- {'name': 'version', 'valueString': self.org_source_v2.version},
- {'name': 'display', 'valueString': self.concept_1.display_name}]}))
+ self.assertEqual(response.data['parameter'][1], {'name': 'version', 'valueString': self.org_source_v2.version})
+ self.assertEqual(response.data['parameter'][2], {'name': 'display', 'valueString': self.concept_1.display_name})
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_private_code_system_answers_as_missing_for_non_viewers(self): # pylint: disable=too-many-locals
+ """
+ A code system the requester can't view answers exactly as one that doesn't exist: at the global base, which
+ shows public code systems only, and at its owner's base to anyone but the owner's members and staff.
+ """
private_org = OrganizationFactory()
private_source = OrganizationSourceFactory(
- organization=private_org, canonical_url='/private/url', public_access=ACCESS_TYPE_NONE)
+ organization=private_org, canonical_url=PRIVATE, public_access=ACCESS_TYPE_NONE)
concept = ConceptFactory(parent=private_source, names=1, names__name='private_concept_name')
Source.persist_new_version(OrganizationSourceFactory.build(
version='v1', mnemonic=private_source.mnemonic, organization=private_org,
- public_access=ACCESS_TYPE_NONE), private_source.created_by)
+ public_access=ACCESS_TYPE_NONE, released=True), private_source.created_by)
member = UserProfileFactory()
private_org.members.add(member)
outsider = {'HTTP_AUTHORIZATION': 'Token ' + self.user_token}
- validate_url = f'/fhir/CodeSystem/$validate-code/?url=/private/url&code={concept.mnemonic}'
- lookup_url = '/fhir/CodeSystem/$lookup/?system={}&code={}'
- not_found = self.client.get(lookup_url.format('/unknown/url', concept.mnemonic))
- self.assertEqual(not_found.status_code, 400)
+ member_headers = {'HTTP_AUTHORIZATION': 'Token ' + member.get_token()}
+ owner_base = f'/orgs/{private_org.mnemonic}'
+ validate_url = '{}/CodeSystem/$validate-code/?url={}&code={}'
+ lookup_url = '{}/CodeSystem/$lookup/?system={}&code={}'
public_concept = ConceptFactory()
selector = (f'&org={public_concept.parent.organization.mnemonic}&source={public_concept.parent.mnemonic}'
f'&concept={public_concept.mnemonic}')
- for headers in [{}, outsider]:
+ hidden = []
+ for base, headers in [('/fhir', {}), ('/fhir', outsider), ('/fhir', member_headers), (owner_base, {}),
+ (owner_base, outsider)]:
# without a code, a code system the requester can't view still answers as one that doesn't exist
- for system_url in ['/private/url', '/unknown/url']:
- response = self.client.get(f'/fhir/CodeSystem/$validate-code/?url={system_url}{selector}', **headers)
- self.assertFalse(response.data['parameter'][0]['valueBoolean'], (system_url, headers))
- for url in [validate_url, f'{validate_url}&display={concept.display_name}']:
- response = self.client.get(url, **headers)
- self.assertEqual(response.status_code, 200, (url, headers))
- self.assertFalse(response.data['parameter'][0]['valueBoolean'], (url, headers))
+ response = self.client.get(f'{base}/CodeSystem/$validate-code/?url={PRIVATE}{selector}', **headers)
+ self.assertFalse(response.data['parameter'][0]['valueBoolean'], (base, headers))
+ hidden.append((f'{base}/CodeSystem/$validate-code/?url={PRIVATE}{selector}', headers, response))
for code in [concept.mnemonic, 'missing']:
- response = self.client.get(lookup_url.format('/private/url', code), **headers)
- self.assertEqual(response.status_code, 400, (code, headers))
- self.assertEqual(response.content, not_found.content, (code, headers))
-
- member_headers = {'HTTP_AUTHORIZATION': 'Token ' + member.get_token()}
- response = self.client.get(validate_url, **member_headers)
+ for url in [validate_url.format(base, PRIVATE, code),
+ validate_url.format(base, PRIVATE, code) + f'&display={concept.display_name}']:
+ response = self.client.get(url, **headers)
+ self.assertEqual(response.status_code, 200, (url, headers))
+ self.assertFalse(response.data['parameter'][0]['valueBoolean'], (url, headers))
+ hidden.append((url, headers, response))
+ response = self.client.get(lookup_url.format(base, PRIVATE, code), **headers)
+ self.assertEqual(response.status_code, 422, (code, headers))
+ hidden.append((lookup_url.format(base, PRIVATE, code), headers, response))
+
+ response = self.client.get(validate_url.format(owner_base, PRIVATE, concept.mnemonic), **member_headers)
self.assertTrue(response.data['parameter'][0]['valueBoolean'])
- response = self.client.get(lookup_url.format('/private/url', concept.mnemonic), **member_headers)
+ response = self.client.get(lookup_url.format(owner_base, PRIVATE, concept.mnemonic), **member_headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parameter'][2]['valueString'], concept.display_name)
+ private_source.is_active = False # now it doesn't exist
+ private_source.save()
+ for url, headers, response in hidden: # each against the same request, at the same base, once it's missing
+ not_found = self.client.get(url, **headers)
+ self.assertEqual((response.status_code, response.content), (not_found.status_code, not_found.content))
+
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_private_code_system_does_not_hide_public_one_with_same_url(self):
- private_source = UserSourceFactory(user=self.user, public_access=ACCESS_TYPE_NONE, canonical_url='/shared/url')
+ private_source = UserSourceFactory(user=self.user, public_access=ACCESS_TYPE_NONE, canonical_url=SHARED)
Source.persist_new_version(UserSourceFactory.build(
- version='v1', mnemonic=private_source.mnemonic, user=self.user), private_source.created_by)
- public_source = OrganizationSourceFactory(canonical_url='/shared/url')
+ version='v1', mnemonic=private_source.mnemonic, user=self.user, released=True), private_source.created_by)
+ public_source = OrganizationSourceFactory(canonical_url=SHARED)
concept = ConceptFactory(parent=public_source)
Source.persist_new_version(OrganizationSourceFactory.build(
- version='v1', mnemonic=public_source.mnemonic, organization=public_source.organization),
+ version='v1', mnemonic=public_source.mnemonic, organization=public_source.organization, released=True),
public_source.created_by)
- response = self.client.get(f'/fhir/CodeSystem/$lookup/?system=/shared/url&code={concept.mnemonic}')
+ response = self.client.get(f'/fhir/CodeSystem/$lookup/?system={SHARED}&code={concept.mnemonic}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parameter'][0]['valueString'], public_source.mnemonic)
- response = self.client.get(f'/fhir/CodeSystem/$validate-code/?url=/shared/url&code={concept.mnemonic}')
+ response = self.client.get(f'/fhir/CodeSystem/$validate-code/?url={SHARED}&code={concept.mnemonic}')
self.assertTrue(response.data['parameter'][0]['valueBoolean'])
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
diff --git a/core/code_systems/tests_converter.py b/core/code_systems/tests_converter.py
new file mode 100644
index 000000000..5fdbfd346
--- /dev/null
+++ b/core/code_systems/tests_converter.py
@@ -0,0 +1,34 @@
+import warnings
+
+from django.test import SimpleTestCase
+from fhir.resources.R4B.codesystem import CodeSystem
+
+from core.code_systems.converter import CodeSystemConverter
+from core.sources.tests.factories import OrganizationSourceFactory
+
+
+class CodeSystemConverterR4Test(SimpleTestCase):
+ """The converter speaks R4 through fhir.resources 8's R4B models, without its deprecated API."""
+
+ def test_to_fhir_builds_an_r4_code_system(self):
+ source = OrganizationSourceFactory.build(
+ mnemonic='test', canonical_url='http://example.org/cs', name='Test', default_locale='en', released=True)
+
+ with warnings.catch_warnings():
+ warnings.simplefilter('error')
+ code_system = CodeSystemConverter.to_fhir(source)
+
+ self.assertEqual(code_system['resourceType'], 'CodeSystem')
+ self.assertEqual(code_system['url'], 'http://example.org/cs')
+ self.assertEqual(code_system['status'], 'active')
+ CodeSystem.model_validate(code_system)
+
+ def test_from_fhir_reads_an_r4_code_system(self):
+ code_system = CodeSystem(id='test', status='retired', content='complete', url='http://example.org/cs')
+
+ self.assertTrue(CodeSystemConverter.can_convert_from_fhir(code_system))
+ source = CodeSystemConverter.from_fhir(code_system.model_dump())
+
+ self.assertEqual(source['mnemonic'], 'test')
+ self.assertEqual(source['canonical_url'], 'http://example.org/cs')
+ self.assertTrue(source['retired'])
diff --git a/core/code_systems/urls.py b/core/code_systems/urls.py
index b290d6b2b..5e72b41ef 100644
--- a/core/code_systems/urls.py
+++ b/core/code_systems/urls.py
@@ -4,10 +4,16 @@
urlpatterns = [
path('', views.CodeSystemListView.as_view(), name='code-system-list'),
- path('$lookup/', views.CodeSystemListLookupView.as_view(), name='code-system-list-lookup'),
- path('$lookup', views.CodeSystemListLookupView.as_view(), name='code-system-list-lookup_no_slash'),
+ path('$lookup/', views.CodeSystemLookupView.as_view(), name='code-system-list-lookup'),
+ path('$lookup', views.CodeSystemLookupView.as_view(), name='code-system-list-lookup_no_slash'),
path('$validate-code/', views.CodeSystemValidateCodeView.as_view(), name='code-system-validate-code'),
path('$validate-code', views.CodeSystemValidateCodeView.as_view(), name='code-system-validate-code_no_slash'),
+ path("/$lookup/", views.CodeSystemLookupView.as_view(), name='code-system-lookup'),
+ path("/$lookup", views.CodeSystemLookupView.as_view(), name='code-system-lookup_no_slash'),
+ path("/$validate-code/", views.CodeSystemValidateCodeView.as_view(),
+ name='code-system-instance-validate-code'),
+ path("/$validate-code", views.CodeSystemValidateCodeView.as_view(),
+ name='code-system-instance-validate-code_no_slash'),
path("/", views.CodeSystemRetrieveUpdateView.as_view(), name='code-system-detail'),
path("", views.CodeSystemRetrieveUpdateView.as_view(), name='code-system-detail_no_slash'),
]
diff --git a/core/code_systems/views.py b/core/code_systems/views.py
index b216160d4..1c7d32a34 100644
--- a/core/code_systems/views.py
+++ b/core/code_systems/views.py
@@ -1,57 +1,21 @@
import logging
-from django.db.models import F
-from rest_framework.exceptions import ValidationError, NotAuthenticated, PermissionDenied
-
-from core.bundles.serializers import FHIRBundleSerializer
-from core.code_systems.serializers import CodeSystemDetailSerializer, \
- ValidateCodeParametersSerializer
+from core.code_systems.serializers import CodeSystemDetailSerializer
from core.common.constants import HEAD
-from core.common.fhir_helpers import translate_fhir_query
-from core.common.serializers import IdentifierSerializer
-from core.concepts.permissions import CanViewParentDictionaryAsGuest
-from core.concepts.views import ConceptRetrieveUpdateDestroyView
-from core.parameters.serializers import ParametersSerializer
-from core.sources.models import Source
+from core.fhir.adapters import r4
+from core.fhir.base import FhirViewMixin, FhirOperationView
+from core.fhir.ops.lookup import lookup
+from core.fhir.ops.validate_code import validate_code_system
+from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.sources.views import SourceListView, SourceRetrieveUpdateDestroyView
logger = logging.getLogger('oclapi')
-def get_viewable_repo(repos, user):
- """The first of these repo versions the user can view, skipping the rest as if they didn't exist. Its HEAD,
- which governs access to its concepts, must be viewable too."""
- for repo in repos.order_by('id'):
- head = repo.head
- if head and repo.has_view_access(user) and head.has_view_access(user):
- return repo
- return None
-
-
-class CodeSystemListView(SourceListView):
+class CodeSystemListView(FhirViewMixin, FhirSearchMixin, SourceListView):
+ resource_type = 'CodeSystem'
serializer_class = CodeSystemDetailSerializer
- @staticmethod
- def bundle_response(data, paginator):
- bundle = FHIRBundleSerializer(
- {'meta': {}, 'total': len(data), 'type': 'searchset', 'entry': FHIRBundleSerializer.convert_to_entry(data)},
- context={'paginator': paginator}
- )
- return bundle.data
-
- def get_filter_params(self, default_version_to_head=True):
- return super().get_filter_params(False)
-
- def apply_query_filters(self, queryset):
- query_fields = list(self.serializer_class.Meta.fields)
- query_fields.extend(['code'])
- queryset = translate_fhir_query(query_fields, self.request.query_params, queryset)
- return queryset
-
- def apply_filters(self, queryset):
- queryset = queryset.exclude(version=HEAD).filter(is_latest_version=True)
- return self.apply_query_filters(queryset)
-
def get_serializer_class(self):
return self.serializer_class
@@ -59,201 +23,22 @@ def get_detail_serializer(self, obj):
return self.serializer_class(obj)
-class CodeSystemLookupNotFoundError(ValidationError):
- def __init__(self, code: str = None):
- message = f"Code '{code}' not found" if code else "Code not found"
- super().__init__(
- detail={
- "resourceType": "OperationOutcome",
- "id": "exception",
- "text": {
- "status": "additional",
- "div": f'{message}
',
- },
- "issue": [
- {
- "severity": "error",
- "code": "not-found",
- "details": {"text": message},
- }
- ],
- }
- )
-
-
-class FhirParameterRequiredError(ValidationError):
- """A 400 OperationOutcome for an operation input that is required, naming it in `expression`."""
- def __init__(self, message: str, expression: str):
- super().__init__(
- detail={
- "resourceType": "OperationOutcome",
- "id": "exception",
- "text": {
- "status": "additional",
- "div": f'{message}
',
- },
- "issue": [
- {
- "severity": "error",
- "code": "invalid",
- "details": {"text": message},
- "expression": [expression],
- }
- ],
- }
- )
-
-
-class CodeSystemListLookupView(ConceptRetrieveUpdateDestroyView):
- serializer_class = ParametersSerializer
-
- def is_container_version_specified(self):
- return True
-
- def verify_scope(self):
- pass
-
- def get_queryset(self):
- queryset = super().get_queryset()
- code = self.request.query_params.get('code')
- system = self.request.query_params.get('system')
- version = self.request.query_params.get('version')
- if code and system:
- source = Source.objects.filter(canonical_url=system)
- if not source:
- system = IdentifierSerializer.convert_fhir_url_to_ocl_uri(system, 'sources')
- source = Source.objects.filter(uri=system)
- if source:
- if version:
- source = source.filter(version=version)
- else:
- source = source.filter(is_latest_version=True).exclude(version=HEAD)
-
- source = get_viewable_repo(source, self.request.user)
- if source:
- queryset = queryset.filter(sources=source, mnemonic=code)
- if not queryset.exists():
- raise CodeSystemLookupNotFoundError(code)
- return queryset
-
- raise CodeSystemLookupNotFoundError()
-
- def get_object(self, queryset=None):
- """A code the requester can't view gives the same answer as a code system that doesn't exist."""
- try:
- return super().get_object(queryset)
- except (NotAuthenticated, PermissionDenied) as ex:
- raise CodeSystemLookupNotFoundError() from ex
-
- def get_serializer(self, instance=None): # pylint: disable=arguments-differ
- if instance:
- return ParametersSerializer.from_concept(instance)
- return None
-
-
-class CodeSystemValidateCodeView(ConceptRetrieveUpdateDestroyView):
- serializer_class = ValidateCodeParametersSerializer
-
- def verify_scope(self):
- pass
-
- def post(self, request, *args, **kwargs):
- return self.retrieve(request, *args, **kwargs)
-
- def update(self, request, *args, **kwargs):
- return self.retrieve(request, *args, **kwargs)
-
- def delete(self, request, *args, **kwargs):
- # Not supported
- pass
-
- def is_container_version_specified(self):
- return True
-
- def get_permissions(self):
- return [CanViewParentDictionaryAsGuest(), ]
-
- def get_queryset(self):
- """The concept with the code in the code system `url` names. A code system that doesn't resolve, or that the
- requester can't view, reads nothing; without a code, or with a code but no url, the request is a 400."""
- queryset = super().get_queryset()
-
- parameters = self.get_parameters()
- url = parameters.get('url')
- code = parameters.get('code')
- version = parameters.get('version')
- display = parameters.get('display')
- source = None
- if url:
- source = Source.objects.filter(canonical_url=url)
- if not source:
- url = IdentifierSerializer.convert_fhir_url_to_ocl_uri(url, 'sources')
- source = Source.objects.filter(uri=url)
-
- if version:
- source = source.filter(version=version)
- else:
- source = source.filter(is_latest_version=True).exclude(version=HEAD)
- source = get_viewable_repo(source, self.request.user)
- if not source:
- return queryset.none()
-
- if not code:
- raise FhirParameterRequiredError('A code to validate is required.', expression='code')
- if not source:
- raise FhirParameterRequiredError(
- 'url is required with a code: the code system to validate it in.', expression='url')
-
- queryset = queryset.filter(sources=source, mnemonic=code)
-
- if display:
- instance = queryset.first()
- if not instance or display not in (instance.name, instance.display_name):
- return queryset.none()
-
- return queryset
-
- def get_parameters(self):
- if self.request.method in ['POST', 'PUT']:
- parameters = self.get_serializer(data=self.request.data, instance=None)
- else:
- parameters = self.get_serializer_class().parse_query_params(self.request.query_params)
- if not parameters.is_valid():
- raise ValidationError(parameters.errors)
- params = parameters.validated_data
- params = params.get('parameters', {})
- return params
-
- def get_object(self, queryset=None):
- queryset = self.get_queryset()
- if not self.is_container_version_specified():
- queryset = queryset.filter(id=F('versioned_object_id'))
- instance = queryset.first()
- if instance:
- try:
- self.check_object_permissions(self.request, instance)
- except (NotAuthenticated, PermissionDenied):
- return {
- 'parameter': [
- {'name': 'result', 'valueBoolean': False}
- ]
- }
+class CodeSystemLookupView(FhirOperationView):
+ """CodeSystem/$lookup and CodeSystem/{id}/$lookup: core.fhir.ops.lookup."""
+ instance = 'source'
+ operation = staticmethod(lookup)
+ adapter = staticmethod(r4.lookup_parameters)
- if instance:
- return {
- 'parameter': [
- {'name': 'result', 'valueBoolean': True}
- ]
- }
- return {
- 'parameter': [
- {'name': 'result', 'valueBoolean': False}
- ]
- }
+class CodeSystemValidateCodeView(FhirOperationView):
+ """CodeSystem/$validate-code and CodeSystem/{id}/$validate-code: core.fhir.ops.validate_code."""
+ instance = 'source'
+ operation = staticmethod(validate_code_system)
+ adapter = staticmethod(r4.validate_code_parameters)
-class CodeSystemRetrieveUpdateView(SourceRetrieveUpdateDestroyView):
+class CodeSystemRetrieveUpdateView(FhirViewMixin, FhirReadMixin, SourceRetrieveUpdateDestroyView):
+ resource_type = 'CodeSystem'
serializer_class = CodeSystemDetailSerializer
def get_filter_params(self, default_version_to_head=True):
diff --git a/core/collections/models.py b/core/collections/models.py
index ffb013656..74b880de5 100644
--- a/core/collections/models.py
+++ b/core/collections/models.py
@@ -167,8 +167,11 @@ def save(
super().save(*args, force_insert=force_insert, force_update=force_update, using=using,
update_fields=update_fields)
- if self.id and 'canonical_url' in dirty_fields and self.active_url_registry_entries.exists():
- resolve_url_registry_entries.apply_async((self.id, self.resource_type), queue='default', permanent=False)
+ if self.id and 'canonical_url' in dirty_fields:
+ if self.active_url_registry_entries.exists():
+ resolve_url_registry_entries.apply_async(
+ (self.id, self.resource_type), queue='default', permanent=False)
+ self.link_url_registry_entries()
def post_create_actions(self):
if self.should_auto_expand:
diff --git a/core/collections/views.py b/core/collections/views.py
index e45e6b8dc..395b64217 100644
--- a/core/collections/views.py
+++ b/core/collections/views.py
@@ -71,6 +71,7 @@
from core.tasks.mixins import TaskMixin, IndexingTaskMixin
from core.tasks.models import Task
from core.tasks.serializers import TaskBriefSerializer
+from core.toggles.models import Toggle
logger = logging.getLogger('oclapi')
@@ -1298,15 +1299,26 @@ class ReferenceExpressionResolveView(APIView):
def get_throttles(self):
return ThrottleUtil.get_throttles_by_user_plan(self.request.user)
+ def get_namespace(self):
+ """
+ ?namespace= is the namespace of every expression that doesn't name its own. Clients such as the Mapper send
+ their project's owner to choose which URL registries apply, so it is used only while the resolver uses the
+ registries. Without them a namespace limits a canonical URL to that owner's repositories, and such a client
+ would stop resolving every canonical URL its owner doesn't hold.
+ """
+ namespace = self.request.query_params.get('namespace')
+ return namespace if namespace and Toggle.get('URL_REGISTRY_IN_RESOLVE_REFERENCE_TOGGLE') else None
+
def get_results(self):
data = self.request.data
if not isinstance(data, list):
data = [data]
from core.common.models import ConceptContainerModel
+ namespace = self.get_namespace()
results = []
for expression in data:
- instance, registry_entry = ConceptContainerModel.resolve_expression_to_version(expression)
+ instance, registry_entry = ConceptContainerModel.resolve_expression_to_version(expression, namespace)
if instance:
result = {
**ReferenceExpressionResolveSerializer(instance).data,
@@ -1333,6 +1345,12 @@ def get_results(self):
responses={
200: ReferenceExpressionResolveSerializer(many=True)
},
+ manual_parameters=[
+ openapi.Parameter(
+ 'namespace', openapi.IN_QUERY, type=openapi.TYPE_STRING,
+ description='Namespace (/orgs/{org}/ or /users/{user}/) for the expressions that name none. '
+ 'Used only while canonical URLs resolve through the URL registries')
+ ],
request_body=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
diff --git a/core/common/fhir_helpers.py b/core/common/fhir_helpers.py
index 892092b34..70d405db4 100644
--- a/core/common/fhir_helpers.py
+++ b/core/common/fhir_helpers.py
@@ -1,54 +1,3 @@
-import json
-
-from core.common.exceptions import Http400
-
-
-def translate_fhir_query(fhir_query_fields, query_params, queryset):
- remaining_query_params = query_params.copy()
- query_fields = fhir_query_fields.copy()
- url = remaining_query_params.get('url')
- if url and 'url' in query_fields:
- queryset = queryset.filter(canonical_url=url)
- remaining_query_params.pop('url')
- query_fields.remove('url')
- language = remaining_query_params.get('language')
- if language and 'language' in query_fields:
- queryset = queryset.filter(default_locale=language)
- remaining_query_params.pop('language')
- query_fields.remove('language')
- status = remaining_query_params.get('status')
- if status and 'status' in query_fields:
- query_fields.remove('status')
- remaining_query_params.pop('status')
- if status == 'retired':
- queryset = queryset.filter(retired=True)
- elif status == 'active':
- queryset = queryset.filter(released=True)
- elif status == 'draft':
- queryset = queryset.filter(released=False)
- title = remaining_query_params.get('title')
- if title and 'title' in query_fields:
- remaining_query_params.pop('title')
- query_fields.remove('title')
- queryset = queryset.filter(full_name=title)
- code = remaining_query_params.get('code')
- if code and 'code' in query_fields:
- remaining_query_params.pop('code')
- query_fields.remove('code')
- queryset = queryset.filter(concepts__mnemonic=code)
- for query_field in query_fields:
- query_value = remaining_query_params.get(query_field)
- if query_value:
- remaining_query_params.pop(query_field, None)
- kwargs = {query_field: query_value}
- queryset = queryset.filter(**kwargs)
-
- if remaining_query_params:
- raise Http400('The following query params are not supported: ' + json.dumps(remaining_query_params))
-
- return queryset
-
-
def delete_empty_fields(obj):
if isinstance(obj, dict):
for field in list(obj.keys()):
diff --git a/core/common/mixins.py b/core/common/mixins.py
index 57ea324e5..206739f7c 100644
--- a/core/common/mixins.py
+++ b/core/common/mixins.py
@@ -562,8 +562,17 @@ def update(self, request, *args, **kwargs): # pylint: disable=unused-argument
class SourceContainerMixin:
- def find_repo_by_canonical_url(self, canonical_url):
+ def find_repo_by_canonical_url(self, canonical_url, repo_type=None):
+ """
+ This owner's lowest-id repository row with the canonical URL, a Source before a Collection; `url|version`
+ names a version. With repo_type (Source or Collection), only that type's active, non-retired rows, and the
+ HEAD unless a version is named.
+ """
url, version = canonical_url_to_url_and_version(canonical_url)
+ if repo_type:
+ repos = {'Source': self.source_set, 'Collection': self.collection_set}[repo_type.OBJECT_TYPE]
+ queryset = repos.filter(canonical_url=url, is_active=True, retired=False)
+ return queryset.filter(version=decode_string(version) if version else HEAD).first()
queryset = self.source_set.filter(canonical_url=url)
if not queryset.exists():
queryset = self.collection_set.filter(canonical_url=url)
diff --git a/core/common/models.py b/core/common/models.py
index 743f2f586..c8348e20a 100644
--- a/core/common/models.py
+++ b/core/common/models.py
@@ -6,6 +6,7 @@
from celery_once import AlreadyQueued
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
from django.core.exceptions import ValidationError
@@ -712,8 +713,12 @@ def get_latest_version(self):
def get_last_version(self):
return self.active_versions.order_by('-created_at').first()
- def get_latest_released_version(self):
- return self.released_versions.filter(retired=False).order_by('-created_at').first()
+ def get_latest_released_version(self, exclude_head=False):
+ """The newest released version that isn't retired; exclude_head skips a HEAD flagged released"""
+ versions = self.released_versions.filter(retired=False)
+ if exclude_head:
+ versions = versions.exclude(version=HEAD)
+ return versions.order_by('-created_at').first()
def get_prev_released_version(self):
return self.released_versions.exclude(id=self.id).order_by('-created_at').first()
@@ -922,13 +927,16 @@ def versions(self):
def delete(self, using=None, keep_parents=False, force=False, sync=False): # pylint: disable=arguments-differ
export_paths = self.get_export_paths_to_delete()
deleted_urls = [self.url]
+ deleted_ids = [self.id]
+ canonical_urls = {self.canonical_url}
if self.is_head:
other_versions = list(self.versions.exclude(id=self.id))
for other_version in other_versions:
other_version.clear_cache()
export_paths += other_version.get_export_paths_to_delete()
deleted_urls.append(other_version.url)
- self.versions.exclude(id=self.id).delete()
+ deleted_ids.append(other_version.id)
+ canonical_urls.add(other_version.canonical_url)
elif self.is_latest_version:
prev_version = self.prev_version
if not force and not prev_version:
@@ -937,6 +945,10 @@ def delete(self, using=None, keep_parents=False, force=False, sync=False): # py
prev_version.is_latest_version = True
prev_version.save()
+ registry_entry_ids = self.unlink_url_registry_entries(deleted_ids)
+ if self.is_head:
+ self.versions.exclude(id=self.id).delete()
+
self.delete_pins()
self.delete_following()
self.delete_version_changelogs(deleted_urls)
@@ -945,6 +957,32 @@ def delete(self, using=None, keep_parents=False, force=False, sync=False): # py
super().delete(using=using, keep_parents=keep_parents)
self.delete_export_paths(export_paths, sync)
self.post_delete_actions()
+ self.relink_url_registry_entries(registry_entry_ids, canonical_urls - {None, ''})
+
+ def unlink_url_registry_entries(self, repo_ids):
+ """
+ Unlinks (saved, so reindexed) the registry entries linked to these rows of this repository before the rows
+ are deleted: url_registry_entries is a GenericRelation, which would delete them with the rows. Returns
+ their ids for relink_url_registry_entries.
+ """
+ from core.url_registry.models import URLRegistry
+ entries = list(URLRegistry.objects.filter(
+ repo_type=ContentType.objects.get_for_model(self), repo_id__in=repo_ids))
+ for entry in entries:
+ entry.unlink()
+ return [entry.id for entry in entries]
+
+ @staticmethod
+ def relink_url_registry_entries(entry_ids, canonical_urls):
+ """
+ After a delete: every active entry that was linked to a deleted row, and every active entry naming one of the
+ deleted rows' canonical URLs, linked or not, is re-resolved (lookup_entry). A retry of a delete that failed
+ part-way so finds, by URL, the entries the failed attempt unlinked. The link is a cache that resolution never
+ reads, so a failure here only leaves it stale until the next delete, retry or write that relinks.
+ """
+ from core.url_registry.models import URLRegistry
+ for entry in URLRegistry.objects.filter(Q(id__in=entry_ids) | Q(url__in=canonical_urls), is_active=True):
+ entry.lookup_entry()
@staticmethod
def delete_version_changelogs(urls):
@@ -1399,6 +1437,20 @@ def is_exporting(self):
def active_url_registry_entries(self):
return self.url_registry_entries.filter(is_active=True)
+ def link_url_registry_entries(self):
+ """
+ For a repository row (HEAD or version) created with, or given, a canonical URL: the active registry entries
+ that name it in this repository's namespace are pointed at what they resolve to now (lookup_entry: the
+ namespace's lowest-id row with the URL, as the registry has always resolved), so an entry made before the row
+ that answers it gets it. The entries already linked to this row are re-resolved by the
+ resolve_url_registry_entries task.
+ """
+ if not self.canonical_url:
+ return
+ from core.url_registry.models import URLRegistry
+ for entry in URLRegistry.objects.filter(is_active=True, url=self.canonical_url, namespace=self.parent_url):
+ entry.lookup_entry()
+
@cached_property
def version_export_path(self):
last_update = self.last_child_update.strftime('%Y-%m-%d_%H%M%S')
@@ -1472,22 +1524,23 @@ def can_view_all_content(self, user):
) or self.public_can_view or self.user_id == user.id or self.organization.members.filter(id=user.id).exists()
@classmethod
- def resolve_expression_to_version(cls, expression):
+ def resolve_expression_to_version(cls, expression, namespace=None):
+ """`namespace` is the namespace of an expression that doesn't name its own (a dict without the key, or a url)"""
url = expression
- namespace = None
version = None
instance = None
resolved_registry_entry = None
if isinstance(expression, dict) and get(expression, 'url'):
url = expression['url']
- namespace = expression.get('namespace', None)
+ namespace = expression.get('namespace', namespace)
version = expression.get('version', None)
if url:
instance, resolved_registry_entry = cls.resolve_reference_expression(url, namespace, version)
return instance, resolved_registry_entry
@classmethod
- def resolve_reference_expression(cls, url, namespace=None, version=None):
+ def resolve_reference_expression( # pylint: disable=too-many-arguments,too-many-locals
+ cls, url, namespace=None, version=None, repo_type=None, allow_head=True, heads=None, head_shown=None):
"""
resolves to repository version according to this process:
@@ -1511,6 +1564,20 @@ def resolve_reference_expression(cls, url, namespace=None, version=None):
is defined somewhere else in OCL)
2. Else if relative URL provided:
- Return the repository directly using the relative URL, or return 404 if not found
+
+ Resolving reads only: it never writes a registry entry.
+
+ Two flags make the answer stricter, for FHIR (core.fhir.resolve); their defaults keep the behaviour above:
+ - repo_type (Source or Collection): resolve only to a repository of that class. A canonical URL matches the
+ active, non-retired HEAD of a repository (its current canonical URL) in every step, so a registry entry whose
+ namespace holds no such repository resolves to nothing, and nothing else is tried. A named version must be
+ active. Unresolved is an unsaved repo_type.
+ - allow_head=False: with no version named, the latest released version other than HEAD (even a HEAD flagged
+ released) or nothing. A version named HEAD is still served.
+ - heads (a Q on HEAD rows, with repo_type): without the registry, a canonical URL matches only the HEADs it
+ holds, so a repository the caller isn't shown never stands in for one they are (FHIR's global base).
+ - head_shown (a callable, with repo_type): a HEAD it refuses resolves to nothing before any version of it is
+ read, so a repository the caller isn't shown costs what one that doesn't exist does (FHIR).
"""
resolution_url, version, is_canonical = cls.__get_resolution_url(url, version)
@@ -1527,36 +1594,52 @@ def resolve_reference_expression(cls, url, namespace=None, version=None):
owner = SourceContainerMixin.get_object_from_namespace(namespace)
if owner:
registry_entry = owner.url_registry_entries.filter(is_active=True, url=resolution_url).first()
- instance = registry_entry.lookup_entry() if registry_entry else owner.find_repo_by_canonical_url( # pylint: disable=line-too-long
- resolution_url)
+ instance = registry_entry.find_repo(repo_type) if registry_entry else \
+ owner.find_repo_by_canonical_url(resolution_url, repo_type)
if is_global_namespace or (not registry_entry and not instance):
registry_entry = URLRegistry.get_active_global_entries().filter(url=resolution_url).first()
if registry_entry:
- instance = registry_entry.lookup_entry()
+ instance = registry_entry.find_repo(repo_type)
- return cls.resolve_repo(instance, version, is_canonical, resolution_url), registry_entry
+ return cls.resolve_repo(
+ instance, version, is_canonical, resolution_url, repo_type, allow_head, head_shown), registry_entry
criteria &= models.Q(canonical_url=resolution_url)
+ if repo_type:
+ criteria &= models.Q(version=HEAD)
+ if heads is not None:
+ criteria &= heads
if namespace:
criteria &= models.Q(models.Q(user__uri=namespace) | models.Q(organization__uri=namespace))
else:
criteria &= models.Q(uri=(resolution_url if resolution_url.endswith('/') else resolution_url + '/'))
from core.repos.models import Repository
- return cls.resolve_repo(Repository.get(criteria), version, is_canonical, resolution_url), registry_entry
+ return cls.resolve_repo(
+ Repository.get(criteria, repo_type), version, is_canonical, resolution_url, repo_type, allow_head,
+ head_shown), registry_entry
@classmethod
- def resolve_repo(cls, instance, version, is_canonical, resolution_url):
+ def resolve_repo( # pylint: disable=too-many-arguments
+ cls, instance, version, is_canonical, resolution_url, repo_type=None, allow_head=True, head_shown=None):
if instance:
- if version:
- instance = instance.versions.filter(version=decode_string(version)).first()
+ head = instance if repo_type and instance.is_head else None
+ if head and head_shown and not head_shown(head):
+ instance = None
+ elif version:
+ versions = instance.active_versions if repo_type else instance.versions
+ instance = versions.filter(version=decode_string(version)).first()
elif instance.is_head:
- instance = instance.get_latest_released_version() or instance
+ instance = (instance.get_latest_released_version() or instance) if allow_head else \
+ instance.get_latest_released_version(exclude_head=True)
+ if instance and head: # the repository's (its HEAD's) access and canonical URL, which FHIR reads too
+ instance.repository_public_access = head.public_access
+ instance.repository_canonical_url = head.canonical_url
if not instance:
from core.sources.models import Source
- instance = Source()
+ instance = (repo_type or Source)()
instance.is_fqdn = is_canonical
instance.resolution_url = resolution_url
diff --git a/core/common/serializers.py b/core/common/serializers.py
index 868b6c876..c2bfdecb2 100644
--- a/core/common/serializers.py
+++ b/core/common/serializers.py
@@ -114,16 +114,19 @@ def parse_identifier(accession_id):
@staticmethod
def convert_ocl_uri_to_fhir_url(uri, resource_type):
- resource_type_uri = f"/{resource_type}/"
+ """
+ An OCL API relative URI (/{owner_type}/{owner_id}/{sources|collections}/{id}/...) as its repository's FHIR one,
+ /{owner_type}/{owner_id}/{resource_type}/{id}/: only the repository type's segment is renamed, so an owner or a
+ repository named sources or collections keeps its name. Any other URI is returned as it is.
+ """
if uri and (uri.startswith('/orgs/') or uri.startswith('/users/')):
- # Recognize OCL API relative URI
- uri = uri.replace('/sources/', resource_type_uri).replace('/collections/', resource_type_uri)
- uri = uri.strip('/')
- parts = uri.split('/')
+ parts = uri.strip('/').split('/')
+ if len(parts) > 2 and parts[2] in ('sources', 'collections'):
+ parts[2] = resource_type
if len(parts) < 4:
raise ValidationError(
'Identifier must be in a format: /{owner_type}/{owner_id}/{resourceType}/{resource_id}/, given: '
- + uri)
+ + '/'.join(parts))
uri = '/' + '/'.join(parts[:4]) + '/'
return uri
diff --git a/core/common/swagger.py b/core/common/swagger.py
index 75e6172a3..d43579e4e 100644
--- a/core/common/swagger.py
+++ b/core/common/swagger.py
@@ -1,7 +1,13 @@
"""Swagger helpers for OCL-specific OpenAPI generation."""
+from copy import deepcopy
+
+from django.conf import settings
from drf_yasg.generators import EndpointEnumerator, OpenAPISchemaGenerator
+from core.fhir.base import FhirViewMixin, is_fhir_path
+from core.fhir.swagger import FhirSwaggerSchemaGenerator, fhir_paths, fhir_swagger_url, on_fhir_host
+
class OCLSwaggerEndpointEnumerator(EndpointEnumerator):
"""Preserve literal dollar signs when deriving OpenAPI paths from Django URL regexes."""
@@ -18,3 +24,26 @@ class OCLSwaggerSchemaGenerator(OpenAPISchemaGenerator):
"""Use the OCL endpoint enumerator so Swagger paths match the published URLs."""
endpoint_enumerator_class = OCLSwaggerEndpointEnumerator
+
+ def get_endpoints(self, request):
+ """Replace every FHIR view entry, including malformed paths from no-slash includes."""
+ return {path: endpoint for path, endpoint in super().get_endpoints(request).items()
+ if not is_fhir_path(path) and not issubclass(endpoint[0], FhirViewMixin)}
+
+ def get_paths(self, endpoints, components, request, public):
+ """Keep the other API operations intact, adding FHIR only when both share a host."""
+ paths, prefix = super().get_paths(endpoints, components, request, public)
+ if not settings.FHIR_SUBDOMAIN:
+ paths.update(fhir_paths(components))
+ return paths, prefix
+
+ def get_schema(self, request=None, public=False):
+ """Select the host's schema and link the API documentation to the separate FHIR service."""
+ if on_fhir_host(request):
+ return FhirSwaggerSchemaGenerator(self.info).get_schema(request, public)
+ schema = super().get_schema(request, public)
+ if settings.FHIR_SUBDOMAIN:
+ schema.info = deepcopy(schema.info)
+ link = fhir_swagger_url(request, self.url or settings.API_BASE_URL)
+ schema.info.description = schema.info.get('description', '') + f'\n\n[FHIR terminology Swagger]({link}).'
+ return schema
diff --git a/core/common/tests.py b/core/common/tests.py
index af3a169d7..f4ec11a45 100644
--- a/core/common/tests.py
+++ b/core/common/tests.py
@@ -75,10 +75,8 @@
from .backends import OCLOIDCAuthenticationBackend
from .exceptions import SSOIdentityMismatchRefused
from .checksums import Checksum
-from .fhir_helpers import translate_fhir_query
from .serializers import IdentifierSerializer
from .validators import URIValidator
-from ..code_systems.serializers import CodeSystemDetailSerializer
from ..concepts.serializers import ConceptDetailSerializer
from ..concepts.tests.factories import ConceptFactory, ConceptNameFactory
from ..mappings.serializers import MappingDetailSerializer
@@ -325,57 +323,6 @@ def setUp(self):
self.maxDiff = None
-class FhirHelpersTest(OCLTestCase):
- def test_language_to_default_locale(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'language': 'eng'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('"sources"."default_locale" = eng' in str(query_set.query))
-
- def test_status_retired(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'status': 'retired'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('WHERE "sources"."retired"' in str(query_set.query))
-
- def test_status_active(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'status': 'active'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('WHERE "sources"."released"' in str(query_set.query))
-
- def test_status_draft(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'status': 'draft'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('WHERE NOT "sources"."released"' in str(query_set.query))
-
- def test_title_to_full_name(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'title': 'some title'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('WHERE "sources"."full_name" = some title' in str(query_set.query))
-
- def test_other_fields(self):
- query_fields = list(CodeSystemDetailSerializer.Meta.fields)
- query_params = {'version': 'v1', 'id': '2'}
- query_set = Source.objects.all()
-
- query_set = translate_fhir_query(query_fields, query_params, query_set)
- self.assertTrue('"sources"."version" = v1' in str(query_set.query))
- self.assertTrue('"sources"."id" = 2' in str(query_set.query))
-
-
class IdentifierSerializerTest(OCLTestCase):
def test_deserialize(self):
data = {'system': '/org/OCL/test',
diff --git a/core/concept_maps/constants.py b/core/concept_maps/constants.py
index 68cc367f2..a9b51bd6e 100644
--- a/core/concept_maps/constants.py
+++ b/core/concept_maps/constants.py
@@ -1 +1,43 @@
RESOURCE_TYPE = 'ConceptMap'
+
+# ConceptMap relationships as FHIR R5 names them (http://hl7.org/fhir/concept-map-relationship): the version-neutral
+# form, which an adapter writes as its version's codes (core.fhir.adapters.r4: R4's equivalence)
+EQUIVALENT = 'equivalent'
+RELATED_TO = 'related-to'
+SOURCE_IS_NARROWER = 'source-is-narrower-than-target'
+SOURCE_IS_BROADER = 'source-is-broader-than-target'
+NOT_RELATED_TO = 'not-related-to'
+
+# The relationship of each OCL map type, the map type spelt as core.fhir.ops.translate.map_type_key spells it (upper
+# case, hyphens for spaces, trimmed), so SAME-AS, SAME AS and Same As are one (design D27). Any other map type is
+# RELATED_TO. A mapping says its from concept is NARROWER-THAN (or BROADER-THAN) its to concept.
+MAP_TYPE_RELATIONSHIPS = {
+ 'SAME-AS': EQUIVALENT,
+ 'NARROWER-THAN': SOURCE_IS_NARROWER,
+ 'BROADER-THAN': SOURCE_IS_BROADER,
+ 'NOT-SAME-AS': NOT_RELATED_TO,
+ # Before WP6 a FHIR ConceptMap write stored an R4 equivalence other than equivalent as the map type, verbatim:
+ # each is the relationship HL7's R4/R5 conversion gives it (unmatched, R5's noMap, is not-related-to; relatedto
+ # and inexact are the default). R5's relationship codes are themselves, related-to the default.
+ 'EQUIVALENT': EQUIVALENT,
+ 'EQUAL': EQUIVALENT,
+ 'WIDER': SOURCE_IS_NARROWER,
+ 'SUBSUMES': SOURCE_IS_NARROWER,
+ 'NARROWER': SOURCE_IS_BROADER,
+ 'SPECIALIZES': SOURCE_IS_BROADER,
+ 'DISJOINT': NOT_RELATED_TO,
+ 'UNMATCHED': NOT_RELATED_TO,
+ 'SOURCE-IS-NARROWER-THAN-TARGET': SOURCE_IS_NARROWER,
+ 'SOURCE-IS-BROADER-THAN-TARGET': SOURCE_IS_BROADER,
+ 'NOT-RELATED-TO': NOT_RELATED_TO,
+}
+# A relationship read the other way, from target to source
+REVERSED_RELATIONSHIPS = {SOURCE_IS_NARROWER: SOURCE_IS_BROADER, SOURCE_IS_BROADER: SOURCE_IS_NARROWER}
+# The OCL map type a relationship is stored as, when a ConceptMap is written; related-to has none (the equivalence
+# written is kept)
+RELATIONSHIP_MAP_TYPES = {
+ EQUIVALENT: 'SAME-AS',
+ SOURCE_IS_NARROWER: 'NARROWER-THAN',
+ SOURCE_IS_BROADER: 'BROADER-THAN',
+ NOT_RELATED_TO: 'NOT-SAME-AS',
+}
diff --git a/core/concept_maps/serializers.py b/core/concept_maps/serializers.py
index 28f126fa1..93868bf47 100644
--- a/core/concept_maps/serializers.py
+++ b/core/concept_maps/serializers.py
@@ -6,28 +6,36 @@
DateTimeField
from core.common.fhir_helpers import delete_empty_fields
-from core.concept_maps.constants import RESOURCE_TYPE
+from core.concept_maps.constants import RESOURCE_TYPE, RELATIONSHIP_MAP_TYPES
from core.common.constants import HEAD
from core.common.serializers import StatusField, IdentifierSerializer
-from core.mappings.constants import SAME_AS
+from core.fhir.adapters import r4
+from core.fhir.ops.concepts import fhir_code
+from core.fhir.ops.translate import relationship, side_system, with_side
+from core.fhir.resolve import namespace_of
+from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin
from core.mappings.models import Mapping
from core.mappings.serializers import MappingDetailSerializer
-from core.parameters.serializers import ParametersSerializer
from core.sources.models import Source
from core.sources.serializers import SourceCreateOrUpdateSerializer
logger = logging.getLogger('oclapi')
+MAPPING_LIMIT = 1000
class ConceptMapGroupField(serializers.Field):
+ """
+ A ConceptMap's groups, one per source and target system, each mapping an element target, with core.fhir.ops.
+ translate's systems (for the caller, at the read's base) and relationships, in R4 (equivalence). Writes store an
+ equivalence as the map type it is.
+ """
+
def to_internal_value(self, data):
mappings = []
for group in data:
for element in group.get('element', []):
for target in element.get('target', []):
- map_type = target.get('equivalence')
- if map_type == 'equivalent':
- map_type = SAME_AS
+ equivalence = target.get('equivalence')
mapping = {
'from_source_url': IdentifierSerializer.convert_fhir_url_to_ocl_uri(group.get('source'),
'sources'),
@@ -35,74 +43,53 @@ def to_internal_value(self, data):
'sources'),
'from_concept_code': element.get('code'),
'to_concept_code': target.get('code'),
- 'map_type': map_type
+ 'map_type': RELATIONSHIP_MAP_TYPES.get(relationship(equivalence), equivalence)
}
mappings.append(mapping)
return {'mappings': mappings}
def to_representation(self, value):
- # limit to 1000 mappings by default
+ """
+ At most 1,000 live mappings, grouped by the systems they are shown as: each side named as $translate names it
+ (side_system), its source's canonical URL when the caller is shown that source at the read's base, else the URL
+ the mapping stores. Whether a source is shown is a subquery of the one statement.
+ """
# TODO: support graphQL to go around the limit
- limit = self.get_limit()
- mappings = value.get_mappings_queryset().filter(retired=False).order_by('id')[:limit]
- groups = {}
- for mapping in mappings:
- key = (mapping.from_source_url or '') + (mapping.to_source_url or '')
- group = groups.get(key)
- if not group:
- if mapping.from_source and mapping.from_source.canonical_url:
- from_url = mapping.from_source.canonical_url
- else:
- from_url = IdentifierSerializer.convert_ocl_uri_to_fhir_url(mapping.from_source_url, RESOURCE_TYPE)
-
- if mapping.to_source and mapping.to_source.canonical_url:
- to_url = mapping.to_source.canonical_url
- else:
- to_url = IdentifierSerializer.convert_ocl_uri_to_fhir_url(mapping.to_source_url, RESOURCE_TYPE)
-
- group = {
- 'source': from_url,
- 'target': to_url
- }
- groups.update({key: group})
- elements = group.get('element')
- if not elements:
- elements = []
- group.update({'element': elements})
- element = None
- for candidate in elements:
- if candidate.get('code') is mapping.from_concept_code:
- element = candidate
- break
- if not element:
- element = {
- 'code': mapping.from_concept_code,
- 'target': []
- }
- elements.append(element)
- targets = element.get('target')
- relationship = mapping.map_type
- if mapping.map_type == SAME_AS:
- relationship = 'equivalent'
- targets.append({
- 'code': mapping.to_concept_code,
- 'equivalence': relationship
+ request, view = self.context.get('request'), self.context.get('view')
+ namespace, user = namespace_of(getattr(view, 'kwargs', None)), getattr(request, 'user', None)
+ mappings = value.get_active_mappings() # neither retired nor deleted
+ for side in ('from', 'to'):
+ mappings = with_side(mappings, side, namespace, user)
+ rows = list(mappings.order_by('id').values(
+ 'map_type', 'from_concept_code', 'to_concept_code', *(
+ f'{side}_{field}' for side in ('from', 'to')
+ for field in ('source_url', 'source__uri', 'shown', 'canonical')))[:MAPPING_LIMIT + 1])
+ self.subsetted = len(rows) > MAPPING_LIMIT
+ groups, elements = {}, {}
+ for row in rows[:MAPPING_LIMIT]:
+ key = tuple(side_system(row[f'{side}_canonical'], row[f'{side}_source_url'],
+ row[f'{side}_source__uri'] if row[f'{side}_shown'] else None)
+ for side in ('from', 'to'))
+ if key not in groups:
+ groups[key] = {'source': key[0], 'target': key[1], 'element': []}
+ code = fhir_code(row['from_concept_code'])
+ element = elements.get((key, code))
+ if element is None:
+ element = elements[(key, code)] = {'code': code, 'target': []}
+ groups[key]['element'].append(element)
+ element['target'].append({
+ 'code': fhir_code(row['to_concept_code']),
+ 'equivalence': r4.equivalence(relationship(row['map_type']))
})
return [*groups.values()]
- def get_limit(self):
- if self.context.get('has_many', False):
- limit = 25
- else:
- limit = 1000
- return limit
-
-class ConceptMapDetailSerializer(serializers.ModelSerializer):
+class ConceptMapDetailSerializer(FhirRepositorySerializerMixin, serializers.ModelSerializer):
+ content_field = 'group'
resourceType = SerializerMethodField(method_name='get_resource_type')
id = CharField(source='mnemonic')
- url = CharField(source='canonical_url', required=False)
+ url = CanonicalUrlField(source='canonical_url', required=False)
title = CharField(source='full_name', required=False)
status = StatusField(source='*')
language = CharField(source='default_locale', required=False)
@@ -117,14 +104,6 @@ class Meta:
'version', 'identifier', 'contact', 'jurisdiction', 'name', 'description', 'publisher', 'purpose',
'copyright', 'date', 'experimental', 'group')
- def __new__(cls, *args, **kwargs):
- if kwargs.get('many', False):
- context = kwargs.get('context', {})
- context.update({'has_many': True})
- kwargs.update({'context': context})
-
- return super().__new__(cls, *args, **kwargs)
-
@staticmethod
def get_resource_type(_):
return RESOURCE_TYPE
@@ -138,6 +117,7 @@ def to_representation(self, instance):
rep = super().to_representation(instance)
delete_empty_fields(rep)
IdentifierSerializer.include_ocl_identifier(instance.uri, RESOURCE_TYPE, rep)
+ self.with_fhir_view(instance, rep)
except (Exception, ):
msg = f'Failed to represent "{instance.uri}" as {RESOURCE_TYPE}'
logger.exception(msg)
@@ -259,27 +239,3 @@ def update(self, instance, validated_data):
self._errors.update(errors)
return source
-
-
-class ConceptMapParametersSerializer(ParametersSerializer):
-
- def update(self, instance, validated_data):
- pass
-
- def create(self, validated_data):
- pass
-
- allowed_input_parameters = {
- 'url': 'valueUri',
- 'conceptMapVersion': 'valueString',
- 'code': 'valueCode',
- 'system': 'valueUri',
- 'version': 'valueString',
- 'source': 'valueUri',
- 'coding': 'valueCoding',
- 'codeableConcept': 'valueCodeableConcept',
- 'target': 'valueUri',
- 'targetsystem': 'valueUri',
- # TODO: dependency?
- 'reverse': 'valueBoolean'
- }
diff --git a/core/concept_maps/tests.py b/core/concept_maps/tests.py
index 013a3858b..49c9ff107 100644
--- a/core/concept_maps/tests.py
+++ b/core/concept_maps/tests.py
@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
from mock.mock import patch, Mock
from rest_framework.test import APIClient
@@ -35,7 +33,7 @@ def setUp(self):
self.user_token = self.user.get_token()
self.user_source = UserSourceFactory(user=self.user, public_access='None', canonical_url='/some/url')
self.user_source_v1 = UserSourceFactory.build(
- version='v1', mnemonic=self.user_source.mnemonic, user=self.user_source.parent)
+ version='v1', mnemonic=self.user_source.mnemonic, user=self.user_source.parent, released=True)
Source.persist_new_version(self.user_source_v1, self.user_source.created_by)
self.org_source_B = OrganizationSourceFactory(organization=self.org, canonical_url='/some/url/B',
@@ -44,7 +42,7 @@ def setUp(self):
names=[ConceptNameFactory.build(name="concept_B_1_name")])
self.concept_B_2 = ConceptFactory(parent=self.org_source_B, mnemonic='concept_B_2')
self.org_source_B_v1 = OrganizationSourceFactory.build(
- version='v1', mnemonic=self.org_source_B.mnemonic, organization=self.org_source_B.parent)
+ version='v1', mnemonic=self.org_source_B.mnemonic, organization=self.org_source_B.parent, released=True)
Source.persist_new_version(self.org_source_B_v1, self.org_source_B.created_by)
self.mapping_1 = MappingFactory(parent=self.org_source, to_concept=self.concept_1,
@@ -61,7 +59,7 @@ def setUp(self):
self.mapping_3.save()
self.org_source_v2 = OrganizationSourceFactory.build(
- version='v2', mnemonic=self.org_source.mnemonic, organization=self.org_source.parent)
+ version='v2', mnemonic=self.org_source.mnemonic, organization=self.org_source.parent, released=True)
Source.persist_new_version(self.org_source_v2, self.org_source.created_by)
for mapping in self.org_source_v2.get_mappings_queryset().all():
@@ -82,31 +80,36 @@ def test_public_can_view(self):
'/orgs/' + self.org.mnemonic + '/ConceptMap/' + self.org_source.mnemonic + '/'
)
self.assertEqual(resource['version'], 'v2')
+ self.assertNotIn('group', resource) # a search entry is a summary; the read has the groups
+
+ resource = self.client.get(response.data['entry'][0]['fullUrl']).data
self.assertEqual(len(resource['group']), 1)
self.assertEqual(resource['group'], [{'source': self.org_source_B_v1.canonical_url,
'target': self.org_source.canonical_url,
'element': [
{'code': 'concept_B_1',
'target': [{'code': 'concept_1', 'equivalence': 'equivalent'}]},
- {'code': 'concept_B_2',
- 'target': [{'code': 'concept_2', 'equivalence': 'equivalent'}]},
- {'code': 'concept_B_2',
- 'target': [{'code': 'concept_1', 'equivalence': 'equivalent'}]}]}])
+ {'code': 'concept_B_2', # one element per code, each of its targets
+ 'target': [{'code': 'concept_2', 'equivalence': 'equivalent'},
+ {'code': 'concept_1', 'equivalence': 'equivalent'}]}]}])
def test_private_can_view(self):
+ # the global base shows public repositories only, whoever asks; the owner base shows the owner its own
response = self.client.get('/fhir/ConceptMap/?url=/some/url', HTTP_AUTHORIZATION='Token ' + self.user_token)
- self.assertEqual(len(response.data['entry']), 2)
- self.assertEqual(response.data['total'], 2)
+ self.assertEqual(len(response.data['entry']), 1)
+ self.assertEqual(response.data['total'], 1)
resource = response.data['entry'][0]['resource']
self.assertEqual(
resource['identifier'][0]['value'],
'/orgs/' + self.org.mnemonic + '/ConceptMap/' + self.org_source.mnemonic + '/'
)
-
self.assertEqual(resource['version'], 'v2')
- resource2 = response.data['entry'][1]['resource']
+
+ response = self.client.get(
+ f'/users/{self.user.mnemonic}/ConceptMap/?url=/some/url', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ resource2 = response.data['entry'][0]['resource']
self.assertEqual(
resource2['identifier'][0]['value'],
'/users/' + self.user.mnemonic + '/ConceptMap/' + self.user_source.mnemonic + '/'
@@ -132,7 +135,7 @@ def test_public_can_list(self):
self.assertEqual(resource2['version'], 'v1')
def test_find_by_title(self):
- response = self.client.get('/fhir/ConceptMap/?title=source name')
+ response = self.client.get('/fhir/ConceptMap/?title:exact=source name') # title alone is 'starts with'
self.assertEqual(len(response.data['entry']), 1)
resource = response.data['entry'][0]['resource']
@@ -145,7 +148,10 @@ def test_find_by_title(self):
def test_private_can_list(self):
response = self.client.get('/fhir/ConceptMap/', HTTP_AUTHORIZATION='Token ' + self.user_token)
- self.assertEqual(len(response.data['entry']), 3)
+ self.assertEqual(len(response.data['entry']), 2) # public only at the global base
+ response = self.client.get(
+ f'/users/{self.user.mnemonic}/ConceptMap/', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ self.assertEqual(len(response.data['entry']), 1)
def test_get_concept_map(self):
response = self.client.get(f'/orgs/{self.org.mnemonic}/ConceptMap/{self.org_source.mnemonic}/')
@@ -510,77 +516,90 @@ def test_put_concept_map_overwriting_version(self):
# check if bad request
self.assertEqual(response.status_code, 400)
- self.assertEqual(response.data, {'version': 'Version 1.0 already exists for CodeSystem test2.'})
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome')
+ self.assertEqual(
+ [(issue['code'], issue['expression'], issue['details']['text']) for issue in response.data['issue']],
+ [('invalid', ['version'], 'Version 1.0 already exists for CodeSystem test2.')])
+
+ def translate(self, base, token=True, **params):
+ """$translate at a base, as the user (or anonymously), after the user's PUT of their ConceptMap, released."""
+ if not Source.objects.filter(mnemonic=self.user_source.mnemonic, user=self.user, version='1.0').exists():
+ self.putConceptMap(status='active')
+ headers = {'HTTP_AUTHORIZATION': 'Token ' + self.user_token} if token else {}
+ return self.client.get(f'{base}/ConceptMap/$translate', params, **headers)
+
+ def assertTranslation(self, response, matches): # pylint: disable=invalid-name
+ """result, a message only when there is no match, and each match: (code, system, equivalence)."""
+ self.assertEqual(response.status_code, 200, response.data)
+ parameters = response.data['parameter']
+ self.assertEqual(parameters[0], {'name': 'result', 'valueBoolean': bool(matches)})
+ self.assertEqual([item['name'] for item in parameters[1:]], ['match'] * len(matches) or ['message'])
+ found = [item['part'] for item in parameters[1:] if item['name'] == 'match']
+ self.assertEqual(
+ [(part[1]['valueCoding']['code'], part[1]['valueCoding']['system'], part[0]['valueCode'],
+ part[2]['valueUri']) for part in found],
+ [(*match, 'http://localhost/url|1.0') for match in matches])
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def test_translate_positive(self):
- self.putConceptMap()
+ """The ConceptMap the user PUT, read back by $translate: the mapping its group wrote."""
+ self.assertTranslation(
+ self.translate(f'/users/{self.user.mnemonic}', url='http://localhost/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1'),
+ [('concept_1', '/some/url', 'equivalent')])
- response = self.client.get(
- f'/users/{self.user.mnemonic}/ConceptMap/$translate?'
- f'system={self.org_source_B_v1.canonical_url}&code=concept_B_1',
- HTTP_AUTHORIZATION='Token ' + self.user_token)
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def test_public_translate_negative(self):
+ """The user's ConceptMap is private: anonymous, it is not found, and no code system has the mapping."""
+ response = self.translate(f'/users/{self.user.mnemonic}', token=False, url='http://localhost/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1')
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.data['issue'][0]['expression'], ['url'])
+ self.assertTranslation(self.translate(f'/users/{self.user.mnemonic}', token=False,
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1'), [])
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(
- response.data,
- {
- 'resourceType': 'Parameters',
- 'parameter': [
- OrderedDict([
- ('name', 'result'),
- ('valueBoolean', True)
- ]),
- OrderedDict([
- ('name', 'match'),
- (
- 'part',
- [
- OrderedDict([
- ('name', 'equivalence'),
- ('valueCode', 'equivalent')
- ]),
- OrderedDict([
- ('name', 'concept'),
- ('valueCoding', OrderedDict([('system', '/some/url'), ('code', 'concept_1')]))
- ])
- ]
- )
- ]),
- OrderedDict([
- ('name', 'match'),
- (
- 'part',
- [
- OrderedDict([
- ('name', 'equivalence'),
- ('valueCode', 'equivalent')
- ]),
- OrderedDict([
- ('name', 'concept'),
- ('valueCoding', OrderedDict([('system', '/some/url'), ('code', 'concept_1')]))
- ])
- ]
- )
- ])
- ]
- })
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def test_translate_negative(self):
+ self.assertTranslation(
+ self.translate(f'/users/{self.user.mnemonic}', url='http://localhost/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_1'), [])
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_public_translate_negative(self):
- self.putConceptMap()
+ def test_translate_with_url(self):
+ """The global base shows public repositories only: the user's private ConceptMap isn't found there."""
+ response = self.translate('/fhir', url='http://localhost/url', system=self.org_source_B_v1.canonical_url,
+ code='concept_B_1')
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.data['issue'][0]['code'], 'not-found')
- response = self.client.get(
- f'/users/{self.user.mnemonic}/ConceptMap/$translate?'
- f'system={self.org_source_B_v1.canonical_url}&code=concept_1')
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def test_translate_with_url_negative(self):
+ response = self.translate(f'/users/{self.user.mnemonic}', url='http://unknown-concept-map/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1')
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.data['issue'][0]['expression'], ['url'])
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(response.data, {
- 'resourceType': 'Parameters',
- 'parameter': [OrderedDict(
- [('name', 'result'), ('valueBoolean', False)])]})
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def test_translate_with_target(self):
+ self.assertTranslation(
+ self.translate(f'/users/{self.user.mnemonic}', url='http://localhost/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1',
+ targetsystem=self.org_source.canonical_url),
+ [('concept_1', '/some/url', 'equivalent')])
+
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def test_translate_with_target_negative(self):
+ self.assertTranslation(
+ self.translate(f'/users/{self.user.mnemonic}', url='http://localhost/url',
+ system=self.org_source_B_v1.canonical_url, code='concept_B_1',
+ targetsystem=self.org_source_B_v1.canonical_url), [])
def newConceptMap(self, owner, identifier, version, group):
if not owner:
@@ -606,7 +625,7 @@ def newConceptMap(self, owner, identifier, version, group):
}
- def putConceptMap(self):
+ def putConceptMap(self, status='draft'):
response = self.client.put(
f'/users/{self.user.mnemonic}/ConceptMap/{self.user_source.mnemonic}/',
HTTP_AUTHORIZATION='Token ' + self.user_token,
@@ -626,7 +645,7 @@ def putConceptMap(self):
'version': '1.0',
'name': 'test',
'id': self.user_source.mnemonic,
- 'status': 'draft',
+ 'status': status,
'group': [{'source': self.org_source_B_v1.canonical_url,
'target': self.org_source.canonical_url,
'element': [
@@ -642,151 +661,36 @@ def putConceptMap(self):
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_translate_negative(self):
- self.putConceptMap()
-
- response = self.client.get(
- f'/users/{self.user.mnemonic}/ConceptMap/$translate?'
- f'system={self.org_source_B_v1.canonical_url}&code=concept_1',
- HTTP_AUTHORIZATION='Token ' + self.user_token)
-
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(response.data, {
- 'resourceType': 'Parameters',
- 'parameter': [OrderedDict(
- [('name', 'result'), ('valueBoolean', False)]
- )]})
-
- @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
- @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_translate_with_url(self):
- self.putConceptMap()
-
- response = self.client.get(
- '/fhir/ConceptMap/$translate?'
- f'url=http://localhost/url&system={self.org_source_B_v1.canonical_url}&code=concept_B_1',
- HTTP_AUTHORIZATION='Token ' + self.user_token)
-
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(
- response.data,
- {
- 'resourceType': 'Parameters',
- 'parameter': [
- OrderedDict([
- ('name', 'result'),
- ('valueBoolean', True)
- ]),
- OrderedDict([
- ('name', 'match'),
- (
- 'part',
- [
- OrderedDict([
- ('name', 'equivalence'),
- ('valueCode', 'equivalent')
- ]),
- OrderedDict([
- ('name', 'concept'),
- ('valueCoding', OrderedDict([('system', '/some/url'), ('code', 'concept_1')]))
- ])
- ]
- )
- ])
- ]
- })
-
- @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
- @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_translate_with_url_negative(self):
- # system/code alone would match mapping_1 (owned by org_source), but the ConceptMap
- # identified by 'url' has no such mapping, so no match should be returned.
- self.putConceptMap()
-
- response = self.client.get(
- '/fhir/ConceptMap/$translate?'
- f'url=http://unknown-concept-map/url&system={self.org_source_B_v1.canonical_url}&code=concept_B_1',
- HTTP_AUTHORIZATION='Token ' + self.user_token)
-
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(response.data, {
- 'resourceType': 'Parameters',
- 'parameter': [OrderedDict(
- [('name', 'result'), ('valueBoolean', False)]
- )]})
-
- @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
- @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_translate_with_target(self):
- self.putConceptMap()
-
- response = self.client.get(
- f'/users/{self.user.mnemonic}/ConceptMap/$translate?'
- f'system={self.org_source_B_v1.canonical_url}&code=concept_B_1'
- f'&targetsystem={self.org_source.canonical_url}', HTTP_AUTHORIZATION='Token ' + self.user_token)
-
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(
- response.data,
- {
- 'resourceType': 'Parameters',
- 'parameter': [
- OrderedDict([
- ('name', 'result'),
- ('valueBoolean', True)
- ]),
- OrderedDict([
- ('name', 'match'),
- (
- 'part',
- [
- OrderedDict([
- ('name', 'equivalence'),
- ('valueCode', 'equivalent')
- ]),
- OrderedDict([
- ('name', 'concept'),
- ('valueCoding', OrderedDict([('system', '/some/url'), ('code', 'concept_1')]))
- ])
- ]
- )
- ]),
- OrderedDict([
- ('name', 'match'),
- (
- 'part',
- [
- OrderedDict([
- ('name', 'equivalence'),
- ('valueCode', 'equivalent')
- ]),
- OrderedDict([
- ('name', 'concept'),
- ('valueCoding', OrderedDict([('system', '/some/url'), ('code', 'concept_1')]))
- ])
- ]
- )
- ])
- ]
- }
- )
-
- @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
- @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
- def test_translate_with_target_negative(self):
- self.putConceptMap()
-
- response = self.client.get(
- f'/users/{self.user.mnemonic}/ConceptMap/$translate?'
- f'system={self.org_source_B_v1.canonical_url}&code=concept_B_1'
- f'&targetsystem={self.org_source_B_v1.canonical_url}',
- HTTP_AUTHORIZATION='Token ' + self.user_token)
-
- self.assertEqual(response.status_code, 200)
- self.assertDictEqual(response.data, {
- 'resourceType': 'Parameters',
- 'parameter': [OrderedDict(
- [('name', 'result'), ('valueBoolean', False)])]})
+ def test_group_equivalence_is_the_map_type_relationship(self):
+ """
+ A group target's equivalence is its map type's relationship in R4 ($translate's table), and a written
+ equivalence is stored as the map type it is, so it reads back the same; one with no map type (relatedto,
+ inexact) is kept as it is.
+ One element per code, whatever the number of its targets.
+ """
+ written = ['equivalent', 'wider', 'narrower', 'disjoint', 'relatedto', 'equal', 'subsumes', 'specializes',
+ 'inexact', 'unmatched']
+ stored = ['SAME-AS', 'NARROWER-THAN', 'BROADER-THAN', 'NOT-SAME-AS', 'relatedto', 'SAME-AS', 'NARROWER-THAN',
+ 'BROADER-THAN', 'inexact', 'NOT-SAME-AS']
+ read = ['equivalent', 'wider', 'narrower', 'disjoint', 'relatedto', 'equivalent', 'wider', 'narrower',
+ 'relatedto', 'disjoint']
+ group = [{'source': self.org_source_B_v1.canonical_url, 'target': self.org_source.canonical_url,
+ 'element': [{'code': 'concept_B_1', 'target': [
+ {'code': f'concept_{index}', 'equivalence': equivalence}
+ for index, equivalence in enumerate(written)]}]}]
+ response = self.client.post(
+ f'/users/{self.user.mnemonic}/ConceptMap/', HTTP_AUTHORIZATION='Token ' + self.user_token,
+ data={**self.newConceptMap(None, 'equivalences', '1.0', group), 'status': 'active'}, format='json')
+ self.assertEqual(response.status_code, 201, response.data)
+ version = Source.objects.get(mnemonic='equivalences', version='1.0', user=self.user)
+ self.assertEqual(list(version.get_mappings_queryset().order_by('id').values_list('map_type', flat=True)),
+ stored)
+
+ resource = self.client.get(f'/users/{self.user.mnemonic}/ConceptMap/equivalences/',
+ HTTP_AUTHORIZATION='Token ' + self.user_token).data
+ [element] = resource['group'][0]['element']
+ self.assertEqual(element['code'], 'concept_B_1')
+ self.assertEqual([target['equivalence'] for target in element['target']], read)
def test_unable_to_represent_as_fhir(self):
instance = Source(id='1', uri='/invalid/uri')
diff --git a/core/concept_maps/views.py b/core/concept_maps/views.py
index e1aa117fa..acdb8fb8f 100644
--- a/core/concept_maps/views.py
+++ b/core/concept_maps/views.py
@@ -1,57 +1,32 @@
import logging
-from django.core.exceptions import ValidationError
-from django.db.models import Q
from django.http import Http404
-from pydash import get
-from core.bundles.serializers import FHIRBundleSerializer
-from core.code_systems.views import FhirParameterRequiredError
from core.common.constants import HEAD
-from core.common.fhir_helpers import translate_fhir_query
-from core.common.permissions import CanViewConceptDictionary
-from core.common.serializers import IdentifierSerializer
-from core.concept_maps.constants import RESOURCE_TYPE
-from core.concept_maps.serializers import ConceptMapDetailSerializer, ConceptMapParametersSerializer
-from core.concepts.permissions import CanAccessParentDictionary
-from core.mappings.constants import SAME_AS
-from core.mappings.views import MappingListView
+from core.common.permissions import HasOwnership
+from core.concept_maps.serializers import ConceptMapDetailSerializer
+from core.fhir.adapters import r4
+from core.fhir.base import FhirViewMixin, FhirOperationView
+from core.fhir.ops.translate import translate
+from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.sources.views import SourceListView, SourceRetrieveUpdateDestroyView
logger = logging.getLogger('oclapi')
-class ConceptMapListView(SourceListView):
+class ConceptMapListView(FhirViewMixin, FhirSearchMixin, SourceListView):
+ resource_type = 'ConceptMap'
serializer_class = ConceptMapDetailSerializer
- @staticmethod
- def bundle_response(data, paginator):
- bundle = FHIRBundleSerializer(
- {'meta': {}, 'total': len(data), 'type': 'searchset', 'entry': FHIRBundleSerializer.convert_to_entry(data)},
- context={'paginator': paginator}
- )
- return bundle.data
-
- def get_filter_params(self, default_version_to_head=True):
- return super().get_filter_params(False)
-
- def apply_query_filters(self, queryset):
- query_fields = list(self.serializer_class.Meta.fields)
- queryset = translate_fhir_query(query_fields, self.request.query_params, queryset)
- return queryset
-
- def apply_filters(self, queryset):
- queryset = queryset.exclude(version=HEAD).filter(is_latest_version=True)
- return self.apply_query_filters(queryset)
-
def get_serializer_class(self):
return self.serializer_class
def get_detail_serializer(self, obj):
- return self.serializer_class(obj)
+ return self.serializer_class(obj, context=self.get_serializer_context())
-class ConceptMapRetrieveUpdateView(SourceRetrieveUpdateDestroyView):
+class ConceptMapRetrieveUpdateView(FhirViewMixin, FhirReadMixin, SourceRetrieveUpdateDestroyView):
+ resource_type = 'ConceptMap'
serializer_class = ConceptMapDetailSerializer
def get_filter_params(self, default_version_to_head=True):
@@ -64,145 +39,25 @@ def get_queryset(self):
return queryset.exclude(version=HEAD)
def get_object(self, queryset=None):
+ """
+ A PUT or PATCH to a ConceptMap that doesn't exist gets None, which creates it, so it needs what a POST needs:
+ staff, the owner, or a member of the owning org (HasOwnership on the owner). Anyone else is refused as they are
+ for an existing one. Any other request for one is a 404 (a read's from FhirReadMixin).
+ """
try:
return super().get_object(queryset)
except Http404:
+ if self.request.method not in ('PUT', 'PATCH'):
+ raise
+ if not HasOwnership().has_object_permission(self.request, self, self.parent_resource):
+ self.permission_denied(self.request)
return None
def get_detail_serializer(self, obj):
- return ConceptMapDetailSerializer(obj)
-
-
-class ConceptMapTranslateView(MappingListView):
- serializer_class = ConceptMapParametersSerializer
-
- def get_permissions(self):
- return [CanAccessParentDictionary(), CanViewConceptDictionary()]
-
- def get_serializer_class(self):
- return ConceptMapParametersSerializer
-
- # $translate answers with the translation of the mappings apply_filters names, and nothing else. The mapping
- # list's shortcuts (a search, a sort or a faceted filter, facets, search stats, a CSV export) would answer in its
- # place, and the search ones skip apply_filters, so each is off. Their parameters are ignored like any other this
- # operation doesn't define.
- allow_csv_export = False
-
- def should_perform_es_search(self):
- return False
-
- def only_facets(self):
- return False
+ return ConceptMapDetailSerializer(obj, context=self.get_serializer_context())
- def only_search_stats(self):
- return False
- def should_include_facets(self):
- return False
-
- def verify_scope(self):
- """
- Override to not verify scope and allow POSTing for everyone
- :return: None
- """
-
- def apply_filters(self, queryset):
- # get_serializer renders the answer, so a POSTed Parameters resource is read by the serializer class itself
- if self.request.method in ['POST', 'PUT']:
- parameters = self.get_serializer_class()(data=self.request.data)
- else:
- parameters = self.get_serializer_class().parse_query_params(self.request.query_params)
-
- if not parameters.is_valid():
- raise ValidationError(message=parameters.errors)
-
- params = parameters.validated_data
- params = params.get('parameters', {})
-
- url = params.get('url')
- code = params.get('code')
- system = params.get('system')
- targetsystem = params.get('targetsystem')
- # TODO: implement 'source' and 'target'
- if not code:
- raise FhirParameterRequiredError('A code to translate is required.', expression='code')
- if not system:
- raise FhirParameterRequiredError(
- 'system is required with a code: the code system the code is from.', expression='system')
-
- if url:
- queryset = queryset.filter(parent__canonical_url=url)
- queryset = queryset.filter(from_concept_code=code)
- system_url = IdentifierSerializer.convert_fhir_url_to_ocl_uri(system, 'sources')
- queryset = queryset.filter(Q(from_source__canonical_url=system) |
- Q(from_source_url=system_url) |
- Q(from_source__uri=system_url))
- if targetsystem:
- target_url = IdentifierSerializer.convert_fhir_url_to_ocl_uri(targetsystem, 'sources')
- queryset = queryset.filter(Q(to_source__canonical_url=targetsystem) |
- Q(to_source_url=target_url) |
- Q(to_source__uri=target_url))
- return queryset
-
- def get_serializer(self, *args, **kwargs):
- instance = get(args, '0')
- many = kwargs.get('many', False)
- if many:
- if isinstance(instance, list) and len(instance) != 0:
- matches = []
- for mapping in instance:
- equivalence = mapping.map_type
- if mapping.map_type == SAME_AS:
- equivalence = "equivalent"
-
- to_url = None
- if mapping.to_source and mapping.to_source.canonical_url:
- to_url = mapping.to_source.canonical_url
- elif mapping.to_source_url:
- to_url = IdentifierSerializer.convert_ocl_uri_to_fhir_url(mapping.to_source_url, RESOURCE_TYPE)
- elif mapping.to_source:
- to_url = IdentifierSerializer.convert_ocl_uri_to_fhir_url(mapping.to_source.uri, RESOURCE_TYPE)
-
- matches.append({
- 'name': 'match',
- 'part': [
- {
- 'name': 'equivalence',
- 'valueCode': equivalence
- },
- {
- 'name': 'concept',
- 'valueCoding': {
- 'system': to_url,
- 'code': mapping.to_concept_code,
- 'userSelected': False
- }
- }
- ]
- })
- return ConceptMapParametersSerializer({'parameter': [
- {
- 'name': 'result',
- 'valueBoolean': True
- },
- *matches
- ]})
-
- return ConceptMapParametersSerializer({'parameter': [
- {
- 'name': 'result',
- 'valueBoolean': False
- }
- ]})
- return super().get_serializer(instance, many, *args, **kwargs)
-
- # Change POST behavior to get
- def post(self, request, *args, **kwargs):
- """
- Change POST behavior to simply list
- :param request:
- :param args:
- :param kwargs:
- :return: parameters
- """
- return self.get(request, *args, **kwargs)
+class ConceptMapTranslateView(FhirOperationView):
+ """ConceptMap/$translate, GET and POST, at the global base and at owner bases: core.fhir.ops.translate, in R4."""
+ operation = staticmethod(translate)
+ adapter = staticmethod(r4.translate_parameters)
diff --git a/core/fhir/__init__.py b/core/fhir/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/fhir/adapters/__init__.py b/core/fhir/adapters/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/fhir/adapters/r4.py b/core/fhir/adapters/r4.py
new file mode 100644
index 000000000..e353cf191
--- /dev/null
+++ b/core/fhir/adapters/r4.py
@@ -0,0 +1,203 @@
+"""
+The FHIR R4 wire form of the version-neutral (R5-shaped) answers core.fhir.ops build, as R4 Parameters resources.
+
+- $lookup: R4's outputs in R4's order (name, version, display, designation, property), with code, system and abstract
+ after display, as FHIRsmith and the terminology ecosystem IG return them. inactive is a property.
+- $validate-code: result first, then message, display, code, system, version, inactive, the unknown system, and the
+ issues as an OperationOutcome.
+- $translate: result, message, and each match's equivalence (a ConceptMap relationship as R4's equivalence), concept
+ and source.
+"""
+from core.concept_maps.constants import (
+ EQUIVALENT, NOT_RELATED_TO, RELATED_TO, SOURCE_IS_BROADER, SOURCE_IS_NARROWER)
+from core.fhir.base import FHIR_RELEASE, prune
+
+# R5 relationship -> R4 equivalence, as HL7's R4/R5 ConceptMap conversion maps them: the target is wider than a source
+# that is narrower than it; not-related-to is disjoint
+EQUIVALENCES = {
+ EQUIVALENT: 'equivalent',
+ RELATED_TO: 'relatedto',
+ SOURCE_IS_NARROWER: 'wider',
+ SOURCE_IS_BROADER: 'narrower',
+ NOT_RELATED_TO: 'disjoint',
+}
+
+
+def parameter(name, value_type, value):
+ return {'name': name, value_type: value}
+
+
+def parameters(items):
+ """A Parameters resource of (name, value type, value) items, leaving out those without a value."""
+ return {'resourceType': 'Parameters', 'parameter': [
+ item if isinstance(item, dict) else parameter(*item) for item in items
+ if isinstance(item, dict) or item[2] is not None]}
+
+
+def part_parameter(name, parts):
+ return {'name': name, 'part': [parameter(*part) for part in parts if part[2] is not None]}
+
+
+# The metadata (core.fhir.capabilities) in R4: the service, SVCM 1.5.1's Terminology Repository, which the
+# CapabilityStatement instantiates, and the definitions of the operations and search parameters it lists
+SERVICE_NAME, SERVICE_TITLE = 'OCLTerminologyService', 'Open Concept Lab FHIR terminology service'
+PUBLISHER = 'Open Concept Lab'
+SVCM_TERMINOLOGY_REPOSITORY = (
+ 'https://profiles.ihe.net/ITI/SVCM/CapabilityStatement/IHE.SVCM.TerminologyRepository|1.5.1')
+OPERATION_DEFINITION = 'http://hl7.org/fhir/OperationDefinition/'
+SYSTEM_OPERATION_DEFINITIONS = {'versions': 'CapabilityStatement-versions'}
+SEARCH_PARAMETER = 'http://hl7.org/fhir/SearchParameter/'
+SEARCH_PARAMETER_TYPES = {
+ '_id': 'token', '_lastUpdated': 'date', 'url': 'uri', 'version': 'token', 'status': 'token', 'identifier': 'token',
+ 'name': 'string', 'title': 'string', 'description': 'string', 'publisher': 'string', 'system': 'uri',
+ 'code': 'token', 'language': 'token', 'reference': 'uri', 'source-system': 'uri', 'source-uri': 'reference',
+ 'target-system': 'uri', 'target-uri': 'reference'}
+# R4 defines these once for every resource, or for every conformance resource; the others per resource type
+SHARED_SEARCH_PARAMETERS = {
+ '_id': 'Resource-id', '_lastUpdated': 'Resource-lastUpdated',
+ **{name: f'conformance-{name}' for name in (
+ 'url', 'version', 'status', 'identifier', 'name', 'title', 'description', 'publisher')}}
+# The search parameters OCL reads otherwise than R4 defines them, and how (core.fhir.search's mapped)
+SEARCH_PARAMETER_DOCUMENTATION = {
+ ('ConceptMap', f'{side}-uri'): (
+ f'Read as `{side}-system`: OCL keeps no {side} value set on a ConceptMap, so this matches the system of a '
+ f"group's {side}, not a value set.") for side in ('source', 'target')}
+
+
+def search_parameter(resource_type, name):
+ """A search parameter as a CapabilityStatement lists it: its name, R4 definition and type, and how OCL reads it
+ when that isn't as R4 defines it."""
+ definition = SHARED_SEARCH_PARAMETERS.get(name, f'{resource_type}-{name}')
+ documentation = SEARCH_PARAMETER_DOCUMENTATION.get((resource_type, name))
+ return {'name': name, 'definition': SEARCH_PARAMETER + definition, 'type': SEARCH_PARAMETER_TYPES[name],
+ **({'documentation': documentation} if documentation else {})}
+
+
+def operation(name, resource_type=None):
+ """An operation as a CapabilityStatement lists it: its name and R4 OperationDefinition."""
+ definition = f'{resource_type}-{name}' if resource_type else SYSTEM_OPERATION_DEFINITIONS[name]
+ return {'name': name, 'definition': OPERATION_DEFINITION + definition}
+
+
+def described(resource_type, metadata):
+ """What a CapabilityStatement and a TerminologyCapabilities both say of the service, in R4's element order."""
+ return {
+ 'resourceType': resource_type, 'name': SERVICE_NAME, 'title': SERVICE_TITLE, 'status': 'active',
+ 'date': metadata['date'], 'publisher': PUBLISHER, 'kind': 'instance', 'software': metadata['software'],
+ 'implementation': {'description': SERVICE_TITLE, 'url': metadata['url']},
+ }
+
+
+def capability_statement(statement):
+ """The CapabilityStatement (core.fhir.capabilities.capability_statement) in R4: fhirVersion 4.0.1, SVCM."""
+ resource = described('CapabilityStatement', statement)
+ software, implementation = resource.pop('software'), resource.pop('implementation')
+ rest = {'mode': 'server', 'resource': [{
+ 'type': item['type'],
+ 'interaction': [{'code': code} for code in item['interactions']],
+ 'searchParam': [search_parameter(item['type'], name) for name in item['search_parameters']],
+ 'operation': [operation(name, item['type']) for name in item['operations']],
+ } for item in statement['resources']], 'operation': [operation(name) for name in statement['operations']]}
+ return {
+ **resource, 'instantiates': [SVCM_TERMINOLOGY_REPOSITORY], 'software': software,
+ 'implementation': implementation, 'fhirVersion': FHIR_RELEASE, 'format': statement['formats'],
+ 'rest': [prune(rest)],
+ }
+
+
+def terminology_capabilities(capabilities):
+ """
+ The TerminologyCapabilities (core.fhir.capabilities.terminology_capabilities) in R4: each code system's versions,
+ the default one marked isDefault, then the blocks of the operations routed.
+ """
+ return prune({
+ **described('TerminologyCapabilities', capabilities),
+ 'codeSystem': [{'uri': item['url'], 'version': [
+ {'code': version, **({'isDefault': True} if version == item['default'] else {})}
+ for version in item['versions']]} for item in capabilities['code_systems']],
+ **{block: capabilities[block] for block in ('expansion', 'translation') if block in capabilities},
+ })
+
+
+def versions_parameters(answer):
+ """$versions' answer (core.fhir.capabilities.versions) as R4 Parameters: each version, then the default."""
+ return parameters([*(('version', 'valueCode', version) for version in answer['versions']),
+ ('default', 'valueCode', answer['default'])])
+
+
+def lookup_parameters(answer):
+ """$lookup's answer (core.fhir.ops.lookup) as R4 Parameters."""
+ return parameters([
+ ('name', 'valueString', answer['name']),
+ ('version', 'valueString', answer['version']),
+ ('display', 'valueString', answer['display']),
+ ('code', 'valueCode', answer['code']),
+ ('system', 'valueUri', answer['system']),
+ ('abstract', 'valueBoolean', answer['abstract']),
+ *(part_parameter('designation', [
+ ('language', 'valueCode', designation['language']),
+ ('use', 'valueCoding', designation['use']),
+ ('value', 'valueString', designation['value'])]) for designation in answer['designations']),
+ part_parameter('property', [('code', 'valueCode', 'inactive'), ('value', 'valueBoolean', answer['inactive'])]),
+ ])
+
+
+def validate_code_parameters(answer):
+ """$validate-code's answer (core.fhir.ops.validate_code) as R4 Parameters."""
+ unknown_name, unknown_system = answer.get('unknown') or (None, None)
+ issues = answer['issues']
+ return parameters([
+ ('result', 'valueBoolean', answer['result']),
+ ('message', 'valueString', answer['message']),
+ ('display', 'valueString', answer.get('display')),
+ ('code', 'valueCode', answer['code']),
+ ('system', 'valueUri', answer['system']),
+ ('version', 'valueString', answer.get('version')),
+ ('inactive', 'valueBoolean', answer.get('inactive')),
+ (unknown_name, 'valueCanonical', unknown_system),
+ ('issues', 'resource', {'resourceType': 'OperationOutcome', 'issue': issues} if issues else None),
+ ])
+
+
+def equivalence(relationship_code):
+ """A relationship's R4 equivalence."""
+ return EQUIVALENCES[relationship_code]
+
+
+def translate_parameters(answer):
+ """$translate's answer (core.fhir.ops.translate) as R4 Parameters: result, message, and each match's parts."""
+ items = [{'name': 'result', 'valueBoolean': answer['result']}]
+ if answer.get('message'):
+ items.append({'name': 'message', 'valueString': answer['message']})
+ for match in answer['matches']:
+ items.append({'name': 'match', 'part': [
+ {'name': 'equivalence', 'valueCode': equivalence(match['relationship'])},
+ {'name': 'concept', 'valueCoding': match['concept']},
+ {'name': 'source', 'valueUri': match['source']},
+ ]})
+ return {'resourceType': 'Parameters', 'parameter': items}
+
+
+def expansion_value_set(resource, answer):
+ """
+ $expand's answer (core.fhir.ops.expand) as an R4 ValueSet: `resource`, the value set version as a read shows it
+ without its compose, with the expansion: the stored expansion's identifier and timestamp, total, offset, the inputs
+ echoed and each used-codesystem, then the page of members.
+ """
+ def present(items):
+ return {key: value for key, value in items if value not in (None, [])}
+
+ expansion = {
+ 'identifier': answer['identifier'],
+ 'timestamp': answer['timestamp'],
+ 'total': answer['total'],
+ 'offset': answer['offset'],
+ 'parameter': [parameter(*item) for item in answer['parameters']] + [
+ parameter('used-codesystem', 'valueUri', system) for system in answer['used']],
+ 'contains': [present((
+ ('system', member['system']), ('version', member['version']), ('code', member['code']),
+ ('display', member['display']), ('inactive', member['inactive']),
+ ('designation', [present(designation.items()) for designation in member['designations'] or []]),
+ )) for member in answer['contains']],
+ }
+ return {**resource, 'expansion': present(expansion.items())}
diff --git a/core/fhir/base.py b/core/fhir/base.py
new file mode 100644
index 000000000..c79137a71
--- /dev/null
+++ b/core/fhir/base.py
@@ -0,0 +1,601 @@
+"""
+The FHIR view layer: what every FHIR view shares, whatever its operation.
+
+- Media types: FHIR JSON and XML in and out, with the aliases FHIR clients use, and `_format`.
+- The `fhirVersion` MIME parameter: R4 (4.0) is served and any other version is refused.
+- XML through fhir.resources' R4B models, which are wire-identical to R4 for the terminology resources, so
+ elements always come out in FHIR's order.
+- One reader for operation parameters, from a GET query string or a POSTed Parameters resource.
+- The FHIR base URL of a request, from its own host.
+- Errors as OperationOutcome (core.fhir.outcomes), in views and in the middleware.
+"""
+import gzip
+import json
+import logging
+import re
+import types
+import typing
+import zlib
+from functools import lru_cache
+
+from django.core.exceptions import BadRequest, SuspiciousOperation
+from django.http import HttpResponse
+from fhir.resources.R4B import get_fhir_model_class
+from fhir_core.types import FhirBase
+from lxml import etree
+from pydantic import ValidationError
+from rest_framework.exceptions import NotAcceptable, ParseError, UnsupportedMediaType
+from rest_framework.negotiation import DefaultContentNegotiation
+from rest_framework.parsers import BaseParser, FormParser, JSONParser, MultiPartParser
+from rest_framework.permissions import AllowAny
+from rest_framework.renderers import BaseRenderer, JSONRenderer
+from rest_framework.response import Response
+from rest_framework.utils.encoders import JSONEncoder
+from rest_framework.utils.mediatypes import _MediaType
+from rest_framework.versioning import AcceptHeaderVersioning
+from rest_framework.views import APIView
+
+from core.common.throttling import ThrottleUtil
+from core.fhir import outcomes
+
+logger = logging.getLogger('oclapi')
+
+FHIR_VERSION = '4.0' # the fhirVersion MIME parameter, and what $versions answers
+FHIR_RELEASE = '4.0.1' # CapabilityStatement.fhirVersion
+ACCEPTED_FHIR_VERSIONS = (FHIR_VERSION, FHIR_RELEASE)
+
+FHIR_JSON = 'application/fhir+json'
+FHIR_XML = 'application/fhir+xml'
+# Other names for the two formats: the short _format values, the DSTU2 types, and the generic XML types R4 says
+# a server SHOULD accept. Plain application/json stays itself, so OCL's own clients keep getting it.
+MEDIA_TYPE_ALIASES = {
+ 'json': FHIR_JSON,
+ 'application/json+fhir': FHIR_JSON,
+ 'xml': FHIR_XML,
+ 'application/xml+fhir': FHIR_XML,
+ 'application/xml': FHIR_XML,
+ 'text/xml': FHIR_XML,
+}
+FORMAT_PARAMETERS = ('_format', 'format')
+# Protocol parameters: they choose the response format, and are never operation or search input
+CONTROL_PARAMETERS = frozenset({'_format', 'format', '_pretty'})
+RESOURCE_TYPES = ('CodeSystem', 'ValueSet', 'ConceptMap')
+OWNER_BASE_PATHS = ('metadata', '$versions') # what an owner base answers besides its resource types
+
+FHIR_NS = 'http://hl7.org/fhir'
+XHTML_NS = 'http://www.w3.org/1999/xhtml'
+EXTENSION_ELEMENTS = ('extension', 'modifierExtension')
+EMPTY_VALUES = (None, '', {}, [])
+# What XML 1.0 can't hold: C0 controls other than tab, LF and CR, lone surrogates, U+FFFE and U+FFFF
+XML_FORBIDDEN_CHARACTERS = re.compile('[^\x09\x0a\x0d\x20-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]')
+REPLACEMENT_CHARACTER = '\ufffd'
+XML_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_.-]*$')
+LOGGED_DROPPED_ELEMENTS = set() # what the XML has left out so far: each kind is logged once per process
+
+
+def xml_parser():
+ """A parser that never fetches or expands entities: a request body is untrusted."""
+ return etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, remove_comments=True)
+
+
+def is_fhir_path(path):
+ """
+ Whether a path is under a FHIR base: the global /fhir/, or at an owner base (/orgs/{org}/, /users/{user}/) a FHIR
+ resource type, its metadata or $versions.
+ """
+ segments = path.strip('/').split('/')
+ return segments[0] == 'fhir' or (len(segments) >= 3 and segments[0] in ('orgs', 'users') and (
+ segments[2] in RESOURCE_TYPES or (len(segments) == 3 and segments[2] in OWNER_BASE_PATHS)))
+
+
+def fhir_base_url(request, kwargs=None):
+ """
+ The FHIR base a request came in on, from its own scheme and host (the forwarded ones behind the load balancer),
+ so links work on every host OCL serves FHIR from: /fhir, or the owner base /orgs/{org} or /users/{user}.
+ """
+ kwargs = kwargs or {}
+ if kwargs.get('org'):
+ path = f"/orgs/{kwargs['org']}"
+ elif kwargs.get('user'):
+ path = f"/users/{kwargs['user']}"
+ else:
+ path = '/fhir'
+ return request.build_absolute_uri(path)
+
+
+def normalize_media_type(media_range):
+ """A media range from Accept, Content-Type or _format, with FHIR's other names replaced by the formal type."""
+ main, separator, params = media_range.partition(';')
+ main = main.strip().lower().replace(' ', '+') # an unencoded + in _format arrives as a space
+ return MEDIA_TYPE_ALIASES.get(main, main) + separator + params
+
+
+def parse_media_type(media_type):
+ """Treat undecodable MIME parameters as a bad request, including unknown RFC 2231 encodings."""
+ try:
+ return _MediaType(media_type)
+ except (LookupError, ValueError) as exc:
+ raise BadRequest('The media type parameters could not be parsed.') from exc
+
+
+def accepted_media_ranges(request):
+ """The media ranges a request accepts: its _format, else its Accept header."""
+ ranges = [item.strip() for item in request.META.get('HTTP_ACCEPT', '').split(',') if item.strip()]
+ for media_range in ranges:
+ parse_media_type(media_range) # validate before _format and browser preference shortcuts
+ for name in FORMAT_PARAMETERS:
+ value = request.GET.get(name)
+ if value:
+ return [normalize_media_type(value)]
+ if any(item.startswith('text/html') for item in ranges) and not any('fhir' in item for item in ranges):
+ return ['application/json'] # a browser: JSON as before, not the application/xml it lists after text/html
+ return [normalize_media_type(item) for item in ranges] or ['*/*']
+
+
+def quality(media_range):
+ try:
+ return float(parse_media_type(media_range).params.get('q', 1))
+ except ValueError:
+ return 0.0
+
+
+def media_type_with_params(media_type, media_range):
+ """The renderer's media type, carrying the parameters the client sent other than q (such as fhirVersion)."""
+ params = [param.strip() for param in media_range.split(';')[1:]]
+ params = [param for param in params if param and not param.lower().startswith('q=')]
+ return '; '.join([media_type, *params])
+
+
+class FhirContentNegotiation(DefaultContentNegotiation):
+ """
+ Content negotiation for FHIR views: `_format` (or `format`) overrides Accept, FHIR's other media type names are
+ understood, and quality values are honoured. DRF's own `?format=` renderer filter is not used.
+ """
+
+ def select_parser(self, request, parsers):
+ content_type = parse_media_type(normalize_media_type(request.content_type or ''))
+ return next((parser for parser in parsers if parse_media_type(parser.media_type).match(content_type)), None)
+
+ def select_renderer(self, request, renderers, format_suffix=None):
+ """Each renderer takes its quality from its most specific match, including q=0 exclusions."""
+ # Reject a malformed Content-Type before choosing XML, so every parsing failure can use safe JSON.
+ parse_media_type(request.META.get('CONTENT_TYPE', request.META.get('HTTP_CONTENT_TYPE', '')))
+ ranges = sorted(enumerate(accepted_media_ranges(request)),
+ key=lambda item: (-parse_media_type(item[1]).precedence, -quality(item[1])))
+ candidates = []
+ for renderer in renderers:
+ for position, media_range in ranges:
+ parsed_range = parse_media_type(media_range)
+ if parse_media_type(renderer.media_type).match(parsed_range):
+ preference = (quality(media_range), parsed_range.precedence, -position)
+ if preference[0] > 0:
+ candidates.append((preference, renderer, media_range))
+ break # a less specific range cannot override this renderer's quality
+ if candidates:
+ _, renderer, media_range = max(candidates, key=lambda candidate: candidate[0])
+ return renderer, media_type_with_params(renderer.media_type, media_range)
+ raise NotAcceptable(available_renderers=renderers)
+
+
+class FhirVersioning(AcceptHeaderVersioning):
+ """
+ The fhirVersion MIME parameter, on Accept (or _format) and on Content-Type. R4 is served: 4.0 (or 4.0.1, or no
+ parameter) is accepted, and any other version is refused, 406 on Accept and 415 on Content-Type. Django
+ lowercases parameter names, so the parameter is read without regard to case.
+ """
+ version_param = 'fhirVersion'
+ default_version = FHIR_VERSION
+ allowed_versions = ACCEPTED_FHIR_VERSIONS
+ invalid_version_message = f'This server speaks FHIR R4: fhirVersion={FHIR_VERSION}.'
+
+ def version_of(self, media_type):
+ return parse_media_type(media_type or '').params.get(self.version_param.lower())
+
+ def determine_version(self, request, *args, **kwargs):
+ content_version = self.version_of(request.content_type)
+ if content_version is not None and content_version not in self.allowed_versions:
+ raise UnsupportedMediaType(request.content_type, detail=self.invalid_version_message)
+ accept_version = self.version_of(request.accepted_media_type)
+ if accept_version is not None and accept_version not in self.allowed_versions:
+ raise NotAcceptable(self.invalid_version_message)
+ return self.default_version
+
+
+def prune(value):
+ """FHIR has no empty values: drop None, '', {} and [] at every depth."""
+ if isinstance(value, dict):
+ items = ((key, prune(item)) for key, item in value.items())
+ return {key: item for key, item in items if item not in EMPTY_VALUES}
+ if isinstance(value, (list, tuple)):
+ return [item for item in (prune(item) for item in value) if item not in EMPTY_VALUES]
+ return value
+
+
+def from_xml(content):
+ """FHIR XML -> FHIR JSON, through the R4B model of the root element. Raises ParseError."""
+ try:
+ root = etree.fromstring(content, parser=xml_parser())
+ name = etree.QName(root)
+ if name.namespace != FHIR_NS:
+ raise ValueError(f'the root element {root.tag} is not a FHIR resource')
+ resource = get_fhir_model_class(name.localname).model_validate_xml(content, xmlparser=xml_parser())
+ except Exception as ex:
+ message = (str(ex).splitlines() or [type(ex).__name__])[0]
+ raise ParseError(f'The body is not a valid FHIR XML resource: {message}') from ex
+ return json.loads(resource.model_dump_json())
+
+
+def json_values(data):
+ """The data as the JSON response carries it: DRF's encoder writes datetimes, dates, decimals and UUIDs."""
+ return json.loads(json.dumps(data, cls=JSONEncoder))
+
+
+def xml_safe(value):
+ """(value, how many characters were replaced): every character XML 1.0 forbids becomes U+FFFD."""
+ if isinstance(value, str):
+ return XML_FORBIDDEN_CHARACTERS.subn(REPLACEMENT_CHARACTER, value)
+ if isinstance(value, dict):
+ items = [(xml_safe(key), xml_safe(item)) for key, item in value.items()]
+ return {key: item for (key, _), (item, _) in items}, sum(left + right for (_, left), (_, right) in items)
+ if isinstance(value, (list, tuple)):
+ items = [xml_safe(item) for item in value]
+ return [item for item, _ in items], sum(count for _, count in items)
+ return value, 0
+
+
+def element_type(annotation):
+ """(the R4B model of a field's values, or None for a primitive; whether the field repeats)."""
+ repeats = False
+ while True:
+ origin = typing.get_origin(annotation)
+ if origin in (typing.Union, types.UnionType):
+ annotation = next(arg for arg in typing.get_args(annotation) if arg is not type(None))
+ elif origin is list:
+ repeats, annotation = True, typing.get_args(annotation)[0]
+ elif origin is typing.Annotated:
+ annotation = typing.get_args(annotation)[0]
+ else:
+ break
+ is_model = isinstance(annotation, type) and issubclass(annotation, FhirBase)
+ return (annotation.get_model_klass() if is_model else None), repeats
+
+
+@lru_cache(maxsize=None)
+def model_elements(model_class):
+ """An R4B model's elements by their JSON name: (field name, the model of its values or None, repeats)."""
+ return {field.alias or name: (name, *element_type(field.annotation))
+ for name, field in model_class.model_fields.items() if name != 'fhir_comments'}
+
+
+def resource_model(data):
+ """The R4B model of a FHIR JSON resource, or None when it isn't one."""
+ if not isinstance(data, dict) or not isinstance(data.get('resourceType'), str):
+ return None
+ try:
+ return get_fhir_model_class(data['resourceType'])
+ except ValueError:
+ return None
+
+
+def log_dropped(model_class, key, reason):
+ if (model_class.__name__, key, reason) not in LOGGED_DROPPED_ELEMENTS:
+ LOGGED_DROPPED_ELEMENTS.add((model_class.__name__, key, reason))
+ logger.warning('FHIR XML: %s.%s %s', model_class.__name__, key, reason)
+
+
+def xhtml_markup(text):
+ """A narrative div as XHTML markup: the text itself when it is an XHTML div, else the text inside one."""
+ try:
+ if etree.fromstring(text, parser=xml_parser()).tag == f'{{{XHTML_NS}}}div':
+ return text
+ except etree.XMLSyntaxError:
+ pass
+ return etree.tostring(xhtml_div(text), encoding='unicode')
+
+
+def conform_value(value_model, value):
+ """One value of an element, conformed, or None when it doesn't have the element's shape."""
+ if value_model is None:
+ return None if isinstance(value, (dict, list)) else value
+ if not isinstance(value, dict):
+ return None
+ model_class = resource_model(value) if 'resourceType' in value else value_model # a resource names its type
+ return conform(model_class, value) if model_class else None
+
+
+def conform(model_class, data):
+ """
+ FHIR JSON reduced to what the R4 model defines, at every depth. A key R4 doesn't define (OCL's revisionDate) and
+ a value of the wrong shape are left out, each kind logged once: that is the content's problem, not the wire
+ format's. The JSON response itself is not changed.
+ """
+ elements, result = model_elements(model_class), {}
+ for key, value in data.items():
+ if key == 'resourceType':
+ result[key] = value
+ continue
+ if key not in elements:
+ log_dropped(model_class, key, 'is an element R4 does not define, so the XML leaves it out')
+ continue
+ _, value_model, repeats = elements[key]
+ if isinstance(value, list) and not repeats: # OCL's ConceptMap.identifier is a list; R4 allows one
+ if len(value) > 1:
+ log_dropped(model_class, key, 'repeats, but not in R4, so the XML keeps only the first')
+ value = value[:1]
+ items = value if isinstance(value, list) else [value]
+ kept = [item for item in (conform_value(value_model, item) for item in items) if item is not None]
+ if len(kept) < len(items):
+ log_dropped(model_class, key, 'has values of a shape R4 does not allow, so the XML leaves them out')
+ if key == 'div' and kept:
+ kept = [xhtml_markup(kept[0])]
+ if kept:
+ result[key] = kept if repeats else kept[0]
+ return result
+
+
+def construct(model_class, data):
+ """
+ A model instance from conformed FHIR JSON, nested models included, built without validation, so the model
+ still writes its elements in FHIR's order. For what the model refuses only on a pattern, such as an OCL id
+ with '_' or '@', or a code with a trailing space.
+ """
+ elements, values = model_elements(model_class), {}
+ for key, value in data.items():
+ if key == 'resourceType':
+ continue
+ name, value_model, repeats = elements[key]
+ items = [construct_value(value_model, item) for item in (value if repeats else [value])]
+ values[name] = items if repeats else items[0]
+ return model_class.model_construct(**values)
+
+
+def construct_value(value_model, value):
+ if value_model is None:
+ return value if isinstance(value, bool) else str(value) # the writer takes text, and booleans as they are
+ return construct(resource_model(value) or value_model, value)
+
+
+def to_xml(data):
+ """
+ FHIR JSON -> FHIR XML, always through the R4B model, so in FHIR's element order. It starts from the values the
+ JSON response carries. Keys R4 doesn't define are left out; a resource the model refuses on a pattern is built
+ without validation; characters XML 1.0 forbids become U+FFFD. Data that isn't a FHIR resource is answered with
+ an OperationOutcome.
+ """
+ if data is None:
+ return b''
+ data = json_values(data)
+ model_class = resource_model(data)
+ if model_class is None:
+ data = outcomes.operation_outcome(outcomes.issue('not-supported', 'This response has no FHIR XML form.'))
+ model_class = resource_model(data)
+ data, replaced = xml_safe(data)
+ if replaced:
+ logger.warning('FHIR XML: %s character(s) XML cannot hold became U+FFFD in a %s', replaced,
+ data['resourceType'])
+ data = prune(conform(model_class, prune(data)))
+ try:
+ instance = model_class.model_validate(data)
+ except ValidationError:
+ instance = construct(model_class, data)
+ try:
+ return instance.model_dump_xml()
+ except Exception:
+ logger.exception('FHIR XML: the %s model could not write it; written element by element', model_class.__name__)
+ root = etree.Element(f"{{{FHIR_NS}}}{data['resourceType']}", nsmap={None: FHIR_NS})
+ write_xml_children(root, data, resource=True)
+ return etree.tostring(root, xml_declaration=True, encoding='utf-8')
+
+
+def write_xml_children(element, obj, resource=False, extension=False):
+ """The last resort writer: element by element, in the data's own order."""
+ for key, value in obj.items():
+ if key == 'resourceType' or not XML_NAME.match(key):
+ continue
+ if not resource and (key == 'id' or (extension and key == 'url')) and not isinstance(value, (dict, list)):
+ element.set(key, str(value)) # an element's id and an extension's url are XML attributes
+ elif key == 'div' and isinstance(value, str):
+ element.append(xhtml_div(value))
+ else:
+ for item in value if isinstance(value, list) else [value]:
+ write_xml_element(element, key, item)
+
+
+def write_xml_element(parent, name, value):
+ child = etree.SubElement(parent, f'{{{FHIR_NS}}}{name}')
+ if isinstance(value, dict) and value.get('resourceType'):
+ inner = etree.SubElement(child, f"{{{FHIR_NS}}}{value['resourceType']}") # a resource in a container
+ write_xml_children(inner, value, resource=True)
+ elif isinstance(value, dict):
+ write_xml_children(child, value, extension=name in EXTENSION_ELEMENTS)
+ else:
+ child.set('value', ('true' if value else 'false') if isinstance(value, bool) else str(value))
+
+
+def xhtml_div(text):
+ try:
+ div = etree.fromstring(text, parser=xml_parser())
+ if div.tag == f'{{{XHTML_NS}}}div':
+ return div
+ except etree.XMLSyntaxError:
+ pass
+ div = etree.Element(f'{{{XHTML_NS}}}div', nsmap={None: XHTML_NS})
+ div.text = text
+ return div
+
+
+class FhirJsonParser(JSONParser):
+ media_type = FHIR_JSON
+
+
+class FhirXmlParser(BaseParser):
+ media_type = FHIR_XML
+
+ def parse(self, stream, media_type=None, parser_context=None):
+ return from_xml(stream.read())
+
+
+class FhirJsonRenderer(JSONRenderer):
+ media_type = FHIR_JSON
+ format = 'json'
+
+
+class FhirXmlRenderer(BaseRenderer):
+ media_type = FHIR_XML
+ format = 'xml'
+ charset = 'utf-8'
+
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ return to_xml(data)
+
+
+def parameter_value(parameter):
+ """
+ A Parameters.parameter's value: its value[x], resource, or parts (as FhirParameters). R4 allows it one of them, so
+ a parameter with more is invalid; a primitive's _value[x] (its id and extensions) is that same value[x].
+ """
+ given = sorted({key.lstrip('_') for key in parameter if key.lstrip('_').startswith('value')} |
+ ({'resource', 'part'} & parameter.keys()))
+ if len(given) > 1:
+ raise outcomes.InvalidError(
+ f"The parameter '{parameter.get('name')}' has more than one value: {', '.join(given)}.",
+ expression=parameter.get('name'))
+ for key, value in parameter.items():
+ if key.startswith('value'):
+ return value
+ if 'resource' in parameter:
+ return parameter['resource']
+ if 'part' in parameter:
+ return FhirParameters.from_resource({'parameter': parameter['part']})
+ return None
+
+
+class FhirParameters:
+ """
+ An operation's input, read the same way from a GET query string and from a POSTed Parameters resource (FHIR JSON,
+ FHIR XML or a form). Names can repeat. Values keep their FHIR JSON form: a string, boolean or number, a dict for
+ a Coding, CodeableConcept or resource, and FhirParameters for parts. On a POST, the body's parameters come first,
+ then the query string's. Protocol parameters such as _format are left out.
+ """
+
+ def __init__(self, items=()):
+ self.items = list(items)
+
+ @classmethod
+ def from_resource(cls, resource):
+ return cls((item.get('name'), parameter_value(item)) for item in resource.get('parameter') or [])
+
+ @classmethod
+ def from_request(cls, request):
+ items = cls.body_items(request.data) if request.method in ('POST', 'PUT') else []
+ return cls(items + cls.query_items(request.query_params))
+
+ @staticmethod
+ def query_items(query):
+ return [(name, value) for name, values in query.lists() if name not in CONTROL_PARAMETERS for value in values]
+
+ @classmethod
+ def body_items(cls, data):
+ if not data:
+ return []
+ if hasattr(data, 'lists'): # a form
+ return cls.query_items(data)
+ if isinstance(data, dict) and data.get('resourceType') == 'Parameters':
+ return cls.from_resource(data).items
+ raise outcomes.InvalidError('The request body must be a Parameters resource.')
+
+ def get(self, name, default=None):
+ return next((value for key, value in self.items if key == name), default)
+
+ def getlist(self, name):
+ return [value for key, value in self.items if key == name]
+
+ def __contains__(self, name):
+ return any(key == name for key, _ in self.items)
+
+
+def outcome_response(request, outcome, status_code, headers=None):
+ """A middleware outcome in the requested format, or safe JSON 400 for unparseable queries or MIME parameters."""
+ renderer = FhirJsonRenderer()
+ try:
+ renderer, _ = FhirContentNegotiation().select_renderer(
+ request, [klass() for klass in FhirViewMixin.renderer_classes])
+ except NotAcceptable:
+ pass
+ except (BadRequest, SuspiciousOperation) as exc:
+ status_code, outcome, _ = outcomes.outcome_for_exception(exc)
+ content_type = f'{renderer.media_type}; charset={renderer.charset}' if renderer.charset else renderer.media_type
+ response = HttpResponse(
+ renderer.render(outcome), status=outcomes.response_status(status_code), content_type=content_type)
+ for key, value in (headers or {}).items():
+ response[key] = value
+ return response
+
+
+def response_json(response):
+ """A response's JSON body, gunzipped if need be, or None."""
+ if 'json' not in response.get('Content-Type', '') or getattr(response, 'streaming', False):
+ return None
+ content = response.content
+ try:
+ if response.get('Content-Encoding') == 'gzip':
+ content = gzip.decompress(content)
+ return json.loads(content)
+ except (OSError, EOFError, zlib.error, ValueError):
+ return None
+
+
+def is_outcome_response(response):
+ """Whether a response is already FHIR: an error from a FHIR view, rather than Django's or another view's."""
+ return 'fhir' in response.get('Content-Type', '') or outcomes.is_outcome(response_json(response))
+
+
+class FhirViewMixin:
+ """
+ The first base of every FHIR view: FHIR JSON and XML in and out, `_format`, the fhirVersion parameter, and every
+ error as an OperationOutcome with a tx-issue-type, never a 5xx.
+ """
+ parser_classes = (FhirJsonParser, JSONParser, FhirXmlParser, FormParser, MultiPartParser)
+ renderer_classes = (FhirJsonRenderer, JSONRenderer, FhirXmlRenderer)
+ content_negotiation_class = FhirContentNegotiation
+ versioning_class = FhirVersioning
+
+ def get_exception_handler(self):
+ return outcomes.exception_handler
+
+ def finalize_response(self, request, response, *args, **kwargs):
+ """Normalize returned outcomes at every status and depth, and wrap other error responses as outcomes."""
+ if isinstance(response, Response):
+ if response.status_code >= 400:
+ if not outcomes.is_outcome(response.data):
+ response.data = outcomes.outcome_for_status(response.status_code, response.data)
+ response.status_code = outcomes.response_status(response.status_code)
+ response.data = outcomes.normalize_outcomes(response.data)
+ return super().finalize_response(request, response, *args, **kwargs)
+
+ def get_fhir_parameters(self):
+ return FhirParameters.from_request(self.request)
+
+ def get_fhir_base_url(self):
+ return fhir_base_url(self.request, self.kwargs)
+
+
+class FhirOperationView(FhirViewMixin, APIView):
+ """
+ A FHIR operation that only reads, GET and POST alike, at the global base and at owner bases: `operation`'s answer
+ for the request's FhirParameters, base and user (and the resource the route kwarg `instance` names, at the instance
+ level), written by `adapter`. Throttled as OCL's other views are.
+ """
+ permission_classes = (AllowAny,) # what an operation shows the caller is core.fhir.resolve's rule
+ operation = adapter = None
+ instance = None
+
+ def get_throttles(self):
+ return ThrottleUtil.get_throttles_by_user_plan(self.request.user)
+
+ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
+ answer = self.operation( # pylint: disable=not-callable
+ self.get_fhir_parameters(), self.kwargs, request.user, kwargs.get(self.instance))
+ return Response(self.adapter(answer)) # pylint: disable=not-callable
+
+ post = get
diff --git a/core/fhir/capabilities.py b/core/fhir/capabilities.py
new file mode 100644
index 000000000..70afe33df
--- /dev/null
+++ b/core/fhir/capabilities.py
@@ -0,0 +1,181 @@
+"""
+What the FHIR metadata says, version-neutral, built from the code: core.fhir.adapters.r4 writes it as R4's
+CapabilityStatement, TerminologyCapabilities and $versions Parameters.
+
+- The routes: what a base offers is what the URL configuration routes under it (/fhir/, /orgs/{org}/ or
+ /users/{user}/). A resource type's search route (a FhirSearchMixin view) is its search-type interaction, its
+ instance route (a FhirReadMixin view) its read, and each $operation route to a FhirOperationView, at the type or
+ instance level, one of its operations; a $operation route directly under the base is a system operation. Routing a
+ view lists it; nothing else does.
+- The search parameters are the search's own (core.fhir.search's PARAMETERS and STRING_FIELDS).
+- The FHIR versions are those the fhirVersion MIME parameter negotiates (core.fhir.base): R4, 4.0.
+- The software is the OCL API and its version; the base URL is the request's.
+- TerminologyCapabilities lists each repository a CodeSystem search at the base shows the caller, under the identifier
+ the resolver serves it at there: its canonical URL (its HEAD's) when the resolver picks it for that URL (with the URL
+ registry when the toggle is on), else its OCL path as a CodeSystem; as resolve() serves them were the repositories
+ the caller isn't shown missing (core.fhir.resolve's served_repositories), so a hidden repository changes nothing in
+ it. Each with its released versions as stored, newest first, and the default, the one served without a version.
+ Under a canonical URL, after the repository's own, the releases of the other repositories with it the base shows
+ that the operations serve by name there (a version named resolves to its holder). A fixed number of queries,
+ bounded as a search's are (SEARCH_TIMEOUT_MS; past it the list is too costly, a 422).
+- The routed operations' contracts fill TerminologyCapabilities' blocks: $translate finds the map from the code's
+ system (core.fhir.ops.translate), so it needs none; $expand pages, with offset and count.
+"""
+import re
+from functools import lru_cache
+
+from django.urls import URLResolver, get_resolver
+from django.utils import timezone
+
+from core import __version__
+from core.fhir import outcomes
+from core.fhir.base import (
+ RESOURCE_TYPES, FhirOperationView, FhirVersioning, FhirViewMixin, fhir_base_url)
+from core.fhir.resolve import served_repositories
+from core.fhir.search import PARAMETERS, STRING_FIELDS, FhirReadMixin, FhirSearchMixin, within_search_timeout
+
+SOFTWARE = {'name': 'OCL API', 'version': __version__}
+STARTED = timezone.now().replace(microsecond=0).isoformat() # what the statements describe was deployed then
+FORMATS = [renderer.media_type for renderer in FhirViewMixin.renderer_classes if 'fhir' in renderer.media_type]
+OWNER_BASES = {'orgs': 'org', 'users': 'user'} # the owner bases' first segment, and the route kwarg naming the owner
+INTERACTIONS = {'read': FhirReadMixin, 'search-type': FhirSearchMixin} # in R4's order, by the view serving each
+CONVERTER = re.compile(r'<(?:\w+:)?(\w+)>') # a route's segment
+# What TerminologyCapabilities says of an operation the base routes, by its block: its contract
+OPERATION_BLOCKS = {
+ ('ConceptMap', 'translate'): ('translation', {'needsMap': False}),
+ ('ValueSet', 'expand'): ('expansion', {'paging': True}),
+}
+
+
+def routes(resolver, prefix=''):
+ """(route, view class) for every route of a URL configuration, its includes followed."""
+ for pattern in resolver.url_patterns:
+ route = prefix + str(pattern.pattern)
+ if isinstance(pattern, URLResolver):
+ yield from routes(pattern, route)
+ else:
+ yield route, getattr(pattern.callback, 'view_class', None)
+
+
+def base_of(route):
+ """(the base, the route's segments under it) of a route under a FHIR base: None for /fhir/, else the owner kwarg
+ ('org' or 'user'). (False, None) for any other route."""
+ segments = route.strip('/').split('/')
+ if segments[0] == 'fhir':
+ return None, segments[1:]
+ converter = CONVERTER.fullmatch(segments[1]) if len(segments) > 1 else None
+ if segments[0] in OWNER_BASES and converter and converter[1] == OWNER_BASES[segments[0]]:
+ return converter[1], segments[2:]
+ return False, None
+
+
+def is_view(view, kind):
+ return isinstance(view, type) and issubclass(view, kind)
+
+
+def offering(segments, view):
+ """
+ What a route under a base offers, from its segments under the base and its view: ('$', name) for a system
+ operation, (resource type, 'interaction', code) or (resource type, 'operation', name), else None.
+ """
+ resource_type, *rest = segments
+ if not rest and resource_type.startswith('$') and is_view(view, FhirOperationView):
+ return '$', resource_type[1:]
+ if resource_type not in RESOURCE_TYPES:
+ return None
+ instance = bool(rest) and bool(CONVERTER.fullmatch(rest[0]))
+ rest = rest[1:] if instance else rest
+ interaction = 'read' if instance else 'search-type'
+ if not rest and is_view(view, INTERACTIONS[interaction]):
+ return resource_type, 'interaction', interaction
+ if len(rest) == 1 and rest[0].startswith('$') and is_view(view, FhirOperationView):
+ return resource_type, 'operation', rest[0][1:]
+ return None
+
+
+@lru_cache(maxsize=None)
+def routed(resolver):
+ """
+ {base: (resources, system operations)} for a URL configuration: each base's resource types in route order, each
+ with its interactions (in R4's order) and its operations (in route order), and the base's own operations.
+ """
+ found = {}
+ for route, view in routes(resolver):
+ fhir_base, segments = base_of(route)
+ offer = offering(segments, view) if fhir_base is not False and segments and segments[0] else None
+ if offer:
+ resources, system = found.setdefault(fhir_base, ({}, []))
+ if offer[0] == '$':
+ system.append(offer[1])
+ else:
+ resources.setdefault(offer[0], {'interaction': [], 'operation': []})[offer[1]].append(offer[2])
+ return {fhir_base: (
+ [{'type': resource_type, 'interactions': [code for code in INTERACTIONS if code in resource['interaction']],
+ 'operations': unique(resource['operation'])} for resource_type, resource in resources.items()],
+ unique(system)) for fhir_base, (resources, system) in found.items()}
+
+
+def unique(items):
+ return list(dict.fromkeys(items))
+
+
+def offered(kwargs):
+ """(resources, system operations) the base `kwargs` names routes."""
+ kind = next((kind for kind in OWNER_BASES.values() if (kwargs or {}).get(kind)), None)
+ return routed(get_resolver()).get(kind, ([], []))
+
+
+def described(request, kwargs):
+ """What both statements say of the service: the base URL, when it was deployed, and the software."""
+ return {'url': fhir_base_url(request, kwargs), 'date': STARTED, 'software': SOFTWARE}
+
+
+def capability_statement(request, kwargs):
+ """The CapabilityStatement of the base `kwargs` names, as the routes under it make it."""
+ resources, system = offered(kwargs)
+ return {
+ **described(request, kwargs),
+ 'formats': FORMATS,
+ 'resources': [{
+ **resource,
+ 'search_parameters': [*PARAMETERS[resource['type']], *STRING_FIELDS]
+ if 'search-type' in resource['interactions'] else []} for resource in resources],
+ 'operations': system,
+ }
+
+
+def versions(parameters, kwargs, user, instance=None): # pylint: disable=unused-argument
+ """
+ $versions: the FHIR versions the fhirVersion MIME parameter accepts, as major.minor (4.0 and 4.0.1 are R4, 4.0),
+ and the one served when a request names none.
+ """
+ return {'versions': unique('.'.join(version.split('.')[:2]) for version in FhirVersioning.allowed_versions),
+ 'default': FhirVersioning.default_version}
+
+
+def code_systems(kwargs, user):
+ """
+ [{url, versions, default}] for each repository a CodeSystem search at the base shows the caller, under the
+ identifier the resolver serves it at (core.fhir.resolve's served_repositories): its released versions, newest
+ first, as they are stored (each a business version, as R4 lists them), and the one served without a version, the
+ newest live one.
+ """
+ listed = []
+ for identifier, rows in sorted(served_repositories(kwargs, user), key=lambda item: item[0]):
+ default = next((version for version, retired in rows if not retired), None)
+ if default is not None:
+ listed.append({'url': identifier, 'versions': [version for version, _ in rows], 'default': default})
+ return listed
+
+
+def terminology_capabilities(request, kwargs):
+ """The TerminologyCapabilities of the base `kwargs` names, for the request's user."""
+ resources, _ = offered(kwargs)
+ operations = {(resource['type'], name) for resource in resources for name in resource['operations']}
+ return {
+ **described(request, kwargs),
+ 'code_systems': within_search_timeout(
+ lambda: code_systems(kwargs, request.user),
+ lambda: outcomes.TooCostlyError('The list of code systems would take too long to answer.')),
+ **dict(block for operation, block in OPERATION_BLOCKS.items() if operation in operations),
+ }
diff --git a/core/fhir/mapping_index.py b/core/fhir/mapping_index.py
new file mode 100644
index 000000000..0fb8d25ce
--- /dev/null
+++ b/core/fhir/mapping_index.py
@@ -0,0 +1,166 @@
+"""
+The versions that may hold mappings from or to a source, as OCL's mappings index (core.mappings.documents) lists them:
+the candidates ConceptMap source-system and target-system search (core.fhir.search) then decides in the database, so a
+search doesn't join a heavily mapped source's mappings with every version's links.
+
+- A mapping's document names the source of each side by owner type, owner and mnemonic ({side}_concept_owner_type,
+ {side}_concept_owner, {side}_concept_source), and the versions of its own repository that hold it (owner_type, owner,
+ source, source_version; owner and source in lowercase).
+- One request per search parameter aggregates the documents whose side is one of the sources named, by owner type,
+ owner, repository and version: the repositories holding such mappings, and their versions. What it reads and lists
+ is scoped to the ConceptMap versions the database shows the caller at the base (scope_of: their owner types,
+ owners, repositories and version names, and HEAD), never by a document's own copy of its access, and only what
+ the base shows is kept: a ConceptMap or a version the caller isn't shown can't change the answer, or exhaust the
+ aggregation's limits unless its names are those of one the caller is shown.
+- The index names candidates; it never decides. The database decides each candidate version by its own mappings
+ (core.fhir.search.mapped), so it alone says what's live, deleted or in the version, and tells apart two owners whose
+ names differ only in case.
+- Bounded: at most MAX_BUCKETS owners, repositories of an owner and versions of a repository; a whole answer only (no
+ partial results), within the time the search has left and without retries. Past the limit or the time the search is
+ too costly; a failed shard, a missing index or any other error of the index is IndexUnavailableError.
+- The index is written after the database. A release created within LAG_WINDOW, of a repository the index lists, is a
+ candidate whether the index lists the release yet or not; past that window, a release whose indexing never
+ completed is missed until it's indexed again.
+"""
+import logging
+import math
+import time
+from datetime import timedelta
+
+from elasticsearch import ApiError, ConnectionTimeout, TransportError
+from elasticsearch_dsl.connections import connections
+
+from core.common.constants import HEAD
+from core.fhir import outcomes
+from core.mappings.documents import MappingDocument
+
+logger = logging.getLogger('oclapi')
+
+MAX_BUCKETS = 1000 # the most owners, repositories of an owner and versions of a repository an aggregation lists
+OWNER_TYPES = 2 # Organization and User
+LAG_WINDOW = timedelta(days=7) # how long after its creation a release may still be missing from the index
+UNAVAILABLE = 'The search could not be completed: the mappings index did not answer in full. Try again later.'
+
+
+class IndexUnavailableError(outcomes.FhirError):
+ """The mappings index answered only in part, or not at all: an error that isn't the request's (422 exception)."""
+ issue_code = 'exception'
+
+
+def scope_of(shown):
+ """
+ {field: sorted values} of the documents of the repositories and versions `shown` ({(owner type, owner, mnemonic):
+ {version names}}), as the index keeps them: owner and repository in lowercase; the version names with HEAD.
+ """
+ return {'owner_type': sorted({owner_type for owner_type, _, _ in shown}),
+ 'owner': sorted({owner.lower() for _, owner, _ in shown}),
+ 'source': sorted({mnemonic.lower() for _, _, mnemonic in shown}),
+ 'source_version': sorted(set().union(*shown.values()) | {HEAD})}
+
+
+def side_query(side, repositories):
+ """The documents whose `side` ('from' or 'to') source is one of `repositories`: (owner type, owner, mnemonic)."""
+ return {'bool': {'should': [{'bool': {'filter': [
+ {'term': {f'{side}_concept_owner_type': owner_type}}, {'term': {f'{side}_concept_owner': owner}},
+ {'term': {f'{side}_concept_source': mnemonic}}]}} for owner_type, owner, mnemonic in sorted(repositories)],
+ 'minimum_should_match': 1}}
+
+
+def terms(field, size, aggs=None, include=None):
+ """A terms aggregation of `field`, listing only the values of `include` (if any) before its size is counted."""
+ return {'terms': {'field': field, 'size': size, **({'include': include} if include else {})},
+ **({'aggs': aggs} if aggs else {})}
+
+
+def by_version(scope):
+ """
+ Owner type, owner, repository and version (HEAD included). The request's filter keeps only documents whose owner
+ type, owner and repository are among `scope`'s, and those are single-valued, so only the versions (a document
+ lists each one holding it) are cut to `scope`'s.
+ """
+ return {'owner_type': terms('owner_type', OWNER_TYPES, {'owner': terms('owner', MAX_BUCKETS, {
+ 'source': terms('source', MAX_BUCKETS, {
+ 'version': terms('source_version', MAX_BUCKETS, include=scope['source_version'])})})})}
+
+
+def versions_of(aggregation, too_costly):
+ """
+ {(owner type, owner, repository): {version names}} for an aggregation by_version(), owner and repository in
+ lowercase; too costly when any level left buckets out.
+ """
+ def buckets(level):
+ if level['sum_other_doc_count']:
+ raise too_costly()
+ return level['buckets']
+
+ return {(kind['key'], owner['key'], repository['key']): {
+ version['key'] for version in buckets(repository['version'])}
+ for kind in buckets(aggregation['owner_type']) for owner in buckets(kind['owner'])
+ for repository in buckets(owner['source'])}
+
+
+def whole(response, too_costly):
+ """A response's aggregations, if the index answered in full; else too costly (its time ran out) or unavailable."""
+ if response.get('timed_out'):
+ raise too_costly()
+ shards = response.get('_shards') or {}
+ if not shards or shards.get('failed') or shards.get('successful') != shards.get('total'):
+ logger.warning('The mappings index answered a FHIR search in part: %s', shards)
+ raise IndexUnavailableError(UNAVAILABLE)
+ return response.get('aggregations') or {}
+
+
+def request(side, alternatives, scope, left, too_costly):
+ """The index's response for each of `alternatives`' repositories among `scope`, within `left` seconds."""
+ client = connections.get_connection().options(request_timeout=left, max_retries=0, retry_on_timeout=False)
+ try:
+ return client.search(
+ index=MappingDocument.Index.name, size=0, track_total_hits=False, allow_partial_search_results=False,
+ timeout=f'{max(1, math.floor(left * 900))}ms',
+ query={'bool': {'filter': [
+ {'bool': {'should': [side_query(side, repositories) for repositories in alternatives],
+ 'minimum_should_match': 1}},
+ *[{'terms': {field: values}} for field, values in scope.items()]]}},
+ aggs={str(number): {'filter': side_query(side, repositories), 'aggs': by_version(scope)}
+ for number, repositories in enumerate(alternatives)})
+ except ConnectionTimeout as ex:
+ raise too_costly() from ex
+ except (ApiError, TransportError) as ex:
+ logger.warning('The mappings index did not answer a FHIR search: %s', ex)
+ raise IndexUnavailableError(UNAVAILABLE) from ex
+
+
+def kept(found, shown):
+ """
+ The repositories and versions of `found` that `shown` lists, HEAD with them; twins in lowercase share theirs. A
+ repository the index lists only by versions `shown` doesn't is left out.
+ """
+ listed = {}
+ for (owner_type, owner, mnemonic), versions in shown.items():
+ listed.setdefault((owner_type, owner.lower(), mnemonic.lower()), {HEAD}).update(versions)
+ return {repository: versions & listed[repository] for repository, versions in found.items()
+ if repository in listed and versions & listed[repository]}
+
+
+def holding(side, repositories, shown, deadline, too_costly): # pylint: disable=too-many-arguments
+ """
+ {key: {(owner type, owner, repository): {version names}}}, owner and repository in lowercase: for each key's
+ repositories ((owner type, owner, mnemonic) of its sources), the repositories and versions among `shown` (the
+ ConceptMap versions the base shows the caller, {(owner type, owner, mnemonic): {version names}}) holding a mapping
+ whose `side` ('from' or 'to') source is one of them, with HEAD when the index lists it. One request, by `deadline`
+ (time.monotonic()), else too_costly(); none when nothing is named or shown.
+ """
+ keys = sorted(key for key, value in repositories.items() if value)
+ if not keys or not shown:
+ return {}
+ left = deadline - time.monotonic()
+ if left <= 0:
+ raise too_costly()
+ aggregations = whole(request(side, [repositories[key] for key in keys], scope_of(shown), left, too_costly),
+ too_costly)
+ try:
+ return {key: kept(versions_of(aggregations[str(number)], too_costly), shown)
+ for number, key in enumerate(keys)}
+ except (KeyError, TypeError) as ex:
+ logger.warning('The mappings index answered a FHIR search without its aggregations')
+ raise IndexUnavailableError(UNAVAILABLE) from ex
diff --git a/core/fhir/ops/__init__.py b/core/fhir/ops/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/fhir/ops/concepts.py b/core/fhir/ops/concepts.py
new file mode 100644
index 000000000..ed6438a71
--- /dev/null
+++ b/core/fhir/ops/concepts.py
@@ -0,0 +1,201 @@
+"""
+What $lookup and $validate-code share (core.fhir.ops.lookup, core.fhir.ops.validate_code): reading the code they are
+asked about, finding it in a code system version, and the concept's display and names.
+
+- Input: a code with its system, version and display as their own parameters, or as a Coding (`coding`, or a
+ codeableConcept holding one coding). Text inputs may come as any FHIR string type (valueString, valueCode,
+ valueUri); anything else is a 400 naming the parameter.
+- A code is found among the version's own concepts, retired ones included, as FHIR spells it or percent-encoded, as
+ OCL stores a code with reserved characters. One query for the concept (with its repository), one for its names.
+- Display: with a language, the concept's preferred name in that language when it has one; else its own display (OCL's
+ display_name: the preferred name in its code system's default locale).
+"""
+from collections import namedtuple
+from urllib.parse import quote, unquote
+
+from django.db.models import F, Q
+
+from core.common.constants import HEAD
+from core.common.utils import to_parent_uri
+from core.concepts.models import Concept
+from core.fhir import outcomes
+from core.fhir.resolve import REPO_PATHS, UnresolvedError, namespace_of, resolve, to_ocl_path
+from core.fhir.search import read_version
+
+# A code, as an operation is given it; `parameter` is the Coding parameter it came in, None for separate parameters
+Coding = namedtuple('Coding', 'system code version display parameter')
+PATHS = {None: '', 'coding': 'Coding.', 'codeableConcept': 'CodeableConcept.coding[0].'}
+
+
+def text(parameters, name):
+ """A text input (a string, code or uri), or None when it's absent or empty; any other value is a 400 naming it."""
+ value = parameters.get(name)
+ if value is None or value == '':
+ return None
+ if not isinstance(value, str):
+ raise outcomes.InvalidError(f"'{name}' must be text.", expression=name)
+ return value
+
+
+def coding_of(value, name):
+ """A Coding parameter as a Coding: a dict, or in a GET, a system|code token. Its parts must be text."""
+ if isinstance(value, str):
+ system, _, code = value.rpartition('|')
+ value = {'system': system, 'code': code}
+ parts = {key: value.get(key) for key in ('system', 'code', 'version', 'display')} if isinstance(value, dict) else {}
+ if not parts or not all(part is None or isinstance(part, str) for part in parts.values()):
+ raise outcomes.InvalidError(f"'{name}' must be a Coding.", expression=name)
+ return Coding(*(part or None for part in parts.values()), parameter=name)
+
+
+def coded_input(parameters, system, version):
+ """
+ The code an operation is asked about: `coding`, a codeableConcept with one coding, or else code and display with
+ the system and version from the parameters named `system` and `version`.
+ """
+ if 'coding' in parameters:
+ return coding_of(parameters.get('coding'), 'coding')
+ if 'codeableConcept' in parameters:
+ concept = parameters.get('codeableConcept')
+ codings = concept.get('coding') if isinstance(concept, dict) else None
+ if not isinstance(codings, list) or len(codings) != 1:
+ raise outcomes.NotSupportedError(
+ 'A codeableConcept is supported with one coding: send each coding on its own.',
+ expression='codeableConcept')
+ return coding_of(codings[0], 'codeableConcept')
+ return Coding(text(parameters, system), text(parameters, 'code'), text(parameters, version),
+ text(parameters, 'display'), None)
+
+
+def path(coding, part):
+ """The FHIRPath of a part of the code in an issue: the parameter itself, or the part of the Coding it came in."""
+ return PATHS[coding.parameter] + part
+
+
+def require(coding, part, message):
+ """The part of the code an operation needs, or a 400 naming the input it's missing from (`part`, or the Coding)."""
+ value = getattr(coding, part)
+ if not value:
+ raise outcomes.InvalidError(message, expression=coding.parameter or part)
+ return value
+
+
+def stored_codes(code):
+ """The forms a code may be stored in: as it is, and percent-encoded as OCL encodes reserved characters."""
+ return list(dict.fromkeys([code, quote(code, safe=''), quote(code)]))
+
+
+def code_q(field, code):
+ """Q for rows whose `field` is the code in a form OCL stores it in: an equality when there's only one."""
+ forms = stored_codes(code)
+ return Q(**{field: forms[0]}) if len(forms) == 1 else Q(**{f'{field}__in': forms})
+
+
+def fhir_code(code):
+ """A code as FHIR spells it: OCL stores one with reserved characters percent-encoded (ICD-11's ND53.Y&XK70)."""
+ return unquote(code) if code and '%' in code else code
+
+
+def system_of(version, named=None):
+ """A code system version's canonical URL (else its repository's), or else the URL it was named by."""
+ return version.canonical_url or getattr(version, 'repository_canonical_url', None) or named
+
+
+def instance_version(resource_type, instance_id, version, kwargs, user):
+ """
+ The repository version an instance-level operation is on: as a read finds it (a release: at the global base the
+ opaque id's, at an owner base the mnemonic's `version`, else the newest shown). At an owner base, HEAD and a named
+ version the read doesn't find go through core.fhir.resolve, with its visibility rule. A global id names a release,
+ so any version named there is left to the caller's version check. Not found is a read's 404.
+ """
+ namespace = namespace_of(kwargs)
+ if namespace is None or version != HEAD:
+ try:
+ return read_version(resource_type, instance_id, version, kwargs, user)
+ except outcomes.NotFoundError:
+ if namespace is None or not version:
+ raise
+ try:
+ return resolve(resource_type, f'{namespace}{REPO_PATHS[resource_type]}/{instance_id}/', version, kwargs, user)
+ except UnresolvedError as ex:
+ raise outcomes.NotFoundError(f'{resource_type}/{instance_id} not found') from ex
+
+
+def is_system_of(system, version, named=None):
+ """
+ Whether a coding's system (or a value set url) names a repository version: its canonical URL (or its
+ repository's), the URL it was resolved by (`named`), or an OCL path of its repository or of the version.
+ """
+ url = system.partition('|')[0]
+ canonicals = {version.canonical_url, getattr(version, 'repository_canonical_url', None),
+ (named or '').partition('|')[0]}
+ if url in canonicals - {None, ''}:
+ return True
+ repository = to_parent_uri(to_ocl_path(url))
+ repository = repository if repository.endswith('/') else f'{repository}/'
+ return repository in {version.uri, '/'.join(version.uri.split('/')[:5]) + '/'}
+
+
+def mismatch(parameters, coding, version, system_name, instance_id):
+ """
+ (expression, message) of an input naming another code system or version than the one resolved, else None. At the
+ instance level, the id names it: the system parameter (`system_name`, url or system), the coding's system and any
+ version named (the version parameter, the coding's, a url|version) must all be its. At the type level, a Coding's
+ system and version must be those of the code system it is validated in, which url and version may name.
+ """
+ if instance_id:
+ systems = [(text(parameters, system_name), system_name), (coding.system, path(coding, 'system'))]
+ versions = [(text(parameters, 'version'), 'version'), (coding.version, path(coding, 'version'))]
+ named = None
+ elif coding.parameter:
+ systems, versions = [(coding.system, path(coding, 'system'))], [(coding.version, path(coding, 'version'))]
+ named = text(parameters, system_name) or coding.system
+ else:
+ return None
+ for system, expression in systems:
+ if system and not is_system_of(system, version, named):
+ return expression, f"'{system}' is not the code system '{system_of(version, named)}' it is about"
+ versions.append((system and system.partition('|')[2], expression))
+ for named_version, expression in versions:
+ if named_version and named_version != version.version:
+ return expression, f"Version '{named_version}' is not the version '{version.version}' it is about"
+ return None
+
+
+def find_concept(version, code):
+ """
+ The code system version's concept with the code, retired or not (a deleted one, is_active false, is gone), with its
+ repository and names; else None.
+ """
+ concepts = version.concepts_set.filter(id=F('versioned_object_id')) if version.is_head else \
+ Concept.objects.filter(sources=version)
+ return concepts.filter(code_q('mnemonic', code), is_active=True).select_related('parent').prefetch_related(
+ 'names').order_by('id').first()
+
+
+def display_of(concept, language=None):
+ """
+ The concept's display: with a language, its preferred name in that language, newest first, when it has one; else
+ its own display. None for a concept without names.
+ """
+ if language:
+ names = sorted((name for name in concept.names.all() if name.locale == language and not name.retired),
+ key=lambda name: (name.locale_preferred, name.created_at), reverse=True)
+ if names:
+ return names[0].name
+ return concept.display_name
+
+
+def has_name(concept, display):
+ """
+ Whether a display is right for the concept: one of its names, in any language, ignoring case (FHIRsmith's default
+ check). A concept without names has nothing to check a display against.
+ """
+ names = [name.name.casefold() for name in concept.names.all()]
+ return not names or display.casefold() in names
+
+
+def designations(concept):
+ """Each of the concept's names as a designation: its language, its OCL name type as `use`, and its text."""
+ return [{'language': name.locale, 'use': {'code': name.type} if name.type else None, 'value': name.name}
+ for name in concept.names.all()]
diff --git a/core/fhir/ops/expand.py b/core/fhir/ops/expand.py
new file mode 100644
index 000000000..19c72bac9
--- /dev/null
+++ b/core/fhir/ops/expand.py
@@ -0,0 +1,394 @@
+"""
+ValueSet $expand (SVCM ITI-97, FHIR R4's $expand), version-neutral: one page of the stored expansion of the value set
+version the request names. core.fhir.adapters.r4 writes the answer as an R4 ValueSet.
+
+- The value set: url (and valueSetVersion, or url|version) at the type level, or ValueSet/{id} at the instance level,
+ resolved as $validate-code resolves it (core.fhir.ops.validate_code.value_set_version): a released version, the
+ newest the caller is shown unless one is named (HEAD only when named); public only at /fhir/; at an owner base,
+ private ones to those who may view them. One that doesn't resolve, or that the caller isn't shown, is a 422 not-found,
+ the same for both.
+- The expansion: the version's default stored expansion, read and never computed or saved, so a POST reads exactly as
+ a GET does. A version without one, or whose expansion is still being computed, can't be expanded (422); so can't one
+ whose references named a code system or value set OCL didn't hold when it was computed (422 not-found, as FHIR
+ answers a value set that draws on an unknown code system), because its stored expansion is incomplete.
+- Members: the expansion's concepts (not deleted) whose code system the caller is shown (core.fhir.resolve's
+ viewable_rows), as ValueSet $validate-code counts them. A member of a code system the caller isn't shown leaves no
+ trace: not in contains, total or used-codesystem, and no other statement runs.
+- Each member: its code system (the canonical URL of the release it is reported in, else its repository's, else the
+ repository's OCL path as a CodeSystem), its code as FHIR spells it, its display (with
+ displayLanguage, its preferred name in that language when it has one), inactive when retired, its names as
+ designations with includeDesignations, and the version: a live release the caller is shown that holds the member's
+ row, the expansion's explicit pin first, then its evaluated pin, then the newest. A versioned-object row carries
+ the concept's newest content, so it uses the newest holding release of another, not deleted, version, whatever the
+ expansion pins. None without one, never HEAD or an unreleased version (as $validate-code answers).
+- Inputs: activeOnly leaves retired concepts out; filter keeps the concepts whose code (as FHIR spells it) or one of
+ whose names has a word starting with each of the filter's words, ignoring case; offset and count page the members in
+ a fixed order (count at most MAX_COUNT, which is also the default); count=0 answers the total alone. total counts
+ what activeOnly and filter keep. The inputs that shaped the answer are echoed in expansion.parameter, then each code
+ system the members come from, with each version they are reported in, as used-codesystem. Every other R4 $expand
+ input is one a stored expansion always satisfies (SATISFIED: OCL's expansions are flat, with no abstract codes), one
+ supported only when false (ONLY_FALSE: an expansion has no definitions, and doesn't record which of its codes are
+ post-coordinated), or a 400 not-supported naming it (NOT_SUPPORTED): an inline value set, a context, a date, code
+ system versions and designations would each need an expansion computed for the request, and OCL serves the ones it
+ stores. Names that aren't $expand inputs are ignored, as _format is.
+- Cost: a fixed number of queries whatever the size of the expansion; for members whose rows no release holds,
+ links of each repository's newest live release the caller is shown are read as a set, then older holding releases
+ probed per member only for those still unresolved. Full concepts and names are read for the page alone. A filtered
+ expansion reads the ids of its matching members once, and its total and page come from them. All queries within
+ SEARCH_TIMEOUT_MS together, past which the expansion is too costly (422).
+"""
+import re
+
+from django.db.models import BooleanField, Case, Exists, ExpressionWrapper, F, Func, IntegerField, JSONField, \
+ OuterRef, Q, Subquery, Value, When
+from django.db.models.functions import Coalesce, JSONObject
+from django.db.models.lookups import Regex
+
+from core.collections.models import Expansion
+from core.common.constants import HEAD
+from core.common.serializers import IdentifierSerializer
+from core.concepts.models import Concept, ConceptName
+from core.fhir import outcomes
+from core.fhir.ops.concepts import designations, display_of, fhir_code, text
+from core.fhir.ops.validate_code import value_set_version
+from core.fhir.resolve import namespace_of, viewable_rows
+from core.fhir.search import within_search_timeout
+from core.sources.models import Source
+
+MAX_COUNT = 1000 # the most members a page holds, and how many it holds when count isn't given
+WORD = re.compile(r'\w+')
+# an escape of an ASCII character that is no word character (not a letter, digit or _), matched ignoring case
+NON_WORD_ESCAPE = '%(?:[01][0-9a-f]|2[0-9a-f]|3[a-f]|40|5[b-e]|60|7[b-f])'
+# the escapes of one non-ASCII character as a stored code writes them, matched ignoring case: a well-formed UTF-8
+# sequence of 2 to 4 bytes (Unicode's Table 3-7), else a single byte, which makes no character
+NON_ASCII_CHARACTER = ('(?:%c[2-9a-f]|%d[0-9a-f])%[89ab][0-9a-f]'
+ '|(?:%e0%[ab][0-9a-f]|%e[1-9a-cef]%[89ab][0-9a-f]|%ed%[89][0-9a-f])%[89ab][0-9a-f]'
+ '|(?:%f0%[9ab][0-9a-f]|%f[1-3]%[89ab][0-9a-f]|%f4%8[0-9a-f])%[89ab][0-9a-f]%[89ab][0-9a-f]'
+ '|%[89a-f][0-9a-f]')
+STORED = 'OCL serves the expansion it stores for the value set version'
+VERSIONS = f'Choosing code system versions is not supported: {STORED}, made from the versions used-codesystem names.'
+NOT_SUPPORTED = { # R4 $expand inputs that need an expansion computed for the request
+ 'valueSet': f'An inline valueSet is not supported: {STORED}, named by url.',
+ 'context': 'The context parameter is not supported.',
+ 'contextDirection': 'The contextDirection parameter is not supported.',
+ 'date': f'An expansion as of a date is not supported: {STORED}.',
+ 'designation': 'Choosing designations is not supported: includeDesignations includes every name.',
+ 'exclude-system': f'Excluding a code system is not supported: {STORED}.',
+ 'system-version': VERSIONS,
+ 'check-system-version': VERSIONS,
+ 'force-system-version': VERSIONS,
+}
+ONLY_FALSE = { # boolean R4 $expand inputs supported when false
+ 'includeDefinition': 'Definitions are not included in an expansion.',
+ 'excludePostCoordinated': f'Excluding post-coordinated codes is not supported: {STORED}, which does not record '
+ 'which of its codes are post-coordinated.',
+}
+SATISFIED = ('excludeNested', 'excludeNotForUI') # booleans every stored expansion meets
+# a reference an expansion couldn't resolve, by its type: (message id, message) without and with a version named
+UNRESOLVED = {
+ 'reference.valueset': (
+ ('VS_EXP_IMPORT_UNK', "Unable to find included value set '{url}'"),
+ ('VS_EXP_IMPORT_UNK_PINNED', "Unable to find included value set '{url}' version '{version}'")),
+ 'system': (
+ ('UNKNOWN_CODESYSTEM_EXP',
+ "A definition for CodeSystem '{url}' could not be found, so the value set cannot be expanded"),
+ ('UNKNOWN_CODESYSTEM_VERSION_EXP',
+ "A definition for CodeSystem '{url}' version '{version}' could not be found, so the value set cannot be "
+ "expanded")),
+}
+
+
+def too_costly():
+ return outcomes.TooCostlyError(
+ 'The expansion would take too long to answer. Narrow it, for example with filter, or page it with count.')
+
+
+def whole_number(parameters, name):
+ """A whole-number input (an integer, or its digits as text), or None when it's absent; anything else is a 400."""
+ value = parameters.get(name)
+ if value is None or value == '':
+ return None
+ if isinstance(value, str) and re.fullmatch(r'[0-9]{1,9}', value.strip()):
+ return int(value)
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
+ return value
+ raise outcomes.InvalidError(f"'{name}' must be a whole number, got '{value}'.", expression=name)
+
+
+def flag(parameters, name):
+ """A boolean input (a boolean, or true or false as text), or None when it's absent; anything else is a 400."""
+ value = parameters.get(name)
+ if value in (None, ''):
+ return None
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str) and (lowered := value.strip().lower()) in ('true', 'false'):
+ return lowered == 'true'
+ raise outcomes.InvalidError(f"'{name}' must be true or false, got '{value}'.", expression=name)
+
+
+class Inputs: # pylint: disable=too-few-public-methods
+ """$expand's inputs other than the value set, read and checked before anything is: the echo is what was given."""
+
+ def __init__(self, parameters):
+ for name, message in NOT_SUPPORTED.items():
+ if name in parameters:
+ raise outcomes.NotSupportedError(message, expression=name)
+ for name, message in ONLY_FALSE.items():
+ if flag(parameters, name):
+ raise outcomes.NotSupportedError(message, expression=name)
+ for name in SATISFIED:
+ flag(parameters, name) # either value is met; one that isn't a boolean is still a 400
+ self.filter = text(parameters, 'filter')
+ self.language = text(parameters, 'displayLanguage')
+ self.designations = flag(parameters, 'includeDesignations')
+ self.active_only = flag(parameters, 'activeOnly')
+ self.offset = whole_number(parameters, 'offset')
+ count = whole_number(parameters, 'count')
+ self.count = MAX_COUNT if count is None else min(count, MAX_COUNT)
+ self.echo = [item for item in (
+ ('filter', 'valueString', self.filter),
+ ('displayLanguage', 'valueCode', self.language),
+ ('includeDesignations', 'valueBoolean', self.designations),
+ ('activeOnly', 'valueBoolean', self.active_only),
+ ('offset', 'valueInteger', self.offset),
+ ('count', 'valueInteger', None if count is None else self.count),
+ ) if item[2] is not None]
+
+
+def escapes(char):
+ """A character percent-encoded, as OCL stores it in a code: %XX for each of its UTF-8 bytes."""
+ return ''.join(f'%{byte:02X}' for byte in char.encode())
+
+
+def spelled(word):
+ """
+ A regex, matched ignoring case, for `word` as a stored code may spell it: each character as itself, or as the
+ escapes of itself or of its other case (é: é, %C3%A9 or %C3%89). \\w characters need no escaping in a regex.
+ """
+ spellings = (dict.fromkeys([char, *(escapes(case) for case in (char, char.lower(), char.upper())
+ if len(case) == 1)]) for char in word)
+ return ''.join(f"(?:{'|'.join(spelling)})" for spelling in spellings)
+
+
+class NonWordBefore(Func): # pylint: disable=abstract-method
+ """
+ Whether, somewhere in the code, a non-ASCII character's escapes stand right before `spelling` (a word, spelled) and
+ that character is no word character: a well-formed UTF-8 sequence, decoded, for a character Postgres doesn't count
+ as a letter, digit or _; or a single byte, which makes no character (FHIR's decoding spells it U+FFFD). A regex
+ takes the longest match where it starts, so a character's escapes are matched whole.
+ """
+ template = (
+ "EXISTS (SELECT FROM regexp_matches(%(expressions)s, 'gi') AS characters (escapes) WHERE CASE WHEN "
+ "length(escapes[1]) = 3 THEN true ELSE convert_from(decode(regexp_replace(escapes[1], '[^0-9A-Fa-f]', '', "
+ "'g'), 'hex'), 'UTF8') COLLATE \"default\" !~ '[[:alnum:]_]' END)")
+ output_field = BooleanField()
+
+ def __init__(self, spelling):
+ super().__init__(F('mnemonic'), Value(f'(?:{NON_ASCII_CHARACTER})(?={spelling})'))
+
+
+def code_has_word(word):
+ """
+ Whether a member's code, as FHIR spells it (core.fhir.ops.concepts.fhir_code decodes OCL's percent-encoding), has
+ a word starting with `word`, ignoring case, by the rule filter applies to names (Postgres's \\m: a word character
+ is a letter or digit of any script, or _). The stored code is matched a whole character at a time, never from inside
+ an escape. A word starts there:
+ - in a code with no escape, where \\m says; so too for a word that doesn't start with a word character (filter's
+ words are Python's \\w+, which counts a few characters more, ² for one): \\m starts no word with one;
+ - at its start, or after a character that is no word character: written as itself, as an ASCII escape, or a % that
+ starts no escape;
+ - after a non-ASCII character's escapes, when that character, decoded, is no word character (NonWordBefore).
+ Each branch is tried only when the ones before it haven't decided, so a code without the word in any spelling costs
+ one regex, and one without escapes the regex names get.
+ """
+ spelling = spelled(word)
+ return Case(
+ When(~Q(mnemonic__contains='%') | ~Q(Regex(Value(word), '^[[:alnum:]_]')),
+ then=ExpressionWrapper(Q(mnemonic__iregex=rf'\m{word}'), output_field=BooleanField())),
+ When(~Q(mnemonic__iregex=spelling), then=False),
+ When(mnemonic__iregex=rf'(?:^|[^[:alnum:]_%]){spelling}', then=True),
+ When(mnemonic__iregex=rf'{NON_WORD_ESCAPE}{spelling}', then=True),
+ When(mnemonic__iregex=rf'%(?![0-9a-f]{{2}}){spelling}', then=True),
+ When(mnemonic__iregex=rf'%[89a-f][0-9a-f]{spelling}', then=NonWordBefore(spelling)),
+ default=False, output_field=BooleanField())
+
+
+def expand(parameters, kwargs, user, instance_id=None):
+ """
+ $expand's answer for its FhirParameters at the base `kwargs` names (ValueSet/{instance_id}/$expand at the instance
+ level), for `user`: {value_set, identifier, timestamp, total, offset, parameters, used, contains}.
+ """
+ inputs = Inputs(parameters)
+ if not instance_id and not text(parameters, 'url'):
+ raise outcomes.InvalidError('url is required: the value set to expand.', expression='url')
+ return within_search_timeout(lambda: Expander(parameters, inputs, kwargs, user, instance_id).answer(), too_costly)
+
+
+def stored_expansion(value_set):
+ """The value set version's default stored expansion, when it is one that can be served; else the 422 why not."""
+ name = f'{value_set.canonical_url or value_set.uri}|{value_set.version}'
+ expansion = Expansion.objects.filter(uri=value_set.expansion_uri).only(
+ 'id', 'uri', 'created_at', 'is_processing', 'unresolved_repo_versions').first() if \
+ value_set.expansion_uri else None
+ if expansion is None:
+ raise outcomes.InvalidError(
+ f'The value set {name} has no stored expansion, so it cannot be expanded.', tx_issue_type='vs-invalid',
+ status_code=422)
+ if expansion.is_processing:
+ raise outcomes.ProcessingError(f'The expansion of the value set {name} is being computed: try again shortly.')
+ if expansion.unresolved_repo_versions:
+ raise unresolved(expansion.unresolved_repo_versions[0])
+ return expansion
+
+
+def unresolved(reference):
+ """The 422 not-found for the first reference an expansion couldn't resolve: a value set, else a code system."""
+ url, version = reference.get('url') or '', reference.get('version') or None
+ message_id, message = UNRESOLVED.get(reference.get('type'), UNRESOLVED['system'])[bool(version)]
+ return outcomes.NotFoundError(message.format(url=url, version=version), tx_issue_type='not-found',
+ message_id=message_id, status_code=422)
+
+
+def code_system(canonical_url, uri, release=None):
+ """
+ (system, version) of a member of the repository with this canonical URL and OCL path, reported in `release`
+ ({version, url}, or None): the release's own canonical URL, else the repository's, else its OCL path as a
+ CodeSystem (/orgs/{org}/CodeSystem/{id}/), as a read names the release; and the release's version.
+ """
+ release = release or {}
+ system = release.get('url') or canonical_url or IdentifierSerializer.convert_ocl_uri_to_fhir_url(uri, 'CodeSystem')
+ return system, release.get('version')
+
+
+class Expander:
+ """One $expand: the value set version, its stored expansion, and the members the caller is shown."""
+
+ def __init__(self, parameters, inputs, kwargs, user, instance_id): # pylint: disable=too-many-arguments
+ self.inputs = inputs
+ self.value_set = value_set_version(parameters, kwargs, user, instance_id)
+ self.expansion = stored_expansion(self.value_set)
+ self.namespace, self.user = namespace_of(kwargs), user
+
+ def members(self):
+ """The expansion's concepts that are its members for the caller: not deleted, of a code system it is shown."""
+ return Concept.objects.filter(
+ Exists(viewable_rows(Source, self.namespace, self.user).filter(id=OuterRef('parent_id'))),
+ expansion_set=self.expansion.id, is_active=True)
+
+ def matching(self):
+ """The members activeOnly and filter keep."""
+ members = self.members()
+ if self.inputs.active_only:
+ members = members.filter(retired=False)
+ # \m: a word starts here; \w characters need no escaping in a regex
+ for word in WORD.findall(self.inputs.filter or ''):
+ members = members.filter(Q(code_has_word(word)) | Exists(ConceptName.objects.filter(
+ concept_id=OuterRef('id'), name__iregex=rf'\m{word}')))
+ return members
+
+ def live_releases(self):
+ """The active, non-retired releases the caller is shown, never HEAD."""
+ return viewable_rows(Source, self.namespace, self.user).filter(
+ is_active=True, released=True, retired=False).exclude(version=HEAD)
+
+ def release(self):
+ """
+ The live release the caller is shown that holds the member's row, as {version, url}: explicit pin first,
+ then evaluated, then newest by source id; None without one. The newest lookup runs only when no pin holds it.
+ """
+ links = Concept.sources.through.objects.filter(
+ source_id__in=self.live_releases().values('id'), concept_id=OuterRef('id'))
+ explicit, evaluated = (
+ versions.through.objects.filter(expansion_id=self.expansion.id).values('source_id')
+ for versions in (Expansion.explicit_source_versions, Expansion.evaluated_source_versions))
+ pinned = links.filter(Q(source_id__in=explicit) | Q(source_id__in=evaluated)).annotate(explicit=Case(
+ When(source_id__in=explicit, then=Value(1)), default=Value(0), output_field=IntegerField())).order_by(
+ '-explicit', '-source_id')
+ return Coalesce(*(Subquery(holding.annotate(
+ release=JSONObject(version='source__version', url='source__canonical_url')).values('release')[:1],
+ output_field=JSONField()) for holding in (pinned, links.order_by('-source_id'))),
+ output_field=JSONField())
+
+ def releases(self, members, rows):
+ """
+ {member id: release} from (id, versioned-object id, parent id, direct release) rows of `members`.
+ Members held by releases keep their direct release. A versioned-object member carries its concept's newest
+ content, so it uses the newest live release the caller is shown that holds an active version, ignoring pins.
+ Only a repository's own releases hold its concepts' versions, so a match in its newest visible live release
+ is also the newest holding release. Those links are read as a set; any still unresolved use a correlated
+ newest lookup, restricted to their member ids. Every member remains a key, with None if no release holds it.
+ """
+ rows = list(rows)
+ found = {member: release for member, _, _, release in rows}
+ missing = {member: (root, parent) for member, root, parent, release in rows if release is None}
+ if not missing:
+ return found
+ newest = self.live_releases().filter(mnemonic=OuterRef('mnemonic')).filter(
+ Q(organization_id=OuterRef('organization_id')) | Q(user_id=OuterRef('user_id'))).order_by('-id')
+ heads = Source.objects.filter(id__in={parent for _, parent in missing.values()}).annotate(
+ newest=Subquery(newest.values('id')[:1])).values('newest')
+ held = dict(Concept.sources.through.objects.filter(
+ source_id__in=heads, concept__is_active=True,
+ concept__versioned_object_id__in=members.order_by().values('versioned_object_id')).annotate(
+ release=JSONObject(version='source__version', url='source__canonical_url')).values_list(
+ 'concept__versioned_object_id', 'release'))
+ for member, (root, _) in list(missing.items()):
+ if root in held:
+ found[member] = held[root]
+ del missing[member]
+ if missing:
+ holding = Concept.sources.through.objects.filter(
+ source_id__in=self.live_releases().values('id'), concept__is_active=True,
+ concept__versioned_object_id=OuterRef('versioned_object_id')).order_by('-source_id')
+ found.update(Concept.objects.filter(id__in=list(missing)).annotate(release=Subquery(holding.annotate(
+ release=JSONObject(version='source__version', url='source__canonical_url')).values('release')[:1],
+ output_field=JSONField())).values_list('id', 'release'))
+ return found
+
+ def used(self):
+ """Each code system and release of all members, including those held only in other versions: system|version."""
+ members = self.members()
+ rows = list(members.annotate(release=self.release()).values_list(
+ 'id', 'versioned_object_id', 'parent_id', 'release', 'parent__canonical_url', 'parent__uri').order_by())
+ releases = self.releases(members, (row[:4] for row in rows))
+ used = {code_system(url, uri, releases[member]) for member, _, _, _, url, uri in rows}
+ return sorted(f'{system}|{version}' if version else system for system, version in used)
+
+ def page(self, ids):
+ """
+ The members `ids` names (the page offset and count chose), in a fixed order, with what each is shown with.
+ Releases, concepts and names are read for the page's members alone, however deep the offset.
+ """
+ page = Concept.objects.filter(id__in=ids)
+ concepts = page.annotate(release=self.release()).select_related('parent').only(
+ 'id', 'mnemonic', 'retired', 'parent_id', 'versioned_object_id', 'parent__canonical_url', 'parent__uri',
+ 'parent__default_locale', 'parent__supported_locales').prefetch_related('names').order_by('id')
+ releases = self.releases(page, (
+ (concept.id, concept.versioned_object_id, concept.parent_id, concept.release) for concept in concepts))
+ members = []
+ for concept in concepts:
+ parent = concept.parent
+ system, version = code_system(parent.canonical_url, parent.uri, releases[concept.id])
+ members.append({
+ 'system': system, 'version': version, 'code': fhir_code(concept.mnemonic),
+ 'display': display_of(concept, self.inputs.language), 'inactive': concept.retired or None,
+ 'designations': designations(concept) if self.inputs.designations else None,
+ })
+ return members
+
+ def answer(self):
+ matching = self.matching()
+ if self.inputs.filter:
+ ids = list(matching.order_by('id').values_list('id', flat=True))
+ total = len(ids)
+ else:
+ total = matching.count()
+ ids = matching.order_by('id').values('id')
+ offset = self.inputs.offset or 0
+ return {
+ 'value_set': self.value_set, 'identifier': self.expansion.uri, 'timestamp': self.expansion.created_at,
+ 'total': total, 'offset': offset, 'parameters': self.inputs.echo,
+ 'used': self.used(),
+ 'contains': self.page(ids[offset:offset + self.inputs.count]) if self.inputs.count else [],
+ }
diff --git a/core/fhir/ops/lookup.py b/core/fhir/ops/lookup.py
new file mode 100644
index 000000000..5106621b5
--- /dev/null
+++ b/core/fhir/ops/lookup.py
@@ -0,0 +1,61 @@
+"""
+CodeSystem $lookup (SVCM ITI-98, FHIR R4's $lookup), version-neutral: what OCL holds about a code of the code system
+version the request names. core.fhir.adapters.r4 writes the answer as R4 Parameters.
+
+- The code system: `system` and `version` (or a `coding`'s), resolved by core.fhir.resolve at the base's namespace: a
+ released version, the newest the caller is shown unless one is named (HEAD only when named); public only at /fhir/;
+ at an owner base, private ones to those who may view them. CodeSystem/{id}/$lookup names it as a read does. One the
+ caller isn't shown answers exactly as one that doesn't exist.
+- The code: that version's concept, retired ones too, which are flagged inactive (core.fhir.ops.concepts).
+- The answer: name, version (the one resolved), display, code, system, abstract (OCL has no abstract concepts), each
+ of the concept's names as a designation, and the inactive property. `displayLanguage` picks the display among the
+ concept's names in that language; a concept without names is displayed by its code.
+- Errors, as FHIRsmith answers them: no code, or no system at the type level, is a 400; a code system that doesn't
+ resolve is a 422 not-found, a code it doesn't hold a 404 (tx-issue-type invalid-code).
+- Cost: a fixed number of queries whatever the size of the code system: resolution, the concept, its names.
+"""
+from rest_framework import status
+
+from core.fhir import outcomes
+from core.fhir.ops.concepts import (
+ coded_input, require, find_concept, display_of, designations, fhir_code, instance_version, mismatch, system_of,
+ text)
+from core.fhir.resolve import UnresolvedError, resolve
+
+
+def code_system_version(coding, kwargs, user, instance_id=None):
+ """The code system version a $lookup or $validate-code names: the instance, else the coding's system and version."""
+ if instance_id:
+ return instance_version('CodeSystem', instance_id, coding.version, kwargs, user)
+ return resolve('CodeSystem', coding.system, coding.version, kwargs, user)
+
+
+def lookup(parameters, kwargs, user, instance_id=None):
+ """
+ $lookup's answer for its FhirParameters at the base `kwargs` names (CodeSystem/{instance_id}/$lookup at the instance
+ level), for `user`: {name, version, display, code, system, abstract, inactive, designations}.
+ """
+ coding = coded_input(parameters, 'system', 'version')
+ target = coding._replace(version=text(parameters, 'version') or coding.version)
+ code = require(coding, 'code', '$lookup needs the code to look up.')
+ if not instance_id:
+ require(coding, 'system', '$lookup needs the system the code is from.')
+ try:
+ version = code_system_version(target, kwargs, user, instance_id)
+ except UnresolvedError as ex:
+ raise outcomes.NotFoundError(
+ str(ex.detail), tx_issue_type='not-found', status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) from ex
+ conflict = mismatch(parameters, coding, version, 'system', instance_id)
+ if conflict:
+ raise outcomes.InvalidError(f'{conflict[1]}: this $lookup is on {instance_id}.', expression=conflict[0])
+ system = system_of(version, coding.system)
+ concept = find_concept(version, code)
+ if not concept:
+ raise outcomes.NotFoundError(
+ f"Unable to find code '{code}' in {system} version {version.version}", tx_issue_type='invalid-code')
+ return {
+ 'name': version.name, 'version': version.version,
+ 'display': display_of(concept, text(parameters, 'displayLanguage')) or fhir_code(concept.mnemonic),
+ 'code': fhir_code(concept.mnemonic), 'system': system, 'abstract': False, 'inactive': bool(concept.retired),
+ 'designations': designations(concept),
+ }
diff --git a/core/fhir/ops/translate.py b/core/fhir/ops/translate.py
new file mode 100644
index 000000000..cb19f288e
--- /dev/null
+++ b/core/fhir/ops/translate.py
@@ -0,0 +1,386 @@
+"""
+ConceptMap $translate (SVCM ITI-101, FHIR R4's $translate), version-neutral: what a translation finds, with OCL map
+types as R5-shaped relationships. core.fhir.adapters.r4 writes the answer as an R4 Parameters resource.
+
+- The ConceptMap: the one `url` (and `conceptMapVersion`) names; else the code system the code comes from, `system`
+ resolved as a CodeSystem (in reverse, `targetsystem`, the system of the codes sought), whose own mappings are its
+ map, at the release `conceptMapVersion` names, else (forward) the code's `version`. Resolution, versions and access
+ are core.fhir.resolve's: a released version, the newest the caller is shown unless one is named; public only at
+ /fhir/; at an owner base, private ones to those who may view them.
+- The matches: that version's live (neither retired nor deleted) mappings from the code (in reverse, to it) whose from
+ side (in reverse, to side) is shown as the code's system; with `targetsystem`, whose other side is shown as that
+ system. Never another repository's mappings, HEAD's or another release's.
+- A side of a mapping is shown as its source's canonical URL, when the caller is shown that source and it has one; else
+ as the URL the mapping stores (its source's OCL URL when it stores none), an OCL URL as a ConceptMap path
+ (/orgs/{org}/ConceptMap/{id}/). So a source the caller isn't shown is named as a source OCL doesn't hold would be,
+ and a system only such sources have answers as an unknown one. ConceptMapGroupField names groups the same way.
+ A system given as an OCL path of a source (/orgs/{org}/CodeSystem/{id}/, or the sources or ConceptMap form) names
+ that repository if the caller is shown it, whether it has a canonical URL or not; the answer names it as above. A
+ ValueSet or collections path names a value set, not a source.
+- Map types (design D27): case, spaces, hyphens, underscores and surrounding whitespace don't matter, so SAME-AS,
+ SAME AS and Same As are one. The relationships are core.concept_maps.constants'; result is true when some match isn't
+ not-related-to. A match's relationship reads from the code named to the match, so in reverse, where the code named
+ is the mapping's target, narrower and broader swap. With no match, the answer says why (message).
+- The code's scope, `source` (the value set the code was chosen from; in reverse, which reverses the meaning of
+ source and target, `target`), is a precondition: a code outside it has no translation, and one that resolves to no
+ value set (or code system) the caller is shown is not found. The other scope narrows nothing: OCL's ConceptMaps
+ declare no scope, so it selects no map (as FHIRsmith reads OCL's maps); `targetsystem` narrows.
+- The code's `version` (or its coding's) keeps the mappings of that version of its system, and those of none
+ (from_source_version; in reverse, to_source_version).
+ `conceptMap` (an inline map) and `dependency` aren't supported.
+- Cost: a fixed number of queries whatever the number of mappings (resolution, the sources the systems name, the ids
+ of the release's mappings of the code, then those mappings, with whether the caller is shown their sources); at
+ most MATCH_LIMIT matches. Its statements are bounded as a search's are (too long is too costly, a 422).
+"""
+import re
+from collections import namedtuple
+from functools import cached_property, reduce
+from operator import or_
+
+from django.core.exceptions import ValidationError
+from django.db.models import Case, Exists, F, OuterRef, Q, When
+
+from core.collections.models import Expansion
+from core.concepts.models import Concept
+from core.common.serializers import IdentifierSerializer
+from core.concept_maps.constants import (
+ MAP_TYPE_RELATIONSHIPS, NOT_RELATED_TO, RELATED_TO, RESOURCE_TYPE, REVERSED_RELATIONSHIPS)
+from core.fhir import outcomes
+from core.fhir.ops.concepts import fhir_code, stored_codes, text
+from core.fhir.resolve import UnresolvedError, namespace_of, resolve, viewable_rows
+from core.fhir.search import within_search_timeout
+from core.mappings.models import Mapping
+from core.sources.models import Source
+
+MATCH_LIMIT = 1000 # the most matches an answer lists
+UNSUPPORTED_PARAMETERS = ('conceptMap', 'dependency')
+# An OCL path of a source: its own (sources), or as FHIR names it (CodeSystem; ConceptMap, how OCL shows a source with
+# no canonical URL). A collections or ValueSet path names a value set, never a source's alias.
+OCL_PATH = re.compile(r'/(orgs|users)/([^/]+)/(?:sources|CodeSystem|ConceptMap)/([^/]+)/?')
+IMPLICIT_VALUE_SET = '?fhir_vs' # {code system}?fhir_vs: every code of the code system
+
+Coding = namedtuple('Coding', 'system code version')
+Request = namedtuple('Request', 'coding url concept_map_version scope scope_name targetsystem reverse')
+
+
+def map_type_key(map_type):
+ """A map type as MAP_TYPE_RELATIONSHIPS spells it: upper case, a run of spaces, hyphens and underscores a hyphen."""
+ return re.sub(r'[\s_-]+', '-', (map_type or '').strip().upper()).strip('-')
+
+
+def relationship(map_type, reverse=False):
+ """
+ The relationship an OCL map type is (design D27): MAP_TYPE_RELATIONSHIPS', else related-to. reverse reads it from
+ the mapping's target to its source, as a reverse translation answers it, relative to the code named (its target).
+ """
+ found = MAP_TYPE_RELATIONSHIPS.get(map_type_key(map_type), RELATED_TO)
+ return REVERSED_RELATIONSHIPS.get(found, found) if reverse else found
+
+
+def side_system(canonical, url, uri=None):
+ """
+ How a side of a mapping is shown (as a ConceptMap group's source or target, or a translation's system): `canonical`,
+ its source's canonical URL when the caller is shown one that has it; else `url`, the URL the mapping stores, or
+ `uri`, its source's OCL URL when it stores none (pass it only for a source the caller is shown); an OCL URL as a
+ ConceptMap path.
+ """
+ if canonical:
+ return canonical
+ url = url or uri
+ try:
+ return IdentifierSerializer.convert_ocl_uri_to_fhir_url(url, RESOURCE_TYPE)
+ except ValidationError: # an OCL URL too short to name a repository
+ return url
+
+
+def repository_paths(system, kinds=('sources',)):
+ """
+ The OCL URL prefixes of the repository an OCL path names (/orgs/{org}/{kind}/{id}/ for each kind), or None for a
+ canonical URL.
+ """
+ path = OCL_PATH.fullmatch(system)
+ return [f'/{path[1]}/{path[2]}/{kind}/{path[3]}/' for kind in kinds] if path else None
+
+
+def named_sources(systems, namespace, user):
+ """
+ For each system, the ids of the Source rows the base shows the caller that it names, in one query: those with it as
+ their canonical URL; for an OCL path, the repository's rows, whether it has a canonical URL or not (the path is an
+ alias: side_system still shows one that has as its canonical URL). A literal list, so the planner reads the mappings
+ by their own indexes.
+ """
+ paths = {system: (repository_paths(system) or [None])[0] for system in systems}
+ rows = viewable_rows(Source, namespace, user).filter(reduce(or_, (
+ Q(uri__startswith=path) if path else Q(canonical_url=system) for system, path in paths.items())))
+ named = {system: [] for system in systems}
+ for source_id, canonical, uri in rows.values_list('id', 'canonical_url', 'uri'):
+ for system, path in paths.items():
+ if uri.startswith(path) if path else canonical == system:
+ named[system].append(source_id)
+ return named
+
+
+def shown_as(side, system, source_ids, namespace, user):
+ """
+ Q for mappings whose `side` ('from' or 'to') `system` names: their source is one of `source_ids` (named_sources');
+ or it isn't one the caller is shown by a canonical URL, and the URL they store names the system, as side_system
+ then shows it. The subquery runs only on the mappings whose URL does.
+ """
+ source, url = f'{side}_source', f'{side}_source_url'
+ unnamed = ~Exists(viewable_rows(Source, namespace, user).filter(
+ id=OuterRef(f'{source}_id'), canonical_url__gt=''))
+ paths = repository_paths(system, ('sources', 'collections', 'ConceptMap'))
+ stored = reduce(or_, (Q(**{f'{url}__startswith': path}) for path in paths)) if paths else Q(**{url: system})
+ return Q(**{f'{source}_id__in': source_ids}) | (stored & unnamed)
+
+
+def scope_system(scope):
+ """The code system a scope names, if it is one: the scope itself, or the code system of an implicit value set."""
+ return scope.removesuffix(IMPLICIT_VALUE_SET) if scope else scope
+
+
+def holds(value_set, code, sources):
+ """
+ Whether a value set's default expansion holds the code as an active concept of one of the `sources`: from the
+ code's concepts, each looked up in the expansion, rather than from the expansion's members.
+ """
+ return bool(value_set.expansion_uri and sources) and Expansion.concepts.through.objects.filter(
+ expansion__uri=value_set.expansion_uri, concept_id__in=Concept.objects.filter(
+ mnemonic__in=stored_codes(code), parent_id__in=sources, is_active=True).values('id')).exists()
+
+
+def too_costly():
+ return outcomes.TooCostlyError(
+ 'The translation would take too long to answer. Narrow it, for example with url, version or targetsystem.')
+
+
+def of_version(side, version):
+ """Q for mappings whose `side` is that version of its system, or names no version; None names no constraint."""
+ if not version:
+ return Q()
+ field = f'{side}_source_version'
+ return Q(**{field: version}) | Q(**{f'{field}__isnull': True}) | Q(**{field: ''})
+
+
+def with_side(queryset, side, namespace, user):
+ """
+ Each mapping annotated with whether the base shows the caller its `side`'s source ({side}_shown), and the canonical
+ URL that side is then shown by ({side}_canonical, None for none): subqueries run only on the mappings listed.
+ """
+ queryset = queryset.annotate(**{f'{side}_shown': Exists(viewable_rows(Source, namespace, user).filter(
+ id=OuterRef(f'{side}_source_id')))})
+ return queryset.annotate(**{f'{side}_canonical': Case(When(
+ Q(**{f'{side}_shown': True, f'{side}_source__canonical_url__gt': ''}),
+ then=F(f'{side}_source__canonical_url')), default=None)})
+
+
+def required_text(parameters, name):
+ """A text parameter the code is given with (code, system); absent or empty, a 400 naming it."""
+ value = text(parameters, name)
+ if value is None:
+ raise outcomes.InvalidError(f"$translate needs '{name}' with the code it translates.", expression=name)
+ return value
+
+
+def boolean(parameters, name):
+ value = parameters.get(name)
+ if value in (None, ''):
+ return False
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str) and value.lower() in ('true', 'false'):
+ return value.lower() == 'true'
+ raise outcomes.InvalidError(f"'{name}' must be true or false.", expression=name)
+
+
+def coding_of(value, name):
+ """A Coding parameter (in a GET, a system|code token), as a Coding; one without a system and a code is a 400."""
+ if isinstance(value, str):
+ system, _, code = value.partition('|')
+ value = {'system': system, 'code': code}
+ if not isinstance(value, dict) or not all(isinstance(value.get(key), str) and value[key]
+ for key in ('system', 'code')):
+ raise outcomes.InvalidError(f"'{name}' must be a Coding with a system and a code.", expression=name)
+ return Coding(value['system'], value['code'], value.get('version'))
+
+
+def read_request(parameters):
+ """The request in FhirParameters: the code (code and system, coding, or a codeableConcept's first coding), etc."""
+ for name in UNSUPPORTED_PARAMETERS:
+ if name in parameters:
+ raise outcomes.NotSupportedError(f"$translate doesn't support '{name}'.", expression=name)
+ if 'coding' in parameters:
+ coding = coding_of(parameters.get('coding'), 'coding')
+ elif 'codeableConcept' in parameters:
+ concept = parameters.get('codeableConcept')
+ codings = (concept.get('coding') if isinstance(concept, dict) else None) or []
+ if not isinstance(codings, list) or not codings:
+ raise outcomes.InvalidError("'codeableConcept' must hold a coding.", expression='codeableConcept')
+ coding = coding_of(codings[0], 'codeableConcept')
+ elif 'code' in parameters:
+ code = required_text(parameters, 'code')
+ system, _, piped = required_text(parameters, 'system').partition('|')
+ coding = Coding(system, code, text(parameters, 'version') or piped or None)
+ else:
+ raise outcomes.InvalidError('$translate needs the code it translates: code and system, coding, or '
+ 'codeableConcept.', expression='code')
+ reverse = boolean(parameters, 'reverse')
+ scope_name = 'target' if reverse else 'source' # R4: reverse reverses the meaning of source and target
+ return Request(coding, text(parameters, 'url'), text(parameters, 'conceptMapVersion'), text(parameters, scope_name),
+ scope_name, text(parameters, 'targetsystem'), reverse)
+
+
+class Translation:
+ """One $translate request at the base `kwargs` names, for `user`."""
+
+ def __init__(self, parameters, kwargs, user):
+ self.request = read_request(parameters)
+ self.kwargs, self.user, self.namespace = kwargs, user, namespace_of(kwargs)
+
+ def answer(self):
+ """
+ {'result', 'message', 'matches'}, each match {'relationship', 'concept': {system, code}, 'source'}. In
+ FHIRsmith's order: the ConceptMap url names, then the code's scope, then the ConceptMap of the code's system.
+ """
+ request, coding = self.request, self.request.coding
+ concept_map, named = self.named_concept_map() if request.url else (None, None)
+ if request.scope and not self.in_scope():
+ return self.no_match(f"'{coding.system}#{coding.code}' is not in the value set '{request.scope}', so no "
+ "ConceptMap can translate it")
+ if not request.url:
+ concept_map, named = self.code_system_map()
+ if concept_map is None:
+ return self.no_match(self.no_concept_map())
+ matches, more = self.matches(concept_map, named)
+ if not matches:
+ into = f" {'from' if request.reverse else 'to'} '{request.targetsystem}'" if request.targetsystem else ''
+ return self.no_match(f"ConceptMap '{named}' has no mapping {'to' if request.reverse else 'from'} "
+ f"'{coding.system}#{coding.code}'{into}")
+ message = f'Only the first {MATCH_LIMIT} matches are listed.' if more else None
+ return {'result': any(match['relationship'] != NOT_RELATED_TO for match in matches), 'message': message,
+ 'matches': matches}
+
+ @staticmethod
+ def no_match(message):
+ return {'result': False, 'message': message, 'matches': []}
+
+ def no_concept_map(self):
+ names = [self.request.coding.system, self.request.targetsystem or '(unspecified)']
+ source, target = names[::-1] if self.request.reverse else names
+ return f"No ConceptMap is available to translate from '{source}' to '{target}'"
+
+ @cached_property
+ def named_sources(self):
+ """named_sources of the code's system, targetsystem and the code system its scope names, looked up once."""
+ request, system = self.request, self.request.coding.system
+ # in this order, whatever their URLs, so the statement's shape is too
+ systems = dict.fromkeys([system, request.targetsystem or system, scope_system(request.scope) or system])
+ return named_sources(systems, self.namespace, self.user)
+
+ def resolve(self, resource_type, url, version, expression):
+ return resolve(resource_type, url, version, self.kwargs, self.user, expression)
+
+ def named_concept_map(self):
+ """(the ConceptMap version url and conceptMapVersion name, how a match's source names it); else a 404."""
+ instance = self.resolve('ConceptMap', self.request.url, self.request.concept_map_version, 'url')
+ return instance, self.map_name(instance, self.request.url)
+
+ def code_system_map(self):
+ """
+ Without url: (the version of the code system the codes come from, how a match's source names it), whose own
+ mappings are its map: the release conceptMapVersion names, else (forward) the code's version, else the newest
+ the caller is shown; (None, None) when there is none.
+ """
+ request = self.request
+ if request.reverse:
+ url, version = request.targetsystem, request.concept_map_version
+ else: # conceptMapVersion names the map's release, else the code's version
+ url, version = request.coding.system, request.concept_map_version or request.coding.version
+ try: # resolve() raises before any query for no url (reverse without targetsystem)
+ instance = self.resolve('CodeSystem', url, version, 'system')
+ except UnresolvedError:
+ return None, None
+ return instance, self.map_name(instance, url)
+
+ @staticmethod
+ def map_name(instance, url):
+ """A ConceptMap version as a match's source names it: canonical URL (else the URL it was named by)|version."""
+ return f"{instance.canonical_url or url.partition('|')[0]}|{instance.version}"
+
+ def in_scope(self):
+ """
+ Whether the code is in its scope (`source`; in reverse, `target`): its own code system, or that system's
+ implicit value set, named any way named_sources reads (its canonical URL or a source's OCL path, of a repository
+ the caller is shown); a value set (a ValueSet or collections path is one) it is a member of (its default
+ expansion), as a concept of a source the caller is shown as the code's system, as the matches are; not another
+ code system. Neither one the caller is shown, nor ?fhir_vs of no system, is a 404 naming the parameter, as
+ FHIRsmith answers a scope it can't find.
+ """
+ scope, name, coding = self.request.scope, self.request.scope_name, self.request.coding
+ system = scope_system(scope)
+ if not system: # ?fhir_vs of no system
+ raise self.scope_not_found(scope, name)
+ if system == coding.system:
+ return True
+ named = self.named_sources
+ if set(named[coding.system]) & set(named[system]): # one repository the caller is shown, whatever names it
+ return True
+ if system == scope: # not an implicit value set: a ValueSet, if one has that URL
+ try:
+ value_set = self.resolve('ValueSet', scope, None, name)
+ except UnresolvedError:
+ pass
+ else:
+ return holds(value_set, coding.code, named[coding.system])
+ try:
+ self.resolve('CodeSystem', system, None, name)
+ except UnresolvedError as ex:
+ raise self.scope_not_found(scope, name) from ex
+ return False
+
+ @staticmethod
+ def scope_not_found(scope, name):
+ return outcomes.NotFoundError(
+ f"The value set '{scope}' could not be found, so the scope of the translation cannot be determined",
+ expression=name, tx_issue_type='not-found')
+
+ def match_ids(self, concept_map, near, far):
+ """
+ The ids of the release's live mappings of the code whose sides are shown as the systems, sorted: in no order and
+ with no limit, so the planner reads the code's mappings by the code (and the source lists) and checks each
+ against the release; ordered and limited, it would walk the release in id order.
+ """
+ request = self.request
+ sides = {near: request.coding.system, **({far: request.targetsystem} if request.targetsystem else {})}
+ mappings = concept_map.get_active_mappings().filter( # neither retired nor deleted
+ of_version(near, request.coding.version), **{f'{near}_concept_code__in': stored_codes(request.coding.code)})
+ for side, system in sides.items():
+ mappings = mappings.filter(shown_as(side, system, self.named_sources[system], self.namespace, self.user))
+ return sorted(mappings.values_list('id', flat=True))
+
+ def matches(self, concept_map, named):
+ """
+ (the matches, whether there were more than MATCH_LIMIT), in mapping order, each match once: the sources the
+ systems name, the mappings' ids, then the first MATCH_LIMIT + 1 of those mappings.
+ """
+ near, far = ('to', 'from') if self.request.reverse else ('from', 'to')
+ ids = self.match_ids(concept_map, near, far)
+ rows = with_side(Mapping.objects.filter(id__in=ids[:MATCH_LIMIT + 1]), far, self.namespace, self.user).order_by(
+ 'id').values('map_type', f'{far}_concept_code', f'{far}_source_url', f'{far}_source__uri', f'{far}_shown',
+ f'{far}_canonical')
+ matches = {}
+ for row in rows:
+ system = side_system(row[f'{far}_canonical'], row[f'{far}_source_url'],
+ row[f'{far}_source__uri'] if row[f'{far}_shown'] else None)
+ match = (system, fhir_code(row[f'{far}_concept_code']), relationship(row['map_type'], self.request.reverse))
+ matches.setdefault(match, {
+ 'relationship': match[2], 'concept': {'system': match[0], 'code': match[1]}, 'source': named})
+ return list(matches.values())[:MATCH_LIMIT], len(ids) > MATCH_LIMIT
+
+
+def translate(parameters, kwargs, user, _instance_id=None):
+ """
+ A $translate's answer (Translation.answer) for its FhirParameters, at the base `kwargs` names, for `user`, its
+ statements bounded as a search's are (a cancelled one is too costly). It has no instance level.
+ """
+ return within_search_timeout(lambda: Translation(parameters, kwargs, user).answer(), too_costly=too_costly)
diff --git a/core/fhir/ops/validate_code.py b/core/fhir/ops/validate_code.py
new file mode 100644
index 000000000..62314dae9
--- /dev/null
+++ b/core/fhir/ops/validate_code.py
@@ -0,0 +1,298 @@
+"""
+CodeSystem and ValueSet $validate-code (SVCM ITI-99, FHIR R4's $validate-code), version-neutral: whether a code is
+valid, with what OCL knows about it. core.fhir.adapters.r4 writes the answer as R4 Parameters.
+
+- Resolution and access are core.fhir.resolve's (a released version, the newest the caller is shown unless one is
+ named; public only at /fhir/; at an owner base, private ones to those who may view them); CodeSystem/{id} and
+ ValueSet/{id} name theirs as a read does. A code system, value set or content the caller isn't shown answers exactly
+ as one that doesn't exist.
+- CodeSystem: the code in the version `url` (and `version`) names: result, and the code, system, version used and
+ display; `display` must be one of the concept's names (ignoring case); a retired concept is valid but flagged
+ inactive, with a warning. The code system is the instance, else url (and version), else a coding's system; a
+ coding of another system is result false (invalid-data). A code system that doesn't resolve answers result false
+ with a not-found issue and x-caused-by-unknown-system, as FHIRsmith does, with or without a code. Then a code is
+ required (400), and a url.
+- ValueSet: membership is the resolved version's default expansion: the code, from a concept whose code system is
+ `system` (and `systemVersion`), in a code system version the caller is shown, released or not. The version
+ answered is a live release the caller is shown that holds the member's row: the expansion's explicit pin, then
+ its evaluated pin, then the newest. A versioned-object row carries the concept's newest content, so it uses the
+ newest holding release of another, not deleted, version, whatever the expansion pins; none without one (never HEAD
+ or an unreleased version). Not a member: result false (not-in-vs), with what the code system itself says of the code
+ (unknown system, unknown code, or its display). A value set that doesn't resolve is a 422 not-found OperationOutcome.
+ Without url (and not at the instance level), the code is checked in its code system alone, as OCL has always answered.
+- Issues carry FHIRsmith's message ids (operationoutcome-message-id) and expressions, so clients and the parity
+ suite compare them by kind; `message` joins their texts.
+- Cost: a fixed number of queries whatever the size of the code system or value set.
+"""
+from django.db.models import Case, Exists, IntegerField, OuterRef, Q, Value, When
+from rest_framework import status
+
+from core.collections.models import Expansion
+from core.common.constants import HEAD
+from core.concepts.models import Concept
+from core.fhir import outcomes
+from core.fhir.ops.concepts import (
+ coded_input, path, require, text, find_concept, display_of, has_name, fhir_code, code_q, instance_version,
+ is_system_of, mismatch, system_of)
+from core.fhir.ops.lookup import code_system_version
+from core.fhir.resolve import UnresolvedError, namespace_of, resolve, to_ocl_path, viewable_rows
+from core.sources.models import Source
+
+UNKNOWN_CODE_SYSTEM = 'x-caused-by-unknown-system' # how CodeSystem $validate-code names an unknown system
+UNKNOWN_VALUE_SET_SYSTEM = 'x-unknown-system' # and ValueSet $validate-code
+
+
+def issue(coding, part, kind, message_id, message):
+ """An issue about one part of the code (code, system or display), of a kind in KINDS."""
+ severity, code, tx_issue_type = KINDS[kind]
+ return outcomes.issue(code, message, severity=severity, expression=path(coding, part),
+ tx_issue_type=tx_issue_type, message_id=message_id)
+
+
+# kind: (severity, issue type, tx-issue-type), as FHIRsmith reports each
+KINDS = {
+ 'unknown-system': ('error', 'not-found', 'not-found'),
+ 'unknown-code': ('error', 'code-invalid', 'invalid-code'),
+ 'wrong-display': ('error', 'invalid', 'invalid-display'),
+ 'inactive': ('warning', 'business-rule', 'code-comment'),
+ 'not-in-value-set': ('error', 'code-invalid', 'not-in-vs'),
+ 'other-system': ('error', 'invalid', 'invalid-data'),
+}
+
+
+def answer(coding, issues=(), **values):
+ """
+ The neutral answer: result (no error issue), the code and system asked about, what was found (version, display,
+ inactive, the unknown system), the issues and the message their texts make.
+ """
+ issues = list(issues)
+ return {
+ 'result': not any(item['severity'] in ('error', 'fatal') for item in issues),
+ 'message': '; '.join(item['details']['text'] for item in issues) or None,
+ 'code': fhir_code(coding.code), 'system': coding.system, **values, 'issues': issues,
+ }
+
+
+def unknown_system(coding, parameter, *more, **extra):
+ """
+ The answer for a code system that doesn't resolve, or that the caller isn't shown: the same for both, as it depends
+ only on what was asked (the system, and the version named or url|version). `more` are issues after its own.
+ """
+ system, _, piped = coding.system.partition('|')
+ version = coding.version or piped or None
+ if version:
+ message_id = 'UNKNOWN_CODESYSTEM_VERSION'
+ message = f"A definition for CodeSystem '{system}' version '{version}' could not be found, " \
+ "so the code cannot be validated"
+ else:
+ message_id = 'UNKNOWN_CODESYSTEM'
+ message = f"A definition for CodeSystem '{system}' could not be found, so the code cannot be validated"
+ named = f'{system}|{version}' if version else system
+ return answer(coding, [issue(coding, 'system', 'unknown-system', message_id, message), *more],
+ unknown=(parameter, named), **extra)
+
+
+def unknown_code(coding, system, version):
+ return issue(coding, 'code', 'unknown-code', 'Unknown_Code_in_Version',
+ f"Unknown code '{fhir_code(coding.code)}' in the CodeSystem '{system}' version '{version}'")
+
+
+def found(coding, concept, version, language):
+ """
+ The answer for a code its code system (or value set) holds: its version, display and inactive flag, with the issues
+ of a display that isn't one of its names and of a retired concept.
+ """
+ display = display_of(concept, language)
+ issues = []
+ if coding.display and not has_name(concept, coding.display):
+ issues.append(issue(
+ coding, 'display', 'wrong-display', 'Display_Name_for__should_be_one_of__instead_of',
+ f"Wrong Display Name '{coding.display}' for {coding.system}#{fhir_code(coding.code)}: it should be one of "
+ f"the concept's names, such as '{display}'"))
+ if concept.retired:
+ issues.append(issue(coding, 'code', 'inactive', 'INACTIVE_CONCEPT_FOUND',
+ f"The concept '{fhir_code(coding.code)}' has a status of inactive and its use should be "
+ "reviewed"))
+ return answer(coding, issues, version=version, display=display, inactive=concept.retired or None)
+
+
+def in_code_system(coding, version, language):
+ """The answer for a code in a code system version it resolved to."""
+ system = system_of(version, coding.system)
+ coding = coding._replace(system=system)
+ concept = find_concept(version, coding.code)
+ if not concept:
+ return answer(coding, [unknown_code(coding, system, version.version)], version=version.version)
+ return found(coding, concept, version.version, language)
+
+
+def validate_code_system(parameters, kwargs, user, instance_id=None):
+ """
+ CodeSystem $validate-code's answer for its FhirParameters at the base `kwargs` names (CodeSystem/{instance_id} at
+ the instance level), for `user`. A url that doesn't resolve answers before the code is required, as it always has.
+ """
+ coding = coded_input(parameters, 'url', 'version')
+ # the code system validated against: the instance, else url and version, else the coding's
+ target = coding._replace(
+ system=text(parameters, 'url') or coding.system, version=text(parameters, 'version') or coding.version)
+ version = None
+ if instance_id or target.system:
+ try:
+ version = code_system_version(target, kwargs, user, instance_id)
+ except UnresolvedError:
+ return unknown_system(target, UNKNOWN_CODE_SYSTEM, version=target.version)
+ require(coding, 'code', 'A code to validate is required.')
+ if version is None:
+ raise outcomes.InvalidError('url is required with a code: the code system to validate it in.',
+ expression=coding.parameter or 'url')
+ conflict = mismatch(parameters, coding, version, 'url', instance_id)
+ if conflict:
+ severity, code, tx_issue_type = KINDS['other-system']
+ return answer(coding, [outcomes.issue(
+ code, f'{conflict[1]}: the code is not validated', severity=severity, expression=conflict[0],
+ tx_issue_type=tx_issue_type)])
+ return in_code_system(coding, version, text(parameters, 'displayLanguage'))
+
+
+def system_q(field, system):
+ """Q for concepts (through `field`) whose repository is the system: by canonical URL, or by an OCL path."""
+ reference = to_ocl_path(system)
+ if reference.startswith('/'):
+ return Q(**{f'{field}__uri': reference if reference.endswith('/') else f'{reference}/'})
+ return Q(**{f'{field}__canonical_url': system})
+
+
+def named_by(system, shown):
+ """
+ (Q for concepts, Q for their concept-source links) the system names, as a read names a code system version: by the
+ version's own canonical URL, else its repository's (HEAD's); an OCL path names every version of its repository.
+ So a concept is the system's when its repository is, or has a version the caller is shown (`shown`) with that
+ canonical URL of its own; and only the versions the system names are answered (as $expand reports a member).
+ The repositories are found among sources alone, so the concepts are still read by their code first.
+ """
+ if to_ocl_path(system).startswith('/'):
+ return system_q('parent', system), Q()
+ renamed = shown.filter(canonical_url=system, mnemonic=OuterRef('parent__mnemonic')).filter(
+ Q(organization_id=OuterRef('parent__organization_id')) | Q(user_id=OuterRef('parent__user_id')))
+ inherited = Q(source__canonical_url__isnull=True) | Q(source__canonical_url='')
+ return system_q('parent', system) | Exists(renamed), Q(source__canonical_url=system) | (
+ inherited & Q(concept__parent__canonical_url=system))
+
+
+def member(value_set, coding, namespace, user):
+ """
+ (the concept, the version of its code system) of the code in the value set version's default expansion, or None:
+ a concept with the code (not deleted), of the system (in systemVersion, if named), in a code system version the
+ caller is shown (not deleted), released or not, that the system names (named_by): the expansion defines the value
+ set. The version answered is a live release the caller is shown that holds the member's row: the expansion's
+ explicit pin, then its evaluated pin, then the newest. A versioned-object row, held only by HEAD, carries the
+ concept's newest content, so it uses the newest holding release of another, not deleted, version, ignoring pins.
+ None without one, never HEAD or an unreleased version. The concept answered is the member itself, as $expand
+ shows it.
+ The code's concepts are read first, by their code and only in code systems the caller is shown (so what hidden ones
+ hold costs nothing), then looked for in the expansion by its (expansion, concept) index, so the cost doesn't grow
+ with the size of the value set.
+ """
+ if not value_set.expansion_uri:
+ return None
+ concepts_named, versions_named = named_by(coding.system, viewable_rows(Source, namespace, user))
+ concepts = dict(Concept.objects.filter(
+ code_q('mnemonic', coding.code), concepts_named,
+ Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('parent_id'))), is_active=True).values_list(
+ 'id', 'versioned_object_id'))
+ concept_ids = list(Expansion.concepts.through.objects.filter(
+ expansion__uri=value_set.expansion_uri, concept_id__in=concepts).values_list('concept_id', flat=True))
+ if not concept_ids:
+ return None
+ release = Q(source__released=True, source__retired=False) & ~Q(source__version=HEAD)
+ links = Concept.sources.through.objects.filter(
+ Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('source_id'))), source__is_active=True)
+ # another version of a member's concept counts only while no release the caller is shown holds the member itself
+ rows = links.filter(
+ Q(concept_id__in=concept_ids) | ~Exists(links.filter(
+ release, concept_id__in=concept_ids,
+ concept__versioned_object_id=OuterRef('concept__versioned_object_id'))),
+ versions_named,
+ concept__versioned_object_id__in=sorted({concepts[concept_id] for concept_id in concept_ids}),
+ concept__is_active=True)
+ if coding.version:
+ rows = rows.filter(source__version=coding.version)
+ def pinned(versions):
+ """Whether the link holds the member's row itself and the expansion pins its source."""
+ return Case(When(Q(concept_id__in=concept_ids) & Exists(versions.through.objects.filter(
+ expansion__uri=value_set.expansion_uri, source_id=OuterRef('source_id'))), then=Value(1)),
+ default=Value(0), output_field=IntegerField())
+ row = rows.annotate(
+ release=Case(When(release, then=Value(1)), default=Value(0), output_field=IntegerField()),
+ held=Case(When(concept_id__in=concept_ids, then=Value(1)), default=Value(0), output_field=IntegerField()),
+ explicit=pinned(Expansion.explicit_source_versions), evaluated=pinned(Expansion.evaluated_source_versions),
+ ).order_by('-release', '-held', '-explicit', '-evaluated', '-source__created_at', 'concept_id').values_list(
+ 'concept_id', 'concept__versioned_object_id', 'source__version', 'release').first()
+ if not row:
+ return None
+ member_id = row[0] if row[0] in concept_ids else min(
+ concept_id for concept_id in concept_ids if concepts[concept_id] == row[1])
+ concept = Concept.objects.filter(id=member_id).select_related('parent').prefetch_related('names').first()
+ return concept, row[2] if row[3] else None
+
+
+def value_set_version(parameters, kwargs, user, instance_id):
+ """
+ The value set version the request names; a url that doesn't resolve is a 422 not-found. At the instance level the id
+ names it, and a url or valueSetVersion naming another one is a 400.
+ """
+ version, url = text(parameters, 'valueSetVersion'), text(parameters, 'url')
+ if instance_id:
+ value_set = instance_version('ValueSet', instance_id, version, kwargs, user)
+ if url and not is_system_of(url, value_set):
+ raise outcomes.InvalidError(f"'{url}' is not the value set {instance_id} this $validate-code is on.",
+ expression='url')
+ for named, expression in ((version, 'valueSetVersion'), ((url or '').partition('|')[2], 'url')):
+ if named and named != value_set.version:
+ raise outcomes.InvalidError(
+ f"Version '{named}' is not the version '{value_set.version}' of the value set {instance_id}.",
+ expression=expression)
+ return value_set
+ try:
+ return resolve('ValueSet', url, version, kwargs, user)
+ except UnresolvedError as ex:
+ raise outcomes.NotFoundError(
+ f"A definition for the value Set '{url}' could not be found", tx_issue_type='not-found',
+ message_id='Unable_to_resolve_value_Set_', status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) from ex
+
+
+def validate_value_set(parameters, kwargs, user, instance_id=None):
+ """
+ ValueSet $validate-code's answer for its FhirParameters at the base `kwargs` names (ValueSet/{instance_id} at the
+ instance level), for `user`. A code and its system are required first.
+ """
+ coding = coded_input(parameters, 'system', 'systemVersion')
+ require(coding, 'code', 'A code to validate is required.')
+ require(coding, 'system', 'system is required with a code: the code system the code is from.')
+ language = text(parameters, 'displayLanguage')
+ if not instance_id and not text(parameters, 'url'):
+ try:
+ return in_code_system(coding, resolve('CodeSystem', coding.system, coding.version, kwargs, user), language)
+ except UnresolvedError:
+ return unknown_system(coding, UNKNOWN_CODE_SYSTEM, version=coding.version)
+
+ value_set = value_set_version(parameters, kwargs, user, instance_id)
+ hit = member(value_set, coding, namespace_of(kwargs), user)
+ return found(coding, *hit, language) if hit else not_a_member(coding, value_set, kwargs, user, language)
+
+
+def not_a_member(coding, value_set, kwargs, user, language):
+ """A code outside the value set: not-in-vs, with what its code system says of it."""
+ name = f"{system_of(value_set, value_set.uri)}|{value_set.version}"
+ outside = issue(coding, 'code', 'not-in-value-set', 'None_of_the_provided_codes_are_in_the_value_set_one',
+ f"The provided code '{coding.system}#{fhir_code(coding.code)}' was not found in the value set "
+ f"'{name}'")
+ try:
+ version = resolve('CodeSystem', coding.system, coding.version, kwargs, user)
+ except UnresolvedError:
+ return unknown_system(coding, UNKNOWN_VALUE_SET_SYSTEM, outside)
+ system = system_of(version, coding.system)
+ concept = find_concept(version, coding.code)
+ if not concept:
+ return answer(coding, [unknown_code(coding, system, version.version), outside])
+ return answer(coding, [outside], version=version.version, display=display_of(concept, language))
diff --git a/core/fhir/outcomes.py b/core/fhir/outcomes.py
new file mode 100644
index 000000000..a77484893
--- /dev/null
+++ b/core/fhir/outcomes.py
@@ -0,0 +1,247 @@
+"""
+OperationOutcome for the FHIR API: typed errors, and the exception handler that answers every error a FHIR view
+raises with an OperationOutcome.
+
+Every issue carries a tx-issue-type coding, as FHIRsmith and tx.fhir.org do, so clients and the parity suite can
+compare errors by kind rather than by wording. No FHIR response is a 5xx: an error OCL can't attribute to the
+request is answered with a 422 and the issue code `exception`, and logged.
+"""
+import logging
+from http import HTTPStatus
+
+from django.core.exceptions import BadRequest, SuspiciousOperation, ValidationError as DjangoValidationError, \
+ PermissionDenied as DjangoPermissionDenied
+from django.http import Http404
+from rest_framework import exceptions, status
+from rest_framework.response import Response
+from rest_framework.views import set_rollback
+
+logger = logging.getLogger('oclapi')
+
+TX_ISSUE_TYPE_SYSTEM = 'http://hl7.org/fhir/tools/CodeSystem/tx-issue-type'
+MESSAGE_ID_EXTENSION = 'http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id'
+
+# The tx-issue-type of an issue that names none, by its FHIR issue type. This is FHIRsmith's table
+# (tx/library/operation-outcome.js), plus `throttled`; every other issue type gets `invalid-data`, as there.
+DEFAULT_TX_ISSUE_TYPES = {
+ 'not-found': 'not-found',
+ 'code-invalid': 'invalid-code',
+ 'business-rule': 'business-rule',
+ 'too-costly': 'too-costly',
+ 'not-supported': 'not-supported',
+ 'throttled': 'too-costly',
+}
+FALLBACK_TX_ISSUE_TYPE = 'invalid-data'
+
+# The issue type of an error that has only an HTTP status
+STATUS_ISSUE_CODES = {
+ 400: 'invalid',
+ 401: 'login',
+ 403: 'forbidden',
+ 404: 'not-found',
+ 405: 'not-supported',
+ 406: 'not-supported',
+ 410: 'deleted',
+ 415: 'not-supported',
+ 422: 'processing',
+ 429: 'throttled',
+}
+NON_FIELD_ERROR_KEYS = ('non_field_errors', '__all__')
+UNEXPECTED_ERROR_STATUS = status.HTTP_422_UNPROCESSABLE_ENTITY
+UNEXPECTED_ERROR_MESSAGE = 'An unexpected error occurred while processing the request.'
+
+
+def tx_issue_type_for(issue_code):
+ """The tx-issue-type for an issue that doesn't name one."""
+ return DEFAULT_TX_ISSUE_TYPES.get(issue_code, FALLBACK_TX_ISSUE_TYPE)
+
+
+def issue( # pylint: disable=too-many-arguments
+ code, text, severity='error', expression=None, tx_issue_type=None, message_id=None, diagnostics=None):
+ """One OperationOutcome issue. `expression` is a FHIRPath (the parameter or element at fault) or a list of them."""
+ result = {
+ 'severity': severity,
+ 'code': code,
+ 'details': {
+ 'coding': [{'system': TX_ISSUE_TYPE_SYSTEM, 'code': tx_issue_type or tx_issue_type_for(code)}],
+ 'text': text
+ }
+ }
+ if expression:
+ result['expression'] = [expression] if isinstance(expression, str) else list(expression)
+ if message_id:
+ result['extension'] = [{'url': MESSAGE_ID_EXTENSION, 'valueString': message_id}]
+ if diagnostics:
+ result['diagnostics'] = diagnostics
+ return result
+
+
+def operation_outcome(*issues):
+ return {'resourceType': 'OperationOutcome', 'issue': list(issues)}
+
+
+def is_outcome(data):
+ return isinstance(data, dict) and data.get('resourceType') == 'OperationOutcome'
+
+
+def response_status(status_code):
+ """The status a FHIR error goes out with: a 5xx becomes a 422."""
+ return UNEXPECTED_ERROR_STATUS if status_code >= 500 else status_code
+
+
+def status_issue_code(status_code):
+ if status_code >= 500:
+ return 'exception'
+ return STATUS_ISSUE_CODES.get(status_code, 'invalid')
+
+
+def status_text(status_code):
+ try:
+ return HTTPStatus(status_code).phrase
+ except ValueError:
+ return UNEXPECTED_ERROR_MESSAGE
+
+
+def to_plain(value):
+ """DRF error details (ErrorDetail strings, nested) as plain JSON values."""
+ if isinstance(value, dict):
+ return {str(key): to_plain(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [to_plain(item) for item in value]
+ return str(value) if isinstance(value, str) else value
+
+
+def detail_issues(detail, code='invalid', field=None):
+ """Issues for a serializer-style error detail: one per message, naming the field it belongs to."""
+ if isinstance(detail, dict):
+ issues = []
+ for key, value in detail.items():
+ issues += detail_issues(value, code, field or (None if key in NON_FIELD_ERROR_KEYS else str(key)))
+ return issues
+ if isinstance(detail, (list, tuple)):
+ return [item for value in detail for item in detail_issues(value, code, field)]
+ return [issue(code, str(detail), expression=field)]
+
+
+def with_tx_issue_types(outcome):
+ """An OperationOutcome built elsewhere, with a tx-issue-type coding and a code on every issue."""
+ outcome = to_plain(outcome)
+ for item in outcome.get('issue') or []:
+ item.setdefault('severity', 'error')
+ item.setdefault('code', 'processing')
+ details = item.get('details')
+ if not isinstance(details, dict):
+ details = item['details'] = {'text': str(details)} if details else {}
+ codings = details.setdefault('coding', [])
+ if not any(coding.get('system') == TX_ISSUE_TYPE_SYSTEM for coding in codings):
+ codings.append({'system': TX_ISSUE_TYPE_SYSTEM, 'code': tx_issue_type_for(item['code'])})
+ return outcome
+
+
+def normalize_outcomes(data):
+ """Normalize every returned OperationOutcome, including resources nested in Bundle entries."""
+ if isinstance(data, dict):
+ data = {key: normalize_outcomes(value) for key, value in data.items()}
+ return with_tx_issue_types(data) if is_outcome(data) else data
+ if isinstance(data, (list, tuple)):
+ return [normalize_outcomes(value) for value in data]
+ return data
+
+
+def outcome_for_status(status_code, data=None):
+ """An error response that isn't an OperationOutcome (an empty 404, a {'detail': ...} body) as one."""
+ code = status_issue_code(status_code)
+ if isinstance(data, dict) and 'detail' in data:
+ return operation_outcome(issue(code, str(data['detail'])))
+ if data and isinstance(data, (dict, list)):
+ return operation_outcome(*detail_issues(data, code))
+ return operation_outcome(issue(code, status_text(status_code)))
+
+
+class FhirError(exceptions.APIException):
+ """An error a FHIR view raises on purpose. Each subclass names its issue type and HTTP status."""
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+ issue_code = 'processing'
+ default_detail = 'The request could not be processed.'
+
+ def __init__( # pylint: disable=too-many-arguments
+ self, message=None, expression=None, tx_issue_type=None, message_id=None, status_code=None):
+ super().__init__(message or self.default_detail)
+ self.expression = expression
+ self.tx_issue_type = tx_issue_type
+ self.message_id = message_id
+ if status_code:
+ self.status_code = status_code
+
+ def outcome(self):
+ return operation_outcome(issue(
+ self.issue_code, str(self.detail), expression=self.expression, tx_issue_type=self.tx_issue_type,
+ message_id=self.message_id))
+
+
+class NotFoundError(FhirError):
+ status_code = status.HTTP_404_NOT_FOUND
+ issue_code = 'not-found'
+ default_detail = 'The resource was not found.'
+
+
+class InvalidError(FhirError):
+ status_code = status.HTTP_400_BAD_REQUEST
+ issue_code = 'invalid'
+ default_detail = 'The request is not valid.'
+
+
+class NotSupportedError(FhirError):
+ status_code = status.HTTP_400_BAD_REQUEST
+ issue_code = 'not-supported'
+ default_detail = 'The request uses something this server does not support.'
+
+
+class TooCostlyError(FhirError):
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+ issue_code = 'too-costly'
+ default_detail = 'The request is too costly to process.'
+
+
+class ProcessingError(FhirError):
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+ issue_code = 'processing'
+
+
+def outcome_for_exception(exc):
+ """(status, OperationOutcome, headers) for an exception raised while answering a FHIR request."""
+ headers = {}
+ if isinstance(exc, FhirError):
+ status_code, outcome = exc.status_code, exc.outcome()
+ elif isinstance(exc, (Http404, exceptions.NotFound)):
+ status_code = 404
+ outcome = operation_outcome(issue('not-found', str(getattr(exc, 'detail', exc)) or status_text(404)))
+ elif isinstance(exc, DjangoPermissionDenied):
+ status_code, outcome = 403, operation_outcome(issue('forbidden', str(exc) or status_text(403)))
+ elif isinstance(exc, (BadRequest, SuspiciousOperation)):
+ status_code, outcome = 400, operation_outcome(issue('invalid', 'The request could not be parsed.'))
+ elif isinstance(exc, DjangoValidationError):
+ detail = exc.message_dict if hasattr(exc, 'error_dict') else exc.messages
+ status_code, outcome = 400, operation_outcome(*detail_issues(detail))
+ elif isinstance(exc, exceptions.ValidationError):
+ status_code = exc.status_code
+ outcome = with_tx_issue_types(exc.detail) if is_outcome(exc.detail) else operation_outcome(
+ *detail_issues(exc.detail))
+ elif isinstance(exc, exceptions.APIException):
+ if getattr(exc, 'auth_header', None):
+ headers['WWW-Authenticate'] = exc.auth_header
+ if getattr(exc, 'wait', None):
+ headers['Retry-After'] = str(int(exc.wait))
+ status_code = exc.status_code
+ outcome = operation_outcome(*detail_issues(exc.detail, status_issue_code(exc.status_code)))
+ else:
+ logger.exception('Unexpected %s answering a FHIR request', type(exc).__name__)
+ status_code, outcome = UNEXPECTED_ERROR_STATUS, operation_outcome(issue('exception', UNEXPECTED_ERROR_MESSAGE))
+ return status_code, outcome, headers
+
+
+def exception_handler(exc, context): # pylint: disable=unused-argument
+ """The exception handler of FHIR views (FhirViewMixin): every error is an OperationOutcome, never a 5xx."""
+ status_code, outcome, headers = outcome_for_exception(exc)
+ set_rollback()
+ return Response(outcome, status=response_status(status_code), headers=headers)
diff --git a/core/fhir/resolve.py b/core/fhir/resolve.py
new file mode 100644
index 000000000..18aae9fe9
--- /dev/null
+++ b/core/fhir/resolve.py
@@ -0,0 +1,527 @@
+"""
+FHIR's canonical resolution: the resolver $resolveReference uses (ConceptContainerModel.resolve_reference_expression,
+with or without the URL registry), with FHIR's rules on top.
+
+- Resource type: CodeSystem and ConceptMap resolve to a Source, ValueSet to a Collection.
+- Namespace: the base URL's. /fhir/ is global; /orgs/{org}/ and /users/{user}/ are that owner's. It comes from the
+ route, never from the request.
+- Version: the one named (the operation's version parameter, else url|version, else the version an OCL path names;
+ a path naming one version with another named names none), HEAD included, as it is stored: a request's value is
+ already decoded, so 2.0+build names 2.0+build. With none named, the newest released
+ version the caller is shown, never HEAD. A repository with nothing released isn't visible, whatever version is
+ named.
+- A version named, with a canonical URL (decision 9): when the repository the resolver picks doesn't serve the caller
+ a release of that name, the release of the lowest-id repository with that canonical URL the base shows the caller
+ that holds one; failing that, the version so named (unreleased, or HEAD) of the lowest-id one it shows, as before;
+ else not found. A repository or version the caller isn't shown never holds it. This applies where the repositories
+ with the canonical URL are the step that picks: without the URL registry, at /fhir/ (when it finds one) and at an
+ owner base (whether or not it finds one, so a hidden repository is answered as a missing one is); with it, at an
+ owner base whose own registry has no entry for the URL and that has a repository with it. A registry entry decides
+ (at /fhir/ only an entry resolves), and where resolution finds no repository it still finds none.
+ At an owner base a repository the base doesn't show the caller, and one whose HEAD it shows but whose releases are
+ all private, are both answered as a missing one is, byte for byte; the statement count can still differ by one,
+ the A.3 residual of the shared resolver's early stop rather than of this rule, which a change in the shared
+ resolver would close. tests_resolve_named_version.HiddenHolderCases measures both, and measures the same
+ differences on the code before this rule.
+- Access, after resolving: at the global base public repositories only, at an owner base what the caller may view.
+ The version and its repository (HEAD) must both be viewable. Without the URL registry, the global base's canonical
+ match considers only the repositories it shows, so a private copy of a canonical never hides a public one. Otherwise
+ (an owner base, a registry entry) a repository the caller can't see is not found, and no other repository is tried;
+ none of its versions is read.
+
+Every failure is the same UnresolvedError, so it never tells a caller that a repository they can't see exists.
+
+served_repositories is the set form of the resolver's choices for code systems, for TerminologyCapabilities: the
+identifier each repository a search shows is served at, and the versions of it a caller is served.
+"""
+from collections import namedtuple
+from urllib.parse import quote_plus
+
+from django.db.models import Case, Exists, OuterRef, Q, Subquery, Value, When
+from django.db.models.functions import Coalesce
+from rest_framework.exceptions import ValidationError
+
+from core.collections.models import Collection
+from core.common.constants import HEAD, ACCESS_TYPE_VIEW, RETIRED_ACCESS_TYPE_EDIT
+from core.common.mixins import SourceContainerMixin
+from core.common.serializers import IdentifierSerializer
+from core.common.utils import decode_string, is_canonical_uri, normalize_public_access, to_parent_uri
+from core.fhir.outcomes import NotFoundError
+from core.sources.models import Source
+from core.toggles.models import Toggle
+from core.url_registry.models import URLRegistry
+
+REPO_TYPES = {'CodeSystem': Source, 'ConceptMap': Source, 'ValueSet': Collection}
+REPO_PATHS = {'CodeSystem': 'sources', 'ConceptMap': 'sources', 'ValueSet': 'collections'}
+PUBLIC_ACCESS = (ACCESS_TYPE_VIEW, RETIRED_ACCESS_TYPE_EDIT) # what public_can_view reads as public
+
+
+class UnresolvedError(NotFoundError):
+ """
+ No repository version of the resource type the caller may see for a canonical URL (and version). `url` is the
+ url without a |version, `version` the one asked for: the version parameter, else the url's.
+ """
+
+ def __init__(self, resource_type, url, version=None, expression=None):
+ self.resource_type, self.url, self.version = resource_type, url, version
+ named = f" version '{version}'" if version else ''
+ super().__init__(
+ f"A definition for {resource_type} '{url}'{named} could not be found", expression=expression,
+ tx_issue_type='not-found')
+
+
+def namespace_of(kwargs):
+ """The namespace of the FHIR base a route's kwargs name: the owner's URI, or None for the global base."""
+ kwargs = kwargs or {}
+ if kwargs.get('org'):
+ return f"/orgs/{kwargs['org']}/"
+ if kwargs.get('user'):
+ return f"/users/{kwargs['user']}/"
+ return None
+
+
+def resolve(resource_type, url, version=None, kwargs=None, user=None, expression=None): # pylint: disable=too-many-arguments,too-many-locals
+ """
+ The repository version a FHIR request names: `url` (a canonical URL, or an OCL path such as
+ /orgs/{org}/CodeSystem/{id}/) and `version`, at the base `kwargs` names, for `user`. A view passes its own
+ self.kwargs and self.request.user. Raises UnresolvedError, whose `expression` is the parameter the url came in.
+ """
+ url = url or ''
+ lookup_url, _, piped_version = url.partition('|')
+ in_path, elsewhere = path_version(lookup_url), version or piped_version or None
+ error = UnresolvedError(resource_type, lookup_url, elsewhere or in_path, expression)
+ namespace = namespace_of(kwargs)
+ # a second pipe, or a path naming one version with another named, names no version OCL holds
+ conflicting = '|' in piped_version or bool(in_path and elsewhere and in_path != elsewhere)
+ if not lookup_url or conflicting or (namespace and not SourceContainerMixin.get_object_from_namespace(namespace)):
+ raise error
+
+ # the version as named (HTTP has decoded the query already), so the shared resolver, which decodes it again,
+ # reads it back as it is stored: 2.0+build stays 2.0+build
+ repo_type = REPO_TYPES[resource_type]
+ instance, entry, picked = shared_resolution(
+ repo_type, lookup_url, as_stored(elsewhere) if elsewhere else None, namespace, user)
+ if instance.id and not (elsewhere or in_path) and not is_shown(instance, namespace, user):
+ instance = latest_public_release(instance) or instance # the newest release is private: the newest public one
+ shown = bool(instance.id) and is_shown(instance, namespace, user)
+ # a version named that the repository picked doesn't serve as a release: its holder's, where the repositories with
+ # the canonical URL are the step that picks (decision 9)
+ if elsewhere and holders_pick(instance, entry, picked, namespace) and not (
+ shown and (instance.is_head or instance.released)):
+ held = held_version(repo_type, instance.resolution_url, elsewhere, namespace, user)
+ if held and held.id != instance.id:
+ if namespace and hasattr(picked, 'fhir_may_view_private'): # the same owner: the same right
+ held.fhir_may_view_private = picked.fhir_may_view_private
+ instance, shown = held, is_shown(held, namespace, user)
+
+ if not shown:
+ raise error
+ return instance
+
+
+def shared_resolution(repo_type, url, version, namespace, user):
+ """
+ (the shared resolver's answer, the registry entry that decided it or None, the HEAD it picked or None), with FHIR's
+ flags: no HEAD by default; at the global base only the HEADs it shows; and a HEAD the caller isn't shown resolves
+ to nothing before any version is read. The version found shares the HEAD's check of the caller's right to the
+ owner's private repositories, so it isn't asked twice.
+ """
+ heads = []
+
+ def head_shown(head):
+ heads.append(head)
+ return is_visible(head, namespace, user)
+ instance, entry = repo_type.resolve_reference_expression(
+ to_ocl_path(url), namespace, version, repo_type=repo_type, allow_head=False,
+ heads=None if namespace else shown_heads(repo_type), head_shown=head_shown)
+ if heads and hasattr(heads[0], 'fhir_may_view_private'):
+ instance.fhir_may_view_private = heads[0].fhir_may_view_private
+ return instance, entry, heads[0] if heads else None
+
+
+def holders_pick(instance, entry, picked, namespace):
+ """
+ Whether the repositories with the canonical URL a request names are the step that picked `instance` (the shared
+ resolver's answer, `entry` the registry entry that decided it, `picked` the HEAD it found): no entry decided, and
+ at /fhir/ a HEAD was found (with the URL registry on, at /fhir/ only an entry finds one), at an owner base whether
+ or not one was. An OCL path names its repository itself.
+ """
+ return instance.is_fqdn and entry is None and bool(namespace or picked)
+
+
+def held_version(repo_type, url, version, namespace, user):
+ """
+ The version named `version` the base shows the caller among the repositories with the canonical URL `url` it
+ shows (their active HEAD has it, isn't retired, and has a live release the caller is shown; at an owner base, the
+ owner's), as resolve() serves a version named when the repository the resolver picks serves no release of it: a
+ release (retired or not), of the lowest-id repository holding one; else the version so named (unreleased, or HEAD)
+ of the lowest-id repository; None without either. Its repository's access and canonical URL are set on it, as the
+ resolver sets them. One query, whatever the number of repositories; what the caller isn't shown is left out in it.
+ """
+ shown = shown_q(namespace, user)
+ owner = {}
+ if namespace:
+ owner = {'organization__uri' if namespace.startswith('/orgs/') else 'user__uri': namespace}
+ same_repository = Q(organization_id=OuterRef('organization_id')) | Q(user_id=OuterRef('user_id'))
+ live = repo_type.objects.filter(
+ shown, same_repository, mnemonic=OuterRef('mnemonic'), is_active=True, released=True,
+ retired=False).exclude(version=HEAD)
+ heads = repo_type.objects.filter(
+ shown, Exists(live), canonical_url=url, version=HEAD, is_active=True, retired=False, **owner)
+ head = heads.filter(same_repository, mnemonic=OuterRef('mnemonic'))
+ release = Q(released=True) & ~Q(version=HEAD)
+ held = repo_type.objects.filter(shown, version=version, is_active=True, **owner).annotate(
+ head_id=Subquery(head.values('id')[:1]), repository_public_access=Subquery(head.values('public_access')[:1]),
+ ).filter(release | Q(head_id=Subquery(heads.order_by('id').values('id')[:1])), head_id__isnull=False).order_by(
+ Case(When(release, then=Value(0)), default=Value(1)), 'head_id').first()
+ if held:
+ held.is_fqdn, held.resolution_url, held.repository_canonical_url = True, url, url
+ held.canonical_url = held.canonical_url or url
+ return held
+
+
+def path_version(url):
+ """The version an OCL path names (/orgs/{org}/sources/{id}/{version}/, or as a CodeSystem path), or None."""
+ parts = to_parent_uri(to_ocl_path(url)).split('/')
+ if len(parts) > 5 and not parts[0] and parts[1] in ('orgs', 'users') and parts[3] in REPO_PATHS.values():
+ return parts[5] or None
+ return None
+
+
+def is_shown(instance, namespace, user):
+ """Whether a base shows the caller a repository version: it is visible there, and so is a release of it."""
+ return is_visible(instance, namespace, user) and has_release(instance, namespace, user)
+
+
+def shown_heads(model):
+ """
+ Q for the HEAD rows the global base shows: public, with a live (released, not retired) public release. The
+ shared resolver's canonical match takes the first of them, without the URL registry. One EXISTS with the owner
+ matched inside it, as with_head, so the planner runs it as a semi-join on the few rows the canonical URL matches.
+ """
+ return Q(public_access__in=PUBLIC_ACCESS) & Exists(model.objects.filter(
+ mnemonic=OuterRef('mnemonic'), is_active=True, released=True, retired=False,
+ public_access__in=PUBLIC_ACCESS).exclude(version=HEAD).filter(
+ Q(organization_id=OuterRef('organization_id')) | Q(user_id=OuterRef('user_id'))))
+
+
+def latest_public_release(instance):
+ """The newest live public release of a version's repository, resolved as the version was, or None."""
+ owner = {'organization_id': instance.organization_id} if instance.organization_id else {'user_id': instance.user_id}
+ latest = type(instance).objects.filter(
+ mnemonic=instance.mnemonic, is_active=True, released=True, retired=False, public_access__in=PUBLIC_ACCESS,
+ **owner).exclude(version=HEAD).order_by('-created_at').first()
+ if latest:
+ latest.repository_public_access = repository_access(instance)
+ latest.repository_canonical_url = getattr(instance, 'repository_canonical_url', None)
+ latest.is_fqdn, latest.resolution_url = instance.is_fqdn, instance.resolution_url
+ if instance.is_fqdn and not latest.canonical_url:
+ latest.canonical_url = instance.resolution_url
+ return latest
+
+
+def to_ocl_path(url):
+ """
+ An OCL FHIR path (/orgs/{org}/CodeSystem/{id}/...) as the OCL path of the repository it names, by its own
+ resource type, so a reference to the other type finds nothing; anything else as it is.
+ """
+ parts = url.split('/')
+ if len(parts) > 3 and not parts[0] and parts[1] in ('orgs', 'users') and parts[3] in REPO_PATHS:
+ parts[3] = REPO_PATHS[parts[3]]
+ return '/'.join(parts)
+
+
+def is_visible(instance, namespace, user):
+ """
+ Whether a base shows a repository version: the version and its repository (its active HEAD, which governs access
+ to the repository's concepts and mappings) must both be viewable there. At the global base both are public, to
+ anyone; at an owner base the same, or any of the owner's versions to those who may view its private repositories
+ (staff, the org's members, the user).
+ """
+ access = repository_access(instance)
+ if access is None:
+ return False
+ return (instance.public_can_view and is_public(access)) or may_view_private(instance, namespace, user)
+
+
+def has_release(instance, namespace=None, user=None):
+ """
+ Whether the repository of a version has a released version that isn't retired, and that the base shows the
+ caller: a public one, or any to those who may view the owner's private repositories. HEAD's released flag isn't
+ one. (The repository's own access, HEAD's, is is_visible's to check.)
+ """
+ if instance.released and not instance.retired and not instance.is_head and (
+ instance.public_can_view or may_view_private(instance, namespace, user)):
+ return True
+ live = instance.released_versions.filter(retired=False).exclude(version=HEAD)
+ if not may_view_private(instance, namespace, user):
+ live = live.filter(public_access__in=PUBLIC_ACCESS)
+ return live.exists()
+
+
+def is_public(public_access):
+ """A public_access value as public_can_view reads it."""
+ return normalize_public_access(public_access).lower() == ACCESS_TYPE_VIEW.lower()
+
+
+def repository_access(instance):
+ """
+ The public_access of a version's repository, its active HEAD's, or None without one. A read (with_repository_access)
+ and the resolver (resolve_repo) set it on the version they find, so it isn't looked up again; with the canonical
+ URL of its repository, which the HEAD looked up here gives the version too (one an OCL path names itself).
+ """
+ if instance.is_head:
+ return instance.public_access if instance.is_active else None
+ if hasattr(instance, 'repository_public_access'):
+ return instance.repository_public_access
+ head = instance.head
+ if head and not hasattr(instance, 'repository_canonical_url'):
+ instance.repository_canonical_url = head.canonical_url
+ return head.public_access if head else None
+
+
+def may_view_private(instance, namespace, user):
+ """At an owner base, whether `user` may view the owner's private repositories; asked once per version."""
+ if namespace is None or not (user and user.is_authenticated):
+ return False
+ if not hasattr(instance, 'fhir_may_view_private'):
+ instance.fhir_may_view_private = instance.has_parent_edit_access(user)
+ return instance.fhir_may_view_private
+
+
+def repository_rows(model, **filters):
+ """
+ For a subquery: the rows of the outer row's repository (same mnemonic and owner) matching `filters`, as two
+ querysets, one for an org-owned outer row and one for a user-owned one (an owner column is NULL on the other).
+ """
+ rows = model.objects.filter(mnemonic=OuterRef('mnemonic'), **filters)
+ return (rows.filter(organization_id=OuterRef('organization_id'), user_id__isnull=True),
+ rows.filter(user_id=OuterRef('user_id'), organization_id__isnull=True))
+
+
+def with_head(model, *conditions, **filters):
+ """
+ For a subquery: whether the outer row's repository has an active HEAD matching `conditions` and `filters`. One
+ EXISTS, the owner matched inside it (an owner column is NULL on the other kind of row, and NULL equals nothing),
+ which the planner runs as one semi-join; two EXISTS joined by OR it plans as an index scan costing far more.
+ """
+ return Exists(model.objects.filter(
+ *conditions, mnemonic=OuterRef('mnemonic'), version=HEAD, is_active=True, **filters).filter(
+ Q(organization_id=OuterRef('organization_id')) | Q(user_id=OuterRef('user_id'))))
+
+
+def with_repository_access(queryset):
+ """Each row annotated with repository_public_access, its repository's (active HEAD's) public_access."""
+ org_heads, user_heads = repository_rows(queryset.model, version=HEAD, is_active=True)
+ return queryset.annotate(repository_public_access=Coalesce(
+ Subquery(org_heads.values('public_access')[:1]), Subquery(user_heads.values('public_access')[:1])))
+
+
+def owner_access(kwargs, user):
+ """
+ (owner filter, may view private) of the base `kwargs` names, for `user`: ({}, False) at the global base; at an
+ owner base, the filter on the owner's rows (None when the owner doesn't exist) and whether `user` may view its
+ private repositories (staff, the org's members, the user).
+ """
+ namespace = namespace_of(kwargs)
+ if namespace is None:
+ return {}, False
+ owner = SourceContainerMixin.get_object_from_namespace(namespace)
+ if not owner:
+ return None, False
+ is_org = bool(kwargs.get('org'))
+ return {'organization_id' if is_org else 'user_id': owner.id}, bool(user and user.is_authenticated) and (
+ user.is_staff or (owner.is_member(user) if is_org else owner.id == user.id))
+
+
+def viewable_rows(model, namespace, user):
+ """
+ The queryset form of is_visible(row, namespace, user) over every row of `model`, HEAD and versions alike: anywhere
+ a public row of a public repository (active HEAD); at an owner base also, whoever owns them, the rows `user` may
+ view by their own rights (all to staff, their orgs' and their own), with an active HEAD. For what a version refers
+ to (a mapping's source, the code systems an expansion drew on), so only repositories the caller is shown match.
+
+ A row and its HEAD share their owner, so "both public, or the caller's own" is "the row public or the caller's own,
+ and its HEAD public or the caller's own": one EXISTS, which the planner runs as a semi-join.
+ """
+ shown = shown_q(namespace, user)
+ return model.objects.filter(shown, with_head(model, shown))
+
+
+def shown_q(namespace, user):
+ """
+ Q for the rows viewable_rows requires of a row and of its HEAD: public; at an owner base also, to those signed in,
+ their own by their rights (everything to staff, their orgs' and their own).
+ """
+ public = Q(public_access__in=PUBLIC_ACCESS)
+ if namespace is None or not (user and user.is_authenticated):
+ return public
+ if user.is_staff:
+ return Q()
+ return public | Q(organization_id__in=user.organizations.values('id')) | Q(user_id=user.id)
+
+
+def visible_versions(resource_type, kwargs=None, user=None, access=None):
+ """
+ The queryset form of is_visible and has_release, for search: the active released versions, never HEAD, that the
+ base `kwargs` names shows `user`. A row is in it exactly when is_visible(row, namespace, user) and
+ has_release(row, namespace, user): at the global base public versions of public repositories (HEAD), whoever asks;
+ at an owner base the owner's, private ones only to those who may view them (staff, the org's members, the user).
+ An owner base whose owner doesn't exist shows nothing. `access`, owner_access's, saves looking it up again.
+
+ A retired release counts only beside a live one the caller is shown; retired releases are so few that they're
+ checked on their own, which keeps the planner on a plain scan of the table.
+ """
+ model = REPO_TYPES[resource_type]
+ owner, private = access or owner_access(kwargs, user)
+ if owner is None:
+ return model.objects.none()
+ access = {} if private else {'public_access__in': PUBLIC_ACCESS}
+ releases = model.objects.filter(is_active=True, released=True, **owner, **access).exclude(version=HEAD).filter(
+ with_head(model, **access))
+ org_live, user_live = repository_rows(model, is_active=True, released=True, retired=False, **access)
+ retired_beside_live = releases.filter(retired=True).filter(
+ Exists(org_live.exclude(version=HEAD)) | Exists(user_live.exclude(version=HEAD)))
+ return releases.filter(Q(retired=False) | Q(id__in=retired_beside_live.values('id')))
+
+
+REGISTRY_TOGGLE = 'URL_REGISTRY_IN_RESOLVE_REFERENCE_TOGGLE' # whether the resolver consults the URL registry
+ServedHead = namedtuple(
+ 'ServedHead', 'id mnemonic organization_id user_id canonical_url uri organization_uri user_uri')
+
+
+def is_looked_up_as_written(url):
+ """
+ Whether the resolver looks a canonical URL up as it is written (resolve_reference_expression's reading of a url):
+ one it takes for canonical (':'), with no |version or ?query part, that isn't an OCL path.
+ """
+ return bool(url) and is_canonical_uri(url) and '|' not in url and '?' not in url and to_ocl_path(url) == url
+
+
+def ocl_path(uri):
+ """
+ A repository's OCL path as a CodeSystem (/orgs/{org}/CodeSystem/{id}/), as $expand names the system of a member
+ without a canonical URL, if the resolver reads it back to the repository (`uri`), naming no version; else None.
+ """
+ try:
+ path = IdentifierSerializer.convert_ocl_uri_to_fhir_url(uri, 'CodeSystem')
+ except ValidationError:
+ return None
+ if is_canonical_uri(path):
+ return None
+ return path if to_parent_uri(to_ocl_path(path)) == uri else None
+
+
+def in_namespace(head, namespace):
+ """Whether a HEAD is the repository of the owner a registry entry's namespace names (get_object_from_namespace)."""
+ if namespace and '/orgs/' in namespace:
+ return head.organization_uri == namespace
+ if namespace and '/users/' in namespace:
+ return head.user_uri == namespace
+ return False
+
+
+def registry_entries(owner):
+ """(the owner's, the global registry's) active entries, each {url: namespace}, for an owner filter: one query."""
+ scope = Q(organization__isnull=True, user__isnull=True)
+ if owner:
+ scope |= Q(**owner)
+ own, shared = {}, {}
+ for url, namespace, organization_id, user_id in URLRegistry.objects.filter(is_active=True).filter(
+ scope).values_list('url', 'namespace', 'organization_id', 'user_id'):
+ (shared if organization_id is None and user_id is None else own)[url] = namespace
+ return own, shared
+
+
+def as_stored(version):
+ """A version as the shared resolver must be given it to look up `version` as it is stored: it decodes what it's
+ given (decode_string), so one that decoding would change is given encoded."""
+ return version if decode_string(version) == version else quote_plus(version)
+
+
+def served_repositories(kwargs, user): # pylint: disable=too-many-locals
+ """
+ [(identifier, [(version, retired)], newest first)] for each repository a CodeSystem search at the base `kwargs`
+ names shows `user` (visible_versions), under the identifier resolve() serves it at there, as it would were the
+ repositories the caller isn't shown not there (so a hidden repository changes nothing here): its canonical URL (its
+ HEAD's) when the resolver picks it for that URL, else its OCL path as a CodeSystem. A repository whose HEAD the
+ resolver passes over (a retired one) isn't listed. The versions are those search shows: what resolve() serves by
+ name. Under a canonical URL, after the repository's own, those of the other repositories with it the base shows
+ that resolve() serves by name there (decision 9), the default still the repository's own.
+
+ The resolver's pick for a canonical URL (one it looks up as written), among active HEADs that aren't retired, as
+ resolve_reference_expression makes it for Sources. Without the URL registry: at /fhir/ the lowest-id HEAD the global
+ base shows (shown_heads); at an owner base the owner's lowest-id one. With it: at /fhir/ the lowest-id HEAD in the
+ global registry entry's namespace, none without an entry; at an owner base the owner's entry's namespace's if the
+ owner has one for the URL, else the owner's lowest-id HEAD.
+
+ A fixed number of queries: the owner and the caller's rights over it, the toggle, the registry's entries (with it
+ on), the versions, the HEADs.
+ """
+ access = owner_access(kwargs, user)
+ owner = access[0]
+ if owner is None:
+ return []
+ registry = Toggle.get(REGISTRY_TOGGLE)
+ own_entries, global_entries = registry_entries(owner) if registry else ({}, {})
+ versions = {}
+ for *repository, version, retired in visible_versions('CodeSystem', access=access).order_by(
+ '-created_at', '-id').values_list('mnemonic', 'organization_id', 'user_id', 'version', 'retired'):
+ versions.setdefault(tuple(repository), []).append((version, retired))
+ if not versions:
+ return []
+
+ heads = viewable_rows(Source, namespace_of(kwargs), user).filter(version=HEAD, is_active=True, retired=False)
+ if owner: # the owner's, and where its registry entries point
+ heads = heads.filter(Q(**owner) | Q(canonical_url__in=list(own_entries)))
+ elif registry: # the repositories searched, and where the global registry's entries point
+ heads = heads.filter(Q(mnemonic__in={mnemonic for mnemonic, _, _ in versions}) | Q(
+ canonical_url__in=list(global_entries)))
+ else:
+ heads = heads.filter(shown_heads(Source))
+ by_url, by_repository = {}, {} # the HEADs with each canonical URL, lowest id first; each repository's
+ for row in heads.order_by('id').values_list(
+ 'id', 'mnemonic', 'organization_id', 'user_id', 'canonical_url', 'uri', 'organization__uri', 'user__uri'):
+ head = ServedHead(*row)
+ by_url.setdefault(head.canonical_url, []).append(head)
+ by_repository[(head.mnemonic, head.organization_id, head.user_id)] = head
+
+ def pick(url):
+ """The HEAD the resolver picks for a canonical URL at this base."""
+ candidates = by_url.get(url, [])
+ if registry:
+ entries = own_entries if owner else global_entries
+ if url in entries:
+ return next((head for head in candidates if in_namespace(head, entries[url])), None)
+ if not owner:
+ return None
+ return next((head for head in candidates if not owner or all(
+ getattr(head, field) == value for field, value in owner.items())), None)
+
+ def held(url, head):
+ """
+ The releases resolve() serves named under a canonical URL it picks `head` for: the head's; then, where the
+ repositories with the URL are the step that picks (no registry entry decides), each other one's the base shows
+ that no earlier one holds, lowest id first (decision 9).
+ """
+ rows = list(versions[(head.mnemonic, head.organization_id, head.user_id)])
+ if registry and (not owner or url in own_entries):
+ return rows
+ names = {version for version, _ in rows}
+ for other in by_url[url]:
+ for version, retired in versions.get((other.mnemonic, other.organization_id, other.user_id), []):
+ if version not in names:
+ names.add(version)
+ rows.append((version, retired))
+ return rows
+
+ served = []
+ for repository, rows in versions.items():
+ head = by_repository.get(repository)
+ if not head:
+ continue
+ url = head.canonical_url
+ identifier = url if is_looked_up_as_written(url) and pick(url) == head else ocl_path(head.uri)
+ if identifier:
+ served.append((identifier, held(url, head) if identifier == url else rows))
+ return served
diff --git a/core/fhir/search.py b/core/fhir/search.py
new file mode 100644
index 000000000..9f02b177a
--- /dev/null
+++ b/core/fhir/search.py
@@ -0,0 +1,751 @@
+"""
+FHIR search and read of CodeSystem, ValueSet and ConceptMap: SVCM's Query transactions (ITI-95, ITI-96, ITI-100).
+
+- What a base shows is core.fhir.resolve's rule: released versions only, never HEAD; at the global base /fhir/ the
+ public ones of public repositories (HEAD), whoever asks; at an owner base the owner's, private ones to those who may
+ view them. Search uses its queryset form (visible_versions), a read checks is_visible and has_release on the version
+ it finds.
+- Ids: at the global base every released version has an opaque, stable id, its database key; an owner base keeps the
+ repository's mnemonic (so its versions share one), and a read there serves the latest release the requester is
+ shown, or the `version` named.
+- Search parameters: SVCM's, with :exact and :contains on the string ones (no modifier is starts-with; both it and
+ :contains ignore case and accents, :exact neither).
+ A comma separates alternatives, a repeated parameter must hold every time. Anything else is a 400 not-supported.
+- Paging: _count (at most MAX_COUNT, which is also the default) and page; Bundle.total is the full count, and every
+ entry has a fullUrl and a search mode. The search runs once, for the ids of every match; the page's rows are then
+ read by id.
+- A search's queries are bounded together by SEARCH_TIMEOUT_MS: one the database cancels makes the search too costly
+ (422). A parameter takes at most MAX_ALTERNATIVES values and a search MAX_SEARCH_ALTERNATIVES (else a 400
+ too-costly), and the canonical URLs each parameter names are looked up in one query. ConceptMap's source and target
+ systems also read their candidate versions from the mappings index (core.fhir.mapping_index), in one request per
+ parameter within the same time.
+- A search entry is the resource's summary, marked SUBSETTED: no concept, compose or group, which only a read returns.
+ A CodeSystem is `complete` (with every concept, retired ones flagged) up to CONCEPT_LIMIT concepts, else
+ `not-present`.
+- A version released before its repository had a canonical URL has the repository's (HEAD's), in its `url` and for
+ the url parameter.
+"""
+import json
+import math
+import re
+import time
+from datetime import datetime, timedelta
+from functools import reduce
+from operator import or_
+
+from dateutil.relativedelta import relativedelta
+from django.conf import settings
+from django.db import connection, transaction, OperationalError
+from django.db.models import BooleanField, Case, F, Exists, Func, OuterRef, Q, Subquery, TextField, Value, When
+from django.db.models.functions import Coalesce
+from django.db.models.lookups import Exact, IContains, In, IStartsWith
+from django.utils import timezone
+from psycopg2 import errorcodes
+from rest_framework.fields import CharField
+from rest_framework.response import Response
+
+from core.bundles.serializers import FHIRBundleSerializer
+from core.collections.models import Expansion
+from core.common.constants import HEAD
+from core.common.db_functions import ImmutableUnaccent
+from core.common.mixins import CustomPaginator
+from core.common.utils import to_int
+from core.concepts.models import Concept
+from core.fhir import mapping_index, outcomes
+from core.fhir.base import CONTROL_PARAMETERS, fhir_base_url
+from core.fhir.resolve import (
+ PUBLIC_ACCESS, REPO_TYPES, namespace_of, owner_access, visible_versions, viewable_rows, is_public, is_shown,
+ repository_rows, with_repository_access)
+from core.mappings.models import Mapping
+from core.orgs.models import Organization
+from core.sources.models import Source
+from core.users.models import UserProfile
+
+MAX_COUNT = 100 # the most entries a search page holds, and how many it holds when _count isn't given
+SEARCH_TIMEOUT_MS = 5000 # the database time a search may take; past it the search is too costly
+CONCEPT_LIMIT = 1000 # a CodeSystem is `complete` up to this many concepts, else `not-present` (design D9)
+SUBSETTED = {'system': 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue', 'code': 'SUBSETTED'}
+PAGING_PARAMETERS = frozenset({'_count', 'page'})
+OPAQUE_ID = re.compile(r'[1-9][0-9]{0,17}') # a database key, as str() writes it
+NOTHING = Q(pk__in=[])
+EVERYTHING = Q(pk__isnull=False) # unlike Q(), it holds as an alternative too (Q() | x is x)
+
+# string parameters: the field each searches, and the lookup of each modifier (:exact is the field's own equality)
+STRING_FIELDS = {'name': 'name', 'title': 'full_name', 'description': 'description', 'publisher': 'publisher'}
+# the title a CodeSystem or ValueSet shows (TitleField): the full name, else the name when there is none (null, empty or
+# only Unicode whitespace as defined by str.strip())
+SHOWN_TITLE = Case(
+ When(Q(full_name__isnull=True) | Q(full_name__regex=(
+ '^[\t\n\v\f\r\x1c\x1d\x1e\x1f \x85\xa0\u1680'
+ '\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a'
+ '\u2028\u2029\u202f\u205f\u3000]*$')), then=F('name')),
+ default=F('full_name'), output_field=TextField())
+STRING_LOOKUPS = {'': IStartsWith, 'exact': None, 'contains': IContains}
+# the other parameters, by resource type: the Search method each one's values go to
+COMMON_PARAMETERS = {
+ '_id': 'by_id', '_lastUpdated': 'by_last_updated', 'url': 'by_url', 'version': 'by_version',
+ 'status': 'by_status', 'identifier': 'by_identifier'}
+PARAMETERS = {
+ 'CodeSystem': {**COMMON_PARAMETERS, 'system': 'by_url', 'code': 'by_code', 'language': 'by_language'},
+ 'ValueSet': {**COMMON_PARAMETERS, 'reference': 'by_reference'},
+ 'ConceptMap': {
+ **COMMON_PARAMETERS, 'source-system': 'by_source', 'source-uri': 'by_source', 'target-system': 'by_target',
+ 'target-uri': 'by_target'},
+}
+# A stored OCL accession identifier, as IdentifierSerializer.find_ocl_identifier finds one in the serialized list: a
+# value that isn't empty, and a type coding that is ACSN of v2-0203. IdentifierTypeCodingSerializer shows a missing code
+# as ACSN and a missing system as v2-0203 (a null one as nothing).
+STORED_ACCESSION = (
+ 'lax $[*] ? (@.value.type() == "string" && @.value != "" && exists(@.type.coding[*] ? ('
+ '(@.code == "ACSN" || !exists(@.code)) && (@.system == "http://hl7.org/fhir/v2/0203" || !exists(@.system)))))')
+# token parameters, by the system their codes belong to: None for none defined (|v is then v), CODE_SYSTEM for the
+# CodeSystem itself (code=|). identifier is a token too, with its own rules (by_identifier).
+CODE_SYSTEM = 'the code system searched'
+TOKEN_SYSTEMS = {
+ '_id': None, 'version': None, 'status': 'http://hl7.org/fhir/publication-status', 'language': 'urn:ietf:bcp:47',
+ 'code': CODE_SYSTEM}
+# what system| (any code of the system) means for a parameter with a system of its own
+ANY_CODE = {'status': EVERYTHING, 'language': Q(default_locale__isnull=False) & ~Q(default_locale='')}
+STATUSES = {'active': Q(released=True, retired=False), 'retired': Q(retired=True)} # search shows no draft
+# the parameters whose values are canonical URLs, looked up before the search (Search.look_up), by what they're among
+LOOKUPS = {
+ 'url': 'repositories', 'system': 'repositories', 'code': 'repositories', 'reference': 'referenced',
+ 'source-system': 'sources', 'source-uri': 'sources', 'target-system': 'sources', 'target-uri': 'sources'}
+# the side of a mapping each ConceptMap system parameter reads, by its Search method
+SIDES = {'by_source': 'from', 'by_target': 'to'}
+MAX_ALTERNATIVES = 20 # the values a search parameter takes: its alternatives and repeats together
+MAX_SEARCH_ALTERNATIVES = 50 # the values a search takes, every parameter's together
+
+# a FHIR date or dateTime search value: prefix, year, month, day, hour, minute, second, fraction, zone
+DATE = re.compile(
+ r'(eq|ne|gt|lt|ge|le|sa|eb)?(\d{4})(?:-(\d{2})(?:-(\d{2})'
+ r'(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})?)?)?)?')
+
+
+def split(value, separator):
+ """A search value split on the separators that aren't escaped; escapes are kept for unescape()."""
+ parts, start, index = [], 0, 0
+ while index < len(value):
+ if value[index] == '\\':
+ index += 2
+ continue
+ if value[index] == separator:
+ parts.append(value[start:index])
+ start = index + 1
+ index += 1
+ return parts + [value[start:]]
+
+
+def token(value):
+ """(system, code) of a token search value, unescaped: system None without a '|', '' for an explicit none."""
+ system, *code = split(value, '|')
+ return (None, unescape(system)) if not code else (unescape(system), unescape('|'.join(code)))
+
+
+def unescape(value):
+ """FHIR's search escapes: a backslash makes the next character (, | $ or \\) literal."""
+ return re.sub(r'\\(.)', r'\1', value)
+
+
+def date_range(value):
+ """
+ (prefix, start, end): the instants [start, end) a date search value covers at its precision, UTC by default. end is
+ None when it would fall past year 9999, which datetime can't hold: the range runs on unbounded.
+ """
+ match = DATE.fullmatch(value)
+ if not match:
+ raise outcomes.InvalidError(f"'{value}' is not a supported FHIR date search value.", expression='_lastUpdated')
+ prefix, year, month, day, hour, minute, second, fraction, zone = match.groups()
+ try:
+ start = datetime(
+ int(year), int(month or 1), int(day or 1), int(hour or 0), int(minute or 0), int(second or 0),
+ int((fraction or '0')[:6].ljust(6, '0')), tzinfo=datetime.strptime(zone or 'Z', '%z').tzinfo)
+ except ValueError as ex:
+ raise outcomes.InvalidError(f"'{value}' is not a valid date.", expression='_lastUpdated') from ex
+ unit = 'seconds' if second else 'minutes' if minute else 'days' if day else 'months' if month else 'years'
+ try:
+ end = start + (
+ timedelta(microseconds=10 ** max(0, 6 - len(fraction))) if fraction else relativedelta(**{unit: 1}))
+ except (ValueError, OverflowError):
+ end = None
+ return prefix or 'eq', start, end
+
+
+def canonical_q(url, repositories):
+ """
+ Rows whose canonical URL is `url` as CanonicalUrlField shows it: their own, or for a row with none (a version
+ released before its repository had one), their repository's (HEAD's). `repositories` are the (mnemonic,
+ organization_id, user_id) of the repositories whose HEAD has it, looked up first (repositories_with), so the
+ planner sees plain conditions rather than a correlated subquery it can't estimate.
+ """
+ if not repositories:
+ return Q(canonical_url=url)
+ return Q(canonical_url=url) | ((Q(canonical_url__isnull=True) | Q(canonical_url='')) & reduce(or_, (
+ Q(mnemonic=mnemonic, organization_id=organization_id, user_id=user_id)
+ for mnemonic, organization_id, user_id in repositories)))
+
+
+def repositories_with(rows, urls):
+ """
+ For each of `urls`, the (mnemonic, organization_id, user_id) of the repositories among `rows` whose HEAD has that
+ canonical URL: one query for them all.
+ """
+ found = {url: [] for url in urls}
+ for url, *repository in rows.filter(version=HEAD, canonical_url__in=urls).values_list(
+ 'canonical_url', 'mnemonic', 'organization_id', 'user_id'):
+ found[url].append(tuple(repository))
+ return found
+
+
+def sources_with(rows, urls, versions):
+ """
+ ({url: [(id, (owner type, owner, mnemonic))]}, {(owner type, owner, mnemonic): {version names}}): for each of
+ `urls` the Source rows among `rows` with that canonical URL, and the repositories and versions of the rows
+ `versions` lists, in one query for them all.
+ """
+ fields = ('canonical_url', 'id', 'mnemonic', 'organization__mnemonic', 'user__username', 'version')
+ named = rows.filter(canonical_url__in=urls).annotate(listed=Value(False)).values_list(*fields, 'listed')
+ found, shown = {url: [] for url in urls}, {}
+ for url, source_id, mnemonic, org, username, version, listed in named.union(
+ versions.annotate(listed=Value(True)).values_list(*fields, 'listed'), all=True):
+ repository = ('Organization', org, mnemonic) if org else ('User', username, mnemonic)
+ if listed:
+ shown.setdefault(repository, set()).add(version)
+ else:
+ found[url].append((source_id, repository))
+ return found, shown
+
+
+def with_repository_canonical(queryset):
+ """Each row annotated with its repository's (HEAD's) canonical URL, for a version that has none of its own."""
+ org_heads, user_heads = repository_rows(queryset.model, version=HEAD)
+ return queryset.annotate(repository_canonical_url=Coalesce(
+ Subquery(org_heads.values('canonical_url')[:1]), Subquery(user_heads.values('canonical_url')[:1])))
+
+
+def code_system_concepts(source):
+ """
+ (count, concepts) for a CodeSystem read, cached on the version: its concepts, retired ones included, when it has
+ at most CONCEPT_LIMIT, else (its number of concepts, None). The number is counted on the version's concept links
+ alone (an index-only count, cheap even for LOINC); within the limit it's the concepts listed.
+ """
+ if not hasattr(source, 'fhir_concepts'):
+ count, concepts = Concept.sources.through.objects.filter(source_id=source.id).count(), None
+ if count <= CONCEPT_LIMIT:
+ concepts = list(source.concepts.filter(is_active=True).select_related('parent').prefetch_related(
+ 'names', 'descriptions').order_by('id'))
+ count = len(concepts)
+ source.fhir_concepts = count, concepts
+ return source.fhir_concepts
+
+
+def concepts_over_limit(source):
+ """Whether a CodeSystem has more than CONCEPT_LIMIT concepts: a search annotates it, a read counts them."""
+ over = getattr(source, 'concepts_over_limit', None)
+ return code_system_concepts(source)[1] is None if over is None else over
+
+
+def with_concepts_over_limit(queryset):
+ """Each Source row annotated with concepts_over_limit, reading at most CONCEPT_LIMIT + 1 of its concept links."""
+ links = Concept.sources.through.objects.filter(source_id=OuterRef('id'))
+ return queryset.annotate(concepts_over_limit=Exists(links[CONCEPT_LIMIT:CONCEPT_LIMIT + 1]))
+
+
+def defines_concept(**filters):
+ """
+ A CodeSystem version with a concept (matching `filters`), as its read lists them: linked to the version and not
+ deleted. A retired concept counts; the read shows it flagged inactive.
+ """
+ return Exists(Concept.sources.through.objects.filter(source_id=OuterRef('id'), concept__is_active=True, **filters))
+
+
+class JsonPathExists(Func): # pylint: disable=abstract-method
+ """jsonb_path_exists(field, path, vars, silent): whether the path finds anything, an error being no match."""
+ function = 'jsonb_path_exists'
+ output_field = BooleanField()
+
+ def __init__(self, field, path, variables):
+ super().__init__(field, Value(path), Value(json.dumps(variables)), Value(True))
+
+
+class JsonbTypeof(Func): # pylint: disable=abstract-method
+ function = 'jsonb_typeof'
+ output_field = TextField()
+
+
+def held_identifier(system, value):
+ """
+ Rows holding an identifier shown as system|value, both checked on the same identifier: `system` None is any
+ system, '' none; `value` None is any value. IdentifierSerializer shows an identifier stored without a system key
+ under API_BASE_URL, and one with a null or empty system without one. Only a list of identifiers is shown; lax mode
+ reads a missing key as nothing (strict mode would make exists() unknown).
+ """
+ conditions, variables = [], {}
+ if value is not None:
+ conditions.append('@.value == $value')
+ variables['value'] = value
+ if system == '':
+ conditions.append('exists(@.system) && (@.system == null || @.system == "")')
+ elif system is not None:
+ no_key = ' || !exists(@.system)' if system == settings.API_BASE_URL else ''
+ conditions.append(f'(@.system == $system{no_key})')
+ variables['system'] = system
+ return Exact(JsonbTypeof('identifier'), 'array') & JsonPathExists(
+ 'identifier', f'lax $[*] ? ({" && ".join(conditions)})', variables)
+
+
+def indexed_repository(owner_type, owner, mnemonic):
+ """
+ The rows of a repository as the mappings index names it (owner and mnemonic in lowercase), twins included. The
+ owner is an uncorrelated IN, so the version rows need no join.
+ """
+ if owner_type == 'Organization':
+ return Q(mnemonic__iexact=mnemonic, organization_id__in=Organization.objects.filter(
+ mnemonic__iexact=owner).values('id'))
+ return Q(mnemonic__iexact=mnemonic, user_id__in=UserProfile.objects.filter(username__iexact=owner).values('id'))
+
+
+def mapped(side, value, sources, holding, namespace, user): # pylint: disable=too-many-arguments
+ """
+ A ConceptMap version holding a live mapping whose group source (side 'from') or target ('to') shows `value`, as
+ ConceptMapGroupField builds groups: retired and deleted mappings aren't read; a group's system is the from or to
+ source's canonical URL when the caller is shown that source, else the URL the mapping stores (an OCL URL as a
+ ConceptMap path, /orgs/{org}/ConceptMap/{id}/). OCL keeps no source or target value set on a ConceptMap, so
+ source-uri and target-uri are read as the systems.
+
+ `sources` are the ids of the sources with that canonical the base shows the caller (viewable_rows), listed first
+ (sources_with), and `holding` the repositories and versions the mappings index lists as holding mappings from or
+ to them (mapping_index.holding). Only those versions, and their repositories' releases created within
+ mapping_index.LAG_WINDOW (which the index may not list yet), are candidates: the database decides each by its own
+ mappings, so a heavily mapped source costs no more than another. A mapping whose source isn't one the caller is
+ shown by a canonical URL is matched by the URL it stores: a source the caller isn't shown answers exactly as one
+ OCL doesn't hold. Alone, that is one EXISTS, which the planner runs as a semi-join; beside the candidates it's an
+ uncorrelated IN, which it runs once.
+ """
+ field = f'mapping__{side}_source'
+ unnamed = ~Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef(f'{field}_id'), canonical_url__gt=''))
+ stored = Q(**{f'{field}_url': value}) & unnamed
+ path = re.fullmatch(r'/(orgs|users)/([^/]+)/ConceptMap/([^/]+)/', value)
+ if path:
+ stored |= reduce(or_, (Q(**{f'{field}_url__startswith': f'/{path[1]}/{path[2]}/{kind}/{path[3]}/'})
+ for kind in ('sources', 'collections', 'ConceptMap'))) & unnamed
+ links = Mapping.sources.through.objects.filter(mapping__retired=False, mapping__is_active=True)
+ if not holding:
+ return Exists(links.filter(stored, source_id=OuterRef('id')))
+ recent = Q(created_at__gte=timezone.now() - mapping_index.LAG_WINDOW)
+ candidates = reduce(or_, (indexed_repository(*repository) & (Q(version__in=sorted(versions)) | recent)
+ for repository, versions in sorted(holding.items())))
+ # each candidate is read through its own links, each link's mapping a scalar subquery the planner can't turn into
+ # a join: it can't start from all the source's mappings instead, which say nothing of where the version's are
+ live_side = Mapping.objects.filter(id=OuterRef('mapping_id'), retired=False, is_active=True).values(
+ f'{side}_source_id')[:1]
+ named = Exists(Mapping.sources.through.objects.filter(In(Subquery(live_side), sources), source_id=OuterRef('id')))
+ return Q(id__in=links.filter(stored).values('source_id')) | (candidates & named)
+
+
+class Search:
+ """The criteria of one search's parameters, on the versions of a resource type at a base, for a caller."""
+
+ def __init__(self, resource_type, kwargs, user=None):
+ self.resource_type = resource_type
+ self.model = REPO_TYPES[resource_type]
+ self.parameters = PARAMETERS[resource_type]
+ self.namespace, self.user = namespace_of(kwargs), user
+ self.access = owner_access(kwargs, user)
+ self.found = {} # (lookup, canonical URL): what look_up found for it
+ self.deadline = time.monotonic() + SEARCH_TIMEOUT_MS / 1000 # for what isn't a query: the mappings index
+
+ def viewable_sources(self):
+ """
+ The Source rows the base shows the caller, whoever owns them: what source-system, target-system and reference
+ match. At an owner base that's another owner's private source too, if the caller may view it themselves.
+ """
+ return viewable_rows(Source, self.namespace, self.user)
+
+ def criteria(self, query):
+ """
+ One Q for a query string: every parameter holds (a repeated one each time), any of a value's comma-separated
+ alternatives does. An empty value isn't a criterion; the format and paging parameters aren't criteria.
+ """
+ criteria = Q()
+ for name, modifier, repeats in self.parameters_of(query):
+ self.look_up(name, [item for alternatives in repeats for item in alternatives])
+ for alternatives in repeats:
+ if alternatives:
+ criteria &= reduce(or_, [self.criterion(name, modifier, item) for item in alternatives])
+ return criteria
+
+ def parameters_of(self, query):
+ """
+ (name, modifier, each repeat's alternatives) for every search parameter of a query string, each supported and
+ within MAX_ALTERNATIVES values (alternatives and repeats together) and the search within
+ MAX_SEARCH_ALTERNATIVES, else a 400 too-costly naming the parameter. Checked before any query runs.
+ """
+ parameters, given, total = [], {}, 0
+ for key, values in query.lists():
+ if key in CONTROL_PARAMETERS or key in PAGING_PARAMETERS:
+ continue
+ name, _, modifier = key.partition(':')
+ self.check(key, name, modifier)
+ repeats = [[item for item in split(value, ',') if item] for value in values]
+ count = sum(map(len, repeats))
+ given[name], total = given.get(name, 0) + count, total + count
+ if given[name] > MAX_ALTERNATIVES or total > MAX_SEARCH_ALTERNATIVES:
+ raise outcomes.TooCostlyError(
+ f'A search takes at most {MAX_ALTERNATIVES} values of a parameter, its alternatives and repeats '
+ f'together, and {MAX_SEARCH_ALTERNATIVES} in all.', expression=key, status_code=400)
+ parameters.append((name, modifier, repeats))
+ return parameters
+
+ def check(self, key, name, modifier):
+ modifiers = STRING_LOOKUPS if name in STRING_FIELDS else ('',) if name in self.parameters else ()
+ if modifier not in modifiers:
+ raise outcomes.NotSupportedError(f"The search parameter '{key}' is not supported.", expression=key)
+
+ def look_up(self, name, alternatives):
+ """
+ The canonical URLs a parameter's alternatives (still escaped) name, looked up together in one query, for its
+ criteria: the repositories with them (url, system, code's system), or among the sources the base shows the
+ caller, the repositories (reference) or the sources (source-system, source-uri, target-system, target-uri).
+ The same query lists the ConceptMap versions the base shows the caller (visible_versions). For the sources
+ found, one request to the mappings index lists the repositories and versions holding mappings from (source-) or
+ to (target-) them, among those versions; none is sent for a URL no source the caller is shown has, or where the
+ base shows no ConceptMap.
+ """
+ lookup = LOOKUPS.get(name)
+ urls = {token(item)[0] if name == 'code' else unescape(item) for item in alternatives} - {None, ''}
+ if not lookup or not urls:
+ return
+ if lookup == 'repositories':
+ found = repositories_with(self.model.objects, urls)
+ elif lookup == 'referenced':
+ found = repositories_with(self.viewable_sources(), urls)
+ else:
+ found, shown = sources_with(
+ self.viewable_sources(), urls, visible_versions(self.resource_type, access=self.access))
+ side = SIDES[self.parameters[name]]
+ holding = mapping_index.holding(
+ side, {url: {repository for _, repository in rows} for url, rows in found.items()}, shown,
+ self.deadline, search_too_costly)
+ self.found.update({(side, url): holding.get(url, {}) for url in found})
+ found = {url: [source_id for source_id, _ in rows] for url, rows in found.items()}
+ self.found.update({(lookup, url): value for url, value in found.items()})
+
+ def criterion(self, name, modifier, value):
+ """The Q for one alternative (still escaped) of a parameter's value."""
+ if name == 'title' and self.resource_type != 'ConceptMap':
+ return self.by_string(SHOWN_TITLE, modifier, unescape(value))
+ if name in STRING_FIELDS:
+ return self.by_string(STRING_FIELDS[name], modifier, unescape(value))
+ if name == 'identifier':
+ return self.by_identifier(value)
+ if name in TOKEN_SYSTEMS:
+ return self.by_token(name, *token(value))
+ return getattr(self, self.parameters[name])(unescape(value))
+
+ def by_token(self, name, system, code):
+ """
+ A token parameter: code (any system), system|code, system| (any code of that system) or |code (explicitly no
+ system), against the system its codes belong to (TOKEN_SYSTEMS). A parameter with no system takes |code as
+ code; any other system is no match.
+ """
+ handler, implicit = getattr(self, self.parameters[name]), TOKEN_SYSTEMS[name]
+ if system is None or (system == '' and implicit is None):
+ return handler(code)
+ if not system or implicit is None:
+ return NOTHING
+ if implicit == CODE_SYSTEM: # system| is any code: a version of that code system with a concept
+ return self.by_url(system) & (handler(code) if code else defines_concept())
+ if system != implicit:
+ return NOTHING
+ return handler(code) if code else ANY_CODE[name]
+
+ @staticmethod
+ def by_string(field, modifier, value):
+ """
+ FHIR string search: :exact is the whole value, case and accents included; no modifier (starts with) and
+ :contains ignore case and accents, unaccenting both sides in the database (LIKE's wildcards are escaped).
+ """
+ lookup = STRING_LOOKUPS[modifier]
+ if lookup is None:
+ return Q(Exact(field, Value(value))) if isinstance(field, Case) else Q(**{field: value})
+ return lookup(ImmutableUnaccent(field), ImmutableUnaccent(Value(value)))
+
+ def by_id(self, value):
+ """The opaque id at the global base, the mnemonic at an owner base."""
+ if self.namespace is None:
+ return Q(id=int(value)) if OPAQUE_ID.fullmatch(value) else NOTHING
+ return Q(mnemonic=value)
+
+ @staticmethod
+ def by_last_updated(value):
+ prefix, start, end = date_range(value)
+ before_end = Q(updated_at__lt=end) if end else EVERYTHING
+ after_end = Q(updated_at__gte=end) if end else NOTHING
+ return {
+ 'eq': Q(updated_at__gte=start) & before_end,
+ 'ne': Q(updated_at__lt=start) | after_end,
+ 'gt': after_end, 'sa': after_end,
+ 'ge': Q(updated_at__gte=start),
+ 'lt': Q(updated_at__lt=start), 'eb': Q(updated_at__lt=start),
+ 'le': before_end,
+ }[prefix]
+
+ def by_url(self, value):
+ return canonical_q(value, self.found[('repositories', value)])
+
+ @staticmethod
+ def by_version(value):
+ return Q(version=value)
+
+ @staticmethod
+ def by_status(value):
+ return STATUSES.get(value, NOTHING)
+
+ def by_identifier(self, value):
+ """
+ A token, matched on one identifier as it's shown: value (any system), system|value, |value (no system) or
+ system| (any value). Besides the identifiers a repository holds, every resource without a stored accession
+ identifier shows a generated one: system API_BASE_URL, value /{orgs|users}/{owner}/{type}/{id}/.
+ """
+ system, value = token(value)
+ value = value or None
+ if system == '' and value is None:
+ return NOTHING
+ criterion = held_identifier(system, value)
+ if system not in (None, settings.API_BASE_URL):
+ return criterion
+ generated = Q(identifier__isnull=True) | ~Q(
+ Exact(JsonbTypeof('identifier'), 'array') & JsonPathExists('identifier', STORED_ACCESSION, {}))
+ if value is None:
+ return criterion | generated
+ accession = re.fullmatch(r'/(orgs|users)/([^/]+)/([^/]+)/([^/]+)/?', value)
+ if accession and accession.group(3) == self.resource_type:
+ owner = 'organization__mnemonic' if accession.group(1) == 'orgs' else 'user__username'
+ criterion |= Q(Q(**{owner: accession.group(2), 'mnemonic': accession.group(4)}) & generated)
+ return criterion
+
+ @staticmethod
+ def by_code(value):
+ """A CodeSystem that defines the code."""
+ return defines_concept(concept__mnemonic=value)
+
+ @staticmethod
+ def by_language(value):
+ return Q(default_locale=value)
+
+ def by_reference(self, value):
+ """
+ A ValueSet whose stored default expansion draws on a version of the code system (canonical URL) named, among
+ those the base shows the caller. An uncorrelated IN, which the planner hashes once, rather than a correlated
+ subquery.
+ """
+ sources = self.viewable_sources().filter(canonical_q(value, self.found[('referenced', value)])).values('id')
+ return reduce(or_, (
+ Q(expansion_uri__in=versions.through.objects.filter(source_id__in=sources).values('expansion__uri'))
+ for versions in (Expansion.explicit_source_versions, Expansion.evaluated_source_versions)))
+
+ def by_source(self, value):
+ """A group's source is the system named."""
+ return mapped('from', value, self.found[('sources', value)], self.found[('from', value)], self.namespace,
+ self.user)
+
+ def by_target(self, value):
+ """A group's target is the system named."""
+ return mapped('to', value, self.found[('sources', value)], self.found[('to', value)], self.namespace,
+ self.user)
+
+
+def page_size(query):
+ value = query.get('_count')
+ if value is None:
+ return MAX_COUNT
+ if not re.fullmatch(r'[0-9]{1,9}', value.strip()):
+ raise outcomes.InvalidError(f"_count must be a whole number, got '{value}'.", expression='_count')
+ return min(int(value), MAX_COUNT)
+
+
+def within_search_timeout(build, too_costly=None):
+ """
+ build() within SEARCH_TIMEOUT_MS in all: each query it runs may take only the time the search has left, so a
+ search of several queries is bounded as a whole. A query the database cancels, or one with no time left, makes the
+ search too costly (422): too_costly() says so, the search's own wording by default (an operation passes its own).
+ Inside a transaction (tests) the timeout would outlive the search, so it isn't set there.
+ """
+ too_costly = too_costly or search_too_costly
+ if connection.vendor != 'postgresql' or connection.in_atomic_block:
+ return build()
+ deadline = time.monotonic() + SEARCH_TIMEOUT_MS / 1000
+
+ def within_time_left(execute, sql, params, many, context):
+ left = math.ceil((deadline - time.monotonic()) * 1000)
+ if left <= 0:
+ raise too_costly()
+ with context['connection'].connection.cursor() as cursor: # the driver's own cursor, past this wrapper
+ cursor.execute('SET LOCAL statement_timeout = %s', [left])
+ return execute(sql, params, many, context)
+
+ try:
+ with transaction.atomic(), connection.execute_wrapper(within_time_left):
+ return build()
+ except OperationalError as ex:
+ if getattr(ex.__cause__, 'pgcode', None) != errorcodes.QUERY_CANCELED:
+ raise
+ raise too_costly() from ex
+
+
+def search_too_costly():
+ return outcomes.TooCostlyError(
+ 'The search would take too long to answer. Narrow it, for example with url, _id or version.')
+
+
+def search_bundle(view):
+ """The searchset Bundle a FHIR search view answers: the base's matching versions, one page of them."""
+ request, resource_type = view.request, view.resource_type
+ count = page_size(request.query_params)
+ search = Search(resource_type, view.kwargs, request.user)
+ ids = list(visible_versions(resource_type, access=search.access).filter(
+ search.criteria(request.query_params)).order_by('-id').values_list('id', flat=True))
+ paginator, entries = None, []
+ if count:
+ page = to_int(request.GET.get('page', '1'), 1)
+ rows = with_repository_canonical(REPO_TYPES[resource_type].objects.filter(
+ id__in=ids[(page - 1) * count:page * count])).order_by('-id')
+ if resource_type == 'CodeSystem':
+ rows = with_concepts_over_limit(rows)
+ paginator = CustomPaginator(
+ request=request, total_count=len(ids), queryset=list(rows), page_size=count, is_sliced=True)
+ resources = view.get_serializer(paginator.current_page_results, many=True).data
+ entries = FHIRBundleSerializer.convert_to_entry(
+ resources, f'{fhir_base_url(request, view.kwargs)}/{resource_type}')
+ bundle = {'meta': {'lastUpdated': timezone.now()}, 'type': 'searchset', 'total': len(ids)}
+ if entries:
+ bundle['entry'] = entries
+ return FHIRBundleSerializer(bundle, context={'paginator': paginator, 'request': request}).data
+
+
+def read(view):
+ """
+ The repository version a FHIR read view names: at the global base by its opaque id, at an owner base by its
+ mnemonic, the version ?version= names (an OCL extension) or else the latest release the requester is shown. It
+ must be one search lists: released, never HEAD, visible to the requester there. Raises NotFoundError.
+ """
+ return read_version(view.resource_type, view.kwargs[view.lookup_field], view.request.query_params.get('version'),
+ view.kwargs, view.request.user)
+
+
+def read_version(resource_type, resource_id, version, kwargs, user):
+ """
+ read's rule for a resource `resource_id` names at the base `kwargs` names, for `user`: the id at the global base,
+ the mnemonic and `version` (else the latest release shown) at an owner base. For the instance-level operations too.
+ """
+ namespace = namespace_of(kwargs)
+ versions = REPO_TYPES[resource_type].objects.filter(is_active=True, released=True).exclude(version=HEAD)
+ latest = False
+ if namespace is None:
+ versions = versions.filter(id=int(resource_id)) if OPAQUE_ID.fullmatch(resource_id) else versions.none()
+ else:
+ owner = {'user__username': kwargs['user']} if kwargs.get('user') else {'organization__mnemonic': kwargs['org']}
+ versions = versions.filter(mnemonic=resource_id, **owner)
+ latest = not version
+ versions = versions.filter(version=version) if version else versions.filter(
+ retired=False).order_by('-created_at')
+
+ instance = with_repository_access(with_repository_canonical(versions)).first()
+ if latest and instance and not is_shown(instance, namespace, user) and is_public(
+ instance.repository_public_access or ''):
+ # the latest is private, in a public repository: the latest public one
+ instance = with_repository_access(with_repository_canonical(
+ versions.filter(public_access__in=PUBLIC_ACCESS))).first()
+ if not instance or not is_shown(instance, namespace, user):
+ raise outcomes.NotFoundError(f'{resource_type}/{resource_id} not found')
+ return instance
+
+
+class FhirRepositoryViewMixin:
+ """The search and read views of CodeSystem, ValueSet and ConceptMap (after FhirViewMixin)."""
+ resource_type = None
+
+ def get_serializer_context(self):
+ return {**super().get_serializer_context(), 'opaque_ids': namespace_of(self.kwargs) is None}
+
+
+class FhirSearchMixin(FhirRepositoryViewMixin):
+ """GET on a resource type is its search."""
+
+ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
+ """The searchset Bundle, in at most SEARCH_TIMEOUT_MS of database time."""
+ return Response(within_search_timeout(lambda: search_bundle(self)))
+
+ head = get
+
+
+class FhirReadMixin(FhirRepositoryViewMixin):
+ """
+ GET on an id is its read, at the global base without the owner check the view's writes keep (verify_scope), and
+ without the source and collection views' recounts.
+ """
+
+ def verify_scope(self):
+ if self.request.method not in ('GET', 'HEAD'):
+ super().verify_scope()
+
+ def get_object(self, queryset=None):
+ if self.request.method not in ('GET', 'HEAD'):
+ return super().get_object(queryset)
+ return read(self)
+
+
+class CanonicalUrlField(CharField):
+ """A resource's canonical URL: the version's own, else its repository's (repository_canonical_url, on a read)."""
+
+ def get_attribute(self, instance):
+ return super().get_attribute(instance) or getattr(instance, 'repository_canonical_url', None)
+
+
+class TitleField(CharField):
+ """
+ A repository's FHIR title: its full name, or its name when it has no full name (empty or only whitespace). Writes
+ still go to full_name.
+ """
+
+ def get_attribute(self, instance):
+ full_name = instance.full_name
+ return full_name if full_name and full_name.strip() else instance.name
+
+
+class FhirRepositorySerializerMixin:
+ """
+ For the CodeSystem, ValueSet and ConceptMap serializers: the opaque id at the global base (context opaque_ids), and
+ in a search (many=True, context has_many) the summary, marked SUBSETTED, without `content_field`.
+ """
+ content_field = None
+
+ @classmethod
+ def many_init(cls, *args, **kwargs):
+ kwargs['context'] = {**kwargs.get('context', {}), 'has_many': True}
+ return super().many_init(*args, **kwargs)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.context.get('has_many'):
+ self.fields.pop(self.content_field, None)
+
+ def with_fhir_view(self, instance, rep):
+ """
+ The id (opaque at the global base), and meta.versionId: the version's opaque id at both bases, so the
+ releases of one repository, which share their mnemonic id at an owner base, stay distinct in a Bundle (bdl-7).
+ A search entry or a read whose content field is subsetted is tagged SUBSETTED. meta keeps its existing values
+ and tags.
+ """
+ if instance.id:
+ if self.context.get('opaque_ids'):
+ rep['id'] = str(instance.id)
+ rep['meta'] = {'versionId': str(instance.id), **(rep.get('meta') or {})}
+ if self.context.get('has_many') or getattr(self.fields.get(self.content_field), 'subsetted', False):
+ meta = rep.get('meta') or {}
+ tags = list(meta.get('tag') or [])
+ if SUBSETTED not in tags:
+ tags.append(SUBSETTED)
+ rep['meta'] = {**meta, 'tag': tags}
+ return rep
diff --git a/core/fhir/swagger.py b/core/fhir/swagger.py
new file mode 100644
index 000000000..282601a65
--- /dev/null
+++ b/core/fhir/swagger.py
@@ -0,0 +1,304 @@
+"""Curated Swagger 2.0 descriptions of the FHIR terminology reads and operations."""
+from copy import deepcopy
+from urllib.parse import urlsplit, urlunsplit
+
+from django.conf import settings
+from drf_yasg import openapi
+from drf_yasg.generators import OpenAPISchemaGenerator
+
+from core.fhir.base import FHIR_JSON, FHIR_XML
+from core.fhir.ops.expand import MAX_COUNT as EXPANSION_MAX_COUNT
+from core.fhir.search import MAX_COUNT, PARAMETERS, STRING_FIELDS, STRING_LOOKUPS
+
+BASES = ('/fhir', '/orgs/{org}', '/users/{user}')
+TAGS = ('CodeSystem', 'ValueSet', 'ConceptMap', 'Capabilities')
+VERSION_SELECTION = (
+ 'The default is the latest released version visible at this base; HEAD is used only when explicitly named. '
+ 'An instance id at /fhir names a released version; at an owner base it is the repository mnemonic.')
+SEARCH_DESCRIPTIONS = {
+ '_id': 'Resource id: an opaque release id at /fhir, or the repository mnemonic at an owner base.',
+ '_lastUpdated': 'FHIR date or dateTime, optionally prefixed by eq, ne, gt, lt, ge, le, sa or eb.',
+ 'url': 'Canonical URL of the resource.',
+ 'version': 'Business version token. Searches include released versions only, never HEAD.',
+ 'status': 'Publication status token: active or retired.',
+ 'identifier': 'Identifier token: value, system|value, |value or system|.',
+ 'system': 'Canonical URL of the code system (an alias of url).',
+ 'code': 'A code defined by the code system; code or system|code.',
+ 'language': 'Language token for the code system (urn:ietf:bcp:47).',
+ 'reference': 'Canonical code system URL referenced by the value set\'s stored default expansion.',
+ 'source-system': 'Canonical URL of a source system in a ConceptMap group.',
+ 'source-uri': 'Alias of source-system.',
+ 'target-system': 'Canonical URL of a target system in a ConceptMap group.',
+ 'target-uri': 'Alias of target-system.',
+}
+
+
+def on_fhir_host(request):
+ """Use the same host boundary for the schema and the FHIR middleware."""
+ return bool(request and settings.FHIR_SUBDOMAIN and
+ request.get_host().startswith(settings.FHIR_SUBDOMAIN + '.'))
+
+
+def fhir_swagger_url(request, api_url):
+ """The FHIR sibling of the API host, keeping the request's scheme and port."""
+ url = urlsplit(request.build_absolute_uri('/') if request else api_url)
+ host = settings.FHIR_SUBDOMAIN + '.' + (url.netloc.partition('.')[2] or url.netloc)
+ return urlunsplit((url.scheme, host, '/swagger/', '', ''))
+
+
+def query(name, description, type_=openapi.TYPE_STRING, **kwargs):
+ """A typed query parameter; complex FHIR values are described in the POST body instead."""
+ return openapi.Parameter(name, openapi.IN_QUERY, type=type_, description=description, **kwargs)
+
+
+NOT_WITH_CODING = ' Not read when coding or codeableConcept is given.'
+FORMAT = query('_format', 'Response format; overrides Accept.', enum=['json', 'xml'])
+CODE = query('code', 'Code to look up, validate or translate; alternatively supply coding.' + NOT_WITH_CODING)
+SYSTEM = query('system', 'Canonical URL or OCL path of the code system the code is from.' + NOT_WITH_CODING)
+VERSION = query('version', 'Business version of the code system; defaults to the latest released version.')
+LOOKUP_SYSTEM = query('system', 'Canonical URL or OCL path of the code system the code is from. At an instance it '
+ 'must name that code system. With coding at the type level, the coding\'s system '
+ 'is used instead.')
+URL = query('url', 'Canonical URL or OCL path of the target resource; may include |version.')
+VALUE_SET_VERSION = query('valueSetVersion', 'Business version of the value set.')
+LANGUAGE = query('displayLanguage', 'Preferred language for the returned display, when a name in it exists.')
+DISPLAY = query('display', 'Display to validate against the concept\'s names, ignoring case.' + NOT_WITH_CODING)
+
+
+def coding(replaces, applies=''):
+ """The coding input of one operation: a system|code token here, a full Coding in a POSTed body."""
+ return query('coding', f'A system|code token. It replaces {replaces}.{applies} A GET token carries no version '
+ 'or display: send those inside a POSTed valueCoding.')
+
+
+OPERATION_PARAMETERS = {
+ ('CodeSystem', '$lookup'): [
+ LOOKUP_SYSTEM, VERSION, CODE,
+ coding('code and system', ' version still selects the code system version; at an instance, a separate '
+ 'system must still name it.'),
+ LANGUAGE],
+ ('CodeSystem', '$validate-code'): [
+ URL, VERSION, CODE,
+ coding('code and display', ' url and version still name the code system validated against.'),
+ DISPLAY, LANGUAGE],
+ ('ValueSet', '$validate-code'): [
+ URL, VALUE_SET_VERSION, SYSTEM,
+ query('systemVersion', 'Business version of the input code system.' + NOT_WITH_CODING),
+ CODE, coding('code, system, systemVersion and display'), DISPLAY, LANGUAGE],
+ ('ValueSet', '$expand'): [
+ URL, VALUE_SET_VERSION,
+ query('filter', 'Each word must prefix a word in the code or one of its names, ignoring case.'), LANGUAGE,
+ query('includeDesignations', 'Include every name as a designation.', openapi.TYPE_BOOLEAN),
+ query('activeOnly', 'Exclude retired concepts.', openapi.TYPE_BOOLEAN),
+ query('offset', 'Number of matching members to skip.', openapi.TYPE_INTEGER, minimum=0, default=0),
+ query('count', 'Page size, capped at 1,000; zero returns only the total.', openapi.TYPE_INTEGER,
+ minimum=0, maximum=EXPANSION_MAX_COUNT, default=EXPANSION_MAX_COUNT),
+ query('includeDefinition', 'Only false is supported; definitions are not included.', openapi.TYPE_BOOLEAN,
+ enum=[False]),
+ query('excludePostCoordinated', 'Only false is supported; stored expansions do not record this distinction.',
+ openapi.TYPE_BOOLEAN, enum=[False]),
+ query('excludeNested', 'Either boolean is accepted; stored expansions are flat.', openapi.TYPE_BOOLEAN),
+ query('excludeNotForUI', 'Either boolean is accepted; stored expansions have no abstract codes.',
+ openapi.TYPE_BOOLEAN)],
+ ('ConceptMap', '$translate'): [
+ URL, query('conceptMapVersion', 'Business version of the map; without url, selects the source\'s map version.'),
+ CODE, SYSTEM,
+ query('version', 'Input code system version; omitted means no mapping-side version filter. '
+ 'Without url in forward translation, also selects the map version unless conceptMapVersion '
+ 'is given.' + NOT_WITH_CODING),
+ coding('code, system and version'),
+ query('source', 'Input value set scope for forward translation; ignored when reverse=true.'),
+ query('target', 'Input value set scope for reverse translation; ignored in forward translation.'),
+ query('targetsystem', 'Restrict matches to this code system; in reverse, the system of the codes sought.'),
+ query('reverse', 'Translate from the target side of the mappings.', openapi.TYPE_BOOLEAN, default=False)],
+}
+OPERATION_DESCRIPTIONS = {
+ ('CodeSystem', '$lookup'): (
+ 'Look up a code', 'Returns the code\'s display, names and inactive flag, including retired concepts. '
+ 'A code and, at type level, its system are required (or supply coding).', (400, 404, 422)),
+ ('CodeSystem', '$validate-code'): (
+ 'Validate a code in a code system',
+ 'Checks the code and optional display in url (or coding.system). A code and a target system are required. '
+ 'An unknown code or system returns Parameters with result=false; retired codes are valid with a warning.',
+ (400,)),
+ ('ValueSet', '$validate-code'): (
+ 'Validate a code in a value set',
+ 'Checks membership in the stored default expansion and an optional display. A code and its system are '
+ 'required (or supply coding). Without url at type level, checks the code system alone. '
+ 'A failed validation returns Parameters with result=false.', (400, 422)),
+ ('ValueSet', '$expand'): (
+ 'Read a stored value set expansion',
+ 'Returns a page of the stored default expansion; never computes or saves an expansion. '
+ 'At type level url is required. count is capped at 1,000. Missing, unfinished or incomplete stored '
+ 'expansions return 422. Inline value sets, contexts, dates, selected designations and code system version '
+ 'overrides are not supported.', (400, 422)),
+ ('ConceptMap', '$translate'): (
+ 'Translate a code',
+ 'Returns live mappings from the named ConceptMap. Without url, uses the input system\'s own mappings '
+ 'in forward translation, or targetsystem\'s mappings in reverse; without targetsystem, reverse has no match. '
+ 'A code and system are required (or supply coding). Supports reverse translation and a target system filter. '
+ 'No match returns Parameters with result=false. A POSTed codeableConcept uses its first coding.',
+ (400, 404, 422)),
+}
+
+
+def resource_schema(*resource_types):
+ """Identify the FHIR response type without duplicating the complete FHIR resource definitions."""
+ return openapi.Schema(type=openapi.TYPE_OBJECT, required=['resourceType'], properties={
+ 'resourceType': openapi.Schema(type=openapi.TYPE_STRING, enum=list(resource_types)),
+ })
+
+
+def parameters_schema(components):
+ """One shared Parameters body, including the structured alternatives to query-string coding."""
+ definitions = components.with_scope(openapi.SCHEMA_DEFINITIONS)
+ if not definitions.has('FhirParameters'):
+ schema = resource_schema('Parameters')
+ schema.properties['parameter'] = openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Schema(
+ type=openapi.TYPE_OBJECT, required=['name'], properties={
+ 'name': openapi.Schema(type=openapi.TYPE_STRING),
+ 'valueString': openapi.Schema(type=openapi.TYPE_STRING),
+ 'valueCode': openapi.Schema(type=openapi.TYPE_STRING),
+ 'valueUri': openapi.Schema(type=openapi.TYPE_STRING),
+ 'valueBoolean': openapi.Schema(type=openapi.TYPE_BOOLEAN),
+ 'valueInteger': openapi.Schema(type=openapi.TYPE_INTEGER),
+ 'valueCoding': openapi.Schema(type=openapi.TYPE_OBJECT, properties={
+ key: openapi.Schema(type=openapi.TYPE_STRING) for key in ('system', 'code', 'version', 'display')}),
+ 'valueCodeableConcept': openapi.Schema(type=openapi.TYPE_OBJECT, properties={
+ 'coding': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_OBJECT))}),
+ }))
+ definitions.set('FhirParameters', schema)
+ return openapi.SchemaRef(definitions, 'FhirParameters')
+
+
+def operation(summary, description, parameters, response_type, errors=(400, 422)):
+ """A read with FHIR response media types and OperationOutcome errors; a token shows private content."""
+ responses = {'200': openapi.Response(response_type, schema=resource_schema(response_type))}
+ errors_by_status = {
+ 400: 'Invalid or unsupported input.', 404: 'Resource or code not found or not visible at this base.',
+ 422: 'The request cannot be processed or exceeds the operation\'s processing bound.',
+ }
+ responses.update({str(code): openapi.Response(errors_by_status[code], schema=resource_schema('OperationOutcome'))
+ for code in errors})
+ return openapi.Operation(
+ operation_id='', summary=summary, description=description, parameters=[*parameters, FORMAT],
+ responses=openapi.Responses(responses), produces=[FHIR_JSON, FHIR_XML])
+
+
+def search_operation(resource_type):
+ """Take the supported names and modifiers from the search parser itself."""
+ parameters = [query(name, SEARCH_DESCRIPTIONS[name]) for name in PARAMETERS[resource_type]]
+ modifiers = {'': 'Starts with, ignoring case and accents.', 'exact': 'Exact, including case and accents.',
+ 'contains': 'Contains, ignoring case and accents.'}
+ parameters.extend(query(f'{name}:{modifier}' if modifier else name, f'{name}: {modifiers[modifier]}')
+ for name in STRING_FIELDS for modifier in STRING_LOOKUPS)
+ parameters.extend([
+ query('_count', 'Page size, capped at 100; zero returns only the total.', openapi.TYPE_INTEGER,
+ minimum=0, maximum=MAX_COUNT, default=MAX_COUNT),
+ query('page', 'Page number, starting at 1.', openapi.TYPE_INTEGER, minimum=1, default=1),
+ ])
+ return operation(f'Search {resource_type} resources',
+ 'Returns a searchset Bundle of visible released versions, never HEAD. Entries are summaries '
+ 'tagged SUBSETTED. Commas separate alternatives; repeated parameters must all match. '
+ 'Unsupported parameters or modifiers return 400.', parameters, 'Bundle')
+
+
+def resource_operations(resource_type, base):
+ """The type's search, read and supported terminology operations, once per canonical suffix."""
+ read_parameters = [] if base == '/fhir' else [query('version', 'Released business version; never HEAD.')]
+ read_description = (
+ 'The opaque id selects one released version.' if base == '/fhir' else
+ 'The id is the repository mnemonic. Returns the latest visible release unless version is named.')
+ paths = {
+ f'/{resource_type}': {'get': search_operation(resource_type)},
+ f'/{resource_type}/{{id}}': {'get': operation(
+ f'Read a {resource_type}', read_description + ' HEAD is never returned. A cut read is tagged SUBSETTED.',
+ read_parameters, resource_type, (400, 404, 422))},
+ }
+ for (resource, name), (summary, description, errors) in OPERATION_DESCRIPTIONS.items():
+ if resource != resource_type:
+ continue
+ for instance in (False, True) if resource != 'ConceptMap' else (False,):
+ suffix = f'/{resource}/' + ('{id}/' if instance else '') + name
+ operation_errors = tuple(sorted(set(errors) | ({404} if instance else set())))
+ documented = operation(summary, description + ' ' + VERSION_SELECTION,
+ OPERATION_PARAMETERS[(resource, name)],
+ 'ValueSet' if name == '$expand' else 'Parameters', operation_errors)
+ paths[suffix] = {'get': documented, 'post': deepcopy(documented)}
+ return paths
+
+
+def capability_operations():
+ """The metadata variants share a route; $versions only supports GET."""
+ metadata = operation('Read service capabilities',
+ 'Returns CapabilityStatement, or TerminologyCapabilities for mode=terminology.',
+ [query('mode', 'Use terminology to list the code systems and versions visible at this base.',
+ enum=['terminology'])], 'CapabilityStatement')
+ metadata.responses['200'] = openapi.Response(
+ 'CapabilityStatement; TerminologyCapabilities when mode=terminology.',
+ schema=resource_schema('CapabilityStatement', 'TerminologyCapabilities'))
+ return {
+ '/metadata': {'get': metadata},
+ '/$versions': {'get': operation('List supported FHIR versions',
+ 'Returns the supported FHIR versions and the default (4.0 for R4).',
+ [], 'Parameters', (400,))},
+ }
+
+
+def path_parameters(path):
+ """Document the FHIR spelling of the route variables, independent of Django's lookup kwarg names."""
+ descriptions = {
+ 'org': 'Organization identifier.', 'user': 'User identifier.',
+ 'id': 'Opaque release id at /fhir; repository mnemonic at an owner base.',
+ }
+ return [openapi.Parameter(name, openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description=description)
+ for name, description in descriptions.items() if '{' + name + '}' in path]
+
+
+def fhir_paths(components):
+ """One source of paths for the dedicated FHIR schema and the combined API schema."""
+ paths = {}
+ body = parameters_schema(components)
+ for tag in TAGS:
+ for base in BASES:
+ templates = capability_operations() if tag == 'Capabilities' else resource_operations(tag, base)
+ for suffix, methods in templates.items():
+ path = base + suffix
+ for method, documented in methods.items():
+ documented.operation_id = 'fhir_' + '_'.join(
+ path.strip('/').replace('{', '').replace('}', '').replace('$', '').replace('-', '_').split('/')
+ ) + '_' + method
+ documented.tags = [tag]
+ documented.parameters = deepcopy(documented.parameters) + path_parameters(path)
+ if method == 'post':
+ documented.consumes = [FHIR_JSON]
+ documented.parameters.append(openapi.Parameter(
+ 'parameters', openapi.IN_BODY, schema=body,
+ description='FHIR Parameters: use the query input names with one value[x] per parameter. '
+ 'coding may use valueCoding; codeableConcept may use valueCodeableConcept '
+ '(one coding for lookup/validation). Body values take precedence over query '
+ 'values. _format stays in the query string.'))
+ paths[path] = openapi.PathItem(**methods)
+ return openapi.Paths(paths)
+
+
+class FhirSwaggerSchemaGenerator(OpenAPISchemaGenerator):
+ """Generate the curated service schema without inspecting inherited resource write methods."""
+
+ def get_schema(self, request=None, public=False): # pylint: disable=unused-argument
+ components = self.reference_resolver_class(openapi.SCHEMA_DEFINITIONS, force_init=True)
+ paths = fhir_paths(components)
+ api_url = settings.API_BASE_URL.rstrip('/') + '/swagger/'
+ info = openapi.Info(
+ title='OCL FHIR terminology service (R4)', default_version='4.0.1',
+ description='FHIR R4 terminology in JSON and XML. Serves the SVCM transactions Query Value Set (ITI-95), '
+ 'Query Code System (ITI-96), Expand Value Set (ITI-97), Lookup Code (ITI-98), '
+ 'Validate Code (ITI-99), Query Concept Map (ITI-100) and Translate Code (ITI-101). '
+ 'Bases: /fhir, /orgs/{org} and /users/{user}. The global base shows public resources; '
+ 'owner bases show resources visible to the caller. '
+ f'[OCL API Swagger]({api_url}) describes the rest of OCL.')
+ security_definitions = self.get_security_definitions()
+ return openapi.Swagger(
+ info=info, paths=paths, security_definitions=security_definitions,
+ security=self.get_security_requirements(security_definitions),
+ _url=request.build_absolute_uri('/') if request else self.url, _prefix='', **dict(components))
diff --git a/core/fhir/tests.py b/core/fhir/tests.py
index ac2ae745d..ef6f90fd6 100644
--- a/core/fhir/tests.py
+++ b/core/fhir/tests.py
@@ -1,5 +1,3 @@
-from unittest.mock import patch
-
from rest_framework.test import APIClient
from core.common.tests import OCLTestCase
@@ -21,10 +19,9 @@ def test_public_can_vew_terminology_statement(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['resourceType'], "TerminologyCapabilities")
- def test_fhir_subdomain_replaces_api_base_url(self):
- with patch('core.fhir.views.settings.FHIR_SUBDOMAIN', 'fhir'):
- with patch('core.fhir.views.settings.API_BASE_URL', 'https://api.openconceptlab.org'):
- response = self.client.get('/fhir/metadata?mode=terminology')
+ def test_implementation_url_is_the_fhir_base_of_the_request_host(self):
+ response = self.client.get(
+ '/fhir/metadata?mode=terminology', HTTP_HOST='fhir.openconceptlab.org', HTTP_X_FORWARDED_PROTO='https')
self.assertEqual(response.status_code, 200)
self.assertEqual(
diff --git a/core/fhir/tests_base.py b/core/fhir/tests_base.py
new file mode 100644
index 000000000..6e49bb2e5
--- /dev/null
+++ b/core/fhir/tests_base.py
@@ -0,0 +1,750 @@
+import datetime
+import json
+import re
+import uuid
+from decimal import Decimal
+from unittest.mock import patch
+
+from django.test import SimpleTestCase, override_settings
+from django.http import HttpResponse, JsonResponse
+from fhir.resources.R4B.bundle import Bundle
+from fhir.resources.R4B.codesystem import CodeSystem, CodeSystemConcept
+from fhir.resources.R4B.operationoutcome import OperationOutcome
+from lxml import etree
+from rest_framework.exceptions import NotAcceptable, ParseError, UnsupportedMediaType
+from rest_framework.parsers import JSONParser
+from rest_framework.renderers import JSONRenderer
+from rest_framework.permissions import AllowAny
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.test import APIRequestFactory
+from rest_framework.views import APIView
+
+from core.fhir import base, outcomes
+from core.fhir.base import FhirContentNegotiation, FhirJsonParser, FhirJsonRenderer, FhirXmlParser, \
+ FhirXmlRenderer, FhirParameters, FhirVersioning, FhirViewMixin
+
+FHIR = '{http://hl7.org/fhir}'
+BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
+
+
+def child_names(element):
+ return [etree.QName(child).localname for child in element]
+
+
+def assert_in_model_order(test, element, model_class):
+ """The element's children are elements of the R4B model, in the model's order (repeats kept together)."""
+ names, sequence = child_names(element), model_class.elements_sequence()
+ test.assertEqual([name for name in names if name not in sequence], [], 'elements the model does not define')
+ test.assertEqual(names, sorted(names, key=sequence.index))
+
+
+def released_code_system(**overrides):
+ """What CodeSystemDetailSerializer gives for a released source version, in its key order."""
+ return {
+ 'resourceType': 'CodeSystem', 'url': 'http://example.org/cs', 'title': 'Codes', 'status': 'active',
+ 'id': 'codes', 'language': 'en', 'count': 1, 'content': 'complete',
+ 'property': [{'code': 'conceptclass', 'uri': 'http://testserver/orgs/OCL/sources/Classes/concepts',
+ 'description': 'Standard list of concept classes.', 'type': 'string'}],
+ 'meta': {'lastUpdated': '2026-10-14T10:00:00.123456Z'}, 'version': 'v1',
+ 'identifier': [{'system': 'http://testserver', 'value': '/orgs/X/CodeSystem/codes/', 'type': {
+ 'text': 'Accession ID', 'coding': [{'system': 'http://hl7.org/fhir/v2/0203', 'code': 'ACSN'}]}}],
+ 'name': 'codes', 'publisher': 'X', 'revisionDate': '2026-10-14T10:00:00Z', 'experimental': False,
+ 'caseSensitive': True, 'compositional': False, 'versionNeeded': False,
+ 'collectionReference': '/orgs/X/collections/c/', 'hierarchyMeaning': 'is-a',
+ 'concept': [{'code': 'c1', 'display': 'One', 'designation': [{'language': 'en', 'value': 'One'}],
+ 'property': [{'code': 'conceptclass', 'valueString': 'Diagnosis'}]}],
+ 'text': {'status': 'generated', 'div': 'Codes
'},
+ 'filters': [{'code': 'x'}],
+ **overrides,
+ }
+
+
+def renderers():
+ return [klass() for klass in FhirViewMixin.renderer_classes]
+
+
+def parsers():
+ return [klass() for klass in FhirViewMixin.parser_classes]
+
+
+class NegotiationTest(SimpleTestCase):
+ factory = APIRequestFactory()
+
+ def select(self, path='/fhir/CodeSystem', **headers):
+ renderer, media_type = FhirContentNegotiation().select_renderer(Request(self.factory.get(path, **headers)),
+ renderers())
+ return type(renderer), media_type
+
+ def test_fhir_json_is_the_default(self):
+ self.assertEqual(self.select(), (FhirJsonRenderer, 'application/fhir+json'))
+ self.assertEqual(self.select(HTTP_ACCEPT='*/*'), (FhirJsonRenderer, 'application/fhir+json'))
+
+ def test_accept_header(self):
+ self.assertEqual(self.select(HTTP_ACCEPT='application/fhir+xml')[0], FhirXmlRenderer)
+ self.assertEqual(self.select(HTTP_ACCEPT='application/xml')[0], FhirXmlRenderer)
+ self.assertEqual(self.select(HTTP_ACCEPT='text/xml')[0], FhirXmlRenderer)
+ self.assertEqual(self.select(HTTP_ACCEPT='application/json+fhir')[0], FhirJsonRenderer)
+ self.assertEqual(self.select(HTTP_ACCEPT='application/json')[0], JSONRenderer)
+
+ def test_quality_values_are_honoured(self):
+ accept = 'application/fhir+json;q=0.5, application/xml+fhir;q=0.9'
+
+ self.assertEqual(self.select(HTTP_ACCEPT=accept)[0], FhirXmlRenderer)
+
+ def test_explicit_exclusions_override_wildcards(self):
+ """A broader range cannot make an explicitly excluded renderer acceptable."""
+ cases = [
+ ('application/fhir+json;q=0, */*;q=1', JSONRenderer),
+ ('application/json+fhir;q=0, application/json;q=0, application/*;q=1', FhirXmlRenderer),
+ ]
+ for accept, expected in cases:
+ with self.subTest(accept=accept):
+ self.assertEqual(self.select(HTTP_ACCEPT=accept)[0], expected)
+
+ def test_most_specific_range_sets_each_renderers_quality(self):
+ """Specific ranges determine quality before renderers compete with one another."""
+ cases = [
+ 'application/fhir+json;q=0.2, application/fhir+xml;q=0.5, application/json;q=0, */*;q=1',
+ 'application/fhir+xml;q=0.5, application/*;q=0, */*;q=1',
+ '*/*;q=1, application/fhir+xml;q=1',
+ ]
+ for accept in cases:
+ with self.subTest(accept=accept):
+ self.assertEqual(self.select(HTTP_ACCEPT=accept)[0], FhirXmlRenderer)
+
+ def test_type_wildcard_exclusion_overrides_universal_wildcard(self):
+ """Reject the request when all available application renderers are excluded."""
+ with self.assertRaises(NotAcceptable):
+ self.select(HTTP_ACCEPT='application/*;q=0, */*;q=1')
+
+ def test_a_malformed_quality_value_ranks_last(self):
+ self.assertEqual(self.select(HTTP_ACCEPT='application/fhir+xml;q=high, application/fhir+json')[0],
+ FhirJsonRenderer)
+
+ def test_a_browser_gets_json_rather_than_the_xml_it_also_lists(self):
+ self.assertEqual(self.select(HTTP_ACCEPT=BROWSER_ACCEPT), (JSONRenderer, 'application/json'))
+
+ def test_format_parameter_overrides_accept(self):
+ cases = [
+ ('_format=xml', FhirXmlRenderer), ('_format=json', FhirJsonRenderer),
+ ('_format=application/fhir%2Bxml', FhirXmlRenderer),
+ ('_format=application/fhir+xml', FhirXmlRenderer), # an unencoded + arrives as a space
+ ('_format=application/xml', FhirXmlRenderer), ('_format=text/xml', FhirXmlRenderer),
+ ('_format=application/json', JSONRenderer), ('format=xml', FhirXmlRenderer),
+ ]
+ for query, renderer in cases:
+ self.assertEqual(
+ self.select(f'/fhir/CodeSystem?{query}', HTTP_ACCEPT='application/fhir+json')[0], renderer, query)
+
+ def test_the_fhir_version_parameter_is_kept_on_the_accepted_media_type(self):
+ self.assertEqual(
+ self.select(HTTP_ACCEPT='application/fhir+json; fhirVersion=4.0'),
+ (FhirJsonRenderer, 'application/fhir+json; fhirVersion=4.0'))
+ self.assertEqual(
+ self.select('/fhir/CodeSystem?_format=application/fhir%2Bxml;fhirVersion=4.0'),
+ (FhirXmlRenderer, 'application/fhir+xml; fhirVersion=4.0'))
+
+ def test_unsupported_formats_are_not_acceptable(self):
+ for path, accept in (('/fhir/CodeSystem?_format=turtle', '*/*'), ('/fhir/CodeSystem', 'text/csv'),
+ ('/fhir/CodeSystem', 'application/fhir+json;q=0')):
+ with self.assertRaises(NotAcceptable, msg=(path, accept)):
+ self.select(path, HTTP_ACCEPT=accept)
+
+ def test_parser_selection_understands_fhir_media_types(self):
+ cases = [
+ ('application/fhir+json', FhirJsonParser), ('application/fhir+json; charset=utf-8', FhirJsonParser),
+ ('application/json+fhir', FhirJsonParser), ('application/json', JSONParser),
+ ('application/fhir+xml', FhirXmlParser), ('application/xml', FhirXmlParser),
+ ('text/xml; charset=utf-8', FhirXmlParser), ('application/fhir+json; fhirVersion=4.0', FhirJsonParser),
+ ]
+ for content_type, parser in cases:
+ request = Request(self.factory.post('/fhir/CodeSystem/$lookup', '{}', content_type=content_type))
+ self.assertIsInstance(FhirContentNegotiation().select_parser(request, parsers()), parser, content_type)
+
+ request = Request(self.factory.post('/fhir/CodeSystem/$lookup', 'x', content_type='text/plain'))
+ self.assertIsNone(FhirContentNegotiation().select_parser(request, parsers()))
+
+
+class VersioningTest(SimpleTestCase):
+ factory = APIRequestFactory()
+
+ def version(self, accepted_media_type, content_type=None):
+ if content_type:
+ request = Request(self.factory.post('/fhir/CodeSystem/$lookup', '{}', content_type=content_type))
+ else:
+ request = Request(self.factory.get('/fhir/CodeSystem'))
+ request.accepted_media_type = accepted_media_type
+ return FhirVersioning().determine_version(request)
+
+ def test_r4_is_served(self):
+ self.assertEqual(self.version('application/fhir+json'), '4.0')
+ self.assertEqual(self.version('application/fhir+json; fhirVersion=4.0'), '4.0')
+ self.assertEqual(self.version('application/fhir+xml; fhirVersion=4.0.1'), '4.0')
+ self.assertEqual(self.version('application/fhir+json', 'application/fhir+json; fhirVersion=4.0'), '4.0')
+
+ def test_another_version_is_refused(self):
+ with self.assertRaises(NotAcceptable):
+ self.version('application/fhir+json; fhirVersion=4.3')
+ with self.assertRaises(NotAcceptable):
+ self.version('application/fhir+json; fhirVersion=5.0')
+ with self.assertRaises(UnsupportedMediaType):
+ self.version('application/fhir+json', 'application/fhir+json; fhirVersion=5.0')
+
+
+class XmlTest(SimpleTestCase):
+ def assert_in_model_order(self, element, model_class):
+ assert_in_model_order(self, element, model_class)
+
+ def test_a_valid_resource_round_trips_through_its_model(self):
+ parameters = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'result', 'valueBoolean': True}, {'name': 'display', 'valueString': 'Malaria'},
+ {'name': 'match', 'part': [
+ {'name': 'concept', 'valueCoding': {'system': 'http://a', 'code': '1', 'display': 'One'}}]},
+ {'name': 'issues', 'resource': {'resourceType': 'OperationOutcome', 'issue': [
+ {'severity': 'error', 'code': 'invalid', 'details': {'text': 'Bad'}}]}}]}
+
+ xml = base.to_xml(parameters)
+
+ self.assertTrue(xml.startswith(b'Codes
'},
+ 'id': 'my_codes@v1',
+ }
+
+ root = etree.fromstring(base.to_xml(code_system))
+
+ self.assertEqual(root.tag, f'{FHIR}CodeSystem')
+ self.assert_in_model_order(root, CodeSystem)
+ self.assertEqual(root.find(f'{FHIR}id').get('value'), 'my_codes@v1')
+ self.assertEqual(root.find(f'{FHIR}caseSensitive').get('value'), 'true')
+ self.assertEqual(root.find(f'{FHIR}count').get('value'), '2')
+ self.assertEqual(root.find(f'{FHIR}extension').get('url'), 'http://ext')
+ self.assertEqual(root.find(f'{FHIR}extension/{FHIR}valueString').get('value'), 'x')
+ self.assertEqual(root.find(f'{FHIR}text/{{http://www.w3.org/1999/xhtml}}div/'
+ f'{{http://www.w3.org/1999/xhtml}}p').text, 'Codes')
+ concepts = root.findall(f'{FHIR}concept')
+ self.assertEqual(len(concepts), 2)
+ self.assert_in_model_order(concepts[0], CodeSystemConcept)
+ self.assertEqual(concepts[0].find(f'{FHIR}designation/{FHIR}language').get('value'), 'en')
+
+ def test_contained_resources_and_narratives_that_are_not_xhtml(self):
+ bundle = {'resourceType': 'Bundle', 'type': 'searchset', 'total': 1, 'unknownElement': 'x',
+ 'entry': [{'resource': {'resourceType': 'CodeSystem', 'id': 'a_b', 'status': 'active',
+ 'text': {'status': 'generated', 'div': 'plain Not a div
'}}},
+ {'resource': {'resourceType': 'NoSuchResource', 'id': 'x'}},
+ {'resource': 'not a resource'}]}
+
+ root = etree.fromstring(base.to_xml(bundle))
+
+ self.assertEqual(child_names(root), ['type', 'total', 'entry', 'entry']) # the other two had nothing R4
+ resource = root.find(f'{FHIR}entry/{FHIR}resource/{FHIR}CodeSystem')
+ self.assertEqual(resource.find(f'{FHIR}id').get('value'), 'a_b')
+ self.assertEqual(resource.find(f'{FHIR}text/{{http://www.w3.org/1999/xhtml}}div').text, 'plain Not a div')
+
+ def test_data_that_is_not_a_resource_is_answered_with_an_outcome(self):
+ root = etree.fromstring(base.to_xml({'versions': ['4.0']}))
+
+ self.assertEqual(root.tag, f'{FHIR}OperationOutcome')
+ self.assertEqual(base.to_xml(None), b'')
+
+ def test_invalid_xml_bodies_are_parse_errors(self):
+ for body in (b'not xml', b'', b'',
+ b''):
+ with self.assertRaises(ParseError, msg=body):
+ base.from_xml(body)
+
+ def test_entities_are_not_resolved(self):
+ body = (b']>'
+ b''
+ b'')
+ try:
+ parsed = base.from_xml(body)
+ except ParseError:
+ return
+ self.assertNotIn('root:', json.dumps(parsed))
+
+ def test_the_xml_parser_and_renderer(self):
+ body = b'' \
+ b''
+ stream = type('Stream', (), {'read': lambda self: body})()
+
+ parsed = FhirXmlParser().parse(stream)
+
+ self.assertEqual(parsed, {'resourceType': 'Parameters', 'parameter': [{'name': 'code', 'valueCode': '1'}]})
+ self.assertEqual(etree.fromstring(FhirXmlRenderer().render(parsed)).tag, f'{FHIR}Parameters')
+
+
+class XmlElementOrderTest(SimpleTestCase):
+ """FHIR XML has a fixed element order: the R4B model's, whatever the serializer's key order."""
+
+ def assert_in_model_order(self, element, model_class):
+ assert_in_model_order(self, element, model_class)
+
+ def test_a_released_code_system_comes_out_in_fhir_element_order(self):
+ xml = base.to_xml(released_code_system())
+
+ root = etree.fromstring(xml)
+ self.assert_in_model_order(root, CodeSystem)
+ self.assertEqual(child_names(root)[:4], ['id', 'meta', 'language', 'text'])
+ for name in ('revisionDate', 'collectionReference', 'filters'):
+ self.assertNotIn(name, child_names(root))
+ concept = root.find(f'{FHIR}concept')
+ self.assert_in_model_order(concept, CodeSystemConcept)
+ self.assertEqual(base.from_xml(xml)['concept'][0]['display'], 'One') # valid R4B XML
+
+ def test_a_search_bundle_holding_one_comes_out_in_fhir_element_order(self):
+ code_system = released_code_system(id='my_codes') # an owner-base id the model refuses
+ bundle = {'resourceType': 'Bundle', 'meta': {'lastUpdated': '2026-10-14T10:00:00Z'}, 'type': 'searchset',
+ 'total': 1, 'entry': [{'resource': code_system}],
+ 'link': [{'relation': 'self', 'url': 'http://testserver/fhir/CodeSystem/?page=1'}]}
+
+ root = etree.fromstring(base.to_xml(bundle))
+
+ self.assertEqual(child_names(root), ['meta', 'type', 'total', 'link', 'entry'])
+ resource = root.find(f'{FHIR}entry/{FHIR}resource/{FHIR}CodeSystem')
+ self.assert_in_model_order(resource, CodeSystem)
+ self.assertEqual(child_names(resource)[:4], ['id', 'meta', 'language', 'text'])
+ self.assertEqual(resource.find(f'{FHIR}id').get('value'), 'my_codes')
+
+ def test_bundle_children_keep_the_model_order_with_an_id(self):
+ root = etree.fromstring(base.to_xml({
+ 'resourceType': 'Bundle', 'entry': [{'resource': {'resourceType': 'CodeSystem', 'id': 'codes@1',
+ 'status': 'active', 'content': 'complete'}}],
+ 'link': [{'relation': 'self', 'url': 'http://x'}], 'total': 1, 'type': 'searchset', 'id': 'b1',
+ 'meta': {'lastUpdated': '2026-10-14T10:00:00Z'}}))
+
+ self.assertEqual(child_names(root), ['id', 'meta', 'type', 'total', 'link', 'entry'])
+
+ def test_keys_r4_does_not_define_are_dropped_at_every_depth(self):
+ code_system = {'resourceType': 'CodeSystem', 'id': 'a', 'status': 'active', 'content': 'complete',
+ 'contact': [{'name': 'Jo', 'my key': 'x', 'telecom': [{'value': 'a@b.c', 'oops': 1}]}],
+ 'concept': [{'code': 'a', 'concept_class': 'Misc'}]}
+
+ root = etree.fromstring(base.to_xml(code_system))
+
+ self.assertEqual(child_names(root.find(f'{FHIR}contact')), ['name', 'telecom'])
+ self.assertEqual(child_names(root.find(f'{FHIR}contact/{FHIR}telecom')), ['value'])
+ self.assertEqual(child_names(root.find(f'{FHIR}concept')), ['code'])
+
+ def test_each_dropped_key_is_logged_once(self):
+ base.LOGGED_DROPPED_ELEMENTS.clear()
+ with self.assertLogs('oclapi', level='WARNING') as logs:
+ base.to_xml(released_code_system())
+ base.to_xml(released_code_system())
+
+ dropped = [line for line in logs.output if 'does not define' in line]
+ self.assertEqual(len(dropped), 3, dropped)
+ self.assertTrue(any('CodeSystem.revisionDate' in line for line in dropped), dropped)
+
+ def test_values_of_the_wrong_shape_are_dropped(self):
+ outcome = {'resourceType': 'OperationOutcome', 'issue': [
+ {'severity': 'error', 'details': 'Failed to represent "/x" as CodeSystem', 'expression': 'code'}],
+ 'text': ['not', 'a', 'narrative'], 'id': {'not': 'an id'}}
+
+ root = etree.fromstring(base.to_xml(outcome))
+
+ self.assertEqual(child_names(root), ['issue'])
+ self.assertEqual(child_names(root.find(f'{FHIR}issue')), ['severity', 'expression'])
+
+ def test_a_resource_the_model_cannot_write_falls_back_to_the_element_writer(self):
+ bundle = {'resourceType': 'Bundle', 'type': 'searchset', 'entry': [{'resource': {
+ 'resourceType': 'CodeSystem', 'id': 'a', 'status': 'active', 'content': 'complete', 'experimental': False,
+ 'text': {'status': 'generated', 'div': ''},
+ 'extension': [{'url': 'http://ext', 'valueString': 'x'}], 'concept': [{'id': 'c1', 'code': 'x'}]}}]}
+ with patch.object(Bundle, 'model_dump_xml', side_effect=ValueError('boom')), \
+ self.assertLogs('oclapi', level='ERROR'):
+ root = etree.fromstring(base.to_xml(bundle))
+
+ code_system = root.find(f'{FHIR}entry/{FHIR}resource/{FHIR}CodeSystem')
+ self.assertEqual(code_system.find(f'{FHIR}id').get('value'), 'a')
+ self.assertEqual(code_system.find(f'{FHIR}experimental').get('value'), 'false')
+ self.assertEqual(code_system.find(f'{FHIR}extension').get('url'), 'http://ext')
+ self.assertEqual(code_system.find(f'{FHIR}concept').get('id'), 'c1')
+ self.assertEqual(code_system.find(f'{FHIR}concept/{FHIR}code').get('value'), 'x')
+ self.assertEqual(code_system.find(f'{FHIR}text/{{http://www.w3.org/1999/xhtml}}div/'
+ f'{{http://www.w3.org/1999/xhtml}}p').text, 'A')
+
+
+# FHIR's instant: a full date and time with seconds and a zone
+FHIR_INSTANT = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$')
+UPDATED = datetime.datetime(2026, 10, 14, 12, 34, 56, 123456, tzinfo=datetime.timezone.utc)
+
+
+def serialized_value_set(id_):
+ """What ValueSetDetailSerializer gives: meta.lastUpdated is the model's raw datetime."""
+ return {'resourceType': 'ValueSet', 'id': id_, 'version': 'v1', 'url': f'http://example.org/vs/{id_}',
+ 'status': 'active', 'meta': {'lastUpdated': UPDATED}, 'date': UPDATED}
+
+
+def json_of(data):
+ """What the JSON response carries for the same data."""
+ return json.loads(FhirJsonRenderer().render(data))
+
+
+class XmlValuesTest(SimpleTestCase):
+ """XML carries the values the JSON response carries, on both the model and the unvalidated path."""
+
+ def last_updated(self, element):
+ return element.find(f'{FHIR}meta/{FHIR}lastUpdated').get('value')
+
+ def test_a_refused_value_set_writes_its_datetimes_as_the_json_does(self):
+ data = serialized_value_set('my_vs') # an owner-base id the model refuses
+
+ root = etree.fromstring(base.to_xml(data))
+
+ expected = json_of(data)['meta']['lastUpdated']
+ self.assertEqual(self.last_updated(root), expected)
+ self.assertRegex(self.last_updated(root), FHIR_INSTANT)
+ self.assertEqual(root.find(f'{FHIR}date').get('value'), json_of(data)['date'])
+
+ def test_every_entry_of_a_bundle_holding_a_refused_one(self):
+ bundle = {'resourceType': 'Bundle', 'type': 'searchset', 'total': 2,
+ 'entry': [{'resource': serialized_value_set('good')}, {'resource': serialized_value_set('bad_id')}]}
+
+ root = etree.fromstring(base.to_xml(bundle))
+
+ expected = json_of(bundle)['entry'][0]['resource']['meta']['lastUpdated']
+ for resource in root.iterfind(f'{FHIR}entry/{FHIR}resource/{FHIR}ValueSet'):
+ self.assertEqual(self.last_updated(resource), expected)
+ self.assertRegex(self.last_updated(resource), FHIR_INSTANT)
+
+ def test_a_valid_resource_writes_the_same_instant(self):
+ data = serialized_value_set('good')
+
+ written = self.last_updated(etree.fromstring(base.to_xml(data)))
+
+ self.assertRegex(written, FHIR_INSTANT)
+ self.assertEqual(datetime.datetime.fromisoformat(written),
+ datetime.datetime.fromisoformat(json_of(data)['meta']['lastUpdated']))
+
+ def test_other_python_values_are_written_as_json_writes_them(self):
+ code = uuid.UUID('12345678-1234-5678-1234-567812345678')
+ parameters = {'resourceType': 'Parameters', 'id': 'p_1', 'parameter': [
+ {'name': 'amount', 'valueDecimal': Decimal('1.50')}, {'name': 'when', 'valueDate': UPDATED.date()},
+ {'name': 'code', 'valueString': code}]}
+
+ values = [element.get('value') for element in etree.fromstring(base.to_xml(parameters)).iter()
+ if element.get('value') and etree.QName(element).localname.startswith('value')]
+
+ self.assertEqual(values, ['1.5', '2026-10-14', str(code)])
+
+
+class XmlSingleElementTest(SimpleTestCase):
+ """A list for an element R4 allows once keeps its first item."""
+
+ def concept_map(self, identifiers):
+ return {'resourceType': 'ConceptMap', 'id': 'cm', 'status': 'active', 'identifier': identifiers}
+
+ def test_a_concept_map_keeps_its_identifier(self):
+ first = {'system': 'http://testserver', 'value': '/orgs/X/ConceptMap/cm/'}
+ for identifiers in ([first], [first, {'value': 'another'}]):
+ root = etree.fromstring(base.to_xml(self.concept_map(identifiers)))
+
+ self.assertEqual(len(root.findall(f'{FHIR}identifier')), 1, identifiers)
+ self.assertEqual(root.find(f'{FHIR}identifier/{FHIR}value').get('value'), '/orgs/X/ConceptMap/cm/')
+
+ def test_the_items_left_out_are_logged_once(self):
+ base.LOGGED_DROPPED_ELEMENTS.clear()
+ identifiers = [{'value': 'a'}, {'value': 'b'}, {'value': 'c'}]
+ with self.assertLogs('oclapi', level='WARNING') as logs:
+ base.to_xml(self.concept_map(identifiers))
+ base.to_xml(self.concept_map(identifiers))
+
+ self.assertEqual(len([line for line in logs.output if 'ConceptMap.identifier' in line]), 1, logs.output)
+
+
+class XmlCharactersTest(SimpleTestCase):
+ """Characters XML 1.0 forbids never stop a response: each becomes U+FFFD, the replacement character."""
+
+ def test_a_control_character_in_a_value_becomes_the_replacement_character(self):
+ parameters = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'display', 'valueString': 'Mal\x0baria\x00'},
+ {'name': 'name', 'valueString': 'Tab\tand\nnew line'}]}
+
+ with self.assertLogs('oclapi', level='WARNING') as logs:
+ xml = base.to_xml(parameters)
+
+ values = [element.get('value') for element in etree.fromstring(xml).iter(f'{FHIR}valueString')]
+ self.assertEqual(values, ['Mal\ufffdaria\ufffd', 'Tab\tand\nnew line'])
+ self.assertIn('2', logs.output[0])
+
+ def test_lone_surrogates_and_non_characters_in_text_and_narrative(self):
+ code_system = {'resourceType': 'CodeSystem', 'id': 'a', 'status': 'active', 'content': 'complete',
+ 'title': 'A\ud800B\uffffC', 'text': {'status': 'generated', 'div': 'x\x1fy'}}
+
+ with self.assertLogs('oclapi', level='WARNING'):
+ root = etree.fromstring(base.to_xml(code_system))
+
+ self.assertEqual(root.find(f'{FHIR}title').get('value'), 'A\ufffdB\ufffdC')
+ self.assertEqual(root.find(f'{FHIR}text/{{http://www.w3.org/1999/xhtml}}div').text, 'x\ufffdy')
+
+ def test_the_element_writer_is_safe_too(self):
+ with patch.object(CodeSystem, 'model_dump_xml', side_effect=ValueError('boom')), \
+ self.assertLogs('oclapi', level='WARNING'):
+ root = etree.fromstring(base.to_xml({'resourceType': 'CodeSystem', 'id': 'a', 'status': 'active',
+ 'content': 'complete', 'title': 'bad\x0b'}))
+
+ self.assertEqual(root.find(f'{FHIR}title').get('value'), 'bad\ufffd')
+
+
+class FhirParametersTest(SimpleTestCase):
+ factory = APIRequestFactory()
+
+ def read(self, django_request):
+ request = Request(django_request, parsers=parsers(), negotiator=FhirContentNegotiation())
+ return FhirParameters.from_request(request)
+
+ def test_query_parameters(self):
+ params = self.read(self.factory.get(
+ '/fhir/CodeSystem/$lookup?system=http://a&code=1&property=x&property=y&_format=xml&format=json&_pretty=1'))
+
+ self.assertEqual(params.get('system'), 'http://a')
+ self.assertEqual(params.getlist('property'), ['x', 'y'])
+ self.assertNotIn('_format', params)
+ self.assertIsNone(params.get('version'))
+ self.assertEqual(params.get('version', 'v1'), 'v1')
+
+ def test_a_posted_parameters_resource(self):
+ body = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'url', 'valueUri': 'http://a'}, {'name': 'activeOnly', 'valueBoolean': False},
+ {'name': 'count', 'valueInteger': 10},
+ {'name': 'coding', 'valueCoding': {'system': 'http://a', 'code': '1'}},
+ {'name': 'valueSet', 'resource': {'resourceType': 'ValueSet', 'status': 'active'}},
+ {'name': 'dependency', 'part': [{'name': 'element', 'valueUri': 'http://e'}]},
+ {'name': 'empty'}]}
+
+ params = self.read(self.factory.post(
+ '/fhir/ValueSet/$expand?_format=xml&offset=5', json.dumps(body), content_type='application/fhir+json'))
+
+ self.assertEqual(params.get('url'), 'http://a')
+ self.assertIs(params.get('activeOnly'), False)
+ self.assertEqual(params.get('count'), 10)
+ self.assertEqual(params.get('coding'), {'system': 'http://a', 'code': '1'})
+ self.assertEqual(params.get('valueSet')['resourceType'], 'ValueSet')
+ self.assertEqual(params.get('dependency').get('element'), 'http://e')
+ self.assertIsNone(params.get('empty'))
+ self.assertEqual(params.get('offset'), '5')
+ self.assertNotIn('_format', params)
+
+ def test_a_posted_xml_parameters_resource(self):
+ body = '' \
+ ''
+
+ params = self.read(self.factory.post('/fhir/CodeSystem/$lookup', body, content_type='application/fhir+xml'))
+
+ self.assertEqual(params.get('code'), '1')
+
+ def test_a_form_post(self):
+ params = self.read(self.factory.post('/fhir/CodeSystem/$lookup', {'code': '1', 'system': 'http://a'}))
+
+ self.assertEqual(params.get('code'), '1')
+ self.assertEqual(params.get('system'), 'http://a')
+
+ def test_a_post_without_a_body_reads_the_query(self):
+ params = self.read(self.factory.post('/fhir/CodeSystem/$lookup?code=1'))
+
+ self.assertEqual(params.get('code'), '1')
+
+ def test_a_posted_resource_that_is_not_parameters_is_invalid(self):
+ with self.assertRaises(outcomes.InvalidError):
+ self.read(self.factory.post(
+ '/fhir/CodeSystem/$lookup', json.dumps({'resourceType': 'CodeSystem'}),
+ content_type='application/fhir+json'))
+
+ def test_a_parameter_with_more_than_one_value_is_invalid_whatever_their_order(self):
+ """R4 gives a parameter one value[x], resource or part: two are refused, never read as whichever came first. A
+ primitive's _value[x] is its own value[x], and another's is a second one."""
+ absent = {'extension': [{'url': 'http://hl7.org/fhir/StructureDefinition/data-absent-reason',
+ 'valueCode': 'unknown'}]}
+ body = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'code', 'valueCode': 'c1', '_valueCode': {'id': 'c'}}, {'name': 'system', '_valueUri': absent}]}
+ params = self.read(self.factory.post(
+ '/fhir/CodeSystem/$lookup', json.dumps(body), content_type='application/fhir+json'))
+ self.assertEqual((params.get('code'), params.get('system')), ('c1', None))
+ for given in ({'valueString': 'c2', 'valueCode': 'c1'}, {'valueCode': 'c1', 'valueString': 'c2'},
+ {'valueCode': 'c1', '_valueString': absent}, {'_valueString': absent, 'valueCode': 'c1'},
+ {'valueUri': 'http://a', 'part': [{'name': 'x', 'valueCode': 'y'}]},
+ {'valueString': 'c1', 'resource': {'resourceType': 'ValueSet'}}):
+ for nested in (False, True):
+ parameter = {'name': 'code', **given}
+ body = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'property', 'part': [parameter]} if nested else parameter]}
+ with self.subTest((given, nested)):
+ with self.assertRaises(outcomes.InvalidError) as raised:
+ self.read(self.factory.post(
+ '/fhir/CodeSystem/$lookup', json.dumps(body), content_type='application/fhir+json'))
+ self.assertEqual(raised.exception.expression, 'code')
+
+
+class BaseUrlTest(SimpleTestCase):
+ factory = APIRequestFactory()
+
+ def test_the_base_comes_from_the_request_host(self):
+ request = self.factory.get(
+ '/orgs/CIEL/CodeSystem/', HTTP_HOST='fhir.openconceptlab.org', HTTP_X_FORWARDED_PROTO='https')
+
+ self.assertEqual(base.fhir_base_url(request), 'https://fhir.openconceptlab.org/fhir')
+ self.assertEqual(base.fhir_base_url(request, {'org': 'CIEL'}), 'https://fhir.openconceptlab.org/orgs/CIEL')
+ self.assertEqual(base.fhir_base_url(request, {'user': 'jo'}), 'https://fhir.openconceptlab.org/users/jo')
+
+ def test_fhir_paths(self):
+ for path in ('/fhir', '/fhir/', '/fhir/metadata', '/fhir/CodeSystem/$lookup/', '/orgs/CIEL/CodeSystem',
+ '/orgs/CIEL/ValueSet/x/$expand/', '/users/jo/ConceptMap/$translate'):
+ self.assertTrue(base.is_fhir_path(path), path)
+ for path in ('/', '/orgs/CIEL/', '/orgs/CIEL/sources/', '/fhirx/', '/version/', '/user/sources/CodeSystem/',
+ '/sources/x/ConceptMap/'):
+ self.assertFalse(base.is_fhir_path(path), path)
+
+
+class OutcomeResponseTest(SimpleTestCase):
+ factory = APIRequestFactory()
+ outcome = outcomes.operation_outcome(outcomes.issue('not-found', 'Nothing here'))
+
+ def test_json_by_default(self):
+ response = base.outcome_response(self.factory.get('/fhir/Patient'), self.outcome, 404)
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ self.assertEqual(json.loads(response.content), self.outcome)
+
+ def test_xml_when_asked_and_never_a_5xx(self):
+ response = base.outcome_response(
+ self.factory.get('/fhir/Patient?_format=xml'), self.outcome, 500, {'Retry-After': '1'})
+
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(response['Content-Type'], 'application/fhir+xml; charset=utf-8')
+ self.assertEqual(response['Retry-After'], '1')
+ self.assertEqual(etree.fromstring(response.content).tag, f'{FHIR}OperationOutcome')
+
+ def test_an_unsupported_format_still_gets_json(self):
+ response = base.outcome_response(self.factory.get('/fhir/Patient?_format=turtle'), self.outcome, 404)
+
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+
+ @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1000)
+ def test_unparseable_query_uses_a_400_json_outcome(self):
+ """Error negotiation must not repeat a failed query parse or retain the original error status."""
+ query = '&'.join(['_format=xml'] + ['x='] * 1001)
+ response = base.outcome_response(
+ self.factory.get(f'/fhir/missing?{query}', HTTP_ACCEPT='application/fhir+xml'),
+ self.outcome, 404, {'Retry-After': '1'})
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ self.assertEqual(response['Retry-After'], '1')
+ outcome = OperationOutcome.model_validate_json(response.content)
+ self.assertEqual(outcome.issue[0].code, 'invalid')
+ self.assertEqual(outcome.issue[0].details.coding[0].system, outcomes.TX_ISSUE_TYPE_SYSTEM)
+ self.assertEqual(outcome.issue[0].details.coding[0].code, 'invalid-data')
+
+ def test_is_outcome_response(self):
+ self.assertTrue(base.is_outcome_response(base.outcome_response(
+ self.factory.get('/fhir/x'), self.outcome, 404)))
+ self.assertTrue(base.is_outcome_response(JsonResponse(self.outcome, status=404)))
+ self.assertFalse(base.is_outcome_response(JsonResponse({'detail': 'Not found.'}, status=404)))
+ self.assertFalse(base.is_outcome_response(HttpResponse(b'Not Found
', status=404)))
+ self.assertFalse(base.is_outcome_response(HttpResponse(b'{oops', content_type='application/json')))
+
+
+class EchoView(FhirViewMixin, APIView):
+ """A FHIR view that answers with its parameters, or with the status it is asked for."""
+ permission_classes = (AllowAny,)
+ authentication_classes = ()
+
+ def get(self, request):
+ status = int(request.query_params.get('status', 200))
+ if status != 200:
+ return Response({'detail': 'As asked.'} if status == 409 else None, status=status)
+ return self.post(request)
+
+ def post(self, _request):
+ params = self.get_fhir_parameters()
+ return Response({'resourceType': 'Parameters', 'parameter': [
+ {'name': name, 'valueString': str(value)} for name, value in params.items] + [
+ {'name': 'base', 'valueUri': self.get_fhir_base_url()}]})
+
+
+class ReturnedOutcomeView(FhirViewMixin, APIView):
+ """Return the legacy serializer error shape with a configurable HTTP status."""
+ permission_classes = (AllowAny,)
+ authentication_classes = ()
+ status_code = 200
+
+ def get(self, _request):
+ """Leave normalization to the production view mixin."""
+ return Response({'resourceType': 'OperationOutcome', 'issue': [
+ {'severity': 'error', 'details': 'Failed to serialize the resource.'}]}, status=self.status_code)
+
+
+class FhirViewMixinTest(SimpleTestCase):
+ factory = APIRequestFactory()
+
+ def test_returned_outcomes_are_normalized_at_every_status(self):
+ """Successful and error statuses both retain the diagnostic in valid JSON and XML outcomes."""
+ for status, expected in ((200, 200), (201, 201), (400, 400), (503, 422)):
+ for media_type in ('application/fhir+json', 'application/fhir+xml'):
+ with self.subTest(status=status, media_type=media_type):
+ response = ReturnedOutcomeView.as_view(status_code=status)(
+ self.factory.get('/fhir/x', HTTP_ACCEPT=media_type))
+ response.render()
+
+ self.assertEqual(response.status_code, expected)
+ self.assertEqual(response['Content-Type'].split(';')[0], media_type)
+ outcome = (OperationOutcome.model_validate_xml(response.content) if media_type.endswith('xml')
+ else OperationOutcome.model_validate_json(response.content))
+ issue = outcome.issue[0]
+ self.assertEqual(issue.code, 'processing')
+ self.assertEqual(issue.details.text, 'Failed to serialize the resource.')
+ self.assertEqual(issue.details.coding[0].system, outcomes.TX_ISSUE_TYPE_SYSTEM)
+ self.assertEqual(issue.details.coding[0].code, 'invalid-data')
+
+ @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1000)
+ def test_query_parsing_failure_in_a_view_is_a_400_json_outcome(self):
+ """A query rejected during initial negotiation is a client error, even when XML was requested."""
+ query = '&'.join(['x='] * 1001)
+ response = EchoView.as_view()(self.factory.get(f'/fhir/x?{query}', HTTP_ACCEPT='application/fhir+xml'))
+ response.render()
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ outcome = OperationOutcome.model_validate_json(response.content)
+ self.assertEqual(outcome.issue[0].code, 'invalid')
+ self.assertEqual(outcome.issue[0].details.coding[0].system, outcomes.TX_ISSUE_TYPE_SYSTEM)
+ self.assertEqual(outcome.issue[0].details.coding[0].code, 'invalid-data')
+
+ def test_a_view_reads_its_parameters_the_same_way_from_get_and_post(self):
+ get = EchoView.as_view()(self.factory.get('/fhir/x?code=1'))
+ post = EchoView.as_view()(self.factory.post(
+ '/fhir/x', json.dumps({'resourceType': 'Parameters', 'parameter': [{'name': 'code', 'valueCode': '1'}]}),
+ content_type='application/fhir+json'))
+
+ self.assertEqual(get.data, post.data)
+ self.assertEqual(get.data['parameter'][0], {'name': 'code', 'valueString': '1'})
+ self.assertEqual(get.data['parameter'][1], {'name': 'base', 'valueUri': 'http://testserver/fhir'})
+
+ def test_errors_a_view_returns_go_out_as_outcomes_and_never_as_a_5xx(self):
+ for status, expected, code in ((404, 404, 'not-found'), (409, 409, 'invalid'), (503, 422, 'exception')):
+ response = EchoView.as_view()(self.factory.get(f'/fhir/x?status={status}'))
+
+ self.assertEqual(response.status_code, expected, status)
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome', status)
+ self.assertEqual(response.data['issue'][0]['code'], code, status)
diff --git a/core/fhir/tests_capabilities.py b/core/fhir/tests_capabilities.py
new file mode 100644
index 000000000..d96eab630
--- /dev/null
+++ b/core/fhir/tests_capabilities.py
@@ -0,0 +1,772 @@
+"""
+FHIR metadata at the global base /fhir/ and at owner bases: the CapabilityStatement built from the routes and views
+that exist (fhirVersion 4.0.1, each resource's interactions, search parameters and operations, $versions, SVCM's
+Terminology Repository, the software), $versions, and TerminologyCapabilities listing the code systems the base shows
+the caller, with the versions it serves and the default one, in a fixed number of queries, with the URL registry
+toggle off and on. OpenConceptLab/ocl_online#451.
+"""
+import json
+
+from django.db import connection, transaction
+from django.test import SimpleTestCase, override_settings
+from django.test.utils import CaptureQueriesContext
+from django.urls import Resolver404, include, path, resolve as resolve_path
+from fhir.resources.R4B.capabilitystatement import CapabilityStatement
+from fhir.resources.R4B.parameters import Parameters
+from fhir.resources.R4B.terminologycapabilities import TerminologyCapabilities
+from mock import patch
+from rest_framework.exceptions import ValidationError
+from rest_framework.test import APIClient
+
+from core import __version__
+from core.common.constants import ACCESS_TYPE_NONE, HEAD
+from core.common.serializers import IdentifierSerializer
+from core.common.tests import OCLTestCase, OCLAPITransactionTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir import base, search
+from core.fhir.base import FhirOperationView
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.fhir.resolve import (
+ ServedHead, UnresolvedError, in_namespace, namespace_of, ocl_path, resolve, viewable_rows, visible_versions)
+from core.fhir.tests_ops_lookup import OpsCorpusMixin, CIEL, ICD10, REST, SHARED, NEWEST_PRIVATE, HIV
+from core.fhir.tests_ops_lookup import name as concept_name
+from core.fhir.tests_search import source
+from core.orgs.tests.factories import OrganizationFactory
+from core.sources.models import Source
+from core.url_registry.factories import OrganizationURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.tests.factories import UserProfileFactory
+
+SVCM_REPOSITORY = 'https://profiles.ihe.net/ITI/SVCM/CapabilityStatement/IHE.SVCM.TerminologyRepository|1.5.1'
+OPERATION_DEFINITION = 'http://hl7.org/fhir/OperationDefinition/'
+CLOSED = 'http://moh.example/fhir/CodeSystem/closed'
+DRAFT = 'http://example.org/fhir/CodeSystem/draft'
+UNREGISTERED = 'http://example.org/fhir/CodeSystem/unregistered'
+REDIRECTED = 'http://example.org/fhir/CodeSystem/redirected'
+COLLIDE = 'http://example.org/fhir/CodeSystem/collide'
+OLD, NEW = 'http://example.org/fhir/CodeSystem/renamed', 'http://example.org/fhir/CodeSystem/renamed-new'
+RETIRED_HEAD = 'http://example.org/fhir/CodeSystem/retired-head'
+NAMELESS = '/orgs/Acme/CodeSystem/nameless/' # a code system without a canonical URL, by its OCL path
+# every R4 operation on the three resources and at the base, routed or not
+R4_OPERATIONS = {
+ 'CodeSystem': ('lookup', 'validate-code', 'subsumes', 'find-matches'),
+ 'ValueSet': ('expand', 'validate-code'),
+ 'ConceptMap': ('translate', 'closure'),
+}
+R4_SYSTEM_OPERATIONS = ('versions', 'closure', 'convert', 'meta')
+BASES = ('/fhir', '/orgs/CIEL', '/users/jdoe')
+
+
+def is_operation_view(url):
+ """Whether Django routes a path to a FHIR operation view."""
+ try:
+ view = resolve_path(url).func.view_class
+ except (Resolver404, AttributeError):
+ return False
+ return issubclass(view, FhirOperationView)
+
+
+def routed_operations(fhir_base='/fhir'):
+ """(resource type, operation) for each R4 operation Django routes to an operation view, at the type or instance
+ level."""
+ return {(resource_type, name) for resource_type, names in R4_OPERATIONS.items() for name in names
+ if is_operation_view(f'{fhir_base}/{resource_type}/${name}') or
+ is_operation_view(f'{fhir_base}/{resource_type}/some-id/${name}')}
+
+
+def code_system_path(owner, mnemonic):
+ """An org's repository's OCL path as a CodeSystem."""
+ return f'/orgs/{owner}/CodeSystem/{mnemonic}/'
+
+
+def client_for(user=None):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client
+
+
+def listed_operations(statement):
+ return {(resource['type'], operation['name']) for resource in statement['rest'][0]['resource']
+ for operation in resource.get('operation', [])}
+
+
+class CapabilityStatementTest(OCLTestCase):
+ def setUp(self):
+ super().setUp()
+ OrganizationFactory(mnemonic='CIEL')
+ UserProfileFactory(username='jdoe')
+
+ def statement(self, fhir_base='/fhir', **params):
+ response = APIClient().get(f'{fhir_base}/metadata', params)
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['resourceType'], 'CapabilityStatement')
+ return response.data
+
+ def test_fhir_version_is_r4s(self):
+ statement = self.statement()
+ self.assertEqual(statement['fhirVersion'], base.FHIR_RELEASE)
+ self.assertEqual(statement['fhirVersion'], '4.0.1')
+ self.assertEqual((statement['kind'], statement['status']), ('instance', 'active'))
+
+ def test_the_operations_are_the_routed_ones(self):
+ for fhir_base in BASES:
+ statement = self.statement(fhir_base)
+ self.assertEqual(listed_operations(statement), routed_operations(fhir_base), fhir_base)
+ for resource in statement['rest'][0]['resource']:
+ for operation in resource.get('operation', []):
+ self.assertEqual(
+ operation['definition'], f"{OPERATION_DEFINITION}{resource['type']}-{operation['name']}")
+ self.assertEqual(statement['rest'][0]['operation'], [
+ {'name': 'versions', 'definition': f'{OPERATION_DEFINITION}CapabilityStatement-versions'}])
+ self.assertTrue({('CodeSystem', 'lookup'), ('CodeSystem', 'validate-code'), ('ValueSet', 'validate-code'),
+ ('ConceptMap', 'translate')} <= listed_operations(self.statement()))
+ self.assertNotIn(('CodeSystem', 'subsumes'), listed_operations(self.statement()))
+
+ def test_system_operations_are_the_routed_ones(self):
+ routed = [name for name in R4_SYSTEM_OPERATIONS if is_operation_view(f'/fhir/${name}')]
+ self.assertEqual(routed, ['versions'])
+ self.assertEqual([operation['name'] for operation in self.statement()['rest'][0]['operation']], routed)
+
+ def test_interactions_and_search_parameters_are_the_searchs(self):
+ resources = {resource['type']: resource for resource in self.statement()['rest'][0]['resource']}
+ self.assertEqual(list(resources), ['CodeSystem', 'ValueSet', 'ConceptMap'])
+ for resource_type, resource in resources.items():
+ self.assertEqual([item['code'] for item in resource['interaction']], ['read', 'search-type'])
+ names = [item['name'] for item in resource['searchParam']]
+ self.assertEqual(names, [*search.PARAMETERS[resource_type], *search.STRING_FIELDS])
+ for item in resource['searchParam']:
+ self.assertTrue(item['definition'].startswith('http://hl7.org/fhir/SearchParameter/'), item)
+ code_system = {item['name']: item for item in resources['CodeSystem']['searchParam']}
+ self.assertEqual(code_system['url'], {
+ 'name': 'url', 'definition': 'http://hl7.org/fhir/SearchParameter/conformance-url', 'type': 'uri'})
+ self.assertEqual(code_system['_lastUpdated']['type'], 'date')
+ self.assertEqual(code_system['title']['type'], 'string')
+ self.assertEqual(code_system['code']['definition'], 'http://hl7.org/fhir/SearchParameter/CodeSystem-code')
+ concept_map = {item['name']: item['type'] for item in resources['ConceptMap']['searchParam']}
+ self.assertEqual((concept_map['source-system'], concept_map['source-uri']), ('uri', 'reference'))
+
+ def test_source_uri_and_target_uri_are_documented_as_read_as_the_systems(self):
+ """OCL keeps no source or target value set on a ConceptMap, so source-uri and target-uri are read as the
+ groups' systems, and only they carry documentation saying so."""
+ for fhir_base in BASES:
+ resources = self.statement(fhir_base)['rest'][0]['resource']
+ documented = {(resource['type'], item['name']): item['documentation']
+ for resource in resources for item in resource['searchParam'] if 'documentation' in item}
+ self.assertEqual(set(documented), {('ConceptMap', 'source-uri'), ('ConceptMap', 'target-uri')}, fhir_base)
+ for side in ('source', 'target'):
+ self.assertTrue(documented[('ConceptMap', f'{side}-uri')].startswith(f'Read as `{side}-system`'))
+
+ def test_it_instantiates_svcms_terminology_repository_and_names_the_software(self):
+ statement = self.statement()
+ self.assertEqual(statement['instantiates'], [SVCM_REPOSITORY])
+ self.assertEqual(statement['software'], {'name': 'OCL API', 'version': __version__})
+ self.assertEqual(statement['format'], ['application/fhir+json', 'application/fhir+xml'])
+ self.assertIn('date', statement)
+
+ def test_the_implementation_url_is_the_requests_base(self):
+ response = APIClient().get(
+ '/orgs/CIEL/metadata', HTTP_HOST='fhir.example.org', HTTP_X_FORWARDED_PROTO='https')
+ self.assertEqual(response.data['implementation']['url'], 'https://fhir.example.org/orgs/CIEL')
+ self.assertEqual(self.statement('/users/jdoe')['implementation']['url'], 'http://testserver/users/jdoe')
+ self.assertEqual(self.statement()['implementation']['url'], 'http://testserver/fhir')
+
+ def test_every_base_with_and_without_a_trailing_slash(self):
+ expected = self.statement()
+ for fhir_base in ('/fhir', *BASES[1:], '/orgs/no-such-org', '/users/no-such-user'):
+ for suffix in ('metadata', 'metadata/'):
+ response = APIClient().get(f'{fhir_base}/{suffix}')
+ self.assertEqual(response.status_code, 200, (fhir_base, suffix))
+ answer = response.data
+ self.assertEqual(answer['implementation']['url'], f'http://testserver{fhir_base}')
+ self.assertEqual({**answer, 'implementation': None}, {**expected, 'implementation': None}, fhir_base)
+ self.assertEqual(APIClient().get('/fhir/').data, expected)
+
+ def test_it_parses_as_r4_json_and_xml(self):
+ for fhir_base in BASES:
+ for media_type in ('application/fhir+json', 'application/fhir+xml'):
+ response = APIClient().get(f'{fhir_base}/metadata', HTTP_ACCEPT=media_type)
+ self.assertEqual(response['Content-Type'].split(';')[0], media_type)
+ statement = (CapabilityStatement.model_validate_xml(response.content) if media_type.endswith('xml')
+ else CapabilityStatement.model_validate_json(response.content))
+ self.assertEqual(statement.fhirVersion, '4.0.1')
+ self.assertEqual(len(statement.rest[0].resource), 3)
+
+ def test_get_only(self):
+ for fhir_base in BASES:
+ response = APIClient().post(f'{fhir_base}/metadata', {}, format='json')
+ self.assertEqual(response.status_code, 405, fhir_base)
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome')
+
+ def test_another_fhir_version_is_refused(self):
+ for fhir_base in BASES:
+ for suffix in ('metadata', 'metadata?mode=terminology', '$versions'):
+ response = APIClient().get(
+ f'{fhir_base}/{suffix}', HTTP_ACCEPT='application/fhir+json; fhirVersion=3.0')
+ self.assertEqual(response.status_code, 406, (fhir_base, suffix))
+ issue = response.data['issue'][0]
+ self.assertEqual(issue['code'], 'not-supported')
+ self.assertEqual(issue['details']['coding'][0]['system'], TX_ISSUE_TYPE_SYSTEM)
+ response = APIClient().get(
+ f'{fhir_base}/{suffix}', HTTP_ACCEPT='application/fhir+json; fhirVersion=4.0')
+ self.assertEqual(response.status_code, 200, (fhir_base, suffix))
+
+ def test_reads_nothing_from_the_database(self):
+ self.statement()
+ with self.assertNumQueries(0):
+ self.statement()
+ with self.assertNumQueries(0):
+ self.statement('/orgs/CIEL')
+
+
+class RoutedOperationView(FhirOperationView):
+ """An operation the statement has not heard of, routed for the test below."""
+ operation = staticmethod(lambda *args: {'resourceType': 'Parameters'})
+ adapter = staticmethod(lambda answer: answer)
+
+
+# A URL configuration with CodeSystem/$subsumes and ValueSet/$expand routed, and no ConceptMap at all
+urlpatterns = [
+ path('fhir/', include('core.fhir.urls')),
+ path('fhir/CodeSystem/$subsumes', RoutedOperationView.as_view()),
+ path('fhir/CodeSystem/', include('core.code_systems.urls')),
+ path('fhir/ValueSet/$expand', RoutedOperationView.as_view()),
+ path('fhir/ValueSet/', include('core.value_sets.urls')),
+]
+
+
+@override_settings(ROOT_URLCONF=__name__)
+class TheRoutesMakeTheStatementTest(OCLTestCase):
+ """Routing an operation lists it, and a resource type with no routes isn't listed: nothing else changes."""
+
+ def test_an_added_route_is_listed_and_a_removed_one_is_not(self):
+ statement = APIClient().get('/fhir/metadata').data
+ resources = {resource['type']: resource for resource in statement['rest'][0]['resource']}
+ self.assertEqual(list(resources), ['CodeSystem', 'ValueSet'])
+ self.assertEqual([item['name'] for item in resources['CodeSystem']['operation']],
+ ['subsumes', 'lookup', 'validate-code'])
+ self.assertIn('expand', [item['name'] for item in resources['ValueSet']['operation']])
+ self.assertEqual(listed_operations(statement), routed_operations())
+
+ capabilities = APIClient().get('/fhir/metadata', {'mode': 'terminology'}).data
+ self.assertEqual(capabilities['expansion'], {'paging': True})
+ self.assertNotIn('translation', capabilities)
+
+
+class VersionsTest(OCLTestCase):
+ def setUp(self):
+ super().setUp()
+ OrganizationFactory(mnemonic='CIEL')
+ UserProfileFactory(username='jdoe')
+
+ def test_versions_at_every_base_with_and_without_a_trailing_slash(self):
+ expected = {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'version', 'valueCode': '4.0'}, {'name': 'default', 'valueCode': '4.0'}]}
+ for fhir_base in (*BASES, '/orgs/no-such-org'):
+ for suffix in ('$versions', '$versions/'):
+ response = APIClient().get(f'{fhir_base}/{suffix}')
+ self.assertEqual((response.status_code, response.data), (200, expected), (fhir_base, suffix))
+
+ def test_the_versions_are_the_negotiated_ones(self):
+ response = APIClient().get('/fhir/$versions')
+ self.assertEqual([item['valueCode'] for item in response.data['parameter']],
+ [base.FHIR_VERSION, base.FhirVersioning.default_version])
+
+ def test_it_parses_as_r4_json_and_xml(self):
+ for media_type in ('application/fhir+json', 'application/fhir+xml'):
+ response = APIClient().get('/orgs/CIEL/$versions', HTTP_ACCEPT=media_type)
+ parameters = (Parameters.model_validate_xml(response.content) if media_type.endswith('xml')
+ else Parameters.model_validate_json(response.content))
+ self.assertEqual([(item.name, item.valueCode) for item in parameters.parameter],
+ [('version', '4.0'), ('default', '4.0')])
+
+ def test_get_only(self):
+ for fhir_base in BASES:
+ response = APIClient().post(f'{fhir_base}/$versions', {}, format='json')
+ self.assertEqual(response.status_code, 405, fhir_base)
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome')
+
+
+class OwnerBaseFhirPathTest(SimpleTestCase):
+ """An owner base's metadata and $versions are FHIR paths, so the FHIR host serves them and errors are outcomes."""
+
+ def test_owner_metadata_and_versions_are_fhir_paths(self):
+ for path_ in ('/orgs/CIEL/metadata', '/orgs/CIEL/metadata/', '/users/jo/$versions', '/users/jo/$versions/'):
+ self.assertTrue(base.is_fhir_path(path_), path_)
+ for path_ in ('/orgs/CIEL/', '/orgs/CIEL/metadata/x/', '/orgs/metadata/', '/sources/x/metadata',
+ '/users/jo/extras/'):
+ self.assertFalse(base.is_fhir_path(path_), path_)
+
+
+class OclPathAsFhirTest(SimpleTestCase):
+ """IdentifierSerializer.convert_ocl_uri_to_fhir_url: an OCL URL as FHIR names it, the repository type's segment."""
+
+ def test_only_the_repository_type_segment_is_replaced(self):
+ convert = IdentifierSerializer.convert_ocl_uri_to_fhir_url
+ for uri, resource_type, expected in (
+ ('/orgs/Acme/sources/collections/', 'CodeSystem', '/orgs/Acme/CodeSystem/collections/'),
+ ('/orgs/Acme/collections/sources/', 'ValueSet', '/orgs/Acme/ValueSet/sources/'),
+ ('/orgs/sources/sources/codes/', 'CodeSystem', '/orgs/sources/CodeSystem/codes/'),
+ ('/users/collections/collections/set/v1/', 'ValueSet', '/users/collections/ValueSet/set/'),
+ # as before for every other name
+ ('/orgs/CIEL/sources/CIEL/', 'CodeSystem', '/orgs/CIEL/CodeSystem/CIEL/'),
+ ('/orgs/CIEL/sources/CIEL/v1/concepts/1/', 'ConceptMap', '/orgs/CIEL/ConceptMap/CIEL/'),
+ ('/users/jo/collections/vs/', 'ValueSet', '/users/jo/ValueSet/vs/'),
+ ('/orgs/CIEL/CodeSystem/CIEL/', 'CodeSystem', '/orgs/CIEL/CodeSystem/CIEL/'),
+ ('http://example.org/fhir/CodeSystem/x', 'CodeSystem', 'http://example.org/fhir/CodeSystem/x'),
+ (None, 'CodeSystem', None)):
+ self.assertEqual(convert(uri, resource_type), expected, uri)
+ with self.assertRaisesMessage(ValidationError, 'given: orgs/CIEL/CodeSystem'):
+ convert('/orgs/CIEL/sources/', 'CodeSystem')
+
+
+class ResolverIdentifiersTest(SimpleTestCase):
+ """The identifiers the resolver reads as written: an OCL path back to its repository, a registry namespace."""
+
+ def test_an_ocl_path_is_one_the_resolver_reads_back_to_the_repository(self):
+ self.assertEqual(ocl_path('/orgs/Acme/sources/nameless/'), '/orgs/Acme/CodeSystem/nameless/')
+ self.assertEqual(ocl_path('/users/jo/sources/codes/'), '/users/jo/CodeSystem/codes/')
+ # too short to name a repository; read as a canonical URL; read as a mapping's URL
+ for uri in ('/orgs/Acme/', '/orgs/Acme/sources/a:b/', '/orgs/Acme/sources/mappings/'):
+ self.assertIsNone(ocl_path(uri), uri)
+
+ def test_a_registry_namespace_names_an_org_or_a_user(self):
+ head = ServedHead(1, 'codes', None, 7, 'urn:x', '/users/jo/sources/codes/', None, '/users/jo/')
+ self.assertTrue(in_namespace(head, '/users/jo/'))
+ for namespace in ('/orgs/jo/', '/users/someone/', '/', None, ''):
+ self.assertFalse(in_namespace(head, namespace), namespace)
+
+
+class Rollback(Exception):
+ """Ends a savepoint, undoing what ran in it."""
+
+
+class TerminologyCapabilitiesCases(OpsCorpusMixin): # pylint: disable=too-many-instance-attributes
+ """
+ Each repository a CodeSystem search at a base shows the caller, under the identifier the resolver serves it at
+ there (its canonical URL when the resolver picks it for that URL, else its OCL path), with the released versions it
+ serves by name, newest first, and the one it serves without a version marked isDefault; as the resolver serves them
+ were the repositories the caller isn't shown not there. On top of the operations' corpus: HL7's retired release
+ beside its live one, a member of HL7, MOH's public release of a repository MOH made private, sehatapp's copy of
+ ICD-10 (a canonical two public repositories share), an org with no sources, and these, mostly Acme's: a code system
+ without a canonical URL; one with nothing released; one the registry doesn't hold; one whose newest release has a
+ canonical URL of its own; one retired (its HEAD); a canonical Acme holds and HL7 holds too, which Acme's registry
+ sends to HL7; two of HL7's sharing a canonical, the older private; releases named 2.0+build and r%41; and a source
+ named collections.
+ """
+
+ def setUp(self): # pylint: disable=too-many-statements
+ super().setUp()
+ self.rest_retired = source(self.hl7, 'restful-interaction', '3.0.1', released=True, retired=True,
+ canonical_url=REST)
+ self.hl7_member = UserProfileFactory(username='hl7-member')
+ self.hl7.members.add(self.hl7_member)
+ self.closed = source(self.moh, 'closed', canonical_url=CLOSED, public_access=ACCESS_TYPE_NONE)
+ self.closed_v1 = source(self.moh, 'closed', 'v1', released=True, canonical_url=CLOSED)
+ self.sehatapp = OrganizationFactory(mnemonic='sehatapp')
+ source(self.sehatapp, 'ICD-10', canonical_url=ICD10)
+ source(self.sehatapp, 'ICD-10', 'v1.0', released=True, canonical_url=ICD10)
+ source(self.acme, 'nameless')
+ source(self.acme, 'nameless', 'v1', released=True)
+ source(self.acme, 'draft', canonical_url=DRAFT)
+ source(self.acme, 'draft', 'v1', canonical_url=DRAFT)
+ source(self.acme, 'unregistered', canonical_url=UNREGISTERED)
+ source(self.acme, 'unregistered', 'v1', released=True, canonical_url=UNREGISTERED)
+ source(self.acme, 'renamed', canonical_url=OLD)
+ source(self.acme, 'renamed', 'v1', released=True, canonical_url=None)
+ source(self.acme, 'renamed', 'v2', released=True, canonical_url=NEW)
+ source(self.acme, 'retired-head', canonical_url=RETIRED_HEAD, retired=True)
+ source(self.acme, 'retired-head', 'v1', released=True, canonical_url=RETIRED_HEAD)
+ source(self.acme, 'local', canonical_url=REDIRECTED)
+ source(self.acme, 'local', 'v1', released=True, canonical_url=REDIRECTED)
+ source(self.hl7, 'redirected', canonical_url=REDIRECTED)
+ source(self.hl7, 'redirected', 'r1', released=True, canonical_url=REDIRECTED)
+ OrganizationURLRegistryFactory(organization=self.acme, url=REDIRECTED, namespace=self.hl7.uri)
+ source(self.hl7, 'collide-private', canonical_url=COLLIDE, public_access=ACCESS_TYPE_NONE)
+ source(self.hl7, 'collide-private', 'p1', released=True, canonical_url=COLLIDE,
+ public_access=ACCESS_TYPE_NONE)
+ source(self.hl7, 'collide-public', canonical_url=COLLIDE)
+ source(self.hl7, 'collide-public', 'v1', released=True, canonical_url=COLLIDE)
+ plus = source(self.acme, 'plus')
+ plus_releases = [source(self.acme, 'plus', '1.0', released=True),
+ source(self.acme, 'plus', '2.0+build', released=True)]
+ ConceptFactory(parent=plus, mnemonic='P1', sources=plus_releases,
+ names=[concept_name('Plus one', preferred=True)])
+ encoded = source(self.acme, 'encoded')
+ ConceptFactory(parent=encoded, mnemonic='E1', sources=[source(self.acme, 'encoded', 'r%41', released=True)],
+ names=[concept_name('Encoded one', preferred=True)])
+ source(self.acme, 'collections')
+ source(self.acme, 'collections', 'v1', released=True)
+ OrganizationFactory(mnemonic='Empty')
+ self.users = (None, self.outsider, self.moh_member, self.hl7_member, self.jdoe, self.admin)
+
+ def capabilities(self, fhir_base='/fhir', user=None, **params):
+ response = client_for(user).get(f'{fhir_base}/metadata', {'mode': 'terminology', **params})
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['resourceType'], 'TerminologyCapabilities')
+ return response
+
+ def code_systems(self, fhir_base='/fhir', user=None):
+ """{uri: [version, with a * on the default]} of a base's TerminologyCapabilities for a caller."""
+ return {item['uri']: [version['code'] + ('*' if version.get('isDefault') else '')
+ for version in item.get('version', [])]
+ for item in self.capabilities(fhir_base, user).data.get('codeSystem', [])}
+
+ @staticmethod
+ def named(identifier, version, kwargs, user):
+ """The version, if resolve() serves it named as it is stored (a business version, as R4 lists it)."""
+ try:
+ instance = resolve('CodeSystem', identifier, version, kwargs, user)
+ except UnresolvedError:
+ return None
+ return version if instance.version == version and instance.released and not instance.is_head else None
+
+ def served(self, kwargs, user): # pylint: disable=too-many-locals
+ """
+ {identifier: (released versions served by name, sorted; the one served without a version)} by search and
+ resolve() themselves: each repository a CodeSystem search at the base shows the caller, under its canonical URL
+ if resolve() serves that repository for it, else its OCL path if it serves it there; each of its released
+ versions resolve() serves named as stored, and the one it serves without a version.
+ """
+ found = {}
+ searched = {(row.mnemonic, row.organization_id, row.user_id) for row in visible_versions(
+ 'CodeSystem', kwargs, user)}
+ for mnemonic, organization_id, user_id in searched:
+ rows = Source.objects.filter(mnemonic=mnemonic, organization_id=organization_id, user_id=user_id)
+ head = rows.get(version=HEAD)
+ owner_type, owner_id, _, repository_id = head.uri.strip('/').split('/')
+ for identifier in (head.canonical_url, f'/{owner_type}/{owner_id}/CodeSystem/{repository_id}/'):
+ try:
+ default = resolve('CodeSystem', identifier, None, kwargs, user) if identifier else None
+ except UnresolvedError:
+ continue
+ if not default or (default.mnemonic, default.organization_id, default.user_id) != (
+ mnemonic, organization_id, user_id):
+ continue
+ # the releases served by name there: the repository's own; under a canonical URL, every release of a
+ # repository with it that resolve() serves, as a version named resolves to its holder (decision 9)
+ holders = Source.objects.filter(
+ version=HEAD, canonical_url=identifier) if identifier == head.canonical_url else [head]
+ releases = Source.objects.filter(released=True, id__in=[
+ row.id for holder in holders for row in Source.objects.filter(
+ mnemonic=holder.mnemonic, organization_id=holder.organization_id, user_id=holder.user_id)])
+ versions = [self.named(identifier, version, kwargs, user) for version in set(
+ releases.exclude(version=HEAD).values_list('version', flat=True))]
+ found[identifier] = (sorted(name for name in versions if name),
+ self.named(identifier, default.version, kwargs, user))
+ break
+ return found
+
+ def served_were_hidden_missing(self, kwargs, user):
+ """served(), with the repositories the base doesn't show the caller (their HEAD) deleted, then restored."""
+ hidden = Source.objects.filter(version=HEAD).exclude(
+ id__in=viewable_rows(Source, namespace_of(kwargs), user).filter(version=HEAD).values('id'))
+ found = None
+ try:
+ with transaction.atomic():
+ for mnemonic, organization_id, user_id in hidden.values_list('mnemonic', 'organization_id', 'user_id'):
+ Source.objects.filter(mnemonic=mnemonic, organization_id=organization_id, user_id=user_id).delete()
+ found = self.served(kwargs, user)
+ raise Rollback
+ except Rollback:
+ pass
+ return found
+
+ def test_it_lists_what_the_resolver_serves_were_hidden_repositories_missing(self):
+ """
+ At every base, to every caller: each repository a CodeSystem search shows, under the identifier the operations
+ serve it at, with the released versions they serve by name and the one they serve without a version, as they
+ serve them were the repositories the caller isn't shown missing. With them there, every version listed is
+ served, but where a hidden repository of the base's owner holds the canonical too: the resolver tries it and
+ stops there (open decision A.3, Codex F2.3).
+ """
+ cases = [({}, '/fhir'), ({'user': 'jdoe'}, '/users/jdoe')] + [
+ ({'org': org}, f'/orgs/{org}') for org in ('HL7', 'MOH', 'CIEL', 'WHO', 'sehatapp', 'Acme', 'Empty')]
+ for kwargs, fhir_base in cases:
+ for user in self.users:
+ listed = self.code_systems(fhir_base, user)
+ self.assertEqual(
+ {uri: (sorted(code.rstrip('*') for code in codes),
+ next((code.rstrip('*') for code in codes if code.endswith('*')), None))
+ for uri, codes in listed.items()},
+ self.served_were_hidden_missing(kwargs, user), (fhir_base, user))
+ unserved = set()
+ for uri, codes in listed.items():
+ for code in [None, *(code.rstrip('*') for code in codes)]:
+ try:
+ resolve('CodeSystem', uri, code, kwargs, user)
+ except UnresolvedError:
+ unserved.add(uri)
+ self.assertEqual(unserved, self.hidden_collisions(fhir_base, user), (fhir_base, user))
+
+ def hidden_collisions(self, fhir_base, user):
+ """The canonicals a hidden repository of the base's owner holds that the listing serves from another one."""
+ if fhir_base == '/orgs/HL7' and user not in (self.hl7_member, self.admin):
+ return {COLLIDE}
+ return set()
+
+ def test_the_global_base(self):
+ """Every repository search shows, under its canonical URL where the resolver picks it for it, else its path."""
+ by_url = {CIEL: ['v2*', 'v1'], ICD10: ['ICD102019*'], NEWEST_PRIVATE: ['v1*'], REST: ['3.0.1', '4.0.1*'],
+ SHARED: ['v1*']}
+ # a canonical another public repository holds too lists that one's releases after its own (decision 9)
+ unregistered = {UNREGISTERED: ['v1*'], REDIRECTED: ['v1*', 'r1'], COLLIDE: ['v1*'], OLD: ['v2*', 'v1']}
+ if not self.registry:
+ by_url[ICD10] = ['ICD102019*', 'v1.0']
+ if self.registry: # what the registry doesn't hold is served by its path
+ unregistered = {
+ code_system_path('Acme', 'unregistered'): ['v1*'], code_system_path('Acme', 'local'): ['v1*'],
+ code_system_path('HL7', 'collide-public'): ['v1*'], code_system_path('Acme', 'renamed'): ['v2*', 'v1']}
+ expected = {
+ **by_url, **unregistered, NAMELESS: ['v1*'], code_system_path('sehatapp', 'ICD-10'): ['v1.0*'],
+ code_system_path('HL7', 'redirected'): ['r1*'], code_system_path('Acme', 'plus'): ['2.0+build*', '1.0'],
+ code_system_path('Acme', 'encoded'): ['r%41*'], '/orgs/Acme/CodeSystem/collections/': ['v1*']}
+ for user in self.users:
+ self.assertEqual(self.code_systems(user=user), expected, user)
+ self.assertEqual(list(self.code_systems()), sorted(expected))
+
+ def test_with_the_registry_on_only_what_the_registry_resolves(self):
+ """
+ F1.1, F2.1: at /fhir/ a canonical the global registry doesn't hold isn't served, so its repository is listed by
+ its path. F2.2: an owner base lists the owner's repositories only; one whose canonical the owner's registry
+ sends elsewhere is listed by its path, and the repository it's sent to isn't the owner's.
+ """
+ self.assertEqual(UNREGISTERED in self.code_systems(), not self.registry)
+ self.assertEqual(code_system_path('Acme', 'unregistered') in self.code_systems(), self.registry)
+ acme = self.code_systems('/orgs/Acme')
+ self.assertEqual(acme[UNREGISTERED], ['v1*'])
+ self.assertEqual((acme.get(REDIRECTED), acme.get(code_system_path('Acme', 'local'))),
+ (None, ['v1*']) if self.registry else (['v1*'], None))
+ self.assertEqual(resolve('CodeSystem', REDIRECTED, None, {'org': 'Acme'}).version,
+ 'r1' if self.registry else 'v1')
+ self.assertEqual(resolve('CodeSystem', code_system_path('Acme', 'local'), None, {'org': 'Acme'}).version, 'v1')
+ self.assertNotIn(CIEL, acme) # the global registry's: resolution falls through to it, but it isn't Acme's
+ self.assertEqual(self.code_systems('/orgs/Empty'), {})
+
+ def test_a_shared_canonical_is_the_repository_the_resolver_picks(self):
+ """
+ F1.2: WHO's ICD-10 (the older), not sehatapp's, with either toggle; F2.1: sehatapp's by its path. Without the
+ registry, sehatapp's release is also served named under the canonical, after WHO's (decision 9); with it, the
+ global entry decides.
+ """
+ listed = self.code_systems()
+ self.assertEqual((listed[ICD10], listed[code_system_path('sehatapp', 'ICD-10')]), (
+ ['ICD102019*'] if self.registry else ['ICD102019*', 'v1.0'], ['v1.0*']))
+ if self.registry:
+ with self.assertRaises(UnresolvedError):
+ resolve('CodeSystem', ICD10, 'v1.0')
+ else:
+ self.assertEqual(resolve('CodeSystem', ICD10, 'v1.0').organization, self.sehatapp)
+ self.assertEqual(resolve('CodeSystem', code_system_path('sehatapp', 'ICD-10'), 'v1.0').version, 'v1.0')
+ self.assertEqual(self.code_systems('/orgs/sehatapp'), {ICD10: ['v1.0*']})
+
+ def test_a_version_is_listed_as_stored_and_served_by_it(self):
+ """
+ F3.1: TerminologyCapabilities lists a release's business version as it is stored (2.0+build, r%41), the newest
+ the default; the operations serve it named so: GET with it encoded in the query, as HTTP does, and POST as a
+ Parameters string or a Coding's version.
+ """
+ listed = self.code_systems('/orgs/Acme')
+ self.assertEqual(listed[code_system_path('Acme', 'plus')], ['2.0+build*', '1.0'])
+ self.assertEqual(listed[code_system_path('Acme', 'encoded')], ['r%41*'])
+ for fhir_base in ('/fhir', '/orgs/Acme'):
+ for mnemonic, code, version in (('plus', 'P1', '2.0+build'), ('encoded', 'E1', 'r%41')):
+ system = code_system_path('Acme', mnemonic)
+ for operation, valid in (('$lookup', False), ('$validate-code', True)):
+ named = {'url' if valid else 'system': system, 'code': code}
+ as_coding = [{'name': 'url', 'valueUri': system}] if valid else []
+ answers = [
+ client_for().get(f'{fhir_base}/CodeSystem/{operation}', {**named, 'version': version}),
+ client_for().get(f'{fhir_base}/CodeSystem/{operation}', named), # the default
+ self.post_parameters(fhir_base, operation, [
+ {'name': key, 'valueCode' if key == 'code' else 'valueUri': value}
+ for key, value in named.items()] + [{'name': 'version', 'valueString': version}]),
+ self.post_parameters(fhir_base, operation, as_coding + [{'name': 'coding', 'valueCoding': {
+ 'system': system, 'code': code, 'version': version}}])]
+ for response in answers:
+ self.assertEqual(response.status_code, 200, (fhir_base, operation, response.data))
+ parameters = {item['name']: item for item in response.data['parameter']}
+ self.assertEqual(parameters['version']['valueString'], version, (fhir_base, operation))
+ if valid:
+ self.assertIs(parameters['result']['valueBoolean'], True, (fhir_base, operation))
+
+ @staticmethod
+ def post_parameters(fhir_base, operation, parameters):
+ return client_for().post(
+ f'{fhir_base}/CodeSystem/{operation}', json.dumps({'resourceType': 'Parameters', 'parameter': parameters}),
+ content_type='application/fhir+json')
+
+ def test_a_repository_named_like_a_repository_type_is_listed_by_its_path(self):
+ """F3.2: a source named collections, by /orgs/Acme/CodeSystem/collections/, which the operations resolve."""
+ for fhir_base, kwargs in (('/fhir', {}), ('/orgs/Acme', {'org': 'Acme'})):
+ self.assertEqual(self.code_systems(fhir_base)['/orgs/Acme/CodeSystem/collections/'], ['v1*'], fhir_base)
+ self.assertEqual(resolve('CodeSystem', '/orgs/Acme/CodeSystem/collections/', None, kwargs).version, 'v1')
+
+ def test_a_code_system_without_a_canonical_url_is_listed_by_its_ocl_path(self):
+ """F1.3: as the operations resolve it, and as $expand names its members' system."""
+ for fhir_base, kwargs in (('/fhir', {}), ('/orgs/Acme', {'org': 'Acme'})):
+ self.assertEqual(self.code_systems(fhir_base)[NAMELESS], ['v1*'], fhir_base)
+ self.assertEqual(resolve('CodeSystem', NAMELESS, None, kwargs).version, 'v1')
+ self.assertNotIn(NAMELESS, self.code_systems('/orgs/HL7')) # another owner's
+
+ def test_a_hidden_repository_of_the_owner_is_listed_as_missing(self):
+ """
+ F1.4: HL7's private collide-private is older than collide-public. To those who may view it, the resolver serves
+ it, and so does the listing. To others, the listing answers byte for byte, in as many queries, as if it
+ weren't there: collide-public's, which the resolver at HL7's base doesn't serve them (it tries the hidden one
+ and stops: open decision A.3).
+ """
+ for user in (self.hl7_member, self.admin): # and collide-public's release, served named (decision 9)
+ self.assertEqual(self.code_systems('/orgs/HL7', user)[COLLIDE], ['p1*', 'v1'], user)
+ others = (None, self.outsider, self.moh_member, self.jdoe)
+ before = {}
+ for user in others:
+ with CaptureQueriesContext(connection) as queries:
+ before[user] = (self.capabilities('/orgs/HL7', user).content, len(queries))
+ self.assertEqual(self.code_systems('/orgs/HL7', user)[COLLIDE], ['v1*'], user)
+ with self.assertRaises(UnresolvedError):
+ resolve('CodeSystem', COLLIDE, None, {'org': 'HL7'}, user)
+ Source.objects.filter(mnemonic='collide-private', organization=self.hl7).delete()
+ for user in others:
+ with CaptureQueriesContext(connection) as queries:
+ content = self.capabilities('/orgs/HL7', user).content
+ self.assertEqual((content, len(queries)), before[user], user)
+ self.assertEqual(resolve('CodeSystem', COLLIDE, None, {'org': 'HL7'}, user).version, 'v1')
+
+ def test_a_release_is_listed_under_the_url_the_resolver_serves_it_at(self):
+ """F1.5: the repository's canonical URL (its HEAD's), not a release's own (open decision DI19)."""
+ acme = self.code_systems('/orgs/Acme')
+ self.assertEqual(acme[OLD], ['v2*', 'v1'])
+ self.assertNotIn(NEW, acme)
+ self.assertEqual(resolve('CodeSystem', OLD, 'v2', {'org': 'Acme'}).version, 'v2')
+ with self.assertRaises(UnresolvedError):
+ resolve('CodeSystem', NEW, None, {'org': 'Acme'})
+
+ def test_a_retired_repository_is_not_listed(self):
+ """F1.6: the resolver serves no repository whose HEAD is retired, by canonical URL or path."""
+ for fhir_base, kwargs in (('/fhir', {}), ('/orgs/Acme', {'org': 'Acme'})):
+ listed = self.code_systems(fhir_base)
+ self.assertNotIn(RETIRED_HEAD, listed)
+ self.assertNotIn('/orgs/Acme/CodeSystem/retired-head/', listed)
+ for url in (RETIRED_HEAD, '/orgs/Acme/CodeSystem/retired-head/'):
+ with self.assertRaises(UnresolvedError):
+ resolve('CodeSystem', url, None, kwargs)
+
+ def test_an_owner_base_shows_the_owners_private_code_systems_to_those_who_may_view_them(self):
+ for user in self.users:
+ hl7 = self.code_systems('/orgs/HL7', user)
+ self.assertEqual(hl7[NEWEST_PRIVATE], ['v2*', 'v1'] if user in (self.hl7_member, self.admin) else ['v1*'])
+ moh = self.code_systems('/orgs/MOH', user)
+ self.assertEqual({url: moh.get(url) for url in (HIV, CLOSED)}, {HIV: ['v1*'], CLOSED: ['v1*']} if user in (
+ self.moh_member, self.admin) else {HIV: None, CLOSED: None}, user)
+ self.assertEqual(self.code_systems('/orgs/no-such-org', user), {}, user)
+ self.assertEqual(self.code_systems('/users/jdoe', self.jdoe)[SHARED], ['v1*'])
+ self.assertEqual(self.code_systems('/orgs/CIEL')[CIEL], ['v2*', 'v1'])
+ self.assertEqual(self.code_systems('/orgs/WHO')[ICD10], ['ICD102019*'])
+
+ def test_a_hidden_repository_answers_as_one_that_does_not_exist(self):
+ """MOH's private repositories, to those who may not view them, and at /fhir/ to everyone: the same bytes and
+ the same number of queries as when they don't exist."""
+ hidden = [('/orgs/MOH', None), ('/orgs/MOH', self.outsider), ('/orgs/MOH', self.hl7_member),
+ *[('/fhir', user) for user in self.users]]
+
+ def answers():
+ found = {}
+ for fhir_base, user in hidden:
+ with CaptureQueriesContext(connection) as queries:
+ content = self.capabilities(fhir_base, user).content
+ found[(fhir_base, user)] = (content, len(queries))
+ return found
+
+ with_them = answers()
+ self.assertNotIn(HIV.encode(), b''.join(content for content, _ in with_them.values()))
+ self.assertNotIn(CLOSED.encode(), b''.join(content for content, _ in with_them.values()))
+ Source.objects.filter(mnemonic__in=['hiv', 'closed'], organization=self.moh).delete()
+ self.assertEqual(answers(), with_them)
+
+ def test_a_fixed_number_of_queries(self):
+ """
+ The toggle, the versions search shows, the HEADs (with the registry on, its entries too); at an owner base, the
+ owner and the caller's rights over it first: whatever the number of code systems.
+ """
+ registry = 1 if self.registry else 0
+
+ def count(expected, fhir_base='/fhir', user=None):
+ with self.assertNumQueries(expected + registry):
+ return len(self.capabilities(fhir_base, user).data.get('codeSystem', []))
+
+ found = [count(3), count(4, '/orgs/HL7'), count(5, '/orgs/HL7', self.hl7_member)]
+ for number in range(30):
+ url = f'http://example.org/fhir/CodeSystem/many-{number}'
+ source(self.hl7, f'many-{number}', canonical_url=url)
+ for version in ('v1', 'v2'):
+ source(self.hl7, f'many-{number}', version, released=True, canonical_url=url)
+ self.assertEqual([count(3), count(4, '/orgs/HL7'), count(5, '/orgs/HL7', self.hl7_member)], [
+ found[0] + 30, found[1] + 30, found[2] + 30])
+
+ def test_the_blocks_are_the_routed_operations(self):
+ capabilities = self.capabilities().data
+ routed = routed_operations()
+ self.assertEqual(capabilities.get('translation'),
+ {'needsMap': False} if ('ConceptMap', 'translate') in routed else None)
+ self.assertEqual(capabilities.get('expansion'),
+ {'paging': True} if ('ValueSet', 'expand') in routed else None)
+ self.assertNotIn('validateCode', capabilities)
+
+ def test_it_describes_the_service_as_the_statement_does(self):
+ capabilities = self.capabilities('/orgs/HL7').data
+ statement = client_for().get('/orgs/HL7/metadata').data
+ for key in ('status', 'date', 'kind', 'software', 'implementation'):
+ self.assertEqual(capabilities[key], statement[key], key)
+
+ def test_it_parses_as_r4_json_and_xml(self):
+ for fhir_base, user in (('/fhir', None), ('/orgs/HL7', self.hl7_member), ('/orgs/no-such-org', None)):
+ for media_type in ('application/fhir+json', 'application/fhir+xml'):
+ response = client_for(user).get(
+ f'{fhir_base}/metadata', {'mode': 'terminology'}, HTTP_ACCEPT=media_type)
+ self.assertEqual(response['Content-Type'].split(';')[0], media_type)
+ capabilities = (
+ TerminologyCapabilities.model_validate_xml(response.content) if media_type.endswith('xml')
+ else TerminologyCapabilities.model_validate_json(response.content))
+ self.assertEqual(capabilities.kind, 'instance')
+ listed = TerminologyCapabilities.model_validate_xml(client_for().get(
+ '/fhir/metadata', {'mode': 'terminology'}, HTTP_ACCEPT='application/fhir+xml').content)
+ self.assertEqual([(item.uri, [(version.code, version.isDefault) for version in item.version])
+ for item in listed.codeSystem if item.uri in (CIEL, NAMELESS)],
+ [(NAMELESS, [('v1', True)]), (CIEL, [('v2', True), ('v1', None)])])
+
+
+class TerminologyCapabilitiesWithoutRegistryTest(TerminologyCapabilitiesCases, OCLTestCase):
+ registry = False
+
+
+class TerminologyCapabilitiesWithRegistryTest(TerminologyCapabilitiesCases, OCLTestCase):
+ registry = True
+
+ def tearDown(self):
+ set_registry_toggle(False)
+ super().tearDown()
+
+
+class TerminologyCapabilitiesTimeoutTest(OCLAPITransactionTestCase):
+ """The list's query is bounded as a search's is: one the database cancels is too costly (422), never a 5xx."""
+
+ def test_a_cancelled_statement_is_too_costly(self):
+ set_registry_toggle(False)
+ org = OrganizationFactory(mnemonic='Acme')
+ source(org, 'codes', canonical_url=DRAFT)
+ source(org, 'codes', 'v1', released=True, canonical_url=DRAFT)
+
+ def slow(*args, **kwargs): # pylint: disable=unused-argument
+ with connection.cursor() as cursor:
+ cursor.execute('SELECT pg_sleep(1)')
+ return []
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 50), patch('core.fhir.capabilities.served_repositories', slow):
+ response = APIClient().get('/fhir/metadata', {'mode': 'terminology'})
+ self.assertEqual(response.status_code, 422, response.data)
+ issue = response.data['issue'][0]
+ self.assertEqual(issue['code'], 'too-costly')
+ self.assertIn({'system': TX_ISSUE_TYPE_SYSTEM, 'code': 'too-costly'}, issue['details']['coding'])
+ self.assertEqual([item['uri'] for item in APIClient().get(
+ '/fhir/metadata', {'mode': 'terminology'}).data['codeSystem']], [DRAFT])
diff --git a/core/fhir/tests_middleware.py b/core/fhir/tests_middleware.py
new file mode 100644
index 000000000..7fdf58307
--- /dev/null
+++ b/core/fhir/tests_middleware.py
@@ -0,0 +1,272 @@
+"""The middleware's answers on FHIR paths are OperationOutcomes (OpenConceptLab/ocl_online#451)."""
+import gzip
+import json
+from unittest.mock import patch
+
+import requests
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
+from django.http import HttpResponse, JsonResponse, HttpResponseNotFound
+from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
+from fhir.resources.R4B.operationoutcome import OperationOutcome
+from lxml import etree
+from rest_framework.test import APIClient
+
+from core.common.exceptions import DeactivatedAccountLoginRefused
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.middlewares.middlewares import FhirErrorMiddleware, FhirMiddleware, RequireAuthenticationMiddleware
+from core.wsgi import application
+
+REQUIRE_AUTHENTICATION = 'core.middlewares.middlewares.RequireAuthenticationMiddleware'
+
+
+def outcome_issue(response):
+ """The first issue of an OperationOutcome response, as severity|code|tx-issue-type."""
+ outcome = json.loads(response.content)
+ issue = outcome['issue'][0]
+ tx_issue_type = next(c['code'] for c in issue['details']['coding'] if c['system'] == TX_ISSUE_TYPE_SYSTEM)
+ return f"{issue['severity']}|{issue['code']}|{tx_issue_type}"
+
+
+class FhirMiddlewareTest(SimpleTestCase):
+ factory = RequestFactory()
+
+ def answer(self, path, response=None, host='api.example.org', **headers):
+ middleware = FhirMiddleware(lambda request: response or HttpResponse('ok'))
+ return middleware(self.factory.get(path, HTTP_HOST=host, **headers))
+
+ @override_settings(FHIR_SUBDOMAIN='fhir')
+ def test_fhir_paths_off_the_fhir_host_are_a_404_outcome(self):
+ for path in ('/fhir/CodeSystem/', '/orgs/CIEL/CodeSystem/$lookup', '/users/jo/ValueSet/x/$expand/'):
+ response = self.answer(path)
+
+ self.assertEqual(response.status_code, 404, path)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json', path)
+ self.assertEqual(outcome_issue(response), 'error|not-found|not-found', path)
+
+ for path in ('/orgs/CIEL/sources/', '/user/sources/CodeSystem/'):
+ self.assertEqual(self.answer(path).content, b'ok', path)
+
+ @override_settings(FHIR_SUBDOMAIN='fhir')
+ def test_other_paths_on_the_fhir_host_are_a_404_outcome(self):
+ response = self.answer('/orgs/CIEL/sources/?q=x', host='fhir.example.org', HTTP_ACCEPT='application/fhir+xml')
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(etree.fromstring(response.content).tag, '{http://hl7.org/fhir}OperationOutcome')
+
+ for path in ('/fhir/CodeSystem/?url=http://x', '/orgs/CIEL/CodeSystem?url=http://x', '/version/'):
+ self.assertEqual(self.answer(path, host='fhir.example.org').content, b'ok', path)
+
+
+
+class FhirErrorMiddlewareTest(SimpleTestCase):
+ """The outermost middleware: an error anywhere in the stack on a FHIR path goes out as an OperationOutcome."""
+ factory = RequestFactory()
+
+ def answer(self, path, response, **headers):
+ return FhirErrorMiddleware(lambda request: response)(self.factory.get(path, **headers))
+
+ def test_is_the_outermost_middleware(self):
+ self.assertEqual(settings.MIDDLEWARE[0], 'core.middlewares.middlewares.FhirErrorMiddleware')
+
+ def test_an_error_no_fhir_view_answered_becomes_an_outcome(self):
+ response = self.answer('/fhir/Patient/', HttpResponseNotFound(b'Not Found
'))
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(outcome_issue(response), 'error|not-found|not-found')
+ self.assertEqual(
+ json.loads(response.content)['issue'][0]['details']['text'], 'Unknown FHIR path: GET /fhir/Patient/')
+
+ server_error = JsonResponse({'error': 'Server Error (500)'}, status=500)
+ server_error['Access-Control-Allow-Origin'] = '*'
+ response = self.answer('/fhir/CodeSystem/', server_error, HTTP_ACCEPT='application/fhir+xml')
+
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(response['Access-Control-Allow-Origin'], '*') # the stack's headers are kept
+ issue = etree.fromstring(response.content).find('{http://hl7.org/fhir}issue/{http://hl7.org/fhir}code')
+ self.assertEqual(issue.get('value'), 'exception')
+
+ def test_a_json_error_keeps_its_message(self):
+ response = self.answer('/orgs/CIEL/CodeSystem/', JsonResponse({'detail': 'Slow down.'}, status=429))
+
+ self.assertEqual(response.status_code, 429)
+ self.assertEqual(json.loads(response.content)['issue'][0]['details']['text'], 'Slow down.')
+
+ def test_answers_left_alone(self):
+ outcome = JsonResponse({'resourceType': 'OperationOutcome', 'issue': []}, status=404)
+ self.assertIs(self.answer('/fhir/CodeSystem/x', outcome), outcome)
+
+ compressed = HttpResponse(gzip.compress(json.dumps({'resourceType': 'OperationOutcome', 'issue': []}).encode()),
+ status=404, content_type='application/json')
+ compressed['Content-Encoding'] = 'gzip'
+ self.assertIs(self.answer('/fhir/CodeSystem/x', compressed), compressed)
+
+ not_found = HttpResponseNotFound(b'Not Found
')
+ self.assertIs(self.answer('/orgs/CIEL/sources/x/', not_found), not_found)
+ ok = HttpResponse('ok')
+ self.assertIs(self.answer('/fhir/CodeSystem/', ok), ok)
+
+
+@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1000, FHIR_SUBDOMAIN=None, REQUIRE_AUTHENTICATION=False,
+ DEBUG=False, DEBUG_PROPAGATE_EXCEPTIONS=False)
+class QueryParsingFailureTest(TestCase):
+ """Malformed query strings remain FHIR errors through the complete middleware stack."""
+
+ def test_too_many_query_fields_return_a_400_json_outcome(self):
+ """Both routed and unknown paths use safe JSON when the query cannot be parsed."""
+ query = '&'.join(['_format=xml'] + ['x='] * 1001)
+ client = APIClient(raise_request_exception=False)
+ for path in ('/fhir/missing', '/fhir/metadata'):
+ with self.subTest(path=path):
+ response = client.get(f'{path}?{query}', HTTP_ACCEPT='application/fhir+xml')
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ OperationOutcome.model_validate_json(response.content)
+ self.assertEqual(outcome_issue(response), 'error|invalid|invalid-data')
+
+
+@override_settings(FHIR_SUBDOMAIN=None, REQUIRE_AUTHENTICATION=False,
+ DEBUG=False, DEBUG_PROPAGATE_EXCEPTIONS=False)
+class MediaTypeParsingFailureTest(TestCase):
+ """Malformed MIME parameters remain client errors through the complete middleware stack."""
+
+ def assert_bad_request_outcome(self, response):
+ """Malformed media types always use safe FHIR JSON, including when XML was requested."""
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ OperationOutcome.model_validate_json(response.content)
+ self.assertEqual(outcome_issue(response), 'error|invalid|invalid-data')
+
+ def test_unknown_accept_encoding_on_unknown_path_is_a_400_json_outcome(self):
+ """An unrouted path must not fail again while negotiating its error response."""
+ response = APIClient(raise_request_exception=False).get(
+ '/fhir/missing', HTTP_ACCEPT="application/fhir+xml; foo*=bad''%41")
+
+ self.assert_bad_request_outcome(response)
+
+ def test_unknown_accept_encoding_on_routed_path_is_a_400_json_outcome(self):
+ """Initial view negotiation rejects the same malformed Accept as middleware negotiation."""
+ client = APIClient(raise_request_exception=False)
+ for path in ('/fhir/metadata', '/fhir/metadata?_format=xml'):
+ for accept in ("application/fhir+xml; foo*=bad''%41", "text/html; foo*=bad''%41, application/xml"):
+ with self.subTest(path=path, accept=accept):
+ response = client.get(path, HTTP_ACCEPT=accept)
+
+ self.assert_bad_request_outcome(response)
+
+ def test_unknown_content_type_encoding_on_post_is_a_400_json_outcome(self):
+ """Use the production request class so Content-Type parsing reaches the middleware stack."""
+ client = APIClient(raise_request_exception=False)
+ with patch('django.test.client.WSGIRequest', application.request_class):
+ for path in ('/fhir/missing', '/fhir/CodeSystem/$lookup'):
+ with self.subTest(path=path):
+ response = client.post(
+ path, b'{"resourceType":"Parameters"}',
+ content_type="application/fhir+json; foo*=bad''%41", HTTP_ACCEPT='application/fhir+xml')
+
+ self.assert_bad_request_outcome(response)
+
+ @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1000)
+ def test_too_many_query_fields_with_charset_are_a_400_json_outcome(self):
+ """Query parsing in the production request's encoding setter remains a client error."""
+ query = '&'.join(['x='] * 1001)
+ client = APIClient(raise_request_exception=False)
+ with patch('django.test.client.WSGIRequest', application.request_class):
+ response = client.get(
+ f'/fhir/metadata?{query}', content_type='application/fhir+json; charset=utf-8',
+ HTTP_ACCEPT='application/fhir+xml')
+
+ self.assert_bad_request_outcome(response)
+
+ def test_unknown_charset_name_is_ignored_as_django_does(self):
+ """An unknown charset name was never an error: Django ignores it and decodes with the default."""
+ client = APIClient(raise_request_exception=False)
+ with patch('django.test.client.WSGIRequest', application.request_class):
+ response = client.get('/fhir/metadata', content_type='application/fhir+json; charset=not-a-charset')
+
+ self.assertEqual(response.status_code, 200)
+
+
+ def test_non_text_charset_with_query_is_a_400_json_outcome(self):
+ """A failed charset is cleared and cannot break error rendering on an unknown path."""
+ client = APIClient(raise_request_exception=False)
+ with patch('django.test.client.WSGIRequest', application.request_class):
+ response = client.get(
+ '/fhir/missing?x=1', content_type='application/fhir+json; charset=base64',
+ HTTP_ACCEPT='application/fhir+xml')
+
+ self.assert_bad_request_outcome(response)
+ self.assertIsNone(response.wsgi_request.encoding)
+
+
+@override_settings(REQUIRE_AUTHENTICATION=True, APPROVED_ANONYMOUS_CLIENTS=[], APPROVED_ANONYMOUS_API_KEYS=[],
+ APPROVED_ANONYMOUS_IPS=[], MIDDLEWARE=[
+ *settings.MIDDLEWARE[:settings.MIDDLEWARE.index(
+ 'django.contrib.auth.middleware.AuthenticationMiddleware') + 1],
+ REQUIRE_AUTHENTICATION,
+ *[name for name in settings.MIDDLEWARE[settings.MIDDLEWARE.index(
+ 'django.contrib.auth.middleware.AuthenticationMiddleware') + 1:]
+ if name != REQUIRE_AUTHENTICATION]])
+class OuterMiddlewareFailureTest(TestCase):
+ """An outer middleware failing on a FHIR path (the sign-in provider down) is not a 5xx."""
+
+ @patch('core.middlewares.middlewares.OCLAuthentication.authenticate',
+ side_effect=requests.exceptions.ConnectionError('sign-in provider unreachable'))
+ def test_an_outer_middleware_error_on_a_fhir_path_is_a_4xx_outcome(self, _authenticate):
+ client = APIClient(raise_request_exception=False)
+ with self.assertLogs('django.request', level='ERROR'):
+ response = client.get('/orgs/CIEL/CodeSystem/$lookup?system=http://x&code=1', HTTP_AUTHORIZATION='Bearer x')
+
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(outcome_issue(response), 'error|exception|invalid-data')
+
+ with self.assertLogs('django.request', level='ERROR'):
+ response = client.get('/orgs/CIEL/sources/', HTTP_AUTHORIZATION='Bearer x')
+
+ self.assertEqual(response.status_code, 500) # other paths keep Django's answer
+ self.assertEqual(json.loads(response.content), {'error': 'Server Error (500)'})
+
+
+@override_settings(REQUIRE_AUTHENTICATION=True, APPROVED_ANONYMOUS_CLIENTS=[], APPROVED_ANONYMOUS_API_KEYS=[],
+ APPROVED_ANONYMOUS_IPS=[])
+class RequireAuthenticationOnFhirPathsTest(SimpleTestCase):
+ factory = RequestFactory()
+
+ def answer(self, path, **headers):
+ request = self.factory.get(path, **headers)
+ request.user = AnonymousUser()
+ return RequireAuthenticationMiddleware(lambda request: HttpResponse('ok'))(request)
+
+ def test_anonymous_access_to_an_owner_base_is_a_403_outcome(self):
+ response = self.answer('/orgs/CIEL/CodeSystem/$lookup?system=x&code=1')
+
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(outcome_issue(response), 'error|login|invalid-data')
+ issue = json.loads(response.content)['issue'][0]
+ self.assertEqual(issue['details']['text'], RequireAuthenticationMiddleware.forbidden_response['detail'])
+ self.assertNotIn('diagnostics', issue)
+
+ def test_other_paths_keep_their_json_403(self):
+ response = self.answer('/orgs/CIEL/sources/')
+
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(json.loads(response.content), RequireAuthenticationMiddleware.forbidden_response)
+
+ def test_the_global_fhir_base_stays_open(self):
+ self.assertEqual(self.answer('/fhir/CodeSystem/$lookup?system=x&code=1').content, b'ok')
+
+ def test_a_refused_sign_in_on_a_fhir_path_is_an_outcome(self):
+ middleware = RequireAuthenticationMiddleware(lambda request: HttpResponse('ok'))
+ middleware.is_request_allowed = lambda request: (_ for _ in ()).throw(DeactivatedAccountLoginRefused())
+
+ request = self.factory.get('/orgs/CIEL/CodeSystem/', HTTP_AUTHORIZATION='Bearer x')
+ response = middleware(request)
+
+ self.assertEqual(response.status_code, 401)
+ self.assertEqual(response['WWW-Authenticate'], 'Bearer realm="api"')
+ self.assertEqual(outcome_issue(response), 'error|login|invalid-data')
+
+ response = middleware(self.factory.get('/orgs/CIEL/sources/', HTTP_AUTHORIZATION='Bearer x'))
+ self.assertEqual(response.status_code, 401)
+ self.assertIn('detail', json.loads(response.content))
diff --git a/core/code_systems/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
similarity index 61%
rename from core/code_systems/tests_operation_bounds.py
rename to core/fhir/tests_operation_bounds.py
index f000b9ffe..ccb81a586 100644
--- a/core/code_systems/tests_operation_bounds.py
+++ b/core/fhir/tests_operation_bounds.py
@@ -2,8 +2,9 @@
FHIR operations read only the concepts and mappings their input names (OpenConceptLab/ocl_online#451).
A $validate-code or $translate without a code, or without the system the code is in, is a 400 OperationOutcome,
-never a read of every concept or mapping. A POSTed Parameters resource is read with the value types R4 gives each
-input, so a code sent as valueString is still the code.
+never a read of every concept or mapping. A $expand reads the stored expansion its value set names, one page of it at a
+time, and without a value set it is a 400 that reads none. A POSTed Parameters resource is read with the value types R4
+gives each input, so a code sent as valueString is still the code.
"""
import json
import re
@@ -16,25 +17,28 @@
from rest_framework.response import Response
from rest_framework.test import APIClient
-from core.code_systems.serializers import ValidateCodeParametersSerializer
from core.collections.models import CollectionReference
from core.collections.tests.factories import OrganizationCollectionFactory, ExpansionFactory
from core.common.mixins import ListWithHeadersMixin
from core.common.tests import OCLTestCase
from core.common.views import BaseAPIView
+from core.concepts.models import Concept
from core.concepts.tests.factories import ConceptFactory
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
from core.mappings.models import Mapping
from core.mappings.tests.factories import MappingFactory
from core.orgs.tests.factories import OrganizationFactory
from core.sources.models import Source
from core.sources.tests.factories import OrganizationSourceFactory
+from core.url_registry.tests_resolution import set_registry_toggle
SYSTEM = 'http://example.org/fhir/CodeSystem/bounds'
VALUE_SET = 'http://example.org/fhir/ValueSet/bounds'
DISPLAY = 'Malaria'
WRONG_DISPLAY = 'Not the display of c1'
VALUE_TYPES = {'url': 'valueUri', 'system': 'valueUri', 'code': 'valueCode', 'display': 'valueString',
- 'systemVersion': 'valueString'}
+ 'systemVersion': 'valueString', 'count': 'valueInteger', 'offset': 'valueInteger',
+ 'activeOnly': 'valueBoolean', 'filter': 'valueString'}
# A statement that reads concept or mapping rows: that table is its own, not a joined one
ROW_READ = re.compile(r'^SELECT .+? FROM "(concepts|mappings)"(?: |$)')
# A literal equality, or a literal IN list, on a column that names rows: a code, an id, or the concept whose
@@ -65,10 +69,31 @@ def unbounded_reads(queries):
return [sql for sql in row_reads(queries) if not NAMED_ROWS.search(sql)]
+def unbounded_expansion_reads(queries, expansion_id):
+ """
+ The statements a $expand runs that read concept rows or their names other than those of the expansion named, or
+ more than 1,000 of them at once: each must be limited to that expansion (in the statement or in the subquery that
+ chooses its page), or name its rows by id.
+ """
+ unbounded = []
+ for query in queries:
+ sql = query['sql']
+ if not re.match(r'^SELECT .+? FROM "(concepts|concept_names)"(?: |$)', sql):
+ continue
+ alias = re.search(r'"collection_expansions_concepts" ([A-Z]\d+) ON', sql)
+ table = f'(?:"collection_expansions_concepts"|{alias[1]})' if alias else '"collection_expansions_concepts"'
+ expansion = re.search(rf'{table}\."expansion_id" = {expansion_id}\b', sql)
+ named = NAMED_ROWS.search(sql) or re.search(r'"concept_id" IN \(\d', sql)
+ if not (expansion or named) or any(int(limit) > 1000 for limit in re.findall(r'LIMIT (\d+)', sql)):
+ unbounded.append(sql)
+ return unbounded
+
+
class FhirOperationBoundsTest(OCLTestCase):
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def setUp(self):
+ set_registry_toggle(False) # the operations resolve through core.fhir.resolve: no registry, as in production
super().setUp()
self.org = OrganizationFactory(mnemonic='BoundsOrg')
self.source = OrganizationSourceFactory(organization=self.org, mnemonic='bounds', canonical_url=SYSTEM)
@@ -90,6 +115,8 @@ def setUp(self):
self.collection.add_references([CollectionReference(
expression=self.concept.uri, collection=self.collection, code='c1', system=self.source.uri, version='v1')])
self.collection_v1.seed_references()
+ self.collection_v1.expansion.concepts.add( # as the release's expansion holds it: the code system release's c1
+ *Concept.objects.filter(sources=self.source_v1, mnemonic='c1'))
self.client = APIClient()
def request(self, path, method, headers=None, **inputs):
@@ -103,7 +130,7 @@ def request(self, path, method, headers=None, **inputs):
parameters = [{'name': name, ('valueString' if 'valueString' in method else VALUE_TYPES[name]): value}
for name, value in inputs.items()]
response = self.client.post(path, json.dumps({'resourceType': 'Parameters', 'parameter': parameters}),
- content_type='application/json')
+ content_type='application/fhir+json')
return response, context.captured_queries
def assert_required(self, response, name, msg):
@@ -112,6 +139,7 @@ def assert_required(self, response, name, msg):
self.assertEqual(outcome['resourceType'], 'OperationOutcome', msg)
self.assertEqual(outcome['issue'][0]['code'], 'invalid', msg)
self.assertEqual(outcome['issue'][0]['expression'], [name], msg)
+ self.assertEqual(outcome['issue'][0]['details']['coding'][0]['system'], TX_ISSUE_TYPE_SYSTEM, msg)
def assert_result(self, response, expected, msg):
self.assertEqual(response.status_code, 200, msg)
@@ -195,6 +223,39 @@ def test_lookup_reads_only_the_named_code(self):
self.assertEqual(unbounded_reads(queries), [], (system, code))
self.assertEqual(response.status_code, 200 if system and code else 400, (system, code))
+ def test_codings_and_instance_routes_read_only_the_named_code(self):
+ """The Coding inputs and the instance-level routes read only the code they name too; without one, a 400."""
+ operations = [
+ ('/fhir/CodeSystem/$lookup', None), ('/fhir/CodeSystem/$validate-code', None),
+ ('/fhir/ValueSet/$validate-code', VALUE_SET),
+ (f'/fhir/CodeSystem/{self.source_v1.id}/$lookup', None),
+ (f'/fhir/CodeSystem/{self.source_v1.id}/$validate-code', None),
+ (f'/orgs/{self.org.mnemonic}/ValueSet/boundsvs/$validate-code', None),
+ ]
+ inputs = [('coding', 'valueCoding', lambda coding: coding),
+ ('codeableConcept', 'valueCodeableConcept', lambda coding: {'coding': [coding]})]
+ for (path, url), (name, value_type, wrap), code in product(operations, inputs, (None, 'c1')):
+ msg = (path, name, code)
+ with self.subTest(msg):
+ coding = {'system': SYSTEM, **({'code': code} if code else {})}
+ parameters = [{'name': name, value_type: wrap(coding)}]
+ if url:
+ parameters.append({'name': 'url', 'valueUri': url})
+ with CaptureQueriesContext(connection) as context:
+ response = self.client.post(
+ path, json.dumps({'resourceType': 'Parameters', 'parameter': parameters}),
+ content_type='application/fhir+json')
+
+ self.assertEqual(unbounded_reads(context.captured_queries), [], msg)
+ if code:
+ self.assertEqual(response.status_code, 200, msg)
+ else:
+ self.assert_required(response, name, msg)
+ for path in ('/fhir/CodeSystem/$lookup', '/fhir/CodeSystem/$validate-code'):
+ response, queries = self.request(path, 'GET', coding=f'{SYSTEM}|c1') # a GET's Coding: system|code
+ self.assertEqual(unbounded_reads(queries), [], path)
+ self.assertEqual(response.status_code, 200, path)
+
def test_operations_take_no_list_shortcut(self):
"""A search, sort, facets, search stats or CSV export never stands in for an operation's own checks."""
operations = [
@@ -235,19 +296,63 @@ def test_operations_take_no_list_shortcut(self):
self.assertEqual(response.status_code, 400, msg)
self.assertEqual({name: mock.call_count for name, mock in mocks.items()}, dict.fromkeys(mocks, 0))
- def test_parameters_are_read_with_the_value_types_r4_allows(self):
- serializer = ValidateCodeParametersSerializer(data={'resourceType': 'Parameters', 'parameter': [
- {'name': 'url', 'valueString': SYSTEM},
- {'name': 'code', 'valueString': 'c1'},
- {'name': 'display', 'valueString': DISPLAY},
- {'name': 'version', 'valueCode': 'v1'},
- {'name': 'system', 'valueBoolean': True},
- ]})
-
- self.assertTrue(serializer.is_valid())
- self.assertEqual(serializer.validated_data['parameters'], {'url': SYSTEM, 'code': 'c1', 'display': DISPLAY})
-
- serializer = ValidateCodeParametersSerializer(data={'resourceType': 'Parameters', 'parameter': [
- {'name': 'code', 'valueCode': 'c1', 'valueString': 'c2'}]})
- self.assertTrue(serializer.is_valid())
- self.assertEqual(serializer.validated_data['parameters'], {'code': 'c1'})
+ def test_expand_reads_only_the_expansion_named(self):
+ """$expand reads the concepts of the stored expansion its value set names, a page of them at a time; without
+ a value set it is a 400 that reads no concept, GET or POST, whatever value types the inputs come in."""
+ expansion_id = self.collection_v1.expansion.id
+ typed = {'count': 1, 'offset': 0, 'activeOnly': True, 'filter': 'mal'}
+ for method, path, url, names in product(
+ ('GET', 'POST', 'POST valueString'),
+ ('/fhir/ValueSet/$expand', f'/orgs/{self.org.mnemonic}/ValueSet/boundsvs/$expand'), (None, VALUE_SET),
+ ((), ('count', 'offset'), ('filter', 'activeOnly'))):
+ values = {name: typed[name] if method == 'POST' else str(typed[name]).lower() for name in names}
+ msg = (method, path, url, names)
+ with self.subTest(msg):
+ response, queries = self.request(path, method, url=url, **values)
+
+ self.assertEqual(unbounded_expansion_reads(queries, expansion_id), [], msg)
+ if not url and '/fhir/' in path:
+ self.assert_required(response, 'url', msg)
+ self.assertEqual(row_reads(queries), [], msg)
+ else:
+ self.assertEqual(response.status_code, 200, msg)
+ self.assertEqual(
+ [member['code'] for member in response.data['expansion'].get('contains', [])], ['c1'], msg)
+ self.assertTrue(row_reads(queries), msg) # the concepts it read were checked
+
+ def test_expand_inputs_it_refuses_read_nothing(self):
+ """An $expand input that would need an expansion computed for the request is a 400 before anything is read."""
+ for name in ('date', 'exclude-system', 'system-version', 'check-system-version', 'force-system-version',
+ 'designation', 'contextDirection', 'context', 'includeDefinition', 'excludePostCoordinated'):
+ for method in ('GET', 'POST valueString'):
+ response, queries = self.request(
+ '/fhir/ValueSet/$expand', method, url=VALUE_SET,
+ **{name: 'true' if name in ('includeDefinition', 'excludePostCoordinated') else SYSTEM})
+ self.assertEqual(response.status_code, 400, (name, method))
+ self.assertEqual(response.data['issue'][0]['expression'], [name], (name, method))
+ self.assertEqual(row_reads(queries), [], (name, method))
+
+ def test_expand_takes_no_list_shortcut(self):
+ """A search, sort, facets, search stats or CSV export never stands in for $expand's own reading."""
+ shortcuts = {
+ (BaseAPIView, 'get_search_results_qs'): (Concept.objects.none(), {}, None, {}),
+ (BaseAPIView, 'get_fuzzy_search_results_qs'): (Concept.objects.none(), {}, None, {}),
+ (BaseAPIView, 'get_facets'): {},
+ (BaseAPIView, 'get_search_stats'): {},
+ (ListWithHeadersMixin, 'get_csv'): Response({'url': 'https://example.org/export.csv'}),
+ }
+ mocks = {name: Mock(return_value=value) for (_, name), value in shortcuts.items()}
+ for (klass, name), mock in zip(shortcuts, mocks.values()):
+ patcher = patch.object(klass, name, mock)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ expected, _ = self.request('/fhir/ValueSet/$expand', 'GET', url=VALUE_SET)
+
+ for params, headers in LIST_SHORTCUTS:
+ with self.subTest((params, headers)):
+ response, queries = self.request(
+ '/fhir/ValueSet/$expand', 'GET', headers=headers, url=VALUE_SET, **params)
+
+ self.assertEqual(unbounded_expansion_reads(queries, self.collection_v1.expansion.id), [])
+ self.assertEqual(json.loads(response.content), json.loads(expected.content), (params, headers))
+ self.assertEqual({name: mock.call_count for name, mock in mocks.items()}, dict.fromkeys(mocks, 0))
diff --git a/core/fhir/tests_ops_concept_versions.py b/core/fhir/tests_ops_concept_versions.py
new file mode 100644
index 000000000..2a5f88999
--- /dev/null
+++ b/core/fhir/tests_ops_concept_versions.py
@@ -0,0 +1,241 @@
+"""
+core.fhir.ops: the release a value set member is reported in, when the stored expansion holds a concept's
+versioned-object row, which only HEAD holds. It carries the concept's newest content, so the newest live release the
+caller is shown that holds another, not deleted, version answers, whatever the expansion pins. A member a release
+holds itself follows the explicit pin, then the evaluated pin, then the newest holding release. $expand
+(contains.version, used-codesystem) and ValueSet $validate-code (version, and systemVersion naming a holding release)
+agree, with the URL registry off and on.
+"""
+import re
+
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase
+from core.concepts.models import Concept
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.tests_operation_bounds import unbounded_expansion_reads
+from core.fhir.tests_ops_lookup import OpsCorpusMixin, name
+from core.fhir.tests_search import collection, source
+from core.orgs.tests.factories import OrganizationFactory
+from core.sources.models import Source
+from core.url_registry.factories import GlobalURLRegistryFactory
+
+DEMO = 'http://example.org/fhir/CodeSystem/demo'
+DEMO_SET = 'http://example.org/fhir/ValueSet/demo-set'
+
+
+class ConceptVersionCases(OpsCorpusMixin):
+ """
+ Acme's code system demo has releases 1 and 2 and the private release 3. Codes A and B each have a versioned-object
+ row, which HEAD holds, and a version row in each release. The value set demo-set 1's stored expansion holds the
+ versioned-object rows.
+ """
+ def setUp(self):
+ super().setUp()
+ self.demo = source(self.acme, 'demo', canonical_url=DEMO)
+ self.demo_v1 = source(self.acme, 'demo', '1', released=True, canonical_url=DEMO)
+ self.demo_v2 = source(self.acme, 'demo', '2', released=True, canonical_url=DEMO)
+ self.demo_v3 = source(self.acme, 'demo', '3', released=True, canonical_url=DEMO,
+ public_access=ACCESS_TYPE_NONE)
+ self.roots = {}
+ for code in ('A', 'B'):
+ root = ConceptFactory(parent=self.demo, mnemonic=code, names=[name(f'Code {code}', preferred=True)])
+ for release in (self.demo_v1, self.demo_v2, self.demo_v3):
+ ConceptFactory(parent=self.demo, mnemonic=code, version=f'{code}-{release.version}',
+ versioned_object=root, sources=[release],
+ names=[name(f'Code {code} in {release.version}', preferred=True)])
+ self.roots[code] = root
+ self.value_set = collection(self.acme, 'demo-set', '1', released=True, canonical_url=DEMO_SET)
+ collection(self.acme, 'demo-set', canonical_url=DEMO_SET)
+ self.stored = ExpansionFactory(mnemonic='autoexpand-1', collection_version=self.value_set)
+ self.stored.concepts.add(*self.roots.values())
+ self.stored.explicit_source_versions.add(self.demo_v1)
+ self.value_set.expansion_uri = self.stored.uri
+ self.value_set.save()
+ if self.registry:
+ for url in (DEMO, DEMO_SET):
+ GlobalURLRegistryFactory(url=url, namespace=self.acme.uri)
+
+ def expanded(self, base='/fhir', user=None, **params):
+ """The expansion answered at this base, with the given paging and filtering inputs."""
+ response = self.get('$expand', base=base, user=user, resource='ValueSet', url=DEMO_SET, **params)
+ self.assertEqual(response.status_code, 200, response.data)
+ return response.data['expansion']
+
+ def members(self, base='/fhir', user=None):
+ return [(member['code'], member.get('version')) for member in self.expanded(base, user)['contains']]
+
+ def used(self, base='/fhir', user=None):
+ return [item['valueUri'] for item in self.expanded(base, user)['parameter']
+ if item['name'] == 'used-codesystem']
+
+ def validated(self, system=DEMO, **params):
+ response = self.get('$validate-code', resource='ValueSet', url=DEMO_SET, system=system, **params)
+ self.assertEqual(response.status_code, 200, response.data)
+ return {item['name']: next(value for key, value in item.items() if key != 'name')
+ for item in response.data['parameter'] if item['name'] in ('result', 'version', 'display')}
+
+ def test_a_versioned_object_member_is_reported_in_the_newest_holding_release_the_caller_is_shown(self):
+ self.assertEqual(self.members(), [('A', '2'), ('B', '2')])
+ self.assertEqual(self.used(), [f'{DEMO}|2'])
+
+ def test_pins_do_not_change_the_release_of_a_versioned_object_member(self):
+ for pinned in ('explicit', 'evaluated', 'none'):
+ with self.subTest(pinned=pinned):
+ self.stored.explicit_source_versions.clear()
+ self.stored.evaluated_source_versions.clear()
+ if pinned == 'explicit':
+ self.stored.explicit_source_versions.add(self.demo_v1)
+ elif pinned == 'evaluated':
+ self.stored.evaluated_source_versions.add(self.demo_v1)
+ self.assertEqual(self.members(), [('A', '2'), ('B', '2')])
+ self.assertEqual(self.used(), [f'{DEMO}|2'])
+ self.assertEqual(self.validated(code='A'), {'result': True, 'version': '2', 'display': 'Code A'})
+
+ def test_a_release_the_caller_is_not_shown_is_never_reported(self):
+ self.stored.explicit_source_versions.clear()
+ self.stored.explicit_source_versions.add(self.demo_v3)
+ for user in (None, self.outsider):
+ self.assertEqual(self.members(user=user), [('A', '2'), ('B', '2')])
+ self.assertEqual(self.members(base='/orgs/Acme', user=self.admin), [('A', '3'), ('B', '3')])
+
+ def test_validate_code_answers_the_release_and_finds_the_code_in_it(self):
+ """The version is the release's; the concept, and so the display, is the member's, as $expand shows it."""
+ self.assertEqual(self.validated(code='A'), {'result': True, 'version': '2', 'display': 'Code A'})
+ for version in ('1', '2'):
+ self.assertEqual(self.validated(code='A', systemVersion=version)['result'], True)
+ self.assertEqual(self.validated(code='A', systemVersion='3')['result'], False) # not shown
+
+ def test_a_coding_expand_returns_validates(self):
+ for member in self.expanded()['contains']:
+ self.assertEqual(self.validated(system=member['system'], code=member['code'],
+ systemVersion=member['version'], display=member['display']),
+ {'result': True, 'version': member['version'], 'display': member['display']})
+
+ def test_a_deleted_version_of_the_concept_is_never_the_release(self):
+ """A release whose version of the concept is deleted doesn't hold it: the next in order answers."""
+ Concept.objects.filter(versioned_object=self.roots['A'], sources=self.demo_v2).update(is_active=False)
+ self.assertEqual(self.members(), [('A', '1'), ('B', '2')])
+ self.assertEqual(self.validated(code='A', systemVersion='2')['result'], False)
+ self.assertEqual(self.validated(code='A'), {'result': True, 'version': '1', 'display': 'Code A'})
+
+ def test_a_member_a_release_holds_itself_answers_as_before(self):
+ """A version row stays in the release that holds it, though the expansion pins another one."""
+ held = ConceptFactory(parent=self.demo, mnemonic='C', sources=[self.demo_v2],
+ names=[name('Code C', preferred=True)])
+ ConceptFactory(parent=self.demo, mnemonic='C', version='C-1', versioned_object=held, sources=[self.demo_v1],
+ names=[name('Code C in 1', preferred=True)])
+ self.stored.concepts.add(held)
+ self.assertEqual(self.members()[-1], ('C', '2'))
+ self.assertEqual(self.validated(code='C'), {'result': True, 'version': '2', 'display': 'Code C'})
+ self.assertEqual(self.validated(code='C', systemVersion='1')['result'], False) # 1 holds another version
+ self.assertEqual(self.validated(code='C', systemVersion='2')['result'], True)
+
+ def test_a_member_its_release_holds_outranks_another_concepts_pinned_version(self):
+ """Two members share the canonical, code and release name: the one a release holds itself answers first."""
+ other = OrganizationFactory(mnemonic='Other')
+ source(other, 'demo', canonical_url=DEMO)
+ other_v1 = source(other, 'demo', '1', released=True, canonical_url=DEMO)
+ held = ConceptFactory(parent=Source.objects.get(organization=other, mnemonic='demo', version='HEAD'),
+ mnemonic='A', sources=[other_v1], names=[name('Other A', preferred=True)])
+ self.stored.concepts.add(held)
+ self.assertEqual(self.validated(code='A', systemVersion='1', display='Other A'),
+ {'result': True, 'version': '1', 'display': 'Other A'})
+
+ def test_a_member_a_release_holds_itself_still_follows_the_pins(self):
+ """For a member held by both releases, an explicit pin wins over an evaluated pin."""
+ root = ConceptFactory(parent=self.demo, mnemonic='C')
+ held = ConceptFactory(parent=self.demo, mnemonic='C', version='C-1', versioned_object=root,
+ sources=[self.demo_v1, self.demo_v2], names=[name('Code C', preferred=True)])
+ self.stored.concepts.set([held])
+ for explicit, evaluated, version in ((True, False, '1'), (False, True, '2'), (True, True, '1')):
+ with self.subTest(explicit=explicit, evaluated=evaluated):
+ self.stored.explicit_source_versions.set([self.demo_v1] if explicit else [])
+ self.stored.evaluated_source_versions.set([self.demo_v2] if evaluated else [])
+ self.assertEqual(self.members(), [('C', version)])
+ self.assertEqual(self.used(), [f'{DEMO}|{version}'])
+ self.assertEqual(self.validated(code='C'), {'result': True, 'version': version, 'display': 'Code C'})
+
+ def test_the_statement_count_does_not_grow_with_either_kind_of_member(self):
+ """Adding members costs no more statements, whether releases hold their rows or other versions."""
+ def statements(roots):
+ self.stored.concepts.set(roots if roots else self.demo_v1.concepts.all())
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expanded()
+ self.assertEqual(len(expansion['contains']), len(self.roots))
+ self.assertEqual(unbounded_expansion_reads(context.captured_queries, self.stored.id), [])
+ return len(context.captured_queries)
+
+ before = statements(None), statements(self.roots.values())
+ for code in ('C', 'D', 'E'):
+ root = ConceptFactory(parent=self.demo, mnemonic=code)
+ for release in (self.demo_v1, self.demo_v2, self.demo_v3):
+ ConceptFactory(parent=self.demo, mnemonic=code, version=f'{code}-{release.version}',
+ versioned_object=root, sources=[release])
+ self.roots[code] = root
+ self.assertEqual((statements(None), statements(self.roots.values())), before)
+
+ def test_releases_holding_every_concept_need_no_correlated_version_lookup(self):
+ """The newest releases are read as sets, whatever the pins, even when only the total is requested."""
+ correlated = re.compile(r'"versioned_object_id" = \("concepts"\."versioned_object_id"\)')
+ for pinned in ('explicit', 'evaluated', 'none'):
+ self.stored.explicit_source_versions.clear()
+ self.stored.evaluated_source_versions.clear()
+ if pinned == 'explicit':
+ self.stored.explicit_source_versions.add(self.demo_v1)
+ elif pinned == 'evaluated':
+ self.stored.explicit_source_versions.add(self.demo)
+ self.stored.evaluated_source_versions.add(self.demo_v1)
+ for count in (0, 1, 1000):
+ with self.subTest(pinned=pinned, count=count):
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expanded(count=count)
+ self.assertEqual(expansion['total'], 2)
+ self.assertEqual([item.get('version') for item in expansion.get('contains', [])],
+ ['2'] * min(count, 2))
+ self.assertEqual([item['valueUri'] for item in expansion['parameter']
+ if item['name'] == 'used-codesystem'], [f'{DEMO}|2'])
+ self.assertEqual([query['sql'] for query in context.captured_queries
+ if correlated.search(query['sql'])], [])
+ self.assertEqual(unbounded_expansion_reads(context.captured_queries, self.stored.id), [])
+
+ def test_the_newest_visible_release_of_each_repository_answers(self):
+ """A private newest release answers only at a base where the caller may view it."""
+ root = ConceptFactory(parent=self.ciel, mnemonic='another')
+ ConceptFactory(parent=self.ciel, mnemonic='another', version='another-1', versioned_object=root,
+ sources=[self.ciel_v1, self.ciel_v2])
+ self.stored.concepts.add(root)
+ for base, user, version in (('/fhir', None, '2'), ('/orgs/Acme', self.outsider, '2'),
+ ('/orgs/Acme', self.admin, '3')):
+ with self.subTest(base=base, version=version):
+ self.assertEqual(self.members(base=base, user=user),
+ [('A', version), ('B', version), ('another', 'v2')])
+ self.assertEqual(self.used(base=base, user=user),
+ sorted([f'{DEMO}|{version}', f'{self.ciel.canonical_url}|v2']))
+
+ def test_an_older_release_answers_when_the_newest_no_longer_holds_the_concept(self):
+ """Missing newest links fall back to an older holding release, or no version when none holds it."""
+ self.stored.explicit_source_versions.clear()
+ Concept.sources.through.objects.filter(
+ source=self.demo_v2, concept__versioned_object=self.roots['A']).delete()
+ Concept.sources.through.objects.filter(
+ source__in=[self.demo_v1, self.demo_v2, self.demo_v3],
+ concept__versioned_object=self.roots['B']).delete()
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expanded()
+ self.assertEqual([(item['code'], item.get('version')) for item in expansion['contains']],
+ [('A', '1'), ('B', None)])
+ self.assertEqual([item['valueUri'] for item in expansion['parameter'] if item['name'] == 'used-codesystem'],
+ [DEMO, f'{DEMO}|1'])
+ self.assertEqual(unbounded_expansion_reads(context.captured_queries, self.stored.id), [])
+
+
+class ConceptVersionWithoutRegistryTest(ConceptVersionCases, OCLTestCase):
+ registry = False
+
+
+class ConceptVersionWithRegistryTest(ConceptVersionCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_ops_expand.py b/core/fhir/tests_ops_expand.py
new file mode 100644
index 000000000..0dbc18dcd
--- /dev/null
+++ b/core/fhir/tests_ops_expand.py
@@ -0,0 +1,616 @@
+"""
+ValueSet/$expand (SVCM ITI-97, FHIR R4 $expand) at the global base /fhir/ and at owner bases, GET and POST, at the type
+and instance levels, with the URL registry toggle off and on: the value set version resolved as core.fhir.resolve does;
+a page of its default stored expansion, never computed or saved; contains with each member's system, version, code,
+display and inactive flag; total, offset, the inputs echoed and used-codesystem; count, offset, activeOnly, filter,
+displayLanguage and includeDesignations; FHIRsmith's errors; members of code systems the caller isn't shown leave no
+trace; a fixed number of queries. OpenConceptLab/ocl_online#451.
+"""
+import json
+import re
+from urllib.parse import quote
+
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from lxml import etree
+from mock.mock import patch
+from rest_framework.test import APIClient
+
+from core.collections.models import Collection, Expansion
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase, OCLAPITransactionTestCase
+from core.concepts.models import Concept
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.base import from_xml
+from core.fhir.outcomes import MESSAGE_ID_EXTENSION, TX_ISSUE_TYPE_SYSTEM
+from core.fhir.tests_ops_lookup import OpsCorpusMixin, name, CIEL, REST, HIV, FEVERS, NEWEST_PRIVATE
+from core.fhir.tests_search import source, collection
+from core.orgs.tests.factories import OrganizationFactory
+from core.url_registry.factories import GlobalURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+
+UNKNOWN_VALUE_SET = 'http://example.org/ocl-fhir-conformance/unknown-valueset'
+PINNED = 'https://example.org/fhir/ValueSet/pinned'
+DRAFTS = 'https://example.org/fhir/ValueSet/drafts'
+DRAFT_ONLY = 'http://example.org/fhir/CodeSystem/head-only'
+INFECTIOUS = {'system': CIEL, 'version': 'v2', 'code': '117053', 'display': 'Infectious enteritis'}
+RETIRED = {'system': CIEL, 'version': 'v2', 'code': '103', 'display': 'HIV antibody', 'inactive': True}
+BATCH = {'system': REST, 'version': '4.0.1', 'code': 'batch'}
+PRIVATE = {'system': HIV, 'version': 'v1', 'code': 'P1', 'display': 'Private code'}
+USED = [f'{REST}|4.0.1', f'{CIEL}|v2']
+OLD_URL = 'http://example.org/fhir/CodeSystem/renamed-old'
+NEW_URL = 'http://example.org/fhir/CodeSystem/renamed'
+
+
+def used(*systems):
+ return [{'name': 'used-codesystem', 'valueUri': system} for system in systems]
+
+
+def issues_of(response):
+ """An OperationOutcome's issues as severity|code|tx-issue-type|expression|message id."""
+ return [
+ '|'.join([item['severity'], item['code'], item['details']['coding'][0]['code'],
+ ','.join(item.get('expression', [])),
+ ','.join(e['valueString'] for e in item.get('extension', []) if e['url'] == MESSAGE_ID_EXTENSION)])
+ for item in response.data['issue']]
+
+
+def name_matches(queries):
+ """The statements that match a filter against concept names."""
+ return [query['sql'] for query in queries if re.search(r'\."name"(::text)? ~\*', query['sql'])]
+
+
+class ExpandCases(OpsCorpusMixin):
+ def expand(self, base='/fhir', user=None, **params):
+ return self.get('$expand', base=base, user=user, resource='ValueSet', **params)
+
+ def post_expand(self, parameters, base='/fhir', user=None):
+ return self.post('$expand', parameters, base=base, user=user, resource='ValueSet')
+
+ def expansion(self, response):
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['resourceType'], 'ValueSet')
+ return response.data['expansion']
+
+ def register(self, url, owner):
+ if self.registry:
+ GlobalURLRegistryFactory(url=url, namespace=owner.uri)
+
+ # the route, the envelope and the members
+
+ def test_a_value_set_version_named_with_a_path_takes_its_repository_canonical(self):
+ draft = collection(self.acme, 'fevers', 'v2')
+ expansion = ExpansionFactory(mnemonic='autoexpand-v2', collection_version=draft)
+ draft.expansion_uri = expansion.uri
+ draft.save()
+
+ for params in ({'url': '/orgs/Acme/ValueSet/fevers/', 'valueSetVersion': 'v2'},
+ {'url': '/orgs/Acme/ValueSet/fevers/v2/'}):
+ response = self.expand(**params)
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual((response.data['url'], response.data['version']), (FEVERS, 'v2'))
+
+ def test_a_type_level_get_answers_the_value_set_with_its_stored_expansion(self):
+ response = self.expand(url=FEVERS)
+
+ expansion = self.expansion(response)
+ self.assertEqual((response.data['url'], response.data['version'], response.data['id']),
+ (FEVERS, 'v1', str(self.fevers_v1.id)))
+ self.assertEqual(response.data['status'], 'active')
+ self.assertNotIn('compose', response.data)
+ self.assertEqual(expansion['identifier'], self.fevers_v1.expansion_uri)
+ self.assertIn('timestamp', expansion)
+ self.assertEqual((expansion['total'], expansion['offset']), (3, 0))
+ self.assertEqual(expansion['parameter'], used(*USED))
+ self.assertEqual(expansion['contains'], [INFECTIOUS, RETIRED, BATCH])
+ self.assertEqual(self.expand(url=FEVERS, valueSetVersion='v1').content, response.content)
+ self.assertEqual(self.expand(url=f'{FEVERS}|v1').content, response.content)
+ self.assertEqual(self.client_for().get('/fhir/ValueSet/$expand/', {'url': FEVERS}).content, response.content)
+
+ def test_a_post_answers_as_a_get_does(self):
+ response = self.expand(url=FEVERS, count='2', activeOnly='true')
+ for parameters in (
+ [{'name': 'url', 'valueUri': FEVERS}, {'name': 'count', 'valueInteger': 2},
+ {'name': 'activeOnly', 'valueBoolean': True}],
+ [{'name': 'url', 'valueString': FEVERS}, {'name': 'count', 'valueString': '2'},
+ {'name': 'activeOnly', 'valueString': 'true'}]):
+ posted = self.post_expand(parameters)
+ self.assertEqual((posted.status_code, posted.content), (response.status_code, response.content))
+
+ def test_an_owner_base_shows_the_callers_private_members(self):
+ expansion = self.expansion(self.expand(base='/orgs/Acme', user=self.moh_member, url=FEVERS))
+ self.assertEqual(expansion['total'], 4)
+ self.assertEqual(expansion['contains'], [INFECTIOUS, RETIRED, BATCH, PRIVATE])
+ self.assertEqual(expansion['parameter'], used(f'{REST}|4.0.1', f'{HIV}|v1', f'{CIEL}|v2'))
+ for base, user in (('/fhir', self.moh_member), ('/orgs/Acme', None), ('/orgs/Acme', self.outsider)):
+ self.assertEqual(self.expansion(self.expand(base=base, user=user, url=FEVERS))['total'], 3, (base, user))
+
+ # paging, activeOnly, filter and the echo
+
+ def test_count_and_offset_page_the_members(self):
+ first = self.expansion(self.expand(url=FEVERS, count='2', offset='0'))
+ second = self.expansion(self.expand(url=FEVERS, count='2', offset='2'))
+ self.assertEqual((first['total'], first['offset'], first['contains']), (3, 0, [INFECTIOUS, RETIRED]))
+ self.assertEqual((second['total'], second['offset'], second['contains']), (3, 2, [BATCH]))
+ self.assertEqual(first['parameter'], [
+ {'name': 'offset', 'valueInteger': 0}, {'name': 'count', 'valueInteger': 2}, *used(*USED)])
+ past = self.expansion(self.expand(url=FEVERS, offset='10'))
+ self.assertEqual((past['total'], past['offset'], 'contains' in past), (3, 10, False))
+
+ def test_count_0_answers_the_total_alone(self):
+ expansion = self.expansion(self.expand(url=FEVERS, count='0'))
+ self.assertEqual(expansion['total'], 3)
+ self.assertNotIn('contains', expansion)
+ self.assertEqual(expansion['parameter'], [{'name': 'count', 'valueInteger': 0}, *used(*USED)])
+
+ def test_count_is_at_most_1000(self):
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expansion(self.expand(url=FEVERS, count='5000'))
+ self.assertEqual(expansion['parameter'][0], {'name': 'count', 'valueInteger': 1000})
+ self.assertEqual(len(expansion['contains']), 3)
+ pages = [query['sql'] for query in context.captured_queries if re.search(r'\bLIMIT 1000\b', query['sql'])]
+ self.assertEqual(len(pages), 1)
+ self.assertTrue(pages[0].startswith('SELECT') and 'FROM "concepts"' in pages[0])
+ with CaptureQueriesContext(connection) as context:
+ self.expand(url=FEVERS) # without count: a page of 1000 too, not echoed
+ self.assertEqual(len([query for query in context.captured_queries if 'LIMIT 1000' in query['sql']]), 1)
+
+ def test_active_only_leaves_retired_members_out(self):
+ expansion = self.expansion(self.expand(url=FEVERS, activeOnly='true'))
+ self.assertEqual((expansion['total'], expansion['contains']), (2, [INFECTIOUS, BATCH]))
+ self.assertEqual(expansion['parameter'][0], {'name': 'activeOnly', 'valueBoolean': True})
+ self.assertEqual(expansion['parameter'][1:], used(*USED)) # used-codesystem: the whole value set's
+ self.assertEqual(self.expansion(self.expand(url=FEVERS, activeOnly='false'))['total'], 3)
+
+ def test_a_filter_keeps_members_with_a_word_starting_with_each_of_its_words(self):
+ for text, members in (('infect', [INFECTIOUS]), ('INFECT', [INFECTIOUS]), ('entérite', [INFECTIOUS]),
+ ('origin enteritis', [INFECTIOUS]), ('bat', [BATCH]), ('10', [RETIRED]),
+ ('hiv', [RETIRED]), ('fect', []), ('antibody enteritis', []), ('', [INFECTIOUS, RETIRED,
+ BATCH])):
+ expansion = self.expansion(self.expand(url=FEVERS, filter=text))
+ self.assertEqual((expansion['total'], expansion.get('contains', [])), (len(members), members), text)
+ expansion = self.expansion(self.expand(url=FEVERS, filter='infect', activeOnly='true', count='1'))
+ self.assertEqual(expansion['parameter'], [
+ {'name': 'filter', 'valueString': 'infect'}, {'name': 'activeOnly', 'valueBoolean': True},
+ {'name': 'count', 'valueInteger': 1}, *used(*USED)])
+
+ def test_display_language_and_designations(self):
+ expansion = self.expansion(self.expand(url=FEVERS, displayLanguage='fr', includeDesignations='true', count='1'))
+ member = expansion['contains'][0]
+ self.assertEqual(member['display'], 'Entérite infectieuse')
+ self.assertEqual(sorted((item['language'], item['value']) for item in member['designation']), [
+ ('en', 'Enteritis of infectious origin'), ('en', 'Infectious enteritis'), ('es', 'enteritis infecciosa'),
+ ('fr', 'Entérite infectieuse')])
+ self.assertEqual(expansion['parameter'][:2], [
+ {'name': 'displayLanguage', 'valueCode': 'fr'}, {'name': 'includeDesignations', 'valueBoolean': True}])
+ self.assertNotIn('designation', self.expansion(self.expand(url=FEVERS))['contains'][0])
+
+ # versions: only releases the caller is shown
+
+ def test_a_members_version_is_the_release_the_expansion_pins_then_evaluated_then_newest(self):
+ pinned = collection(self.acme, 'pinned', 'v1', released=True, canonical_url=PINNED)
+ collection(self.acme, 'pinned', canonical_url=PINNED)
+ expansion = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=pinned)
+ expansion.concepts.add(self.infectious, self.older) # 117053 in CIEL v1 and v2, 999 in v1 only
+ expansion.explicit_source_versions.add(self.ciel_v1)
+ expansion.evaluated_source_versions.add(self.ciel_v2)
+ pinned.expansion_uri = expansion.uri
+ pinned.save()
+ self.register(PINNED, self.acme)
+
+ answer = self.expansion(self.expand(url=PINNED))
+ self.assertEqual([(member['code'], member['version']) for member in answer['contains']],
+ [('117053', 'v1'), ('999', 'v1')])
+ self.assertEqual(answer['parameter'], used(f'{CIEL}|v1'))
+ expansion.explicit_source_versions.clear()
+ answer = self.expansion(self.expand(url=PINNED))
+ self.assertEqual([(member['code'], member['version']) for member in answer['contains']],
+ [('117053', 'v2'), ('999', 'v1')])
+ self.assertEqual(answer['parameter'], used(f'{CIEL}|v1', f'{CIEL}|v2'))
+ expansion.evaluated_source_versions.clear()
+ self.assertEqual([member['version'] for member in self.expansion(self.expand(url=PINNED))['contains']],
+ ['v2', 'v1']) # the newest release holding it
+
+ def test_a_version_is_only_a_release_the_caller_is_shown_never_head(self):
+ drafts = OrganizationFactory(mnemonic='Drafts')
+ head_only = source(drafts, 'head-only', canonical_url=DRAFT_ONLY)
+ value_set = collection(self.acme, 'drafts', 'v1', released=True, canonical_url=DRAFTS)
+ collection(self.acme, 'drafts', canonical_url=DRAFTS)
+ expansion = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=value_set)
+ newest = Concept.objects.filter(parent=self.newest_private, mnemonic='N1').first() # in public v1, private v2
+ expansion.concepts.add(
+ ConceptFactory(parent=head_only, mnemonic='D1', names=[name('D one', preferred=True)]), newest)
+ expansion.explicit_source_versions.add(head_only, self.newest_private_v2)
+ value_set.expansion_uri = expansion.uri
+ value_set.save()
+ self.register(DRAFTS, self.acme)
+
+ answer = self.expansion(self.expand(url=DRAFTS))
+ self.assertEqual(answer['contains'], [ # N1 is the older concept
+ {'system': NEWEST_PRIVATE, 'version': 'v1', 'code': 'N1', 'display': 'N one'},
+ {'system': DRAFT_ONLY, 'code': 'D1', 'display': 'D one'}])
+ self.assertEqual(answer['parameter'], used(DRAFT_ONLY, f'{NEWEST_PRIVATE}|v1'))
+ staff = self.expansion(self.expand(base='/orgs/Acme', user=self.admin, url=DRAFTS))
+ self.assertIn(f'{NEWEST_PRIVATE}|v2', [item['valueUri'] for item in staff['parameter']])
+
+ def test_head_is_expanded_only_when_named(self):
+ """HEAD's own stored expansion answers when valueSetVersion names HEAD; the default stays the release."""
+ head_expansion = ExpansionFactory(mnemonic='autoexpand-HEAD', collection_version=self.fevers)
+ head_expansion.concepts.add(self.infectious)
+ self.fevers.expansion_uri = head_expansion.uri
+ self.fevers.save()
+ response = self.expand(url=FEVERS, valueSetVersion='HEAD')
+ self.assertEqual((response.data['version'], self.expansion(response)['contains']), ('HEAD', [INFECTIOUS]))
+ self.assertEqual(self.expansion(self.expand(url=FEVERS))['total'], 3)
+
+ def test_a_member_of_a_code_system_without_a_canonical_url_is_named_by_its_path(self):
+ local = source(self.acme, 'local', canonical_url=None)
+ local_v1 = source(self.acme, 'local', 'v1', released=True, canonical_url=None)
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=local, mnemonic='L1', sources=[local_v1], names=[name('Local one', preferred=True)]))
+ answer = self.expansion(self.expand(url=FEVERS, filter='local'))
+ self.assertEqual(answer['contains'], [
+ {'system': '/orgs/Acme/CodeSystem/local/', 'version': 'v1', 'code': 'L1', 'display': 'Local one'}])
+ self.assertIn('/orgs/Acme/CodeSystem/local/|v1', [item.get('valueUri') for item in answer['parameter']])
+
+ def test_deleted_concepts_and_releases_are_gone(self):
+ deleted = ConceptFactory(parent=self.ciel, mnemonic='555', sources=[self.ciel_v2], is_active=False,
+ names=[name('Deleted', preferred=True)])
+ self.fevers_v1.expansion.concepts.add(deleted)
+ self.assertEqual(self.expansion(self.expand(url=FEVERS))['total'], 3)
+ self.rest_v1.is_active = False
+ self.rest_v1.save()
+ answer = self.expansion(self.expand(url=FEVERS))
+ self.assertEqual(answer['contains'][2], {'system': REST, 'code': 'batch'})
+ self.assertEqual(answer['parameter'], used(REST, f'{CIEL}|v2'))
+
+ def test_a_filter_matches_the_words_of_a_code_as_fhir_spells_it(self):
+ """filter matches a stored code by its decoded words, ignoring case, non-ASCII letters too: never by the
+ characters of an escape, and a word starts after an encoded character only when that character, decoded, isn't
+ a word character (Codex F1.2, F2.1)."""
+ codes = {'ND53.Y%26XK70': 'Encoded', quote('café'): 'Coffee shop', quote('caféine'): 'Stimulant',
+ quote('été'): 'Summer', quote('QX–ZZ9'): 'Range', '50%off': 'Discount', '%FFyy': 'Replacement',
+ quote('cm.²'): 'Area'}
+ for code, display in codes.items():
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=self.ciel, mnemonic=code, sources=[self.ciel_v2], names=[name(display, preferred=True)]))
+ for text, decoded in (
+ ('26', []), ('c3', []), ('A9', []), ('80', []), # the characters of an escape
+ ('CAFÉ', ['café', 'caféine']), ('Café', ['café', 'caféine']), ('ÉTÉ', ['été']), # case, non-ASCII too
+ ('CAFÉINE', ['caféine']), ('ine', []), ('té', []), # é is a word character: no word starts after it
+ ('zz9', ['QX–ZZ9']), ('XK70', ['ND53.Y&XK70']), # – and & are not
+ ('ND53.Y&XK70', ['ND53.Y&XK70']), ('xk', ['ND53.Y&XK70']), ('Y&XK', ['ND53.Y&XK70']), # decoded, whole
+ ('encoded', ['ND53.Y&XK70']), ('K70', []), # by a name; a word's middle
+ ('CAF', ['café', 'caféine']),
+ ('off', ['50%off']), ('yy', ['\ufffdyy']), # a % that is no escape; a byte that is no character
+ ('²', []), ('cm', ['cm.²'])): # \m, as for names: ² is no word character to Postgres
+ with self.subTest(text):
+ expansion = self.expansion(self.expand(url=FEVERS, filter=text))
+ self.assertEqual([member['code'] for member in expansion.get('contains', [])], decoded)
+ self.assertEqual(expansion['total'], len(decoded))
+
+ def test_a_members_system_is_its_releases_own_canonical_url(self):
+ """A release with a canonical URL of its own names its members' system, else its repository's does, in contains
+ and in used-codesystem (Codex F1.3; search and read follow the same rule)."""
+ renamed = source(self.acme, 'renamed', canonical_url=NEW_URL)
+ old = source(self.acme, 'renamed', 'v1', released=True, canonical_url=OLD_URL)
+ plain = source(self.acme, 'renamed', 'v2', released=True, canonical_url=None)
+ for code, sources in (('R1', [old]), ('R2', [plain]), ('R3', [])):
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=renamed, mnemonic=code, sources=sources, names=[name(f'Renamed {code}', preferred=True)]))
+ answer = self.expansion(self.expand(url=FEVERS, filter='renamed'))
+ self.assertEqual([(member['system'], member.get('version'), member['code']) for member in answer['contains']], [
+ (OLD_URL, 'v1', 'R1'), (NEW_URL, 'v2', 'R2'), (NEW_URL, None, 'R3')])
+ self.assertEqual([item['valueUri'] for item in answer['parameter'] if item['name'] == 'used-codesystem'],
+ sorted([*USED, f'{OLD_URL}|v1', f'{NEW_URL}|v2', NEW_URL]))
+
+ # what the caller isn't shown leaves no trace
+
+ def sent(self, requests):
+ """{(method, path, params, caller): (status, bytes, statements run, ids and numbers normalised)}, for each of
+ requests' (method, path, params, callers)."""
+ answers = {}
+ for method, path, params, callers in requests:
+ for who, user in callers:
+ with CaptureQueriesContext(connection) as context:
+ if method == 'GET':
+ response = self.client_for(user).get(path, params)
+ else:
+ response = self.client_for(user).post(
+ path, json.dumps({'resourceType': 'Parameters', 'parameter': params}),
+ content_type='application/fhir+json')
+ answers[(method, path, json.dumps(params), who)] = (
+ response.status_code, response.content,
+ [re.sub(r'\b\d+\b', 'N', query['sql']) for query in context.captured_queries])
+ return answers
+
+ def assert_same_when_missing(self, hidden, missing, bytes_only=lambda key: False):
+ """The two worlds' answers are alike in status and bytes, and in the statements run, except for the requests
+ bytes_only(key) names (statements that resolve() itself runs differently, outside this operation)."""
+ self.assertEqual(hidden.keys(), missing.keys())
+ for key, (status, content, statements) in hidden.items():
+ self.assertEqual((status, content), missing[key][:2], key)
+ if not bytes_only(key):
+ self.assertEqual(statements, missing[key][2], key)
+
+ def test_a_member_the_caller_is_not_shown_leaves_no_trace(self):
+ """An expansion with a hidden member answers byte for byte, and with the same statements, as one without it:
+ in contains, total, used-codesystem and the versions reported, for any input that shapes the answer."""
+ late = ConceptFactory(parent=self.ciel, mnemonic='Z9', sources=[self.ciel_v2], names=[
+ name('Zed nine code', preferred=True), name('Zède', 'fr', True)])
+ expansion = self.fevers_v1.expansion
+ expansion.concepts.add(late) # after the hidden member in id order: a page can run across it
+ expansion.explicit_source_versions.add(self.hiv_v1) # its release, pinned
+ expansion.evaluated_source_versions.add(self.hiv_v1)
+ callers = [('anonymous', None), ('outsider', self.outsider), ('moh-member', self.moh_member),
+ ('staff', self.admin)]
+ inputs = ({}, {'count': '1', 'offset': '1'}, {'count': '1', 'offset': '2'}, {'count': '2', 'offset': '2'},
+ {'offset': '4'}, {'offset': '10'}, {'activeOnly': 'true'}, {'filter': 'private'}, {'filter': 'p1'},
+ {'filter': 'private', 'count': '0'}, {'count': '0'},
+ {'displayLanguage': 'fr', 'includeDesignations': 'true'}, {'includeDesignations': 'true'})
+ requests = [('GET', '/fhir/ValueSet/$expand', {'url': FEVERS, **params}, callers) for params in inputs] + [
+ ('GET', '/orgs/Acme/ValueSet/$expand', {'url': FEVERS, **params}, callers[:2]) for params in inputs] + [
+ ('POST', '/fhir/ValueSet/$expand', [{'name': 'url', 'valueUri': FEVERS}, {'name': 'count',
+ 'valueInteger': 1}], callers),
+ ('GET', f'/fhir/ValueSet/{self.fevers_v1.id}/$expand', {'offset': '2'}, callers),
+ ('GET', '/orgs/Acme/ValueSet/fevers/$expand', {'filter': 'p'}, callers[:2])]
+ hidden = self.sent(requests)
+ self.assertEqual(hidden[('GET', '/fhir/ValueSet/$expand', json.dumps({'url': FEVERS}), 'moh-member')][0], 200)
+ expansion.concepts.remove(self.private_code)
+ expansion.explicit_source_versions.remove(self.hiv_v1)
+ expansion.evaluated_source_versions.remove(self.hiv_v1)
+ self.assert_same_when_missing(hidden, self.sent(requests))
+
+ def test_a_value_set_the_caller_is_not_shown_answers_as_a_missing_one(self):
+ url = 'https://example.org/hidden'
+ private = collection(self.moh, 'hidden', 'v1', released=True, canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ collection(self.moh, 'hidden', canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ private.expansion_uri = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=private).uri
+ private.save()
+ self.register(url, self.moh)
+ self.assertEqual(self.expand(base='/orgs/MOH', user=self.moh_member, url=url).status_code, 200)
+ everyone = [('anonymous', None), ('outsider', self.outsider), ('moh-member', self.moh_member),
+ ('staff', self.admin)]
+ outside = everyone[:2]
+ by_path = {'url': '/orgs/MOH/ValueSet/hidden/'}
+ path_v1 = {'url': '/orgs/MOH/ValueSet/hidden/v1/'} # resolve() reads the version's HEAD first, hidden or not
+ requests = [(method, f'{base}/ValueSet/$expand', params, callers) for base, callers in (
+ ('/fhir', everyone), ('/orgs/MOH', outside)) for method, params in (
+ ('GET', {'url': url}), ('GET', {'url': f'{url}|v1'}), ('GET', {'url': url, 'valueSetVersion': 'v1'}),
+ ('GET', {'url': url, 'valueSetVersion': 'HEAD'}), ('GET', {'url': url, 'valueSetVersion': 'v9'}),
+ ('GET', by_path), ('GET', path_v1), ('GET', {'url': url, 'filter': 'inf', 'count': '0'}),
+ ('POST', [{'name': 'url', 'valueUri': url}]),
+ ('POST', [{'name': 'url', 'valueString': url}, {'name': 'valueSetVersion', 'valueString': 'v1'}]))]
+ requests += [('GET', f'/fhir/ValueSet/{private.id}/$expand', params, everyone) for params in (
+ {}, {'valueSetVersion': 'HEAD'})] + [
+ ('POST', f'/fhir/ValueSet/{private.id}/$expand', [], everyone),
+ ('GET', '/orgs/MOH/ValueSet/hidden/$expand', {}, outside),
+ ('GET', '/orgs/MOH/ValueSet/hidden/$expand', {'valueSetVersion': 'HEAD'}, outside)]
+ hidden = self.sent(requests)
+ Collection.objects.filter(mnemonic='hidden').delete()
+ missing = self.sent(requests)
+ self.assertEqual(issues_of(self.expand(url=url)), ['error|not-found|not-found||Unable_to_resolve_value_Set_'])
+ for key, (status, _, _) in hidden.items():
+ self.assertIn(status, (404, 422), key)
+ # the same statements everywhere but where resolve() itself differs: for a caller who is signed in, at an owner
+ # base (a membership check, only when a private row is found), with the registry on at an owner base (its
+ # lookup is skipped for a private canonical), and for an OCL path naming a version (its HEAD is read first)
+ self.assert_same_when_missing(
+ hidden, missing, lambda key: key[1].startswith('/orgs/MOH') or key[2] == json.dumps(path_v1))
+
+ def test_a_private_version_of_a_value_set_answers_as_a_missing_one(self):
+ """The newest release is private: named, or by its id, it answers as a version that doesn't exist."""
+ v2 = collection(self.acme, 'fevers', 'v2', released=True, canonical_url=FEVERS, public_access=ACCESS_TYPE_NONE)
+ v2.expansion_uri = ExpansionFactory(mnemonic='autoexpand-v2', collection_version=v2).uri
+ v2.save()
+ everyone = [('anonymous', None), ('outsider', self.outsider), ('moh-member', self.moh_member),
+ ('staff', self.admin)]
+ requests = [('GET', '/fhir/ValueSet/$expand', params, everyone) for params in (
+ {'url': FEVERS, 'valueSetVersion': 'v2'}, {'url': f'{FEVERS}|v2'},
+ {'url': '/orgs/Acme/ValueSet/fevers/v2/'})] + [
+ ('POST', '/fhir/ValueSet/$expand', [{'name': 'url', 'valueUri': FEVERS},
+ {'name': 'valueSetVersion', 'valueString': 'v2'}], everyone),
+ ('GET', f'/fhir/ValueSet/{v2.id}/$expand', {}, everyone),
+ ('GET', '/orgs/Acme/ValueSet/$expand', {'url': FEVERS, 'valueSetVersion': 'v2'}, everyone[:3])]
+ hidden = self.sent(requests)
+ Collection.objects.filter(id=v2.id).delete()
+ # an OCL path naming a version reads its HEAD first, and an owner base checks a signed-in caller's membership
+ # once a private row is found: statements resolve() itself runs differently, not this operation
+ self.assert_same_when_missing(hidden, self.sent(requests), lambda key: key[1].startswith('/orgs/Acme') or
+ '/fevers/v2/' in key[2])
+
+ # errors
+
+ def test_errors(self):
+ response = self.expand(url=UNKNOWN_VALUE_SET)
+ self.assert_outcome(response, 422, 'not-found', 'not-found')
+ self.assertEqual(self.expand(url=FEVERS, valueSetVersion='ocl-fhir-conformance-no-such-version').status_code,
+ 422)
+ for params, expression in (({}, 'url'), ({'count': '-1'}, 'count'), ({'count': 'all'}, 'count'),
+ ({'offset': '1.5'}, 'offset'), ({'activeOnly': 'maybe'}, 'activeOnly'),
+ ({'includeDesignations': '1'}, 'includeDesignations')):
+ response = self.expand(**({'url': FEVERS} if expression != 'url' else {}), **params)
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [expression], params)
+ for parameter in ({'name': 'valueSet', 'resource': {'resourceType': 'ValueSet', 'status': 'active'}},
+ {'name': 'context', 'valueUri': 'http://example.org/context'}):
+ response = self.post_expand([{'name': 'url', 'valueUri': FEVERS}, parameter])
+ self.assert_outcome(response, 400, 'not-supported', 'not-supported')
+ self.assertEqual(response.data['issue'][0]['expression'], [parameter['name']])
+ response = self.post_expand([{'name': 'url', 'valueUri': FEVERS}, {'name': 'count', 'valueBoolean': True}])
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+
+ def test_inputs_it_does_not_honour_are_refused(self):
+ """Every R4 $expand input either shapes the answer, is one a stored expansion always satisfies, or is a 400
+ not-supported naming it, GET and POST: none is ignored (Codex F1.1). Names that aren't $expand inputs are."""
+ refused = {
+ 'date': ('valueDateTime', '2020-01-01'), 'exclude-system': ('valueCanonical', CIEL),
+ 'system-version': ('valueCanonical', f'{CIEL}|v1'),
+ 'check-system-version': ('valueCanonical', f'{CIEL}|v1'),
+ 'force-system-version': ('valueCanonical', f'{CIEL}|v1'), 'designation': ('valueString', 'fr'),
+ 'contextDirection': ('valueCode', 'incoming'), 'includeDefinition': ('valueBoolean', True),
+ 'excludePostCoordinated': ('valueBoolean', True)} # Codex F2.2: nothing leaves such codes out
+ for name_, (value_type, value) in refused.items():
+ responses = (self.expand(url=FEVERS, **{name_: 'true' if value is True else value}),
+ self.post_expand([{'name': 'url', 'valueUri': FEVERS}, {'name': name_, value_type: value}]))
+ for response in responses:
+ self.assert_outcome(response, 400, 'not-supported', 'not-supported')
+ self.assertEqual(response.data['issue'][0]['expression'], [name_], name_)
+ expected = self.expand(url=FEVERS).content
+ for params in ({'excludeNested': 'true'}, {'excludeNested': 'false'}, {'excludeNotForUI': 'true'},
+ {'excludePostCoordinated': 'false'}, {'includeDefinition': 'false'}, {'q': 'anything'}):
+ self.assertEqual(self.expand(url=FEVERS, **params).content, expected, params)
+ for name_ in ('excludePostCoordinated', 'includeDefinition'):
+ posted = self.post_expand([{'name': 'url', 'valueUri': FEVERS}, {'name': name_, 'valueBoolean': False}])
+ self.assertEqual((posted.status_code, posted.content), (200, expected), name_)
+ self.assert_outcome(self.expand(url=FEVERS, excludeNested='perhaps'), 400, 'invalid', 'invalid-data')
+
+ def test_an_expansion_that_cannot_be_served_is_a_422(self):
+ expansion = self.fevers_v1.expansion
+ expansion.unresolved_repo_versions = [
+ {'url': 'http://snomed.info/sct', 'type': 'reference.system', 'version': '', 'namespace': None}]
+ expansion.save()
+ response = self.expand(url=FEVERS)
+ self.assertEqual(issues_of(response), ['error|not-found|not-found||UNKNOWN_CODESYSTEM_EXP'])
+ self.assertEqual(response.status_code, 422)
+ self.assertIn("CodeSystem 'http://snomed.info/sct' could not be found", response.data['issue'][0]['details'][
+ 'text'])
+ expansion.unresolved_repo_versions = [{'url': 'http://example.org/vs', 'type': 'reference.valueset',
+ 'version': '2'}]
+ expansion.save()
+ self.assertEqual(issues_of(self.expand(url=FEVERS)),
+ ['error|not-found|not-found||VS_EXP_IMPORT_UNK_PINNED'])
+ expansion.unresolved_repo_versions = []
+ expansion.is_processing = True
+ expansion.save()
+ self.assert_outcome(self.expand(url=FEVERS), 422, 'processing', 'invalid-data')
+ self.fevers_v1.expansion_uri = None
+ self.fevers_v1.save()
+ self.assert_outcome(self.expand(url=FEVERS), 422, 'invalid', 'vs-invalid')
+
+ def test_a_post_never_persists(self):
+ expansions = list(Expansion.objects.values_list('id', 'parameters'))
+ members = self.fevers_v1.expansion.concepts.count()
+ for parameters in ([{'name': 'url', 'valueUri': FEVERS}, {'name': 'filter', 'valueString': 'infect'}],
+ [{'name': 'url', 'valueUri': FEVERS}, {'name': 'activeOnly', 'valueBoolean': True}]):
+ self.assertLess(self.expansion(self.post_expand(parameters))['total'], 3)
+ self.assertEqual(list(Expansion.objects.values_list('id', 'parameters')), expansions)
+ self.assertEqual(self.fevers_v1.expansion.concepts.count(), members)
+
+ # the instance level, XML
+
+ def test_the_instance_level(self):
+ expected = self.expansion(self.expand(url=FEVERS))
+ for path in (f'/fhir/ValueSet/{self.fevers_v1.id}/$expand', '/orgs/Acme/ValueSet/fevers/$expand',
+ '/orgs/Acme/ValueSet/fevers/$expand/'):
+ response = self.client_for().get(path)
+ self.assertEqual(self.expansion(response), expected, path)
+ response = self.client_for().get('/orgs/Acme/ValueSet/fevers/$expand', {'valueSetVersion': 'v1'})
+ self.assertEqual(self.expansion(response), expected)
+ for params, expression in (({'url': CIEL}, 'url'), ({'valueSetVersion': 'v9'}, 'valueSetVersion')):
+ response = self.client_for().get('/orgs/Acme/ValueSet/fevers/$expand', params)
+ self.assertEqual(response.status_code, 400 if expression == 'url' else 404, params)
+ self.assertEqual(self.client_for().get(f'/fhir/ValueSet/{self.fevers_v1.id}/$expand', {'url': CIEL})
+ .status_code, 400)
+ self.assertEqual(self.client_for().get('/orgs/Acme/ValueSet/nothing/$expand').status_code, 404)
+
+ def test_xml(self):
+ response = self.client_for().get('/fhir/ValueSet/$expand', {'url': FEVERS, '_format': 'xml'})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/fhir+xml; charset=utf-8')
+ root = etree.fromstring(response.content)
+ self.assertEqual(root.tag, '{http://hl7.org/fhir}ValueSet')
+ value_set = from_xml(response.content)
+ self.assertEqual(value_set['expansion']['total'], 3)
+ self.assertEqual([member['code'] for member in value_set['expansion']['contains']], ['117053', '103', 'batch'])
+ self.assertTrue(value_set['expansion']['contains'][1]['inactive'])
+
+ # cost
+
+ def test_a_filtered_page_matches_names_once(self):
+ """A page larger than the matching members reads the name match once, for both total and contains."""
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expansion(self.expand(url=FEVERS, filter='infect', count='1000'))
+ self.assertEqual((expansion['total'], expansion['offset'], expansion['contains']), (1, 0, [INFECTIOUS]))
+ self.assertEqual(len(name_matches(context.captured_queries)), 1)
+
+ def test_a_filtered_page_with_an_offset_matches_names_once(self):
+ """The total counts every match, and the offset selects a page in id order without matching names again."""
+ for number in range(30):
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=self.ciel, mnemonic=f'x{number}', sources=[self.ciel_v1, self.ciel_v2],
+ names=[name(f'Extra {number}', preferred=True)]))
+ with CaptureQueriesContext(connection) as context:
+ expansion = self.expansion(self.expand(url=FEVERS, filter='extra', count='2', offset='1'))
+ self.assertEqual((expansion['total'], expansion['offset']), (30, 1))
+ self.assertEqual(expansion['contains'], [
+ {'system': CIEL, 'version': 'v2', 'code': 'x1', 'display': 'Extra 1'},
+ {'system': CIEL, 'version': 'v2', 'code': 'x2', 'display': 'Extra 2'}])
+ self.assertEqual(len(name_matches(context.captured_queries)), 1)
+
+ def test_an_empty_filtered_page_reads_no_page_or_names(self):
+ """A zero count, an offset past the matches and no matches read no page or names."""
+ resolution = 5 if self.registry else 3
+ for text, count, offset, total in (('infect', '0', '0', 1), ('infect', '2', '1', 1),
+ ('missing', '1000', '0', 0)):
+ with self.assertNumQueries(resolution + 3):
+ expansion = self.expansion(self.expand(url=FEVERS, filter=text, count=count, offset=offset))
+ self.assertEqual((expansion['total'], expansion['offset']), (total, int(offset)))
+ self.assertNotIn('contains', expansion)
+
+ def test_a_fixed_number_of_queries(self):
+ """Resolution, the stored expansion, the total, used-codesystem, one page and its names: whatever the size of
+ the expansion, and with count=0 no page."""
+ resolution = 5 if self.registry else 3 # the toggle, the repository, its release (+ the registry's two)
+ with self.assertNumQueries(resolution + 5):
+ self.expand(url=FEVERS)
+ for number in range(30):
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=self.ciel, mnemonic=f'x{number}', sources=[self.ciel_v1, self.ciel_v2],
+ names=[name(f'Extra {number}', preferred=True)]))
+ self.fevers_v1.expansion.concepts.add(ConceptFactory( # a word after an encoded character: same statements
+ parent=self.ciel, mnemonic=quote('QX–ZZ9'), sources=[self.ciel_v2], names=[name('Range', preferred=True)]))
+ for params in ({}, {'count': '10', 'offset': '20', 'filter': 'extra', 'activeOnly': 'true'},
+ {'displayLanguage': 'fr', 'includeDesignations': 'true'}, {'filter': 'zz9 range'}):
+ with self.assertNumQueries(resolution + 5):
+ self.assertEqual(self.expand(url=FEVERS, **params).status_code, 200)
+ with self.assertNumQueries(resolution + 3):
+ self.expand(url=FEVERS, count='0')
+ with self.assertNumQueries(resolution + 4): # an empty page: no names to read
+ self.expand(url=FEVERS, offset='100')
+
+
+class ExpandWithoutRegistryTest(ExpandCases, OCLTestCase):
+ registry = False
+
+
+class ExpandWithRegistryTest(ExpandCases, OCLTestCase):
+ registry = True
+
+
+class ExpandTimeoutTest(OCLAPITransactionTestCase):
+ """$expand's statements are bounded as a search's are: one the database cancels is too costly (422)."""
+
+ def test_a_cancelled_statement_is_too_costly(self):
+ set_registry_toggle(False)
+ org = OrganizationFactory(mnemonic='Acme')
+ value_set = collection(org, 'fevers', 'v1', released=True, canonical_url=FEVERS)
+ collection(org, 'fevers', canonical_url=FEVERS)
+ value_set.expansion_uri = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=value_set).uri
+ value_set.save()
+
+ def slow(*_):
+ with connection.cursor() as cursor:
+ cursor.execute('SELECT pg_sleep(1)')
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 50), patch('core.fhir.ops.expand.Expander.used', slow):
+ response = APIClient().get('/fhir/ValueSet/$expand', {'url': FEVERS})
+ self.assertEqual(response.status_code, 422, response.data)
+ issue = response.data['issue'][0]
+ self.assertEqual(issue['code'], 'too-costly')
+ self.assertIn({'system': TX_ISSUE_TYPE_SYSTEM, 'code': 'too-costly'}, issue['details']['coding'])
+ self.assertIn('expansion', issue['details']['text'])
+ self.assertEqual(json.loads(APIClient().get('/fhir/ValueSet/$expand', {'url': FEVERS}).content)['expansion'][
+ 'total'], 0)
diff --git a/core/fhir/tests_ops_lookup.py b/core/fhir/tests_ops_lookup.py
new file mode 100644
index 000000000..72015fdf3
--- /dev/null
+++ b/core/fhir/tests_ops_lookup.py
@@ -0,0 +1,529 @@
+"""
+CodeSystem/$lookup (SVCM ITI-98, FHIR R4 $lookup) at the global base /fhir/ and at owner bases, GET and POST, at the
+type and instance levels: the code system resolved as core.fhir.resolve does (released versions only, the newest the
+caller is shown unless one is named, public only at /fhir/), with the URL registry toggle off and on; name, version
+(the one resolved), display, code, system, abstract, designations and the inactive property; retired codes found and
+flagged; displayLanguage; FHIRsmith's errors; a code system the caller isn't shown answers as a missing one; a fixed
+number of queries. OpenConceptLab/ocl_online#451.
+"""
+import json
+
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from rest_framework.test import APIClient
+
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory, ConceptNameFactory
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.fhir.tests_search import source, collection
+from core.orgs.tests.factories import OrganizationFactory
+from core.url_registry.factories import GlobalURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.models import UserProfile
+from core.users.tests.factories import UserProfileFactory
+
+CIEL = 'https://CIELterminology.org'
+ICD10 = 'http://hl7.org/fhir/sid/icd-10'
+HIV = 'http://moh.example/fhir/CodeSystem/hiv'
+REST = 'http://hl7.org/fhir/restful-interaction'
+SHARED = 'http://example.org/fhir/CodeSystem/shared'
+NEWEST_PRIVATE = 'http://example.org/fhir/CodeSystem/newest-private'
+UNKNOWN = 'http://example.org/ocl-fhir-conformance/unknown-system'
+FEVERS = 'https://example.org/fhir/ValueSet/fevers'
+INACTIVE = {'name': 'property', 'part': [
+ {'name': 'code', 'valueCode': 'inactive'}, {'name': 'value', 'valueBoolean': False}]}
+
+
+def name(text, locale='en', preferred=False, name_type='Fully-Specified'):
+ return ConceptNameFactory.build(name=text, locale=locale, locale_preferred=preferred, type=name_type)
+
+
+class OpsCorpusMixin:
+ """
+ CIEL with two releases: 117053 in both (names in three languages), retired 103 in the newer, 999 in the older only.
+ WHO's ICD-10, whose release has no canonical of its own; HL7's restful-interaction, whose 'batch' has no names.
+ MOH's private code system (members see it at MOH's base). A canonical held privately (lower id) and publicly; a
+ code system whose newest release is private. Acme's public value set 'fevers', whose release's default expansion
+ holds CIEL 117053 and 103, MOH's P1 and HL7's batch. `registry` is the URL registry toggle: off as in production,
+ or on with the curated registry's global entries (none for private repositories).
+ """
+ registry = False
+
+ def setUp(self): # pylint: disable=too-many-statements
+ set_registry_toggle(self.registry)
+ super().setUp()
+ self.admin = UserProfile.objects.get(username='ocladmin')
+ self.ciel_org = OrganizationFactory(mnemonic='CIEL')
+ self.who = OrganizationFactory(mnemonic='WHO')
+ self.hl7 = OrganizationFactory(mnemonic='HL7')
+ self.moh = OrganizationFactory(mnemonic='MOH')
+ self.acme = OrganizationFactory(mnemonic='Acme')
+ self.moh_member = UserProfileFactory(username='moh-member')
+ self.moh.members.add(self.moh_member)
+ self.outsider = UserProfileFactory(username='outsider')
+ self.jdoe = UserProfileFactory(username='jdoe')
+
+ self.ciel = source(self.ciel_org, 'CIEL', canonical_url=CIEL, default_locale='en', supported_locales=['en'])
+ self.ciel_v1 = source(self.ciel_org, 'CIEL', 'v1', released=True, canonical_url=CIEL)
+ self.ciel_v2 = source(self.ciel_org, 'CIEL', 'v2', released=True, canonical_url=CIEL)
+ self.infectious = ConceptFactory(
+ parent=self.ciel, mnemonic='117053', sources=[self.ciel_v1, self.ciel_v2], names=[
+ name('Infectious enteritis', preferred=True), name('Entérite infectieuse', 'fr', True),
+ name('enteritis infecciosa', 'es'), name('Enteritis of infectious origin', name_type='Synonym')])
+ self.retired = ConceptFactory(parent=self.ciel, mnemonic='103', retired=True, sources=[self.ciel_v2],
+ names=[name('HIV antibody', preferred=True)])
+ self.older = ConceptFactory(parent=self.ciel, mnemonic='999', sources=[self.ciel_v1],
+ names=[name('Older code', preferred=True)])
+
+ self.icd = source(self.who, 'ICD-10-WHO', canonical_url=ICD10)
+ self.icd_2019 = source(self.who, 'ICD-10-WHO', 'ICD102019', released=True, canonical_url=None)
+ ConceptFactory(parent=self.icd, mnemonic='S20.4', sources=[self.icd_2019],
+ names=[name('Other superficial injuries of back wall of thorax', preferred=True)])
+ self.rest = source(self.hl7, 'restful-interaction', canonical_url=REST)
+ self.rest_v1 = source(self.hl7, 'restful-interaction', '4.0.1', released=True, canonical_url=REST)
+ self.batch = ConceptFactory(parent=self.rest, mnemonic='batch', sources=[self.rest_v1])
+
+ self.hiv = source(self.moh, 'hiv', canonical_url=HIV, public_access=ACCESS_TYPE_NONE)
+ self.hiv_v1 = source(self.moh, 'hiv', 'v1', released=True, canonical_url=HIV, public_access=ACCESS_TYPE_NONE)
+ self.private_code = ConceptFactory(parent=self.hiv, mnemonic='P1', sources=[self.hiv_v1],
+ names=[name('Private code', preferred=True)])
+
+ private_shared = source(self.jdoe, 'shared', canonical_url=SHARED, public_access=ACCESS_TYPE_NONE)
+ private_shared_v1 = source(self.jdoe, 'shared', 'v1', released=True, canonical_url=SHARED,
+ public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=private_shared, mnemonic='S1', sources=[private_shared_v1],
+ names=[name('Private copy', preferred=True)])
+ self.public_shared = source(self.hl7, 'shared', canonical_url=SHARED)
+ self.public_shared_v1 = source(self.hl7, 'shared', 'v1', released=True, canonical_url=SHARED)
+ ConceptFactory(parent=self.public_shared, mnemonic='S1', sources=[self.public_shared_v1],
+ names=[name('Public copy', preferred=True)])
+
+ self.newest_private = source(self.hl7, 'newest', canonical_url=NEWEST_PRIVATE)
+ self.newest_public_v1 = source(self.hl7, 'newest', 'v1', released=True, canonical_url=NEWEST_PRIVATE)
+ self.newest_private_v2 = source(self.hl7, 'newest', 'v2', released=True, canonical_url=NEWEST_PRIVATE,
+ public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=self.newest_private, mnemonic='N1',
+ sources=[self.newest_public_v1, self.newest_private_v2], names=[name('N one', preferred=True)])
+
+ self.fevers = collection(self.acme, 'fevers', canonical_url=FEVERS)
+ self.fevers_v1 = collection(self.acme, 'fevers', 'v1', released=True, canonical_url=FEVERS)
+ expansion = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=self.fevers_v1)
+ expansion.concepts.add(self.infectious, self.retired, self.private_code, self.batch)
+ expansion.evaluated_source_versions.add(self.ciel_v2)
+ self.fevers_v1.expansion_uri = expansion.uri
+ self.fevers_v1.save()
+
+ if self.registry:
+ for url, owner in ((CIEL, self.ciel_org), (ICD10, self.who), (REST, self.hl7), (SHARED, self.hl7),
+ (NEWEST_PRIVATE, self.hl7), (FEVERS, self.acme)):
+ GlobalURLRegistryFactory(url=url, namespace=owner.uri)
+
+ @staticmethod
+ def client_for(user=None):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client
+
+ def get(self, operation, base='/fhir', user=None, resource='CodeSystem', **params):
+ return self.client_for(user).get(f'{base}/{resource}/{operation}', params)
+
+ def post( # pylint: disable=too-many-arguments
+ self, operation, parameters, base='/fhir', user=None, resource='CodeSystem'):
+ return self.client_for(user).post(
+ f'{base}/{resource}/{operation}', json.dumps({'resourceType': 'Parameters', 'parameter': parameters}),
+ content_type='application/fhir+json')
+
+ @staticmethod
+ def by_name(response):
+ """A Parameters response's parameters: {name: parameter}, each designation in `designations`."""
+ parameters = {}
+ for parameter in response.data['parameter']:
+ if parameter['name'] == 'designation':
+ parameters.setdefault('designations', []).append(parameter)
+ else:
+ parameters[parameter['name']] = parameter
+ return parameters
+
+ def assert_outcome(self, response, status_code, issue, tx_issue_type):
+ self.assertEqual(response.status_code, status_code, response.data)
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome')
+ self.assertEqual(response.data['issue'][0]['code'], issue)
+ self.assertEqual(response.data['issue'][0]['details']['coding'],
+ [{'system': TX_ISSUE_TYPE_SYSTEM, 'code': tx_issue_type}])
+
+
+class LookupCases(OpsCorpusMixin):
+ def lookup(self, **params):
+ return self.get('$lookup', **params)
+
+ def test_answers_name_version_display_code_system_abstract_designations_and_inactive(self):
+ response = self.lookup(system=CIEL, code='117053')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['parameter'][:6], [
+ {'name': 'name', 'valueString': 'CIEL'},
+ {'name': 'version', 'valueString': 'v2'},
+ {'name': 'display', 'valueString': 'Infectious enteritis'},
+ {'name': 'code', 'valueCode': '117053'},
+ {'name': 'system', 'valueUri': CIEL},
+ {'name': 'abstract', 'valueBoolean': False},
+ ])
+ self.assertEqual(response.data['parameter'][-1], INACTIVE)
+ designations = sorted(
+ [{part['name']: next(value for key, value in part.items() if key.startswith('value'))
+ for part in designation['part']} for designation in self.by_name(response)['designations']],
+ key=lambda designation: designation['value'])
+ self.assertEqual(designations, [
+ {'language': 'en', 'use': {'code': 'Synonym'}, 'value': 'Enteritis of infectious origin'},
+ {'language': 'fr', 'use': {'code': 'Fully-Specified'}, 'value': 'Entérite infectieuse'},
+ {'language': 'en', 'use': {'code': 'Fully-Specified'}, 'value': 'Infectious enteritis'},
+ {'language': 'es', 'use': {'code': 'Fully-Specified'}, 'value': 'enteritis infecciosa'},
+ ])
+
+ def test_answers_the_version_it_resolved(self):
+ """The version named, else the newest release; never the repository's latest when another was named."""
+ self.assertEqual(self.by_name(self.lookup(system=CIEL, code='117053', version='v1'))['version']['valueString'],
+ 'v1')
+ older = self.lookup(system=CIEL, code='999', version='v1')
+ self.assertEqual(older.status_code, 200, older.data)
+ self.assertEqual((self.by_name(older)['version']['valueString'], self.by_name(older)['display']['valueString']),
+ ('v1', 'Older code'))
+ self.assert_outcome(self.lookup(system=CIEL, code='999'), 404, 'not-found', 'invalid-code')
+ self.assertEqual(self.by_name(self.lookup(system=f'{CIEL}|v1', code='999'))['version']['valueString'], 'v1')
+
+ def test_a_release_without_a_canonical_of_its_own_resolves_by_its_repository(self):
+ response = self.lookup(system=ICD10, code='S20.4', version='ICD102019')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'ICD102019')
+ self.assertEqual(self.by_name(response)['system']['valueUri'], ICD10)
+
+ def test_a_retired_code_is_found_and_flagged_inactive(self):
+ response = self.lookup(system=CIEL, code='103')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['property']['part'][1], {'name': 'value', 'valueBoolean': True})
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'HIV antibody')
+
+ def test_display_language(self):
+ response = self.lookup(system=CIEL, code='117053', displayLanguage='fr')
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Entérite infectieuse')
+ # a language the concept has no name in: its own display
+ response = self.lookup(system=CIEL, code='117053', displayLanguage='ru')
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Infectious enteritis')
+
+ def test_a_concept_without_names_is_displayed_by_its_code(self):
+ response = self.lookup(system=REST, code='batch')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['display'], {'name': 'display', 'valueString': 'batch'})
+ self.assertNotIn('designations', self.by_name(response))
+
+ def test_errors(self):
+ self.assert_outcome(self.lookup(system=CIEL, code='nope'), 404, 'not-found', 'invalid-code')
+ self.assertNotIn('expression', self.lookup(system=CIEL, code='nope').data['issue'][0])
+ for params in ({'system': UNKNOWN, 'code': '1'}, {'system': CIEL, 'code': '117053', 'version': 'nope'}):
+ response = self.lookup(**params)
+ self.assert_outcome(response, 422, 'not-found', 'not-found')
+ self.assertNotIn('expression', response.data['issue'][0])
+ for params, missing in (({'system': CIEL}, 'code'), ({'code': '117053'}, 'system'), ({}, 'code')):
+ response = self.lookup(**params)
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [missing])
+
+ def test_post(self):
+ for value_types in (('valueUri', 'valueCode', 'valueString'), ('valueString', 'valueString', 'valueString')):
+ response = self.post('$lookup', [
+ {'name': 'system', value_types[0]: CIEL}, {'name': 'code', value_types[1]: '117053'},
+ {'name': 'version', value_types[2]: 'v1'}])
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+ self.assertEqual(self.by_name(response)['code']['valueCode'], '117053')
+ response = self.client_for().post(
+ '/fhir/CodeSystem/$lookup',
+ '',
+ content_type='application/fhir+xml', HTTP_ACCEPT='application/fhir+xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'', response.content)
+
+ def test_a_coding(self):
+ response = self.post('$lookup', [
+ {'name': 'coding', 'valueCoding': {'system': CIEL, 'code': '999', 'version': 'v1'}}])
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Older code')
+ response = self.post('$lookup', [{'name': 'coding', 'valueCoding': {'system': CIEL}}])
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], ['coding'])
+
+ def test_the_instance_level(self):
+ response = self.get(f'{self.ciel_v1.id}/$lookup', code='999')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+ response = self.get(f'{self.icd_2019.id}/$lookup', code='S20.4') # a release with no canonical of its own
+ self.assertEqual(self.by_name(response)['system']['valueUri'], ICD10)
+
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', code='117053')
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v2')
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', code='999', version='v1')
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Older code')
+ response = self.post('CIEL/$lookup/', [{'name': 'code', 'valueCode': '103'}], base='/orgs/CIEL')
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'HIV antibody')
+
+ self.assert_outcome(self.get('CIEL/$lookup', code='117053'), 404, 'not-found', 'not-found')
+ self.assert_outcome(self.get(f'{self.hiv_v1.id}/$lookup', code='P1', user=self.moh_member),
+ 404, 'not-found', 'not-found')
+ response = self.get('hiv/$lookup', base='/orgs/MOH', code='P1', user=self.moh_member)
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Private code')
+
+ def test_a_code_system_the_caller_is_not_shown_answers_as_a_missing_one(self):
+ member = self.get('$lookup', base='/orgs/MOH', user=self.moh_member, system=HIV, code='P1')
+ self.assertEqual(member.status_code, 200, member.data)
+ self.assertEqual(self.by_name(member)['display']['valueString'], 'Private code')
+
+ hidden = [self.get('$lookup', base=base, user=user, system=HIV, code=code)
+ for base, user in (('/fhir', None), ('/fhir', self.outsider), ('/fhir', self.moh_member),
+ ('/orgs/MOH', None), ('/orgs/MOH', self.outsider))
+ for code in ('P1', 'nope')]
+ self.hiv.is_active = False # now it doesn't exist
+ self.hiv.save()
+ missing = self.get('$lookup', system=HIV, code='P1')
+ self.assertEqual(missing.status_code, 422)
+ for response in hidden:
+ self.assertEqual((response.status_code, response.content), (missing.status_code, missing.content))
+
+ def test_a_private_copy_of_a_canonical_does_not_hide_the_public_one(self):
+ response = self.lookup(system=SHARED, code='S1')
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'Public copy')
+
+ def test_the_default_version_is_the_newest_release_the_caller_is_shown(self):
+ response = self.lookup(system=NEWEST_PRIVATE, code='N1')
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+ response = self.get('$lookup', base='/orgs/HL7', user=self.admin, system=NEWEST_PRIVATE, code='N1')
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v2')
+
+ def test_a_hidden_version_in_a_path_answers_as_a_missing_one(self):
+ """A private version named in an OCL path is not found, exactly as one that doesn't exist (Codex F1.1)."""
+ path = '/orgs/HL7/CodeSystem/newest/v2/'
+ hidden = self.lookup(system=path, code='N1')
+ self.assertEqual(hidden.status_code, 422, hidden.data)
+ self.assertEqual(self.by_name(self.lookup(system='/orgs/HL7/CodeSystem/newest/v1/', code='N1'))['version'],
+ {'name': 'version', 'valueString': 'v1'})
+ self.newest_private_v2.is_active = False
+ self.newest_private_v2.save()
+ self.assertEqual(self.lookup(system=path, code='N1').content, hidden.content)
+
+ def test_a_path_naming_a_version_and_another_version_named(self):
+ """
+ An OCL path naming one version with another named (version, url|version, HEAD, a coding's) names no version
+ OCL holds: a private version in the path answers byte for byte as a missing one, at /fhir/ and at an owner base
+ for a non-member (adversarial A.1).
+ """
+ path = '/orgs/HL7/CodeSystem/newest/v2/'
+ requests = [
+ ('get', {'system': path, 'version': 'v1', 'code': 'N1'}), ('get', {'system': f'{path}|v1', 'code': 'N1'}),
+ ('get', {'system': path, 'version': 'HEAD', 'code': 'N1'}),
+ ('post', [{'name': 'coding', 'valueCoding': {'system': path, 'version': 'v1', 'code': 'N1'}}])]
+ def ask(base, user, method, inputs):
+ if method == 'get':
+ return self.get('$lookup', base=base, user=user, **inputs)
+ return self.post('$lookup', inputs, base=base, user=user)
+ bases = [('/fhir', None), ('/orgs/HL7', None), ('/orgs/HL7', self.outsider)]
+ hidden = [(base, user, method, inputs, ask(base, user, method, inputs))
+ for base, user in bases for method, inputs in requests]
+ self.newest_private_v2.is_active = False
+ self.newest_private_v2.save()
+ for base, user, method, inputs, response in hidden:
+ missing = ask(base, user, method, inputs)
+ self.assertEqual((response.status_code, response.content), (missing.status_code, missing.content),
+ (base, method, inputs))
+ self.assertEqual(response.status_code, 422, (base, method, inputs))
+
+ def test_an_owner_base_costs_the_same_for_a_hidden_and_an_unknown_code_system(self):
+ """Anonymous, at an owner base: a private code system costs the queries an unknown one does (adversarial A.3).
+ For a signed-in non-member the membership check remains, and with the registry an owner-base match stops the
+ fall-through to the global entry: both are decision A.3's."""
+ if self.registry:
+ self.skipTest('with the registry, an owner-base match stops the fall-through (decision A.3)')
+ counts = {}
+ for name_, operation, params, status_code in (
+ ('hidden', '$lookup', {'system': HIV}, 422),
+ ('unknown', '$lookup', {'system': 'http://moh.example/fhir/CodeSystem/nope'}, 422),
+ ('hidden instance', 'hiv/$lookup', {}, 404), ('unknown instance', 'nope/$lookup', {}, 404)):
+ with CaptureQueriesContext(connection) as context:
+ response = self.get(operation, base='/orgs/MOH', code='P1', **params)
+ self.assertEqual(response.status_code, status_code, name_)
+ counts[name_] = len(context.captured_queries)
+ self.assertEqual(counts['hidden'], counts['unknown'])
+ self.assertEqual(counts['hidden instance'], counts['unknown instance'])
+
+ def test_an_instance_and_a_coding_of_another_system(self):
+ """At the instance level, a coding names the code system it is from: another one's code isn't looked up."""
+ response = self.post('CIEL/$lookup', [{'name': 'coding', 'valueCoding': {'system': ICD10, 'code': '117053'}}],
+ base='/orgs/CIEL')
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], ['Coding.system'])
+ response = self.post('CIEL/$lookup', [{'name': 'coding', 'valueCoding': {'system': CIEL, 'code': '117053'}}],
+ base='/orgs/CIEL')
+ self.assertEqual(response.status_code, 200, response.data)
+
+ def test_an_instance_and_another_version_or_system_named(self):
+ """
+ At the instance level, the id names the code system version: a version (the version parameter, or a coding's)
+ or a system parameter naming another one is a 400, never answered from the instance (Codex F2.1, F2.2).
+ """
+ global_ciel_v1 = f'{self.ciel_v1.id}/$lookup'
+ for base, operation, params, expression in (
+ ('/fhir', global_ciel_v1, {'code': '999', 'version': 'v2'}, 'version'),
+ ('/fhir', global_ciel_v1, {'code': '999', 'system': ICD10}, 'system'),
+ ('/orgs/CIEL', 'CIEL/$lookup', {'code': '117053', 'system': ICD10}, 'system')):
+ response = self.get(operation, base=base, **params)
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [expression], (base, params))
+ for base, operation, extra, expression in (
+ ('/fhir', global_ciel_v1, [], 'Coding.version'),
+ ('/orgs/CIEL', 'CIEL/$lookup', [{'name': 'version', 'valueString': 'v1'}], 'Coding.version'),
+ ('/orgs/CIEL', 'CIEL/$lookup', [{'name': 'system', 'valueUri': ICD10}], 'system')):
+ coding = {'system': CIEL, 'code': '999', 'version': 'v2' if expression.endswith('version') else 'v1'}
+ response = self.post(operation, [{'name': 'coding', 'valueCoding': coding}, *extra], base=base)
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [expression], (base, extra))
+ # the instance's own version, named any of these ways, is answered
+ response = self.get(global_ciel_v1, code='999', version='v1', system=f'{CIEL}|v1')
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+ response = self.post('CIEL/$lookup', [{'name': 'coding', 'valueCoding': {
+ 'system': CIEL, 'code': '999', 'version': 'v1'}}], base='/orgs/CIEL')
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+
+ def test_an_owner_instance_serves_head_when_named(self):
+ """
+ At an owner base, CodeSystem/{id}/$lookup serves HEAD when version=HEAD names it, as the type level does
+ (design D6), with HEAD's visibility rule; a global id names a release, so HEAD there is a conflict (Codex F3.1).
+ """
+ ConceptFactory(parent=self.ciel, mnemonic='H1', names=[name('In HEAD only', preferred=True)])
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', code='H1', version='HEAD')
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = self.by_name(response)
+ self.assertEqual((answer['version']['valueString'], answer['display']['valueString']), ('HEAD', 'In HEAD only'))
+ self.assert_outcome(self.get('CIEL/$lookup', base='/orgs/CIEL', code='H1'), 404, 'not-found', 'invalid-code')
+ response = self.get('hiv/$lookup', base='/orgs/MOH', user=self.moh_member, code='P1', version='HEAD')
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'HEAD')
+ hidden = self.get('hiv/$lookup', base='/orgs/MOH', user=self.outsider, code='P1', version='HEAD')
+ self.assertEqual(hidden.status_code, 404)
+ self.hiv.is_active = False
+ self.hiv.save()
+ self.assertEqual(self.get('hiv/$lookup', base='/orgs/MOH', code='P1', version='HEAD').content, hidden.content)
+ response = self.get(f'{self.ciel_v1.id}/$lookup', code='999', version='HEAD')
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], ['version'])
+
+ def test_a_deleted_concept_version_is_not_found(self):
+ """A concept version deleted (is_active false) is gone, a retired one stays (Codex F1.4)."""
+ ConceptFactory(parent=self.ciel, mnemonic='555', sources=[self.ciel_v2], is_active=False,
+ names=[name('Deleted', preferred=True)])
+ self.assert_outcome(self.lookup(system=CIEL, code='555'), 404, 'not-found', 'invalid-code')
+
+ def test_an_owner_instance_serves_a_named_unreleased_version(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3', canonical_url=CIEL)
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', code='D1', version='v3')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = self.by_name(response)
+ self.assertEqual((answer['version']['valueString'], answer['display']['valueString']), ('v3', 'In v3 only'))
+ self.assert_outcome(self.get('CIEL/$lookup', base='/orgs/CIEL', code='D1'),
+ 404, 'not-found', 'invalid-code')
+ self.assert_outcome(self.get(f'{draft.id}/$lookup', code='D1', version='v3'),
+ 404, 'not-found', 'not-found')
+ response = self.client_for().get('/orgs/CIEL/CodeSystem/CIEL/', {'version': 'v3'})
+ self.assert_outcome(response, 404, 'not-found', 'not-found')
+
+ def test_a_private_unreleased_instance_version_is_visible_to_its_owner(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3', canonical_url=CIEL, public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+ self.ciel_org.members.add(self.moh_member)
+ for user in (None, self.outsider):
+ hidden = self.get('CIEL/$lookup', base='/orgs/CIEL', user=user, code='D1', version='v3')
+ missing = self.get('CIEL/$lookup', base='/orgs/CIEL', user=user, code='D1', version='missing')
+ self.assert_outcome(hidden, 404, 'not-found', 'not-found')
+ self.assert_outcome(missing, 404, 'not-found', 'not-found')
+ self.assertEqual(hidden.content, missing.content)
+
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', user=self.moh_member, code='D1', version='v3')
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = self.by_name(response)
+ self.assertEqual((answer['version']['valueString'], answer['display']['valueString']), ('v3', 'In v3 only'))
+ self.assert_outcome(self.get('CIEL/$lookup', base='/orgs/CIEL', user=self.moh_member,
+ code='D1', version='missing'), 404, 'not-found', 'not-found')
+
+ def test_a_named_unreleased_instance_version_without_a_canonical_takes_its_repository_canonical(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3')
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', system=CIEL, code='D1', version='v3')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = self.by_name(response)
+ self.assertEqual(
+ (answer['system']['valueUri'], answer['version']['valueString'], answer['display']['valueString']),
+ (CIEL, 'v3', 'In v3 only'))
+
+ def test_a_named_unreleased_instance_version_with_its_own_canonical_takes_its_repository_canonical_too(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3', canonical_url='https://example.org/old-ciel')
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+
+ for system in (CIEL, 'https://example.org/old-ciel'):
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', system=system, code='D1', version='v3')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['display']['valueString'], 'In v3 only')
+
+ def test_a_version_named_with_a_path_takes_its_repository_canonical(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3')
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+
+ for params in ({'system': '/orgs/CIEL/CodeSystem/CIEL/', 'version': 'v3'},
+ {'system': '/orgs/CIEL/CodeSystem/CIEL/v3/'}):
+ response = self.lookup(code='D1', **params)
+
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = self.by_name(response)
+ self.assertEqual((answer['system']['valueUri'], answer['version']['valueString']), (CIEL, 'v3'))
+
+ def test_a_released_owner_instance_version_uses_three_queries(self):
+ with self.assertNumQueries(3):
+ response = self.get('CIEL/$lookup', base='/orgs/CIEL', code='999', version='v1')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(self.by_name(response)['version']['valueString'], 'v1')
+
+ def test_a_fixed_number_of_queries(self):
+ """Resolution (the toggle, the repository, its release; the registry adds its entry and namespace owner), the
+ concept and its names, however many concepts the code system holds."""
+ count = 7 if self.registry else 5
+ with self.assertNumQueries(count):
+ self.lookup(system=CIEL, code='117053')
+ for number in range(30):
+ ConceptFactory(parent=self.ciel, mnemonic=f'x{number}', sources=[self.ciel_v2], names=[name(f'x {number}')])
+ with self.assertNumQueries(count):
+ self.lookup(system=CIEL, code='117053')
+ with self.assertNumQueries(count - 1): # no names to read
+ self.lookup(system=CIEL, code='nope')
+
+
+class LookupWithoutRegistryTest(LookupCases, OCLTestCase):
+ registry = False
+
+
+class LookupWithRegistryTest(LookupCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_ops_validate_code.py b/core/fhir/tests_ops_validate_code.py
new file mode 100644
index 000000000..00234b0da
--- /dev/null
+++ b/core/fhir/tests_ops_validate_code.py
@@ -0,0 +1,571 @@
+"""
+CodeSystem/$validate-code and ValueSet/$validate-code (SVCM ITI-99, FHIR R4 $validate-code) at the global base /fhir/
+and at owner bases, GET and POST, at the type and instance levels, with the URL registry toggle off and on: the
+answer's code, system, version used and display; FHIRsmith's issues and message ids (unknown system or version, unknown
+code, wrong display, inactive, not in the value set); value set membership from the resolved release's default
+expansion; a value set that doesn't resolve is a 422 OperationOutcome; whatever the caller isn't shown answers as
+missing; a fixed number of queries. OpenConceptLab/ocl_online#451.
+"""
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+
+from core.collections.models import Collection
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.outcomes import MESSAGE_ID_EXTENSION
+from core.fhir.tests_ops_lookup import (
+ OpsCorpusMixin, name, CIEL, ICD10, HIV, REST, UNKNOWN, FEVERS, SHARED)
+from core.fhir.tests_search import source, collection
+from core.orgs.tests.factories import OrganizationFactory
+from core.url_registry.factories import GlobalURLRegistryFactory
+
+DRAFT_ONLY = 'http://example.org/fhir/CodeSystem/head-only'
+GONE = 'http://example.org/fhir/CodeSystem/gone'
+DRAFT_UNRELEASED = 'http://example.org/fhir/CodeSystem/unreleased'
+RENAMED = 'http://example.org/fhir/CodeSystem/renamed'
+RENAMED_OLD = 'http://example.org/fhir/CodeSystem/renamed-old'
+
+
+def issues_of(parameters):
+ """The `issues` OperationOutcome's issues as severity|code|tx-issue-type|expression|message id."""
+ outcome = parameters.get('issues', {}).get('resource', {'issue': []})
+ return sorted(
+ '|'.join([item['severity'], item['code'], item['details']['coding'][0]['code'],
+ ','.join(item.get('expression', [])),
+ ','.join(e['valueString'] for e in item.get('extension', []) if e['url'] == MESSAGE_ID_EXTENSION)])
+ for item in outcome['issue'])
+
+
+class ValidateCodeCases(OpsCorpusMixin):
+ def validate(self, resource='CodeSystem', base='/fhir', user=None, **params):
+ return self.get('$validate-code', base=base, user=user, resource=resource, **params)
+
+ def answer(self, response):
+ """{name: value} of a Parameters answer, the issues as issues_of has them."""
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['parameter'][0]['name'], 'result')
+ parameters = self.by_name(response)
+ values = {key: next(value for name_, value in parameter.items() if name_ not in ('name',))
+ for key, parameter in parameters.items() if key != 'issues'}
+ if 'issues' in parameters:
+ values['issues'] = issues_of(parameters)
+ self.assertIn('message', values)
+ return values
+
+ # CodeSystem
+
+ def test_a_valid_code_answers_its_code_system_version_and_display(self):
+ self.assertEqual(self.answer(self.validate(url=CIEL, code='117053')), {
+ 'result': True, 'system': CIEL, 'code': '117053', 'version': 'v2', 'display': 'Infectious enteritis'})
+ self.assertEqual(self.answer(self.validate(url=CIEL, code='999', version='v1'))['version'], 'v1')
+ self.assertEqual(self.answer(self.validate(url=ICD10, code='S20.4', version='ICD102019'))['result'], True)
+
+ def test_a_display_is_one_of_the_concepts_names_in_any_case(self):
+ for display in ('Infectious enteritis', 'INFECTIOUS ENTERITIS', 'Entérite infectieuse',
+ 'Enteritis of infectious origin'):
+ self.assertTrue(self.answer(self.validate(url=CIEL, code='117053', display=display))['result'], display)
+ self.assertEqual(self.answer(self.validate(url=CIEL, code='117053', display='Not it')), {
+ 'result': False, 'system': CIEL, 'code': '117053', 'version': 'v2', 'display': 'Infectious enteritis',
+ 'message': self.answer(self.validate(url=CIEL, code='117053', display='Not it'))['message'],
+ 'issues': ['error|invalid|invalid-display|display|Display_Name_for__should_be_one_of__instead_of']})
+ # a concept without names has none to check a display against
+ self.assertEqual(self.answer(self.validate(url=REST, code='batch', display='Anything')), {
+ 'result': True, 'system': REST, 'code': 'batch', 'version': '4.0.1'})
+
+ def test_a_retired_code_is_valid_and_inactive_with_a_warning(self):
+ answer = self.answer(self.validate(url=CIEL, code='103'))
+ self.assertEqual((answer['result'], answer['inactive'], answer['issues']),
+ (True, True, ['warning|business-rule|code-comment|code|INACTIVE_CONCEPT_FOUND']))
+
+ def test_an_unknown_code(self):
+ answer = self.answer(self.validate(url=CIEL, code='nope'))
+ self.assertEqual((answer['result'], answer['version'], answer['issues']),
+ (False, 'v2', ['error|code-invalid|invalid-code|code|Unknown_Code_in_Version']))
+
+ def test_an_unknown_system_or_version_with_or_without_a_code(self):
+ self.assertEqual(self.answer(self.validate(url=UNKNOWN, code='1')), {
+ 'result': False, 'system': UNKNOWN, 'code': '1', 'x-caused-by-unknown-system': UNKNOWN,
+ 'message': f"A definition for CodeSystem '{UNKNOWN}' could not be found, so the code cannot be validated",
+ 'issues': ['error|not-found|not-found|system|UNKNOWN_CODESYSTEM']})
+ answer = self.answer(self.validate(url=CIEL, code='117053', version='nope'))
+ self.assertEqual((answer['version'], answer['x-caused-by-unknown-system'], answer['issues']),
+ ('nope', f'{CIEL}|nope', ['error|not-found|not-found|system|UNKNOWN_CODESYSTEM_VERSION']))
+ self.assertNotIn('code', self.answer(self.validate(url=UNKNOWN)))
+ answer = self.answer(self.validate(url=f'{CIEL}|nope', code='117053'))
+ self.assertEqual((answer['x-caused-by-unknown-system'], answer['issues']),
+ (f'{CIEL}|nope', ['error|not-found|not-found|system|UNKNOWN_CODESYSTEM_VERSION']))
+
+ def test_a_code_system_the_caller_is_not_shown_answers_as_a_missing_one(self):
+ member = self.validate(base='/orgs/MOH', user=self.moh_member, url=HIV, code='P1')
+ self.assertTrue(self.answer(member)['result'])
+ hidden = [self.validate(base=base, user=user, url=HIV, **code)
+ for base, user in (('/fhir', None), ('/fhir', self.moh_member), ('/orgs/MOH', None),
+ ('/orgs/MOH', self.outsider))
+ for code in ({'code': 'P1'}, {'code': 'nope'}, {})]
+ self.hiv.is_active = False
+ self.hiv.save()
+ missing = {code: self.validate(url=HIV, **({'code': code} if code else {})).content
+ for code in ('P1', 'nope', None)}
+ for response, code in zip(hidden, ['P1', 'nope', None] * 4):
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, missing[code])
+
+ def test_a_private_copy_of_a_canonical_does_not_hide_the_public_one(self):
+ self.assertEqual(self.answer(self.validate(url=SHARED, code='S1'))['display'], 'Public copy')
+
+ def test_post_and_a_coding(self):
+ response = self.post('$validate-code', [
+ {'name': 'url', 'valueUri': CIEL}, {'name': 'code', 'valueString': '117053'},
+ {'name': 'display', 'valueString': 'Infectious enteritis'}])
+ self.assertTrue(self.answer(response)['result'])
+ response = self.post('$validate-code', [{'name': 'coding', 'valueCoding': {
+ 'system': CIEL, 'code': '117053', 'version': 'v1', 'display': 'Wrong'}}])
+ answer = self.answer(response)
+ self.assertEqual((answer['version'], answer['issues']), ('v1', [
+ 'error|invalid|invalid-display|Coding.display|Display_Name_for__should_be_one_of__instead_of']))
+ response = self.validate(coding=f'{CIEL}|103')
+ self.assertTrue(self.answer(response)['inactive'])
+
+ def test_a_codeable_concept_and_inputs_that_are_not_text(self):
+ response = self.post('$validate-code', [{'name': 'codeableConcept', 'valueCodeableConcept': {
+ 'coding': [{'system': CIEL, 'code': '117053', 'display': 'Infectious enteritis'}]}}])
+ self.assertTrue(self.answer(response)['result'])
+ response = self.post('$validate-code', [{'name': 'codeableConcept', 'valueCodeableConcept': {
+ 'coding': [{'system': CIEL, 'code': '117053'}, {'system': ICD10, 'code': 'S20.4'}]}}])
+ self.assert_outcome(response, 400, 'not-supported', 'not-supported')
+ self.assertEqual(response.data['issue'][0]['expression'], ['codeableConcept'])
+ for parameter, expression in (({'name': 'url', 'valueBoolean': True}, 'url'),
+ ({'name': 'coding', 'valueCoding': {'system': CIEL, 'code': 7}}, 'coding')):
+ response = self.post('$validate-code', [parameter, {'name': 'code', 'valueCode': '117053'}])
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [expression])
+
+ def test_a_code_stored_percent_encoded(self):
+ """OCL stores a code with reserved characters percent-encoded (ICD-11's ND53.Y&XK70); FHIR names it as it is."""
+ ConceptFactory(parent=self.ciel, mnemonic='ND53.Y%26XK70', sources=[self.ciel_v2],
+ names=[name('Encoded', preferred=True)])
+ self.assertEqual(self.answer(self.validate(url=CIEL, code='ND53.Y&XK70')), {
+ 'result': True, 'system': CIEL, 'code': 'ND53.Y&XK70', 'version': 'v2', 'display': 'Encoded'})
+
+ def test_the_instance_level(self):
+ response = self.get(f'{self.ciel_v1.id}/$validate-code', code='999')
+ self.assertEqual(self.answer(response)['version'], 'v1')
+ response = self.get('CIEL/$validate-code', base='/orgs/CIEL', code='117053', display='Nope')
+ self.assertFalse(self.answer(response)['result'])
+
+ def test_xml(self):
+ response = self.client_for().get(
+ f'/fhir/CodeSystem/$validate-code?url={UNKNOWN}&code=1&_format=xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'', response.content)
+ self.assertIn(b'')))
+ self.assertEqual(answers[0], answers[1])
+ self.assertEqual(answers[0], answers[2])
+
+ def test_a_deleted_code_system_release(self):
+ """A deactivated release (is_active false) whose concept and expansion links remain is no version a member is
+ in, nor one to answer: the member is answered from what remains (Codex F2.3)."""
+ gone = source(OrganizationFactory(mnemonic='Gone'), 'gone', canonical_url=GONE)
+ gone_v1 = source(gone.organization, 'gone', 'v1', released=True, canonical_url=GONE)
+ self.fevers_v1.expansion.concepts.add(ConceptFactory(
+ parent=gone, mnemonic='G1', sources=[gone_v1], names=[name('G one', preferred=True)]))
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=GONE, code='G1'))['version'], 'v1')
+ gone_v1.is_active = False
+ gone_v1.save()
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=GONE, code='G1')), {
+ 'result': True, 'system': GONE, 'code': 'G1', 'display': 'G one'})
+ answer = self.answer(self.validate_in(url=FEVERS, system=GONE, code='G1', systemVersion='v1'))
+ self.assertEqual((answer['result'], answer['issues']), (False, [
+ 'error|code-invalid|not-in-vs|code|None_of_the_provided_codes_are_in_the_value_set_one',
+ 'error|not-found|not-found|system|UNKNOWN_CODESYSTEM_VERSION']))
+
+ def test_a_deleted_concept_version_is_not_valid(self):
+ """Deleted (is_active false) concept versions are gone from code systems and value sets (Codex F1.4)."""
+ deleted = ConceptFactory(parent=self.ciel, mnemonic='555', sources=[self.ciel_v2], is_active=False,
+ names=[name('Deleted', preferred=True)])
+ self.fevers_v1.expansion.concepts.add(deleted)
+ self.assertEqual(self.answer(self.validate(url=CIEL, code='555'))['issues'],
+ ['error|code-invalid|invalid-code|code|Unknown_Code_in_Version'])
+ self.assertFalse(self.answer(self.validate_in(url=FEVERS, system=CIEL, code='555'))['result'])
+
+ def test_the_version_answered_is_the_one_the_expansion_pins(self):
+ """An expansion's explicit source version is the version answered, though a newer release holds the same
+ concept (Codex F1.3); then the version it evaluated; never a HEAD or unreleased version (F1.5)."""
+ pinned = collection(self.acme, 'pinned', 'v1', released=True, canonical_url='https://example.org/pinned')
+ collection(self.acme, 'pinned', canonical_url='https://example.org/pinned')
+ expansion = ExpansionFactory(mnemonic='autoexpand-v1', collection_version=pinned)
+ expansion.concepts.add(self.infectious) # in CIEL v1 and v2
+ expansion.explicit_source_versions.add(self.ciel_v1)
+ expansion.evaluated_source_versions.add(self.ciel_v2)
+ pinned.expansion_uri = expansion.uri
+ pinned.save()
+ if self.registry:
+ GlobalURLRegistryFactory(url='https://example.org/pinned', namespace=self.acme.uri)
+ self.assertEqual(self.answer(self.validate_in(
+ url='https://example.org/pinned', system=CIEL, code='117053'))['version'], 'v1')
+
+ def test_a_member_is_validated_by_the_system_expand_reports_it_in(self):
+ """
+ A release with a canonical URL of its own names its members, else its repository's does: ValueSet $validate-code
+ accepts the system $expand reports a member in, with that release's version (WP7 Codex F1.3; a read names the
+ release the same way).
+ """
+ renamed = source(self.acme, 'renamed', canonical_url=RENAMED)
+ old = source(self.acme, 'renamed', 'v1', released=True, canonical_url=RENAMED_OLD)
+ plain = source(self.acme, 'renamed', 'v2', released=True, canonical_url=None)
+ self.fevers_v1.expansion.concepts.add(*(
+ ConceptFactory(parent=renamed, mnemonic=code, sources=[version],
+ names=[name(f'Renamed {code}', preferred=True)])
+ for code, version in (('R1', old), ('R2', plain))))
+ expanded = self.get('$expand', resource='ValueSet', url=FEVERS, filter='renamed').data['expansion']['contains']
+ self.assertEqual([(member['code'], member['system'], member['version']) for member in expanded],
+ [('R1', RENAMED_OLD, 'v1'), ('R2', RENAMED, 'v2')])
+ for code, system, version in (('R1', RENAMED_OLD, 'v1'), ('R2', RENAMED, 'v2')):
+ answer = self.answer(self.validate_in(url=FEVERS, system=system, code=code))
+ self.assertEqual((answer['result'], answer['system'], answer.get('version')), (True, system, version), code)
+
+ def test_a_member_from_a_code_system_with_no_release(self):
+ """A released value set's expansion defines its members, whatever the release state of their code systems;
+ the version is answered only when a release the caller is shown holds the concept (Codex F1.5)."""
+ drafts = OrganizationFactory(mnemonic='Drafts')
+ head_only = source(drafts, 'head-only', canonical_url=DRAFT_ONLY)
+ unreleased = source(drafts, 'unreleased', canonical_url=DRAFT_UNRELEASED)
+ unreleased_v1 = source(drafts, 'unreleased', 'v1', canonical_url=DRAFT_UNRELEASED)
+ expansion = self.fevers_v1.expansion
+ expansion.concepts.add(
+ ConceptFactory(parent=head_only, mnemonic='D1', names=[name('D one', preferred=True)]),
+ ConceptFactory(parent=unreleased, mnemonic='U1', sources=[unreleased_v1],
+ names=[name('U one', preferred=True)]))
+ expansion.explicit_source_versions.add(unreleased_v1)
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=DRAFT_ONLY, code='D1')), {
+ 'result': True, 'system': DRAFT_ONLY, 'code': 'D1', 'display': 'D one'})
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=DRAFT_UNRELEASED, code='U1')), {
+ 'result': True, 'system': DRAFT_UNRELEASED, 'code': 'U1', 'display': 'U one'})
+
+ def test_code_system_query_counts(self):
+ count = 7 if self.registry else 5
+ with self.assertNumQueries(count):
+ self.validate(url=CIEL, code='117053', display='Infectious enteritis')
+ for number in range(30):
+ ConceptFactory(parent=self.ciel, mnemonic=f'x{number}', sources=[self.ciel_v2], names=[name(f'x {number}')])
+ with self.assertNumQueries(count):
+ self.validate(url=CIEL, code='117053', display='Infectious enteritis')
+
+ # ValueSet
+
+ def validate_in(self, base='/fhir', user=None, **params):
+ return self.validate(resource='ValueSet', base=base, user=user, **params)
+
+ def test_membership_is_the_default_expansion(self):
+ """A code in the release's expansion is a member, whether or not a reference names it."""
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=CIEL, code='117053')), {
+ 'result': True, 'system': CIEL, 'code': '117053', 'version': 'v2', 'display': 'Infectious enteritis'})
+ answer = self.answer(self.validate_in(url=FEVERS, system=CIEL, code='103'))
+ self.assertEqual((answer['result'], answer['inactive']), (True, True))
+ answer = self.answer(self.validate_in(url=FEVERS, system=REST, code='batch', display='Whatever'))
+ self.assertEqual(answer, {'result': True, 'system': REST, 'code': 'batch', 'version': '4.0.1'})
+
+ def test_a_code_of_another_system_is_not_a_member(self):
+ answer = self.answer(self.validate_in(url=FEVERS, system=ICD10, code='117053'))
+ self.assertEqual(answer['issues'], [
+ 'error|code-invalid|invalid-code|code|Unknown_Code_in_Version',
+ 'error|code-invalid|not-in-vs|code|None_of_the_provided_codes_are_in_the_value_set_one'])
+ self.assertNotIn('version', answer)
+
+ def test_a_code_its_system_holds_outside_the_value_set(self):
+ self.assertEqual(self.answer(self.validate_in(url=FEVERS, system=CIEL, code='999', systemVersion='v1')), {
+ 'result': False, 'system': CIEL, 'code': '999', 'version': 'v1', 'display': 'Older code',
+ 'message': f"The provided code '{CIEL}#999' was not found in the value set '{FEVERS}|v1'",
+ 'issues': ['error|code-invalid|not-in-vs|code|None_of_the_provided_codes_are_in_the_value_set_one']})
+
+ def test_an_unknown_system_in_a_value_set(self):
+ answer = self.answer(self.validate_in(url=FEVERS, system=UNKNOWN, code='117053'))
+ self.assertEqual((answer['x-unknown-system'], answer['issues']), (UNKNOWN, [
+ 'error|code-invalid|not-in-vs|code|None_of_the_provided_codes_are_in_the_value_set_one',
+ 'error|not-found|not-found|system|UNKNOWN_CODESYSTEM']))
+
+ def test_a_wrong_display_in_a_value_set(self):
+ answer = self.answer(self.validate_in(url=FEVERS, system=CIEL, code='117053', display='Not it'))
+ self.assertEqual((answer['result'], answer['display'], answer['issues']), (False, 'Infectious enteritis', [
+ 'error|invalid|invalid-display|display|Display_Name_for__should_be_one_of__instead_of']))
+
+ def test_a_member_from_a_code_system_the_caller_is_not_shown_answers_as_an_unknown_system(self):
+ self.assertTrue(self.answer(self.validate_in(
+ base='/orgs/Acme', user=self.moh_member, url=FEVERS, system=HIV, code='P1'))['result'])
+ hidden = self.validate_in(url=FEVERS, system=HIV, code='P1', user=self.moh_member)
+ self.hiv.is_active = False
+ self.hiv.save()
+ self.assertEqual(hidden.content, self.validate_in(url=FEVERS, system=HIV, code='P1').content)
+ self.assertIn('x-unknown-system', self.answer(hidden))
+
+ def test_a_value_set_that_does_not_resolve_is_a_422(self):
+ response = self.validate_in(url='http://example.org/no-such-value-set', system=CIEL, code='117053')
+ self.assert_outcome(response, 422, 'not-found', 'not-found')
+ self.assertEqual(response.data['issue'][0]['extension'][0]['valueString'], 'Unable_to_resolve_value_Set_')
+ self.assertNotIn('expression', response.data['issue'][0])
+ self.assert_outcome(self.validate_in(url=FEVERS, valueSetVersion='nope', system=CIEL, code='117053'),
+ 422, 'not-found', 'not-found')
+
+ def test_a_value_set_the_caller_is_not_shown_answers_as_a_missing_one(self):
+ self.fevers.public_access = self.fevers_v1.public_access = 'None'
+ self.fevers.save()
+ self.fevers_v1.save()
+ self.acme.members.add(self.outsider)
+ self.assertTrue(self.answer(self.validate_in(
+ base='/orgs/Acme', user=self.outsider, url=FEVERS, system=CIEL, code='117053'))['result'])
+ hidden = [self.validate_in(base=base, user=user, url=FEVERS, system=CIEL, code='117053')
+ for base, user in (('/fhir', None), ('/fhir', self.outsider), ('/orgs/Acme', self.jdoe))]
+ self.fevers.is_active = False
+ self.fevers.save()
+ missing = self.validate_in(url=FEVERS, system=CIEL, code='117053')
+ for response in hidden:
+ self.assertEqual((response.status_code, response.content), (422, missing.content))
+
+ def test_value_set_post_and_instance_level(self):
+ response = self.post('$validate-code', [
+ {'name': 'url', 'valueUri': FEVERS}, {'name': 'code', 'valueString': '117053'},
+ {'name': 'system', 'valueString': CIEL}], resource='ValueSet')
+ self.assertTrue(self.answer(response)['result'])
+ response = self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet', system=CIEL, code='117053')
+ self.assertTrue(self.answer(response)['result'])
+ response = self.get(f'{self.fevers_v1.id}/$validate-code', resource='ValueSet', system=CIEL, code='999')
+ self.assertFalse(self.answer(response)['result'])
+ self.assert_outcome(self.get('fevers/$validate-code', resource='ValueSet', system=CIEL, code='117053'),
+ 404, 'not-found', 'not-found')
+
+ def test_a_value_set_instance_and_another_version_or_url_named(self):
+ """At the instance level the id names the value set version: a valueSetVersion or url naming another one is a
+ 400, never answered from the instance (the ValueSet side of Codex F2.1 and F2.2)."""
+ global_fevers = f'{self.fevers_v1.id}/$validate-code'
+ for base, operation, params, expression in (
+ ('/fhir', global_fevers, {'valueSetVersion': 'v2'}, 'valueSetVersion'),
+ ('/fhir', global_fevers, {'url': 'https://example.org/another'}, 'url'),
+ ('/orgs/Acme', 'fevers/$validate-code', {'url': f'{FEVERS}|v2'}, 'url')):
+ response = self.get(operation, base=base, resource='ValueSet', system=CIEL, code='117053', **params)
+ self.assert_outcome(response, 400, 'invalid', 'invalid-data')
+ self.assertEqual(response.data['issue'][0]['expression'], [expression], (base, params))
+ response = self.get(global_fevers, resource='ValueSet', system=CIEL, code='117053', valueSetVersion='v1',
+ url=FEVERS)
+ self.assertTrue(self.answer(response)['result'])
+
+ def test_without_a_value_set_the_code_system_alone_answers(self):
+ self.assertEqual(self.answer(self.validate_in(system=CIEL, code='999', systemVersion='v1'))['result'], True)
+ self.assertIn('x-caused-by-unknown-system', self.answer(self.validate_in(system=UNKNOWN, code='1')))
+
+ def test_value_set_query_counts(self):
+ """Resolving the value set, the code's concepts, those in the expansion, the code system version of the member
+ (with whether the caller is shown it), the concept and its names, whatever the size of the value set; a code
+ outside it adds its code system's resolution."""
+ resolution = 5 if self.registry else 3 # the toggle, the repository, its release (+ the registry's two)
+ with self.assertNumQueries(resolution + 5):
+ self.validate_in(url=FEVERS, system=CIEL, code='117053')
+ for number in range(30):
+ concept = ConceptFactory(parent=self.ciel, mnemonic=f'x{number}', sources=[self.ciel_v2])
+ self.fevers_v1.expansion.concepts.add(concept)
+ with self.assertNumQueries(resolution + 5):
+ self.validate_in(url=FEVERS, system=CIEL, code='117053')
+ with self.assertNumQueries(2 * resolution + 4): # its concepts, none in the expansion; the code system's
+ self.validate_in(url=FEVERS, system=CIEL, code='999', systemVersion='v1')
+ with self.assertNumQueries(2 * resolution + 2): # no concept with the code: no membership to read
+ self.validate_in(url=FEVERS, system=CIEL, code='nope')
+
+ def test_an_owner_value_set_instance_serves_a_named_unreleased_version(self):
+ draft = collection(self.acme, 'fevers', 'v2', canonical_url=FEVERS)
+ expansion = ExpansionFactory(mnemonic='autoexpand-v2', collection_version=draft)
+ expansion.concepts.add(self.older)
+ draft.expansion_uri = expansion.uri
+ draft.save()
+
+ response = self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet', system=CIEL,
+ code='999', valueSetVersion='v2')
+
+ answer = self.answer(response)
+ self.assertEqual((answer['result'], answer['display']), (True, 'Older code'))
+ self.assertFalse(self.answer(self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet',
+ system=CIEL, code='999'))['result'])
+ self.assert_outcome(self.get(f'{draft.id}/$validate-code', resource='ValueSet', system=CIEL, code='999',
+ valueSetVersion='v2'), 404, 'not-found', 'not-found')
+ response = self.client_for().get('/orgs/Acme/ValueSet/fevers/', {'version': 'v2'})
+ self.assert_outcome(response, 404, 'not-found', 'not-found')
+
+ def test_a_private_unreleased_value_set_instance_version_is_visible_to_its_owner(self):
+ draft = collection(self.acme, 'fevers', 'v2', canonical_url=FEVERS, public_access=ACCESS_TYPE_NONE)
+ expansion = ExpansionFactory(mnemonic='autoexpand-v2', collection_version=draft)
+ expansion.concepts.add(self.older)
+ draft.expansion_uri = expansion.uri
+ draft.save()
+ self.acme.members.add(self.moh_member)
+ for user in (None, self.outsider):
+ hidden = self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet', user=user,
+ system=CIEL, code='999', valueSetVersion='v2')
+ missing = self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet', user=user,
+ system=CIEL, code='999', valueSetVersion='missing')
+ self.assert_outcome(hidden, 404, 'not-found', 'not-found')
+ self.assert_outcome(missing, 404, 'not-found', 'not-found')
+ self.assertEqual(hidden.content, missing.content)
+
+ answer = self.answer(self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet',
+ user=self.moh_member, system=CIEL, code='999', valueSetVersion='v2'))
+ self.assertEqual((answer['result'], answer['display']), (True, 'Older code'))
+ self.assert_outcome(self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet',
+ user=self.moh_member, system=CIEL, code='999', valueSetVersion='missing'),
+ 404, 'not-found', 'not-found')
+
+ def test_a_named_unreleased_value_set_instance_version_without_a_canonical_takes_its_repository_canonical(self):
+ draft = collection(self.acme, 'fevers', 'v2')
+ expansion = ExpansionFactory(mnemonic='autoexpand-v2', collection_version=draft)
+ expansion.concepts.add(self.older)
+ draft.expansion_uri = expansion.uri
+ draft.save()
+
+ answer = self.answer(self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet', url=FEVERS,
+ system=CIEL, code='999', valueSetVersion='v2'))
+
+ self.assertEqual((answer['result'], answer['display']), (True, 'Older code'))
+
+ def test_a_code_system_version_named_with_a_path_takes_its_repository_canonical(self):
+ draft = source(self.ciel_org, 'CIEL', 'v3')
+ ConceptFactory(parent=self.ciel, mnemonic='D1', sources=[draft],
+ names=[name('In v3 only', preferred=True)])
+
+ coding = {'name': 'coding', 'valueCoding': {'system': CIEL, 'code': 'D1'}}
+ for parameters in ([{'name': 'url', 'valueUri': '/orgs/CIEL/CodeSystem/CIEL/'},
+ {'name': 'version', 'valueString': 'v3'}, coding],
+ [{'name': 'url', 'valueUri': '/orgs/CIEL/CodeSystem/CIEL/v3/'}, coding]):
+ answer = self.answer(self.post('$validate-code', parameters))
+
+ self.assertEqual((answer['result'], answer['system'], answer['version']), (True, CIEL, 'v3'))
+
+ def test_a_released_owner_value_set_instance_version_uses_six_queries(self):
+ with self.assertNumQueries(6):
+ response = self.get('fevers/$validate-code', base='/orgs/Acme', resource='ValueSet',
+ system=CIEL, code='117053', valueSetVersion='v1')
+
+ self.assertTrue(self.answer(response)['result'])
+
+
+class ValidateCodeWithoutRegistryTest(ValidateCodeCases, OCLTestCase):
+ registry = False
+
+
+class ValidateCodeWithRegistryTest(ValidateCodeCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_outcomes.py b/core/fhir/tests_outcomes.py
new file mode 100644
index 000000000..3dd4d2d20
--- /dev/null
+++ b/core/fhir/tests_outcomes.py
@@ -0,0 +1,229 @@
+from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied as DjangoPermissionDenied
+from django.http import Http404
+from django.test import SimpleTestCase
+from fhir.resources.R4B.operationoutcome import OperationOutcome
+from rest_framework import exceptions
+
+from core.common.exceptions import Http400
+from core.fhir import outcomes
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+
+
+def issue_summary(outcome):
+ """severity|code|tx-issue-type|expression for each issue: what the parity suite compares."""
+ summary = []
+ for issue in outcome['issue']:
+ coding = [c['code'] for c in issue['details']['coding'] if c['system'] == TX_ISSUE_TYPE_SYSTEM]
+ expression = ','.join(issue.get('expression', []))
+ summary.append(f"{issue['severity']}|{issue['code']}|{','.join(coding)}|{expression}")
+ return summary
+
+
+class TypedErrorTest(SimpleTestCase):
+ def test_each_typed_error_has_its_code_tx_issue_type_and_status(self):
+ cases = [
+ (outcomes.NotFoundError, 404, 'not-found', 'not-found'),
+ (outcomes.InvalidError, 400, 'invalid', 'invalid-data'),
+ (outcomes.NotSupportedError, 400, 'not-supported', 'not-supported'),
+ (outcomes.TooCostlyError, 422, 'too-costly', 'too-costly'),
+ (outcomes.ProcessingError, 422, 'processing', 'invalid-data'),
+ ]
+ for klass, status, code, tx_issue_type in cases:
+ error = klass('Something is wrong', expression='system')
+ self.assertEqual(error.status_code, status, klass)
+ outcome = error.outcome()
+ self.assertEqual(issue_summary(outcome), [f'error|{code}|{tx_issue_type}|system'], klass)
+ self.assertEqual(outcome['issue'][0]['details']['text'], 'Something is wrong')
+ OperationOutcome.model_validate(outcome)
+
+ def test_a_typed_error_can_name_its_tx_issue_type_status_and_message_id(self):
+ error = outcomes.NotFoundError(
+ "Unknown code 'x'", expression=['code', 'coding.code'], tx_issue_type='invalid-code',
+ message_id='Unknown_Code_in_Version', status_code=422)
+
+ outcome = error.outcome()
+
+ self.assertEqual(error.status_code, 422)
+ self.assertEqual(issue_summary(outcome), ['error|not-found|invalid-code|code,coding.code'])
+ self.assertEqual(outcome['issue'][0]['extension'], [
+ {'url': outcomes.MESSAGE_ID_EXTENSION, 'valueString': 'Unknown_Code_in_Version'}])
+ OperationOutcome.model_validate(outcome)
+
+ def test_a_typed_error_without_a_message_or_expression(self):
+ outcome = outcomes.NotSupportedError().outcome()
+
+ self.assertEqual(issue_summary(outcome), ['error|not-supported|not-supported|'])
+ self.assertNotIn('expression', outcome['issue'][0])
+ self.assertTrue(outcome['issue'][0]['details']['text'])
+
+ def test_issue_codes_without_a_tx_issue_type_of_their_own_get_invalid_data(self):
+ self.assertEqual(outcomes.tx_issue_type_for('forbidden'), 'invalid-data')
+ self.assertEqual(outcomes.tx_issue_type_for('code-invalid'), 'invalid-code')
+ self.assertEqual(outcomes.tx_issue_type_for('throttled'), 'too-costly')
+
+
+class OutcomeNormalizationTest(SimpleTestCase):
+ """Returned outcomes are normalized throughout the response without changing neighboring resources."""
+
+ def test_nested_outcomes_preserve_existing_codings_and_other_resources(self):
+ """An outcome inside another outcome still gains its missing code and structured details."""
+ coding = {'system': TX_ISSUE_TYPE_SYSTEM, 'code': 'invalid-code'}
+ nested = {'resourceType': 'OperationOutcome', 'id': 'nested', 'issue': [{'details': 'Nested failure'}]}
+ outer = {'resourceType': 'OperationOutcome', 'contained': [nested], 'issue': [
+ {'severity': 'error', 'code': 'code-invalid', 'details': {'coding': [coding], 'text': 'Unknown code'}}]}
+ code_system = {'resourceType': 'CodeSystem', 'status': 'active', 'content': 'not-present'}
+ bundle = {'resourceType': 'Bundle', 'type': 'collection', 'entry': [
+ {'resource': outer}, {'resource': code_system}]}
+
+ normalized = outcomes.normalize_outcomes(bundle)
+
+ result = normalized['entry'][0]['resource']
+ OperationOutcome.model_validate(result)
+ self.assertEqual(result['issue'], outer['issue'])
+ self.assertEqual(issue_summary(result['contained'][0]), ['error|processing|invalid-data|'])
+ self.assertEqual(result['contained'][0]['issue'][0]['details']['text'], 'Nested failure')
+ self.assertEqual(normalized['entry'][1]['resource'], code_system)
+ self.assertEqual(nested['issue'], [{'details': 'Nested failure'}])
+
+
+class ExceptionHandlerTest(SimpleTestCase):
+ def handle(self, exc):
+ response = outcomes.exception_handler(exc, {})
+ OperationOutcome.model_validate(response.data)
+ return response
+
+ def test_typed_error(self):
+ response = self.handle(outcomes.NotFoundError('No such system', expression='system'))
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(issue_summary(response.data), ['error|not-found|not-found|system'])
+
+ def test_not_found_exceptions(self):
+ for exc in (Http404('nope'), exceptions.NotFound()):
+ response = self.handle(exc)
+
+ self.assertEqual(response.status_code, 404, exc)
+ self.assertEqual(issue_summary(response.data), ['error|not-found|not-found|'], exc)
+
+ def test_drf_exceptions_keep_their_status(self):
+ cases = [
+ (exceptions.NotAuthenticated(), 401, 'login'),
+ (exceptions.AuthenticationFailed(), 401, 'login'),
+ (exceptions.PermissionDenied(), 403, 'forbidden'),
+ (DjangoPermissionDenied(), 403, 'forbidden'),
+ (exceptions.MethodNotAllowed('DELETE'), 405, 'not-supported'),
+ (exceptions.NotAcceptable(), 406, 'not-supported'),
+ (exceptions.UnsupportedMediaType('text/plain'), 415, 'not-supported'),
+ (exceptions.ParseError(), 400, 'invalid'),
+ (Http400('The following query params are not supported: x'), 400, 'invalid'),
+ (exceptions.Throttled(wait=3), 429, 'throttled'),
+ ]
+ for exc, status, code in cases:
+ response = self.handle(exc)
+
+ self.assertEqual(response.status_code, status, exc)
+ self.assertEqual(response.data['issue'][0]['code'], code, exc)
+ self.assertEqual(
+ response.data['issue'][0]['details']['coding'][0]['code'], outcomes.tx_issue_type_for(code), exc)
+
+ def test_throttled_and_unauthenticated_keep_their_headers(self):
+ self.assertEqual(self.handle(exceptions.Throttled(wait=3))['Retry-After'], '3')
+
+ exc = exceptions.NotAuthenticated()
+ exc.auth_header = 'Token'
+ self.assertEqual(self.handle(exc)['WWW-Authenticate'], 'Token')
+
+ def test_validation_error_dict_names_each_field(self):
+ response = self.handle(exceptions.ValidationError(
+ {'version': ['Version 1.0 already exists.'], 'non_field_errors': ['Bad.'], 'url': 'Required.'}))
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(issue_summary(response.data), [
+ 'error|invalid|invalid-data|version', 'error|invalid|invalid-data|', 'error|invalid|invalid-data|url'])
+ self.assertEqual(
+ [issue['details']['text'] for issue in response.data['issue']],
+ ['Version 1.0 already exists.', 'Bad.', 'Required.'])
+
+ def test_validation_error_list_and_nested_dict(self):
+ response = self.handle(exceptions.ValidationError(['First.', 'Second.']))
+ self.assertEqual(issue_summary(response.data), ['error|invalid|invalid-data|', 'error|invalid|invalid-data|'])
+
+ response = self.handle(exceptions.ValidationError({'parameter': [{'name': ['Required.']}]}))
+ self.assertEqual(issue_summary(response.data), ['error|invalid|invalid-data|parameter'])
+ self.assertEqual(response.data['issue'][0]['details']['text'], 'Required.')
+
+ def test_validation_error_carrying_an_operation_outcome_is_kept_and_gets_a_tx_issue_type(self):
+ detail = {'resourceType': 'OperationOutcome', 'issue': [
+ {'severity': 'error', 'code': 'not-found', 'details': {'text': "Code 'x' not found"}}]}
+
+ response = self.handle(exceptions.ValidationError(detail=detail))
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(issue_summary(response.data), ['error|not-found|not-found|'])
+ self.assertEqual(response.data['issue'][0]['details']['text'], "Code 'x' not found")
+
+ def test_an_outcome_without_codes_gets_them(self):
+ detail = {'resourceType': 'OperationOutcome', 'issue': [{'severity': 'error', 'details': 'Failed'}, {}]}
+
+ response = self.handle(exceptions.ValidationError(detail=detail))
+
+ self.assertEqual(issue_summary(response.data), ['error|processing|invalid-data|'] * 2)
+ self.assertEqual(response.data['issue'][0]['details']['text'], 'Failed')
+
+ def test_django_validation_error(self):
+ response = self.handle(DjangoValidationError({'url': ['Enter a valid URL.']}))
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(issue_summary(response.data), ['error|invalid|invalid-data|url'])
+
+ response = self.handle(DjangoValidationError('Bad parameters'))
+ self.assertEqual(issue_summary(response.data), ['error|invalid|invalid-data|'])
+ self.assertEqual(response.data['issue'][0]['details']['text'], 'Bad parameters')
+
+ def test_server_side_api_exceptions_never_return_a_5xx(self):
+ class Unavailable(exceptions.APIException):
+ status_code = 503
+
+ for exc in (exceptions.APIException(), Unavailable()):
+ response = self.handle(exc)
+
+ self.assertEqual(response.status_code, 422, exc)
+ self.assertEqual(issue_summary(response.data), ['error|exception|invalid-data|'], exc)
+
+ def test_unexpected_exception_is_logged_and_answered_with_a_4xx_outcome(self):
+ with self.assertLogs('oclapi', level='ERROR') as logs:
+ response = self.handle(TypeError('get_serializer() got multiple values for argument'))
+
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(issue_summary(response.data), ['error|exception|invalid-data|'])
+ self.assertNotIn('get_serializer', response.data['issue'][0]['details']['text'])
+ self.assertIn('TypeError', logs.output[0])
+
+
+class OutcomeForStatusTest(SimpleTestCase):
+ def test_an_error_status_without_a_body(self):
+ self.assertEqual(issue_summary(outcomes.outcome_for_status(404)), ['error|not-found|not-found|'])
+ self.assertEqual(issue_summary(outcomes.outcome_for_status(405)), ['error|not-supported|not-supported|'])
+ self.assertEqual(issue_summary(outcomes.outcome_for_status(409)), ['error|invalid|invalid-data|'])
+ self.assertEqual(issue_summary(outcomes.outcome_for_status(500)), ['error|exception|invalid-data|'])
+ self.assertEqual(outcomes.outcome_for_status(499)['issue'][0]['details']['text'],
+ outcomes.UNEXPECTED_ERROR_MESSAGE)
+
+ def test_a_body_with_a_detail_keeps_its_message(self):
+ outcome = outcomes.outcome_for_status(403, {'detail': 'You do not have permission.'})
+
+ self.assertEqual(issue_summary(outcome), ['error|forbidden|invalid-data|'])
+ self.assertEqual(outcome['issue'][0]['details']['text'], 'You do not have permission.')
+
+ def test_a_field_error_body_names_its_fields(self):
+ outcome = outcomes.outcome_for_status(400, {'version': ['Version 1.0 already exists.']})
+
+ self.assertEqual(issue_summary(outcome), ['error|invalid|invalid-data|version'])
+
+ def test_is_outcome(self):
+ self.assertTrue(outcomes.is_outcome({'resourceType': 'OperationOutcome', 'issue': []}))
+ self.assertFalse(outcomes.is_outcome({'detail': 'x'}))
+ self.assertFalse(outcomes.is_outcome(None))
+
+ def test_response_status_maps_5xx_to_422(self):
+ self.assertEqual(outcomes.response_status(503), 422)
+ self.assertEqual(outcomes.response_status(404), 404)
diff --git a/core/fhir/tests_protocol.py b/core/fhir/tests_protocol.py
new file mode 100644
index 000000000..3bf5c00f0
--- /dev/null
+++ b/core/fhir/tests_protocol.py
@@ -0,0 +1,376 @@
+"""
+The FHIR protocol through the real views (OpenConceptLab/ocl_online#451): FHIR JSON and XML in and out, _format,
+the fhirVersion parameter, trailing slashes on operations, and errors as OperationOutcome, never a 5xx.
+"""
+import json
+from datetime import datetime
+from unittest.mock import patch, Mock
+
+from fhir.resources.R4B.bundle import Bundle
+from fhir.resources.R4B.codesystem import CodeSystem
+from fhir.resources.R4B.operationoutcome import OperationOutcome
+from django.utils import timezone
+from lxml import etree
+from rest_framework.response import Response
+from rest_framework.test import APIClient
+
+from core.code_systems.views import CodeSystemValidateCodeView
+from core.collections.tests.factories import OrganizationCollectionFactory, ExpansionFactory
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir import base
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.fhir.tests_base import assert_in_model_order, child_names
+from core.mappings.tests.factories import MappingFactory
+from core.orgs.tests.factories import OrganizationFactory
+from core.sources.models import Source
+from core.sources.tests.factories import OrganizationSourceFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.tests.factories import UserProfileFactory
+from core.value_sets.views import ValueSetExpandView
+
+FHIR = '{http://hl7.org/fhir}'
+SYSTEM = 'http://example.org/fhir/CodeSystem/proto'
+VALUE_SET = 'http://example.org/fhir/ValueSet/proto'
+RELEASED = 'http://example.org/fhir/CodeSystem/released'
+
+
+def parameters(**values):
+ """A Parameters resource from name=(valueType, value) pairs."""
+ return {'resourceType': 'Parameters',
+ 'parameter': [{'name': name, f'value{kind}': value} for name, (kind, value) in values.items()]}
+
+
+def parameters_xml(**values):
+ """The same Parameters resource as FHIR XML."""
+ items = ''.join(f''
+ for name, (kind, value) in values.items())
+ return f'{items}'
+
+
+class FhirProtocolTest(OCLTestCase):
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def setUp(self):
+ set_registry_toggle(False) # the operations resolve through core.fhir.resolve: no registry, as in production
+ super().setUp()
+ self.org = OrganizationFactory(mnemonic='ProtoOrg')
+ self.source = OrganizationSourceFactory(
+ organization=self.org, mnemonic='proto', canonical_url=SYSTEM, full_name='Protocol codes')
+ self.concept = ConceptFactory(parent=self.source, mnemonic='c1', names=1, names__name='Malaria')
+ self.other = ConceptFactory(parent=self.source, mnemonic='c2')
+ MappingFactory(parent=self.source, from_concept=self.concept, to_concept=self.other)
+ self.source_v1 = OrganizationSourceFactory.build(
+ version='v1', mnemonic=self.source.mnemonic, organization=self.org, canonical_url=SYSTEM, released=True)
+ Source.persist_new_version(self.source_v1, self.source.created_by)
+ self.collection = OrganizationCollectionFactory(
+ organization=self.org, mnemonic='protovs', canonical_url=VALUE_SET, version='HEAD')
+ self.collection_v1 = OrganizationCollectionFactory(
+ organization=self.org, mnemonic='protovs', canonical_url=VALUE_SET, version='v1', is_latest_version=True,
+ released=True)
+ expansion = ExpansionFactory(mnemonic='e1', collection_version=self.collection_v1, parameters={})
+ self.collection_v1.expansion_uri = expansion.uri
+ self.collection_v1.save()
+ self.user = UserProfileFactory(username='protouser')
+ self.client = APIClient()
+
+ def assert_fhir_answer(self, response, msg=None):
+ """Never a 5xx; an error is an OperationOutcome whose every issue has a tx-issue-type coding."""
+ self.assertLess(response.status_code, 500, msg)
+ if response.status_code >= 400:
+ outcome = self.body(response)
+ self.assertEqual(outcome['resourceType'], 'OperationOutcome', msg)
+ for issue in outcome['issue']:
+ self.assertIn(TX_ISSUE_TYPE_SYSTEM, [coding['system'] for coding in issue['details']['coding']], msg)
+
+ @staticmethod
+ def body(response):
+ if 'xml' in response['Content-Type']:
+ return base.from_xml(response.content)
+ return json.loads(response.content)
+
+ def post(self, path, body, content_type='application/fhir+json', **headers):
+ data = body if isinstance(body, str) else json.dumps(body)
+ return self.client.post(path, data, content_type=content_type, **headers)
+
+ # FHIR JSON in
+
+ def test_fhir_json_posts_are_read(self):
+ validate = parameters(url=('Uri', SYSTEM), code=('Code', 'c1'), version=('String', 'v1'))
+ for content_type in ('application/fhir+json', 'application/fhir+json; charset=utf-8',
+ 'application/json+fhir', 'application/fhir+json; fhirVersion=4.0'):
+ response = self.post('/fhir/CodeSystem/$validate-code', validate, content_type)
+
+ self.assertEqual(response.status_code, 200, content_type)
+ self.assertEqual(response['Content-Type'], 'application/fhir+json')
+ self.assertEqual(response.data['parameter'][0], {'name': 'result', 'valueBoolean': True})
+
+ response = self.post('/fhir/ValueSet/$validate-code', parameters(
+ url=('Uri', VALUE_SET), system=('Uri', SYSTEM), code=('Code', 'c1')))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['resourceType'], 'Parameters')
+
+ def test_operations_answer_posts_without_a_5xx(self):
+ cases = [
+ ('/fhir/CodeSystem/$lookup', parameters(system=('Uri', SYSTEM), code=('Code', 'c1'))),
+ ('/fhir/ConceptMap/$translate',
+ parameters(url=('Uri', SYSTEM), system=('Uri', SYSTEM), code=('Code', 'c1'))),
+ ('/fhir/CodeSystem/$validate-code', {'resourceType': 'Parameters', 'parameter': [{'name': 'code'}]}),
+ ('/fhir/CodeSystem/$validate-code', '{not json'),
+ ]
+ for path, body in cases:
+ self.assert_fhir_answer(self.post(path, body), path)
+
+ # XML in and out
+
+ def test_format_parameter_on_search(self):
+ response = self.client.get(f'/fhir/CodeSystem/?url={SYSTEM}&_format=json')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['resourceType'], 'Bundle')
+ self.assertEqual(len(response.data['entry']), 1)
+
+ response = self.client.get(f'/fhir/CodeSystem/?url={SYSTEM}&_format=xml')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/fhir+xml; charset=utf-8')
+ root = etree.fromstring(response.content)
+ self.assertEqual(root.tag, f'{FHIR}Bundle')
+ self.assertEqual(root.find(f'{FHIR}entry/{FHIR}resource/{FHIR}CodeSystem/{FHIR}url').get('value'), SYSTEM)
+
+ def test_xml_out_on_operations_and_metadata(self):
+ lookup = f'/fhir/CodeSystem/$lookup?system={SYSTEM}&code=c1'
+ for path, headers in ((lookup + '&_format=xml', {}), (lookup, {'HTTP_ACCEPT': 'application/fhir+xml'}),
+ (lookup, {'HTTP_ACCEPT': 'application/xml'})):
+ response = self.client.get(path, **headers)
+
+ self.assertEqual(response.status_code, 200, (path, headers))
+ self.assertEqual(response['Content-Type'], 'application/fhir+xml; charset=utf-8')
+ parameters_ = base.from_xml(response.content)
+ self.assertEqual(parameters_['resourceType'], 'Parameters')
+ self.assertIn({'name': 'display', 'valueString': 'Malaria'}, parameters_['parameter'])
+
+ response = self.client.get(f'/fhir/CodeSystem/$validate-code?url={SYSTEM}&code=c1&_format=xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(base.from_xml(response.content)['parameter'][0], {'name': 'result', 'valueBoolean': True})
+
+ for search in ('ValueSet', 'ConceptMap'):
+ response = self.client.get(f'/fhir/{search}/?url={VALUE_SET}&_format=xml')
+ self.assertEqual(response.status_code, 200, search)
+ self.assertEqual(etree.fromstring(response.content).tag, f'{FHIR}Bundle', search)
+
+ response = self.client.get(f'/orgs/{self.org.mnemonic}/ValueSet/protovs/$expand?_format=xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(etree.fromstring(response.content).tag, f'{FHIR}ValueSet')
+
+ response = self.client.get('/fhir/metadata?_format=xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(etree.fromstring(response.content).tag, f'{FHIR}CapabilityStatement')
+
+ @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+ @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+ def released_source(self):
+ """A released source version with an owner-base id FHIR doesn't allow and a display with a control character."""
+ source = OrganizationSourceFactory(organization=self.org, mnemonic='rel_codes', canonical_url=RELEASED)
+ ConceptFactory(parent=source, mnemonic='r1', names=1, names__name='Mal\x0baria')
+ Source.persist_new_version(OrganizationSourceFactory.build(
+ version='v1', mnemonic='rel_codes', organization=self.org, canonical_url=RELEASED, released=True),
+ source.created_by)
+ # a release through the API sets the revision date (ConceptContainerModel.clean)
+ Source.objects.filter(mnemonic='rel_codes', version='v1').update(revision_date=timezone.now())
+
+ def test_xml_of_released_code_systems_is_in_fhir_element_order_and_json_is_unchanged(self):
+ self.released_source()
+
+ response = self.client.get(f'/fhir/CodeSystem/?url={RELEASED}')
+ self.assertIn('revisionDate', response.data['entry'][0]['resource'])
+
+ response = self.client.get(f'/fhir/CodeSystem/?url={RELEASED}&_format=xml')
+ self.assertEqual(response.status_code, 200)
+ root = etree.fromstring(response.content)
+ assert_in_model_order(self, root, Bundle)
+ self.assertLess(child_names(root).index('link'), child_names(root).index('entry'))
+ resource = root.find(f'{FHIR}entry/{FHIR}resource/{FHIR}CodeSystem')
+ assert_in_model_order(self, resource, CodeSystem)
+ released = Source.objects.get(mnemonic='rel_codes', version='v1')
+ self.assertEqual(resource.find(f'{FHIR}id').get('value'), str(released.id)) # opaque at the global base
+
+ response = self.client.get(f'/orgs/{self.org.mnemonic}/CodeSystem/rel_codes/?_format=xml')
+ self.assertEqual(response.status_code, 200)
+ root = etree.fromstring(response.content)
+ assert_in_model_order(self, root, CodeSystem)
+ self.assertEqual(child_names(root)[:2], ['id', 'meta'])
+
+ def test_xml_values_match_json_for_a_value_set_and_a_concept_map_at_an_owner_base(self):
+ OrganizationCollectionFactory(
+ organization=self.org, mnemonic='my_vs', canonical_url='http://example.org/vs/my_vs', version='HEAD')
+ OrganizationCollectionFactory(
+ organization=self.org, mnemonic='my_vs', canonical_url='http://example.org/vs/my_vs', version='v1',
+ is_latest_version=True, released=True)
+ for path in (f'/orgs/{self.org.mnemonic}/ValueSet/my_vs/', f'/orgs/{self.org.mnemonic}/ConceptMap/proto/'):
+ as_json = json.loads(self.client.get(path).content)
+ response = self.client.get(path + '?_format=xml')
+
+ self.assertEqual(response.status_code, 200, path)
+ root = etree.fromstring(response.content)
+ last_updated = root.find(f'{FHIR}meta/{FHIR}lastUpdated').get('value')
+ self.assertEqual(datetime.fromisoformat(last_updated),
+ datetime.fromisoformat(as_json['meta']['lastUpdated']), path)
+ self.assertEqual(root.find(f'{FHIR}identifier/{FHIR}value').get('value'),
+ as_json['identifier'][0]['value'], path)
+
+ def test_a_control_character_never_stops_an_xml_response(self):
+ self.released_source()
+ lookup = f'/fhir/CodeSystem/$lookup?system={RELEASED}&code=r1'
+
+ self.assertIn({'name': 'display', 'valueString': 'Mal\x0baria'}, self.client.get(lookup).data['parameter'])
+
+ response = self.client.get(lookup + '&_format=xml')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn({'name': 'display', 'valueString': 'Mal\ufffdaria'}, base.from_xml(response.content)['parameter'])
+
+ def test_xml_in_and_out(self):
+ body = parameters_xml(url=('Uri', SYSTEM), code=('Code', 'c1'), version=('String', 'v1'))
+
+ response = self.post(
+ '/fhir/CodeSystem/$validate-code', body, 'application/fhir+xml', HTTP_ACCEPT='application/fhir+xml')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(base.from_xml(response.content)['parameter'][0], {'name': 'result', 'valueBoolean': True})
+
+ def test_an_invalid_xml_body_is_a_400_outcome_in_xml(self):
+ response = self.post('/fhir/CodeSystem/$validate-code', '| is a release of that code system with a concept, and code= a
+ release defining it. A concept counts as the read lists it: linked and not deleted; a retired one counts.
+ """
+ ConceptFactory(parent=self.who_icd, mnemonic='A00', retired=True, sources=[self.who_icd_2019])
+ ConceptFactory(parent=self.ciel_source, mnemonic='gone', is_active=False, sources=[self.ciel_v1])
+
+ self.assertEqual(self.keys(self.search('CodeSystem', code=f'{ICD10}|')), [f'{ICD10}|ICD102019'])
+ self.assertEqual(self.keys(self.search('CodeSystem', code=f'{ICD10}|A00')), [f'{ICD10}|ICD102019'])
+ self.assertEqual(self.search('CodeSystem', code=f'{CIEL}|')['total'], 0)
+ self.assertEqual(self.search('CodeSystem', code='gone')['total'], 0)
+
+ def test_token_parameters_take_their_system(self):
+ """
+ R4 tokens: code, system|code, or |code (no system). status's system is publication-status, language's BCP 47,
+ code's the code system itself; version and _id have none, so |v is v, and any system finds nothing.
+ """
+ status, bcp47 = 'http://hl7.org/fhir/publication-status', 'urn:ietf:bcp:47'
+ Source.objects.filter(id=self.ciel_v2.id).update(default_locale='fr')
+ Source.objects.filter(id=self.ciel_v1.id).update(version='v1|a,b')
+ ConceptFactory(parent=self.ciel_source, mnemonic='1427', sources=[self.ciel_v2])
+ ConceptFactory(parent=self.sehat_icd, mnemonic='1427', sources=[self.sehat_icd_v1])
+ active = sorted([f'{CIEL}|v2', f'{ICD10}|ICD102019', f'{ICD10}|v1.0', 'https://example.org/my-codes|v1'])
+ ciel_v2, icd_v1, ciel_v1 = [f'{CIEL}|v2'], [f'{ICD10}|v1.0'], [f'{CIEL}|v1|a,b']
+ pk = str(self.sehat_icd_v1.id)
+ cases = [
+ ('status', 'active', active), ('status', f'{status}|active', active),
+ ('status', f'{status}|retired', ciel_v1),
+ ('status', f'{status}|active,{status}|retired', self.keys(self.search('CodeSystem'))),
+ ('status', '|active', []), ('status', 'http://example.org/other|active', []),
+ ('status', f'{status}|', self.keys(self.search('CodeSystem'))), ('status', 'http://example.org/other|', []),
+ ('language', f'{bcp47}|', self.keys(self.search('CodeSystem'))), ('language', 'urn:other|', []),
+ ('code', f'{CIEL}|', ciel_v2), ('code', f'{ICD10}|', icd_v1), # releases with concepts only
+ ('code', 'http://example.org/other|', []), ('version', 'urn:x|', []), ('_id', 'urn:x|', []),
+ ('language', 'fr', ciel_v2), ('language', f'{bcp47}|fr', ciel_v2), ('language', '|fr', []),
+ ('language', 'urn:other|fr', []),
+ ('code', '1427', sorted(ciel_v2 + icd_v1)), ('code', f'{CIEL}|1427', ciel_v2),
+ ('code', f'{ICD10}|1427', icd_v1),
+ ('code', f'{CIEL}|1427,{ICD10}|1427', sorted(ciel_v2 + icd_v1)), ('code', '|1427', []),
+ ('code', 'http://example.org/other|1427', []), ('code', f'{CIEL}|A00', []),
+ ('version', 'v1.0', icd_v1), ('version', '|v1.0', icd_v1), ('version', 'urn:x|v1.0', []),
+ ('version', r'v1\|a\,b', ciel_v1), ('version', r'|v1\|a\,b', ciel_v1), ('version', 'v1|a', []),
+ ('_id', pk, icd_v1), ('_id', f'|{pk}', icd_v1), ('_id', f'urn:x|{pk}', []),
+ ]
+ for name, value, keys in cases:
+ self.assertEqual(self.keys(self.search('CodeSystem', **{name: value})), keys, f'{name}={value}')
+ owner = [('_id', 'CIEL', 2), ('_id', '|CIEL', 2), ('_id', 'urn:x|CIEL', 0)]
+ for name, value, total in owner:
+ self.assertEqual(self.search('CodeSystem', base='/orgs/CIEL', **{name: value})['total'], total, value)
+
+ def test_last_updated_and_its_prefixes(self):
+ Source.objects.filter(id=self.ciel_v2.id).update(updated_at=at(2020))
+ Source.objects.filter(id=self.sehat_icd_v1.id).update(updated_at=at(2022))
+ pinned = {'url': CIEL, 'version': 'v2'}
+ for value, found in (('2020', True), ('eq2020-06', True), ('2020-06-15', True), ('2020-06-16', False),
+ ('gt2019', True), ('gt2020', False), ('sa2019-12-31', True), ('ge2020-06-15', True),
+ ('ge2020-06-16', False), ('lt2021', True), ('lt2020-06-15', False), ('eb2020-06-16', True),
+ ('le2020-06-15', True), ('le2020-06-14', False), ('ne2020', False), ('ne2021', True),
+ ('ge2020-06-15T12:00:00Z', True), ('gt2020-06-15T12:00:00Z', False),
+ ('lt2020-06-15T13:00:00+01:00', False), ('le2020-06-15T13:00+01:00', True)):
+ self.assertEqual(self.search('CodeSystem', _lastUpdated=value, **pinned)['total'], int(found), value)
+
+ response = self.client.get('/fhir/CodeSystem/?_lastUpdated=ge2020&_lastUpdated=lt2021')
+ self.assertEqual(self.keys(response.data), [f'{CIEL}|v2'])
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', _lastUpdated='2020,2022')), sorted([f'{CIEL}|v2', f'{ICD10}|v1.0']))
+
+ def test_last_updated_at_the_end_of_time(self):
+ """A value in year 9999 has no end to build (year 10000): its range runs on unbounded."""
+ for value, total in (('lt9999', 5), ('eb9999', 5), ('le9999', 5), ('le9999-12', 5), ('le9999-12-31', 5),
+ ('le9999-12-31T23:59:59Z', 5), ('le9999-12-31T23:59:59.999999Z', 5), ('ne9999-12', 5),
+ ('9999', 0), ('9999-12', 0), ('9999-12-31', 0), ('gt9999', 0), ('sa9999-12', 0),
+ ('gt9999-12-31', 0), ('ge9999-12-31', 0), ('gt9999-12-31T23:59:59Z', 0)):
+ self.assertEqual(self.search('CodeSystem', _lastUpdated=value)['total'], total, value)
+ self.assertEqual(self.search('CodeSystem', _lastUpdated='le9999,lt2000')['total'], 5) # an alternative too
+
+ def test_unsupported_parameters_and_values_are_400_outcomes(self):
+ for query in ('unsupported_parameter=1', 'q=CIEL', 'limit=10', 'name:missing=true', 'url:below=http://x',
+ '_sort=name', 'reference=http://x', 'source-system=http://x'):
+ issue = self.assertOutcome(self.client.get(f'/fhir/CodeSystem/?{query}'), 400, 'not-supported',
+ 'not-supported')
+ self.assertIn(query.split('=', maxsplit=1)[0].split(':')[0], issue['details']['text'], query)
+ for query in ('_count=-1', '_count=ten', '_lastUpdated=yesterday', '_lastUpdated=ap2020',
+ '_lastUpdated=2020-13', '_lastUpdated=2020-06-15T12:00%2B25:00', 'page=0'):
+ self.assertOutcome(self.client.get(f'/fhir/CodeSystem/?{query}'), 400, 'invalid', 'invalid-data')
+ # the format parameters choose the response, they aren't search input
+ self.assertEqual(self.search('CodeSystem', _format='json', _pretty='true')['total'], 5)
+ self.assertEqual(self.search('CodeSystem', name='')['total'], 5) # an empty value is no criterion
+
+ def test_count_paging_total_and_links(self):
+ bundle = self.search('CodeSystem', _count='2')
+ self.assertEqual((len(bundle['entry']), bundle['total']), (2, 5))
+ links = {link['relation']: link['url'] for link in bundle['link']}
+ self.assertEqual(set(links), {'self', 'next'})
+ query = parse_qs(urlsplit(links['next']).query)
+ self.assertEqual((query['page'], query['_count']), (['2'], ['2']))
+ self.assertTrue(links['next'].startswith('http://testserver/fhir/CodeSystem/?'))
+
+ page_2 = self.get(links['next']).data
+ self.assertEqual({link['relation'] for link in page_2['link']}, {'self', 'next', 'previous'})
+ page_3 = self.search('CodeSystem', _count='2', page='3')
+ self.assertEqual({link['relation'] for link in page_3['link']}, {'self', 'previous'})
+ seen = [entry['resource']['id'] for page in (bundle, page_2, page_3) for entry in page['entry']]
+ self.assertEqual(sorted(seen), self.ids(self.search('CodeSystem'))) # every version once, in a stable order
+
+ bundle = self.search('CodeSystem', _count='0')
+ self.assertEqual(bundle['total'], 5)
+ self.assertNotIn('entry', bundle)
+
+ with patch('core.fhir.search.MAX_COUNT', 3):
+ bundle = self.search('CodeSystem', _count='1000')
+ self.assertEqual((len(bundle['entry']), bundle['total']), (3, 5))
+ self.assertEqual(len(self.search('CodeSystem')['entry']), 3) # the default is the maximum
+
+ def test_entries_are_summaries_marked_subsetted(self):
+ ConceptFactory(parent=self.ciel_source, sources=[self.ciel_v2])
+ for entry in self.search('CodeSystem')['entry']:
+ resource = entry['resource']
+ self.assertNotIn('concept', resource)
+ self.assertNotIn('count', resource)
+ self.assertEqual(resource['content'], 'complete')
+ self.assertIn(SUBSETTED, resource['meta']['tag'])
+ self.assertIn('lastUpdated', resource['meta'])
+
+ with patch('core.fhir.search.CONCEPT_LIMIT', 0):
+ contents = {entry['resource']['version']: entry['resource']['content']
+ for entry in self.search('CodeSystem', url=CIEL)['entry']}
+ self.assertEqual(contents, {'v1': 'complete', 'v2': 'not-present'})
+
+ def test_owner_base_search(self):
+ bundle = self.search('CodeSystem', base='/orgs/CIEL')
+ self.assertEqual(self.keys(bundle), [f'{CIEL}|v1', f'{CIEL}|v2'])
+ for entry in bundle['entry']:
+ self.assertEqual(entry['resource']['id'], 'CIEL')
+ self.assertEqual(entry['fullUrl'], 'http://testserver/orgs/CIEL/CodeSystem/CIEL')
+
+ self.assertEqual(self.keys(self.search('CodeSystem', base='/users/jdoe')), ['https://example.org/my-codes|v1'])
+ self.assertEqual(self.search('CodeSystem', base='/orgs/nobody')['total'], 0)
+ self.assertEqual(self.search('CodeSystem', base='/orgs/misc')['total'], 0)
+
+ # a private owner's repositories: to its members and staff only
+ fixture_releases = [f'{FIXTURE}|1.0.0', f'{FIXTURE}|2.0.0']
+ for user, keys in ((None, []), (self.outsider, []), (self.member, fixture_releases),
+ (self.admin, fixture_releases)):
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', base='/orgs/FHIR-Fixtures', user=user)), keys, user)
+ bundle = self.search('CodeSystem', base='/orgs/FHIR-Fixtures', user=self.member, url=FIXTURE, version='1.0.0')
+ self.assertEqual(self.ids(bundle), ['fixture-codes'])
+
+ def test_no_query_per_entry(self):
+ def count_queries(expected, **params):
+ with self.assertNumQueries(expected):
+ return self.client.get('/fhir/CodeSystem/', params).data
+
+ # the count and the page, its summaries included (and the url's repositories)
+ one = count_queries(3, url=CIEL, version='v2')
+ every = count_queries(2)
+ self.assertEqual((one['total'], every['total']), (1, 5))
+ with self.assertNumQueries(3): # + the owner
+ self.client.get('/orgs/CIEL/CodeSystem/')
+
+
+class ValueSetSearchTest(SearchCorpusMixin, OCLTestCase):
+ def test_global_search_and_the_repository_canonical(self):
+ bundle = self.search('ValueSet')
+
+ self.assertEqual(self.keys(bundle), [f'{ICD10_VS}|v1.0', f'{CIEL_VS}|v1'])
+ self.assertEqual(self.ids(bundle), sorted([str(self.icd_vs_v1.id), str(self.ciel_vs_v1.id)]))
+ self.assertEqual(self.keys(self.search('ValueSet', url=ICD10_VS)), [f'{ICD10_VS}|v1.0'])
+ for entry in bundle['entry']:
+ self.assertEqual(entry['fullUrl'], f"http://testserver/fhir/ValueSet/{entry['resource']['id']}")
+ self.assertNotIn('compose', entry['resource'])
+ self.assertIn(SUBSETTED, entry['resource']['meta']['tag'])
+
+ def test_reference_is_a_code_system_the_stored_expansion_draws_on(self):
+ self.assertEqual(self.keys(self.search('ValueSet', reference=ICD10)), [f'{ICD10_VS}|v1.0'])
+ self.assertEqual(self.keys(self.search('ValueSet', reference=CIEL)), [f'{CIEL_VS}|v1'])
+ self.assertEqual(self.search('ValueSet', reference='http://loinc.org')['total'], 0)
+
+ def test_owner_base(self):
+ bundle = self.search('ValueSet', base='/orgs/CIEL')
+ self.assertEqual((self.keys(bundle), self.ids(bundle)), ([f'{CIEL_VS}|v1'], ['ciel-set']))
+
+ def test_unsupported(self):
+ self.assertOutcome(self.client.get('/fhir/ValueSet/?system=http://x'), 400, 'not-supported', 'not-supported')
+
+ def test_status_takes_its_system(self):
+ for resource_type in ('ValueSet', 'ConceptMap'):
+ bare = self.search(resource_type, status='active')['total']
+ self.assertGreater(bare, 0)
+ qualified = self.search(resource_type, status='http://hl7.org/fhir/publication-status|active')
+ self.assertEqual(qualified['total'], bare, resource_type)
+ self.assertEqual(self.search(resource_type, status='urn:x|active')['total'], 0, resource_type)
+
+ def test_no_query_per_entry(self):
+ with self.assertNumQueries(2):
+ self.client.get('/fhir/ValueSet/')
+
+
+class ConceptMapSearchTest(SearchCorpusMixin, OCLTestCase):
+ def setUp(self):
+ super().setUp()
+ icd_code = ConceptFactory(parent=self.sehat_icd, mnemonic='A00')
+ ciel_code = ConceptFactory(parent=self.ciel_source, mnemonic='1427')
+ MappingFactory(
+ parent=self.ciel_source, from_concept=ciel_code, to_concept=icd_code, from_source=self.ciel_source,
+ to_source=self.sehat_icd, from_concept_code='1427', to_concept_code='A00', sources=[self.ciel_v2])
+
+ def test_source_and_target(self):
+ everything = self.search('ConceptMap')
+ self.assertEqual(self.keys(everything), CodeSystemSearchTest.PUBLIC_RELEASES)
+ for entry in everything['entry']:
+ self.assertNotIn('group', entry['resource'])
+ self.assertEqual(entry['fullUrl'], f"http://testserver/fhir/ConceptMap/{entry['resource']['id']}")
+
+ for name in ('source-system', 'source-uri'):
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: CIEL})), [f'{CIEL}|v2'], name)
+ self.assertEqual(self.search('ConceptMap', **{name: ICD10})['total'], 0, name)
+ for name in ('target-system', 'target-uri'):
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: ICD10})), [f'{CIEL}|v2'], name)
+ self.assertEqual(self.search('ConceptMap', **{name: CIEL})['total'], 0, name)
+
+ def test_source_system_is_the_mappings_source_as_the_read_shows_it(self):
+ """
+ group.source is each live mapping's from_source canonical, or for a source without one its OCL path as a
+ ConceptMap; group.target the same for to_source. A container's own canonical isn't its source system.
+ """
+ uncanonical = source(self.misc, 'local-codes')
+ local = ConceptFactory(parent=uncanonical, mnemonic='L1')
+ icd_code = ConceptFactory(parent=self.sehat_icd, mnemonic='B01')
+ mine = ConceptFactory(parent=self.jdoe_codes, mnemonic='m1')
+ for from_source, from_concept in ((self.sehat_icd, icd_code), (uncanonical, local)):
+ MappingFactory(
+ parent=self.jdoe_codes, from_concept=from_concept, to_concept=mine, from_source=from_source,
+ to_source=self.jdoe_codes, from_source_url=from_source.uri, to_source_url=self.jdoe_codes.uri,
+ sources=[self.jdoe_codes_v1])
+ my_codes = 'https://example.org/my-codes|v1'
+ groups = self.read(f'/fhir/ConceptMap/{self.jdoe_codes_v1.id}')['group']
+ self.assertEqual(
+ sorted(group['source'] for group in groups), sorted([ICD10, '/orgs/misc/ConceptMap/local-codes/']))
+
+ for name in ('source-system', 'source-uri'):
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: ICD10})), [my_codes], name)
+ self.assertEqual(
+ self.keys(self.search('ConceptMap', **{name: '/orgs/misc/ConceptMap/local-codes/'})), [my_codes], name)
+ self.assertEqual(self.search('ConceptMap', **{name: 'https://example.org/my-codes'})['total'], 0, name)
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: CIEL})), [f'{CIEL}|v2'], name)
+ self.assertEqual(
+ self.keys(self.search('ConceptMap', **{'target-system': 'https://example.org/my-codes'})), [my_codes])
+ # a target with a canonical is shown by it, not by its OCL path
+ self.assertEqual(self.search('ConceptMap', **{'target-system': '/users/jdoe/ConceptMap/my-codes/'})['total'], 0)
+
+ def test_retired_mappings_are_no_part_of_a_concept_map(self):
+ """A read leaves retired mappings out (ConceptMapGroupField), so source and target searches don't count them."""
+ loinc = source(self.misc, 'LOINC', canonical_url='http://loinc.org')
+ loinc_code = ConceptFactory(parent=loinc, mnemonic='1234-5')
+ MappingFactory(
+ parent=self.ciel_source, from_concept=ConceptFactory(parent=self.ciel_source), to_concept=loinc_code,
+ from_source=self.ciel_source, to_source=loinc, to_concept_code='1234-5', retired=True,
+ sources=[self.ciel_v2])
+ jdoe_code = ConceptFactory(parent=self.jdoe_codes, mnemonic='x1')
+ retired = MappingFactory(
+ parent=self.jdoe_codes, from_concept=jdoe_code, to_concept=loinc_code, from_source=self.jdoe_codes,
+ to_source=loinc, retired=True, sources=[self.jdoe_codes_v1])
+
+ for name in ('target-system', 'target-uri'):
+ self.assertEqual(self.search('ConceptMap', **{name: 'http://loinc.org'})['total'], 0, name)
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: ICD10})), [f'{CIEL}|v2'], name)
+ resource = self.read(f'/fhir/ConceptMap/{self.ciel_v2.id}')
+ self.assertEqual([group['target'] for group in resource['group']], [ICD10])
+ for name in ('source-system', 'source-uri'):
+ self.assertEqual(self.search('ConceptMap', **{name: 'https://example.org/my-codes'})['total'], 0, name)
+
+ type(retired).objects.filter(id=retired.id).update(retired=False)
+ self.assertEqual(
+ self.keys(self.search('ConceptMap', **{'source-system': 'https://example.org/my-codes'})),
+ ['https://example.org/my-codes|v1'])
+ self.assertEqual(self.keys(self.search('ConceptMap', **{'target-system': 'http://loinc.org'})),
+ ['https://example.org/my-codes|v1'])
+
+ def test_no_query_per_entry(self):
+ with self.assertNumQueries(3): # the sources with the target's canonical, the matches' ids, the page
+ self.assertEqual(self.client.get('/fhir/ConceptMap/', {'target-system': ICD10}).data['total'], 1)
+
+
+class ReferencedSourceAccessTest(SearchCorpusMixin, OCLTestCase):
+ """
+ source-system, target-system (and their -uri forms) and reference look a canonical up only among the sources the
+ base shows the caller: public ones at /fhir/; at an owner base also, whoever owns them, those the caller may view.
+ A mapping whose source is another one is matched only by the URL it stores. So the canonical of a source the caller
+ isn't shown answers exactly as one no source has: the same total, the same queries.
+ """
+ PRIVATE = 'http://moh.example/fhir/CodeSystem/private'
+ UNKNOWN = 'http://moh.example/fhir/CodeSystem/unknown'
+
+ def setUp(self):
+ super().setUp()
+ self.private = source(self.moh, 'private-codes', canonical_url=self.PRIVATE, public_access=ACCESS_TYPE_NONE)
+ self.private_v1 = source(self.moh, 'private-codes', 'v1', released=True, canonical_url=self.PRIVATE,
+ public_access=ACCESS_TYPE_NONE)
+ self.private_code = ConceptFactory(parent=self.private, mnemonic='P1')
+ for _ in range(3):
+ MappingFactory(parent=self.private, from_source=self.private, to_source=self.private,
+ from_concept=self.private_code, to_concept=self.private_code, sources=[self.private_v1])
+ drawing = 'https://example.org/ValueSet/drawing' # a public value set whose stored expansion drew on it
+ collection(self.sehatapp, 'drawing', canonical_url=drawing)
+ self.with_expansion(collection(self.sehatapp, 'drawing', 'v1', released=True, canonical_url=drawing),
+ explicit=[self.private_v1])
+
+ def with_partner(self):
+ """
+ Partner's public ConceptMap maps to and from MOH's private source, and its public value set drew on it; a
+ member of both orgs, and a member of Partner only.
+ """
+ partner = OrganizationFactory(mnemonic='Partner')
+ self.both = UserProfileFactory(username='both-member')
+ self.partner_only = UserProfileFactory(username='partner-member')
+ partner.members.add(self.both, self.partner_only)
+ self.moh.members.add(self.both)
+ maps = source(partner, 'partner-map', canonical_url='https://example.org/partner-map')
+ maps_v1 = source(partner, 'partner-map', 'v1', released=True, canonical_url=maps.canonical_url)
+ own = ConceptFactory(parent=maps, mnemonic='X1')
+ MappingFactory(parent=maps, from_source=maps, to_source=self.private, from_concept=own,
+ to_concept=self.private_code, sources=[maps_v1])
+ MappingFactory(parent=maps, from_source=self.private, to_source=maps, from_concept=self.private_code,
+ to_concept=own, sources=[maps_v1])
+ collection(partner, 'partner-set', canonical_url='https://example.org/partner-set')
+ self.with_expansion(
+ collection(partner, 'partner-set', 'v1', released=True, canonical_url='https://example.org/partner-set'),
+ explicit=[self.private_v1])
+
+ def answer(self, path, user, name, value, base='/fhir'): # pylint: disable=too-many-arguments
+ """The total and the SQL of a search, with the value searched for written as URL."""
+ with CaptureQueriesContext(connection) as context:
+ total = self.search(path, base=base, user=user, **{name: value})['total']
+ return total, [query['sql'].replace(value, 'URL') for query in context.captured_queries]
+
+ def test_a_source_the_caller_is_not_shown_answers_as_an_unknown_one(self):
+ for user in (None, self.outsider, self.admin):
+ for resource_type, name in (('ConceptMap', 'source-system'), ('ConceptMap', 'target-system'),
+ ('ConceptMap', 'source-uri'), ('ConceptMap', 'target-uri'),
+ ('ValueSet', 'reference')):
+ private = self.answer(resource_type, user, name, self.PRIVATE)
+ self.assertEqual(private, self.answer(resource_type, user, name, self.UNKNOWN), (user, name))
+ self.assertEqual(private[0], 0, (user, name))
+
+ def test_at_its_owner_base_to_those_who_may_view_it(self):
+ for name in ('source-system', 'target-system'):
+ for user, keys in ((None, []), (self.outsider, []), (self.moh_member, [f'{self.PRIVATE}|v1']),
+ (self.admin, [f'{self.PRIVATE}|v1'])):
+ self.assertEqual(self.keys(self.search(
+ 'ConceptMap', base='/orgs/MOH', user=user, **{name: self.PRIVATE})), keys, (name, user))
+
+ def test_across_owners_by_the_callers_own_rights(self):
+ """
+ At an owner base, another owner's private source is matched for a caller who may view it by their own rights
+ (staff, a member of its org). A member of the base's org only, or an anonymous caller, gets what an unknown
+ canonical gets. The global base stays public only, whoever asks.
+ """
+ self.with_partner()
+ for resource_type, name, key in (
+ ('ConceptMap', 'target-system', 'https://example.org/partner-map|v1'),
+ ('ConceptMap', 'source-system', 'https://example.org/partner-map|v1'),
+ ('ConceptMap', 'target-uri', 'https://example.org/partner-map|v1'),
+ ('ConceptMap', 'source-uri', 'https://example.org/partner-map|v1'),
+ ('ValueSet', 'reference', 'https://example.org/partner-set|v1')):
+ for user in (self.both, self.admin):
+ self.assertEqual(self.keys(self.search(
+ resource_type, base='/orgs/Partner', user=user, **{name: self.PRIVATE})), [key], (name, user))
+ self.assertEqual(self.search(resource_type, user=user, **{name: self.PRIVATE})['total'], 0)
+ for user in (self.partner_only, None):
+ private = self.answer(resource_type, user, name, self.PRIVATE, '/orgs/Partner')
+ self.assertEqual(private, self.answer(resource_type, user, name, self.UNKNOWN, '/orgs/Partner'),
+ (name, user))
+ self.assertEqual(private[0], 0, (name, user))
+ # the caller's own rights are a subquery of the source lookup, not a query of their own
+ with self.assertNumQueries(5): # the owner, the caller's membership of it, the sources, the matches, the page
+ self.search('ConceptMap', base='/orgs/Partner', user=self.both, **{'target-system': self.PRIVATE})
+
+ def test_the_source_filter_agrees_with_is_visible(self):
+ """viewable_rows is is_visible's queryset form, over every source row, HEAD and versions alike."""
+ self.with_partner()
+ for namespace in (None, '/orgs/Partner/', '/orgs/MOH/', '/orgs/FHIR-Fixtures/'):
+ for user in (AnonymousUser(), self.outsider, self.member, self.moh_member, self.partner_only, self.both,
+ self.admin):
+ expected = {row.id for row in Source.objects.all() if fhir_resolve.is_visible(row, namespace, user)}
+ shown = set(fhir_resolve.viewable_rows(Source, namespace, user).values_list('id', flat=True))
+ self.assertEqual(shown, expected, (namespace, user))
+ self.assertIn(self.private.id, fhir_resolve.viewable_rows(Source, '/orgs/Partner/', self.both).values_list(
+ 'id', flat=True))
+
+
+class SearchCostTest(SearchCorpusMixin, OCLTestCase):
+ """
+ A search's cost is bounded: the canonical URLs a parameter names are looked up together, in one query, and a
+ parameter takes at most MAX_ALTERNATIVES values (its alternatives and repeats together), a search at most
+ MAX_SEARCH_ALTERNATIVES; past either the search is too costly, a 400 naming the parameter.
+ """
+ def setUp(self):
+ super().setUp()
+ ConceptFactory(parent=self.ciel_source, mnemonic='c19', sources=[self.ciel_v2])
+ MappingFactory(parent=self.ciel_source, from_source=self.ciel_source, to_source=self.sehat_icd,
+ sources=[self.ciel_v2])
+
+ @staticmethod
+ def alternatives(count, last):
+ return ','.join([f'https://example.org/unknown-{index}' for index in range(count - 1)] + [last])
+
+ def test_canonicals_are_looked_up_in_one_query_per_parameter(self):
+ for resource_type, name, url, total in (
+ ('CodeSystem', 'url', CIEL, 2), ('CodeSystem', 'system', ICD10, 2), ('ValueSet', 'url', CIEL_VS, 1),
+ ('ValueSet', 'reference', CIEL, 1), ('ConceptMap', 'source-system', CIEL, 1),
+ ('ConceptMap', 'target-uri', ICD10, 1)):
+ for count in (1, 20):
+ with self.assertNumQueries(3): # the canonicals' repositories or sources, the matches' ids, the page
+ response = self.client.get(f'/fhir/{resource_type}/', {name: self.alternatives(count, url)})
+ self.assertEqual(response.data['total'], total, (resource_type, name, count))
+ with self.assertNumQueries(4): # one lookup for each parameter
+ response = self.client.get('/fhir/CodeSystem/', {
+ 'url': self.alternatives(20, CIEL), 'code': ','.join(f'{CIEL}|c{index}' for index in range(20))})
+ self.assertEqual(self.keys(response.data), [f'{CIEL}|v2'])
+
+ def test_a_parameter_takes_at_most_20_values(self):
+ for params, expression in (
+ ({'url': self.alternatives(21, CIEL)}, 'url'),
+ ({'url': [self.alternatives(15, CIEL), self.alternatives(6, ICD10)]}, 'url'), # repeats count too
+ ({'_id': ','.join(['1'] * 21)}, '_id'),
+ ({'name': ','.join(['a'] * 11), 'name:exact': ','.join(['b'] * 10)}, 'name:exact')):
+ with self.assertNumQueries(0):
+ response = self.client.get('/fhir/CodeSystem/', params)
+ issue = self.assertOutcome(response, 400, 'too-costly', 'too-costly')
+ self.assertEqual(issue['expression'], [expression], params)
+ self.assertEqual(self.search('CodeSystem', url=self.alternatives(20, CIEL))['total'], 2)
+
+ def test_a_search_takes_at_most_50_values(self):
+ params = {'url': self.alternatives(20, CIEL), 'system': self.alternatives(20, CIEL),
+ 'version': ','.join(f'v{index}' for index in range(10))}
+ self.assertEqual(self.search('CodeSystem', **params)['total'], 2)
+ params['_id'] = '1'
+ issue = self.assertOutcome(self.client.get('/fhir/CodeSystem/', params), 400, 'too-costly', 'too-costly')
+ self.assertEqual(issue['expression'], ['_id'])
+
+
+class VersionIdTest(SearchCorpusMixin, OCLTestCase):
+ """
+ Every released version has a stable meta.versionId, its opaque id, so an owner-base Bundle with several releases
+ of one repository (one mnemonic id, one fullUrl) still identifies each entry (R4 bdl-7).
+ """
+ def test_owner_base_entries_are_told_apart_by_version_id(self):
+ Collection.objects.filter(id=self.ciel_vs_v2.id).update(released=True)
+ for resource_type, versions in (('CodeSystem', (self.ciel_v1, self.ciel_v2)),
+ ('ConceptMap', (self.ciel_v1, self.ciel_v2)),
+ ('ValueSet', (self.ciel_vs_v1, self.ciel_vs_v2))):
+ bundle = self.search(resource_type, base='/orgs/CIEL')
+ identities = [(entry['fullUrl'], entry['resource']['meta']['versionId']) for entry in bundle['entry']]
+ self.assertEqual(len(identities), 2, resource_type)
+ self.assertEqual(len(set(identities)), 2, resource_type)
+ self.assertEqual(len({fullurl for fullurl, _ in identities}), 1, resource_type) # ids stay mnemonics
+ self.assertEqual(sorted(version_id for _, version_id in identities),
+ sorted(str(version.id) for version in versions), resource_type)
+ for entry in bundle['entry']:
+ meta = entry['resource']['meta']
+ self.assertIn('lastUpdated', meta)
+ self.assertIn(SUBSETTED, meta['tag'])
+
+ def test_reads_and_global_entries_carry_it_too(self):
+ for resource_type, version, owner_path in (
+ ('CodeSystem', self.ciel_v2, '/orgs/CIEL/CodeSystem/CIEL/'),
+ ('ConceptMap', self.ciel_v2, '/orgs/CIEL/ConceptMap/CIEL/'),
+ ('ValueSet', self.ciel_vs_v1, '/orgs/CIEL/ValueSet/ciel-set/')):
+ for path in (f'/fhir/{resource_type}/{version.id}', owner_path):
+ meta = self.get(path).data['meta']
+ self.assertEqual(meta['versionId'], str(version.id), path)
+ self.assertIn('lastUpdated', meta)
+ entry = next(entry for entry in self.search(resource_type)['entry']
+ if entry['resource']['id'] == str(version.id))
+ self.assertEqual(entry['resource']['meta']['versionId'], entry['resource']['id'])
+
+
+class ReadTest(SearchCorpusMixin, OCLTestCase):
+ def test_global_read_by_opaque_id(self):
+ for resource_type, version in (('CodeSystem', self.who_icd_2019), ('CodeSystem', self.ciel_v1),
+ ('ConceptMap', self.ciel_v2), ('ValueSet', self.icd_vs_v1)):
+ resource = self.read(f'/fhir/{resource_type}/{version.id}')
+ self.assertEqual(
+ (resource['resourceType'], resource['id'], resource['version']),
+ (resource_type, str(version.id), version.version))
+ self.assertEqual(self.read(f'/fhir/{resource_type}/{version.id}/')['id'], str(version.id))
+
+ self.assertEqual(self.read(f'/fhir/CodeSystem/{self.who_icd_2019.id}')['url'], ICD10) # the repository's
+ self.assertEqual(self.read(f'/fhir/ValueSet/{self.icd_vs_v1.id}')['url'], ICD10_VS)
+ self.assertEqual(self.read(f'/fhir/CodeSystem/{self.ciel_v1.id}')['status'], 'retired')
+
+ def test_global_read_finds_only_what_global_search_lists(self):
+ for version in (self.who_icd, self.sehat_icd_v2, self.draft_v1, self.gone_v1, self.flagged, self.fixture_v1):
+ for user in (None, self.member, self.admin):
+ self.assertNotFound(f'/fhir/CodeSystem/{version.id}', user)
+ self.assertNotFound(f'/fhir/ValueSet/{self.ciel_vs_v2.id}')
+ for missing in ('CIEL', 'ICD-10', '0', f'0{self.ciel_v2.id}', '99999999', '12345678901234567890123'):
+ self.assertNotFound(f'/fhir/CodeSystem/{missing}')
+ self.assertNotFound(f'/fhir/ConceptMap/{missing}')
+
+ def test_owner_read_is_the_latest_release_or_the_version_named(self):
+ Source.objects.filter(id=self.ciel_v1.id).update(retired=False)
+ newest = source(self.ciel, 'CIEL', 'v3', canonical_url=CIEL) # unreleased
+ resource = self.read('/orgs/CIEL/CodeSystem/CIEL/')
+ self.assertEqual((resource['id'], resource['version']), ('CIEL', 'v2'))
+ self.assertEqual(self.read('/orgs/CIEL/CodeSystem/CIEL', version='v1')['version'], 'v1')
+ self.assertEqual(self.read('/orgs/CIEL/ConceptMap/CIEL/')['version'], 'v2')
+ self.assertEqual(self.read('/orgs/sehatapp/ValueSet/icd10-all/')['url'], ICD10_VS)
+ self.assertEqual(self.read('/users/jdoe/CodeSystem/my-codes/')['version'], 'v1')
+ for version in (newest.version, HEAD, 'no-such-version'):
+ self.assertNotFound('/orgs/CIEL/CodeSystem/CIEL/', version=version)
+ for path in ('/orgs/misc/CodeSystem/draft/', '/orgs/misc/CodeSystem/gone/', '/orgs/misc/CodeSystem/flagged/',
+ '/orgs/nobody/CodeSystem/CIEL/', '/orgs/WHO/CodeSystem/CIEL/', '/orgs/CIEL/ValueSet/CIEL/',
+ '/orgs/CIEL/ConceptMap/nothing/'):
+ self.assertNotFound(path)
+
+ def test_a_retired_release_is_the_default_only_when_named(self):
+ self.assertEqual(self.read('/orgs/CIEL/CodeSystem/CIEL/')['version'], 'v2')
+ self.assertEqual(self.read('/orgs/CIEL/CodeSystem/CIEL/', version='v1')['status'], 'retired')
+
+ def test_private_owner_read(self):
+ for user in (self.member, self.admin):
+ resource = self.read('/orgs/FHIR-Fixtures/CodeSystem/fixture-codes/', user)
+ self.assertEqual((resource['id'], resource['version']), ('fixture-codes', '2.0.0'))
+ for user in (None, self.outsider):
+ self.assertNotFound('/orgs/FHIR-Fixtures/CodeSystem/fixture-codes/', user)
+
+ def test_code_system_content_is_complete_up_to_the_limit(self):
+ names = [ConceptNameFactory.build(name='Malaria', locale='en', locale_preferred=True),
+ ConceptNameFactory.build(name='Paludisme', locale='fr')]
+ malaria = ConceptFactory(parent=self.ciel_source, mnemonic='1427', names=names, sources=[self.ciel_v2],
+ descriptions=[ConceptDescriptionFactory.build(name='A mosquito-borne disease')])
+ retired = ConceptFactory(parent=self.ciel_source, mnemonic='9999', retired=True, sources=[self.ciel_v2])
+ ConceptFactory(parent=self.ciel_source, mnemonic='gone', is_active=False, sources=[self.ciel_v2])
+
+ resource = self.read(f'/fhir/CodeSystem/{self.ciel_v2.id}')
+
+ self.assertEqual((resource['content'], resource['count']), ('complete', 2))
+ concepts = {concept['code']: concept for concept in resource['concept']}
+ self.assertEqual(set(concepts), {malaria.mnemonic, retired.mnemonic})
+ self.assertEqual(concepts['1427']['display'], 'Malaria')
+ self.assertEqual(concepts['1427']['definition'], 'A mosquito-borne disease')
+ self.assertEqual(
+ sorted((name['language'], name['value']) for name in concepts['1427']['designation']),
+ [('en', 'Malaria'), ('fr', 'Paludisme')])
+ self.assertIn({'code': 'inactive', 'valueBoolean': True}, concepts['9999']['property'])
+ self.assertNotIn('tag', resource['meta'])
+
+ # above the limit the count is the version's concept links, an index-only count: a deleted concept still
+ # linked would count (production has none on a released version)
+ with patch('core.fhir.search.CONCEPT_LIMIT', 1):
+ resource = self.read(f'/fhir/CodeSystem/{self.ciel_v2.id}')
+ self.assertEqual((resource['content'], resource['count']), ('not-present', 3))
+ self.assertNotIn('concept', resource)
+
+ def test_reads_run_no_query_per_concept_or_mapping(self):
+ def read_queries(path):
+ with self.assertNumQueries(5): # the version, its concept count, the concepts, their names, descriptions
+ self.read(path)
+
+ ConceptFactory(parent=self.ciel_source, sources=[self.ciel_v2], names=1, descriptions=1)
+ read_queries(f'/fhir/CodeSystem/{self.ciel_v2.id}')
+ for _ in range(3):
+ ConceptFactory(parent=self.ciel_source, sources=[self.ciel_v2], names=2, descriptions=1)
+ read_queries(f'/fhir/CodeSystem/{self.ciel_v2.id}')
+ read_queries('/orgs/CIEL/CodeSystem/CIEL/')
+
+ for _ in range(3):
+ MappingFactory(parent=self.ciel_source, from_source=self.ciel_source, to_source=self.sehat_icd,
+ sources=[self.ciel_v2])
+ with self.assertNumQueries(2): # the version, its mappings
+ self.read(f'/fhir/ConceptMap/{self.ciel_v2.id}')
+
+ def test_value_set_compose_caps_only_concept_references(self):
+ for code in ('M1', 'M2', 'M3'):
+ self.ciel_vs_v1.references.create(expression=code, reference_type='mappings', system=CIEL, code=code)
+ with patch('core.value_sets.serializers.REFERENCE_LIMIT', 2):
+ with self.assertNumQueries(2):
+ resource = self.read(f'/fhir/ValueSet/{self.ciel_vs_v1.id}')
+ self.assertNotIn('compose', resource)
+ self.assertNotIn('tag', resource['meta'])
+
+ for count, code in enumerate(('C', 'B', 'A'), 1):
+ self.ciel_vs_v1.references.create(expression=code, reference_type='concepts', system=CIEL,
+ code=code, include=code != 'B')
+ with self.assertNumQueries(2) as queries:
+ resource = self.read(f'/fhir/ValueSet/{self.ciel_vs_v1.id}')
+
+ self.assertEqual(resource['compose']['include'], [{'system': CIEL, 'concept': [{'code': 'C'}]}])
+ if count == 1:
+ self.assertNotIn('exclude', resource['compose'])
+ else:
+ self.assertEqual(resource['compose']['exclude'], [{'system': CIEL, 'concept': [{'code': 'B'}]}])
+ if count > 2:
+ self.assertEqual(resource['meta']['tag'], [SUBSETTED])
+ else:
+ self.assertNotIn('tag', resource['meta'])
+ self.assertIn('lastUpdated', resource['meta'])
+ self.assertEqual(resource['meta']['versionId'], str(self.ciel_vs_v1.id))
+ self.assertIn('LIMIT 3', queries.captured_queries[-1]['sql'])
+
+ def test_concept_map_group_caps_only_live_mappings(self):
+ for flags in ({'retired': True}, {'is_active': False}):
+ MappingFactory(parent=self.ciel_source, from_source=self.ciel_source, to_source=self.sehat_icd,
+ sources=[self.ciel_v2], **flags)
+ with patch('core.concept_maps.serializers.MAPPING_LIMIT', 2):
+ with self.assertNumQueries(2):
+ resource = self.read(f'/fhir/ConceptMap/{self.ciel_v2.id}')
+ self.assertNotIn('group', resource)
+ self.assertNotIn('tag', resource['meta'])
+
+ for count, code in enumerate(('C', 'B', 'A'), 1):
+ MappingFactory(parent=self.ciel_source, from_source=self.ciel_source, to_source=self.sehat_icd,
+ from_concept_code='1', to_concept_code=code, sources=[self.ciel_v2])
+ with self.assertNumQueries(2) as queries:
+ resource = self.read(f'/fhir/ConceptMap/{self.ciel_v2.id}')
+
+ targets = [target['code'] for group in resource['group'] for element in group['element']
+ for target in element['target']]
+ self.assertEqual(targets, ['C'] if count == 1 else ['C', 'B'])
+ if count > 2:
+ self.assertEqual(resource['meta']['tag'], [SUBSETTED])
+ else:
+ self.assertNotIn('tag', resource['meta'])
+ self.assertIn('lastUpdated', resource['meta'])
+ self.assertEqual(resource['meta']['versionId'], str(self.ciel_v2.id))
+ self.assertIn('LIMIT 3', queries.captured_queries[-1]['sql'])
+
+ def test_subsetted_reads_keep_existing_metadata(self):
+ tag = {'system': 'http://example.org/tags', 'code': 'example'}
+ meta = {'lastUpdated': '2026-01-01T00:00:00Z', 'tag': [tag]}
+ for code in ('A', 'B'):
+ self.ciel_vs_v1.references.create(expression=code, reference_type='concepts', system=CIEL, code=code)
+ MappingFactory(parent=self.ciel_source, from_source=self.ciel_source, to_source=self.sehat_icd,
+ from_concept_code='1', to_concept_code=code, sources=[self.ciel_v2])
+ for resource_type, version, module, serializer, limit in (
+ ('ValueSet', self.ciel_vs_v1, 'value_sets', 'ValueSetDetailSerializer', 'REFERENCE_LIMIT'),
+ ('ConceptMap', self.ciel_v2, 'concept_maps', 'ConceptMapDetailSerializer', 'MAPPING_LIMIT')):
+ with patch(f'core.{module}.serializers.{limit}', 1), \
+ patch(f'core.{module}.serializers.{serializer}.get_meta', return_value=meta):
+ resource = self.read(f'/fhir/{resource_type}/{version.id}')
+
+ self.assertEqual(resource['meta'], {**meta, 'versionId': str(version.id), 'tag': [tag, SUBSETTED]})
+ self.assertEqual(meta['tag'], [tag])
+
+ def test_writes_keep_the_owner_check(self):
+ client = APIClient()
+ client.force_authenticate(self.admin)
+ response = client.put(f'/fhir/CodeSystem/{self.ciel_v2.id}/', {}, format='json')
+ self.assertEqual(response.status_code, 404)
+
+
+class RepositoryAccessTest(SearchCorpusMixin, OCLTestCase):
+ """
+ A version is shown only where its repository is too: its HEAD, which governs access to its concepts and mappings,
+ must be viewable as well as the version. MOH made hiv-codes and hiv-set private after releasing v1 public, so v1
+ is shown at MOH's base to MOH's members and staff, and nowhere else. A retired release counts only beside a live
+ one the caller is shown, and an owner-base read without a version serves the newest release the caller is shown.
+ """
+ def setUp(self):
+ super().setUp()
+ ConceptFactory(parent=self.hiv, mnemonic='H1', sources=[self.hiv_v1])
+ MappingFactory(parent=self.hiv, from_source=self.hiv, to_source=self.ciel_source, sources=[self.hiv_v1])
+ self.users = (None, self.outsider, self.member, self.moh_member, self.admin)
+ self.viewers = (self.moh_member, self.admin)
+
+ def test_search_shows_a_release_where_its_repository_is_shown(self):
+ hiv, hiv_set = [f'{HIV}|v1'], [f'{HIV_VS}|v1']
+ for user in self.users:
+ for base, shown in (('/fhir', False), ('/orgs/MOH', user in self.viewers)):
+ for resource_type, params, keys in (
+ ('CodeSystem', {}, hiv), ('CodeSystem', {'url': HIV}, hiv), ('CodeSystem', {'code': 'H1'}, hiv),
+ ('CodeSystem', {'code': f'{HIV}|'}, hiv), ('ConceptMap', {}, hiv),
+ ('ConceptMap', {'url': HIV}, hiv), ('ValueSet', {}, hiv_set),
+ ('ValueSet', {'url': HIV_VS}, hiv_set), ('ValueSet', {'reference': CIEL}, hiv_set)):
+ bundle = self.search(resource_type, base=base, user=user, **params)
+ found = [key for key in self.keys(bundle) if key in keys]
+ self.assertEqual(found, keys if shown else [], (user, base, resource_type, params))
+ if base == '/orgs/MOH':
+ self.assertEqual(bundle['total'], len(found), (user, resource_type, params))
+
+ def test_read_serves_a_release_where_its_repository_is_shown(self):
+ for user in self.users:
+ for resource_type, version in (
+ ('CodeSystem', self.hiv_v1), ('ConceptMap', self.hiv_v1), ('ValueSet', self.hiv_vs_v1)):
+ self.assertNotFound(f'/fhir/{resource_type}/{version.id}', user)
+ for path, url in (('/orgs/MOH/CodeSystem/hiv-codes/', HIV), ('/orgs/MOH/ConceptMap/hiv-codes/', HIV),
+ ('/orgs/MOH/ValueSet/hiv-set/', HIV_VS)):
+ if user in self.viewers:
+ resource = self.read(path, user)
+ self.assertEqual((resource['version'], resource['url']), ('v1', url), (path, user))
+ else:
+ self.assertNotFound(path, user)
+ self.assertNotFound(path, user, version='v1')
+ self.assertEqual([concept['code'] for concept in self.read(
+ '/orgs/MOH/CodeSystem/hiv-codes/', self.moh_member)['concept']], ['H1'])
+
+ def test_a_retired_release_counts_beside_a_live_one_the_caller_is_shown(self):
+ url = 'http://moh.example/fhir/CodeSystem/mixed'
+ source(self.moh, 'mixed', canonical_url=url)
+ retired = source(self.moh, 'mixed', 'v1', released=True, retired=True, canonical_url=url)
+ source(self.moh, 'mixed', 'v2', released=True, canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ for user in self.users:
+ shown = user in self.viewers
+ self.assertEqual(self.search('CodeSystem', user=user, url=url)['total'], 0, user)
+ self.assertNotFound(f'/fhir/CodeSystem/{retired.id}', user)
+ self.assertEqual(self.keys(self.search('CodeSystem', base='/orgs/MOH', user=user, url=url)),
+ [f'{url}|v1', f'{url}|v2'] if shown else [], user)
+ if shown:
+ self.assertEqual(self.read('/orgs/MOH/CodeSystem/mixed/', user, version='v1')['status'], 'retired')
+ else:
+ self.assertNotFound('/orgs/MOH/CodeSystem/mixed/', user, version='v1')
+
+ def test_an_owner_read_serves_the_newest_release_the_caller_is_shown(self):
+ url = 'http://moh.example/fhir/CodeSystem/newest'
+ source(self.moh, 'newest', canonical_url=url)
+ source(self.moh, 'newest', 'v1', released=True, canonical_url=url)
+ source(self.moh, 'newest', 'v2', released=True, canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ for user in self.users:
+ newest = 'v2' if user in self.viewers else 'v1'
+ self.assertEqual(self.read('/orgs/MOH/CodeSystem/newest/', user)['version'], newest, user)
+ listed = self.keys(self.search('CodeSystem', base='/orgs/MOH', user=user, _id='newest'))
+ self.assertIn(f'{url}|{newest}', listed, user)
+
+
+class VisibleVersionsTest(SearchCorpusMixin, OCLTestCase):
+ """visible_versions is the queryset form of is_visible and has_release: they agree on every row."""
+ def test_the_queryset_agrees_with_is_visible_and_has_release(self):
+ # a retired public release beside a private live one; a private newest release; a deleted repository (HEAD)
+ source(self.moh, 'mixed', canonical_url='urn:mixed')
+ source(self.moh, 'mixed', 'v1', released=True, retired=True, canonical_url='urn:mixed')
+ source(self.moh, 'mixed', 'v2', released=True, canonical_url='urn:mixed', public_access=ACCESS_TYPE_NONE)
+ source(self.moh, 'newest', canonical_url='urn:newest')
+ source(self.moh, 'newest', 'v1', released=True, canonical_url='urn:newest')
+ source(self.moh, 'newest', 'v2', released=True, canonical_url='urn:newest', public_access=ACCESS_TYPE_NONE)
+ Source.objects.filter(id=self.jdoe_codes.id).update(is_active=False)
+ users = (AnonymousUser(), self.outsider, self.member, self.moh_member, self.admin)
+ cases = [({}, user) for user in users] + [
+ ({'org': org}, user) for org in ('FHIR-Fixtures', 'MOH') for user in users] + [
+ ({'org': 'CIEL'}, AnonymousUser()), ({'org': 'misc'}, self.admin), ({'user': 'jdoe'}, AnonymousUser()),
+ ({'user': 'jdoe'}, self.jdoe)]
+ for kwargs, user in cases:
+ for resource_type in ('CodeSystem', 'ValueSet'):
+ model = fhir_resolve.REPO_TYPES[resource_type]
+ namespace = fhir_resolve.namespace_of(kwargs)
+ expected = {
+ row.id for row in model.objects.filter(is_active=True, released=True).exclude(version=HEAD)
+ if (namespace is None or row.parent.uri == namespace) and
+ fhir_resolve.is_visible(row, namespace, user) and fhir_resolve.has_release(row, namespace, user)}
+ visible = set(fhir_resolve.visible_versions(resource_type, kwargs, user).values_list('id', flat=True))
+ self.assertEqual(visible, expected, (kwargs, user, resource_type))
+ self.assertIn(self.hiv_v1.id, fhir_resolve.visible_versions(
+ 'CodeSystem', {'org': 'MOH'}, self.moh_member).values_list('id', flat=True))
+
+
+class SearchTimeoutTest(TransactionTestCase):
+ """A search the database cancels at SEARCH_TIMEOUT_MS is too costly (a 422 outcome), never a 5xx."""
+ def test_a_cancelled_query_is_too_costly(self):
+ def slow():
+ with connection.cursor() as cursor:
+ cursor.execute('SELECT pg_sleep(1)')
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 50):
+ with self.assertRaises(TooCostlyError) as context:
+ fhir_search.within_search_timeout(slow)
+ self.assertEqual((context.exception.status_code, context.exception.issue_code), (422, 'too-costly'))
+
+ self.assertEqual(fhir_search.within_search_timeout(lambda: 'answered'), 'answered')
+ with connection.cursor() as cursor: # the timeout ended with the search
+ cursor.execute('SHOW statement_timeout')
+ self.assertEqual(cursor.fetchone()[0], '0')
+
+ def test_the_timeout_bounds_the_search_as_a_whole(self):
+ """Each query may take only the time the search has left, so queries that fit alone can still add up past it."""
+ def slow():
+ with connection.cursor() as cursor:
+ for _ in range(4):
+ cursor.execute('SELECT pg_sleep(0.04)')
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 100):
+ with self.assertRaises(TooCostlyError):
+ fhir_search.within_search_timeout(slow)
+ self.assertEqual(fhir_search.within_search_timeout(lambda: 'answered'), 'answered')
+
+ def test_a_query_with_no_time_left_is_too_costly(self):
+ """A query that starts after the search's time is up isn't sent: the search is too costly already."""
+ def late():
+ time.sleep(0.06)
+ with connection.cursor() as cursor:
+ cursor.execute('SELECT 1')
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 50):
+ with self.assertRaises(TooCostlyError):
+ fhir_search.within_search_timeout(late)
+
+ def test_other_database_errors_are_raised(self):
+ def broken():
+ raise OperationalError('connection lost')
+
+ with self.assertRaises(OperationalError):
+ fhir_search.within_search_timeout(broken)
diff --git a/core/fhir/tests_search_systems.py b/core/fhir/tests_search_systems.py
new file mode 100644
index 000000000..ffdea7874
--- /dev/null
+++ b/core/fhir/tests_search_systems.py
@@ -0,0 +1,513 @@
+"""
+core.fhir.search: ConceptMap source-system, source-uri, target-system and target-uri (SVCM ITI-100), with the URL
+registry toggle off and on. The versions holding a source's mappings are read from OCL's mappings index, one bounded
+request per parameter, and the database checks the few mappings the index names for each version: the same Bundle as
+before in a fixed number of queries, however many mappings a source has. A system or a ConceptMap the caller isn't
+shown leaves no trace: the same bytes, statements and index requests as one OCL doesn't hold.
+OpenConceptLab/ocl_online#451.
+"""
+import json
+import re
+import uuid
+from datetime import datetime, timedelta, timezone as dt_timezone
+
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from django.utils import timezone
+from elastic_transport import ApiResponseMeta, HttpHeaders, NodeConfig
+from elasticsearch import ApiError, ConnectionError as ESConnectionError, ConnectionTimeout, Elasticsearch, \
+ NotFoundError
+from elasticsearch_dsl.connections import connections
+from mock import patch
+from rest_framework.test import APIClient
+
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.mapping_index import LAG_WINDOW
+from core.fhir.resolve import visible_versions
+from core.fhir.tests_search import SearchCorpusMixin, source, CIEL, ICD10
+from core.mappings.documents import MappingDocument
+from core.mappings.models import Mapping
+from core.mappings.tests.factories import MappingFactory
+from core.orgs.tests.factories import OrganizationFactory
+from core.sources.models import Source
+from core.url_registry.factories import GlobalURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.tests.factories import UserProfileFactory
+
+PRIVATE = 'http://moh.example/fhir/CodeSystem/private'
+MY_CODES = 'https://example.org/my-codes'
+NOW = datetime(2026, 10, 16, 12, tzinfo=dt_timezone.utc)
+
+
+def index_requests():
+ """A spy on the mappings index's search requests: patch it in, then read its call_args_list."""
+ return patch.object(Elasticsearch, 'search', autospec=True, side_effect=Elasticsearch.search)
+
+
+def es_error(kind, status):
+ """An error as the Elasticsearch client raises it for an HTTP status."""
+ meta = ApiResponseMeta(status=status, http_version='1.1', headers=HttpHeaders(), duration=0.0,
+ node=NodeConfig('http', 'es', 9200))
+ return kind(message='refused', meta=meta, body={'error': {'type': 'refused'}})
+
+
+def aggregations(node):
+ """Every aggregation of an index request's aggs, nested ones included: (kind, its body)."""
+ for aggregation in node.values():
+ for kind, body in aggregation.items():
+ if kind == 'aggs':
+ yield from aggregations(body)
+ else:
+ yield kind, body
+
+
+class SystemSearchCases(SearchCorpusMixin):
+ """
+ CIEL v2 maps to sehatapp's ICD-10; jdoe's my-codes v1 maps to CIEL. MOH's private source is mapped to and from by
+ Partner's public map. `registry` is the URL registry toggle, on with global entries for CIEL and ICD-10.
+ """
+ registry = False
+
+ def setUp(self):
+ super().setUp()
+ set_registry_toggle(self.registry)
+ if self.registry:
+ GlobalURLRegistryFactory(url=CIEL, namespace=self.ciel.uri)
+ GlobalURLRegistryFactory(url=ICD10, namespace=self.who.uri)
+ self.ciel_code = ConceptFactory(parent=self.ciel_source, mnemonic='1427')
+ self.icd_code = ConceptFactory(parent=self.sehat_icd, mnemonic='A00')
+ self.ciel_to_icd = self.mapping(self.ciel_source, self.ciel_code, self.icd_code, [self.ciel_v2])
+ self.my_code = ConceptFactory(parent=self.jdoe_codes, mnemonic='m1')
+ self.my_codes_to_ciel = self.mapping(self.jdoe_codes, self.my_code, self.ciel_code, [self.jdoe_codes_v1])
+
+ self.private = source(self.moh, 'private-codes', canonical_url=PRIVATE, public_access=ACCESS_TYPE_NONE)
+ source(self.moh, 'private-codes', 'v1', released=True, canonical_url=PRIVATE, public_access=ACCESS_TYPE_NONE)
+ self.private_code = ConceptFactory(parent=self.private, mnemonic='P1')
+ self.partner = OrganizationFactory(mnemonic='Partner')
+ self.partner_only = UserProfileFactory(username='partner-member')
+ self.partner.members.add(self.partner_only)
+ self.partner_map = source(self.partner, 'partner-map', canonical_url='https://example.org/partner-map')
+ self.partner_map_v1 = source(self.partner, 'partner-map', 'v1', released=True,
+ canonical_url='https://example.org/partner-map')
+ own = ConceptFactory(parent=self.partner_map, mnemonic='X1')
+ self.mapping(self.partner_map, own, self.private_code, [self.partner_map_v1])
+ self.mapping(self.partner_map, self.private_code, own, [self.partner_map_v1])
+
+ @staticmethod
+ def mapping(parent, from_concept, to_concept, sources, **kwargs):
+ """A mapping as OCL stores one: both sides' sources, their URLs, the codes and its repository's access."""
+ kwargs.setdefault('to_concept_code', to_concept.mnemonic)
+ kwargs.setdefault('public_access', parent.public_access)
+ return MappingFactory(
+ parent=parent, from_concept=from_concept, to_concept=to_concept, from_source=from_concept.parent,
+ to_source=to_concept.parent, from_source_url=from_concept.parent.uri, to_source_url=to_concept.parent.uri,
+ from_concept_code=from_concept.mnemonic, sources=sources, **kwargs)
+
+ @staticmethod
+ def client_for(user):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client
+
+ # the versions come from the mappings index
+
+ @staticmethod
+ def unindexed(mapping):
+ """The index as it is while a release's indexing runs: the mapping's document lists HEAD alone."""
+ connections.get_connection().update(
+ index=MappingDocument.Index.name, id=str(mapping.id), doc={'source_version': ['HEAD']}, refresh=True)
+
+ def test_a_release_whose_mappings_are_not_indexed_yet_answers_as_before(self):
+ """
+ While a release's indexing runs, the documents of its mappings don't list it yet: a release created within
+ LAG_WINDOW, of a repository the index shows mapping to the system, is checked in the database all the same.
+ The same Bundle before, during and after.
+ """
+ names = ('target-system', 'target-uri')
+ before = {name: self.client.get('/fhir/ConceptMap/', {name: ICD10}).data for name in names}
+ self.assertEqual(self.keys(before['target-system']), [f'{CIEL}|v2'])
+ self.unindexed(self.ciel_to_icd)
+ for name in names:
+ during = self.client.get('/fhir/ConceptMap/', {name: ICD10}).data
+ self.assertEqual((self.keys(during), during['total']), (self.keys(before[name]), 1), name)
+ MappingDocument().update(self.ciel_to_icd)
+ self.assertEqual(self.keys(self.search('ConceptMap', **{'target-system': ICD10})), [f'{CIEL}|v2'])
+
+ def unindexed_release(self, target):
+ """A public ConceptMap no other test's documents name, its release v1 mapping to `target`: (key, mapping)."""
+ name = f'maps-{uuid.uuid4().hex[:8]}'
+ maps = source(self.misc, name, canonical_url=f'https://example.org/{name}')
+ release = source(self.misc, name, 'v1', released=True, canonical_url=maps.canonical_url)
+ return f'{maps.canonical_url}|v1', release, self.mapping(maps, ConceptFactory(parent=maps), target, [release])
+
+ def test_residual_a_release_unindexed_past_the_lag_window_is_missed(self):
+ """
+ Residual (a decision for Jon): past LAG_WINDOW, a release whose mappings' documents never came to list it (its
+ indexing failed) is missed until it's indexed again; WP3's statement found it. Within the window it's found.
+ """
+ key, release, mapping = self.unindexed_release(self.icd_code)
+ self.assertIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10})))
+ self.unindexed(mapping)
+ self.assertIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10}))) # within the window
+ Source.objects.filter(id=release.id).update(created_at=timezone.now() - LAG_WINDOW - timedelta(hours=1))
+ self.assertNotIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10})))
+ MappingDocument().update(mapping)
+ self.assertIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10})))
+
+ def test_residual_a_repository_none_of_whose_mappings_are_indexed_is_missed(self):
+ """
+ Residual (a decision for Jon): a ConceptMap none of whose mappings to the system are in the index yet (an
+ import that skipped indexing, or one still indexing) is missed, at any age, until they are indexed; WP3's
+ statement found it.
+ """
+ key, release, _ = self.unindexed_release(self.icd_code)
+ mappings = Mapping.objects.filter(parent__mnemonic=release.mnemonic) # every row: HEAD's latest version too
+ MappingDocument().update(mappings, action='delete')
+ self.assertNotIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10})))
+ MappingDocument().update(mappings)
+ self.assertIn(key, self.keys(self.search('ConceptMap', **{'target-system': ICD10})))
+
+ def test_residual_an_ocl_path_is_matched_by_the_stored_url_statement(self):
+ """
+ Residual (a decision for Jon): a system written as an OCL path is matched as WP3 matched it, by the URL each
+ mapping stores, a prefix read of that index with no request to the mappings index: its cost follows the
+ number of mappings storing the URL (LOINC's 1.08M locally, about 3 s; see the report).
+ """
+ with CaptureQueriesContext(connection) as context, index_requests() as spy:
+ response = self.client.get('/fhir/ConceptMap/', {'target-system': '/orgs/sehatapp/ConceptMap/ICD-10/'})
+ self.assertEqual((response.status_code, response.data['total']), (200, 0))
+ self.assertEqual(spy.call_args_list, [])
+ self.assertIn("LIKE '/orgs/sehatapp/sources/ICD-10/%'", context.captured_queries[1]['sql'])
+
+ def test_one_bounded_index_request_per_parameter(self):
+ """
+ One request per system parameter, whatever its alternatives, sent only for a system among the sources the base
+ shows the caller: none for one OCL doesn't hold or the caller isn't shown. It reads only the documents of the
+ ConceptMap versions the base shows the caller, as the database lists them (never a document's own access copy),
+ whole (no partial results) within a time limit, and each aggregation has an explicit size and lists only those
+ repositories and versions (and HEAD); it lists versions, not mappings.
+ """
+ unknown = ','.join(f'https://example.org/unknown-{index}' for index in range(19))
+ for base, user, params, requests, total in (
+ ('/fhir', None, {'source-system': CIEL}, 1, 1),
+ ('/fhir', None, {'source-uri': f'{unknown},{CIEL}'}, 1, 1),
+ ('/fhir', None, {'source-system': CIEL, 'target-system': ICD10}, 2, 1),
+ ('/fhir', None, {'target-system': f'{ICD10},{CIEL}'}, 1, 2),
+ ('/fhir', None, {'target-system': unknown}, 0, 0),
+ ('/fhir', self.admin, {'target-system': PRIVATE}, 0, 0),
+ ('/fhir', self.admin, {'target-system': CIEL}, 1, 1),
+ ('/orgs/Partner', self.partner_only, {'target-system': PRIVATE}, 0, 0),
+ ('/orgs/Partner', self.admin, {'target-system': PRIVATE}, 1, 1),
+ ('/orgs/Partner', None, {'target-system': f'{PRIVATE},{CIEL}'}, 1, 0)):
+ with index_requests() as spy:
+ response = self.client_for(user).get(f'{base}/ConceptMap/', params)
+ self.assertEqual((response.status_code, response.data['total']), (200, total), params)
+ self.assertEqual(len(spy.call_args_list), requests, (base, params))
+ for call in spy.call_args_list:
+ self.assert_bounded(call.kwargs, self.scope(base, user))
+
+ @staticmethod
+ def scope(base, user):
+ """
+ The owner types, owners and repositories (in lowercase, as the index keeps them) and the version names (with
+ HEAD) of the ConceptMap versions the database shows `user` at `base`.
+ """
+ kwargs = {} if base == '/fhir' else {'org': base.rsplit('/', 1)[-1]}
+ scope = {'owner_type': set(), 'owner': set(), 'source': set(), 'source_version': {'HEAD'}}
+ for mnemonic, org, username, version in visible_versions('ConceptMap', kwargs, user).values_list(
+ 'mnemonic', 'organization__mnemonic', 'user__username', 'version'):
+ scope['owner_type'].add('Organization' if org else 'User')
+ scope['owner'].add((org or username).lower())
+ scope['source'].add(mnemonic.lower())
+ scope['source_version'].add(version)
+ return {field: sorted(values) for field, values in scope.items()}
+
+ def assert_bounded(self, body, scope): # pylint: disable=invalid-name
+ """
+ An index request: whole, timed, filtered to the documents of `scope` (never by the documents' own access),
+ every aggregation sized, the versions listing only `scope`'s (the filter bounds the rest), no hits.
+ """
+ self.assertEqual((body['index'], body['size'], body['allow_partial_search_results']),
+ (MappingDocument.Index.name, 0, False))
+ self.assertRegex(body['timeout'], r'^[0-9]+ms$')
+ self.assertNotIn('public_can_view', json.dumps(body))
+ self.assertEqual(body['query']['bool']['filter'][1:],
+ [{'terms': {field: values}} for field, values in scope.items()])
+ found = list(aggregations(body['aggs']))
+ self.assertNotIn('top_hits', [kind for kind, _ in found])
+ terms = [aggregation for kind, aggregation in found if kind == 'terms']
+ self.assertEqual([aggregation['field'] for aggregation in terms][:4], list(scope))
+ for aggregation in terms:
+ self.assertLessEqual(aggregation['size'], 1000, aggregation)
+ self.assertEqual(aggregation.get('include'),
+ scope['source_version'] if aggregation['field'] == 'source_version' else None, aggregation)
+
+ def test_queries_stay_three_however_many_mappings(self):
+ for index in range(12):
+ self.mapping(self.ciel_source, ConceptFactory(parent=self.ciel_source), self.icd_code,
+ [self.ciel_v1, self.ciel_v2], to_concept_code=f'A{index:02}')
+ with self.assertNumQueries(3): # the sources with the canonical, the matches' ids, the page
+ response = self.client.get('/fhir/ConceptMap/', {'target-system': ICD10})
+ self.assertEqual(self.keys(response.data), [f'{CIEL}|v1', f'{CIEL}|v2'])
+
+ def test_a_version_is_decided_by_all_its_mappings_not_a_few(self):
+ """
+ However many of a version's newer mappings the database rejects (retired or deleted there, live in the index),
+ an older live one still finds it; once that one is retired too, the version isn't found.
+ """
+ newer = [self.mapping(self.jdoe_codes, ConceptFactory(parent=self.jdoe_codes), self.ciel_code,
+ [self.jdoe_codes_v1]) for _ in range(4)]
+ Mapping.objects.filter(id__in=[mapping.id for mapping in newer[:2]]).update(retired=True)
+ Mapping.objects.filter(id__in=[mapping.id for mapping in newer[2:]]).update(is_active=False)
+ for name in ('target-system', 'target-uri'):
+ self.assertEqual(self.keys(self.search('ConceptMap', **{name: CIEL})), [f'{MY_CODES}|v1'], name)
+ Mapping.objects.filter(id=self.my_codes_to_ciel.id).update(retired=True)
+ self.assertEqual(self.search('ConceptMap', **{'target-system': CIEL})['total'], 0)
+
+ def test_owners_whose_names_differ_only_in_case_are_told_apart(self):
+ """
+ The index keeps a mapping's owner and repository names in lowercase, so twins share its versions; the database
+ decides each twin's version by its own mappings.
+ """
+ maps = []
+ for mnemonic in ('Acme', 'ACME'):
+ owner = OrganizationFactory(mnemonic=mnemonic)
+ head = source(owner, 'Maps', canonical_url=f'https://example.org/{mnemonic}/maps')
+ maps.append(source(owner, 'Maps', 'v1', released=True, canonical_url=head.canonical_url))
+ self.mapping(maps[0].head, ConceptFactory(parent=maps[0].head), self.ciel_code, [maps[0]])
+ self.assertEqual(self.keys(self.search('ConceptMap', **{'target-system': CIEL})),
+ sorted([f'{MY_CODES}|v1', 'https://example.org/Acme/maps|v1']))
+ self.mapping(maps[1].head, ConceptFactory(parent=maps[1].head), self.ciel_code, [maps[1]])
+ self.assertEqual(self.keys(self.search('ConceptMap', **{'target-system': CIEL})), sorted(
+ [f'{MY_CODES}|v1', 'https://example.org/ACME/maps|v1', 'https://example.org/Acme/maps|v1']))
+ Source.objects.filter(mnemonic='Maps', organization__mnemonic='ACME').update(public_access=ACCESS_TYPE_NONE)
+ self.assertEqual(self.keys(self.search('ConceptMap', **{'target-system': CIEL})),
+ sorted([f'{MY_CODES}|v1', 'https://example.org/Acme/maps|v1']))
+
+ # cost: a bound on the index's answer and on its time
+
+ def test_a_repository_made_public_is_found_before_its_documents_say_so(self):
+ """
+ Visibility is the database's: a ConceptMap just made public is found while its mappings' documents still say
+ they aren't public (index_source_public_access still running), as WP3 found it.
+ """
+ name = f'opened-{uuid.uuid4().hex[:8]}'
+ head = source(self.misc, name, canonical_url=f'https://example.org/{name}', public_access=ACCESS_TYPE_NONE)
+ release = source(self.misc, name, 'v1', released=True, canonical_url=head.canonical_url,
+ public_access=ACCESS_TYPE_NONE)
+ self.mapping(head, ConceptFactory(parent=head), self.ciel_code, [release]) # documented as not public
+ key = f'{head.canonical_url}|v1'
+ self.assertNotIn(key, self.keys(self.search('ConceptMap', **{'target-system': CIEL})))
+ Source.objects.filter(mnemonic=name).update(public_access='View')
+ for parameter in ('target-system', 'target-uri'):
+ self.assertIn(key, self.keys(self.search('ConceptMap', **{parameter: CIEL})), parameter)
+
+ def test_a_public_repository_whose_mappings_say_private_is_found(self):
+ """A public ConceptMap's mappings whose own public_access says private are its mappings all the same."""
+ name = f'marked-{uuid.uuid4().hex[:8]}'
+ head = source(self.misc, name, canonical_url=f'https://example.org/{name}')
+ release = source(self.misc, name, 'v1', released=True, canonical_url=head.canonical_url)
+ self.mapping(head, ConceptFactory(parent=head), self.ciel_code, [release], public_access=ACCESS_TYPE_NONE)
+ for parameter in ('target-system', 'target-uri'):
+ self.assertIn(f'{head.canonical_url}|v1', self.keys(self.search('ConceptMap', **{parameter: CIEL})),
+ parameter)
+
+ def test_more_versions_than_the_index_lists_is_too_costly(self):
+ self.mapping(self.ciel_source, ConceptFactory(parent=self.ciel_source), self.icd_code, [self.ciel_v1])
+ self.assertEqual(self.search('ConceptMap', **{'target-system': ICD10})['total'], 2)
+ with patch('core.fhir.mapping_index.MAX_BUCKETS', 1):
+ response = self.client.get('/fhir/ConceptMap/', {'target-system': ICD10})
+ self.assertOutcome(response, 422, 'too-costly', 'too-costly')
+
+ def test_an_index_request_past_the_search_time_is_too_costly(self):
+ with patch.object(Elasticsearch, 'search', autospec=True, side_effect=ConnectionTimeout('timed out')):
+ response = self.client.get('/fhir/ConceptMap/', {'target-system': ICD10})
+ self.assertOutcome(response, 422, 'too-costly', 'too-costly')
+ with index_requests() as spy, patch('core.fhir.search.SEARCH_TIMEOUT_MS', 0):
+ response = self.client.get('/fhir/ConceptMap/', {'source-system': CIEL})
+ self.assertOutcome(response, 422, 'too-costly', 'too-costly')
+ self.assertEqual(spy.call_args_list, []) # with no time left, it isn't sent
+
+ def test_an_index_answer_that_is_not_whole_is_an_outcome(self):
+ """
+ A search the index answered only in part, or not at all, is never taken as complete: past its time it's too
+ costly; a failed shard, a missing index or any other error is the outcome of an error that isn't the
+ request's (a 422 `exception`), never a 5xx or a short Bundle.
+ """
+ shards = {'total': 2, 'successful': 2, 'skipped': 0, 'failed': 0}
+ empty = {'aggregations': {}, 'hits': {'hits': []}}
+ for answer, code, tx_issue_type in (
+ ({**empty, 'timed_out': True, '_shards': shards}, 'too-costly', 'too-costly'),
+ ({**empty, 'timed_out': False, '_shards': {**shards, 'successful': 1, 'failed': 1}}, 'exception',
+ 'invalid-data'),
+ ({**empty, 'timed_out': False}, 'exception', 'invalid-data'),
+ (es_error(NotFoundError, 404), 'exception', 'invalid-data'),
+ (es_error(ApiError, 500), 'exception', 'invalid-data'),
+ (ESConnectionError('refused'), 'exception', 'invalid-data'),
+ (ConnectionTimeout('timed out'), 'too-costly', 'too-costly')):
+ effect = {'side_effect': answer} if isinstance(answer, Exception) else {'return_value': answer}
+ with patch.object(Elasticsearch, 'search', autospec=True, **effect):
+ response = self.client.get('/fhir/ConceptMap/', {'target-system': ICD10})
+ self.assertOutcome(response, 422, code, tx_issue_type)
+
+ # what the caller isn't shown leaves no trace
+
+ def answers(self, requests):
+ """{(base, params, caller): (status, bytes, statements, index requests)}, the ids the index names as N."""
+ answers = {}
+ for base, params, callers in requests:
+ for who, user in callers:
+ with CaptureQueriesContext(connection) as context, index_requests() as spy, patch(
+ 'core.fhir.search.timezone') as clock:
+ clock.now.return_value = NOW
+ response = self.client_for(user).get(f'{base}/ConceptMap/', params)
+ statements = [re.sub(r'\b\d+\b', 'N', re.sub(r'"mapping_id" IN \([^)]*\)', '"mapping_id" IN (N)',
+ query['sql'])) for query in context.captured_queries]
+ answers[(base, json.dumps(params), who)] = (
+ response.status_code, response.content, statements, len(spy.call_args_list))
+ return answers
+
+ def test_a_system_the_caller_is_not_shown_answers_as_a_missing_one(self):
+ """
+ MOH's private source, by canonical URL and by OCL path, on either side, answers with the same bytes,
+ statements and index requests as once it's gone: Partner's mappings then store its URL with no source, as
+ mappings into a source OCL doesn't hold do.
+ """
+ callers = [('anonymous', None), ('outsider', self.outsider), ('partner-member', self.partner_only),
+ ('staff', self.admin)]
+ requests = [(base, {name: value}, callers if base == '/fhir' else callers[:3])
+ for base in ('/fhir', '/orgs/Partner', '/orgs/MOH')
+ for value in (PRIVATE, '/orgs/MOH/ConceptMap/private-codes/')
+ for name in ('source-system', 'source-uri', 'target-system', 'target-uri')]
+ hidden = self.answers(requests)
+ self.assertEqual(hidden[('/fhir', json.dumps({'target-system': '/orgs/MOH/ConceptMap/private-codes/'}),
+ 'anonymous')][1].count(b'"fullUrl"'), 1) # Partner's map, by the stored URL
+ Source.objects.filter(mnemonic='private-codes').delete()
+ missing = self.answers(requests)
+ self.assertEqual(hidden.keys(), missing.keys())
+ for key, answer in hidden.items():
+ self.assertEqual(answer, missing[key], key)
+
+ def test_a_concept_map_the_caller_is_not_shown_leaves_no_trace(self):
+ """
+ A private ConceptMap mapping into CIEL changes no public answer: the same bytes, statements and index requests
+ as without it, though the index lists its versions too.
+ """
+ hidden = source(self.moh, 'moh-map', canonical_url='https://moh.example/fhir/ConceptMap/moh',
+ public_access=ACCESS_TYPE_NONE)
+ hidden_v1 = source(self.moh, 'moh-map', 'v1', released=True, canonical_url=hidden.canonical_url,
+ public_access=ACCESS_TYPE_NONE)
+ for _ in range(3):
+ self.mapping(hidden, ConceptFactory(parent=hidden), self.ciel_code, [hidden_v1])
+ self.mapping(hidden, self.ciel_code, ConceptFactory(parent=hidden), [hidden_v1])
+ callers = [('anonymous', None), ('outsider', self.outsider)]
+ requests = [(base, {name: value}, callers) for base in ('/fhir', '/orgs/CIEL') for value in (CIEL, ICD10)
+ for name in ('source-system', 'target-system')]
+ with_it = self.answers(requests)
+ self.assertEqual(with_it[('/fhir', json.dumps({'target-system': CIEL}), 'anonymous')][:1], (200,))
+ Source.objects.filter(mnemonic='moh-map').delete()
+ self.assertEqual(with_it, self.answers(requests))
+
+ def test_concept_maps_the_caller_is_not_shown_count_toward_no_limit(self):
+ """
+ A private ConceptMap with more versions than the aggregation lists, mapping into a public system, changes no
+ public answer, at /fhir/ or its owner's base: the same bytes, statements and index requests as without it.
+ The index reads only the documents of the ConceptMaps the base shows the caller.
+ """
+ name = f'target-{uuid.uuid4().hex[:8]}' # a system no other test's documents map to
+ target = source(self.misc, name, canonical_url=f'https://example.org/{name}')
+ code = ConceptFactory(parent=target)
+ shown = source(self.misc, f'{name}-map', canonical_url=f'https://example.org/{name}-map')
+ self.mapping(shown, ConceptFactory(parent=shown), code,
+ [source(self.misc, f'{name}-map', 'v1', released=True, canonical_url=shown.canonical_url)])
+ hidden = source(self.moh, f'{name}-hidden', public_access=ACCESS_TYPE_NONE)
+ for version in ('v1', 'v2', 'v3'):
+ self.mapping(hidden, ConceptFactory(parent=hidden), code, [
+ source(self.moh, f'{name}-hidden', version, released=True, public_access=ACCESS_TYPE_NONE)])
+ callers = [('anonymous', None), ('outsider', self.outsider)]
+ requests = [(base, {parameter: target.canonical_url}, callers) for base in ('/fhir', '/orgs/MOH')
+ for parameter in ('target-system', 'target-uri')]
+ with patch('core.fhir.mapping_index.MAX_BUCKETS', 2): # the public map's HEAD and v1 fit; the hidden one's not
+ with_it = self.answers(requests)
+ self.assertEqual(with_it[('/fhir', json.dumps({'target-system': target.canonical_url}), 'anonymous')][
+ 1].count(b'"fullUrl"'), 1)
+ Source.objects.filter(mnemonic=f'{name}-hidden').delete()
+ self.assertEqual(with_it, self.answers(requests))
+
+ def test_versions_the_caller_is_not_shown_count_toward_no_limit(self):
+ """
+ Private versions of a public ConceptMap holding its mappings into a public system change no public answer: the
+ same bytes, statements and index requests as without them. The aggregation lists only versions the base
+ shows the caller.
+ """
+ name = f'target-{uuid.uuid4().hex[:8]}' # a system no other test's documents map to
+ target = source(self.misc, name, canonical_url=f'https://example.org/{name}')
+ maps = source(self.misc, f'{name}-map', canonical_url=f'https://example.org/{name}-map')
+ versions = [source(self.misc, f'{name}-map', 'v1', released=True, canonical_url=maps.canonical_url)] + [
+ source(self.misc, f'{name}-map', f'private-{number}', released=True, public_access=ACCESS_TYPE_NONE)
+ for number in range(3)]
+ self.mapping(maps, ConceptFactory(parent=maps), ConceptFactory(parent=target), versions)
+ callers = [('anonymous', None), ('outsider', self.outsider)]
+ requests = [(base, {parameter: target.canonical_url}, callers) for base in ('/fhir', '/orgs/misc')
+ for parameter in ('target-system', 'target-uri')]
+ with patch('core.fhir.mapping_index.MAX_BUCKETS', 2): # HEAD and v1 fit; the private versions don't
+ with_them = self.answers(requests)
+ self.assertEqual(with_them[('/fhir', json.dumps({'target-system': target.canonical_url}), 'anonymous')][
+ 1].count(b'"fullUrl"'), 1)
+ Source.objects.filter(mnemonic=f'{name}-map', version__startswith='private-').delete()
+ self.assertEqual(with_them, self.answers(requests))
+
+ def held_by_a_hidden_version(self, version, lagging, **kwargs):
+ """
+ A public ConceptMap (HEAD and a public v1) and the system it maps into by one older mapping that only its
+ version `version` holds (HEAD no longer does): (the system, that version, the mapping). With `lagging`,
+ v1's own mapping to the system has no documents yet.
+ """
+ name = f'target-{uuid.uuid4().hex[:8]}' # a system no other test's documents map to
+ target = source(self.misc, name, canonical_url=f'https://example.org/{name}')
+ to_code = ConceptFactory(parent=target)
+ maps = source(self.misc, f'{name}-map', canonical_url=f'https://example.org/{name}-map')
+ release = source(self.misc, maps.mnemonic, 'v1', released=True, canonical_url=maps.canonical_url)
+ code = ConceptFactory(parent=maps)
+ if lagging:
+ own = self.mapping(maps, code, to_code, [release])
+ MappingDocument().update(Mapping.objects.filter(versioned_object_id=own.versioned_object_id),
+ action='delete')
+ hidden = source(self.misc, maps.mnemonic, version, canonical_url=maps.canonical_url, **kwargs)
+ held = self.mapping(maps, code, to_code, [hidden])
+ clones = Mapping.objects.filter(versioned_object_id=held.versioned_object_id).exclude(id=held.id)
+ MappingDocument().update(clones, action='delete')
+ clones.delete()
+ held.sources.remove(maps)
+ MappingDocument().update(held, refresh=True)
+ return target, hidden, held
+
+ def test_a_version_the_caller_is_not_shown_holding_a_mapping_leaves_no_trace(self):
+ """
+ A public ConceptMap whose only mapping into a system is held by a version the base doesn't show (a private
+ release, or an unreleased version) answers as without that version: the same bytes, statements and index
+ requests, at /fhir/ and the owner's base, also while v1's own mapping isn't indexed yet.
+ """
+ callers = [('anonymous', None), ('outsider', self.outsider)]
+ for version, kwargs in (('v2', {'released': True, 'public_access': ACCESS_TYPE_NONE}), ('v1.0', {})):
+ for lagging in (False, True):
+ with self.subTest(version=version, lagging=lagging):
+ target, hidden, held = self.held_by_a_hidden_version(version, lagging, **kwargs)
+ requests = [(base, {parameter: target.canonical_url}, callers) for base in ('/fhir', '/orgs/misc')
+ for parameter in ('target-system', 'target-uri')]
+ with_it = self.answers(requests)
+ MappingDocument().update(Mapping.objects.filter(id=held.id), action='delete')
+ Mapping.objects.filter(id=held.id).delete()
+ Source.objects.filter(id=hidden.id).delete()
+ self.assertEqual(with_it, self.answers(requests))
+
+
+class SystemSearchWithoutRegistryTest(SystemSearchCases, OCLTestCase):
+ registry = False
+
+
+class SystemSearchWithRegistryTest(SystemSearchCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_swagger.py b/core/fhir/tests_swagger.py
new file mode 100644
index 000000000..bd8cd2e52
--- /dev/null
+++ b/core/fhir/tests_swagger.py
@@ -0,0 +1,251 @@
+"""The terminology schema describes the routed reads and keeps the other API operations unchanged."""
+import re
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from django.conf import settings
+from django.http import HttpResponse
+from django.test import RequestFactory, SimpleTestCase, override_settings
+from django.urls import resolve
+from drf_yasg import openapi
+from drf_yasg.generators import OpenAPISchemaGenerator
+from rest_framework.request import Request
+from rest_framework.test import APIClient, APITestCase
+from whitenoise.middleware import WhiteNoiseMiddleware
+
+from core.common.swagger import OCLSwaggerEndpointEnumerator, OCLSwaggerSchemaGenerator
+from core.fhir.base import FhirViewMixin, is_fhir_path
+from core.fhir.search import PARAMETERS, STRING_FIELDS, STRING_LOOKUPS
+from core.fhir.swagger import FhirSwaggerSchemaGenerator
+from core.middlewares.middlewares import FhirMiddleware
+from core.urls import api_info
+
+BASES = ('/fhir', '/orgs/{org}', '/users/{user}')
+OPERATIONS = {
+ 'CodeSystem': ('/$lookup', '/{id}/$lookup', '/$validate-code', '/{id}/$validate-code'),
+ 'ValueSet': ('/$validate-code', '/{id}/$validate-code', '/$expand', '/{id}/$expand'),
+ 'ConceptMap': ('/$translate',),
+}
+MIDDLEWARE = ['core.middlewares.middlewares.FhirMiddleware']
+SERVICE_MIDDLEWARE = tuple(settings.MIDDLEWARE)
+
+
+def expected_operations():
+ """An independent pin of the documented methods and paths at all three bases."""
+ expected = {}
+ for base in BASES:
+ for resource, operations in OPERATIONS.items():
+ for suffix in ('', '/{id}'):
+ expected[('get', f'{base}/{resource}{suffix}')] = resource
+ for suffix in operations:
+ for method in ('get', 'post'):
+ expected[(method, f'{base}/{resource}{suffix}')] = resource
+ for suffix in ('/metadata', '/$versions'):
+ expected[('get', base + suffix)] = 'Capabilities'
+ return expected
+
+
+def operations_in(schema):
+ """Index actual schema operations without counting path-level parameters."""
+ return {(method, path): operation for path, item in schema['paths'].items()
+ for method, operation in item.items() if method in openapi.PathItem.OPERATION_NAMES}
+
+
+class FhirSwaggerTest(SimpleTestCase):
+ """Schema generation is independent of resource data and serializer introspection."""
+
+ def setUp(self):
+ self.schema = FhirSwaggerSchemaGenerator(api_info).get_schema(public=True)
+
+ def test_exact_operations_and_routed_methods(self):
+ actual, expected = operations_in(self.schema), expected_operations()
+ self.assertEqual(set(actual), set(expected))
+ self.assertEqual(len(actual), 78)
+ identifiers = []
+ for (method, path), operation in actual.items():
+ self.assertFalse(path.endswith('/'), path)
+ self.assertEqual(operation['tags'], [expected[(method, path)]])
+ self.assertTrue(operation['summary'])
+ self.assertNotIn('\n', operation['summary'])
+ self.assertTrue(operation['description'])
+ self.assertNotIn('security', operation) # the schema's: a token shows private content at owner bases
+ identifiers.append(operation['operationId'])
+ concrete = path.replace('{org}', 'Acme').replace('{user}', 'jo').replace('{id}', '123')
+ view_class = resolve(concrete).func.view_class
+ self.assertTrue(issubclass(view_class, FhirViewMixin), path)
+ self.assertIn(method, view_class.http_method_names, path)
+ self.assertTrue(callable(getattr(view_class, method, None)), path)
+ parameters = operation['parameters']
+ self.assertEqual({p['name'] for p in parameters if p['in'] == 'path'}, set(re.findall(r'{(\w+)}', path)))
+ self.assertEqual(len({(p['in'], p['name']) for p in parameters}), len(parameters))
+ self.assertIn('_format', [p['name'] for p in parameters])
+ self.assertEqual(len(identifiers), len(set(identifiers)))
+
+ def test_search_parameters_match_the_search_parser(self):
+ for base in BASES:
+ for resource in OPERATIONS:
+ parameters = self.schema['paths'][f'{base}/{resource}']['get']['parameters']
+ query = {p['name']: p for p in parameters if p['in'] == 'query'}
+ names = {f'{name}:{modifier}' if modifier else name
+ for name in STRING_FIELDS for modifier in STRING_LOOKUPS}
+ self.assertEqual(set(query), set(PARAMETERS[resource]) | names | {'_count', 'page', '_format'})
+ self.assertEqual(query['_count']['type'], 'integer')
+ self.assertEqual(query['_count']['maximum'], 100)
+ self.assertEqual(query['_format']['enum'], ['json', 'xml'])
+
+ def test_operation_parameters_and_bodies(self):
+ expected = {
+ 'CodeSystem/$lookup': {'system', 'version', 'code', 'coding', 'displayLanguage'},
+ 'CodeSystem/$validate-code': {'url', 'version', 'code', 'coding', 'display', 'displayLanguage'},
+ 'ValueSet/$validate-code': {
+ 'url', 'valueSetVersion', 'system', 'systemVersion', 'code', 'coding', 'display', 'displayLanguage'},
+ 'ValueSet/$expand': {
+ 'url', 'valueSetVersion', 'filter', 'displayLanguage', 'includeDesignations', 'activeOnly',
+ 'offset', 'count', 'includeDefinition', 'excludePostCoordinated', 'excludeNested', 'excludeNotForUI'},
+ 'ConceptMap/$translate': {
+ 'url', 'conceptMapVersion', 'code', 'system', 'version', 'coding', 'source', 'target',
+ 'targetsystem', 'reverse'},
+ }
+ for path, names in expected.items():
+ for base in BASES:
+ for method in ('get', 'post'):
+ operation = self.schema['paths'][f'{base}/{path}'][method]
+ parameters = operation['parameters']
+ query = {p['name']: p for p in parameters if p['in'] == 'query'}
+ self.assertEqual(set(query), names | {'_format'})
+ self.assertTrue(all('type' in parameter for parameter in query.values()))
+ bodies = [p for p in parameters if p['in'] == 'body']
+ self.assertEqual(len(bodies), int(method == 'post'))
+ if bodies:
+ self.assertEqual(operation['consumes'], ['application/fhir+json'])
+ self.assertEqual(bodies[0]['schema']['$ref'], '#/definitions/FhirParameters')
+ expand = self.schema['paths']['/fhir/ValueSet/$expand']['get']['parameters']
+ count = next(p for p in expand if p['name'] == 'count')
+ self.assertEqual((count['type'], count['maximum']), ('integer', 1000))
+
+ def test_coding_says_which_inputs_it_replaces(self):
+ # a coding replaces these inputs (core.fhir.ops: coded_input, translate.read_request), and the coding's
+ # description names them; those it leaves unread say so ($lookup's system still names an instance: mismatch)
+ replaced = {
+ 'CodeSystem/$lookup': ({'code', 'system'}, {'code'}),
+ 'CodeSystem/$validate-code': ({'code', 'display'}, {'code', 'display'}),
+ 'ValueSet/$validate-code': ({'code', 'system', 'systemVersion', 'display'},
+ {'code', 'system', 'systemVersion', 'display'}),
+ 'ConceptMap/$translate': ({'code', 'system', 'version'}, {'code', 'system', 'version'}),
+ }
+ for path, (names, unread) in replaced.items():
+ query = {p['name']: p for p in self.schema['paths'][f'/fhir/{path}']['get']['parameters']}
+ for name in names:
+ self.assertIn(name, query['coding']['description'], (path, name))
+ self.assertEqual('Not read when coding' in query[name]['description'], name in unread, (path, name))
+ lookup = {p['name']: p for p in self.schema['paths']['/fhir/CodeSystem/$lookup']['get']['parameters']}
+ self.assertIn('At an instance it must name that code system', lookup['system']['description'])
+
+ def test_tags_order_responses_and_security_definitions(self):
+ for tag in (*OPERATIONS, 'Capabilities'):
+ paths = [path for (_, path), operation in operations_in(self.schema).items() if operation['tags'] == [tag]]
+ order = [next(index for index, base in enumerate(BASES) if path.startswith(base + '/')) for path in paths]
+ self.assertEqual(order, sorted(order))
+ self.assertEqual(set(self.schema['securityDefinitions']), {'Basic', 'Token'})
+ self.assertEqual({name for requirement in self.schema['security'] for name in requirement}, {'Basic', 'Token'})
+ for (_, path), operation in operations_in(self.schema).items():
+ success = operation['responses']['200']
+ self.assertTrue(success['schema'])
+ for code, response in operation['responses'].items():
+ if code != '200':
+ self.assertEqual(response['schema']['properties']['resourceType']['enum'], ['OperationOutcome'])
+ if path.endswith('/metadata'):
+ self.assertIn('TerminologyCapabilities', success['description'])
+
+
+@override_settings(FHIR_SUBDOMAIN='fhir', MIDDLEWARE=MIDDLEWARE, ALLOWED_HOSTS=['*'])
+class FhirSwaggerHostTest(SimpleTestCase):
+ """The FHIR host serves its own documentation without opening other API paths."""
+
+ def test_schema_urls_use_the_request_host_and_forwarded_scheme(self):
+ for path in ('/swagger/?format=openapi', '/swagger.json', '/swagger.yaml'):
+ response = APIClient().get(path, HTTP_HOST='fhir.example.org:8443', HTTP_X_FORWARDED_PROTO='https')
+ self.assertEqual(response.status_code, 200, path)
+ self.assertEqual(set(operations_in(response.data)), set(expected_operations()))
+ self.assertEqual(response.data['host'], 'fhir.example.org:8443')
+ self.assertEqual(response.data['schemes'], ['https'])
+ self.assertEqual(response.data['basePath'], '/')
+ self.assertIn('FHIR terminology', response.data['info']['title'])
+
+ def test_only_the_documentation_paths_open_on_the_fhir_host(self):
+ response = APIClient().get('/swagger/', HTTP_HOST='fhir.example.org', HTTP_ACCEPT='text/html')
+ self.assertEqual(response.status_code, 200)
+ middleware = FhirMiddleware(lambda request: HttpResponse('allowed'))
+ for path in ('/swagger/', '/swagger.json', '/swagger.yaml', '/version/'):
+ request = RequestFactory().get(path, HTTP_HOST='fhir.example.org')
+ self.assertEqual(middleware(request).content, b'allowed', path)
+ for path in ('/orgs/', '/redoc/', '/swagger', '/swagger/other', '/swagger.json/extra', '/swagger.yaml/'):
+ response = APIClient().get(path, HTTP_HOST='fhir.example.org')
+ self.assertEqual(response.status_code, 404, path)
+ self.assertEqual(response.json()['resourceType'], 'OperationOutcome', path)
+
+ def test_the_ui_files_are_served_ahead_of_the_host_check(self):
+ """Static files are served before FhirMiddleware, so the Swagger UI's files load on the FHIR host."""
+ static = 'whitenoise.middleware.WhiteNoiseMiddleware'
+ if static in SERVICE_MIDDLEWARE:
+ self.assertLess(SERVICE_MIDDLEWARE.index(static),
+ SERVICE_MIDDLEWARE.index('core.middlewares.middlewares.FhirMiddleware'))
+ with TemporaryDirectory() as directory:
+ target = Path(directory) / 'drf-yasg' / 'style.css'
+ target.parent.mkdir(parents=True)
+ target.write_text('body {}')
+ with self.settings(STATIC_ROOT=directory, STATIC_URL='/static/', WHITENOISE_AUTOREFRESH=True,
+ WHITENOISE_USE_FINDERS=False):
+ middleware = WhiteNoiseMiddleware(FhirMiddleware(
+ lambda request: HttpResponse('not static', status=404)))
+ response = middleware(RequestFactory().get('/static/drf-yasg/style.css', HTTP_HOST='fhir.example.org'))
+ self.assertEqual(response.status_code, 200)
+ response.close()
+
+
+class OriginalSchemaGenerator(OpenAPISchemaGenerator):
+ """The API generator before FHIR curation, for a comparison of non-FHIR operations."""
+ endpoint_enumerator_class = OCLSwaggerEndpointEnumerator
+
+
+@override_settings(MIDDLEWARE=MIDDLEWARE, ALLOWED_HOSTS=['*'])
+class ApiSwaggerTest(APITestCase):
+ """Compare the complete API schema using real resolver endpoints and the original inspector."""
+
+ def schema(self):
+ response = self.client.get('/swagger/?format=openapi', HTTP_HOST='api.example.org',
+ HTTP_X_FORWARDED_PROTO='https')
+ self.assertEqual(response.status_code, 200)
+ return response.data
+
+ @override_settings(FHIR_SUBDOMAIN='fhir')
+ def test_separate_host_omits_fhir_and_links_to_its_swagger(self):
+ schema = self.schema()
+ self.assertFalse(any(is_fhir_path(path) for path in schema['paths']))
+ self.assertFalse(any('CodeSystem' in path or 'ValueSet' in path or 'ConceptMap' in path
+ for path in schema['paths']))
+ self.assertIn('https://fhir.example.org/swagger/', schema['info']['description'])
+ self.assertNotIn('fhir.example.org', api_info.description)
+
+ @override_settings(FHIR_SUBDOMAIN=None)
+ def test_same_host_includes_only_curated_fhir(self):
+ schema = self.schema()
+ fhir = {key: operation for key, operation in operations_in(schema).items() if is_fhir_path(key[1])}
+ curated = operations_in(FhirSwaggerSchemaGenerator(api_info).get_schema(public=True))
+ self.assertEqual(fhir, curated)
+ self.assertFalse(any('CodeSystem$' in path or 'ValueSet$' in path or 'ConceptMap$' in path
+ for path in schema['paths']))
+
+ def test_non_fhir_paths_and_operations_are_unchanged(self):
+ request = Request(RequestFactory().get('/swagger/', HTTP_HOST='api.example.org'))
+ original = OriginalSchemaGenerator(api_info)
+ endpoints = {path: endpoint for path, endpoint in original.get_endpoints(request).items()
+ if not issubclass(endpoint[0], FhirViewMixin) and not is_fhir_path(path)}
+ original_schema = original.get_schema(request, public=True)
+ expected = {path: item for path, item in original_schema.paths.items() if path in endpoints}
+ self.assertTrue(expected)
+ for subdomain in ('fhir', None):
+ with self.settings(FHIR_SUBDOMAIN=subdomain):
+ schema = OCLSwaggerSchemaGenerator(api_info).get_schema(request, public=True)
+ actual = {path: item for path, item in schema.paths.items() if not is_fhir_path(path)}
+ self.assertEqual(actual, dict(expected))
diff --git a/core/fhir/tests_title.py b/core/fhir/tests_title.py
new file mode 100644
index 000000000..307c9ecab
--- /dev/null
+++ b/core/fhir/tests_title.py
@@ -0,0 +1,69 @@
+from core.common.tests import OCLTestCase
+from core.fhir.tests_search import SearchCorpusMixin, collection, source
+from core.orgs.tests.factories import OrganizationFactory
+
+TITLED = 'https://example.org/titled'
+BARE = 'https://example.org/bare'
+BLANK = 'https://example.org/blank'
+
+
+class FhirTitleTest(SearchCorpusMixin, OCLTestCase):
+ """The FHIR title is the repository's full name, or its name when it has none (empty or blank)."""
+ def setUp(self):
+ super().setUp()
+ self.titles = OrganizationFactory(mnemonic='titles')
+ for make, kind in ((source, 'cs'), (collection, 'vs')):
+ for mnemonic, full_name, url in (('titled', 'The Full Name', TITLED), ('bare', '', BARE),
+ ('blank', ' \t ', BLANK), ('nbsp', '\u00a0', f'{BLANK}/nbsp'),
+ ('figure-space', '\u2007', f'{BLANK}/figure-space')):
+ make(self.titles, f'{kind}-{mnemonic}', released=True, canonical_url=f'{url}/{kind}',
+ full_name=full_name)
+ make(self.titles, f'{kind}-{mnemonic}', 'v1', released=True, canonical_url=f'{url}/{kind}',
+ full_name=full_name)
+
+ def expected(self, kind):
+ return {
+ f'{kind}-titled': 'The Full Name', f'{kind}-bare': f'{kind}-bare', f'{kind}-blank': f'{kind}-blank',
+ f'{kind}-nbsp': f'{kind}-nbsp', f'{kind}-figure-space': f'{kind}-figure-space'}
+
+ def test_search_entries_and_reads_at_both_bases(self):
+ for resource_type, kind in (('CodeSystem', 'cs'), ('ValueSet', 'vs')):
+ for base in ('/fhir', '/orgs/titles'):
+ bundle = self.search(resource_type, base=base)
+ titles = {entry['resource']['name']: entry['resource'].get('title') for entry in bundle['entry']
+ if entry['resource']['name'].startswith(f'{kind}-')}
+ self.assertEqual(titles, self.expected(kind), (resource_type, base))
+ for mnemonic, title in self.expected(kind).items():
+ read = self.read(f'/orgs/titles/{resource_type}/{mnemonic}/', version='v1')
+ self.assertEqual(read['title'], title, (resource_type, mnemonic))
+ found = self.search(resource_type, base='/fhir', **{'name:exact': mnemonic})
+ global_id = found['entry'][0]['resource']['id']
+ self.assertEqual(self.read(f'/fhir/{resource_type}/{global_id}/')['title'], title)
+
+ def names_found(self, where, key, value):
+ resource_type, base, kind = where
+ bundle = self.search(resource_type, base=base, **{key: value})
+ names = {entry['resource']['name'] for entry in bundle.get('entry', [])}
+ return sorted(name for name in names if name.startswith(f'{kind}-'))
+
+ def test_title_search_matches_the_title_the_read_shows(self):
+ for resource_type, kind in (('CodeSystem', 'cs'), ('ValueSet', 'vs')):
+ for base in ('/fhir', '/orgs/titles'):
+ def check(expected, key, value, where=(resource_type, base, kind)):
+ self.assertEqual(
+ self.names_found(where, key, value), [f'{where[2]}-{n}' for n in expected], (where, key, value))
+
+ check(['bare'], 'title:exact', f'{kind}-bare')
+ check(['blank'], 'title:exact', f'{kind}-blank')
+ check(['titled'], 'title:exact', 'The Full Name')
+ check(['bare'], 'title', f'{kind}-ba')
+ check(['titled'], 'title', 'the full')
+ check(['blank'], 'title:contains', 'BLAN')
+ check(['titled'], 'title:contains', 'full')
+ for mnemonic in ('nbsp', 'figure-space'):
+ check([mnemonic], 'title:exact', f'{kind}-{mnemonic}')
+ check([mnemonic], 'title', f'{kind}-{mnemonic[:-1]}')
+ check([mnemonic], 'title:contains', mnemonic.upper())
+ # a name doesn't match the title when there is a full name
+ check([], 'title:exact', f'{kind}-titled')
+ check([], 'title:contains', 'titled')
diff --git a/core/fhir/tests_translate.py b/core/fhir/tests_translate.py
new file mode 100644
index 000000000..9c17b5dbe
--- /dev/null
+++ b/core/fhir/tests_translate.py
@@ -0,0 +1,1055 @@
+"""
+ConceptMap/$translate (SVCM ITI-101, FHIR R4 $translate) at the global base /fhir/ and at owner bases, GET and POST:
+the ConceptMap the request names (url) or the code system the code comes from, resolved as WP2's resolver does
+(released versions only, the newest the caller is shown unless one is named, public only at /fhir/); that version's
+own live mappings, never another repository's or HEAD's; OCL map types as relationships (SAME-AS = SAME AS = Same As)
+written as R4 equivalence; systems by canonical URL; source, targetsystem and reverse; a source the caller isn't shown
+answers as an unknown one; a fixed number of queries. OpenConceptLab/ocl_online#451.
+"""
+import json
+import re
+from itertools import product
+
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from mock import patch
+from rest_framework.test import APIClient
+
+from core.collections.models import Collection
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE
+from core.common.tests import OCLTestCase, OCLAPITransactionTestCase
+from core.concepts.models import Concept
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.ops.translate import repository_paths
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.fhir.tests_search import source, collection, indexing
+from core.mappings.models import Mapping
+from core.mappings.tests.factories import MappingFactory
+from core.orgs.tests.factories import OrganizationFactory
+from core.url_registry.factories import GlobalURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.models import UserProfile
+from core.users.tests.factories import UserProfileFactory
+
+CIEL = 'https://CIELterminology.org'
+ICD10 = 'http://hl7.org/fhir/sid/icd-10'
+PIH = 'https://pih.example/fhir/CodeSystem/pih'
+PRIVATE = 'http://moh.example/fhir/CodeSystem/private'
+UNKNOWN = 'http://example.org/ocl-fhir-conformance/unknown-system'
+FIXTURE = 'https://openconceptlab.org/fhir-fixtures/CodeSystem/fixture-codes'
+DAK = 'https://joe.aml'
+CIEL_VS = 'https://example.org/ValueSet/ciel-set'
+ICD10_VS = 'https://example.org/ValueSet/icd-set'
+MIXED_VS = 'http://moh.example/fhir/ValueSet/mixed'
+SNOMED_PATH = '/orgs/IHTSDO/ConceptMap/SNOMED-CT/' # a source with no canonical URL is named by its OCL path
+OMOP_PATH = '/orgs/OHDSI/ConceptMap/OMOP-Standard/' # a mapping to a source OCL doesn't hold: the URL it stores
+PRIVATE_PATH = '/orgs/MOH/ConceptMap/private-codes/'
+
+
+class TranslateCorpusMixin:
+ """
+ CIEL with two releases and mappings in each, one in HEAD only, and one retired; its 113275 maps to ICD-10 (WHO),
+ to a SNOMED source with no canonical, to a source OCL doesn't hold, and to MOH's private source. PIH's own
+ ConceptMap also maps CIEL 113275. MOH's private source maps back to CIEL; the private fixture org has one release
+ with a mapping; a user's ConceptMap (DAK) holds mappings from CIEL into itself. `registry` is the URL registry
+ toggle: off by default, as in production; on, with a global entry for each public canonical, as the curated
+ registry has them.
+ """
+ registry = False
+
+ def setUp(self):
+ set_registry_toggle(self.registry)
+ super().setUp()
+ self.enterContext(indexing())
+ self.admin = UserProfile.objects.get(username='ocladmin')
+ self.ciel = OrganizationFactory(mnemonic='CIEL')
+ self.who = OrganizationFactory(mnemonic='WHO')
+ self.ihtsdo = OrganizationFactory(mnemonic='IHTSDO')
+ self.pih = OrganizationFactory(mnemonic='PIH')
+ self.moh = OrganizationFactory(mnemonic='MOH')
+ self.fixtures = OrganizationFactory(mnemonic='FHIR-Fixtures', public_access=ACCESS_TYPE_NONE)
+ self.moh_member = UserProfileFactory(username='moh-member')
+ self.moh.members.add(self.moh_member)
+ self.member = UserProfileFactory(username='fixture-member')
+ self.fixtures.members.add(self.member)
+ self.outsider = UserProfileFactory(username='outsider')
+ self.jamlung = UserProfileFactory(username='jamlung')
+
+ self.ciel_source = source(self.ciel, 'CIEL', canonical_url=CIEL)
+ self.ciel_v1 = source(self.ciel, 'CIEL', 'v1', released=True, canonical_url=CIEL)
+ self.ciel_v2 = source(self.ciel, 'CIEL', 'v2', released=True, canonical_url=CIEL)
+ self.icd = source(self.who, 'ICD-10-WHO', canonical_url=ICD10)
+ self.icd_2019 = source(self.who, 'ICD-10-WHO', 'ICD102019', released=True, canonical_url=ICD10)
+ self.snomed = source(self.ihtsdo, 'SNOMED-CT')
+ self.private = source(self.moh, 'private-codes', canonical_url=PRIVATE, public_access=ACCESS_TYPE_NONE)
+ self.private_v1 = source(
+ self.moh, 'private-codes', 'v1', released=True, canonical_url=PRIVATE, public_access=ACCESS_TYPE_NONE)
+ self.pih_source = source(self.pih, 'PIH', canonical_url=PIH)
+ self.pih_v1 = source(self.pih, 'PIH', 'v1', released=True, canonical_url=PIH)
+ self.fixture = source(self.fixtures, 'fixture-codes', canonical_url=FIXTURE, public_access=ACCESS_TYPE_NONE)
+ self.fixture_v1 = source(self.fixtures, 'fixture-codes', '1.0.0', released=True, canonical_url=FIXTURE,
+ public_access=ACCESS_TYPE_NONE)
+ self.dak = source(self.jamlung, 'Example-DAK-Source', canonical_url=DAK)
+ self.dak_fhir = source(self.jamlung, 'Example-DAK-Source', 'fhir', released=True, canonical_url=DAK)
+
+ if self.registry:
+ for url, owner in ((CIEL, self.ciel), (ICD10, self.who), (PIH, self.pih), (DAK, self.jamlung)):
+ GlobalURLRegistryFactory(url=url, namespace=owner.uri)
+
+ self.mapping('SAME-AS', self.icd, 'A00', [self.ciel_v1]) # only in the older release
+ self.mapping('BROADER-THAN', self.icd, 'O02.1', [self.ciel_v1, self.ciel_v2])
+ self.mapping('SAME AS', self.snomed, '371374003', [self.ciel_v2])
+ self.mapping('NOT-SAME-AS', self.icd, 'O03', [self.ciel_v2])
+ self.mapping('MAPS TO', None, '4154112', [self.ciel_v2], to_source_url='/orgs/OHDSI/sources/OMOP-Standard/')
+ self.mapping('Q-AND-A', self.private, 'P1', [self.ciel_v2])
+ self.mapping('SAME-AS', self.icd, 'O99', [self.ciel_v2], retired=True)
+ self.mapping('SAME-AS', self.icd, 'Z99', []) # HEAD only: never released
+ self.mapping('SAME-AS', self.icd, 'B20', [self.ciel_v2], from_code='138405')
+ self.mapping('SAME-AS', self.pih_source, '7880', [self.pih_v1], parent=self.pih_source,
+ from_source=self.ciel_source) # PIH's map of a CIEL code
+ self.mapping('NARROWER-THAN', self.ciel_source, '113275', [self.private_v1], parent=self.private,
+ from_code='P1')
+ self.mapping('SAME-AS', self.icd, 'F01', [self.fixture_v1], parent=self.fixture, from_code='FX-1')
+ self.mapping('CONCEPT-SET', self.dak, 'ANC.B10.DE73', [self.dak_fhir], parent=self.dak,
+ from_source=self.ciel_source, from_code='45651') # from CIEL, as production's DAK maps are
+
+ def mapping( # pylint: disable=too-many-arguments
+ self, map_type, to_source, to_code, releases, parent=None, from_source=None, from_code='113275', **kwargs):
+ """A mapping held by `parent` (CIEL's HEAD by default) and the `releases` listed, from `from_source`'s code."""
+ parent = parent or self.ciel_source
+ from_source = from_source or parent
+ kwargs.setdefault('to_source_url', to_source.uri if to_source else None)
+ return MappingFactory(
+ parent=parent, map_type=map_type, from_concept=None, to_concept=None, from_source=from_source,
+ from_source_url=from_source.uri, from_concept_code=from_code, to_source=to_source,
+ to_concept_code=to_code, sources=releases, **kwargs)
+
+ def get(self, base='/fhir', user=None, **params):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client.get(f'{base}/ConceptMap/$translate', params)
+
+ def post(self, parameters, base='/fhir', user=None, **query):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ body = {'resourceType': 'Parameters', 'parameter': parameters}
+ path = f'{base}/ConceptMap/$translate' + (f"?{'&'.join(f'{k}={v}' for k, v in query.items())}" if query else '')
+ return client.post(path, body, format='json')
+
+ def answer(self, response):
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data['resourceType'], 'Parameters')
+ return response.data
+
+ @staticmethod
+ def values(parameters, name):
+ return [next(value for key, value in item.items() if key.startswith('value'))
+ for item in parameters['parameter'] if item['name'] == name]
+
+ @staticmethod
+ def matches(parameters):
+ """Each match as system|code|equivalence, sorted."""
+ found = []
+ for item in parameters['parameter']:
+ if item['name'] == 'match':
+ parts = {part['name']: part for part in item['part']}
+ coding = parts['concept']['valueCoding']
+ found.append(f"{coding['system']}|{coding['code']}|{parts['equivalence']['valueCode']}")
+ return sorted(found)
+
+ def result(self, parameters):
+ results = self.values(parameters, 'result')
+ self.assertEqual(len(results), 1, parameters)
+ return results[0]
+
+ def capture(self, **params):
+ """The answer, and the SQL it ran."""
+ with CaptureQueriesContext(connection) as context:
+ parameters = self.answer(self.get(**params))
+ return parameters, [query['sql'] for query in context.captured_queries]
+
+ def assertOutcome( # pylint: disable=invalid-name,too-many-arguments
+ self, response, status, code, tx_code, expression=None):
+ self.assertEqual(response.status_code, status, response.data)
+ self.assertEqual(response.data['resourceType'], 'OperationOutcome')
+ issue = response.data['issue'][0]
+ self.assertEqual(issue['code'], code)
+ self.assertIn({'system': TX_ISSUE_TYPE_SYSTEM, 'code': tx_code}, issue['details']['coding'])
+ if expression:
+ self.assertEqual(issue['expression'], [expression])
+ return issue
+
+
+CIEL_113275_V2 = sorted([
+ f'{ICD10}|O02.1|narrower', f'{SNOMED_PATH}|371374003|equivalent', f'{ICD10}|O03|disjoint',
+ f'{OMOP_PATH}|4154112|relatedto', f'{PRIVATE_PATH}|P1|relatedto'])
+
+
+class TranslateMappingsTest(TranslateCorpusMixin, OCLTestCase):
+ """What a translation finds: the resolved version's own live mappings of the code, and nothing else."""
+
+ def test_reads_the_mappings_of_the_resolved_release_only(self):
+ """
+ Without url, the code system's own mappings in the release the request names, else the newest the caller is
+ shown: not another release's, not HEAD's, not retired ones, and not another repository's (PIH's map of the
+ same CIEL code).
+ """
+ parameters = self.answer(self.get(system=CIEL, code='113275'))
+ self.assertTrue(self.result(parameters))
+ self.assertEqual(self.matches(parameters), CIEL_113275_V2)
+ self.assertEqual(self.values(parameters, 'message'), [])
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='113275', version='v2'))), CIEL_113275_V2)
+ self.assertEqual(
+ self.matches(self.answer(self.get(system=CIEL, code='113275', version='v1'))),
+ sorted([f'{ICD10}|A00|equivalent', f'{ICD10}|O02.1|narrower']))
+ self.assertEqual(self.matches(self.answer(self.get(system=f'{CIEL}|v1', code='113275'))),
+ sorted([f'{ICD10}|A00|equivalent', f'{ICD10}|O02.1|narrower']))
+
+ def test_each_match_names_the_concept_map_it_comes_from(self):
+ parameters = self.answer(self.get(system=CIEL, code='138405'))
+ self.assertEqual(parameters['parameter'], [
+ {'name': 'result', 'valueBoolean': True},
+ {'name': 'match', 'part': [
+ {'name': 'equivalence', 'valueCode': 'equivalent'},
+ {'name': 'concept', 'valueCoding': {'system': ICD10, 'code': 'B20'}},
+ {'name': 'source', 'valueUri': f'{CIEL}|v2'}]}])
+
+ def test_map_types_are_relationships_whatever_their_spelling(self):
+ """
+ Case, spaces, hyphens, underscores and surrounding whitespace don't matter (design D27): SAME-AS is
+ equivalent, NARROWER-THAN wider, BROADER-THAN narrower, NOT-SAME-AS disjoint, anything else relatedto.
+ """
+ spellings = {
+ 'SAME-AS': 'equivalent', 'SAME AS': 'equivalent', 'Same As': 'equivalent', 'same_as': 'equivalent',
+ ' SAME-AS ': 'equivalent', 'NARROWER-THAN ': 'wider', 'Narrower Than': 'wider',
+ 'BROADER-THAN ': 'narrower', 'broader-than': 'narrower', 'NOT-SAME-AS': 'disjoint',
+ 'Not Same As': 'disjoint', 'MAPS TO': 'relatedto', 'CONCEPT-SET': 'relatedto', 'Q-AND-A': 'relatedto',
+ 'REPLACED BY': 'relatedto', 'Associated-finding': 'relatedto', '': 'relatedto'}
+ for index, map_type in enumerate(spellings):
+ self.mapping(map_type, self.icd, f'S{index}', [self.ciel_v2], from_code=f'spelling-{index}')
+ for index, (map_type, equivalence) in enumerate(spellings.items()):
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code=f'spelling-{index}'))),
+ [f'{ICD10}|S{index}|{equivalence}'], repr(map_type))
+
+ def test_equivalences_stored_as_map_types_are_their_relationships(self):
+ """
+ Before WP6, a FHIR ConceptMap write stored every R4 equivalence but equivalent as the map type, verbatim:
+ those rows read as the relationship the equivalence is, as do R5 relationship codes, whatever their spelling.
+ """
+ stored = {
+ 'equivalent': 'equivalent', 'equal': 'equivalent', 'wider': 'wider', 'subsumes': 'wider',
+ 'narrower': 'narrower', 'specializes': 'narrower', 'relatedto': 'relatedto', 'inexact': 'relatedto',
+ 'disjoint': 'disjoint', 'unmatched': 'disjoint', 'related-to': 'relatedto',
+ 'source-is-narrower-than-target': 'wider', 'source-is-broader-than-target': 'narrower',
+ 'not-related-to': 'disjoint', 'Disjoint ': 'disjoint', 'WIDER': 'wider', 'Not Related To': 'disjoint'}
+ for index, map_type in enumerate(stored):
+ self.mapping(map_type, self.icd, f'E{index}', [self.ciel_v2], from_code=f'old-{index}')
+ for index, (map_type, equivalence) in enumerate(stored.items()):
+ parameters = self.answer(self.get(system=CIEL, code=f'old-{index}'))
+ self.assertEqual(self.matches(parameters), [f'{ICD10}|E{index}|{equivalence}'], repr(map_type))
+ self.assertEqual(self.result(parameters), equivalence != 'disjoint', repr(map_type))
+ group = next(group for group in APIClient().get(f'/fhir/ConceptMap/{self.ciel_v2.id}').data['group']
+ if group['target'] == ICD10)
+ read = {element['code']: element['target'][0]['equivalence'] for element in group['element']}
+ self.assertEqual([read[f'old-{index}'] for index in range(len(stored))], list(stored.values()))
+
+ def test_the_code_system_version_names_the_mappings_of_that_version(self):
+ """
+ version (or the coding's version) narrows an explicit ConceptMap's mappings to those of that version of the
+ code's system, and the unversioned ones; in reverse, the version is the target side's.
+ """
+ maps = source(self.pih, 'version-map', canonical_url='https://pih.example/fhir/ConceptMap/versions')
+ maps_v1 = source(self.pih, 'version-map', 'v1', released=True, canonical_url=maps.canonical_url)
+ if self.registry:
+ GlobalURLRegistryFactory(url=maps.canonical_url, namespace=self.pih.uri)
+ for from_code, from_version, to_code, to_version in (
+ ('9000', 'v1', 'V1', None), ('9000', 'v2', 'V2', None), ('9000', None, 'ANY', None),
+ ('R2019', None, 'O55', 'ICD102019'), ('R2010', None, 'O55', '2010'), ('RANY', '', 'O55', '')):
+ self.mapping('SAME-AS', self.icd, to_code, [maps_v1], parent=maps, from_source=self.ciel_source,
+ from_code=from_code, from_source_version=from_version, to_source_version=to_version)
+ forward = {'url': maps.canonical_url, 'system': CIEL, 'code': '9000'}
+ for version, codes in ((None, ['ANY', 'V1', 'V2']), ('v1', ['ANY', 'V1']), ('v2', ['ANY', 'V2']),
+ ('v3', ['ANY'])):
+ expected = [f'{ICD10}|{code}|equivalent' for code in codes]
+ versioned = {'version': version} if version else {}
+ self.assertEqual(self.matches(self.answer(self.get(**forward, **versioned))), expected, version)
+ coding = {'system': CIEL, 'code': '9000', **({'version': version} if version else {})}
+ self.assertEqual(self.matches(self.answer(self.post([
+ {'name': 'url', 'valueUri': maps.canonical_url}, {'name': 'coding', 'valueCoding': coding}]))),
+ expected, version)
+ reverse = {'url': maps.canonical_url, 'system': ICD10, 'code': 'O55', 'reverse': 'true'}
+ for version, codes in ((None, ['R2010', 'R2019', 'RANY']), ('ICD102019', ['R2019', 'RANY']),
+ ('2010', ['R2010', 'RANY'])):
+ expected = [f'{CIEL}|{code}|equivalent' for code in codes]
+ versioned = {'version': version} if version else {}
+ self.assertEqual(self.matches(self.answer(self.get(**reverse, **versioned))), expected, version)
+ coding = {'system': ICD10, 'code': 'O55', **({'version': version} if version else {})}
+ self.assertEqual(self.matches(self.answer(self.post([
+ {'name': 'url', 'valueUri': maps.canonical_url}, {'name': 'coding', 'valueCoding': coding},
+ {'name': 'reverse', 'valueBoolean': True}]))), expected, version)
+
+ def test_concept_map_version_names_the_release_of_the_implicit_map(self):
+ """
+ Without url, conceptMapVersion names the release of the code system's map (in reverse, targetsystem's),
+ whatever version names the code's own system version.
+ """
+ older = sorted([f'{ICD10}|A00|equivalent', f'{ICD10}|O02.1|narrower'])
+ for params in ({'conceptMapVersion': 'v1'}, {'conceptMapVersion': 'v1', 'version': 'v2'}):
+ parameters = self.answer(self.get(system=CIEL, code='113275', **params))
+ self.assertEqual(self.matches(parameters), older, params)
+ self.assertEqual(parameters['parameter'][1]['part'][2], {'name': 'source', 'valueUri': f'{CIEL}|v1'},
+ params)
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='113275', conceptMapVersion='v2'))),
+ CIEL_113275_V2)
+ parameters = self.answer(self.get(system=CIEL, code='113275', conceptMapVersion='v9'))
+ self.assertFalse(self.result(parameters))
+ self.assertEqual(len(self.values(parameters, 'message')), 1)
+ reverse = {'system': ICD10, 'code': 'A00', 'targetsystem': CIEL, 'reverse': 'true'}
+ self.assertEqual(self.matches(self.answer(self.get(**reverse, conceptMapVersion='v1'))),
+ [f'{CIEL}|113275|equivalent'])
+ self.assertEqual(self.matches(self.answer(self.get(**reverse))), [])
+
+ def test_an_ocl_path_names_its_source_with_a_canonical_url_too(self):
+ """
+ A system or targetsystem given as an OCL path (CodeSystem, ConceptMap or sources form) names the repository it
+ names, if the caller is shown it, even one with a canonical URL; the answer names it by that canonical URL.
+ """
+ for path in ('/orgs/CIEL/CodeSystem/CIEL/', '/orgs/CIEL/sources/CIEL/', '/orgs/CIEL/ConceptMap/CIEL'):
+ for params in ({'url': CIEL}, {}):
+ parameters = self.answer(self.get(system=path, code='138405', **params))
+ self.assertEqual(self.matches(parameters), [f'{ICD10}|B20|equivalent'], (path, params))
+ for path in ('/orgs/WHO/CodeSystem/ICD-10-WHO/', '/orgs/WHO/sources/ICD-10-WHO/'):
+ self.assertEqual(
+ self.matches(self.answer(self.get(system=CIEL, code='113275', targetsystem=path))),
+ sorted([f'{ICD10}|O02.1|narrower', f'{ICD10}|O03|disjoint']), path)
+ self.assertEqual(self.matches(self.answer(self.get(
+ system=path, code='O02.1', targetsystem='/orgs/CIEL/CodeSystem/CIEL/', reverse='true'))),
+ [f'{CIEL}|113275|wider'], path)
+ for path in ('/orgs/MOH/CodeSystem/private-codes/', '/orgs/WHO/CodeSystem/no-such-source/'):
+ parameters = self.answer(self.get(system=CIEL, code='113275', targetsystem=path.replace(
+ '/CodeSystem/', '/sources/')))
+ self.assertEqual(self.matches(parameters), [] if 'no-such' in path else [f'{PRIVATE_PATH}|P1|relatedto'],
+ path)
+
+ def test_a_deleted_mapping_is_no_translation(self):
+ """A mapping version deleted (is_active false) though still in the release is read nowhere."""
+ deleted = self.mapping('SAME-AS', self.icd, 'D99', [self.ciel_v2], from_code='8000')
+ self.mapping('SAME-AS', self.icd, 'D98', [self.ciel_v2], from_code='8000')
+ Mapping.objects.filter(id=deleted.id).update(is_active=False)
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='8000'))), [f'{ICD10}|D98|equivalent'])
+ for code in ('D99', 'D98'):
+ parameters = self.answer(self.get(system=ICD10, code=code, targetsystem=CIEL, reverse='true'))
+ self.assertEqual(self.matches(parameters), [] if code == 'D99' else [f'{CIEL}|8000|equivalent'], code)
+ group = next(group for group in APIClient().get(f'/fhir/ConceptMap/{self.ciel_v2.id}').data['group']
+ if group['target'] == ICD10)
+ self.assertEqual([target['code'] for element in group['element'] if element['code'] == '8000'
+ for target in element['target']], ['D98'])
+
+ def test_not_same_as_alone_is_no_translation(self):
+ """result is true only when some match isn't disjoint (R4: not unmatched or disjoint)."""
+ self.mapping('NOT-SAME-AS', self.icd, 'Q00', [self.ciel_v2], from_code='5000')
+ parameters = self.answer(self.get(system=CIEL, code='5000'))
+ self.assertFalse(self.result(parameters))
+ self.assertEqual(self.matches(parameters), [f'{ICD10}|Q00|disjoint'])
+
+ def test_targetsystem_keeps_the_matches_into_that_system(self):
+ parameters = self.answer(self.get(system=CIEL, code='113275', targetsystem=ICD10))
+ self.assertTrue(self.result(parameters))
+ self.assertEqual(self.matches(parameters), sorted([f'{ICD10}|O02.1|narrower', f'{ICD10}|O03|disjoint']))
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='113275', targetsystem=SNOMED_PATH))),
+ [f'{SNOMED_PATH}|371374003|equivalent'])
+ none = self.answer(self.get(system=CIEL, code='113275', targetsystem=UNKNOWN))
+ self.assertFalse(self.result(none))
+ self.assertEqual(len(self.values(none, 'message')), 1)
+
+ def test_no_translation_says_why(self):
+ """result false comes with a message: an unknown code, or a system no ConceptMap translates from."""
+ for params in ({'system': CIEL, 'code': 'no-such-code'},
+ {'system': CIEL, 'code': 'no-such-code', 'version': 'v1'},
+ {'system': UNKNOWN, 'code': '113275'}, {'system': CIEL, 'code': '113275', 'version': 'v9'},
+ {'system': ICD10, 'code': 'O02.1'}):
+ parameters = self.answer(self.get(**params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(len(self.values(parameters, 'message')), 1, params)
+ self.assertEqual(self.matches(parameters), [], params)
+
+ def test_url_names_the_concept_map(self):
+ """
+ With url (and conceptMapVersion) the ConceptMap is the one named, and only its mappings from the code's system
+ count: DAK's ConceptMap holds mappings from CIEL codes into DAK's own.
+ """
+ self.assertEqual(self.matches(self.answer(self.get(url=CIEL, system=CIEL, code='113275'))), CIEL_113275_V2)
+ self.assertEqual(
+ self.matches(self.answer(self.get(url=CIEL, conceptMapVersion='v1', system=CIEL, code='113275'))),
+ sorted([f'{ICD10}|A00|equivalent', f'{ICD10}|O02.1|narrower']))
+ self.assertEqual(self.matches(self.answer(self.get(url=PIH, system=CIEL, code='113275'))),
+ [f'{PIH}|7880|equivalent'])
+ self.assertEqual(self.matches(self.answer(self.get(url=DAK, system=CIEL, code='45651'))),
+ [f'{DAK}|ANC.B10.DE73|relatedto'])
+ for params in ({'url': DAK, 'system': DAK, 'code': '45651'}, {'system': DAK, 'code': '45651'}):
+ parameters = self.answer(self.get(**params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(self.matches(parameters), [], params)
+
+ def test_an_unknown_concept_map_is_not_found(self):
+ for params in ({'url': UNKNOWN}, {'url': CIEL, 'conceptMapVersion': 'v9'}, {'url': PRIVATE}):
+ self.assertOutcome(self.get(system=CIEL, code='113275', **params), 404, 'not-found', 'not-found', 'url')
+
+ def test_reverse_finds_the_codes_that_map_to_the_code(self):
+ """
+ reverse: the concept named is the target; the ConceptMap is url's, else targetsystem's (the system of the
+ codes sought). Each match is a source concept; its equivalence is relative to the code named, so CIEL
+ 113275 BROADER-THAN O02.1 is a match that is wider than O02.1.
+ """
+ for params in ({'targetsystem': CIEL}, {'url': CIEL}, {'url': CIEL, 'targetsystem': CIEL}):
+ parameters = self.answer(self.get(system=ICD10, code='O02.1', reverse='true', **params))
+ self.assertTrue(self.result(parameters), params)
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'], params)
+ self.assertEqual(self.matches(self.answer(self.get(system=PIH, code='7880', reverse='true', url=PIH))),
+ [f'{CIEL}|113275|equivalent'])
+ self.assertEqual(
+ self.matches(self.answer(self.get(system=ICD10, code='A00', reverse='true', targetsystem=CIEL))), [])
+ for params in ({}, {'targetsystem': UNKNOWN}): # no ConceptMap to read
+ parameters = self.answer(self.get(system=ICD10, code='O02.1', reverse='true', **params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(len(self.values(parameters, 'message')), 1, params)
+ self.assertEqual(self.matches(self.answer(self.get(system=ICD10, code='O02.1', reverse='false'))), [])
+
+ def test_a_reverse_equivalence_is_relative_to_the_code_named(self):
+ """
+ R4's equivalence reads from the code translated to the match: forward, a mapping from it NARROWER-THAN its
+ target makes a wider match; in reverse the code named is the mapping's target, so the same mapping is a
+ narrower match, and BROADER-THAN a wider one. Equivalent, relatedto and disjoint read the same both ways.
+ """
+ for index, (map_type, forward, backward) in enumerate((
+ ('NARROWER-THAN', 'wider', 'narrower'), ('BROADER-THAN', 'narrower', 'wider'),
+ ('wider', 'wider', 'narrower'), ('SAME-AS', 'equivalent', 'equivalent'),
+ ('NOT-SAME-AS', 'disjoint', 'disjoint'), ('Q-AND-A', 'relatedto', 'relatedto'))):
+ self.mapping(map_type, self.icd, f'W{index}', [self.ciel_v2], from_code=f'way-{index}')
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code=f'way-{index}'))),
+ [f'{ICD10}|W{index}|{forward}'], map_type)
+ self.assertEqual(self.matches(self.answer(self.get(
+ system=ICD10, code=f'W{index}', targetsystem=CIEL, reverse='true'))),
+ [f'{CIEL}|way-{index}|{backward}'], map_type)
+
+ def test_codes_stored_url_encoded_are_translated_as_codes(self):
+ """OCL stores a code with reserved characters percent-encoded (ICD-11's ND53.Y&XK70); FHIR has the code."""
+ self.mapping('SAME-AS', self.icd, 'ND53.Y%26XK70', [self.ciel_v2], from_code='7000')
+ self.mapping('SAME-AS', self.icd, 'GB41%2FMF8Y', [self.ciel_v2], from_code='7001')
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='7000'))),
+ [f'{ICD10}|ND53.Y&XK70|equivalent'])
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='7001'))),
+ [f'{ICD10}|GB41/MF8Y|equivalent'])
+ for code, source_code in (('ND53.Y&XK70', '7000'), ('GB41/MF8Y', '7001')):
+ self.assertEqual(
+ self.matches(self.answer(self.get(system=ICD10, code=code, reverse='true', targetsystem=CIEL))),
+ [f'{CIEL}|{source_code}|equivalent'])
+
+ def test_matches_are_listed_once_and_at_most_match_limit(self):
+ self.mapping('SAME-AS', self.icd, 'D1', [self.ciel_v2], from_code='6000')
+ self.mapping('Same As', self.icd, 'D1', [self.ciel_v2], from_code='6000') # the same match again
+ for code in ('D2', 'D3', 'D4'):
+ self.mapping('SAME-AS', self.icd, code, [self.ciel_v2], from_code='6000')
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='6000'))),
+ [f'{ICD10}|D{index}|equivalent' for index in range(1, 5)])
+ with patch('core.fhir.ops.translate.MATCH_LIMIT', 2):
+ parameters = self.answer(self.get(system=CIEL, code='6000'))
+ self.assertTrue(self.result(parameters))
+ self.assertEqual(len(self.matches(parameters)), 2)
+ self.assertEqual(len(self.values(parameters, 'message')), 1)
+
+
+class TranslateMappingsWithRegistryTest(TranslateMappingsTest):
+ """The same, with the URL registry on: the global base resolves a canonical through its global entry."""
+ registry = True
+
+
+class TranslatePathTest(OCLTestCase):
+ def test_repository_paths(self):
+ """An OCL path's repository, whatever its owner and id are called."""
+ self.assertIsNone(repository_paths(CIEL))
+ self.assertEqual(repository_paths('/orgs/sources/ConceptMap/sources/', ('sources', 'ConceptMap')),
+ ['/orgs/sources/sources/sources/', '/orgs/sources/ConceptMap/sources/'])
+ self.assertEqual(repository_paths('/users/jdoe/CodeSystem/codes'), ['/users/jdoe/sources/codes/'])
+
+
+class TranslateRequestTest(TranslateCorpusMixin, OCLTestCase):
+ """The request: GET or POST (a Parameters resource, as HAPI sends it), the code in any of its forms."""
+
+ def test_post_answers_as_get(self):
+ """POST $translate with a Parameters body, code and system, coding or codeableConcept (HAPI's form)."""
+ expected = sorted([f'{ICD10}|O02.1|narrower', f'{ICD10}|O03|disjoint'])
+ for parameters in (
+ [{'name': 'system', 'valueUri': CIEL}, {'name': 'code', 'valueCode': '113275'},
+ {'name': 'targetsystem', 'valueUri': ICD10}],
+ [{'name': 'coding', 'valueCoding': {'system': CIEL, 'code': '113275'}},
+ {'name': 'targetsystem', 'valueUri': ICD10}],
+ [{'name': 'codeableConcept', 'valueCodeableConcept': {'coding': [{'system': CIEL, 'code': '113275'}]}},
+ {'name': 'targetsystem', 'valueUri': ICD10}],
+ [{'name': 'url', 'valueUri': CIEL}, {'name': 'system', 'valueUri': CIEL},
+ {'name': 'version', 'valueString': 'v2'}, {'name': 'code', 'valueCode': '113275'},
+ {'name': 'targetsystem', 'valueUri': ICD10}, {'name': 'reverse', 'valueBoolean': False}]):
+ response = self.post(parameters)
+ self.assertEqual(self.matches(self.answer(response)), expected, parameters)
+ reverse = self.post([{'name': 'system', 'valueUri': ICD10}, {'name': 'code', 'valueCode': 'O02.1'},
+ {'name': 'targetsystem', 'valueUri': CIEL}, {'name': 'reverse', 'valueBoolean': True}])
+ self.assertEqual(self.matches(self.answer(reverse)), [f'{CIEL}|113275|wider'])
+
+ def test_get_takes_a_coding_as_a_token(self):
+ self.assertEqual(self.matches(self.answer(self.get(coding=f'{CIEL}|138405'))), [f'{ICD10}|B20|equivalent'])
+
+ def test_trailing_slash_and_owner_bases(self):
+ client = APIClient()
+ response = client.get('/fhir/ConceptMap/$translate/', {'system': CIEL, 'code': '138405'})
+ self.assertEqual(self.matches(self.answer(response)), [f'{ICD10}|B20|equivalent'])
+ self.assertEqual(self.matches(self.answer(self.get(base='/orgs/CIEL', system=CIEL, code='138405'))),
+ [f'{ICD10}|B20|equivalent'])
+ self.assertEqual(self.matches(self.answer(self.get(base='/users/jamlung', url=DAK, system=CIEL, code='45651'))),
+ [f'{DAK}|ANC.B10.DE73|relatedto'])
+ parameters = self.answer(self.get(base='/orgs/WHO', system=CIEL, code='138405')) # not WHO's
+ self.assertFalse(self.result(parameters))
+
+ def test_xml(self):
+ response = APIClient().get(
+ '/fhir/ConceptMap/$translate', {'system': CIEL, 'code': '138405', '_format': 'xml'})
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(response['Content-Type'].startswith('application/fhir+xml'))
+ content = response.content.decode()
+ for fragment in ('', '',
+ f'', ''):
+ self.assertIn(fragment, content)
+
+ def test_bad_requests(self):
+ for params, code, expression in (
+ ({}, 'invalid', 'code'),
+ ({'system': CIEL}, 'invalid', 'code'),
+ ({'url': CIEL, 'system': CIEL}, 'invalid', 'code'),
+ ({'code': '113275'}, 'invalid', 'system'),
+ ({'system': CIEL, 'code': '113275', 'reverse': 'maybe'}, 'invalid', 'reverse'),
+ ({'coding': 'no-bar'}, 'invalid', 'coding'),
+ ({'system': CIEL, 'code': '113275', 'dependency': 'x'}, 'not-supported', 'dependency'),
+ ):
+ issue = self.assertOutcome(self.get(**params), 400, code, 'not-supported' if code == 'not-supported'
+ else 'invalid-data', expression)
+ self.assertTrue(issue['details']['text'], params)
+ for parameters, expression in (
+ ([{'name': 'codeableConcept', 'valueCodeableConcept': {'text': 'no coding'}}], 'codeableConcept'),
+ ([{'name': 'coding', 'valueCoding': {'code': '113275'}}], 'coding'),
+ ([{'name': 'system', 'valueUri': CIEL}, {'name': 'code', 'valueBoolean': True}], 'code'),
+ ([{'name': 'system', 'valueUri': CIEL}, {'name': 'code', 'valueCode': '1'},
+ {'name': 'conceptMap', 'resource': {'resourceType': 'ConceptMap'}}], 'conceptMap')):
+ response = self.post(parameters)
+ self.assertEqual(response.status_code, 400, (parameters, response.data))
+ self.assertEqual(response.data['issue'][0]['expression'], [expression])
+ self.assertOutcome(
+ APIClient().post('/fhir/ConceptMap/$translate', {'resourceType': 'Bundle'}, format='json'),
+ 400, 'invalid', 'invalid-data')
+
+
+class TranslateBoundsTest(TranslateCorpusMixin, OCLTestCase):
+ """
+ No request reads mappings other than those of the code it names: without a code, or a code without its system, it
+ is a 400 naming the parameter, whatever the method and however the values are typed (a POST's valueString too).
+ """
+ TYPES = {'url': 'valueUri', 'system': 'valueUri', 'code': 'valueCode'}
+
+ def test_reads_only_the_named_code(self):
+ for method, url, code, system in product(('GET', 'POST', 'POST valueString'), (None, CIEL), (None, '138405'),
+ (None, CIEL)):
+ msg = (method, url, code, system)
+ inputs = {name: value for name, value in (('url', url), ('code', code), ('system', system)) if value}
+ with self.subTest(msg), CaptureQueriesContext(connection) as context:
+ if method == 'GET':
+ response = self.get(**inputs)
+ else:
+ response = self.post([{'name': name, ('valueString' if 'valueString' in method
+ else self.TYPES[name]): value}
+ for name, value in inputs.items()])
+ reads = [query['sql'] for query in context.captured_queries if ' FROM "mappings"' in query['sql']]
+ self.assertTrue(all('"mappings"."from_concept_code" IN (\'138405\'' in sql
+ or re.search(r'"mappings"\."id" IN \(\d', sql) for sql in reads), msg)
+ if not code or not system:
+ self.assertOutcome(response, 400, 'invalid', 'invalid-data', 'system' if code else 'code')
+ self.assertEqual(reads, [], msg)
+ else:
+ self.assertEqual(self.matches(self.answer(response)), [f'{ICD10}|B20|equivalent'], msg)
+
+
+class TranslateScopeTest(TranslateCorpusMixin, OCLTestCase):
+ """
+ source: the value set (or code system) the code was chosen from; in reverse, target is. The other narrows nothing
+ (FHIRsmith's reading).
+ """
+
+ def setUp(self):
+ super().setUp()
+ for owner, mnemonic, url, code_system, code in ((self.ciel, 'ciel-set', CIEL_VS, self.ciel_source, '113275'),
+ (self.who, 'icd-set', ICD10_VS, self.icd, 'O02.1')):
+ concept = ConceptFactory(parent=code_system, mnemonic=code)
+ collection(owner, mnemonic, canonical_url=url)
+ value_set = collection(owner, mnemonic, 'v1', released=True, canonical_url=url)
+ expansion = ExpansionFactory(collection_version=value_set)
+ expansion.concepts.set([concept])
+ Collection.objects.filter(id=value_set.id).update(expansion_uri=expansion.uri)
+ # MOH's public value set holds a code of its private source and one of SNOMED, which has no canonical URL
+ snomed_code = ConceptFactory(parent=self.snomed, mnemonic='371374003')
+ collection(self.moh, 'mixed-set', canonical_url=MIXED_VS)
+ mixed = collection(self.moh, 'mixed-set', 'v1', released=True, canonical_url=MIXED_VS)
+ expansion = ExpansionFactory(collection_version=mixed)
+ expansion.concepts.set([ConceptFactory(parent=self.private, mnemonic='P1'), snomed_code])
+ Collection.objects.filter(id=mixed.id).update(expansion_uri=expansion.uri)
+ self.mapping('SAME-AS', self.icd, 'S371', [self.ciel_v2], from_source=self.snomed, from_code='371374003')
+
+ def test_a_scope_holds_only_the_codes_of_systems_the_caller_is_shown(self):
+ """
+ A public value set holding a private source's code says nothing about it to those who can't view the source: a
+ private system answers word for word, and with the same statements, as an unknown one, forward and reverse.
+ """
+ for user in (None, self.outsider, self.moh_member, self.admin):
+ for params in ({'source': MIXED_VS}, {'target': MIXED_VS, 'reverse': 'true', 'targetsystem': CIEL}):
+ private, private_sql = self.capture(system=PRIVATE, code='P1', user=user, **params)
+ unknown, unknown_sql = self.capture(system=UNKNOWN, code='P1', user=user, **params)
+ self.assertEqual(json.loads(json.dumps(private).replace(PRIVATE, 'SYSTEM')),
+ json.loads(json.dumps(unknown).replace(UNKNOWN, 'SYSTEM')), (user, params))
+ self.assertEqual([sql.replace(PRIVATE, 'SYSTEM') for sql in private_sql],
+ [sql.replace(UNKNOWN, 'SYSTEM') for sql in unknown_sql], (user, params))
+ self.assertFalse(self.result(private), (user, params))
+ # to a member of MOH at its base, P1 is in the scope
+ parameters = self.answer(self.get(base='/orgs/MOH', user=self.moh_member, system=PRIVATE, code='P1',
+ source=MIXED_VS))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'])
+
+ def test_a_code_system_scope_is_its_repository_whatever_names_it(self):
+ """
+ A code system scope is the code's own system when it names the same repository the caller is shown: by its
+ canonical URL, an OCL path (CodeSystem or sources form) or either's implicit value set (?fhir_vs).
+ """
+ ciel_names = (CIEL, f'{CIEL}?fhir_vs', '/orgs/CIEL/sources/CIEL/', '/orgs/CIEL/CodeSystem/CIEL/',
+ '/orgs/CIEL/CodeSystem/CIEL/?fhir_vs')
+ for system in (CIEL, '/orgs/CIEL/CodeSystem/CIEL/'):
+ for scope in ciel_names:
+ parameters = self.answer(self.get(url=CIEL, system=system, code='113275', source=scope))
+ self.assertEqual(self.matches(parameters), CIEL_113275_V2, (system, scope))
+ icd_names = (ICD10, f'{ICD10}?fhir_vs', '/orgs/WHO/CodeSystem/ICD-10-WHO/', '/orgs/WHO/sources/ICD-10-WHO/')
+ for system in icd_names[2:]:
+ for scope in icd_names:
+ parameters = self.answer(self.get(system=system, code='O02.1', targetsystem=CIEL, reverse='true',
+ target=scope))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'], (system, scope))
+ for params in ({'source': f'{ICD10}?fhir_vs'}, {'source': '/orgs/WHO/CodeSystem/ICD-10-WHO/'}):
+ parameters = self.answer(self.get(url=CIEL, system='/orgs/CIEL/CodeSystem/CIEL/', code='113275', **params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(len(self.values(parameters, 'message')), 1, params)
+ for scope in (f'{UNKNOWN}?fhir_vs', '/orgs/WHO/CodeSystem/no-such-source/'):
+ self.assertOutcome(self.get(system=CIEL, code='113275', source=scope), 404, 'not-found', 'not-found',
+ 'source')
+
+ def test_a_scope_naming_a_system_the_caller_is_not_shown_answers_as_an_unknown_one(self):
+ """MOH's private source by path, its canonical as the scope: to those it isn't shown, as an unknown one."""
+ hidden, unknown = '/orgs/MOH/CodeSystem/private-codes/', '/orgs/MOH/CodeSystem/no-such-codes/'
+ other = 'http://moh.example/fhir/CodeSystem/no-such-codes'
+ for user in (None, self.outsider, self.moh_member, self.admin):
+ for scope_name, extra in (('source', {}), ('target', {'reverse': 'true', 'targetsystem': CIEL})):
+ for suffix in ('', '?fhir_vs'):
+ answers = [
+ json.loads(json.dumps(self.get(user=user, system=system, code='P1', **extra, **{
+ scope_name: scope + suffix}).data).replace(system, 'SYSTEM').replace(scope, 'SCOPE'))
+ for system, scope in ((hidden, PRIVATE), (unknown, other))]
+ self.assertEqual(answers[0], answers[1], (user, scope_name, suffix))
+ self.assertEqual(answers[0]['resourceType'], 'OperationOutcome', (user, scope_name, suffix))
+ self.assertEqual(answers[0]['issue'][0]['expression'], [scope_name])
+ for scope in (PRIVATE, f'{PRIVATE}?fhir_vs', hidden):
+ parameters = self.answer(self.get(base='/orgs/MOH', user=self.moh_member, system=hidden, code='P1',
+ source=scope))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'], scope)
+
+ def test_a_value_set_path_names_a_value_set_not_its_namesake_code_system(self):
+ """
+ A ValueSet (or collections) path is a value set: a collection named as CIEL's source is, under CIEL, is no alias
+ of the source, so its members decide; a path naming no value set the caller is shown is a 404 naming the
+ parameter, forward and reverse, a private one as an unknown one.
+ """
+ for owner, mnemonic, code in ((self.ciel, 'CIEL', '113275'), (self.who, 'ICD-10-WHO', 'O02.1')):
+ concept = Concept.objects.filter( # the scope corpus's
+ parent=self.ciel_source if owner == self.ciel else self.icd, mnemonic=code).first()
+ collection(owner, mnemonic, canonical_url=f'https://example.org/ValueSet/{mnemonic}')
+ value_set = collection(owner, mnemonic, 'v1', released=True,
+ canonical_url=f'https://example.org/ValueSet/{mnemonic}')
+ expansion = ExpansionFactory(collection_version=value_set)
+ expansion.concepts.set([concept])
+ Collection.objects.filter(id=value_set.id).update(expansion_uri=expansion.uri)
+ reverse = {'targetsystem': CIEL, 'reverse': 'true'}
+ for kind in ('ValueSet', 'collections'):
+ parameters = self.answer(self.get(system=CIEL, code='113275', source=f'/orgs/CIEL/{kind}/CIEL/'))
+ self.assertEqual(self.matches(parameters), CIEL_113275_V2, kind)
+ parameters = self.answer(self.get(system=ICD10, code='O02.1', target=f'/orgs/WHO/{kind}/ICD-10-WHO/',
+ **reverse))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'], kind)
+ for params in ({'system': CIEL, 'code': '138405', 'source': f'/orgs/CIEL/{kind}/CIEL/'},
+ {'system': ICD10, 'code': 'O03', 'target': f'/orgs/WHO/{kind}/ICD-10-WHO/', **reverse}):
+ parameters = self.answer(self.get(**params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(self.matches(parameters), [], params)
+ for params, name in (({'system': CIEL, 'code': '113275', 'source': f'/orgs/CIEL/{kind}/no-such-set/'},
+ 'source'),
+ ({'system': ICD10, 'code': 'O02.1', 'target': f'/orgs/WHO/{kind}/no-such-set/',
+ **reverse}, 'target')):
+ self.assertOutcome(self.get(**params), 404, 'not-found', 'not-found', name)
+ collection(self.moh, 'private-set', canonical_url='http://moh.example/fhir/ValueSet/private',
+ public_access=ACCESS_TYPE_NONE)
+ collection(self.moh, 'private-set', 'v1', released=True,
+ canonical_url='http://moh.example/fhir/ValueSet/private', public_access=ACCESS_TYPE_NONE)
+ for user in (None, self.outsider, self.moh_member, self.admin):
+ for name, params in (('source', {'system': CIEL, 'code': '113275'}),
+ ('target', {'system': ICD10, 'code': 'O02.1', **reverse})):
+ answers = [self.get(user=user, **params, **{name: f'/orgs/MOH/ValueSet/{mnemonic}/'}).data
+ for mnemonic in ('private-set', 'no-such-set')]
+ self.assertEqual(json.loads(json.dumps(answers[0]).replace('private-set', 'SET')),
+ json.loads(json.dumps(answers[1]).replace('no-such-set', 'SET')), (user, name))
+ self.assertEqual(answers[0]['issue'][0]['expression'], [name], (user, name))
+
+ def test_an_implicit_value_set_of_no_system_is_not_found(self):
+ self.assertOutcome(self.get(system=CIEL, code='113275', source='?fhir_vs'), 404, 'not-found', 'not-found',
+ 'source')
+ self.assertOutcome(self.get(system=ICD10, code='O02.1', targetsystem=CIEL, reverse='true', target='?fhir_vs'),
+ 404, 'not-found', 'not-found', 'target')
+
+ def test_a_deleted_concept_is_no_member_of_a_scope(self):
+ Concept.objects.filter(parent=self.ciel_source, mnemonic='113275').update(is_active=False) # every version
+ parameters = self.answer(self.get(system=CIEL, code='113275', source=CIEL_VS))
+ self.assertFalse(self.result(parameters))
+ self.assertEqual(self.matches(parameters), [])
+
+ def test_a_scope_holds_codes_of_a_system_named_by_its_ocl_path(self):
+ """SNOMED has no canonical URL: in a scope, as in a match, its system is its OCL path."""
+ parameters = self.answer(self.get(url=CIEL, system=SNOMED_PATH, code='371374003', source=MIXED_VS))
+ self.assertEqual(self.matches(parameters), [f'{ICD10}|S371|equivalent'])
+ parameters = self.answer(self.get(system=SNOMED_PATH, code='371374003', targetsystem=CIEL, reverse='true',
+ target=MIXED_VS))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|equivalent'])
+ for params in ({'url': CIEL, 'source': CIEL_VS}, {'targetsystem': CIEL, 'reverse': 'true', 'target': CIEL_VS}):
+ parameters = self.answer(self.get(system=SNOMED_PATH, code='371374003', **params))
+ self.assertFalse(self.result(parameters), params)
+
+ def test_a_code_in_the_source_scope_is_translated(self):
+ for scope in (CIEL, f'{CIEL}?fhir_vs', CIEL_VS):
+ parameters = self.answer(self.get(system=CIEL, code='113275', source=scope))
+ self.assertEqual(self.matches(parameters), CIEL_113275_V2, scope)
+
+ def test_a_code_outside_the_source_scope_has_no_translation(self):
+ for params in ({'code': '138405', 'source': CIEL_VS}, {'code': '113275', 'source': ICD10}):
+ parameters = self.answer(self.get(system=CIEL, **params))
+ self.assertFalse(self.result(parameters), params)
+ self.assertEqual(self.matches(parameters), [], params)
+ self.assertEqual(len(self.values(parameters, 'message')), 1, params)
+
+ def test_an_unknown_source_scope_is_not_found(self):
+ for scope in (UNKNOWN, PRIVATE):
+ self.assertOutcome(self.get(system=CIEL, code='113275', source=scope), 404, 'not-found', 'not-found',
+ 'source')
+
+ def test_target_narrows_nothing(self):
+ for target in (ICD10, CIEL_VS, UNKNOWN):
+ self.assertEqual(self.matches(self.answer(self.get(system=CIEL, code='113275', target=target))),
+ CIEL_113275_V2, target)
+
+ def test_reverse_swaps_the_scopes(self):
+ """
+ reverse reverses the meaning of source and target (R4): the code named, now a target code, must be in the
+ target scope, and source, now the scope of the codes sought, narrows nothing, as target does forward.
+ """
+ reverse = {'system': ICD10, 'code': 'O02.1', 'targetsystem': CIEL, 'reverse': 'true'}
+ for scopes in ({'target': ICD10}, {'target': ICD10_VS}, {'target': f'{ICD10}?fhir_vs'}, {'source': CIEL_VS},
+ {'source': CIEL}, {'source': UNKNOWN}, {'source': ICD10_VS},
+ {'source': CIEL_VS, 'target': ICD10_VS}):
+ parameters = self.answer(self.get(**reverse, **scopes))
+ self.assertEqual(self.matches(parameters), [f'{CIEL}|113275|wider'], scopes)
+ for scopes in ({'target': CIEL_VS}, {'target': CIEL}, {'source': ICD10_VS, 'target': CIEL_VS}):
+ parameters = self.answer(self.get(**reverse, **scopes))
+ self.assertFalse(self.result(parameters), scopes)
+ self.assertEqual(self.matches(parameters), [], scopes)
+ self.assertEqual(len(self.values(parameters, 'message')), 1, scopes)
+ for scope in (UNKNOWN, PRIVATE):
+ self.assertOutcome(self.get(**reverse, target=scope), 404, 'not-found', 'not-found', 'target')
+
+ def test_svcm_shape(self):
+ """SVCM's ITI-101 request: url, source, target, system and code."""
+ parameters = self.answer(self.get(url=CIEL, source=CIEL, target=ICD10, system=CIEL, code='113275'))
+ self.assertEqual(self.matches(parameters), CIEL_113275_V2)
+
+
+class TranslateAccessTest(TranslateCorpusMixin, OCLTestCase):
+ """
+ Resolution and access are core.fhir.resolve's. A system or ConceptMap the caller isn't shown is unknown to them,
+ and a mapping into a source they aren't shown names it as a mapping into a source OCL doesn't hold would: by the
+ URL it stores, never by the source's canonical URL.
+ """
+
+ def test_a_system_the_caller_is_not_shown_answers_as_an_unknown_one(self):
+ """The same answer, word for word but the system's name, and no mapping read."""
+ for user in (None, self.outsider, self.moh_member, self.admin):
+ private, private_sql = self.capture(system=PRIVATE, code='P1', user=user)
+ unknown, unknown_sql = self.capture(system=UNKNOWN, code='P1', user=user)
+ self.assertEqual(json.loads(json.dumps(private).replace(PRIVATE, 'SYSTEM')),
+ json.loads(json.dumps(unknown).replace(UNKNOWN, 'SYSTEM')), user)
+ self.assertFalse(self.result(private), user)
+ for sql in (private_sql, unknown_sql):
+ self.assertFalse([query for query in sql if '"mappings"' in query], user)
+
+ def test_every_input_naming_a_repository_the_caller_is_not_shown_runs_as_an_unknown_one(self):
+ """
+ At /fhir/, each input that names a repository (url, system, targetsystem in reverse, a code system or value
+ set scope, ?fhir_vs, an OCL path) answers the same bytes, after the same number of queries, whether it names
+ MOH's private repository or one OCL doesn't hold. (At an owner's base, a signed-in caller who isn't a member
+ runs two queries more for a private repository: its owner and their membership are read.)
+ """
+ private_set = 'http://moh.example/fhir/ValueSet/private-set'
+ collection(self.moh, 'private-set', canonical_url=private_set, public_access=ACCESS_TYPE_NONE)
+ collection(self.moh, 'private-set', 'v1', released=True, canonical_url=private_set,
+ public_access=ACCESS_TYPE_NONE)
+ reverse = {'system': CIEL, 'code': '113275', 'reverse': 'true'}
+ for hidden, unknown in ((PRIVATE, UNKNOWN), (private_set, 'http://moh.example/fhir/ValueSet/no-such-set'),
+ ('/orgs/MOH/CodeSystem/private-codes/', '/orgs/MOH/CodeSystem/no-such-codes/'),
+ (PRIVATE_PATH, '/orgs/MOH/ConceptMap/no-such-codes/'),
+ ('/orgs/MOH/ValueSet/private-set/', '/orgs/MOH/ValueSet/no-such-set/')):
+ for params in (lambda x: {'url': x, 'system': CIEL, 'code': '113275'},
+ lambda x: {'system': x, 'code': 'P1'},
+ lambda x: {**reverse, 'targetsystem': x},
+ lambda x: {'system': CIEL, 'code': '113275', 'source': x},
+ lambda x: {'system': CIEL, 'code': '113275', 'source': f'{x}?fhir_vs'},
+ lambda x: {**reverse, 'targetsystem': CIEL, 'target': x}):
+ for user in (None, self.outsider, self.moh_member, self.admin):
+ answers = []
+ for value in (hidden, unknown):
+ with CaptureQueriesContext(connection) as context:
+ content = self.get(user=user, **params(value)).content.decode()
+ answers.append((content.replace(value, 'VALUE'), len(context.captured_queries)))
+ self.assertEqual(answers[0], answers[1], (hidden, params('VALUE'), user))
+
+ def test_a_newer_private_release_changes_no_byte_of_the_answer(self):
+ """
+ A public repository's newer private release changes nothing for those it isn't shown: the system's own map,
+ the map url names and a value set scope answer the same bytes before and after (A.5).
+ """
+ concept = ConceptFactory(parent=self.ciel_source, mnemonic='113275')
+ collection(self.ciel, 'ciel-set', canonical_url=CIEL_VS)
+ value_set = collection(self.ciel, 'ciel-set', 'v1', released=True, canonical_url=CIEL_VS)
+ expansion = ExpansionFactory(collection_version=value_set)
+ expansion.concepts.set([concept])
+ Collection.objects.filter(id=value_set.id).update(expansion_uri=expansion.uri)
+ requests = ({'system': CIEL, 'code': '138405'}, {'url': CIEL, 'system': CIEL, 'code': '138405'},
+ {'system': CIEL, 'code': '113275', 'source': CIEL_VS})
+ users = (None, self.outsider)
+ before = [self.get(user=user, **params).content for params in requests for user in users]
+ for content in before:
+ self.assertTrue(self.result(json.loads(content)), content)
+ source(self.ciel, 'CIEL', 'v3', released=True, canonical_url=CIEL, public_access=ACCESS_TYPE_NONE)
+ collection(self.ciel, 'ciel-set', 'v2', released=True, canonical_url=CIEL_VS, public_access=ACCESS_TYPE_NONE)
+ self.assertEqual([self.get(user=user, **params).content for params in requests for user in users], before)
+
+ def test_at_its_owner_base_to_those_who_may_view_it(self):
+ for user, matches in ((None, []), (self.outsider, []), (self.moh_member, [f'{CIEL}|113275|wider']),
+ (self.admin, [f'{CIEL}|113275|wider'])):
+ parameters = self.answer(self.get(base='/orgs/MOH', user=user, system=PRIVATE, code='P1'))
+ self.assertEqual(self.matches(parameters), matches, user)
+ for user, matches in ((None, []), (self.outsider, []), (self.member, [f'{ICD10}|F01|equivalent'])):
+ parameters = self.answer(self.get(base='/orgs/FHIR-Fixtures', user=user, system=FIXTURE, code='FX-1'))
+ self.assertEqual(self.matches(parameters), matches, user)
+
+ def test_a_target_the_caller_is_not_shown_is_named_by_the_url_the_mapping_stores(self):
+ """
+ Only at an owner base, and only to those who may view it, is MOH's private source named by its canonical; its
+ OCL path then names it too, as an alias. To anyone else its canonical names nothing.
+ """
+ for base, user, system in (
+ ('/fhir', None, PRIVATE_PATH), ('/fhir', self.moh_member, PRIVATE_PATH),
+ ('/fhir', self.admin, PRIVATE_PATH), ('/orgs/CIEL', None, PRIVATE_PATH),
+ ('/orgs/CIEL', self.outsider, PRIVATE_PATH), ('/orgs/CIEL', self.moh_member, PRIVATE),
+ ('/orgs/CIEL', self.admin, PRIVATE)):
+ for targetsystem in sorted({system, PRIVATE_PATH}):
+ parameters = self.answer(self.get(base=base, user=user, system=CIEL, code='113275',
+ targetsystem=targetsystem))
+ self.assertEqual(self.matches(parameters), [f'{system}|P1|relatedto'], (base, user, targetsystem))
+ if system == PRIVATE_PATH:
+ self.assertEqual(self.matches(self.answer(self.get(
+ base=base, user=user, system=CIEL, code='113275', targetsystem=PRIVATE))), [], (base, user))
+
+ def test_the_default_version_is_the_newest_release_the_caller_is_shown(self):
+ """
+ A newer private release doesn't hide the public one before it: without a version (and without url, or with
+ it), the newest release the caller may see is the ConceptMap.
+ """
+ source(self.ciel, 'CIEL', 'v3', released=True, canonical_url=CIEL, public_access=ACCESS_TYPE_NONE)
+ for params in ({'system': CIEL, 'code': '138405'}, {'url': CIEL, 'system': CIEL, 'code': '138405'}):
+ parameters = self.answer(self.get(**params))
+ self.assertEqual(self.matches(parameters), [f'{ICD10}|B20|equivalent'], params)
+ self.assertEqual(parameters['parameter'][1]['part'][2]['valueUri'], f'{CIEL}|v2', params)
+
+ def test_a_reverse_from_a_source_the_caller_is_not_shown(self):
+ """MOH's private map of P1 is no ConceptMap to those it isn't shown."""
+ for base, user, matches in (('/fhir', self.moh_member, []), ('/orgs/MOH', None, []),
+ ('/orgs/MOH', self.moh_member, [f'{PRIVATE}|P1|narrower'])):
+ parameters = self.answer(self.get(base=base, user=user, system=CIEL, code='113275', reverse='true',
+ targetsystem=PRIVATE))
+ self.assertEqual(self.matches(parameters), matches, (base, user))
+
+
+class TranslateCostTest(TranslateCorpusMixin, OCLTestCase):
+ """A fixed number of queries per call, whatever the number of mappings."""
+
+ def test_query_counts(self):
+ """
+ Resolution (the registry toggle, the repository, its newest release), the sources the systems name, the ids of
+ the release's mappings of the code, then those mappings, with whether the caller is shown their sources. url
+ resolves the ConceptMap the same way.
+ """
+ for count in (1, 20):
+ for index in range(count):
+ self.mapping('SAME-AS', self.icd, f'N{count}-{index}', [self.ciel_v2], from_code=f'n{count}')
+ for params in ({'system': CIEL, 'code': f'n{count}'},
+ {'system': CIEL, 'code': f'n{count}', 'targetsystem': ICD10},
+ {'url': CIEL, 'system': CIEL, 'code': f'n{count}'},
+ {'system': ICD10, 'code': f'N{count}-0', 'reverse': 'true', 'targetsystem': CIEL}):
+ with self.subTest(count=count, params=params), self.assertNumQueries(6):
+ self.assertTrue(self.result(self.answer(self.get(**params))))
+ with self.assertNumQueries(2): # the toggle, no repository: no ConceptMap, no mappings read
+ self.answer(self.get(system=UNKNOWN, code='1'))
+ with self.assertNumQueries(7): # + the owner
+ self.answer(self.get(base='/orgs/CIEL', system=CIEL, code='n1'))
+ with self.assertNumQueries(5): # no mapping of the code: nothing more to read
+ self.answer(self.get(system=CIEL, code='no-such-code'))
+
+
+class ConceptMapGroupTest(TranslateCorpusMixin, OCLTestCase):
+ """
+ A ConceptMap read's groups use $translate's relationships, codes and systems (ConceptMapGroupField): a side is named
+ by its source's canonical URL when the caller is shown that source, else by the URL the mapping stores.
+ """
+
+ def read(self, path, user=None):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ response = client.get(path)
+ self.assertEqual(response.status_code, 200, response.data)
+ return response.data
+
+ @staticmethod
+ def targets(resource):
+ return sorted((group['source'], group['target'], element['code'], target['code'], target['equivalence'])
+ for group in resource['group'] for element in group['element'] for target in element['target'])
+
+ def test_group_targets(self):
+ self.mapping('SAME-AS', self.icd, 'ND53.Y%26XK70', [self.ciel_v2], from_code='7000')
+ for path, user, private in ((f'/fhir/ConceptMap/{self.ciel_v2.id}', None, PRIVATE_PATH),
+ (f'/fhir/ConceptMap/{self.ciel_v2.id}', self.moh_member, PRIVATE_PATH),
+ ('/orgs/CIEL/ConceptMap/CIEL/', None, PRIVATE_PATH),
+ ('/orgs/CIEL/ConceptMap/CIEL/', self.outsider, PRIVATE_PATH),
+ ('/orgs/CIEL/ConceptMap/CIEL/', self.moh_member, PRIVATE),
+ ('/orgs/CIEL/ConceptMap/CIEL/', self.admin, PRIVATE)):
+ resource = self.read(path, user)
+ self.assertEqual(self.targets(resource), sorted([
+ (CIEL, ICD10, '113275', 'O02.1', 'narrower'), (CIEL, SNOMED_PATH, '113275', '371374003', 'equivalent'),
+ (CIEL, ICD10, '113275', 'O03', 'disjoint'), (CIEL, OMOP_PATH, '113275', '4154112', 'relatedto'),
+ (CIEL, private, '113275', 'P1', 'relatedto'), (CIEL, ICD10, '138405', 'B20', 'equivalent'),
+ (CIEL, ICD10, '7000', 'ND53.Y&XK70', 'equivalent')]), (path, user))
+ self.assertEqual(sorted(element['code'] for group in resource['group'] if group['target'] == ICD10
+ for element in group['element']), ['113275', '138405', '7000'], (path, user))
+ self.assertEqual(len(resource['group']), len({(group['source'], group['target'])
+ for group in resource['group']}), (path, user))
+
+ def test_a_side_the_caller_is_not_shown_reads_as_an_unknown_one(self):
+ """
+ A mapping into MOH's private source reads exactly as one into a source OCL doesn't hold that stores the same
+ URL; one from it, the same.
+ """
+ maps = source(self.pih, 'unknown-map', canonical_url='https://pih.example/fhir/ConceptMap/unknown')
+ maps_v1 = source(self.pih, 'unknown-map', 'v1', released=True, canonical_url=maps.canonical_url)
+ hidden_map = source(self.pih, 'hidden-map', canonical_url='https://pih.example/fhir/ConceptMap/hidden')
+ hidden_v1 = source(self.pih, 'hidden-map', 'v1', released=True, canonical_url=hidden_map.canonical_url)
+ for parent, release, linked in ((maps, maps_v1, None), (hidden_map, hidden_v1, self.private)):
+ self.mapping('Q-AND-A', linked, 'P1', [release], parent=parent, from_source=self.ciel_source,
+ to_source_url=self.private.uri)
+ mapping = self.mapping('Q-AND-A', self.ciel_source, '113275', [release], parent=parent,
+ from_source=self.ciel_source, from_code='P2')
+ Mapping.objects.filter(id=mapping.id).update(from_source=linked, from_source_url=self.private.uri)
+ for user in (None, self.outsider, self.moh_member):
+ unknown, hidden = (self.targets(self.read(f'/fhir/ConceptMap/{release.id}', user))
+ for release in (maps_v1, hidden_v1))
+ self.assertEqual(hidden, unknown, user)
+ self.assertEqual(hidden, sorted([(CIEL, PRIVATE_PATH, '113275', 'P1', 'relatedto'),
+ (PRIVATE_PATH, CIEL, 'P2', '113275', 'relatedto')]), user)
+
+ def test_the_searches_answer_a_side_the_caller_is_not_shown_as_an_unknown_one(self):
+ """
+ source-system and target-system, by canonical URL or by OCL path, match a mapping linked to a source the caller
+ isn't shown exactly as one into a source OCL doesn't hold that stores the same kind of URL: the same total, and
+ the same statements.
+ """
+ secret_url = 'http://moh.example/fhir/CodeSystem/secret'
+ secret = source(self.moh, 'secret-codes', canonical_url=secret_url, public_access=ACCESS_TYPE_NONE)
+ source(self.moh, 'secret-codes', 'v1', released=True, canonical_url=secret_url, public_access=ACCESS_TYPE_NONE)
+ unknown_url = 'http://moh.example/fhir/CodeSystem/no-such-codes'
+ for mnemonic, linked, url in (('by-hidden-canonical', secret, secret_url),
+ ('by-unknown-canonical', None, unknown_url),
+ ('by-hidden-path', secret, secret.uri),
+ ('by-unknown-path', None, '/orgs/MOH/sources/no-such-codes/')):
+ parent = source(self.pih, mnemonic, canonical_url=f'https://pih.example/fhir/ConceptMap/{mnemonic}')
+ release = source(self.pih, mnemonic, 'v1', released=True, canonical_url=parent.canonical_url)
+ self.mapping('SAME-AS', linked, 'P9', [release], parent=parent, from_source=self.ciel_source,
+ from_code='9999', to_source_url=url)
+ mapping = self.mapping('SAME-AS', self.ciel_source, '9999', [release], parent=parent,
+ from_source=self.ciel_source, from_code='P9')
+ Mapping.objects.filter(id=mapping.id).update(from_source=linked, from_source_url=url)
+ for hidden, unknown in ((secret_url, unknown_url),
+ ('/orgs/MOH/ConceptMap/secret-codes/', '/orgs/MOH/ConceptMap/no-such-codes/')):
+ for name in ('target-system', 'source-system'):
+ answers = [self.search_answer(name, value, mnemonic)
+ for value, mnemonic in ((hidden, 'secret-codes'), (unknown, 'no-such-codes'))]
+ self.assertEqual(answers[0], answers[1], (name, hidden))
+ self.assertEqual(answers[0][0], 1, (name, hidden))
+
+ @staticmethod
+ def search_answer(name, value, mnemonic):
+ """A search's total and its SQL, with the value, the repository id and the page's row ids written as such."""
+ with CaptureQueriesContext(connection) as context:
+ bundle = APIClient().get('/fhir/ConceptMap/', {name: value}).data
+ return bundle['total'], [re.sub(r'IN \(\d+\)', 'IN (ID)', query['sql'].replace(value, 'VALUE').replace(
+ mnemonic, 'ID')) for query in context.captured_queries]
+
+ def test_a_deleted_mapping_is_no_part_of_a_concept_map(self):
+ """The read leaves out a deleted mapping version still in the release, so the searches don't count it."""
+ loinc = source(self.who, 'LOINC', canonical_url='http://loinc.org')
+ deleted = self.mapping('SAME-AS', loinc, '1234-5', [self.ciel_v2], from_code='8001')
+ search = {'target-system': 'http://loinc.org'}
+ self.assertEqual(APIClient().get('/fhir/ConceptMap/', search).data['total'], 1)
+ Mapping.objects.filter(id=deleted.id).update(is_active=False)
+ self.assertEqual(APIClient().get('/fhir/ConceptMap/', search).data['total'], 0)
+ groups = APIClient().get(f'/fhir/ConceptMap/{self.ciel_v2.id}').data['group']
+ self.assertNotIn('http://loinc.org', [group['target'] for group in groups])
+
+
+class TranslateTimeoutTest(OCLAPITransactionTestCase):
+ """$translate's statements are bounded as a search's are: one the database cancels is too costly (422)."""
+
+ def test_a_cancelled_statement_is_too_costly(self):
+ set_registry_toggle(False)
+ org = OrganizationFactory(mnemonic='CIEL')
+ source(org, 'CIEL', canonical_url=CIEL)
+ source(org, 'CIEL', 'v1', released=True, canonical_url=CIEL)
+
+ def slow(*_):
+ with connection.cursor() as cursor:
+ cursor.execute('SELECT pg_sleep(1)')
+
+ with patch('core.fhir.search.SEARCH_TIMEOUT_MS', 50), patch(
+ 'core.fhir.ops.translate.Translation.matches', slow):
+ response = APIClient().get('/fhir/ConceptMap/$translate', {'system': CIEL, 'code': '1'})
+ self.assertEqual(response.status_code, 422, response.data)
+ issue = response.data['issue'][0]
+ self.assertEqual(issue['code'], 'too-costly')
+ self.assertIn({'system': TX_ISSUE_TYPE_SYSTEM, 'code': 'too-costly'}, issue['details']['coding'])
+ self.assertIn('translation', issue['details']['text'])
diff --git a/core/fhir/urls.py b/core/fhir/urls.py
index bfceb36c6..eed78379e 100644
--- a/core/fhir/urls.py
+++ b/core/fhir/urls.py
@@ -5,4 +5,7 @@
urlpatterns = [
path('', views.CapabilityStatementView.as_view(), name='capability-statement'),
path('metadata', views.CapabilityStatementView.as_view(), name='capability-statement'),
+ path('metadata/', views.CapabilityStatementView.as_view(), name='capability-statement-slash'),
+ path('$versions', views.VersionsView.as_view(), name='fhir-versions'),
+ path('$versions/', views.VersionsView.as_view(), name='fhir-versions-slash'),
]
diff --git a/core/fhir/views.py b/core/fhir/views.py
index 46b1664f8..6d8974e8d 100644
--- a/core/fhir/views.py
+++ b/core/fhir/views.py
@@ -1,305 +1,32 @@
-import logging
-
+"""
+The FHIR metadata views, at the global base and at owner bases: what core.fhir.capabilities builds from the code,
+in R4 (core.fhir.adapters.r4).
+"""
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
-from core import settings
from core.common.throttling import ThrottleUtil
-
-logger = logging.getLogger('oclapi')
+from core.fhir import capabilities
+from core.fhir.adapters import r4
+from core.fhir.base import FhirOperationView, FhirViewMixin
-class CapabilityStatementView(APIView):
+class CapabilityStatementView(FhirViewMixin, APIView):
+ """metadata: the base's CapabilityStatement, or with mode=terminology its TerminologyCapabilities for the caller."""
permission_classes = (AllowAny,)
def get_throttles(self):
return ThrottleUtil.get_throttles_by_user_plan(self.request.user)
- def get(self, request):
- mode = request.query_params.get('mode')
- fhir_base_url = f"{settings.API_BASE_URL}/fhir"
- if settings.FHIR_SUBDOMAIN:
- # TODO: it's not always right as 'api.' may not be a part of the API base url
- fhir_base_url = fhir_base_url.replace('://api.', f"://{settings.FHIR_SUBDOMAIN}.", 1)
- if mode == 'terminology':
- response = {
- "resourceType": "TerminologyCapabilities",
- "id": "openconceptlab-terminology-capabilities",
- "text": {
- "status": "generated",
- "div": "\n\t\t\t\n"
- "
The OCL Server supports the following resources: "
- "CodeSystem, ValueSet, ConceptMap.
\n\t\t\t\n
"
- },
- "url": "urn:uuid:0a651364-aee5-4b4e-8259-39e8a1cff71a",
- "version": "0.1",
- "name": "OCLTerminologyCapabilities",
- "title": "OpenConceptLab FHIR Terminology Capabilities",
- "status": "draft",
- "experimental": True,
- "date": "2024-05-15",
- "publisher": "OpenConceptLab",
- "contact": [{
- "name": "System Administrator",
- "telecom": [{
- "system": "email",
- "value": "admin@openconceptlab.org"
- }]
- }],
- "description": "This is the FHIR terminology capability statement for the OCL FHIR.",
- "kind": "instance",
- "software": {
- "name": "oclfhir",
- "version": settings.VERSION
- },
- "implementation": {
- "description": "OCL FHIR Terminology Server",
- "url": fhir_base_url
- },
- "codeSearch": "all"
- }
- else:
- response = {
- "resourceType": "CapabilityStatement",
- "id": "openconceptlab-terminology-server",
- "text": {
- "status": "extensions",
- "div": "\n"
- "
OpenConceptLab Capability Statement
\n\n"
- },
- "url": "http://hl7.org/fhir/CapabilityStatement/terminology-server",
- "version": "0.1",
- "name": "OCLTerminologyCapabilityStatement",
- "title": "OpenConceptLab FHIR Terminology Server — Capability Statement",
- "status": "draft",
- "experimental": True,
- "date": "2024-05-15",
- "description": "This is a draft capability statement for a OCL FHIR Terminology Server.",
- "kind": "instance",
- "implementation": {
- "description": "The OCL FHIR Terminology Server"
- },
- "fhirVersion": "4.0.0",
- "format": ["json", "xml"],
- "rest": [
- {
- "mode": "server",
- "resource": [
- {
- "type": "CodeSystem",
- "profile": "http://hl7.org/fhir/StructureDefinition/CodeSystem",
- "interaction": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "read"
- }, {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "search-type"
- }
- ],
- "searchParam": [
- {
- "name": "url",
- "definition": "http://hl7.org/fhir/SearchParameter/CodeSystem-url",
- "type": "uri"
- }, {
- "name": "version",
- "definition": "http://hl7.org/fhir/SearchParameter/CodeSystem-version",
- "type": "token"
- }, {
- "name": "name",
- "definition": "http://hl7.org/fhir/SearchParameter/CodeSystem-name",
- "type": "string"
- }, {
- "name": "title",
- "definition": "http://hl7.org/fhir/SearchParameter/CodeSystem-title",
- "type": "string"},
- {
- "name": "status",
- "definition": "http://hl7.org/fhir/SearchParameter/CodeSystem-status",
- "type": "token"
- }],
- "operation": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "name": "expand",
- "definition": "http://hl7.org/fhir/OperationDefinition/CodeSystem-lookup"
- }, {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "name": "expand",
- "definition": "http://hl7.org/fhir/OperationDefinition/"
- "CodeSystem-validate-code"
- }
- # }, {
- # "extension": [
- # {
- # "url": "http://hl7.org/fhir/StructureDefinition/"
- # "capabilitystatement-expectation",
- # "valueCode": "SHALL"
- # }
- # ],
- # "name": "expand",
- # "definition": "http://hl7.org/fhir/OperationDefinition/CodeSystem-subsumes"
- # }
- ]
- }, {
- "type": "ValueSet",
- "profile": "http://hl7.org/fhir/StructureDefinition/ValueSet",
- "interaction": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "read"
- }, {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "search-type"
- }
- ],
- "searchParam": [
- {
- "name": "url",
- "definition": "http://hl7.org/fhir/SearchParameter/ValueSet-url",
- "type": "uri"
- }, {
- "name": "version",
- "definition": "http://hl7.org/fhir/SearchParameter/ValueSet-version",
- "type": "token"
- }, {"name": "name",
- "definition": "http://hl7.org/fhir/SearchParameter/ValueSet-name",
- "type": "string"
- },
- {
- "name": "title",
- "definition": "http://hl7.org/fhir/SearchParameter/ValueSet-title",
- "type": "string"
- }, {
- "name": "status",
- "definition": "http://hl7.org/fhir/SearchParameter/ValueSet-status",
- "type": "token"
- }
- ],
- "operation": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "name": "expand",
- "definition": "http://hl7.org/fhir/OperationDefinition/ValueSet-expand"
- }, {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "name": "expand",
- "definition": "http://hl7.org/fhir/OperationDefinition/ValueSet-validate-code"
- }
- ]
- }, {
- "type": "ConceptMap",
- "profile": "http://hl7.org/fhir/StructureDefinition/ConceptMap",
- "interaction": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "read"
- }, {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "code": "search-type"
- }
- ],
- "searchParam": [
- {
- "name": "url",
- "definition": "http://hl7.org/fhir/SearchParameter/ConceptMap-url",
- "type": "uri"
- }, {
- "name": "version",
- "definition": "http://hl7.org/fhir/SearchParameter/ConceptMap-version",
- "type": "token"
- }, {
- "name": "name",
- "definition": "http://hl7.org/fhir/SearchParameter/ConceptMap-name",
- "type": "string"
- }, {
- "name": "title",
- "definition": "http://hl7.org/fhir/SearchParameter/ConceptMap-title",
- "type": "string"
- }, {
- "name": "status",
- "definition": "http://hl7.org/fhir/SearchParameter/ConceptMap-status",
- "type": "token"
- }
- ],
- "operation": [
- {
- "extension": [
- {
- "url": "http://hl7.org/fhir/StructureDefinition/"
- "capabilitystatement-expectation",
- "valueCode": "SHALL"
- }
- ],
- "name": "expand",
- "definition": "http://hl7.org/fhir/OperationDefinition/ConceptMap-translate"
- }
- ]
- }
- ]
- }
- ]
- }
- return Response(response)
+ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
+ if request.query_params.get('mode') == 'terminology':
+ return Response(r4.terminology_capabilities(capabilities.terminology_capabilities(request, self.kwargs)))
+ return Response(r4.capability_statement(capabilities.capability_statement(request, self.kwargs)))
+
+
+class VersionsView(FhirOperationView):
+ """$versions, GET only: the FHIR versions served, and the default."""
+ http_method_names = ['get', 'head', 'options']
+ operation = staticmethod(capabilities.versions)
+ adapter = staticmethod(r4.versions_parameters)
diff --git a/core/integration_tests/tests_code_systems.py b/core/integration_tests/tests_code_systems.py
index 763493947..1bc701e0e 100644
--- a/core/integration_tests/tests_code_systems.py
+++ b/core/integration_tests/tests_code_systems.py
@@ -220,6 +220,7 @@ def test_posting_code_systems_specific_json(self):
'type': 'Coding'
}],
'meta': {
+ 'versionId': ANY,
'lastUpdated': ANY
},
'version': '07/14/2020',
diff --git a/core/integration_tests/tests_fhir_owner_access.py b/core/integration_tests/tests_fhir_owner_access.py
index 6cb924d5c..a868f94f9 100644
--- a/core/integration_tests/tests_fhir_owner_access.py
+++ b/core/integration_tests/tests_fhir_owner_access.py
@@ -3,6 +3,7 @@
from rest_framework.exceptions import ValidationError
from core.collections.models import Collection
+from core.collections.tests.factories import OrganizationCollectionFactory
from core.common.tests import OCLAPITestCase
from core.importers.importer import ResourceImporter
from core.importers.models import CREATED, UPDATED, PERMISSION_DENIED
@@ -107,6 +108,71 @@ def test_outsider_cannot_create_under_org(self):
self.assertEqual(response.status_code, 403)
+@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
+@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
+class FHIRUpdateOwnerTest(OCLAPITestCase):
+ """
+ A FHIR PUT or PATCH needs what a POST needs: staff, the owner, or a member of the owning org. A ConceptMap PUT to an
+ id that doesn't exist creates it, so it's refused to anyone else, and the answer for a missing id is the answer for
+ an existing one.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.org = OrganizationFactory(mnemonic='OtherOrg')
+ self.member = UserProfileFactory(username='othermember')
+ self.org.members.add(self.member)
+ self.outsider = UserProfileFactory(username='otheroutsider')
+ for mnemonic in ('existing-cs', 'existing-cm'):
+ OrganizationSourceFactory(organization=self.org, mnemonic=mnemonic, public_access='None')
+ OrganizationSourceFactory(
+ organization=self.org, mnemonic=mnemonic, version='1.0', released=True, public_access='None')
+ OrganizationCollectionFactory(organization=self.org, mnemonic='existing-vs', public_access='None')
+ OrganizationCollectionFactory(
+ organization=self.org, mnemonic='existing-vs', version='1.0', released=True, public_access='None')
+
+ def write(self, method, user, resource_type, mnemonic):
+ resource = fhir_resource(resource_type, mnemonic, f'http://example.com/{mnemonic}')
+ resource['version'] = '2.0'
+ auth = {'HTTP_AUTHORIZATION': 'Token ' + user.get_token()} if user else {}
+ return getattr(self.client, method)(
+ f'/orgs/{self.org.mnemonic}/{resource_type}/{mnemonic}/', resource, format='json', **auth)
+
+ def repositories(self):
+ return sorted(f'{repo.mnemonic}|{repo.version}' for model in (Source, Collection)
+ for repo in model.objects.filter(organization=self.org))
+
+ def test_others_create_and_change_nothing(self):
+ before = self.repositories()
+ for resource_type, existing in (('CodeSystem', 'existing-cs'), ('ValueSet', 'existing-vs'),
+ ('ConceptMap', 'existing-cm')):
+ for method in ('put', 'patch'):
+ for user in (None, self.outsider):
+ answers = [self.write(method, user, resource_type, mnemonic).status_code
+ for mnemonic in (existing, 'missing')]
+ self.assertIn(answers[0], (401, 403), (resource_type, method, user))
+ self.assertEqual(answers[1], answers[0], (resource_type, method, user))
+ self.assertEqual(self.repositories(), before)
+
+ def test_a_concept_map_put_checks_ownership_before_it_creates(self):
+ """The create itself checks ownership, whatever looked the owner up."""
+ with patch('core.common.mixins.SubResourceMixin.get_object_for_path', Mock(return_value=self.org)):
+ for method in ('put', 'patch'):
+ self.assertEqual(self.write(method, self.outsider, 'ConceptMap', 'missing').status_code, 403, method)
+ self.assertFalse(Source.objects.filter(mnemonic='missing').exists())
+
+ def test_a_member_may_create_a_concept_map_by_put(self):
+ response = self.write('put', self.member, 'ConceptMap', 'created')
+
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(
+ [repo for repo in self.repositories() if repo.startswith('created|')], ['created|2.0', 'created|HEAD'])
+ self.assertEqual(self.write('put', self.member, 'CodeSystem', 'missing').status_code, 404)
+ self.assertEqual(self.client.delete(
+ f'/orgs/{self.org.mnemonic}/ConceptMap/missing/',
+ HTTP_AUTHORIZATION='Token ' + self.member.get_token()).status_code, 404)
+
+
class BulkImportOwnerAccessTest(OCLAPITestCase):
"""The requester must own, or be a member of, the owner they import into."""
diff --git a/core/middlewares/middlewares.py b/core/middlewares/middlewares.py
index 8c181cb8e..635afae1c 100644
--- a/core/middlewares/middlewares.py
+++ b/core/middlewares/middlewares.py
@@ -1,11 +1,10 @@
import logging
import time
-import requests
from django.conf import settings
-from django.http import HttpResponseNotFound, HttpResponse
from django.http.response import JsonResponse
from django.utils.deprecation import MiddlewareMixin
+from django.core.exceptions import SuspiciousOperation
from pydash import get
from request_logging.middleware import LoggingMiddleware
from rest_framework.exceptions import AuthenticationFailed
@@ -18,10 +17,14 @@
from core.common.exceptions import DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused
from core.common.throttling import ThrottleUtil
from core.common.utils import set_current_user, set_request_url
+from core.fhir.base import FHIR_JSON, is_fhir_path, is_outcome_response, outcome_response, response_json
+from core.fhir.outcomes import issue, operation_outcome, outcome_for_status
+from core.fhir.swagger import on_fhir_host
from core.services.analytics_event_emitter import AnalyticsEventEmitter
from core.services.auth.core import AuthService
request_logger = logging.getLogger('request_logger')
+fhir_logger = logging.getLogger(__name__)
MAX_BODY_LENGTH = 50000
@@ -132,13 +135,20 @@ def __call__(self, request):
is_allowed = self.is_request_allowed(request)
except (DeactivatedAccountLoginRefused, SSOIdentityMismatchRefused) as ex:
# a refused sign-in says why, rather than reading as anonymous access (ocl_online#339)
+ headers = {'WWW-Authenticate': 'Bearer realm="api"'}
+ if is_fhir_path(request.path):
+ return outcome_response(
+ request, operation_outcome(issue('login', str(ex.detail))), ex.status_code, headers)
response = JsonResponse({'detail': str(ex.detail)}, status=ex.status_code)
- response['WWW-Authenticate'] = 'Bearer realm="api"'
+ response['WWW-Authenticate'] = headers['WWW-Authenticate']
return response
if is_allowed:
return self.get_response(request)
+ if is_fhir_path(request.path):
+ return outcome_response(request, operation_outcome(issue(
+ 'login', self.forbidden_response['detail'])), 403)
return JsonResponse(self.forbidden_response, status=403)
def is_request_allowed(self, request):
@@ -250,57 +260,62 @@ def has_approved_ip(request):
return remote_addr in approved_ips
-class FhirMiddleware(BaseMiddleware):
+class FhirErrorMiddleware(BaseMiddleware):
"""
- It is used to expose FHIR endpoints under FHIR subdomain only and convert content from xml to json.
- If FHIR is not deployed under a dedicated subdomain then FHIR_SUBDOMAIN environment variable should be empty.
+ The outermost middleware (first in MIDDLEWARE). On a FHIR path, an error response that isn't FHIR, from anywhere
+ in the stack (an unrouted path, a crash in a view or in another middleware, such as a sign-in provider that
+ can't be reached), goes out as an OperationOutcome, and a 5xx as a 422. The stack's headers are kept. Other
+ paths are left as they are.
"""
+ replaced_headers = ('content-type', 'content-length', 'content-encoding')
def __call__(self, request):
- absolute_uri = request.build_absolute_uri()
+ if getattr(request, 'fhir_content_type_error', False):
+ # Request construction failed: render safe JSON without parsing GET/POST or negotiating again.
+ return JsonResponse(outcome_for_status(400), status=400, content_type=FHIR_JSON)
+ try:
+ response = self.get_response(request)
+ except Exception as ex: # pylint: disable=broad-exception-caught
+ # An error that escapes every inner converter (e.g. a logging handler re-reading an over-long query
+ # while reporting it): on a FHIR path it still goes out as an OperationOutcome, never a 5xx.
+ if not is_fhir_path(request.path):
+ raise
+ fhir_logger.exception('Unexpected %s answering a FHIR request', type(ex).__name__)
+ status = 400 if isinstance(ex, SuspiciousOperation) else 500
+ return outcome_response(request, outcome_for_status(status, None), 400 if status == 400 else 422)
+ if response.status_code < 400 or not is_fhir_path(request.path) or is_outcome_response(response):
+ return response
+ if response.status_code == 404: # nothing is routed there
+ outcome = operation_outcome(issue('not-found', f'Unknown FHIR path: {request.method} {request.path}'))
+ else: # a server error's own message isn't passed on
+ outcome = outcome_for_status(
+ response.status_code, response_json(response) if response.status_code < 500 else None)
+ headers = {key: value for key, value in response.items() if key.lower() not in self.replaced_headers}
+ return outcome_response(request, outcome, response.status_code, headers)
- if settings.FHIR_SUBDOMAIN:
- uri = absolute_uri.split('/')
- domain = uri[2] if len(uri) > 2 else ''
- is_fhir_domain = domain.startswith(settings.FHIR_SUBDOMAIN + '.')
- resource_type = uri[5] if len(uri) > 5 else None
- global_space = uri[3] if len(uri) > 3 else None
- is_fhir_resource = (global_space == 'fhir' or resource_type == 'CodeSystem' or
- resource_type == 'ValueSet' or resource_type == 'ConceptMap')
-
- if is_fhir_domain:
- if global_space != 'version' and not is_fhir_resource:
- return HttpResponseNotFound()
- elif is_fhir_resource:
- return HttpResponseNotFound()
-
- if settings.FHIR_VALIDATOR_URL and ('/fhir/' in absolute_uri or '/CodeSystem/' in absolute_uri or
- '/ValueSet/' in absolute_uri or '/ConceptMap' in absolute_uri):
- accept_content_type = request.headers.get('Accept', '')
- content_type = request.headers.get('Content-Type', '')
-
- if content_type.startswith('application/xml') or content_type.startswith('application/fhir+xml'):
- request.META['CONTENT_TYPE'] = "application/json"
- if request.method in ['POST', 'PUT']:
- json_request = requests.post(settings.FHIR_VALIDATOR_URL +
- '/convert?version=4.0&type=xml&toType=json', data=request.body)
- request._body = json_request
-
- if accept_content_type.startswith(
- 'application/xml') or accept_content_type.startswith('application/fhir+xml'):
- request.META['HTTP_ACCEPT'] = "application/json"
- response = self.get_response(request)
+class FhirMiddleware(BaseMiddleware):
+ """
+ Keeps FHIR on the FHIR host when FHIR_SUBDOMAIN is set: FHIR resource paths 404 elsewhere, and other paths 404 on
+ the FHIR host, each with an OperationOutcome.
+ """
+ documentation_paths = ('/swagger/', '/swagger.json', '/swagger.yaml') # the FHIR host's own Swagger
- if accept_content_type.startswith(
- 'application/xml') or accept_content_type.startswith('application/fhir+xml'):
- xml_response = requests.post(settings.FHIR_VALIDATOR_URL + '/convert?version=4.0&type=json&toType=xml',
- data=response.content)
- response = HttpResponse(xml_response, content_type=accept_content_type)
- else:
- response = self.get_response(request)
+ def __call__(self, request):
+ fhir_path = is_fhir_path(request.path)
+ if settings.FHIR_SUBDOMAIN:
+ fhir_host = on_fhir_host(request)
+ if fhir_host and not fhir_path and request.path.strip('/').split('/')[0] != 'version' and \
+ request.path not in self.documentation_paths:
+ return self.not_found(request, 'This path is not part of the FHIR API.')
+ if not fhir_host and fhir_path:
+ return self.not_found(request, 'The FHIR API is served on its own host.')
- return response
+ return self.get_response(request)
+
+ @staticmethod
+ def not_found(request, message):
+ return outcome_response(request, operation_outcome(issue('not-found', message)), 404)
class ThrottleHeadersMiddleware(MiddlewareMixin):
diff --git a/core/orgs/urls.py b/core/orgs/urls.py
index 655e4c24d..8804f3d2b 100644
--- a/core/orgs/urls.py
+++ b/core/orgs/urls.py
@@ -1,5 +1,6 @@
from django.urls import path, include
+from core.fhir.views import CapabilityStatementView, VersionsView
from core.users.views import UserListView
from . import views
@@ -21,6 +22,10 @@
'/url-registry/', include('core.url_registry.urls'), name='org-url-registry-urls'),
path('/repos/', include('core.repos.urls'), name='org-repos-urls'),
path('/sources/', include('core.sources.urls')),
+ path('/metadata', CapabilityStatementView.as_view(), name='organization-fhir-metadata'),
+ path('/metadata/', CapabilityStatementView.as_view(), name='organization-fhir-metadata-slash'),
+ path('/$versions', VersionsView.as_view(), name='organization-fhir-versions'),
+ path('/$versions/', VersionsView.as_view(), name='organization-fhir-versions-slash'),
path('/CodeSystem/', include('core.code_systems.urls'), name='code_systems_urls'),
path('/CodeSystem', include('core.code_systems.urls'), name='code_systems_urls_no_slash'),
path('/ValueSet/', include('core.value_sets.urls'), name='value_sets_urls'),
diff --git a/core/parameters/serializers.py b/core/parameters/serializers.py
deleted file mode 100644
index 9275076cb..000000000
--- a/core/parameters/serializers.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from rest_framework import serializers
-from rest_framework.fields import CharField, SerializerMethodField, BooleanField, IntegerField
-
-from core.common.constants import HEAD
-from core.common.serializers import ReadSerializerMixin
-
-# The R4 value types of operation inputs that are read from a valueString too: some clients send a code or a URI
-# as a plain string.
-STRING_TOLERANT_VALUE_TYPES = ('valueCode', 'valueUri')
-
-
-class ParameterCodingSerializer(ReadSerializerMixin, serializers.Serializer):
- system = CharField()
- code = CharField()
-
-
-class PartParameterSerializer(ReadSerializerMixin, serializers.Serializer):
- name = CharField()
- valueString = CharField(required=False)
- valueCoding = ParameterCodingSerializer(required=False)
- valueBoolean = BooleanField(required=False)
- valueUri = CharField(required=False)
- valueCode = CharField(required=False)
-
-
-class ParameterSerializer(ReadSerializerMixin, serializers.Serializer):
- name = CharField()
- part = PartParameterSerializer(many=True, required=False)
- valueString = CharField(required=False)
- valueCoding = ParameterCodingSerializer(required=False)
- valueBoolean = BooleanField(required=False)
- valueUri = CharField(required=False)
- valueCode = CharField(required=False)
- valueInteger = IntegerField(required=False)
-
-
-class ParametersSerializer(ReadSerializerMixin, serializers.Serializer):
- resourceType = SerializerMethodField(method_name='get_resource_type')
- parameter = ParameterSerializer(many=True)
- allowed_input_parameters = {}
-
- @staticmethod
- def get_resource_type(_):
- return 'Parameters'
-
- @classmethod
- def parse_query_params(cls, query_params):
- parameters = []
- for key, value in query_params.items():
- if key in cls.allowed_input_parameters:
- parameters.append(
- {
- 'name': key,
- cls.allowed_input_parameters[key]: value
- }
- )
-
- if parameters:
- return cls(data={'parameter': parameters})
- return cls(data={'parameter': []})
-
- def to_internal_value(self, data):
- parameters = {}
-
- for parameter in data.get('parameter', []):
- name = parameter.get('name', None)
- value_type = self.allowed_input_parameters.get(name, None) if name else None
- if value_type:
- value = parameter.get(value_type, None)
- if value is None and value_type in STRING_TOLERANT_VALUE_TYPES:
- value = parameter.get('valueString', None)
- if value:
- parameters[name] = value
-
- return {'parameters': parameters}
-
- @classmethod
- def from_concept(cls, concept):
- source = concept.sources.filter(is_latest_version=True).exclude(version=HEAD).first()
- if not source:
- source = concept.sources.filter(is_latest_version=True).first()
- parameters = {
- 'parameter': [
- {
- 'name': 'name',
- 'valueString': source.name
- },
- {
- 'name': 'version',
- 'valueString': source.version
- },
- {
- 'name': 'display',
- 'valueString': concept.name if concept.name else concept.display_name
- }
- ]
- }
- return cls(parameters)
diff --git a/core/parameters/tests.py b/core/parameters/tests.py
deleted file mode 100644
index 4c3c8f8f5..000000000
--- a/core/parameters/tests.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from core.common.tests import OCLTestCase
-from core.concepts.tests.factories import LocalizedTextFactory, ConceptFactory
-from core.parameters.serializers import ParametersSerializer
-from core.sources.tests.factories import OrganizationSourceFactory
-
-
-class ParametersTest(OCLTestCase):
- class CustomParametersSerializer(ParametersSerializer):
- allowed_parameters = {
- 'url': 'valueUri',
- 'code': 'valueCode',
- 'system': 'valueUri'
- }
-
- def test_parse_query_params(self):
- query_params = {'url': 'https://openconceptlab.org', 'code': 'testCode', 'otherParam': 'otherValue'}
- serializer = self.CustomParametersSerializer.parse_query_params(query_params)
- serializer.is_valid()
- params = serializer.validated_data['parameter']
- self.assertIsInstance(serializer, self.CustomParametersSerializer)
- self.assertEqual(len(params), 2)
- self.assertEqual(params[0]['name'], 'url')
- self.assertEqual(params[0]['valueUri'], 'https://openconceptlab.org')
- self.assertEqual(params[1]['name'], 'code')
- self.assertEqual(params[1]['valueCode'], 'testCode')
-
- def test_from_concept(self):
- source = OrganizationSourceFactory(default_locale='fr', supported_locales=['fr', 'ti'])
- ch_locale = LocalizedTextFactory(locale_preferred=True, locale='ch')
- en_locale = LocalizedTextFactory(locale_preferred=True, locale='en')
- concept = ConceptFactory(names=[ch_locale, en_locale], parent=source)
- serializer = ParametersSerializer.from_concept(concept)
- params = serializer.data['parameter']
-
- self.assertIsInstance(serializer, ParametersSerializer)
- self.assertEqual(len(params), 3)
- self.assertEqual(params[0]['name'], 'name')
- self.assertEqual(params[0]['valueString'], source.name)
- self.assertEqual(params[1]['name'], 'version')
- self.assertEqual(params[1]['valueString'], source.version)
- self.assertEqual(params[2]['name'], 'display')
- self.assertEqual(params[2]['valueString'], concept.name)
\ No newline at end of file
diff --git a/core/repos/models.py b/core/repos/models.py
index 540415ec3..9e2a99b61 100644
--- a/core/repos/models.py
+++ b/core/repos/models.py
@@ -17,7 +17,11 @@ def count(self):
class Repository:
@classmethod
- def get(cls, criteria):
+ def get(cls, criteria, repo_type=None):
+ """The lowest-id repository matching criteria: a Source before a Collection, or only repo_type's."""
+ if repo_type:
+ return repo_type.objects.filter(criteria).first()
+
repo = Source.objects.filter(criteria).first()
if not repo:
diff --git a/core/settings.py b/core/settings.py
index 63c89575e..f260c445a 100644
--- a/core/settings.py
+++ b/core/settings.py
@@ -194,6 +194,7 @@ def get_set_from_env(name):
}
MIDDLEWARE = [
+ 'core.middlewares.middlewares.FhirErrorMiddleware', # outermost: a FHIR path's error is an OperationOutcome
'django.middleware.gzip.GZipMiddleware',
'cid.middleware.CidMiddleware',
'core.middlewares.middlewares.CustomLoggerMiddleware',
@@ -421,8 +422,6 @@ def get_set_from_env(name):
API_SUPERUSER_PASSWORD = os.environ.get('API_SUPERUSER_PASSWORD', 'Root123') # password for ocladmin superuser
API_SUPERUSER_TOKEN = os.environ.get('API_SUPERUSER_TOKEN', '891b4b17feab99f3ff7e5b5d04ccc5da7aa96da6')
-FHIR_VALIDATOR_URL = os.environ.get('FHIR_VALIDATOR_URL', None)
-
# Redis
REDIS_CONNECTION_OPTIONS = {
'socket_timeout': 5.0,
@@ -598,7 +597,8 @@ def get_set_from_env(name):
if ENV and ENV != 'development':
# Serving swagger static files (inserted after SecurityMiddleware)
- MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
+ MIDDLEWARE.insert(MIDDLEWARE.index('django.middleware.gzip.GZipMiddleware') + 1,
+ 'whitenoise.middleware.WhiteNoiseMiddleware')
EMAIL_SUBJECT_PREFIX = os.environ.get('EMAIL_SUBJECT_PREFIX', None)
if not EMAIL_SUBJECT_PREFIX:
diff --git a/core/sources/models.py b/core/sources/models.py
index 079a473ba..513263669 100644
--- a/core/sources/models.py
+++ b/core/sources/models.py
@@ -699,8 +699,11 @@ def save(
else:
self.__update_sequences(dirty_fields)
- if self.id and 'canonical_url' in dirty_fields and self.active_url_registry_entries.exists():
- resolve_url_registry_entries.apply_async((self.id, self.resource_type), queue='default', permanent=False)
+ if self.id and 'canonical_url' in dirty_fields:
+ if self.active_url_registry_entries.exists():
+ resolve_url_registry_entries.apply_async(
+ (self.id, self.resource_type), queue='default', permanent=False)
+ self.link_url_registry_entries()
def __update_sequences(self, dirty_fields=[]): # pylint: disable=dangerous-default-value
def should_update(is_seq, field):
diff --git a/core/url_registry/models.py b/core/url_registry/models.py
index c8f793f04..eb40f57d5 100644
--- a/core/url_registry/models.py
+++ b/core/url_registry/models.py
@@ -107,25 +107,42 @@ def lookup(cls, url, registry_owner=None):
repo = None
if entry:
- repo = entry.lookup_entry()
+ repo = entry.find_repo()
return repo, entry
- def lookup_entry(self):
+ def find_repo(self, repo_type=None):
+ """
+ The repository the entry points at now: the one with its url in its namespace (none without a namespace).
+ repo_type limits it to a Source or a Collection (find_repo_by_canonical_url). Reads only.
+ """
namespace_owner = self.namespace_owner
- if namespace_owner:
- repo = namespace_owner.find_repo_by_canonical_url(self.url)
- self._set_repo(repo)
- return repo
+ return namespace_owner.find_repo_by_canonical_url(self.url, repo_type) if namespace_owner else None
- return None
+ def lookup_entry(self):
+ """
+ find_repo, kept on the entry as `repo` (cleared when nothing is found): for an entry's create or update and
+ for a repository's creation or canonical URL change, never for a read
+ """
+ repo = self.find_repo()
+ self._set_repo(repo)
+ return repo
@classmethod
def get_entry(cls, url, owner=None):
entries = owner.url_registry_entries.filter(is_active=True) if owner else cls.get_active_global_entries()
return entries.filter(url=url).first()
+ def unlink(self):
+ """Clears the cached repository, as before that repository is deleted"""
+ self._set_repo(None)
+
def _set_repo(self, repo):
- if repo:
+ """
+ Points the entry at repo, or at nothing; saves (and so reindexes) only when that changes, and only the link's
+ own columns, so an instance read before a concurrent edit (a new namespace, say) can't undo it
+ """
+ link = (ContentType.objects.get_for_model(repo).id, repo.id) if repo else (None, None)
+ if (self.repo_type_id, self.repo_id) != link:
self.repo = repo
- self.save()
+ self.save(update_fields=['repo_type', 'repo_id', 'updated_at'])
diff --git a/core/url_registry/tests_resolution.py b/core/url_registry/tests_resolution.py
new file mode 100644
index 000000000..a057aae4e
--- /dev/null
+++ b/core/url_registry/tests_resolution.py
@@ -0,0 +1,607 @@
+"""
+The shared canonical resolver (ConceptContainerModel.resolve_reference_expression) with the URL registry toggle off
+and on: today's answers for its existing callers, the flags FHIR resolves with (repo_type, allow_head), reads that
+never write a registry entry, the registry links kept on the write side instead, and $resolveReference's ?namespace=.
+OpenConceptLab/ocl_online#451.
+"""
+from django.test import override_settings
+from mock import patch
+from pydash import get
+
+from core.collections.models import Collection
+from core.collections.tests.factories import OrganizationCollectionFactory
+from core.common.constants import HEAD
+from core.common.models import ConceptContainerModel
+from core.common.tasks import resolve_url_registry_entries
+from core.common.tests import OCLTestCase, OCLAPITestCase
+from core.orgs.tests.factories import OrganizationFactory
+from core.sources.models import Source
+from core.sources.tests.factories import OrganizationSourceFactory
+from core.toggles.models import Toggle
+from core.url_registry.documents import URLRegistryDocument
+from core.url_registry.factories import GlobalURLRegistryFactory, OrganizationURLRegistryFactory
+from core.url_registry.models import URLRegistry
+from core.users.models import UserProfile
+
+REGISTRY_TOGGLE = 'URL_REGISTRY_IN_RESOLVE_REFERENCE_TOGGLE'
+ICD10 = 'http://hl7.org/fhir/sid/icd-10'
+PBD = 'http://pbd.com.np'
+DRAFT = 'https://example.org/draft'
+FLAGGED = 'https://example.org/flagged-head'
+
+
+def set_registry_toggle(on):
+ """The toggle in every environment's column, so a test doesn't depend on which one settings.ENV reads."""
+ Toggle.objects.filter(name=REGISTRY_TOGGLE).update(
+ dev=on, qa=on, demo=on, staging=on, who_staging=on, production=on)
+ assert Toggle.get(REGISTRY_TOGGLE) is on
+
+
+def source(org, mnemonic, version=HEAD, **kwargs):
+ return OrganizationSourceFactory(organization=org, mnemonic=mnemonic, version=version, **kwargs)
+
+
+def collection(org, mnemonic, version=HEAD, **kwargs):
+ return OrganizationCollectionFactory(organization=org, mnemonic=mnemonic, version=version, **kwargs)
+
+
+class ResolverCorpusMixin:
+ """
+ ICD-10 shared by WHO (first, so the lower id) and sehatapp, with WHO's released version row lacking the
+ canonical as in production; a Source and a Collection sharing a canonical in one org (padam); a repository with
+ no released version.
+ """
+ def setUp(self):
+ super().setUp()
+ self.who = OrganizationFactory(mnemonic='WHO')
+ self.sehatapp = OrganizationFactory(mnemonic='sehatapp')
+ self.padam = OrganizationFactory(mnemonic='padam')
+ self.drafts = OrganizationFactory(mnemonic='drafts')
+
+ self.who_icd = source(self.who, 'ICD-10-WHO', canonical_url=ICD10)
+ self.who_icd_2019 = source(self.who, 'ICD-10-WHO', 'ICD102019', released=True, canonical_url=None)
+ self.sehat_icd = source(self.sehatapp, 'ICD-10', canonical_url=ICD10)
+ self.sehat_icd_v1 = source(self.sehatapp, 'ICD-10', 'v1.0', released=True, canonical_url=ICD10)
+
+ self.pbd_source = source(self.padam, 'pbd', canonical_url=PBD)
+ self.pbd_source_v1 = source(self.padam, 'pbd', 'v1', released=True, canonical_url=PBD)
+ self.pbd_collection = collection(self.padam, 'pbd-vs', canonical_url=PBD)
+ self.pbd_collection_v1 = collection(self.padam, 'pbd-vs', 'v1', released=True, canonical_url=PBD)
+
+ self.draft = source(self.drafts, 'draft', canonical_url=DRAFT)
+
+
+class StrictDefaultCases:
+ """With the toggle off and on (the toggle classes mix this in)."""
+ def test_a_head_flagged_released_is_never_the_strict_default(self):
+ """allow_head=False skips HEAD even when HEAD is flagged released; the default keeps today's answer"""
+ head = source(self.drafts, 'flagged', released=True, canonical_url=FLAGGED)
+ GlobalURLRegistryFactory(url=FLAGGED, namespace=self.drafts.uri) # read only with the toggle on
+
+ version, _ = Source.resolve_reference_expression(FLAGGED)
+ self.assertEqual(version.id, head.id)
+ for repo_type in (None, Source):
+ version, _ = Source.resolve_reference_expression(FLAGGED, repo_type=repo_type, allow_head=False)
+ self.assertIsNone(version.id)
+ self.assertTrue(version.is_fqdn)
+ version, _ = Source.resolve_reference_expression(
+ FLAGGED, version=HEAD, repo_type=repo_type, allow_head=False)
+ self.assertEqual(version.id, head.id)
+
+ released = source(self.drafts, 'flagged', 'v1', released=True, canonical_url=FLAGGED)
+ version, _ = Source.resolve_reference_expression(FLAGGED, repo_type=Source, allow_head=False)
+ self.assertEqual(version.id, released.id)
+
+
+class ResolverToggleOffTest(StrictDefaultCases, ResolverCorpusMixin, OCLTestCase):
+ """No registry: the lowest-id active repository with the canonical, Source before Collection."""
+ def setUp(self):
+ set_registry_toggle(False)
+ super().setUp()
+
+ def test_defaults_keep_todays_answers(self):
+ version, entry = Source.resolve_reference_expression(ICD10)
+ self.assertEqual(version.id, self.who_icd_2019.id)
+ self.assertEqual(version.canonical_url, ICD10) # filled in from the request, as before
+ self.assertTrue(version.is_fqdn)
+ self.assertIsNone(entry)
+
+ # a namespace limits the match to that owner's repositories
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/')
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/padam/')
+ self.assertIsNone(version.id)
+
+ # type-blind: a Source wins over a Collection with the same canonical, whatever class asks
+ version, _ = Collection.resolve_reference_expression(PBD)
+ self.assertEqual(version.id, self.pbd_source_v1.id)
+ self.assertIsInstance(version, Source)
+
+ # nothing released: HEAD
+ version, _ = Source.resolve_reference_expression(DRAFT)
+ self.assertEqual(version.id, self.draft.id)
+
+ # a version row's own canonical matches, and is returned as it is
+ self.sehat_icd.canonical_url = 'http://example.org/new-icd'
+ self.sehat_icd.save()
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/')
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+
+ # unknown: an unsaved Source
+ version, _ = Collection.resolve_reference_expression('http://example.org/unknown')
+ self.assertIsNone(version.id)
+ self.assertIsInstance(version, Source)
+
+ def test_repo_type_resolves_only_to_that_type(self):
+ version, _ = Source.resolve_reference_expression(PBD, repo_type=Collection)
+ self.assertEqual(version.id, self.pbd_collection_v1.id)
+ self.assertIsInstance(version, Collection)
+
+ version, _ = Source.resolve_reference_expression(PBD, repo_type=Source)
+ self.assertEqual(version.id, self.pbd_source_v1.id)
+
+ version, _ = Source.resolve_reference_expression(ICD10, repo_type=Collection)
+ self.assertIsNone(version.id)
+ self.assertIsInstance(version, Collection)
+
+ version, _ = Source.resolve_reference_expression(self.pbd_collection.uri, repo_type=Source)
+ self.assertIsNone(version.id)
+ version, _ = Source.resolve_reference_expression(self.pbd_collection.uri, repo_type=Collection)
+ self.assertEqual(version.id, self.pbd_collection_v1.id)
+
+ def test_repo_type_matches_the_repository_canonical_on_its_head(self):
+ self.sehat_icd.canonical_url = 'http://example.org/new-icd'
+ self.sehat_icd.save()
+
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/', repo_type=Source)
+ self.assertIsNone(version.id)
+
+ version, _ = Source.resolve_reference_expression(
+ 'http://example.org/new-icd', '/orgs/sehatapp/', repo_type=Source)
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+
+ def test_repo_type_serves_only_active_versions(self):
+ self.sehat_icd_v1.is_active = False
+ self.sehat_icd_v1.save()
+
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/', 'v1.0')
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+
+ version, _ = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/', 'v1.0', repo_type=Source)
+ self.assertIsNone(version.id)
+
+
+class ResolverToggleOnTest(StrictDefaultCases, ResolverCorpusMixin, OCLTestCase):
+ """The registry's order: the owner's entry, the owner's repositories, the global entry; nothing else."""
+ def setUp(self):
+ set_registry_toggle(True)
+ super().setUp()
+ self.icd_entry = GlobalURLRegistryFactory(url=ICD10, namespace=self.who.uri)
+ self.pbd_entry = GlobalURLRegistryFactory(url=PBD, namespace=self.padam.uri)
+ GlobalURLRegistryFactory(url=DRAFT, namespace=self.drafts.uri)
+
+ def test_defaults_keep_todays_answers(self):
+ version, entry = Source.resolve_reference_expression(ICD10)
+ self.assertEqual(version.id, self.who_icd_2019.id)
+ self.assertEqual(entry, self.icd_entry)
+
+ version, entry = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/')
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+ self.assertIsNone(entry)
+
+ version, entry = Collection.resolve_reference_expression(PBD)
+ self.assertEqual(version.id, self.pbd_source_v1.id)
+ self.assertEqual(entry, self.pbd_entry)
+
+ version, _ = Source.resolve_reference_expression(DRAFT)
+ self.assertEqual(version.id, self.draft.id)
+
+ version, entry = Source.resolve_reference_expression('http://example.org/unregistered')
+ self.assertIsNone(version.id)
+ self.assertIsNone(entry)
+
+ def test_repo_type_finds_that_type_in_the_entry_namespace(self):
+ version, entry = Source.resolve_reference_expression(PBD, repo_type=Collection)
+ self.assertEqual(version.id, self.pbd_collection_v1.id)
+ self.assertEqual(entry, self.pbd_entry)
+
+ version, entry = Source.resolve_reference_expression(PBD, repo_type=Source)
+ self.assertEqual(version.id, self.pbd_source_v1.id)
+ self.assertEqual(entry, self.pbd_entry)
+
+ def test_an_entry_without_a_repository_of_the_type_is_unresolved_without_falling_through(self):
+ collection(self.sehatapp, 'icd-vs', canonical_url=ICD10)
+ collection(self.sehatapp, 'icd-vs', 'v1', released=True, canonical_url=ICD10)
+
+ version, entry = Source.resolve_reference_expression(ICD10, repo_type=Collection)
+ self.assertIsNone(version.id)
+ self.assertIsInstance(version, Collection)
+ self.assertEqual(entry, self.icd_entry)
+
+ def test_an_owner_entry_resolves_by_type_in_its_namespace(self):
+ owner_entry = OrganizationURLRegistryFactory(organization=self.sehatapp, url=PBD, namespace=self.padam.uri)
+
+ version, entry = Source.resolve_reference_expression(PBD, '/orgs/sehatapp/', repo_type=Collection)
+ self.assertEqual(version.id, self.pbd_collection_v1.id)
+ self.assertEqual(entry, owner_entry)
+
+ version, entry = Source.resolve_reference_expression(ICD10, '/orgs/sehatapp/', repo_type=Source)
+ self.assertEqual(version.id, self.sehat_icd_v1.id)
+ self.assertIsNone(entry)
+
+ def test_repo_type_skips_a_deleted_repository(self):
+ self.who_icd.is_active = False
+ self.who_icd.save()
+
+ version, _ = Source.resolve_reference_expression(ICD10)
+ self.assertEqual(version.id, self.who_icd_2019.id)
+
+ version, entry = Source.resolve_reference_expression(ICD10, repo_type=Source)
+ self.assertIsNone(version.id)
+ self.assertEqual(entry, self.icd_entry)
+
+ def test_resolving_never_writes_a_registry_entry(self):
+ OrganizationURLRegistryFactory(organization=self.sehatapp, url=PBD, namespace=self.padam.uri)
+
+ with patch.object(URLRegistry, 'save') as save_mock:
+ Source.resolve_reference_expression(ICD10)
+ Source.resolve_reference_expression(PBD, '/orgs/sehatapp/')
+ Source.resolve_reference_expression(PBD, repo_type=Collection)
+
+ save_mock.assert_not_called()
+ self.icd_entry.refresh_from_db()
+ self.assertIsNone(self.icd_entry.repo_id)
+
+
+class URLRegistryReadsTest(ResolverCorpusMixin, OCLTestCase):
+ def setUp(self):
+ super().setUp()
+ self.entry = GlobalURLRegistryFactory(url=PBD, namespace=self.padam.uri)
+
+ def test_lookup_reads_without_writing(self):
+ with patch.object(URLRegistry, 'save') as save_mock:
+ repo, entry = URLRegistry.lookup(PBD)
+
+ save_mock.assert_not_called()
+ self.assertEqual(repo, self.pbd_source)
+ self.assertEqual(entry, self.entry)
+
+ def test_find_repo(self):
+ self.assertEqual(self.entry.find_repo(), self.pbd_source)
+ self.assertEqual(self.entry.find_repo(Source), self.pbd_source)
+ self.assertEqual(self.entry.find_repo(Collection), self.pbd_collection)
+ self.assertIsNone(URLRegistry(url=PBD).find_repo())
+
+ def test_lookup_entry_keeps_the_link_and_saves_only_on_a_change(self):
+ """The write path: lookup_entry links the repository found, clears a link that no longer resolves, and saves
+ (so reindexes) only when the link changes"""
+ with patch.object(URLRegistry, 'save', wraps=self.entry.save) as save_mock:
+ self.assertEqual(self.entry.lookup_entry(), self.pbd_source)
+ self.assertEqual(self.entry.lookup_entry(), self.pbd_source)
+ self.assertEqual(save_mock.call_count, 1)
+ self.entry.refresh_from_db()
+ self.assertEqual(self.entry.repo, self.pbd_source)
+
+ self.entry.namespace = self.drafts.uri
+ self.entry.save() # as the entry's update does, before its lookup_entry
+ self.assertIsNone(self.entry.lookup_entry())
+ self.entry.refresh_from_db()
+ self.assertEqual(
+ (self.entry.namespace, self.entry.repo_id, self.entry.repo_type_id), (self.drafts.uri, None, None))
+
+ with patch.object(URLRegistry, 'save') as save_mock:
+ self.assertIsNone(URLRegistry(url='http://example.org/nowhere', namespace=self.padam.uri).lookup_entry())
+ save_mock.assert_not_called()
+
+
+def run_inline(args, **_):
+ """resolve_url_registry_entries.apply_async, run in the test's own transaction (tests have no broker)"""
+ return resolve_url_registry_entries(*args)
+
+
+@override_settings(ES_SYNC=True, ELASTICSEARCH_DSL_AUTOSYNC=True, ELASTICSEARCH_DSL_AUTO_REFRESH=True)
+class URLRegistryLinkTest(OCLAPITestCase):
+ """
+ An entry's cached repository is kept on the write side, since reads no longer write: a repository created after
+ its entry links it, and a canonical URL change clears the entries of the old URL and fills those of the new one,
+ in the registry API and in the registry's search index. Indexing is switched on here because a test that pauses
+ it leaves it paused (resume_indexing does nothing in TEST_MODE).
+ """
+ OLD, NEW = 'https://example.org/acme/codes', 'https://example.org/acme/codes-v2'
+
+ def setUp(self):
+ super().setUp()
+ self.auth = {'HTTP_AUTHORIZATION': 'Token ' + UserProfile.objects.get(username='ocladmin').get_token()}
+ self.org = OrganizationFactory(mnemonic='acme-registry')
+
+ def create_entry(self, base, url):
+ response = self.client.post(
+ f'{base}url-registry/', {'name': url, 'url': url, 'namespace': self.org.uri}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 201, response.data)
+ return f"{base}url-registry/{response.data['id']}/", response.data['id']
+
+ def linked_repo(self, entry):
+ """The entry's repository in the registry API and in its search document (the repo and repoOwner facets)"""
+ path, entry_id = entry
+ response = self.client.get(path, **self.auth)
+ self.assertEqual(response.status_code, 200)
+ URLRegistryDocument._index.refresh() # pylint: disable=protected-access
+ hits = URLRegistryDocument.search().filter('ids', values=[str(entry_id)]).execute()
+ self.assertEqual(len(hits), 1)
+ return get(response.data, 'repo.url'), (get(hits[0], 'repo'), get(hits[0], 'repo_owner'))
+
+ def test_a_repository_created_after_its_entries_links_them_and_a_canonical_change_moves_the_links(self):
+ old_entries = [self.create_entry('/', self.OLD), self.create_entry(self.org.uri, self.OLD)]
+ new_entry = self.create_entry(self.org.uri, self.NEW)
+ for entry in old_entries + [new_entry]:
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+
+ response = self.client.post(f'{self.org.uri}sources/', {
+ 'id': 'codes', 'name': 'codes', 'short_code': 'codes', 'canonical_url': self.OLD,
+ 'default_locale': 'en', 'supported_locales': 'en'}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 201, response.data)
+ codes = f'{self.org.uri}sources/codes/'
+ for entry in old_entries:
+ self.assertEqual(self.linked_repo(entry), (codes, ('codes', 'acme-registry')))
+ self.assertEqual(self.linked_repo(new_entry), (None, (None, None)))
+
+ with patch.object(resolve_url_registry_entries, 'apply_async', side_effect=run_inline) as queued:
+ response = self.client.put(codes, {'canonical_url': self.NEW}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 200, response.data)
+ queued.assert_called_once() # the entries the repository was linked to, through the existing task
+ for entry in old_entries:
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+ self.assertEqual(self.linked_repo(new_entry), (codes, ('codes', 'acme-registry')))
+
+ def test_a_version_given_an_entrys_canonical_links_it_and_loses_it_again(self):
+ """A version row that gains the canonical an unlinked entry names is what the registry's lookup finds"""
+ head_entry = self.create_entry(self.org.uri, self.OLD)
+ head = source(self.org, 'codes', canonical_url=self.OLD)
+ source(self.org, 'codes', 'v1', released=True, canonical_url=self.OLD)
+ head_link = (head.uri, ('codes', 'acme-registry'))
+ self.assertEqual(self.linked_repo(head_entry), head_link) # a new version doesn't take HEAD's entry
+
+ new_entries = [self.create_entry('/', self.NEW), self.create_entry(self.org.uri, self.NEW)]
+ version_url = f'{head.uri}v1/'
+ response = self.client.put(version_url, {'canonical_url': self.NEW}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 200, response.data)
+ for entry in new_entries:
+ self.assertEqual(self.linked_repo(entry), (version_url, ('codes', 'acme-registry')))
+ self.assertEqual(self.linked_repo(head_entry), head_link)
+
+ with patch.object(resolve_url_registry_entries, 'apply_async', side_effect=run_inline) as queued:
+ response = self.client.put(
+ version_url, {'canonical_url': 'https://example.org/acme/elsewhere'}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 200, response.data)
+ queued.assert_called_once()
+ for entry in new_entries:
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+ self.assertEqual(self.linked_repo(head_entry), head_link)
+
+ def entry_state(self, entry):
+ """(exists, active) for an entry, read from the database"""
+ row = URLRegistry.objects.filter(id=entry[1]).values_list('is_active', flat=True).first()
+ return row is not None, bool(row)
+
+ def create_source(self, canonical_url):
+ response = self.client.post(f'{self.org.uri}sources/', {
+ 'id': 'codes', 'name': 'codes', 'short_code': 'codes', 'canonical_url': canonical_url,
+ 'default_locale': 'en', 'supported_locales': 'en'}, format='json', **self.auth)
+ self.assertEqual(response.status_code, 201, response.data)
+ return f'{self.org.uri}sources/codes/'
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_deleting_a_repository_unlinks_its_entries_instead_of_deleting_them(self, _):
+ """
+ A hard delete of a repository (HEAD and versions) keeps every registry entry linked to it, global and
+ owner-scoped alike, unlinked; recreating the repository links them again
+ """
+ entries = [self.create_entry('/', self.OLD), self.create_entry(self.org.uri, self.OLD)]
+ codes = self.create_source(self.OLD)
+ source(self.org, 'codes', 'v1', released=True, canonical_url=self.OLD)
+ for entry in entries:
+ self.assertEqual(self.linked_repo(entry), (codes, ('codes', 'acme-registry')))
+
+ response = self.client.delete(codes, **self.auth)
+ self.assertEqual(response.status_code, 204, response.data)
+ self.assertFalse(Source.objects.filter(mnemonic='codes', organization=self.org).exists())
+ for entry in entries:
+ self.assertEqual(self.entry_state(entry), (True, True))
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+
+ self.assertEqual(self.create_source(self.OLD), codes)
+ for entry in entries:
+ self.assertEqual(self.linked_repo(entry), (codes, ('codes', 'acme-registry')))
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_deleting_a_version_relinks_its_entry_to_what_it_resolves_to_now(self, _):
+ """An entry linked to a version row survives that version's delete and moves to the released sibling"""
+ source(self.org, 'codes', canonical_url=self.OLD)
+ source(self.org, 'codes', 'v1', released=True, canonical_url=self.NEW, is_latest_version=False)
+ source(self.org, 'codes', 'v2', released=True, canonical_url=self.NEW, is_latest_version=True)
+ entry = self.create_entry('/', self.NEW)
+ v1_url, v2_url = f'{self.org.uri}sources/codes/v1/', f'{self.org.uri}sources/codes/v2/'
+ self.assertEqual(self.linked_repo(entry), (v1_url, ('codes', 'acme-registry')))
+
+ response = self.client.delete(v1_url, **self.auth)
+ self.assertEqual(response.status_code, 204, getattr(response, 'data', None))
+ self.assertEqual(self.entry_state(entry), (True, True))
+ self.assertEqual(self.linked_repo(entry), (v2_url, ('codes', 'acme-registry')))
+
+ def fail_once(self):
+ """A delete that fails part-way: after the entries are unlinked and, for a HEAD, its versions deleted"""
+ return patch.object(ConceptContainerModel, 'delete_version_checksum_maps', side_effect=RuntimeError('fail'))
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_a_failed_repository_delete_keeps_its_entries_and_a_retry_finishes_it(self, _):
+ entries = [self.create_entry('/', self.OLD), self.create_entry(self.org.uri, self.OLD)]
+ codes = self.create_source(self.OLD)
+ source(self.org, 'codes', 'v1', released=True, canonical_url=self.OLD)
+
+ with self.fail_once():
+ response = self.client.delete(codes, **self.auth)
+ self.assertEqual(response.status_code, 400, response.data) # delete_source reports the failure
+ for entry in entries:
+ self.assertEqual(self.entry_state(entry), (True, True))
+
+ response = self.client.delete(codes, **self.auth)
+ self.assertEqual(response.status_code, 204, response.data)
+ for entry in entries:
+ self.assertEqual(self.entry_state(entry), (True, True))
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+
+ self.assertEqual(self.create_source(self.OLD), codes)
+ for entry in entries:
+ self.assertEqual(self.linked_repo(entry), (codes, ('codes', 'acme-registry')))
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_a_retried_repository_delete_relinks_entries_its_failed_attempt_unlinked(self, _):
+ """The retry finds the entries by the deleted rows' canonical URL and links them to what is left"""
+ entry = self.create_entry('/', self.OLD)
+ codes = self.create_source(self.OLD)
+ other = source(self.org, 'codes-2', canonical_url=self.OLD)
+ self.assertEqual(self.linked_repo(entry), (codes, ('codes', 'acme-registry')))
+
+ with self.fail_once():
+ self.assertEqual(self.client.delete(codes, **self.auth).status_code, 400)
+ self.assertEqual(self.entry_state(entry), (True, True))
+
+ self.assertEqual(self.client.delete(codes, **self.auth).status_code, 204)
+ self.assertEqual(URLRegistry.objects.get(id=entry[1]).repo_id, other.id)
+ self.assertEqual(self.linked_repo(entry), (other.uri, ('codes-2', 'acme-registry')))
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_a_failed_version_delete_keeps_its_entry_and_a_retry_moves_it(self, _):
+ source(self.org, 'codes', canonical_url=self.OLD)
+ source(self.org, 'codes', 'v1', released=True, canonical_url=self.NEW, is_latest_version=False)
+ v2 = source(self.org, 'codes', 'v2', released=True, canonical_url=self.NEW, is_latest_version=True)
+ entry = self.create_entry('/', self.NEW)
+ v1_url, v2_url = f'{self.org.uri}sources/codes/v1/', f'{self.org.uri}sources/codes/v2/'
+ self.assertEqual(self.linked_repo(entry), (v1_url, ('codes', 'acme-registry')))
+
+ with self.fail_once(), self.assertRaises(RuntimeError):
+ self.client.delete(v1_url, **self.auth)
+ self.assertEqual(self.entry_state(entry), (True, True))
+
+ response = self.client.delete(v1_url, **self.auth)
+ self.assertEqual(response.status_code, 204, getattr(response, 'data', None))
+ self.assertEqual(self.entry_state(entry), (True, True))
+ self.assertEqual(URLRegistry.objects.get(id=entry[1]).repo_id, v2.id)
+ self.assertEqual(self.linked_repo(entry), (v2_url, ('codes', 'acme-registry')))
+
+ def test_a_stale_entry_saves_only_its_link(self):
+ """Saving the cached link writes only the link columns, so a stale instance can't undo a concurrent edit"""
+ other = OrganizationFactory(mnemonic='acme-other')
+ codes = source(self.org, 'codes', canonical_url=self.OLD)
+ entry = self.create_entry('/', self.OLD)
+ stale = URLRegistry.objects.get(id=entry[1])
+ self.assertEqual(stale.repo, codes)
+
+ URLRegistry.objects.filter(id=stale.id).update(namespace=other.uri, name='redirected')
+ stale.unlink()
+ row = URLRegistry.objects.get(id=stale.id)
+ self.assertEqual(
+ (row.namespace, row.name, row.repo_type_id, row.repo_id), (other.uri, 'redirected', None, None))
+ self.assertEqual(self.linked_repo(entry), (None, (None, None)))
+
+ stale.lookup_entry() # the stale namespace still finds codes; only the link is written
+ row = URLRegistry.objects.get(id=stale.id)
+ self.assertEqual((row.namespace, row.name, row.repo_id), (other.uri, 'redirected', codes.id))
+ self.assertEqual(self.linked_repo(entry), (codes.uri, ('codes', 'acme-registry')))
+
+ @patch('core.common.models.delete_s3_objects') # export files: no broker in tests
+ def test_a_namespace_redirected_during_a_delete_is_kept_and_resolved(self, _):
+ """The entry is redirected to another namespace between the delete loading it and unlinking it"""
+ other = OrganizationFactory(mnemonic='acme-other')
+ source(self.org, 'codes', canonical_url=self.OLD)
+ other_codes = source(other, 'codes', canonical_url=self.OLD)
+ entry = self.create_entry('/', self.OLD)
+ unlink = URLRegistry.unlink
+
+ def unlink_after_a_concurrent_redirect(instance):
+ URLRegistry.objects.filter(id=instance.id).update(namespace=other.uri)
+ unlink(instance)
+
+ with patch.object(URLRegistry, 'unlink', autospec=True, side_effect=unlink_after_a_concurrent_redirect):
+ self.assertEqual(self.client.delete(f'{self.org.uri}sources/codes/', **self.auth).status_code, 204)
+ row = URLRegistry.objects.get(id=entry[1])
+ self.assertEqual((row.namespace, row.repo_id), (other.uri, other_codes.id))
+ self.assertEqual(self.linked_repo(entry), (other_codes.uri, ('codes', 'acme-other')))
+
+ def test_a_collection_created_after_its_entry_links_it(self):
+ entry = GlobalURLRegistryFactory(url=self.OLD, namespace=self.org.uri)
+ source(self.org, 'codes', canonical_url=self.OLD.replace('codes', 'other'))
+ value_set = collection(self.org, 'codes-vs', canonical_url=self.OLD)
+ entry.refresh_from_db()
+ self.assertEqual(entry.repo, value_set)
+
+ # neither a new version row nor a later repository with the same canonical takes the entry over
+ collection(self.org, 'codes-vs', 'v1', released=True, canonical_url=self.OLD)
+ collection(self.org, 'codes-vs-2', canonical_url=self.OLD)
+ entry.refresh_from_db()
+ self.assertEqual(entry.repo, value_set)
+
+ def test_entries_elsewhere_are_left_alone(self):
+ other = OrganizationFactory(mnemonic='acme-other')
+ entry = GlobalURLRegistryFactory(url=self.OLD, namespace=other.uri)
+ inactive = GlobalURLRegistryFactory(url=self.NEW, namespace=self.org.uri, is_active=False)
+ source(self.org, 'codes', canonical_url=self.OLD)
+ source(self.org, 'codes-2', canonical_url=self.NEW)
+ for unlinked in (entry, inactive):
+ unlinked.refresh_from_db()
+ self.assertIsNone(unlinked.repo_id)
+
+
+class ResolveReferenceNamespaceTest(OCLAPITestCase):
+ """$resolveReference's ?namespace=: the namespace of every expression that doesn't name its own."""
+ def setUp(self):
+ super().setUp()
+ self.token = UserProfile.objects.get(username='ocladmin').get_token()
+ self.org_a = OrganizationFactory(mnemonic='org-a')
+ self.org_b = OrganizationFactory(mnemonic='org-b')
+ self.source_a = source(self.org_a, 'codes', canonical_url=ICD10)
+ self.source_a_v1 = source(self.org_a, 'codes', 'v1', released=True, canonical_url=ICD10)
+ self.source_b = source(self.org_b, 'codes', canonical_url=ICD10)
+ self.source_b_v1 = source(self.org_b, 'codes', 'v1', released=True, canonical_url=ICD10)
+ GlobalURLRegistryFactory(url=ICD10, namespace=self.org_a.uri)
+
+ def resolve(self, expressions, query=''):
+ response = self.client.post(
+ f'/$resolveReference/{query}', expressions, HTTP_AUTHORIZATION='Token ' + self.token, format='json')
+ self.assertEqual(response.status_code, 200)
+ return [item['result']['version_url'] if item['resolved'] else None for item in response.data]
+
+ def test_with_the_registry(self):
+ set_registry_toggle(True)
+ a_v1, b_v1 = self.source_a_v1.uri, self.source_b_v1.uri
+
+ self.assertEqual(self.resolve([{'url': ICD10}]), [a_v1])
+ self.assertEqual(self.resolve([{'url': ICD10}, ICD10], '?namespace=/orgs/org-b/'), [b_v1, b_v1])
+ self.assertEqual(
+ self.resolve([{'url': ICD10, 'namespace': '/orgs/org-a/'}, {'url': ICD10, 'namespace': None}],
+ '?namespace=/orgs/org-b/'),
+ [a_v1, a_v1])
+ self.assertEqual(self.resolve([{'url': ICD10}], '?namespace='), [a_v1])
+
+ def test_one_expression_query_count(self):
+ """
+ A hot path: these counts may only fall. Without the registry: the token (3), the toggle, the repository, its
+ latest released version, that version's HEAD for its view access (ocl_online#486) and the serializer (2);
+ ?namespace= adds a toggle read. The registry adds the global entry, its namespace owner and a Source check
+ before the read; there ?namespace= also swaps the global entry and its owner for the namespace owner and its
+ own entry.
+ """
+ for registry, query, count in ((False, '', 9), (False, '?namespace=/orgs/org-b/', 10),
+ (True, '', 12), (True, '?namespace=/orgs/org-b/', 13)):
+ with self.subTest(registry=registry, query=query):
+ set_registry_toggle(registry)
+ with self.assertNumQueries(count):
+ self.resolve([{'url': ICD10}], query)
+
+ def test_without_the_registry_the_parameter_is_not_used(self):
+ set_registry_toggle(False)
+ a_v1, b_v1 = self.source_a_v1.uri, self.source_b_v1.uri
+
+ self.assertEqual(self.resolve([{'url': ICD10}], '?namespace=/orgs/org-b/'), [a_v1])
+ self.assertEqual(self.resolve([{'url': ICD10}], '?namespace=/orgs/nobody/'), [a_v1])
+ self.assertEqual(self.resolve([{'url': ICD10, 'namespace': '/orgs/org-b/'}], '?namespace=/orgs/org-a/'), [b_v1])
diff --git a/core/users/urls.py b/core/users/urls.py
index c39ab6e6d..4c88a4606 100644
--- a/core/users/urls.py
+++ b/core/users/urls.py
@@ -1,6 +1,7 @@
from django.urls import include, path
from core.capabilities.views import UserCapabilityOverrideListView, UserCapabilityOverrideDetailView
+from core.fhir.views import CapabilityStatementView, VersionsView
from core.orgs import views as org_views
from core.tasks import views as task_views
from . import views
@@ -60,6 +61,10 @@
path('/map-projects/', include('core.map_projects.urls')),
# TODO: require FHIR subdomain
+ path('/metadata', CapabilityStatementView.as_view(), name='userprofile-fhir-metadata'),
+ path('/metadata/', CapabilityStatementView.as_view(), name='userprofile-fhir-metadata-slash'),
+ path('/$versions', VersionsView.as_view(), name='userprofile-fhir-versions'),
+ path('/$versions/', VersionsView.as_view(), name='userprofile-fhir-versions-slash'),
path('/CodeSystem/', include('core.code_systems.urls'), name='code_systems_urls'),
path('/CodeSystem', include('core.code_systems.urls'), name='code_systems_urls_no_slash'),
path('/ValueSet/', include('core.value_sets.urls'), name='value_sets_urls'),
diff --git a/core/value_sets/serializers.py b/core/value_sets/serializers.py
index f01a7722a..8e3001e8a 100644
--- a/core/value_sets/serializers.py
+++ b/core/value_sets/serializers.py
@@ -5,16 +5,17 @@
BooleanField, ListField, URLField
from core.code_systems.serializers import CodeSystemConceptSerializer, TextField
-from core.collections.models import Collection, Expansion
+from core.collections.models import Collection
from core.collections.parsers import CollectionReferenceParser
from core.collections.serializers import CollectionCreateOrUpdateSerializer
from core.common.constants import HEAD
from core.common.fhir_helpers import delete_empty_fields
from core.common.serializers import StatusField, IdentifierSerializer, ReadSerializerMixin
-from core.parameters.serializers import ParametersSerializer
+from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin, TitleField
from core.value_sets.constants import RESOURCE_TYPE
logger = logging.getLogger('oclapi')
+REFERENCE_LIMIT = 1000
class FilterValueSetSerializer(ReadSerializerMixin, serializers.Serializer):
@@ -30,12 +31,6 @@ def __init__(self, *args, **kwargs):
self.fields.pop('property')
-class ValueSetExpansionConceptSerializer(CodeSystemConceptSerializer):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.fields.pop('definition')
-
-
class ValueSetComposeIncludeField(ReadSerializerMixin, serializers.Serializer):
system = CharField(required=False)
version = CharField(required=False)
@@ -115,10 +110,10 @@ def to_representation(self, value):
include = []
exclude = []
+ references = list(value.references.filter(reference_type='concepts').order_by('id')[:REFERENCE_LIMIT + 1])
+ self.subsetted = len(references) > REFERENCE_LIMIT
grouped_references = {}
- for reference in value.references.all():
- if reference.reference_type != 'concepts':
- continue
+ for reference in references[:REFERENCE_LIMIT]:
ref_list = grouped_references.get((reference.include, reference.system, reference.version), None)
if not ref_list:
ref_list = []
@@ -144,11 +139,12 @@ def to_representation(self, value):
return None
-class ValueSetDetailSerializer(serializers.ModelSerializer):
+class ValueSetDetailSerializer(FhirRepositorySerializerMixin, serializers.ModelSerializer):
+ content_field = 'compose'
resourceType = SerializerMethodField(method_name='get_resource_type')
id = CharField(source='mnemonic')
- url = CharField(source='canonical_url', required=False)
- title = CharField(source='full_name', required=False)
+ url = CanonicalUrlField(source='canonical_url', required=False)
+ title = TitleField(source='full_name', required=False)
status = StatusField(source='*')
meta = SerializerMethodField()
identifier = IdentifierSerializer(many=True, required=False)
@@ -255,6 +251,7 @@ def to_representation(self, instance):
rep = super().to_representation(instance)
delete_empty_fields(rep)
IdentifierSerializer.include_ocl_identifier(instance.uri, RESOURCE_TYPE, rep)
+ self.with_fhir_view(instance, rep)
except (Exception, ):
msg = f'Failed to represent "{instance.uri}" as {RESOURCE_TYPE}'
logger.exception(msg)
@@ -276,52 +273,9 @@ def get_meta(obj):
return {'lastUpdated': obj.updated_at}
-class ValueSetExpansionParametersSerializer(ParametersSerializer):
- allowed_input_parameters = {
- 'url': 'valueUri',
- 'filter': 'valueString',
- 'date': 'valueDate',
- 'offset': 'valueInteger',
- 'count': 'valueInteger',
- 'activeOnly': 'valueBoolean',
- 'exclude-system': 'valueString',
- 'system-version': 'valueString'
- }
-
- def update(self, instance, validated_data):
- pass
-
- def create(self, validated_data):
- pass
+class ValueSetExpansionSerializer(ValueSetDetailSerializer):
+ """A value set version as $expand answers it: the read's ValueSet without compose. The view adds the expansion."""
-
-class ValueSetExpansionField(serializers.Field):
- default_count = 1000
- default_offset = 0
- timestamp = DateTimeField()
-
- def to_internal_value(self, _):
- return None
-
- def to_representation(self, value):
- return {
- 'identifier': value.uri,
- 'timestamp': self.timestamp.to_representation(value.created_at),
- 'total': value.concepts.count(),
- 'offset': self.default_offset,
- 'contains': ValueSetExpansionConceptSerializer(value.concepts.order_by('id')
- [self.default_offset:self.default_count], many=True).data
- }
-
-
-class ValueSetExpansionSerializer(serializers.ModelSerializer):
- resourceType = SerializerMethodField(method_name='get_resource_type')
- expansion = ValueSetExpansionField(source='*')
-
- class Meta:
- model = Expansion
- fields = ('resourceType', 'id', 'expansion')
-
- @staticmethod
- def get_resource_type(_):
- return RESOURCE_TYPE
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields.pop(self.content_field)
diff --git a/core/value_sets/tests.py b/core/value_sets/tests.py
index f68e35f21..4f00ebcc0 100644
--- a/core/value_sets/tests.py
+++ b/core/value_sets/tests.py
@@ -1,14 +1,16 @@
from mock.mock import patch, Mock
-from core.collections.models import CollectionReference, Collection
+from core.collections.models import CollectionReference, Collection, Expansion
from core.collections.tests.factories import OrganizationCollectionFactory, ExpansionFactory, UserCollectionFactory
from core.common.constants import ACCESS_TYPE_NONE
from core.common.tests import OCLAPITestCase
from core.concepts.documents import ConceptDocument
+from core.concepts.models import Concept
from core.concepts.tests.factories import ConceptFactory
from core.orgs.tests.factories import OrganizationFactory
from core.sources.models import Source
from core.sources.tests.factories import OrganizationSourceFactory, UserSourceFactory
+from core.url_registry.tests_resolution import set_registry_toggle
from core.users.models import UserProfile
from core.users.tests.factories import UserProfileFactory
from core.value_sets.serializers import ValueSetDetailSerializer
@@ -18,6 +20,7 @@ class ValueSetTest(OCLAPITestCase):
@patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts'))
@patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings'))
def setUp(self):
+ set_registry_toggle(False) # the operations resolve through core.fhir.resolve: no registry, as in production
super().setUp()
self.org = OrganizationFactory()
self.org_source = OrganizationSourceFactory(organization=self.org, canonical_url='http://some/url')
@@ -41,7 +44,8 @@ def setUp(self):
self.collection = OrganizationCollectionFactory(
organization=self.org, mnemonic='c1', canonical_url='http://c1.com', version='HEAD')
self.collection_v1 = OrganizationCollectionFactory(
- mnemonic='c1', canonical_url='http://c1.com', version='v1', organization=self.collection.organization)
+ mnemonic='c1', canonical_url='http://c1.com', version='v1', organization=self.collection.organization,
+ released=True)
expansion = ExpansionFactory(mnemonic='e1', collection_version=self.collection)
self.collection.expansion_uri = expansion.uri
self.collection.save()
@@ -75,7 +79,8 @@ def test_public_can_find_globally(self):
response = self.client.get('/fhir/ValueSet/?url=http://c1.com')
self.assertEqual(len(response.data['entry']), 1)
- resource = response.data['entry'][0]['resource']
+ self.assertNotIn('compose', response.data['entry'][0]['resource']) # a search entry is a summary
+ resource = self.client.get(response.data['entry'][0]['fullUrl']).data
self.assertEqual(
resource['identifier'][0]['value'], f'/orgs/{self.org.mnemonic}/ValueSet/{self.collection.mnemonic}/')
self.assertEqual(len(resource['compose']['include']), 1)
@@ -430,6 +435,15 @@ def test_update_with_compose(self):
self.assertEqual(len(resource['compose']['include'][1]['concept']), 1)
self.assertEqual(resource['compose']['include'][1]['concept'][0]['code'], self.concept_2.mnemonic)
+ def release_system(self):
+ """FHIR shows code systems with a release: the org source's v2 is one."""
+ Source.objects.filter(id=self.org_source_v2.id).update(released=True)
+
+ def expand(self, collection, *concepts):
+ """The concepts, as the org source's v2 holds them, in the collection version's default expansion."""
+ collection.expansion.concepts.add(*Concept.objects.filter(
+ sources=self.org_source_v2, mnemonic__in=[concept.mnemonic for concept in concepts]))
+
def test_validate_code(self):
self.collection.add_references([
CollectionReference(
@@ -442,6 +456,8 @@ def test_validate_code(self):
),
])
self.collection_v1.seed_references()
+ self.release_system()
+ self.expand(self.collection_v1, self.concept_1, self.concept_2)
response = self.client.get(
f'/orgs/{self.org.mnemonic}/ValueSet/{self.collection.mnemonic}/$validate-code/'
@@ -486,6 +502,8 @@ def test_validate_code_globally(self):
),
])
self.collection_v1.seed_references()
+ self.release_system()
+ self.expand(self.collection_v1, self.concept_1, self.concept_2)
response = self.client.get(
f'/fhir/ValueSet/$validate-code/'
@@ -509,6 +527,8 @@ def test_validate_code_globally_via_post(self):
),
])
self.collection_v1.seed_references()
+ self.release_system()
+ self.expand(self.collection_v1, self.concept_1, self.concept_2)
response = self.client.post(
'/fhir/ValueSet/$validate-code/',
@@ -552,34 +572,34 @@ def test_validate_code_globally_negative(self):
self.assertEqual(resource['parameter'][0]['valueBoolean'], False)
def test_validate_code_private_value_set_only_for_members_and_staff(self):
- self.collection.add_references([
- CollectionReference(
- expression=self.concept_1.uri, collection=self.collection, code=self.concept_1.mnemonic,
- system=self.concept_1.parent.uri, version='v2'
- ),
- ])
- self.collection_v1.seed_references()
+ """A private value set: at its owner's base for members and staff; anyone else, and anyone at the global base
+ (which shows public value sets only), is answered as for a value set that doesn't exist."""
+ self.release_system()
+ self.expand(self.collection_v1, self.concept_1)
Collection.objects.filter(mnemonic='c1', organization=self.org).update(public_access=ACCESS_TYPE_NONE)
member = UserProfileFactory()
self.org.members.add(member)
admin = UserProfile.objects.get(username='ocladmin')
query = f'system=http://some/url&systemVersion={self.org_source_v2.version}&code={self.concept_1.mnemonic}'
- urls = [
- f'/orgs/{self.org.mnemonic}/ValueSet/{self.collection.mnemonic}/$validate-code/?{query}',
- f'/fhir/ValueSet/$validate-code/?url=http://c1.com&{query}',
- ]
-
- for url in urls:
- for headers, expected in [
- ({}, False),
- ({'HTTP_AUTHORIZATION': 'Token ' + self.user_token}, False),
- ({'HTTP_AUTHORIZATION': 'Token ' + member.get_token()}, True),
- ({'HTTP_AUTHORIZATION': 'Token ' + admin.get_token()}, True),
- ]:
- response = self.client.get(url, **headers)
-
- self.assertEqual(response.status_code, 200, url)
- self.assertEqual(response.data['parameter'][0]['valueBoolean'], expected, (url, headers))
+ route = f'/orgs/{self.org.mnemonic}/ValueSet/{self.collection.mnemonic}/$validate-code/?{query}'
+ by_url = f'/fhir/ValueSet/$validate-code/?url=http://c1.com&{query}'
+
+ for headers, expected in [
+ ({}, 404),
+ ({'HTTP_AUTHORIZATION': 'Token ' + self.user_token}, 404),
+ ({'HTTP_AUTHORIZATION': 'Token ' + member.get_token()}, True),
+ ({'HTTP_AUTHORIZATION': 'Token ' + admin.get_token()}, True),
+ ]:
+ response = self.client.get(route, **headers)
+ if expected is True:
+ self.assertEqual(response.status_code, 200, headers)
+ self.assertTrue(response.data['parameter'][0]['valueBoolean'], headers)
+ else:
+ self.assertEqual(response.status_code, expected, headers)
+ self.assertEqual(response.data['issue'][0]['code'], 'not-found')
+ response = self.client.get(by_url, **headers)
+ self.assertEqual(response.status_code, 422, headers)
+ self.assertEqual(response.data['issue'][0]['code'], 'not-found')
def add_reference(self, collection, concept):
if not collection.expansion_uri:
@@ -592,17 +612,32 @@ def add_reference(self, collection, concept):
),
])
+ def released(self, collection, concept):
+ """A release of a collection, whose default expansion holds the concept."""
+ owner = {'user': collection.user} if collection.user_id else {'organization': collection.organization}
+ factory = UserCollectionFactory if collection.user_id else OrganizationCollectionFactory
+ release = factory(mnemonic=collection.mnemonic, version='v1', released=True,
+ public_access=collection.public_access, **owner)
+ self.add_reference(release, concept)
+ self.expand(release, concept)
+ return release
+
def test_validate_code_reads_only_value_sets_the_requester_can_view(self):
- self.add_reference(self.collection, self.concept_1)
+ """
+ Only the value set the request names is read (`url`, or the route's), never one other query params pick; one
+ the requester can't view answers as one that doesn't exist. Without `url`, the global route checks the code
+ system alone.
+ """
+ self.release_system()
+ self.expand(self.collection_v1, self.concept_1)
private_org = OrganizationFactory()
member = UserProfileFactory()
private_org.members.add(member)
- private_collection = OrganizationCollectionFactory(
- organization=private_org, mnemonic='priv', public_access=ACCESS_TYPE_NONE)
- self.add_reference(private_collection, self.concept_2)
- self.add_reference(OrganizationCollectionFactory(organization=private_org, mnemonic='pub'), self.concept_1)
+ priv = self.released(OrganizationCollectionFactory(
+ organization=private_org, mnemonic='priv', public_access=ACCESS_TYPE_NONE), self.concept_2)
+ self.released(OrganizationCollectionFactory(organization=private_org, mnemonic='pub'), self.concept_1)
owner = UserProfileFactory()
- self.add_reference(
+ upriv = self.released(
UserCollectionFactory(user=owner, mnemonic='upriv', public_access=ACCESS_TYPE_NONE), self.concept_2)
query = f'system=http://some/url&systemVersion={self.org_source_v2.version}&code='
private_org_params = f'org={private_org.mnemonic}&collection=priv'
@@ -611,13 +646,20 @@ def test_validate_code_reads_only_value_sets_the_requester_can_view(self):
for url in [
f'/orgs/{self.org.mnemonic}/ValueSet/c1/$validate-code/?{private_org_params}&{query}',
f'/orgs/{private_org.mnemonic}/ValueSet/pub/$validate-code/?collection=priv&{query}',
- f'/fhir/ValueSet/upriv/$validate-code/?{query}',
- f'/fhir/ValueSet/upriv/$validate-code/?user={owner.username}&{query}',
- f'/fhir/ValueSet/c1/$validate-code/?user={owner.username}&collection=upriv&{query}',
+ f'/fhir/ValueSet/{self.collection_v1.id}/$validate-code/?user={owner.username}&collection=upriv&'
+ f'{query}',
]:
response = self.client.get(url + self.concept_2.mnemonic, **headers)
self.assertEqual(response.status_code, 200, (url, headers))
self.assertFalse(response.data['parameter'][0]['valueBoolean'], (url, headers))
+ for url in [
+ f'/orgs/{private_org.mnemonic}/ValueSet/priv/$validate-code/?{query}',
+ f'/users/{owner.username}/ValueSet/upriv/$validate-code/?{query}',
+ f'/fhir/ValueSet/{priv.id}/$validate-code/?{query}',
+ f'/fhir/ValueSet/{upriv.id}/$validate-code/?user={owner.username}&{query}',
+ ]:
+ response = self.client.get(url + self.concept_2.mnemonic, **headers)
+ self.assertEqual(response.status_code, 404, (url, headers))
# Without `url`, the global route checks the code system alone; other params don't pick a value set
for concept in [self.concept_1, self.concept_2]:
@@ -625,15 +667,14 @@ def test_validate_code_reads_only_value_sets_the_requester_can_view(self):
f'/fhir/ValueSet/$validate-code/?{private_org_params}&{query}{concept.mnemonic}', **headers)
self.assertTrue(response.data['parameter'][0]['valueBoolean'], (concept.mnemonic, headers))
- for url, headers in [
+ for url, user in [
(f'/orgs/{private_org.mnemonic}/ValueSet/priv/$validate-code/?{query}', member),
- (f'/fhir/ValueSet/priv/$validate-code/?{query}', member),
- (f'/fhir/ValueSet/upriv/$validate-code/?{query}', owner),
+ (f'/users/{owner.username}/ValueSet/upriv/$validate-code/?{query}', owner),
]:
- response = self.client.get(
- url + self.concept_2.mnemonic, HTTP_AUTHORIZATION='Token ' + headers.get_token())
+ response = self.client.get(url + self.concept_2.mnemonic, HTTP_AUTHORIZATION='Token ' + user.get_token())
self.assertTrue(response.data['parameter'][0]['valueBoolean'], url)
- response = self.client.get(f'/fhir/ValueSet/c1/$validate-code/?{query}{self.concept_1.mnemonic}')
+ response = self.client.get(f'/fhir/ValueSet/{self.collection_v1.id}/$validate-code/?{query}'
+ f'{self.concept_1.mnemonic}')
self.assertTrue(response.data['parameter'][0]['valueBoolean'])
def test_validate_code_with_display_for_code_not_in_value_set(self):
@@ -648,6 +689,7 @@ def test_validate_code_with_display_for_code_not_in_value_set(self):
self.assertFalse(response.data['parameter'][0]['valueBoolean'])
def test_expand(self):
+ """$expand reads the released version's stored expansion, by GET or POST, and a POST never makes another."""
self.client.post(
f'/users/{self.user.mnemonic}/ValueSet/',
HTTP_AUTHORIZATION='Token ' + self.user_token,
@@ -655,7 +697,7 @@ def test_expand(self):
'resourceType': 'ValueSet',
'id': 'c2',
'url': 'http://c2.com',
- 'status': 'draft',
+ 'status': 'active',
'version': '1',
'name': 'collection1',
'description': 'This is a test collection',
@@ -676,10 +718,9 @@ def test_expand(self):
},
format='json'
)
+ expansions = list(Expansion.objects.values_list('id', flat=True))
- ConceptDocument().update(self.concept_1.parent.concepts_set.all())
-
- response = self.client.post(
+ posted = self.client.post(
'/users/' + self.user.mnemonic + '/ValueSet/c2/$expand/',
HTTP_AUTHORIZATION='Token ' + self.user_token,
data={
@@ -693,76 +734,19 @@ def test_expand(self):
},
format='json'
)
-
- resource = response.data
-
- self.assertEqual(resource['resourceType'], 'ValueSet')
- expansion = resource['expansion']
- self.assertIsNotNone(expansion['timestamp'])
- self.assertIn('/users/' + self.user.mnemonic + '/collections/c2/1/expansions', expansion['identifier'])
- self.assertEqual(len(expansion['contains']), 1)
- self.assertEqual(expansion['contains'][0]['code'], self.concept_1.mnemonic)
-
- def text_get_expand(self):
- self.client.post(
- f'/users/{self.user.mnemonic}/ValueSet/',
- HTTP_AUTHORIZATION='Token ' + self.user_token,
- data={
- 'resourceType': 'ValueSet',
- 'id': 'c2',
- 'url': 'http://c2.com',
- 'status': 'draft',
- 'version': '1',
- 'name': 'collection1',
- 'description': 'This is a test collection',
- 'compose': {
- 'include': [
- {
- 'system': 'http://some/url',
- 'version': self.org_source_v2.version,
- 'concept': [
- {
- 'code': self.concept_1.mnemonic
- }
- ]
- }
- ]
- }
- },
- format='json'
- )
-
- ConceptDocument().update(self.concept_1.parent.concepts_set.all())
-
- self.client.post(
- '/users/' + self.user.mnemonic + '/ValueSet/c2/$expand/',
- HTTP_AUTHORIZATION='Token ' + self.user_token,
- data={
- 'resourceType': 'Parameters',
- 'parameter': [
- {
- 'name': 'filter',
- 'valueString': self.concept_1.mnemonic
- }
- ]
- },
- format='json'
- )
-
response = self.client.get(
- '/users/' + self.user.mnemonic + '/ValueSet/c2/$expand/?filter=' + {self.concept_1.mnemonic},
- HTTP_AUTHORIZATION='Token ' + self.user_token,
- format='json'
- )
-
- resource = response.data
-
- self.assertEqual(resource['resourceType'], 'ValueSet')
- expansion = resource['expansion']
- self.assertIsNotNone(expansion['timestamp'])
- self.assertIn('/users/' + self.user.mnemonic + '/collections/c2/1/expansions', expansion['identifier'])
- self.assertEqual(len(expansion['contains']), 1)
- self.assertEqual(expansion['contains'][0]['code'], self.concept_1.mnemonic)
+ f'/users/{self.user.mnemonic}/ValueSet/c2/$expand/', {'filter': self.concept_1.mnemonic},
+ HTTP_AUTHORIZATION='Token ' + self.user_token)
+
+ for resource in (posted.data, response.data):
+ self.assertEqual(resource['resourceType'], 'ValueSet')
+ expansion = resource['expansion']
+ self.assertIsNotNone(expansion['timestamp'])
+ self.assertIn('/users/' + self.user.mnemonic + '/collections/c2/1/expansions', expansion['identifier'])
+ self.assertEqual(len(expansion['contains']), 1)
+ self.assertEqual(expansion['contains'][0]['code'], self.concept_1.mnemonic)
+ self.assertEqual(posted.content, response.content)
+ self.assertEqual(list(Expansion.objects.values_list('id', flat=True)), expansions)
def test_unable_to_represent_as_fhir(self):
instance = Collection(id='1', uri='/invalid/uri')
diff --git a/core/value_sets/urls.py b/core/value_sets/urls.py
index f642c0ede..ad809da56 100644
--- a/core/value_sets/urls.py
+++ b/core/value_sets/urls.py
@@ -9,6 +9,8 @@
name='value-set-validate-code_no_slash'),
path("$validate-code/", views.ValueSetValidateCodeView.as_view(), name='value-set-validate-code-global'),
path("$validate-code", views.ValueSetValidateCodeView.as_view(), name='value-set-validate-code-global_no_slash'),
+ path("$expand/", views.ValueSetExpandView.as_view(), name='value-set-expand-global'),
+ path("$expand", views.ValueSetExpandView.as_view(), name='value-set-expand-global_no_slash'),
path("/$expand/", views.ValueSetExpandView.as_view(), name='value-set-expand'),
path("/$expand", views.ValueSetExpandView.as_view(), name='value-set-expand_no_slash'),
path("/", views.ValueSetRetrieveUpdateView.as_view(), name='value-set-detail'),
diff --git a/core/value_sets/views.py b/core/value_sets/views.py
index 1faaea35c..6aa10203a 100644
--- a/core/value_sets/views.py
+++ b/core/value_sets/views.py
@@ -1,48 +1,22 @@
import logging
-from django.core.exceptions import ValidationError
-from rest_framework import status
-from rest_framework.response import Response
-
-from core.bundles.serializers import FHIRBundleSerializer
-from core.code_systems.views import CodeSystemValidateCodeView, get_viewable_repo, FhirParameterRequiredError
-from core.collections.models import Collection
-from core.collections.views import CollectionListView, CollectionRetrieveUpdateDestroyView, \
- CollectionVersionExpansionsView
-from core.common.constants import HEAD, LATEST
-from core.common.fhir_helpers import translate_fhir_query
-from core.concepts.models import Concept
-from core.sources.models import Source
-from core.value_sets.serializers import ValueSetDetailSerializer, \
- ValueSetExpansionParametersSerializer, ValueSetExpansionSerializer
+from core.collections.views import CollectionListView, CollectionRetrieveUpdateDestroyView
+from core.common.constants import HEAD
+from core.fhir.adapters import r4
+from core.fhir.base import FhirViewMixin, FhirOperationView
+from core.fhir.ops.expand import expand
+from core.fhir.ops.validate_code import validate_value_set
+from core.fhir.resolve import namespace_of
+from core.fhir.search import FhirSearchMixin, FhirReadMixin
+from core.value_sets.serializers import ValueSetDetailSerializer, ValueSetExpansionSerializer
logger = logging.getLogger('oclapi')
-class ValueSetListView(CollectionListView):
+class ValueSetListView(FhirViewMixin, FhirSearchMixin, CollectionListView):
+ resource_type = 'ValueSet'
serializer_class = ValueSetDetailSerializer
- @staticmethod
- def bundle_response(data, paginator):
- bundle = FHIRBundleSerializer(
- {'meta': {}, 'total': len(data), 'type': 'searchset', 'entry': FHIRBundleSerializer.convert_to_entry(data)},
- context={'paginator': paginator}
- )
- return bundle.data
-
- def get_filter_params(self, default_version_to_head=True):
- return super().get_filter_params(False)
-
- def apply_query_filters(self, queryset):
- query_fields = list(self.serializer_class.Meta.fields)
- queryset = translate_fhir_query(query_fields, self.request.query_params, queryset)
-
- return queryset
-
- def apply_filters(self, queryset):
- queryset = queryset.exclude(version=HEAD).filter(is_latest_version=True)
- return self.apply_query_filters(queryset)
-
def get_serializer_class(self):
return self.serializer_class
@@ -50,87 +24,15 @@ def get_detail_serializer(self, obj):
return self.serializer_class(obj)
-class ValueSetValidateCodeView(CodeSystemValidateCodeView):
- CONTAINER_PARAMS = ('user', 'org', 'source', 'collection', 'version')
-
- def get_route_value_set_versions(self):
- """Ids of the route's collection versions the requester can view, narrowed by a `version` query param. The
- owner-less route reads every collection with that id the requester can view."""
- user = self.request.user
- heads = Collection.objects.filter(
- mnemonic=self.kwargs['collection'], version=HEAD).select_related('organization')
- if 'org' in self.kwargs:
- heads = heads.filter(organization__mnemonic=self.kwargs['org'])
- elif 'user' in self.kwargs:
- heads = heads.filter(user__username=self.kwargs['user'])
- version = self.request.query_params.get('version')
- version_ids = []
- for head in heads:
- has_private_access = bool(user.is_authenticated and head.has_parent_edit_access(user))
- if not has_private_access and not head.public_can_view:
- continue
- versions = head.versions.only('id', 'public_access')
- if version == LATEST:
- versions = versions.filter(released=True)[:1]
- elif version:
- versions = versions.filter(version=version)
- version_ids += [repo.id for repo in versions if has_private_access or repo.public_can_view]
- return version_ids
+class ValueSetValidateCodeView(FhirOperationView):
+ """ValueSet/$validate-code and ValueSet/{id}/$validate-code: core.fhir.ops.validate_code."""
+ instance = 'collection'
+ operation = staticmethod(validate_value_set)
+ adapter = staticmethod(r4.validate_code_parameters)
- def get_queryset(self):
- """Reads only the value sets the request names (`url` and the route's collection), never ones other query
- params pick; a value set that doesn't resolve or the requester can't view reads nothing. Without a code, or
- with a code but no system, the request is a 400."""
- params = {key: value for key, value in self.params.items() if key not in self.CONTAINER_PARAMS}
- queryset = Concept.get_base_queryset(params)
-
- parameters = self.get_parameters()
-
- url = parameters.get('url')
- code = parameters.get('code')
- system = parameters.get('system')
- display = parameters.get('display')
- system_version = parameters.get('systemVersion')
-
- if url:
- collection = get_viewable_repo(
- Collection.objects.filter(canonical_url=url, is_latest_version=True).exclude(version=HEAD),
- self.request.user)
- if not collection:
- return queryset.none()
- queryset = queryset.filter(references__collection=collection)
-
- if 'collection' in self.kwargs:
- version_ids = self.get_route_value_set_versions()
- if not version_ids:
- return queryset.none()
- queryset = queryset.filter(expansion_set__collection_version__in=version_ids)
-
- if not code:
- raise FhirParameterRequiredError('A code to validate is required.', expression='code')
- if not system:
- raise FhirParameterRequiredError(
- 'system is required with a code: the code system the code is from.', expression='system')
-
- concept_source = Source.objects.filter(canonical_url=system)
- if system_version:
- concept_source = concept_source.filter(version=system_version)
- else:
- concept_source = concept_source.filter(is_latest_version=True).exclude(version=HEAD)
- concept_source = get_viewable_repo(concept_source, self.request.user)
- if not concept_source:
- return queryset.none()
- queryset = queryset.filter(sources=concept_source, mnemonic=code)
-
- if display:
- instance = queryset.first()
- if not instance or display not in (instance.name, instance.display_name):
- return queryset.none()
-
- return queryset
-
-
-class ValueSetRetrieveUpdateView(CollectionRetrieveUpdateDestroyView):
+
+class ValueSetRetrieveUpdateView(FhirViewMixin, FhirReadMixin, CollectionRetrieveUpdateDestroyView):
+ resource_type = 'ValueSet'
serializer_class = ValueSetDetailSerializer
def get_filter_params(self, default_version_to_head=True):
@@ -147,44 +49,13 @@ def get_detail_serializer(self, obj):
return ValueSetDetailSerializer(obj)
-class ValueSetExpandView(CollectionVersionExpansionsView):
- sync = True
-
- def get_serializer_class(self):
- if self.request.method == 'POST':
- return ValueSetExpansionParametersSerializer
-
- return ValueSetExpansionSerializer
-
- def get_response_serializer_class(self):
- return ValueSetExpansionSerializer
-
- def get_filter_params(self, default_version_to_head=True):
- return super().get_filter_params(False)
-
- def get_base_queryset(self):
- queryset = super().get_base_queryset()
- queryset = queryset.exclude(version=HEAD).filter(is_latest_version=True)[:1]
- return queryset
-
- def get_queryset(self):
- qs = super().get_queryset()
-
- if self.request.method == 'GET':
- parameters = ValueSetExpansionParametersSerializer.parse_query_params(self.request.query_params)
- if not parameters.is_valid():
- raise ValidationError(message=parameters.errors)
-
- params = parameters.validated_data
- params = params.get('parameters', {})
- qs = qs.filter(parameters=params if params else {}).order_by('-id')
-
- return qs
-
- def get(self, request, *args, **kwargs):
- instance = self.get_queryset().first()
- if instance:
- serializer = self.get_serializer(instance)
- return Response(serializer.data)
+class ValueSetExpandView(FhirOperationView):
+ """ValueSet/$expand and ValueSet/{id}/$expand: core.fhir.ops.expand. A POST reads as a GET does."""
+ instance = 'collection'
+ operation = staticmethod(expand)
- return Response(status=status.HTTP_404_NOT_FOUND)
+ def adapter(self, answer):
+ """The answer as an R4 ValueSet: the value set version as a read shows it, with the expansion."""
+ resource = ValueSetExpansionSerializer(
+ answer['value_set'], context={'opaque_ids': namespace_of(self.kwargs) is None}).data
+ return r4.expansion_value_set(resource, answer)
diff --git a/core/wsgi.py b/core/wsgi.py
index 883d9f9c1..6c1add058 100644
--- a/core/wsgi.py
+++ b/core/wsgi.py
@@ -7,10 +7,48 @@
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
+import codecs
import os
+from django.core.exceptions import BadRequest, SuspiciousOperation
+from django.core.handlers.wsgi import WSGIRequest
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+
+class FhirWSGIRequest(WSGIRequest):
+ """Let FHIR middleware answer malformed Content-Type parameters rejected during request construction."""
+
+ def _set_content_type_params(self, meta):
+ """Preserve the invalid raw header and mark FHIR requests for a safe JSON 400 response."""
+ from core.fhir.base import is_fhir_path
+
+ try:
+ super()._set_content_type_params(meta)
+ except (LookupError, ValueError, BadRequest, SuspiciousOperation):
+ if not is_fhir_path(self.path):
+ raise
+ self._mark_bad_content_type()
+ return
+ charset = self.content_params.get('charset')
+ # Django accepts any codec as the charset, but decoding the query with a non-text one (base64) fails later.
+ if charset and is_fhir_path(self.path) and not self._is_text_encoding(charset):
+ self._mark_bad_content_type()
+
+ @staticmethod
+ def _is_text_encoding(charset):
+ try:
+ return codecs.lookup(charset)._is_text_encoding # pylint: disable=protected-access
+ except LookupError:
+ return True # Django ignores an unknown charset name, so the query decodes with the default
+
+ def _mark_bad_content_type(self):
+ self.fhir_content_type_error = True
+ self.content_type = ''
+ self.content_params = {}
+ # Bypass the encoding setter, which can parse the rejected query again.
+ self._encoding = None
+
application = get_wsgi_application()
+application.request_class = FhirWSGIRequest
diff --git a/requirements.txt b/requirements.txt
index 7bfa0b1fc..b9b67dd03 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -42,7 +42,7 @@ django-celery-beat==2.9.0
jsondiff==2.0.0
django-silk==5.5.0
azure-storage-blob==12.19.0
-fhir.resources==7.1.0
+fhir.resources[xml]==8.3.0
ijson==3.2.3
responses==0.25.0
pydantic==2.10.6