From 660d49a5d57487651a925ff327d5e5f9f6bcc20b Mon Sep 17 00:00:00 2001
From: Jonathan Payne Codes
A
). 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'}
+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):
+ """For each of `urls`, the ids of the Source rows among `rows` with that canonical URL: one query for them all."""
+ found = {url: [] for url in urls}
+ for url, source_id in rows.filter(canonical_url__in=urls).values_list('canonical_url', 'id'):
+ found[url].append(source_id)
+ return found
+
+
+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 mapped(side, value, sources):
+ """
+ A ConceptMap version holding a live mapping whose group source (side 'from') or target ('to') shows `value`, as
+ ConceptMapGroupField builds groups: retired mappings aren't read; a group's system is the from or to source's
+ canonical URL, or for one without, its OCL URL as a ConceptMap path (/orgs/{org}/ConceptMap/{id}/), or the
+ mapping's own URL when it names no OCL source. A search over many mappings is bounded by SEARCH_TIMEOUT_MS.
+ 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, listed first (sources_with), so the planner can read the
+ mappings by their source index. Only those the base shows the caller (viewable_rows) are listed: a canonical only
+ other sources have answers as one no source has, so how long the search takes doesn't depend on mappings the
+ caller isn't shown.
+ """
+ field = f'mapping__{side}_source'
+ shown = Q(**{f'{field}_id__in': sources}) | (Q(**{f'{field}_url': value}) & Q(**{f'{field}__isnull': True}))
+ 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')))
+ without_canonical = Q(**{f'{field}__isnull': True}) | Q(**{f'{field}__canonical_url__isnull': True}) | Q(
+ **{f'{field}__canonical_url': ''})
+ shown |= stored & without_canonical
+ return Exists(Mapping.sources.through.objects.filter(
+ source_id=OuterRef('id'), mapping__retired=False).filter(shown))
+
+
+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
+
+ 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).
+ """
+ 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 = sources_with(self.viewable_sources(), urls)
+ 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 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(**{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)])
+
+ def by_target(self, value):
+ """A group's target is the system named."""
+ return mapped('to', value, self.found[('sources', value)])
+
+
+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):
+ """
+ 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). Inside a transaction (tests) the timeout would outlive the search, so it isn't set there.
+ """
+ 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 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.
+ """
+ resource_type, resource_id = view.resource_type, view.kwargs[view.lookup_field]
+ namespace, user = namespace_of(view.kwargs), view.request.user
+ 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:
+ versions = view.filter_queryset_by_owner(versions.filter(mnemonic=resource_id))
+ version = view.request.query_params.get('version')
+ latest = not version
+ versions = versions.filter(version=version) if version else versions.filter(
+ retired=False).order_by('-created_at')
+
+ def shown(instance):
+ return instance and is_visible(instance, namespace, user) and has_release(instance, namespace, user)
+
+ instance = with_repository_access(with_repository_canonical(versions)).first()
+ if latest and instance and not shown(instance): # the latest is private: the latest public one, if shown
+ instance = with_repository_access(with_repository_canonical(
+ versions.filter(public_access__in=PUBLIC_ACCESS))).first()
+ if not shown(instance):
+ 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 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 is also tagged SUBSETTED. meta keeps what the serializer put there (lastUpdated).
+ """
+ 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'):
+ rep['meta'] = {**(rep.get('meta') or {}), 'tag': [SUBSETTED]}
+ return rep
diff --git a/core/fhir/tests_protocol.py b/core/fhir/tests_protocol.py
index ced0bf63e..5bc7cf4ad 100644
--- a/core/fhir/tests_protocol.py
+++ b/core/fhir/tests_protocol.py
@@ -56,7 +56,7 @@ def setUp(self):
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)
+ 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')
@@ -186,7 +186,8 @@ def test_xml_of_released_code_systems_is_in_fhir_element_order_and_json_is_uncha
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)
- self.assertEqual(resource.find(f'{FHIR}id').get('value'), 'rel_codes')
+ 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)
@@ -195,9 +196,11 @@ def test_xml_of_released_code_systems_is_in_fhir_element_order_and_json_is_uncha
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)
+ 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')
diff --git a/core/fhir/tests_resolve.py b/core/fhir/tests_resolve.py
index 637823f44..d697ccfda 100644
--- a/core/fhir/tests_resolve.py
+++ b/core/fhir/tests_resolve.py
@@ -269,6 +269,29 @@ def test_a_private_repository_that_resolves_hides_a_public_one(self):
self.assertUnresolved('CodeSystem', shared, kwargs={'org': 'FHIR-Fixtures'}, user=self.outsider)
self.assertEqual(self.resolve('CodeSystem', shared, kwargs={'org': 'HL7'}).id, public_v1.id)
+ def test_a_public_release_of_a_private_repository(self):
+ """
+ A repository's HEAD must be viewable for its releases to be: a release made public before its repository was
+ made private resolves only at its owner's base, for those who may view the owner's private repositories.
+ """
+ url = 'http://moh.example/fhir/CodeSystem/hiv'
+ moh = OrganizationFactory(mnemonic='MOH')
+ moh_member = UserProfileFactory(username='moh-member')
+ moh.members.add(moh_member)
+ source(moh, 'hiv', canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ release = source(moh, 'hiv', 'v1', released=True, canonical_url=url)
+ if self.registry:
+ GlobalURLRegistryFactory(url=url, namespace=moh.uri)
+
+ for user in (ANONYMOUS, self.outsider, moh_member, self.admin):
+ for reference in (url, f'{url}|v1', '/orgs/MOH/CodeSystem/hiv/'):
+ self.assertUnresolved('CodeSystem', reference, user=user)
+ for user in (ANONYMOUS, self.outsider):
+ self.assertUnresolved('CodeSystem', url, kwargs={'org': 'MOH'}, user=user)
+ for user in (moh_member, self.admin):
+ for reference in (url, f'{url}|v1', '/orgs/MOH/CodeSystem/hiv/'):
+ self.assertEqual(self.resolve('CodeSystem', reference, kwargs={'org': 'MOH'}, user=user).id, release.id)
+
def test_query_counts(self):
"""
A hot path: these counts may only fall. Without the registry: the toggle, the repository and its latest
diff --git a/core/fhir/tests_search.py b/core/fhir/tests_search.py
new file mode 100644
index 000000000..3d10ad2e2
--- /dev/null
+++ b/core/fhir/tests_search.py
@@ -0,0 +1,1127 @@
+"""
+core.fhir.search: FHIR search and read of CodeSystem, ValueSet and ConceptMap (SVCM ITI-95, ITI-96 and ITI-100) at the
+global base /fhir/ and at owner bases. Released versions only, never HEAD; public ones at the global base whoever asks;
+an opaque id per released version at the global base, the mnemonic at an owner base; SVCM's search parameters with
+:exact and :contains; _count, paging, the real total and a fullUrl on every entry; a CodeSystem read is complete up to
+the concept limit, else not-present. OpenConceptLab/ocl_online#451.
+"""
+import time
+from datetime import datetime, timezone as dt_timezone
+from urllib.parse import parse_qs, urlsplit
+
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
+from django.db import connection, OperationalError
+from django.test import TransactionTestCase
+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 OrganizationCollectionFactory, ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE, HEAD
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory, ConceptNameFactory, ConceptDescriptionFactory
+from core.fhir import resolve as fhir_resolve, search as fhir_search
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM, TooCostlyError
+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, UserSourceFactory
+from core.users.models import UserProfile
+from core.users.tests.factories import UserProfileFactory
+
+ICD10 = 'http://hl7.org/fhir/sid/icd-10'
+CIEL = 'https://CIELterminology.org'
+FIXTURE = 'https://openconceptlab.org/fhir-fixtures/CodeSystem/fixture-codes'
+ICD10_VS = 'http://sahra.tools/fhir/ValueSet/icd10-all'
+CIEL_VS = 'https://example.org/ValueSet/ciel-set'
+HIV = 'http://moh.example/fhir/CodeSystem/hiv'
+HIV_VS = 'http://moh.example/fhir/ValueSet/hiv'
+SUBSETTED = {'system': 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue', 'code': 'SUBSETTED'}
+HL7_OID = {'system': 'urn:ietf:rfc:3986', 'value': 'urn:oid:2.16.840.1.113883.6.90'}
+ACSN = {'text': 'Accession ID', 'coding': [{'system': 'http://hl7.org/fhir/v2/0203', 'code': 'ACSN'}]}
+
+
+def source(owner, mnemonic, version=HEAD, **kwargs):
+ if isinstance(owner, UserProfile):
+ return UserSourceFactory(user=owner, mnemonic=mnemonic, name=mnemonic, version=version, **kwargs)
+ return OrganizationSourceFactory(organization=owner, mnemonic=mnemonic, name=mnemonic, version=version, **kwargs)
+
+
+def collection(owner, mnemonic, version=HEAD, **kwargs):
+ return OrganizationCollectionFactory(
+ organization=owner, mnemonic=mnemonic, name=mnemonic, version=version, **kwargs)
+
+
+def at(year):
+ return datetime(year, 6, 15, 12, tzinfo=dt_timezone.utc)
+
+
+class SearchCorpusMixin:
+ """
+ ICD-10 shared by WHO (whose released version has no canonical of its own, as in production) and sehatapp (with a
+ newer unreleased version); CIEL with a retired release and a live one, with concepts and mappings; repositories
+ nothing visible comes from (nothing released, only a retired release, a HEAD flagged released); the private fixture
+ org with two releases; a user's repository; MOH's code system and value set, made private after a public release;
+ three value sets with stored expansions.
+ """
+ def setUp(self):
+ super().setUp()
+ self.client = APIClient()
+ self.admin = UserProfile.objects.get(username='ocladmin')
+ self.who = OrganizationFactory(mnemonic='WHO')
+ self.sehatapp = OrganizationFactory(mnemonic='sehatapp')
+ self.ciel = OrganizationFactory(mnemonic='CIEL')
+ self.misc = OrganizationFactory(mnemonic='misc')
+ self.fixtures = OrganizationFactory(mnemonic='FHIR-Fixtures', public_access=ACCESS_TYPE_NONE)
+ self.member = UserProfileFactory(username='fixture-member')
+ self.outsider = UserProfileFactory(username='outsider')
+ self.fixtures.members.add(self.member)
+ self.jdoe = UserProfileFactory(username='jdoe')
+
+ self.who_icd = source(self.who, 'ICD-10-WHO', canonical_url=ICD10, full_name='ICD-10 WHO Edition')
+ self.who_icd_2019 = source(
+ self.who, 'ICD-10-WHO', 'ICD102019', released=True, canonical_url=None, full_name='ICD-10 WHO Edition',
+ publisher='World Health Organization', identifier=[HL7_OID])
+ self.sehat_icd = source(self.sehatapp, 'ICD-10', canonical_url=ICD10, full_name='ICD-10 for Sehat')
+ self.sehat_icd_v1 = source(
+ self.sehatapp, 'ICD-10', 'v1.0', released=True, canonical_url=ICD10, full_name='ICD-10 for Sehat')
+ self.sehat_icd_v2 = source(self.sehatapp, 'ICD-10', 'v2.0', canonical_url=ICD10) # newest, unreleased
+
+ self.ciel_source = source(
+ self.ciel, 'CIEL', canonical_url=CIEL, full_name='Columbia International eHealth Laboratory',
+ description='Concepts for clinical care')
+ self.ciel_v1 = source(
+ self.ciel, 'CIEL', 'v1', released=True, retired=True, canonical_url=CIEL,
+ full_name='Columbia International eHealth Laboratory', description='Concepts for clinical care')
+ self.ciel_v2 = source(
+ self.ciel, 'CIEL', 'v2', released=True, canonical_url=CIEL,
+ full_name='Columbia International eHealth Laboratory', description='Concepts for clinical care')
+
+ # nothing visible comes from these
+ self.draft = source(self.misc, 'draft', canonical_url='https://example.org/draft')
+ self.draft_v1 = source(self.misc, 'draft', 'v1', canonical_url='https://example.org/draft')
+ self.gone = source(self.misc, 'gone', canonical_url='https://example.org/gone')
+ self.gone_v1 = source(
+ self.misc, 'gone', 'v1', released=True, retired=True, canonical_url='https://example.org/gone')
+ self.flagged = source(self.misc, 'flagged', released=True, canonical_url='https://example.org/flagged')
+
+ 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.fixture_v2 = source(
+ self.fixtures, 'fixture-codes', '2.0.0', released=True, canonical_url=FIXTURE,
+ public_access=ACCESS_TYPE_NONE)
+
+ self.jdoe_codes = source(self.jdoe, 'my-codes', canonical_url='https://example.org/my-codes')
+ self.jdoe_codes_v1 = source(
+ self.jdoe, 'my-codes', 'v1', released=True, canonical_url='https://example.org/my-codes')
+
+ # repositories made private after a public release: the release keeps its own public access, but its
+ # repository (HEAD) is private, so it's shown only at MOH's base, to MOH's members and staff
+ self.moh = OrganizationFactory(mnemonic='MOH')
+ self.moh_member = UserProfileFactory(username='moh-member')
+ self.moh.members.add(self.moh_member)
+ self.hiv = source(self.moh, 'hiv-codes', canonical_url=HIV, public_access=ACCESS_TYPE_NONE)
+ self.hiv_v1 = source(self.moh, 'hiv-codes', 'v1', released=True, canonical_url=None) # the repository's
+
+ # the value sets: sehatapp's draws on its ICD-10 v1.0, the CIEL one on CIEL's HEAD; the released version of
+ # the ICD-10 one has no canonical of its own
+ self.icd_vs = collection(self.sehatapp, 'icd10-all', canonical_url=ICD10_VS, full_name='All ICD-10 codes')
+ self.icd_vs_v1 = collection(
+ self.sehatapp, 'icd10-all', 'v1.0', released=True, canonical_url=None, full_name='All ICD-10 codes')
+ self.with_expansion(self.icd_vs_v1, explicit=[self.sehat_icd_v1])
+ self.ciel_vs = collection(self.ciel, 'ciel-set', canonical_url=CIEL_VS)
+ self.ciel_vs_v1 = collection(self.ciel, 'ciel-set', 'v1', released=True, canonical_url=CIEL_VS)
+ self.with_expansion(self.ciel_vs_v1, evaluated=[self.ciel_source])
+ self.ciel_vs_v2 = collection(self.ciel, 'ciel-set', 'v2', canonical_url=CIEL_VS) # unreleased
+ self.hiv_vs = collection(self.moh, 'hiv-set', canonical_url=HIV_VS, public_access=ACCESS_TYPE_NONE)
+ self.hiv_vs_v1 = collection(self.moh, 'hiv-set', 'v1', released=True, canonical_url=HIV_VS)
+ self.with_expansion(self.hiv_vs_v1, explicit=[self.ciel_v2])
+
+ @staticmethod
+ def with_expansion(collection_version, explicit=(), evaluated=()):
+ expansion = ExpansionFactory(collection_version=collection_version)
+ expansion.explicit_source_versions.set(explicit)
+ expansion.evaluated_source_versions.set(evaluated)
+ Collection.objects.filter(id=collection_version.id).update(expansion_uri=expansion.uri)
+
+ def get(self, path, user=None, **params):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client.get(path, params)
+
+ def read(self, path, user=None, **params):
+ response = self.get(path, user, **params)
+ self.assertEqual(response.status_code, 200, response.data)
+ return response.data
+
+ def search(self, resource_type, base='/fhir', user=None, **params):
+ response = self.get(f'{base}/{resource_type}/', user, **params)
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual((response.data['resourceType'], response.data['type']), ('Bundle', 'searchset'))
+ return response.data
+
+ @staticmethod
+ def keys(bundle):
+ return sorted(f"{entry['resource'].get('url')}|{entry['resource']['version']}"
+ for entry in bundle.get('entry', []))
+
+ @staticmethod
+ def ids(bundle):
+ return sorted(entry['resource']['id'] for entry in bundle.get('entry', []))
+
+ def assertOutcome(self, response, status, code, tx_issue_type): # pylint: disable=invalid-name
+ 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_issue_type}, issue['details']['coding'])
+ return issue
+
+ def assertNotFound(self, path, user=None, **params): # pylint: disable=invalid-name
+ response = self.get(path, user, **params)
+ self.assertOutcome(response, 404, 'not-found', 'not-found')
+
+
+class CodeSystemSearchTest(SearchCorpusMixin, OCLTestCase): # pylint: disable=too-many-public-methods
+ PUBLIC_RELEASES = sorted([
+ f'{CIEL}|v1', f'{CIEL}|v2', f'{ICD10}|ICD102019', f'{ICD10}|v1.0', 'https://example.org/my-codes|v1'])
+
+ def test_global_search_lists_every_released_public_version_with_an_opaque_id(self):
+ bundle = self.search('CodeSystem')
+
+ # never HEAD, an unreleased version, a repository with nothing (or only a retired release) released, or a
+ # private one; a retired release of a repository with a live one is listed
+ self.assertEqual(self.keys(bundle), self.PUBLIC_RELEASES)
+ self.assertEqual(bundle['total'], 5)
+ versions = [self.ciel_v1, self.ciel_v2, self.who_icd_2019, self.sehat_icd_v1, self.jdoe_codes_v1]
+ self.assertEqual(self.ids(bundle), sorted(str(version.id) for version in versions))
+ for entry in bundle['entry']:
+ self.assertEqual(entry['fullUrl'], f"http://testserver/fhir/CodeSystem/{entry['resource']['id']}")
+ self.assertEqual(entry['search'], {'mode': 'match'})
+ self.assertEqual(bundle['link'][0]['relation'], 'self')
+
+ def test_global_base_is_public_only_whoever_asks(self):
+ for user in (self.member, self.admin, self.jdoe):
+ self.assertEqual(self.keys(self.search('CodeSystem', user=user)), self.PUBLIC_RELEASES, user.username)
+
+ def test_a_version_with_its_own_canonical_is_found_by_that_one_only(self):
+ """The repository's (HEAD's) canonical stands in only for a version with none, as CanonicalUrlField shows it."""
+ old = 'https://old.example.org/ciel'
+ Source.objects.filter(id=self.ciel_v1.id).update(canonical_url=old)
+ ConceptFactory(parent=self.ciel_source, mnemonic='1427', sources=[self.ciel_v1, self.ciel_v2])
+
+ self.assertEqual(self.keys(self.search('CodeSystem', url=CIEL)), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', url=old)), [f'{old}|v1'])
+ self.assertEqual(self.keys(self.search('CodeSystem', system=old)), [f'{old}|v1'])
+ self.assertEqual(self.keys(self.search('CodeSystem', code=f'{CIEL}|1427')), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', code=f'{old}|1427')), [f'{old}|v1'])
+ Source.objects.filter(id=self.ciel_v1.id).update(canonical_url='') # an empty one is none
+ self.assertEqual(self.keys(self.search('CodeSystem', url=CIEL)), [f'{CIEL}|v1', f'{CIEL}|v2'])
+
+ def test_url_matches_the_version_canonical_or_its_repository_canonical(self):
+ bundle = self.search('CodeSystem', url=ICD10)
+
+ self.assertEqual(self.keys(bundle), [f'{ICD10}|ICD102019', f'{ICD10}|v1.0'])
+ who = next(entry['resource'] for entry in bundle['entry'] if entry['resource']['version'] == 'ICD102019')
+ self.assertEqual(who['url'], ICD10) # the repository's canonical, as the version has none
+ self.assertEqual(self.keys(self.search('CodeSystem', system=ICD10)), self.keys(bundle))
+
+ bundle = self.search('CodeSystem', url=ICD10, version='ICD102019')
+ self.assertEqual((self.keys(bundle), bundle['total']), ([f'{ICD10}|ICD102019'], 1))
+ self.assertEqual(self.search('CodeSystem', url='http://example.org/unknown')['total'], 0)
+ self.assertNotIn('entry', self.search('CodeSystem', url='http://example.org/unknown'))
+
+ def test_id_is_the_opaque_id_at_the_global_base_and_the_mnemonic_at_an_owner_base(self):
+ bundle = self.search('CodeSystem', _id=str(self.sehat_icd_v1.id))
+ self.assertEqual(self.keys(bundle), [f'{ICD10}|v1.0'])
+ for missing in ('ICD-10', str(self.sehat_icd.id), str(self.sehat_icd_v2.id), 'ocl-no-such-id'):
+ self.assertEqual(self.search('CodeSystem', _id=missing)['total'], 0, missing)
+
+ bundle = self.search('CodeSystem', base='/orgs/CIEL', _id='CIEL')
+ self.assertEqual(self.keys(bundle), [f'{CIEL}|v1', f'{CIEL}|v2'])
+ self.assertEqual(self.ids(bundle), ['CIEL', 'CIEL'])
+ self.assertEqual(self.search('CodeSystem', base='/orgs/CIEL', _id=str(self.ciel_v2.id))['total'], 0)
+
+ def test_string_parameters_and_their_modifiers(self):
+ # no modifier: starts with, ignoring case
+ self.assertEqual(self.keys(self.search('CodeSystem', title='icd-10')), [f'{ICD10}|ICD102019', f'{ICD10}|v1.0'])
+ self.assertEqual(self.keys(self.search('CodeSystem', name='ciel')), [f'{CIEL}|v1', f'{CIEL}|v2'])
+ self.assertEqual(self.search('CodeSystem', title='WHO')['total'], 0)
+ # :exact is the whole value, case included
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', **{'title:exact': 'ICD-10 WHO Edition'})), [f'{ICD10}|ICD102019'])
+ self.assertEqual(self.search('CodeSystem', **{'title:exact': 'icd-10 who edition'})['total'], 0)
+ self.assertEqual(self.search('CodeSystem', **{'title:exact': 'ICD-10'})['total'], 0)
+ # :contains anywhere, ignoring case
+ self.assertEqual(self.keys(self.search('CodeSystem', **{'title:contains': 'who'})), [f'{ICD10}|ICD102019'])
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', **{'description:contains': 'CLINICAL'})), [f'{CIEL}|v1', f'{CIEL}|v2'])
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', publisher='world health')), [f'{ICD10}|ICD102019'])
+ self.assertEqual(self.keys(self.search('CodeSystem', **{'name:exact': 'CIEL'})), [f'{CIEL}|v1', f'{CIEL}|v2'])
+
+ def test_strings_ignore_accents_except_exact(self):
+ Source.objects.filter(id=self.ciel_v2.id).update(full_name='Édition française')
+
+ self.assertEqual(self.keys(self.search('CodeSystem', title='edition')), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', title='ÉDITION')), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', **{'title:contains': 'francaise'})), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', **{'title:contains': 'FRANÇAISE'})), [f'{CIEL}|v2'])
+ self.assertEqual(self.keys(self.search('CodeSystem', **{'title:exact': 'Édition française'})), [f'{CIEL}|v2'])
+ self.assertEqual(self.search('CodeSystem', **{'title:exact': 'Edition francaise'})['total'], 0)
+ for wildcard in ('%', '_', 'É_ition'): # LIKE's wildcards are plain characters
+ self.assertEqual(self.search('CodeSystem', **{'title:contains': wildcard})['total'], 0, wildcard)
+
+ def test_a_comma_is_or_and_a_repeated_parameter_is_and(self):
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', version='v1,v1.0')),
+ sorted([f'{CIEL}|v1', f'{ICD10}|v1.0', 'https://example.org/my-codes|v1']))
+ response = self.client.get('/fhir/CodeSystem/?name=ci&name=ICD')
+ self.assertEqual(response.data['total'], 0)
+ response = self.client.get('/fhir/CodeSystem/?name=ci&name=CIEL')
+ self.assertEqual(self.keys(response.data), [f'{CIEL}|v1', f'{CIEL}|v2'])
+ # an escaped comma is part of the value
+ Source.objects.filter(id=self.ciel_v2.id).update(full_name='Columbia, International')
+ self.assertEqual(self.keys(self.search('CodeSystem', title=r'Columbia\, Int')), [f'{CIEL}|v2'])
+
+ def test_status(self):
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', status='active')),
+ sorted([f'{CIEL}|v2', f'{ICD10}|ICD102019', f'{ICD10}|v1.0', 'https://example.org/my-codes|v1']))
+ self.assertEqual(self.keys(self.search('CodeSystem', status='retired')), [f'{CIEL}|v1'])
+ self.assertEqual(self.search('CodeSystem', status='draft')['total'], 0)
+ self.assertEqual(self.search('CodeSystem', status='unknown')['total'], 0)
+ self.assertEqual(self.search('CodeSystem', status='retired,active')['total'], 5)
+
+ def test_identifier(self):
+ for token in ('urn:ietf:rfc:3986|urn:oid:2.16.840.1.113883.6.90', 'urn:oid:2.16.840.1.113883.6.90',
+ 'urn:ietf:rfc:3986|'):
+ self.assertEqual(self.keys(self.search('CodeSystem', identifier=token)), [f'{ICD10}|ICD102019'], token)
+ for token in ('urn:ietf:rfc:3986|urn:oid:1.2.3', 'http://example.org|urn:oid:2.16.840.1.113883.6.90'):
+ self.assertEqual(self.search('CodeSystem', identifier=token)['total'], 0, token)
+
+ # the OCL accession identifier every resource carries
+ bundle = self.search('CodeSystem', identifier='/orgs/sehatapp/CodeSystem/ICD-10/')
+ self.assertEqual(self.keys(bundle), [f'{ICD10}|v1.0'])
+ accession = bundle['entry'][0]['resource']['identifier'][0]
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', identifier=f"{accession['system']}|{accession['value']}")),
+ [f'{ICD10}|v1.0'])
+ self.assertEqual(self.search('CodeSystem', identifier='/orgs/sehatapp/ValueSet/ICD-10/')['total'], 0)
+ self.assertEqual(
+ self.search('CodeSystem', identifier='/orgs/FHIR-Fixtures/CodeSystem/fixture-codes/')['total'], 0)
+
+ def test_a_stored_accession_without_a_value_leaves_the_generated_one(self):
+ """IdentifierSerializer.find_ocl_identifier needs an accession value; without one the generated id shows."""
+ api = settings.API_BASE_URL
+ for stored in ([{'system': 'urn:external', 'type': ACSN}],
+ [{'system': 'urn:external', 'value': '', 'type': ACSN}],
+ [{'system': 'urn:external', 'value': None, 'type': ACSN}]):
+ Source.objects.filter(id=self.who_icd_2019.id).update(identifier=stored)
+ resource = self.search('CodeSystem', url=ICD10, version='ICD102019')['entry'][0]['resource']
+ self.assertIn({'system': api, 'value': '/orgs/WHO/CodeSystem/ICD-10-WHO/'},
+ [{'system': item.get('system'), 'value': item.get('value')}
+ for item in resource['identifier']], stored)
+ for token in (f'{api}|', '/orgs/WHO/CodeSystem/ICD-10-WHO/', f'{api}|/orgs/WHO/CodeSystem/ICD-10-WHO/'):
+ self.assertIn(f'{ICD10}|ICD102019', self.keys(self.search('CodeSystem', identifier=token)), token)
+
+ # with a value, the stored one is shown instead; a coding's missing code and system show ACSN's defaults
+ path = '/orgs/WHO/CodeSystem/ICD-10-WHO/'
+ for coding in (ACSN['coding'][0], {}, {'code': 'ACSN'}):
+ Source.objects.filter(id=self.who_icd_2019.id).update(
+ identifier=[{'system': 'urn:external', 'value': path, 'type': {'coding': [coding]}}])
+ self.assertNotIn(f'{ICD10}|ICD102019', self.keys(self.search('CodeSystem', identifier=f'{api}|')), coding)
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', identifier=f'urn:external|{path}')), [f'{ICD10}|ICD102019'])
+ # another type, or a coding whose code or system is null, is no accession
+ for coding in ({'code': 'MR'}, {'code': None}, {'code': 'ACSN', 'system': None}):
+ Source.objects.filter(id=self.who_icd_2019.id).update(
+ identifier=[{'system': 'urn:external', 'value': path, 'type': {'coding': [coding]}}])
+ self.assertIn(f'{ICD10}|ICD102019', self.keys(self.search('CodeSystem', identifier=f'{api}|')), coding)
+
+ def test_identifier_system_is_matched_on_the_same_identifier(self):
+ """
+ |value is an identifier without a system; system| is any value of that system. Both are checked on one
+ identifier, as it's shown: a stored identifier without a system key shows API_BASE_URL's, a null or empty one
+ none; the generated accession identifier (API_BASE_URL) shows when no accession identifier is stored.
+ """
+ oid = 'urn:oid:2.16.840.1.113883.6.90'
+ Source.objects.filter(id=self.ciel_v2.id).update(identifier=[{'system': None, 'value': oid}])
+ Source.objects.filter(id=self.sehat_icd_v1.id).update(identifier=[{'system': '', 'value': oid}])
+ Source.objects.filter(id=self.ciel_v1.id).update(identifier=[{'value': oid}, {'system': 'urn:x', 'value': 'y'}])
+ Source.objects.filter(id=self.jdoe_codes_v1.id).update(identifier=[
+ {'system': 'https://fhir.openconceptlab.org', 'value': '/users/jdoe/CodeSystem/my-codes/', 'type': ACSN}])
+ api = settings.API_BASE_URL
+
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', identifier=f'|{oid}')), sorted([f'{CIEL}|v2', f'{ICD10}|v1.0']))
+ self.assertEqual(self.keys(self.search('CodeSystem', identifier=oid)), sorted(
+ [f'{CIEL}|v1', f'{CIEL}|v2', f'{ICD10}|ICD102019', f'{ICD10}|v1.0']))
+ self.assertEqual(self.keys(self.search('CodeSystem', identifier=f'{api}|{oid}')), [f'{CIEL}|v1'])
+ self.assertEqual(self.search('CodeSystem', identifier=f'urn:x|{oid}')['total'], 0) # not the same identifier
+ self.assertEqual(self.search('CodeSystem', identifier='|/orgs/sehatapp/CodeSystem/ICD-10/')['total'], 0)
+ self.assertEqual(self.search('CodeSystem', identifier='|')['total'], 0)
+
+ # system|: the generated accession identifiers too, for a repository with no stored identifier at all
+ Source.objects.filter(id=self.who_icd_2019.id).update(identifier={})
+ bundle = self.search('CodeSystem', identifier=f'{api}|')
+ self.assertEqual(
+ self.keys(bundle), sorted([f'{CIEL}|v1', f'{CIEL}|v2', f'{ICD10}|ICD102019', f'{ICD10}|v1.0']))
+ for entry in bundle['entry']:
+ self.assertIn(api, [identifier.get('system') for identifier in entry['resource']['identifier']])
+ # a stored accession identifier is shown instead of the generated one
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', identifier='https://fhir.openconceptlab.org|')),
+ ['https://example.org/my-codes|v1'])
+ self.assertEqual(self.search('CodeSystem', identifier=f'{api}|/users/jdoe/CodeSystem/my-codes/')['total'], 0)
+ self.assertEqual(
+ self.keys(self.search('CodeSystem', identifier='/users/jdoe/CodeSystem/my-codes/')),
+ ['https://example.org/my-codes|v1'])
+ self.assertEqual(self.keys(self.search('CodeSystem', identifier='urn:x|')), [f'{CIEL}|v1'])
+
+ # identifiers that aren't a list aren't shown, so they aren't found either
+ Source.objects.filter(id=self.ciel_v1.id).update(identifier={'system': 'urn:x', 'value': 'y'})
+ self.assertEqual(self.search('CodeSystem', identifier='urn:x|')['total'], 0)
+
+ def test_language_system_only_needs_a_language(self):
+ Source.objects.filter(id=self.ciel_v2.id).update(default_locale='')
+ self.assertEqual(self.keys(self.search('CodeSystem', language='urn:ietf:bcp:47|')), sorted(
+ [f'{CIEL}|v1', f'{ICD10}|ICD102019', f'{ICD10}|v1.0', 'https://example.org/my-codes|v1']))
+
+ def test_code_needs_a_concept_the_read_lists(self):
+ """
+ code searches concept.code, so 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_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/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/value_sets/serializers.py b/core/value_sets/serializers.py
index f01a7722a..f26676d05 100644
--- a/core/value_sets/serializers.py
+++ b/core/value_sets/serializers.py
@@ -11,6 +11,7 @@
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.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin
from core.parameters.serializers import ParametersSerializer
from core.value_sets.constants import RESOURCE_TYPE
@@ -144,10 +145,11 @@ 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)
+ url = CanonicalUrlField(source='canonical_url', required=False)
title = CharField(source='full_name', required=False)
status = StatusField(source='*')
meta = SerializerMethodField()
@@ -255,6 +257,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)
diff --git a/core/value_sets/tests.py b/core/value_sets/tests.py
index f68e35f21..3aed58614 100644
--- a/core/value_sets/tests.py
+++ b/core/value_sets/tests.py
@@ -41,7 +41,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 +76,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)
diff --git a/core/value_sets/views.py b/core/value_sets/views.py
index 810d63b8c..84def3e0a 100644
--- a/core/value_sets/views.py
+++ b/core/value_sets/views.py
@@ -4,15 +4,14 @@
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.code_systems.views import CodeSystemValidateCodeView, get_viewable_repo
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.fhir.base import FhirViewMixin
+from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.sources.models import Source
from core.value_sets.serializers import ValueSetDetailSerializer, \
ValueSetExpansionParametersSerializer, ValueSetExpansionSerializer
@@ -20,30 +19,10 @@
logger = logging.getLogger('oclapi')
-class ValueSetListView(FhirViewMixin, 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
@@ -131,7 +110,8 @@ def get_queryset(self):
return queryset
-class ValueSetRetrieveUpdateView(FhirViewMixin, CollectionRetrieveUpdateDestroyView):
+class ValueSetRetrieveUpdateView(FhirViewMixin, FhirReadMixin, CollectionRetrieveUpdateDestroyView):
+ resource_type = 'ValueSet'
serializer_class = ValueSetDetailSerializer
def get_filter_params(self, default_version_to_head=True):
From d007d554e638ee0e637cf9ae707e2965b09553df Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 04/18] OpenConceptLab/ocl_online#451 | Missing FHIR operation
inputs get consistent, typed error outcomes
Required-input errors in $validate-code and $translate now use the shared
FHIR InvalidError. A missing required input is a 400 OperationOutcome naming
it, with terminology issue coding. CodeSystem $validate-code for an unknown
or unavailable system still answers result=false, with or without a code.
core/fhir/tests_operation_bounds.py checks FHIR JSON POSTs and the issue
coding, alongside bounded concept and mapping reads and unchanged answers
for list-only options.
It replaces master's copy of these bounds tests
(core/code_systems/tests_operation_bounds.py, which came from this work)
with core/fhir/tests_operation_bounds.py. Its $translate cases run by GET
here; the POST cases come back with the $translate work later in the series.
The operation input checks of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/code_systems/views.py | 7 +++----
core/concept_maps/views.py | 14 +++++++-------
.../tests_operation_bounds.py | 11 ++++++-----
core/value_sets/views.py | 7 ++++---
4 files changed, 20 insertions(+), 19 deletions(-)
rename core/{code_systems => fhir}/tests_operation_bounds.py (97%)
diff --git a/core/code_systems/views.py b/core/code_systems/views.py
index 99518162b..0666d76c0 100644
--- a/core/code_systems/views.py
+++ b/core/code_systems/views.py
@@ -10,7 +10,7 @@
from core.concepts.permissions import CanViewParentDictionaryAsGuest
from core.concepts.views import ConceptRetrieveUpdateDestroyView
from core.fhir.base import FhirViewMixin
-from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM
+from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM, InvalidError
from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.parameters.serializers import ParametersSerializer
from core.sources.models import Source
@@ -157,10 +157,9 @@ def get_queryset(self):
return queryset.none()
if not code:
- raise FhirParameterRequiredError('A code to validate is required.', expression='code')
+ raise InvalidError('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')
+ raise InvalidError('url is required with a code: the code system to validate it in.', expression='url')
queryset = queryset.filter(sources=source, mnemonic=code)
diff --git a/core/concept_maps/views.py b/core/concept_maps/views.py
index 9ce92b260..58c217eb0 100644
--- a/core/concept_maps/views.py
+++ b/core/concept_maps/views.py
@@ -12,6 +12,7 @@
from core.concept_maps.serializers import ConceptMapDetailSerializer, ConceptMapParametersSerializer
from core.concepts.permissions import CanAccessParentDictionary
from core.fhir.base import FhirViewMixin
+from core.fhir.outcomes import InvalidError
from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.mappings.constants import SAME_AS
from core.mappings.views import MappingListView
@@ -72,10 +73,9 @@ def get_permissions(self):
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.
+ # $translate answers from the mappings apply_filters names, and from nothing else. The mapping list's shortcuts
+ # (a search, a sort or a faceted filter, facets, search stats, a CSV export) would skip apply_filters, so each is
+ # off, and their parameters are ignored like any other this operation doesn't define.
allow_csv_export = False
def should_perform_es_search(self):
@@ -115,10 +115,10 @@ def apply_filters(self, queryset):
targetsystem = params.get('targetsystem')
# TODO: implement 'source' and 'target'
if not code:
- raise FhirParameterRequiredError('A code to translate is required.', expression='code')
+ raise InvalidError('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')
+ raise InvalidError('system is required with a code: the code system the code is from.',
+ expression='system')
if url:
queryset = queryset.filter(parent__canonical_url=url)
diff --git a/core/code_systems/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
similarity index 97%
rename from core/code_systems/tests_operation_bounds.py
rename to core/fhir/tests_operation_bounds.py
index f000b9ffe..7f9d32cca 100644
--- a/core/code_systems/tests_operation_bounds.py
+++ b/core/fhir/tests_operation_bounds.py
@@ -23,6 +23,7 @@
from core.common.tests import OCLTestCase
from core.common.views import BaseAPIView
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
@@ -103,7 +104,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 +113,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)
@@ -171,12 +173,11 @@ def test_value_set_validate_code_reads_only_the_named_code(self):
self.assert_result(response, display is None, msg)
def test_translate_reads_only_the_named_code(self):
- for method, url, code, system in product(
- ('GET', 'POST', 'POST valueString'), (None, SYSTEM), (None, 'c1'), (None, SYSTEM)):
- msg = (method, url, code, system)
+ for url, code, system in product((None, SYSTEM), (None, 'c1'), (None, SYSTEM)):
+ msg = (url, code, system)
with self.subTest(msg):
response, queries = self.request(
- '/fhir/ConceptMap/$translate', method, url=url, code=code, system=system)
+ '/fhir/ConceptMap/$translate', 'GET', url=url, code=code, system=system)
self.assertEqual(unbounded_reads(queries), [], msg)
if not code:
diff --git a/core/value_sets/views.py b/core/value_sets/views.py
index 84def3e0a..3ede02898 100644
--- a/core/value_sets/views.py
+++ b/core/value_sets/views.py
@@ -11,6 +11,7 @@
from core.common.constants import HEAD, LATEST
from core.concepts.models import Concept
from core.fhir.base import FhirViewMixin
+from core.fhir.outcomes import InvalidError
from core.fhir.search import FhirSearchMixin, FhirReadMixin
from core.sources.models import Source
from core.value_sets.serializers import ValueSetDetailSerializer, \
@@ -87,10 +88,10 @@ def get_queryset(self):
queryset = queryset.filter(expansion_set__collection_version__in=version_ids)
if not code:
- raise FhirParameterRequiredError('A code to validate is required.', expression='code')
+ raise InvalidError('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')
+ raise InvalidError('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:
From ca20fa52eb2d8e10da0a41ec27945643788190c0 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 05/18] OpenConceptLab/ocl_online#451 | FHIR lookup and
validation answer for the requested version and value set
$lookup and CodeSystem and ValueSet $validate-code now use shared operations
in core/fhir/ops and an R4 response adapter. GET and POST accept separate
inputs or a Coding, with instance routes for lookup and validation.
CodeableConcept validation accepts one coding.
Operations resolve the newest release the caller may view unless a version
is named. Without the URL registry, a canonical at the global base resolves
only among repositories the caller is shown, so a private copy never hides a
public one; the shared resolver in core/common/models.py gains optional
heads and head_shown arguments for this, and other callers keep their
defaults. Owner instance routes accept HEAD when explicitly named.
Conflicting systems or versions are reported, deleted concepts are absent,
and unavailable repositories receive the same answers as missing ones.
$lookup returns the resolved version, display, designations and inactive
property. Validation returns the code, system, version, display and issues;
display checks accept any concept name regardless of case, and retired codes
remain valid with a warning. ValueSet membership comes from its default
expansion, reporting a visible holding release: the explicit pin, then the
evaluated pin, then the newest.
Tests cover GET and POST, instance routes, encoded codes, display languages,
version conflicts, expansion membership and access with both registry
settings. Query checks keep reads focused on the named code as repositories
grow.
Work package WP4 + WP5 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/code_systems/tests.py | 165 ++++++----
core/code_systems/urls.py | 10 +-
core/code_systems/views.py | 203 +-----------
core/common/models.py | 22 +-
core/fhir/adapters/__init__.py | 0
core/fhir/adapters/r4.py | 57 ++++
core/fhir/base.py | 24 ++
core/fhir/ops/__init__.py | 0
core/fhir/ops/concepts.py | 197 +++++++++++
core/fhir/ops/lookup.py | 61 ++++
core/fhir/ops/validate_code.py | 265 +++++++++++++++
core/fhir/resolve.py | 88 ++++-
core/fhir/search.py | 27 +-
core/fhir/tests_operation_bounds.py | 38 +++
core/fhir/tests_ops_lookup.py | 449 +++++++++++++++++++++++++
core/fhir/tests_ops_validate_code.py | 474 +++++++++++++++++++++++++++
core/fhir/tests_protocol.py | 11 +-
core/fhir/tests_resolve.py | 50 ++-
core/parameters/serializers.py | 24 --
core/parameters/tests.py | 19 --
core/value_sets/tests.py | 115 ++++---
core/value_sets/views.py | 94 +-----
22 files changed, 1929 insertions(+), 464 deletions(-)
create mode 100644 core/fhir/adapters/__init__.py
create mode 100644 core/fhir/adapters/r4.py
create mode 100644 core/fhir/ops/__init__.py
create mode 100644 core/fhir/ops/concepts.py
create mode 100644 core/fhir/ops/lookup.py
create mode 100644 core/fhir/ops/validate_code.py
create mode 100644 core/fhir/tests_ops_lookup.py
create mode 100644 core/fhir/tests_ops_validate_code.py
diff --git a/core/code_systems/tests.py b/core/code_systems/tests.py
index d1fa73a8c..517ec5d39 100644
--- a/core/code_systems/tests.py
+++ b/core/code_systems/tests.py
@@ -6,7 +6,6 @@
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)
@@ -37,7 +42,7 @@ def setUp(self):
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, released=True)
Source.persist_new_version(self.user_source_v1, self.user_source.created_by)
@@ -45,7 +50,7 @@ def setUp(self):
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)
@@ -65,7 +70,7 @@ def test_public_can_view(self):
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/CodeSystem/?url=/some/url', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ 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']
@@ -76,7 +81,7 @@ def test_private_can_view(self):
self.assertEqual(resource['version'], 'v2')
response = self.client.get(
- f'/users/{self.user.mnemonic}/CodeSystem/?url=/some/url', HTTP_AUTHORIZATION='Token ' + self.user_token)
+ 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']
@@ -85,7 +90,7 @@ def test_private_can_view(self):
'/users/' + self.user.mnemonic + '/CodeSystem/' + self.user_source.mnemonic + '/'
)
self.assertEqual(resource['version'], 'v1')
- self.assertEqual(self.client.get(f'/users/{self.user.mnemonic}/CodeSystem/?url=/some/url').data['total'], 0)
+ 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/')
@@ -166,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(
@@ -188,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/'
@@ -218,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/'
@@ -227,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/'
@@ -241,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/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 0666d76c0..1c7d32a34 100644
--- a/core/code_systems/views.py
+++ b/core/code_systems/views.py
@@ -1,34 +1,17 @@
import logging
-from django.db.models import F
-from rest_framework.exceptions import ValidationError, NotAuthenticated, PermissionDenied
-
-from core.code_systems.serializers import CodeSystemDetailSerializer, \
- ValidateCodeParametersSerializer
+from core.code_systems.serializers import CodeSystemDetailSerializer
from core.common.constants import HEAD
-from core.common.serializers import IdentifierSerializer
-from core.concepts.permissions import CanViewParentDictionaryAsGuest
-from core.concepts.views import ConceptRetrieveUpdateDestroyView
-from core.fhir.base import FhirViewMixin
-from core.fhir.outcomes import TX_ISSUE_TYPE_SYSTEM, InvalidError
+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.parameters.serializers import ParametersSerializer
-from core.sources.models import Source
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(FhirViewMixin, FhirSearchMixin, SourceListView):
resource_type = 'CodeSystem'
serializer_class = CodeSystemDetailSerializer
@@ -40,174 +23,18 @@ 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": {"coding": [{"system": TX_ISSUE_TYPE_SYSTEM, "code": "not-found"}], "text": message},
- }
- ],
- }
- )
-
-
-class CodeSystemListLookupView(FhirViewMixin, 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(FhirViewMixin, 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 InvalidError('A code to validate is required.', expression='code')
- if not source:
- raise InvalidError('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(FhirViewMixin, FhirReadMixin, SourceRetrieveUpdateDestroyView):
diff --git a/core/common/models.py b/core/common/models.py
index abb1b1eab..e61e71111 100644
--- a/core/common/models.py
+++ b/core/common/models.py
@@ -1539,8 +1539,8 @@ def resolve_expression_to_version(cls, expression, namespace=None):
return instance, resolved_registry_entry
@classmethod
- def resolve_reference_expression( # pylint: disable=too-many-arguments
- cls, url, namespace=None, version=None, repo_type=None, allow_head=True):
+ 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:
@@ -1574,6 +1574,10 @@ def resolve_reference_expression( # pylint: disable=too-many-arguments
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)
@@ -1599,11 +1603,13 @@ def resolve_reference_expression( # pylint: disable=too-many-arguments
instance = registry_entry.find_repo(repo_type)
return cls.resolve_repo(
- instance, version, is_canonical, resolution_url, repo_type, allow_head), registry_entry
+ 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:
@@ -1611,15 +1617,17 @@ def resolve_reference_expression( # pylint: disable=too-many-arguments
from core.repos.models import Repository
return cls.resolve_repo(
- Repository.get(criteria, repo_type), version, is_canonical, resolution_url, repo_type, allow_head
- ), registry_entry
+ Repository.get(criteria, repo_type), version, is_canonical, resolution_url, repo_type, allow_head,
+ head_shown), registry_entry
@classmethod
def resolve_repo( # pylint: disable=too-many-arguments
- cls, instance, version, is_canonical, resolution_url, repo_type=None, allow_head=True):
+ cls, instance, version, is_canonical, resolution_url, repo_type=None, allow_head=True, head_shown=None):
if instance:
head = instance if repo_type and instance.is_head else None
- if version:
+ 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:
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..cf4027b99
--- /dev/null
+++ b/core/fhir/adapters/r4.py
@@ -0,0 +1,57 @@
+"""
+The FHIR R4 wire form of the version-neutral 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.
+"""
+
+
+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]}
+
+
+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),
+ ])
diff --git a/core/fhir/base.py b/core/fhir/base.py
index fc17e72d1..36c7fe759 100644
--- a/core/fhir/base.py
+++ b/core/fhir/base.py
@@ -27,12 +27,15 @@
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')
@@ -564,3 +567,24 @@ def get_fhir_parameters(self):
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/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..1fecc3359
--- /dev/null
+++ b/core/fhir/ops/concepts.py
@@ -0,0 +1,197 @@
+"""
+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); or, at an owner base with HEAD
+ named, HEAD through core.fhir.resolve, with its visibility rule (design D6). A global id names a release, so a
+ HEAD 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:
+ return read_version(resource_type, instance_id, version, kwargs, user)
+ try:
+ return resolve(resource_type, f'{namespace}{REPO_PATHS[resource_type]}/{instance_id}/', HEAD, 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/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/validate_code.py b/core/fhir/ops/validate_code.py
new file mode 100644
index 000000000..3f85a8687
--- /dev/null
+++ b/core/fhir/ops/validate_code.py
@@ -0,0 +1,265 @@
+"""
+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 release the caller is shown that holds the concept: the expansion's explicit version, else the one
+ it evaluated, else the newest; 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 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: the expansion defines the value set. The version answered is a
+ release the caller is shown that holds the concept: the one the expansion pins (explicit), else the one it
+ evaluated, else the newest; None without one, never HEAD or an unreleased version.
+ 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
+ concept_ids = list(Concept.objects.filter(
+ code_q('mnemonic', coding.code), system_q('parent', coding.system),
+ Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('parent_id'))), is_active=True).values_list(
+ 'id', flat=True))
+ if concept_ids:
+ concept_ids = list(Expansion.concepts.through.objects.filter(
+ expansion__uri=value_set.expansion_uri, concept_id__in=concept_ids).values_list('concept_id', flat=True))
+ if not concept_ids:
+ return None
+ rows = Concept.sources.through.objects.filter(
+ Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('source_id'))), concept_id__in=concept_ids,
+ source__is_active=True)
+ if coding.version:
+ rows = rows.filter(source__version=coding.version)
+ def pinned(versions):
+ return Exists(versions.through.objects.filter(
+ expansion__uri=value_set.expansion_uri, source_id=OuterRef('source_id')))
+ release = Q(source__released=True, source__retired=False) & ~Q(source__version=HEAD)
+ row = rows.annotate(
+ release=Case(When(release, then=Value(1)), default=Value(0), output_field=IntegerField()),
+ explicit=pinned(Expansion.explicit_source_versions), evaluated=pinned(Expansion.evaluated_source_versions),
+ ).order_by('-release', '-explicit', '-evaluated', '-source__created_at', 'concept_id').values_list(
+ 'concept_id', 'source__version', 'release').first()
+ if not row:
+ return None
+ concept = Concept.objects.filter(id=row[0]).select_related('parent').prefetch_related('names').first()
+ return concept, row[1] if row[2] 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/resolve.py b/core/fhir/resolve.py
index 075446c20..6a743addf 100644
--- a/core/fhir/resolve.py
+++ b/core/fhir/resolve.py
@@ -5,11 +5,15 @@
- 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), HEAD included. With none named, the
- latest released version, never HEAD. A repository with nothing released isn't visible, whatever version is named.
+- 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. 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.
- 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. A repository the caller can't see is not found, and
- no other repository is tried.
+ 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.
"""
@@ -19,7 +23,7 @@
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.utils import normalize_public_access
+from core.common.utils import normalize_public_access, to_parent_uri
from core.fhir.outcomes import NotFoundError
from core.sources.models import Source
@@ -60,21 +64,81 @@ def resolve(resource_type, url, version=None, kwargs=None, user=None, expression
"""
url = url or ''
lookup_url, _, piped_version = url.partition('|')
- error = UnresolvedError(resource_type, lookup_url, version or piped_version or None, expression)
+ 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)
- if not lookup_url or '|' in piped_version or (
- namespace and not SourceContainerMixin.get_object_from_namespace(namespace)):
+ # 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
- repo_type = REPO_TYPES[resource_type]
- instance, _ = repo_type.resolve_reference_expression(
- to_ocl_path(url), namespace, version, repo_type=repo_type, allow_head=False)
+ instance = shared_resolution(REPO_TYPES[resource_type], url, version, 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
- if not instance.id or not is_visible(instance, namespace, user) or not has_release(instance, namespace, user):
+ if not instance.id or not is_shown(instance, namespace, user):
raise error
return instance
+def shared_resolution(repo_type, url, version, namespace, user):
+ """
+ The shared resolver's answer, 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, _ = 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
+
+
+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.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
diff --git a/core/fhir/search.py b/core/fhir/search.py
index cf697229c..0c5f4dce5 100644
--- a/core/fhir/search.py
+++ b/core/fhir/search.py
@@ -52,7 +52,7 @@
from core.fhir import 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_visible, has_release,
+ 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.sources.models import Source
@@ -564,27 +564,34 @@ def read(view):
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.
"""
- resource_type, resource_id = view.resource_type, view.kwargs[view.lookup_field]
- namespace, user = namespace_of(view.kwargs), view.request.user
+ 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:
- versions = view.filter_queryset_by_owner(versions.filter(mnemonic=resource_id))
- version = view.request.query_params.get('version')
+ 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')
- def shown(instance):
- return instance and is_visible(instance, namespace, user) and has_release(instance, namespace, user)
-
instance = with_repository_access(with_repository_canonical(versions)).first()
- if latest and instance and not shown(instance): # the latest is private: the latest public one, if shown
+ 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 shown(instance):
+ if not instance or not is_shown(instance, namespace, user):
raise outcomes.NotFoundError(f'{resource_type}/{resource_id} not found')
return instance
diff --git a/core/fhir/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
index 7f9d32cca..b780322ea 100644
--- a/core/fhir/tests_operation_bounds.py
+++ b/core/fhir/tests_operation_bounds.py
@@ -22,6 +22,7 @@
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
@@ -29,6 +30,7 @@
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'
@@ -70,6 +72,7 @@ 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)
@@ -91,6 +94,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):
@@ -196,6 +201,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 = [
diff --git a/core/fhir/tests_ops_lookup.py b/core/fhir/tests_ops_lookup.py
new file mode 100644
index 000000000..70225493f
--- /dev/null
+++ b/core/fhir/tests_ops_lookup.py
@@ -0,0 +1,449 @@
+"""
+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_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..b7d3860e0
--- /dev/null
+++ b/core/fhir/tests_ops_validate_code.py
@@ -0,0 +1,474 @@
+"""
+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.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'
+
+
+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_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')
+
+
+class ValidateCodeWithoutRegistryTest(ValidateCodeCases, OCLTestCase):
+ registry = False
+
+
+class ValidateCodeWithRegistryTest(ValidateCodeCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_protocol.py b/core/fhir/tests_protocol.py
index 5bc7cf4ad..56389cedf 100644
--- a/core/fhir/tests_protocol.py
+++ b/core/fhir/tests_protocol.py
@@ -13,6 +13,7 @@
from lxml import etree
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
@@ -23,6 +24,7 @@
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
FHIR = '{http://hl7.org/fhir}'
@@ -48,6 +50,7 @@ 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(
@@ -61,7 +64,8 @@ def setUp(self):
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)
+ 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()
@@ -302,7 +306,7 @@ def test_serializer_outcomes_are_normalized_in_reads_and_bundles(self):
def test_errors_are_outcomes_with_a_tx_issue_type(self):
cases = [
- ('/fhir/CodeSystem/$lookup?system=http://example.org/unknown&code=1', 400),
+ ('/fhir/CodeSystem/$lookup?system=http://example.org/unknown&code=1', 422),
('/fhir/CodeSystem/?unsupported_parameter=1', 400),
('/fhir/CodeSystem/no-such-code-system/', 404),
('/fhir/Patient/', 404),
@@ -324,7 +328,8 @@ def test_an_error_returned_rather_than_raised_is_an_outcome(self):
def test_a_crash_outside_the_view_handler_is_a_4xx_outcome(self):
client = APIClient(raise_request_exception=False)
- with self.assertLogs('django.request', level='ERROR'):
+ with self.assertLogs('django.request', level='ERROR'), patch.object(
+ CodeSystemValidateCodeView, 'delete', create=True, new=lambda *args, **kwargs: None):
response = client.delete('/fhir/CodeSystem/$validate-code') # the view returns None: a 500 in Django
self.assertEqual(response.status_code, 422)
diff --git a/core/fhir/tests_resolve.py b/core/fhir/tests_resolve.py
index d697ccfda..49cf91527 100644
--- a/core/fhir/tests_resolve.py
+++ b/core/fhir/tests_resolve.py
@@ -246,11 +246,11 @@ def test_another_owners_canonical_at_an_owner_base(self):
else:
self.assertUnresolved('CodeSystem', CIEL, kwargs={'org': 'sehatapp'})
- def test_a_private_repository_that_resolves_hides_a_public_one(self):
+ def test_a_private_copy_of_a_canonical_and_a_public_one(self):
"""
- Access is checked after resolving, and nothing else is tried: when the private copy of a shared canonical
- resolves (the lower id without the registry, the global entry's namespace with it), the public copy is not
- found at the global base, while each owner base still finds its own.
+ Without the registry, the global base matches a canonical only among the repositories it shows, so the private
+ copy of a shared canonical (the lower id) never hides the public one. A registry entry is the curated answer:
+ when it names the private copy's namespace, nothing else is tried. Each owner base still finds its own.
"""
shared = 'http://example.org/shared-codes'
private = source(self.fixtures, 'shared', canonical_url=shared, public_access=ACCESS_TYPE_NONE)
@@ -263,7 +263,11 @@ def test_a_private_repository_that_resolves_hides_a_public_one(self):
GlobalURLRegistryFactory(url=shared, namespace=self.fixtures.uri)
for user in (ANONYMOUS, self.member, self.admin):
- self.assertUnresolved('CodeSystem', shared, user=user)
+ if self.registry:
+ self.assertUnresolved('CodeSystem', shared, user=user)
+ else:
+ self.assertEqual(self.resolve('CodeSystem', shared, user=user).id, public_v1.id)
+ self.assertEqual(self.resolve('CodeSystem', shared, 'v1', user=user).id, public_v1.id)
self.assertEqual(
self.resolve('CodeSystem', shared, kwargs={'org': 'FHIR-Fixtures'}, user=self.member).id, private_v1.id)
self.assertUnresolved('CodeSystem', shared, kwargs={'org': 'FHIR-Fixtures'}, user=self.outsider)
@@ -292,6 +296,38 @@ def test_a_public_release_of_a_private_repository(self):
for reference in (url, f'{url}|v1', '/orgs/MOH/CodeSystem/hiv/'):
self.assertEqual(self.resolve('CodeSystem', reference, kwargs={'org': 'MOH'}, user=user).id, release.id)
+ def test_the_default_version_is_the_newest_release_the_caller_is_shown(self):
+ """
+ A newest release that is private doesn't hide an older public one at the global base, or from those who may
+ not view the owner's private repositories at its base; those who may get the newest. A named one is as named.
+ """
+ url = 'http://example.org/newest-private'
+ source(self.hl7, 'newest', canonical_url=url)
+ public_v1 = source(self.hl7, 'newest', 'v1', released=True, canonical_url=url)
+ private_v2 = source(self.hl7, 'newest', 'v2', released=True, canonical_url=url, public_access=ACCESS_TYPE_NONE)
+ if self.registry:
+ GlobalURLRegistryFactory(url=url, namespace=self.hl7.uri)
+ for kwargs, user in ((None, ANONYMOUS), (None, self.admin), ({'org': 'HL7'}, self.outsider)):
+ self.assertEqual(self.resolve('CodeSystem', url, kwargs=kwargs, user=user).id, public_v1.id)
+ self.assertUnresolved('CodeSystem', url, 'v2', kwargs, user)
+ self.assertEqual(self.resolve('CodeSystem', url, kwargs={'org': 'HL7'}, user=self.admin).id, private_v2.id)
+
+ def test_a_version_in_an_ocl_path_is_a_version_named(self):
+ """
+ /orgs/{org}/sources/{id}/{version}/ names that version, so a private one answers as a missing one: it never
+ falls back to the newest public release, as a request naming no version does (Codex F1.1).
+ """
+ source(self.hl7, 'newest')
+ public_v1 = source(self.hl7, 'newest', 'v1', released=True)
+ private_v2 = source(self.hl7, 'newest', 'v2', released=True, public_access=ACCESS_TYPE_NONE)
+ for path in ('/orgs/HL7/sources/newest/v2/', '/orgs/HL7/CodeSystem/newest/v2/',
+ '/orgs/HL7/sources/newest/v2/concepts/c1/'):
+ error = self.assertUnresolved('CodeSystem', path)
+ self.assertEqual(error.version, 'v2')
+ self.assertEqual(self.resolve('CodeSystem', path, kwargs={'org': 'HL7'}, user=self.admin).id, private_v2.id)
+ self.assertEqual(self.resolve('CodeSystem', '/orgs/HL7/CodeSystem/newest/v1/').id, public_v1.id)
+ self.assertEqual(self.resolve('CodeSystem', '/orgs/HL7/CodeSystem/newest/').id, public_v1.id)
+
def test_query_counts(self):
"""
A hot path: these counts may only fall. Without the registry: the toggle, the repository and its latest
@@ -304,7 +340,9 @@ def test_query_counts(self):
(3 + extra, ('CodeSystem', ICD10)),
(3 + extra, ('CodeSystem', ICD10, 'ICD102019')),
(4 + extra, ('ValueSet', PBD, None, {'org': 'padam'})),
- (6 + extra, ('CodeSystem', FIXTURE, None, {'org': 'FHIR-Fixtures'}, self.member)),
+ # the caller's right to the owner's private repositories is checked once, on HEAD; the registry finds
+ # HEAD through its owner, so its organization is already known
+ (6 + extra - bool(self.registry), ('CodeSystem', FIXTURE, None, {'org': 'FHIR-Fixtures'}, self.member)),
):
with self.subTest(args=args), self.assertNumQueries(count):
self.resolve(*args)
diff --git a/core/parameters/serializers.py b/core/parameters/serializers.py
index 9275076cb..97da0cc34 100644
--- a/core/parameters/serializers.py
+++ b/core/parameters/serializers.py
@@ -1,7 +1,6 @@
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
@@ -73,26 +72,3 @@ def to_internal_value(self, data):
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
index d83d4ccc9..8dfdec4b9 100644
--- a/core/parameters/tests.py
+++ b/core/parameters/tests.py
@@ -1,7 +1,5 @@
from core.common.tests import OCLTestCase
-from core.concepts.tests.factories import ConceptNameFactory, ConceptFactory
from core.parameters.serializers import ParametersSerializer
-from core.sources.tests.factories import OrganizationSourceFactory
class ParametersTest(OCLTestCase):
@@ -19,20 +17,3 @@ def test_parse_query_params(self):
self.assertIsInstance(serializer, self.CustomParametersSerializer)
self.assertEqual(
serializer.validated_data, {'parameters': {'url': 'https://openconceptlab.org', 'code': 'testCode'}})
-
- def test_from_concept(self):
- source = OrganizationSourceFactory(default_locale='fr', supported_locales=['fr', 'ti'])
- ch_locale = ConceptNameFactory.build(locale_preferred=True, locale='ch')
- en_locale = ConceptNameFactory.build(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.display_name)
diff --git a/core/value_sets/tests.py b/core/value_sets/tests.py
index 3aed58614..d9e09d4e3 100644
--- a/core/value_sets/tests.py
+++ b/core/value_sets/tests.py
@@ -5,10 +5,12 @@
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')
@@ -432,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(
@@ -444,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/'
@@ -488,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/'
@@ -511,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/',
@@ -554,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:
@@ -594,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'
@@ -613,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]:
@@ -627,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):
diff --git a/core/value_sets/views.py b/core/value_sets/views.py
index 3ede02898..5d724ec4f 100644
--- a/core/value_sets/views.py
+++ b/core/value_sets/views.py
@@ -4,16 +4,13 @@
from rest_framework import status
from rest_framework.response import Response
-from core.code_systems.views import CodeSystemValidateCodeView, get_viewable_repo
-from core.collections.models import Collection
from core.collections.views import CollectionListView, CollectionRetrieveUpdateDestroyView, \
CollectionVersionExpansionsView
-from core.common.constants import HEAD, LATEST
-from core.concepts.models import Concept
-from core.fhir.base import FhirViewMixin
-from core.fhir.outcomes import InvalidError
+from core.common.constants import HEAD
+from core.fhir.adapters import r4
+from core.fhir.base import FhirViewMixin, FhirOperationView
+from core.fhir.ops.validate_code import validate_value_set
from core.fhir.search import FhirSearchMixin, FhirReadMixin
-from core.sources.models import Source
from core.value_sets.serializers import ValueSetDetailSerializer, \
ValueSetExpansionParametersSerializer, ValueSetExpansionSerializer
@@ -31,84 +28,11 @@ 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
-
- 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 InvalidError('A code to validate is required.', expression='code')
- if not system:
- raise InvalidError('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 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)
class ValueSetRetrieveUpdateView(FhirViewMixin, FhirReadMixin, CollectionRetrieveUpdateDestroyView):
From 8cddccff997f39639abd613299bde87d6b47edb1 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 06/18] OpenConceptLab/ocl_online#451 | FHIR translation
returns the requested map's live mappings and R4 equivalences
$translate now reads the resolved ConceptMap version's own live mappings
through core/fhir/ops/translate.py. GET takes code and system, or a
system|code coding token; a POSTed Parameters also takes a Coding or a
CodeableConcept. url and conceptMapVersion select the map. Without url, a
forward translation uses the input system's own map, and a reverse one uses
targetsystem's (with no targetsystem there is no map).
Map types and stored relationship spellings become R4 equivalences. Reverse
translation swaps direction and wider or narrower meaning; code-system
versions and targetsystem narrow the matches. The input code must belong to
its source scope, or target scope in reverse, with ValueSet paths resolved
as value sets.
Translation, ConceptMap reads and system searches name mapping sides
consistently: a visible source's canonical URL, otherwise the mapping's
stored URL. Deleted or retired mappings are omitted, each match names its
map, and results list at most 1,000 matches within the search time bound.
Tests cover versions, aliases, scopes, reverse equivalences, GET and POST,
JSON and XML, access rules and bounded queries. ConceptMap reads group
repeated source codes together, and writes store equivalences as the
corresponding map types.
Work package WP6 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/concept_maps/constants.py | 42 ++
core/concept_maps/serializers.py | 118 ++-
core/concept_maps/tests.py | 316 +++-----
core/concept_maps/views.py | 157 +---
core/fhir/adapters/r4.py | 35 +-
core/fhir/ops/translate.py | 386 ++++++++++
core/fhir/search.py | 35 +-
core/fhir/tests_operation_bounds.py | 7 +-
core/fhir/tests_translate.py | 1054 +++++++++++++++++++++++++++
9 files changed, 1695 insertions(+), 455 deletions(-)
create mode 100644 core/fhir/ops/translate.py
create mode 100644 core/fhir/tests_translate.py
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 53884fbc7..2f29fba5e 100644
--- a/core/concept_maps/serializers.py
+++ b/core/concept_maps/serializers.py
@@ -6,14 +6,16 @@
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.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.constants import SAME_AS
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
@@ -21,14 +23,18 @@
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'),
@@ -36,59 +42,43 @@ 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
- mappings = value.get_mappings_queryset().filter(retired=False).select_related(
- 'from_source', 'to_source').order_by('id')[:1000]
- 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 = 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')))[:1000]
+ groups, elements = {}, {}
+ for row in rows:
+ 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()]
@@ -247,27 +237,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 83ded029f..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
@@ -91,10 +89,9 @@ def test_public_can_view(self):
'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
@@ -524,75 +521,85 @@ def test_put_concept_map_overwriting_version(self):
[(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:
@@ -618,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,
@@ -638,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': [
@@ -654,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 58c217eb0..acdb8fb8f 100644
--- a/core/concept_maps/views.py
+++ b/core/concept_maps/views.py
@@ -1,21 +1,14 @@
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.common.constants import HEAD
-from core.common.permissions import CanViewConceptDictionary, HasOwnership
-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.fhir.base import FhirViewMixin
-from core.fhir.outcomes import InvalidError
+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.mappings.constants import SAME_AS
-from core.mappings.views import MappingListView
from core.sources.views import SourceListView, SourceRetrieveUpdateDestroyView
logger = logging.getLogger('oclapi')
@@ -29,7 +22,7 @@ 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(FhirViewMixin, FhirReadMixin, SourceRetrieveUpdateDestroyView):
@@ -61,138 +54,10 @@ def get_object(self, queryset=None):
return None
def get_detail_serializer(self, obj):
- return ConceptMapDetailSerializer(obj)
+ return ConceptMapDetailSerializer(obj, context=self.get_serializer_context())
-class ConceptMapTranslateView(FhirViewMixin, MappingListView):
- serializer_class = ConceptMapParametersSerializer
-
- def get_permissions(self):
- return [CanAccessParentDictionary(), CanViewConceptDictionary()]
-
- def get_serializer_class(self):
- return ConceptMapParametersSerializer
-
- # $translate answers from the mappings apply_filters names, and from nothing else. The mapping list's shortcuts
- # (a search, a sort or a faceted filter, facets, search stats, a CSV export) would skip apply_filters, so each is
- # off, and 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
-
- 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 InvalidError('A code to translate is required.', expression='code')
- if not system:
- raise InvalidError('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/adapters/r4.py b/core/fhir/adapters/r4.py
index cf4027b99..4304ae2db 100644
--- a/core/fhir/adapters/r4.py
+++ b/core/fhir/adapters/r4.py
@@ -1,11 +1,25 @@
"""
-The FHIR R4 wire form of the version-neutral answers core.fhir.ops build, as R4 Parameters resources.
+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)
+
+# 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):
@@ -55,3 +69,22 @@ def validate_code_parameters(answer):
(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}
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/search.py b/core/fhir/search.py
index 0c5f4dce5..bad2b5473 100644
--- a/core/fhir/search.py
+++ b/core/fhir/search.py
@@ -269,30 +269,29 @@ def held_identifier(system, value):
'identifier', f'lax $[*] ? ({" && ".join(conditions)})', variables)
-def mapped(side, value, sources):
+def mapped(side, value, sources, namespace, user):
"""
A ConceptMap version holding a live mapping whose group source (side 'from') or target ('to') shows `value`, as
- ConceptMapGroupField builds groups: retired mappings aren't read; a group's system is the from or to source's
- canonical URL, or for one without, its OCL URL as a ConceptMap path (/orgs/{org}/ConceptMap/{id}/), or the
- mapping's own URL when it names no OCL source. A search over many mappings is bounded by SEARCH_TIMEOUT_MS.
+ 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}/). A search over many mappings is bounded by SEARCH_TIMEOUT_MS.
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, listed first (sources_with), so the planner can read the
- mappings by their source index. Only those the base shows the caller (viewable_rows) are listed: a canonical only
- other sources have answers as one no source has, so how long the search takes doesn't depend on mappings the
- caller isn't shown.
+ mappings by their source index. Only those the base shows the caller (viewable_rows) are listed, and 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, and how long the search takes doesn't depend on it.
"""
field = f'mapping__{side}_source'
- shown = Q(**{f'{field}_id__in': sources}) | (Q(**{f'{field}_url': value}) & Q(**{f'{field}__isnull': True}))
+ unnamed = ~Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef(f'{field}_id'), canonical_url__gt=''))
+ shown = Q(**{f'{field}_id__in': sources}) | (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')))
- without_canonical = Q(**{f'{field}__isnull': True}) | Q(**{f'{field}__canonical_url__isnull': True}) | Q(
- **{f'{field}__canonical_url': ''})
- shown |= stored & without_canonical
+ shown |= stored & unnamed
return Exists(Mapping.sources.through.objects.filter(
- source_id=OuterRef('id'), mapping__retired=False).filter(shown))
+ source_id=OuterRef('id'), mapping__retired=False, mapping__is_active=True).filter(shown))
class Search:
@@ -485,11 +484,11 @@ def by_reference(self, value):
def by_source(self, value):
"""A group's source is the system named."""
- return mapped('from', value, self.found[('sources', value)])
+ return mapped('from', value, self.found[('sources', 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)])
+ return mapped('to', value, self.found[('sources', value)], self.namespace, self.user)
def page_size(query):
@@ -501,12 +500,14 @@ def page_size(query):
return min(int(value), MAX_COUNT)
-def within_search_timeout(build):
+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). Inside a transaction (tests) the timeout would outlive the search, so it isn't set there.
+ 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
@@ -528,7 +529,7 @@ def within_time_left(execute, sql, params, many, context):
raise too_costly() from ex
-def too_costly():
+def search_too_costly():
return outcomes.TooCostlyError(
'The search would take too long to answer. Narrow it, for example with url, _id or version.')
diff --git a/core/fhir/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
index b780322ea..005100034 100644
--- a/core/fhir/tests_operation_bounds.py
+++ b/core/fhir/tests_operation_bounds.py
@@ -178,11 +178,12 @@ def test_value_set_validate_code_reads_only_the_named_code(self):
self.assert_result(response, display is None, msg)
def test_translate_reads_only_the_named_code(self):
- for url, code, system in product((None, SYSTEM), (None, 'c1'), (None, SYSTEM)):
- msg = (url, code, system)
+ for method, url, code, system in product(
+ ('GET', 'POST', 'POST valueString'), (None, SYSTEM), (None, 'c1'), (None, SYSTEM)):
+ msg = (method, url, code, system)
with self.subTest(msg):
response, queries = self.request(
- '/fhir/ConceptMap/$translate', 'GET', url=url, code=code, system=system)
+ '/fhir/ConceptMap/$translate', method, url=url, code=code, system=system)
self.assertEqual(unbounded_reads(queries), [], msg)
if not code:
diff --git a/core/fhir/tests_translate.py b/core/fhir/tests_translate.py
new file mode 100644
index 000000000..4e31ed8c2
--- /dev/null
+++ b/core/fhir/tests_translate.py
@@ -0,0 +1,1054 @@
+"""
+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
+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.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'])
From ea7a43ae76e93f4d23a9e70dd8b88bdcccadd071 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 07/18] OpenConceptLab/ocl_online#451 | FHIR expansion pages
the requested value set's stored members
ValueSet $expand now serves the resolved version's default stored expansion
at type and instance routes. GET and POST only read it; neither computes nor
saves an expansion. Missing, unfinished or unresolved expansions return a
422 OperationOutcome.
Responses carry the ValueSet, expansion identifier and timestamp, total,
offset, echoed inputs and used code systems. Each visible member has its
system, code and display, with a holding release when available, inactive
status and optional designations. count and offset page up to 1,000 members;
activeOnly, word-prefix filter and displayLanguage shape the answer.
Members report their release's own canonical URL when present, and ValueSet
validation accepts that system too. Filters match decoded codes and names.
Inputs requiring a newly computed expansion are refused explicitly, and
members whose code systems the caller cannot view contribute neither content
nor totals.
Tests cover paging, filtering, languages, designations, release selection,
access and unsupported inputs with both registry settings. They also check
that POST writes nothing, queries stay bounded and timeouts return too-
costly outcomes.
Work package WP7 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/adapters/r4.py | 25 ++
core/fhir/ops/expand.py | 341 ++++++++++++++++
core/fhir/ops/validate_code.py | 30 +-
core/fhir/tests_operation_bounds.py | 89 ++++-
core/fhir/tests_ops_expand.py | 567 +++++++++++++++++++++++++++
core/fhir/tests_ops_validate_code.py | 22 ++
core/fhir/tests_protocol.py | 5 +-
core/value_sets/serializers.py | 62 +--
core/value_sets/tests.py | 91 +----
core/value_sets/urls.py | 2 +
core/value_sets/views.py | 61 +--
11 files changed, 1107 insertions(+), 188 deletions(-)
create mode 100644 core/fhir/ops/expand.py
create mode 100644 core/fhir/tests_ops_expand.py
diff --git a/core/fhir/adapters/r4.py b/core/fhir/adapters/r4.py
index 4304ae2db..a7c98eb67 100644
--- a/core/fhir/adapters/r4.py
+++ b/core/fhir/adapters/r4.py
@@ -88,3 +88,28 @@ def translate_parameters(answer):
{'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/ops/expand.py b/core/fhir/ops/expand.py
new file mode 100644
index 000000000..ae3a66fbd
--- /dev/null
+++ b/core/fhir/ops/expand.py
@@ -0,0 +1,341 @@
+"""
+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 release the caller is shown that holds the concept, the
+ one the expansion pins (explicit) first, then the one it evaluated, then the newest; 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, the page read alone; all of them 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 release(self):
+ """
+ For a member: the release it is reported in, as {version, url (its own canonical URL)}: a live release the
+ caller is shown that holds the concept, the expansion's explicit one first, then the one it evaluated, then the
+ newest; None without one.
+ Newest is by the source's id (a repository's releases' ids follow their creation), so the lookup of the newest
+ stops at the concept's first link it finds.
+ """
+ releases = viewable_rows(Source, self.namespace, self.user).filter(
+ is_active=True, released=True, retired=False).exclude(version=HEAD).values('id')
+ holding = Concept.sources.through.objects.filter(concept_id=OuterRef('id'), source_id__in=releases)
+ 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 = holding.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(links.annotate(
+ release=JSONObject(version='source__version', url='source__canonical_url')).values('release')[:1],
+ output_field=JSONField()) for links in (pinned, holding.order_by('-source_id'))), output_field=JSONField())
+
+ def used(self):
+ """Each code system the members come from, with each version they are reported in: system|version."""
+ rows = self.members().annotate(release=self.release()).values_list(
+ 'parent__canonical_url', 'parent__uri', 'release').order_by().distinct()
+ used = {code_system(url, uri, release) for url, uri, release in rows}
+ return sorted(f'{system}|{version}' if version else system for system, version in used)
+
+ def page(self, matching):
+ """
+ The page offset and count name, in a fixed order, with what each member is shown with. The page is chosen
+ first, so what it is shown with is read for its members alone, however deep the offset.
+ """
+ offset = self.inputs.offset or 0
+ page = matching.order_by('id').values('id')[offset:offset + self.inputs.count]
+ concepts = Concept.objects.filter(id__in=page).annotate(release=self.release()).select_related('parent').only(
+ 'id', 'mnemonic', 'retired', 'parent_id', 'parent__canonical_url', 'parent__uri',
+ 'parent__default_locale', 'parent__supported_locales').prefetch_related('names').order_by('id')
+ members = []
+ for concept in concepts:
+ parent = concept.parent
+ system, version = code_system(parent.canonical_url, parent.uri, concept.release)
+ 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()
+ return {
+ 'value_set': self.value_set, 'identifier': self.expansion.uri, 'timestamp': self.expansion.created_at,
+ 'total': matching.count(), 'offset': self.inputs.offset or 0, 'parameters': self.inputs.echo,
+ 'used': self.used(), 'contains': self.page(matching) if self.inputs.count else [],
+ }
diff --git a/core/fhir/ops/validate_code.py b/core/fhir/ops/validate_code.py
index 3f85a8687..d319a1d82 100644
--- a/core/fhir/ops/validate_code.py
+++ b/core/fhir/ops/validate_code.py
@@ -161,21 +161,39 @@ def system_q(field, system):
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: the expansion defines the value set. The version answered is a
- release the caller is shown that holds the concept: the one the expansion pins (explicit), else the one it
- evaluated, else the newest; None without one, never HEAD or an unreleased version.
+ 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 release the caller is shown that holds the concept: the one the expansion pins
+ (explicit), else the one it evaluated, else the newest; None without one, never HEAD or an unreleased version.
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))
concept_ids = list(Concept.objects.filter(
- code_q('mnemonic', coding.code), system_q('parent', coding.system),
+ code_q('mnemonic', coding.code), concepts_named,
Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('parent_id'))), is_active=True).values_list(
'id', flat=True))
if concept_ids:
@@ -184,8 +202,8 @@ def member(value_set, coding, namespace, user):
if not concept_ids:
return None
rows = Concept.sources.through.objects.filter(
- Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('source_id'))), concept_id__in=concept_ids,
- source__is_active=True)
+ Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('source_id'))), versions_named,
+ concept_id__in=concept_ids, source__is_active=True)
if coding.version:
rows = rows.filter(source__version=coding.version)
def pinned(versions):
diff --git a/core/fhir/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
index 005100034..0ad7701c5 100644
--- a/core/fhir/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
@@ -37,7 +38,8 @@
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
@@ -68,6 +70,26 @@ 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'))
@@ -275,6 +297,67 @@ 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_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))
+
def test_parameters_are_read_with_the_value_types_r4_allows(self):
serializer = ValidateCodeParametersSerializer(data={'resourceType': 'Parameters', 'parameter': [
{'name': 'url', 'valueString': SYSTEM},
diff --git a/core/fhir/tests_ops_expand.py b/core/fhir/tests_ops_expand.py
new file mode 100644
index 000000000..06f8cf6f4
--- /dev/null
+++ b/core/fhir/tests_ops_expand.py
@@ -0,0 +1,567 @@
+"""
+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']]
+
+
+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_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_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_validate_code.py b/core/fhir/tests_ops_validate_code.py
index b7d3860e0..a950ba570 100644
--- a/core/fhir/tests_ops_validate_code.py
+++ b/core/fhir/tests_ops_validate_code.py
@@ -23,6 +23,8 @@
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):
@@ -320,6 +322,26 @@ def test_the_version_answered_is_the_one_the_expansion_pins(self):
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)."""
diff --git a/core/fhir/tests_protocol.py b/core/fhir/tests_protocol.py
index 56389cedf..3bf5c00f0 100644
--- a/core/fhir/tests_protocol.py
+++ b/core/fhir/tests_protocol.py
@@ -11,6 +11,7 @@
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
@@ -26,6 +27,7 @@
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'
@@ -321,7 +323,8 @@ def test_errors_are_outcomes_with_a_tx_issue_type(self):
self.assert_fhir_answer(response, path)
def test_an_error_returned_rather_than_raised_is_an_outcome(self):
- response = self.client.get(f'/orgs/{self.org.mnemonic}/ValueSet/protovs/$expand?count=5')
+ with patch.object(ValueSetExpandView, 'get', new=lambda *args, **kwargs: Response(status=404)):
+ response = self.client.get(f'/orgs/{self.org.mnemonic}/ValueSet/protovs/$expand?count=5')
self.assertEqual(response.status_code, 404)
self.assert_fhir_answer(response)
diff --git a/core/value_sets/serializers.py b/core/value_sets/serializers.py
index f26676d05..b448da2e7 100644
--- a/core/value_sets/serializers.py
+++ b/core/value_sets/serializers.py
@@ -5,14 +5,13 @@
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.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin
-from core.parameters.serializers import ParametersSerializer
from core.value_sets.constants import RESOURCE_TYPE
logger = logging.getLogger('oclapi')
@@ -31,12 +30,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)
@@ -279,52 +272,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 d9e09d4e3..4f00ebcc0 100644
--- a/core/value_sets/tests.py
+++ b/core/value_sets/tests.py
@@ -1,6 +1,6 @@
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
@@ -689,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,
@@ -696,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',
@@ -717,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={
@@ -734,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 5d724ec4f..6aa10203a 100644
--- a/core/value_sets/views.py
+++ b/core/value_sets/views.py
@@ -1,18 +1,14 @@
import logging
-from django.core.exceptions import ValidationError
-from rest_framework import status
-from rest_framework.response import Response
-
-from core.collections.views import CollectionListView, CollectionRetrieveUpdateDestroyView, \
- CollectionVersionExpansionsView
+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, \
- ValueSetExpansionParametersSerializer, ValueSetExpansionSerializer
+from core.value_sets.serializers import ValueSetDetailSerializer, ValueSetExpansionSerializer
logger = logging.getLogger('oclapi')
@@ -53,44 +49,13 @@ def get_detail_serializer(self, obj):
return ValueSetDetailSerializer(obj)
-class ValueSetExpandView(FhirViewMixin, 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)
From 7b7cadd1b0601b5506c3d756382c9856406da7da Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 08/18] OpenConceptLab/ocl_online#451 | FHIR metadata describes
the operations and code-system versions each base serves
CapabilityStatement is built from the routes and search definitions in
core/fhir/capabilities.py. It advertises FHIR R4, the available interactions
and operations, SVCM terminology support, and the request's own base URL.
metadata and GET $versions work at global, organization and user bases, with
either trailing-slash form.
TerminologyCapabilities lists the code systems the caller may view and their
released versions, each under its canonical URL or its OCL CodeSystem path.
The default release is marked, and expansion and translation capabilities
follow the routed operations.
Business versions are listed as stored and resolve without a second decoding
changing their meaning. OCL-to-FHIR path conversion replaces only the
repository-type segment, preserving owner and repository names.
Tests compare advertised routes, parameters and versions with the
implementation, check JSON and XML at every base, and cover registry
choices, shared canonicals, access, query counts and timeouts.
Work package WP8 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/common/serializers.py | 15 +-
core/fhir/adapters/r4.py | 80 ++++
core/fhir/base.py | 9 +-
core/fhir/capabilities.py | 179 ++++++++
core/fhir/resolve.py | 144 ++++++-
core/fhir/tests_capabilities.py | 743 ++++++++++++++++++++++++++++++++
core/fhir/urls.py | 2 +
core/fhir/views.py | 309 +------------
core/orgs/urls.py | 5 +
core/users/urls.py | 5 +
10 files changed, 1190 insertions(+), 301 deletions(-)
create mode 100644 core/fhir/capabilities.py
create mode 100644 core/fhir/tests_capabilities.py
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/fhir/adapters/r4.py b/core/fhir/adapters/r4.py
index a7c98eb67..81e0a9631 100644
--- a/core/fhir/adapters/r4.py
+++ b/core/fhir/adapters/r4.py
@@ -10,6 +10,7 @@
"""
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
@@ -37,6 +38,85 @@ 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')}}
+
+
+def search_parameter(resource_type, name):
+ """A search parameter as a CapabilityStatement lists it: its name, R4 definition and type."""
+ definition = SHARED_SEARCH_PARAMETERS.get(name, f'{resource_type}-{name}')
+ return {'name': name, 'definition': SEARCH_PARAMETER + definition, 'type': SEARCH_PARAMETER_TYPES[name]}
+
+
+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([
diff --git a/core/fhir/base.py b/core/fhir/base.py
index 36c7fe759..d5a48b52d 100644
--- a/core/fhir/base.py
+++ b/core/fhir/base.py
@@ -60,6 +60,7 @@
# 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'
@@ -78,9 +79,13 @@ def xml_parser():
def is_fhir_path(path):
- """Whether a path is under a FHIR base: the global /fhir/, or a FHIR resource type at an owner base."""
+ """
+ Whether a path is under a FHIR base: the global /fhir/, or at an owner base a FHIR resource type, its metadata or
+ $versions.
+ """
segments = path.strip('/').split('/')
- return segments[0] == 'fhir' or (len(segments) >= 3 and segments[2] in RESOURCE_TYPES)
+ return segments[0] == 'fhir' or (len(segments) >= 3 and segments[2] in RESOURCE_TYPES) or (
+ len(segments) == 3 and segments[0] in ('orgs', 'users') and segments[2] in OWNER_BASE_PATHS)
def fhir_base_url(request, kwargs=None):
diff --git a/core/fhir/capabilities.py b/core/fhir/capabilities.py
new file mode 100644
index 000000000..be64c35ce
--- /dev/null
+++ b/core/fhir/capabilities.py
@@ -0,0 +1,179 @@
+"""
+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. 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/resolve.py b/core/fhir/resolve.py
index 6a743addf..a088b4f02 100644
--- a/core/fhir/resolve.py
+++ b/core/fhir/resolve.py
@@ -6,7 +6,8 @@
- 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. With none named, the newest released
+ 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.
- Access, after resolving: at the global base public repositories only, at an owner base what the caller may view.
@@ -16,16 +17,26 @@
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 Exists, OuterRef, Q, Subquery
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.utils import normalize_public_access, to_parent_uri
+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'}
@@ -72,7 +83,10 @@ def resolve(resource_type, url, version=None, kwargs=None, user=None, expression
if not lookup_url or conflicting or (namespace and not SourceContainerMixin.get_object_from_namespace(namespace)):
raise error
- instance = shared_resolution(REPO_TYPES[resource_type], url, version, namespace, user)
+ # 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
+ instance = shared_resolution(
+ REPO_TYPES[resource_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
@@ -291,3 +305,127 @@ def visible_versions(resource_type, kwargs=None, user=None, access=None):
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.
+
+ 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)
+
+ 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, rows))
+ return served
diff --git a/core/fhir/tests_capabilities.py b/core/fhir/tests_capabilities.py
new file mode 100644
index 000000000..9b5bcd03a
--- /dev/null
+++ b/core/fhir/tests_capabilities.py
@@ -0,0 +1,743 @@
+"""
+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_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
+ versions = [self.named(identifier, version, kwargs, user) for version in rows.filter(
+ released=True).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*']}
+ unregistered = {UNREGISTERED: ['v1*'], REDIRECTED: ['v1*'], COLLIDE: ['v1*'], OLD: ['v2*', 'v1']}
+ 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."""
+ listed = self.code_systems()
+ self.assertEqual((listed[ICD10], listed[code_system_path('sehatapp', 'ICD-10')]), (['ICD102019*'], ['v1.0*']))
+ with self.assertRaises(UnresolvedError):
+ resolve('CodeSystem', ICD10, 'v1.0')
+ 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):
+ self.assertEqual(self.code_systems('/orgs/HL7', user)[COLLIDE], ['p1*'], 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/urls.py b/core/fhir/urls.py
index 59ceb5e4d..eed78379e 100644
--- a/core/fhir/urls.py
+++ b/core/fhir/urls.py
@@ -6,4 +6,6 @@
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 ec1dea678..6d8974e8d 100644
--- a/core/fhir/views.py
+++ b/core/fhir/views.py
@@ -1,303 +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
-from core.fhir.base import FhirViewMixin
-
-logger = logging.getLogger('oclapi')
+from core.fhir import capabilities
+from core.fhir.adapters import r4
+from core.fhir.base import FhirOperationView, FhirViewMixin
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 = self.get_fhir_base_url()
- 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/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/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'),
From 357e496d5e9454a15a7504b652b182e5337bad69 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 09/18] OpenConceptLab/ocl_online#451 | ConceptMap system
searches use indexed candidates and check each release's mappings
ConceptMap source-system and target-system searches use
core/fhir/mapping_index.py to find candidate repositories and versions. The
database checks each candidate through its own live mappings, avoiding a
join over every mapping of a heavily mapped source.
Index requests are scoped by the versions the database shows the caller, use
one bounded request per parameter and require a complete answer. Time or
aggregation limits return too-costly; unavailable or partial index responses
return a 422 OperationOutcome.
Recent releases of repositories found in the index are checked for seven
days even if the release is not indexed yet. Matches through a source's
canonical URL need the index: repositories with no indexed mappings, and
older unindexed releases, are absent until indexed. Matches on the URL a
mapping stores, external URLs and OCL paths alike, don't need the mappings
to be indexed, but a search that also names a visible source's canonical
URL needs a complete answer from the index.
Tests cover candidate verification, case-distinct owners, current access
rules, recent releases, index lag, incomplete responses, timeouts and fixed
database query counts with both registry settings.
Work package WP3b of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/mapping_index.py | 166 ++++++++++
core/fhir/search.py | 107 +++++--
core/fhir/tests_search.py | 11 +-
core/fhir/tests_search_systems.py | 513 ++++++++++++++++++++++++++++++
core/fhir/tests_translate.py | 3 +-
5 files changed, 772 insertions(+), 28 deletions(-)
create mode 100644 core/fhir/mapping_index.py
create mode 100644 core/fhir/tests_search_systems.py
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/search.py b/core/fhir/search.py
index bad2b5473..0cd2d188b 100644
--- a/core/fhir/search.py
+++ b/core/fhir/search.py
@@ -16,7 +16,9 @@
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.
+ 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`.
@@ -36,7 +38,7 @@
from django.db import connection, transaction, OperationalError
from django.db.models import BooleanField, Exists, Func, OuterRef, Q, Subquery, TextField, Value
from django.db.models.functions import Coalesce
-from django.db.models.lookups import Exact, IContains, IStartsWith
+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
@@ -49,13 +51,15 @@
from core.common.mixins import CustomPaginator
from core.common.utils import to_int
from core.concepts.models import Concept
-from core.fhir import outcomes
+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
@@ -99,6 +103,8 @@
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
@@ -183,12 +189,23 @@ def repositories_with(rows, urls):
return found
-def sources_with(rows, urls):
- """For each of `urls`, the ids of the Source rows among `rows` with that canonical URL: one query for them all."""
- found = {url: [] for url in urls}
- for url, source_id in rows.filter(canonical_url__in=urls).values_list('canonical_url', 'id'):
- found[url].append(source_id)
- 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):
@@ -269,29 +286,53 @@ def held_identifier(system, value):
'identifier', f'lax $[*] ? ({" && ".join(conditions)})', variables)
-def mapped(side, value, sources, namespace, user):
+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}/). A search over many mappings is bounded by SEARCH_TIMEOUT_MS.
- 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, listed first (sources_with), so the planner can read the
- mappings by their source index. Only those the base shows the caller (viewable_rows) are listed, and 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, and how long the search takes doesn't depend on it.
+ 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=''))
- shown = Q(**{f'{field}_id__in': sources}) | (Q(**{f'{field}_url': value}) & unnamed)
+ 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')))
- shown |= stored & unnamed
- return Exists(Mapping.sources.through.objects.filter(
- source_id=OuterRef('id'), mapping__retired=False, mapping__is_active=True).filter(shown))
+ 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:
@@ -304,6 +345,7 @@ def __init__(self, resource_type, kwargs, user=None):
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):
"""
@@ -357,6 +399,10 @@ 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, ''}
@@ -367,7 +413,14 @@ def look_up(self, name, alternatives):
elif lookup == 'referenced':
found = repositories_with(self.viewable_sources(), urls)
else:
- found = sources_with(self.viewable_sources(), urls)
+ 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):
@@ -484,11 +537,13 @@ def by_reference(self, value):
def by_source(self, value):
"""A group's source is the system named."""
- return mapped('from', value, self.found[('sources', value)], self.namespace, self.user)
+ 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.namespace, self.user)
+ return mapped('to', value, self.found[('sources', value)], self.found[('to', value)], self.namespace,
+ self.user)
def page_size(query):
diff --git a/core/fhir/tests_search.py b/core/fhir/tests_search.py
index 3d10ad2e2..eedbb8b8a 100644
--- a/core/fhir/tests_search.py
+++ b/core/fhir/tests_search.py
@@ -13,7 +13,7 @@
from django.contrib.auth.models import AnonymousUser
from django.db import connection, OperationalError
from django.test import TransactionTestCase
-from django.test.utils import CaptureQueriesContext
+from django.test.utils import CaptureQueriesContext, override_settings
from mock import patch
from rest_framework.test import APIClient
@@ -54,6 +54,14 @@ def collection(owner, mnemonic, version=HEAD, **kwargs):
organization=owner, mnemonic=mnemonic, name=mnemonic, version=version, **kwargs)
+def indexing():
+ """
+ Saves indexed, for the mappings index ConceptMap system search reads: an earlier test's pause_indexing() can leave
+ indexing off for the rest of a run (resume_indexing does nothing in TEST_MODE).
+ """
+ return override_settings(ES_SYNC=True, ELASTICSEARCH_DSL_AUTOSYNC=True, ELASTICSEARCH_DSL_AUTO_REFRESH=True)
+
+
def at(year):
return datetime(year, 6, 15, 12, tzinfo=dt_timezone.utc)
@@ -68,6 +76,7 @@ class SearchCorpusMixin:
"""
def setUp(self):
super().setUp()
+ self.enterContext(indexing())
self.client = APIClient()
self.admin = UserProfile.objects.get(username='ocladmin')
self.who = OrganizationFactory(mnemonic='WHO')
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_translate.py b/core/fhir/tests_translate.py
index 4e31ed8c2..9c17b5dbe 100644
--- a/core/fhir/tests_translate.py
+++ b/core/fhir/tests_translate.py
@@ -23,7 +23,7 @@
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
+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
@@ -61,6 +61,7 @@ class TranslateCorpusMixin:
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')
From 33058bd1f3031405c519a52ea47f8ca16e3c988a Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 10/18] OpenConceptLab/ocl_online#451 | CodeSystem and ValueSet
titles fall back to the name when no full name is set
CodeSystem and ValueSet reads and search entries use the repository name as
the title when the full name is null, empty or only whitespace. A nonblank
full name remains the title, and title writes still update the full name.
Title searches match that same displayed value for prefix, :exact and
:contains searches. Tests cover both resource types at global and owner
bases, including Unicode whitespace and names that differ from a nonblank
full name.
Work package WP3c of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/code_systems/serializers.py | 4 +-
core/fhir/search.py | 25 +++++++++++-
core/fhir/tests_title.py | 69 ++++++++++++++++++++++++++++++++
core/value_sets/serializers.py | 4 +-
4 files changed, 96 insertions(+), 6 deletions(-)
create mode 100644 core/fhir/tests_title.py
diff --git a/core/code_systems/serializers.py b/core/code_systems/serializers.py
index e81819d2c..c63068b3e 100644
--- a/core/code_systems/serializers.py
+++ b/core/code_systems/serializers.py
@@ -14,7 +14,7 @@
from core.concepts.models import Concept, ConceptName
from core.concepts.serializers import ConceptDetailSerializer
from core.fhir import search as fhir_search
-from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin
+from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin, TitleField
from core.parameters.serializers import ParametersSerializer
from core.sources.models import Source
from core.sources.serializers import SourceCreateOrUpdateSerializer
@@ -181,7 +181,7 @@ class CodeSystemDetailSerializer(FhirRepositorySerializerMixin, serializers.Mode
resourceType = SerializerMethodField(method_name='get_resource_type')
id = CharField(source='mnemonic')
url = CanonicalUrlField(source='canonical_url', required=False)
- title = CharField(source='full_name', 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')
diff --git a/core/fhir/search.py b/core/fhir/search.py
index 0cd2d188b..d32796f24 100644
--- a/core/fhir/search.py
+++ b/core/fhir/search.py
@@ -36,7 +36,7 @@
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.db import connection, transaction, OperationalError
-from django.db.models import BooleanField, Exists, Func, OuterRef, Q, Subquery, TextField, Value
+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
@@ -72,6 +72,14 @@
# 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 = {
@@ -425,6 +433,8 @@ def look_up(self, name, alternatives):
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':
@@ -458,7 +468,7 @@ def by_string(field, modifier, value):
"""
lookup = STRING_LOOKUPS[modifier]
if lookup is None:
- return Q(**{field: value})
+ 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):
@@ -693,6 +703,17 @@ 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
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/value_sets/serializers.py b/core/value_sets/serializers.py
index b448da2e7..bc37c20ad 100644
--- a/core/value_sets/serializers.py
+++ b/core/value_sets/serializers.py
@@ -11,7 +11,7 @@
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.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin
+from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin, TitleField
from core.value_sets.constants import RESOURCE_TYPE
logger = logging.getLogger('oclapi')
@@ -143,7 +143,7 @@ class ValueSetDetailSerializer(FhirRepositorySerializerMixin, serializers.ModelS
resourceType = SerializerMethodField(method_name='get_resource_type')
id = CharField(source='mnemonic')
url = CanonicalUrlField(source='canonical_url', required=False)
- title = CharField(source='full_name', required=False)
+ title = TitleField(source='full_name', required=False)
status = StatusField(source='*')
meta = SerializerMethodField()
identifier = IdentifierSerializer(many=True, required=False)
From 0345c0d64cfa296248ba900350052cb5a5da9551 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 11/18] OpenConceptLab/ocl_online#451 | A named FHIR version
resolves to a visible repository that holds it
When the first repository selected for a canonical URL cannot serve the
named release, FHIR resolution can select the lowest-id visible repository
with that canonical that holds it. A named unreleased version or HEAD
remains limited to the first eligible repository; unavailable holders are
not served.
This applies where repositories sharing the canonical determine resolution.
An explicit registry entry still decides, owner bases stay within their
owner, and requests without a version keep their default.
TerminologyCapabilities also lists the additional releases served by name
under the canonical, preserving the default.
Tests cover lookup, validation, expansion, translation, search and
capabilities across callers, bases and both registry settings, including
retired, deleted and unavailable versions. Query-count tests retain the
shared resolver's existing differences and account for the HEAD read used by
$resolveReference's view-access check.
Work package WP2b of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/capabilities.py | 6 +-
core/fhir/resolve.py | 126 ++-
core/fhir/tests_capabilities.py | 36 +-
core/fhir/tests_resolve.py | 8 +-
core/fhir/tests_resolve_named_version.py | 720 ++++++++++++++++++
.../tests_resolve_named_version_access.py | 574 ++++++++++++++
core/url_registry/tests_resolution.py | 11 +-
7 files changed, 1446 insertions(+), 35 deletions(-)
create mode 100644 core/fhir/tests_resolve_named_version.py
create mode 100644 core/fhir/tests_resolve_named_version_access.py
diff --git a/core/fhir/capabilities.py b/core/fhir/capabilities.py
index be64c35ce..70afe33df 100644
--- a/core/fhir/capabilities.py
+++ b/core/fhir/capabilities.py
@@ -14,8 +14,10 @@
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. A
- fixed number of queries, bounded as a search's are (SEARCH_TIMEOUT_MS; past it the list is too costly, a 422).
+ 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.
"""
diff --git a/core/fhir/resolve.py b/core/fhir/resolve.py
index a088b4f02..de6852b4e 100644
--- a/core/fhir/resolve.py
+++ b/core/fhir/resolve.py
@@ -10,6 +10,19 @@
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
@@ -24,7 +37,7 @@
from collections import namedtuple
from urllib.parse import quote_plus
-from django.db.models import Exists, OuterRef, Q, Subquery
+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
@@ -67,7 +80,7 @@ def namespace_of(kwargs):
return None
-def resolve(resource_type, url, version=None, kwargs=None, user=None, expression=None): # pylint: disable=too-many-arguments
+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
@@ -85,33 +98,86 @@ def resolve(resource_type, url, version=None, kwargs=None, user=None, expression
# 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
- instance = shared_resolution(
- REPO_TYPES[resource_type], lookup_url, as_stored(elsewhere) if elsewhere else None, namespace, user)
+ 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
-
- if not instance.id or not is_shown(instance, namespace, user):
+ 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, 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.
+ (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, _ = repo_type.resolve_reference_expression(
+ 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
+ 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 is set on it, as the resolver sets it.
+ 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 = True, url
+ held.canonical_url = held.canonical_url or url
+ return held
def path_version(url):
@@ -274,13 +340,21 @@ def viewable_rows(model, namespace, user):
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 model.objects.filter(public, with_head(model, public))
+ return public
if user.is_staff:
- return model.objects.filter(with_head(model))
- shown = public | Q(organization_id__in=user.organizations.values('id')) | Q(user_id=user.id)
- return model.objects.filter(shown, with_head(model, shown))
+ 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):
@@ -368,7 +442,8 @@ def served_repositories(kwargs, user): # pylint: disable=too-many-locals
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.
+ 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
@@ -419,6 +494,23 @@ def pick(url):
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)
@@ -427,5 +519,5 @@ def pick(url):
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, rows))
+ served.append((identifier, held(url, head) if identifier == url else rows))
return served
diff --git a/core/fhir/tests_capabilities.py b/core/fhir/tests_capabilities.py
index 9b5bcd03a..a311f996c 100644
--- a/core/fhir/tests_capabilities.py
+++ b/core/fhir/tests_capabilities.py
@@ -432,8 +432,15 @@ def served(self, kwargs, user): # pylint: disable=too-many-locals
if not default or (default.mnemonic, default.organization_id, default.user_id) != (
mnemonic, organization_id, user_id):
continue
- versions = [self.named(identifier, version, kwargs, user) for version in rows.filter(
- released=True).exclude(version=HEAD).values_list('version', flat=True)]
+ # 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
@@ -491,7 +498,10 @@ 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*']}
- unregistered = {UNREGISTERED: ['v1*'], REDIRECTED: ['v1*'], COLLIDE: ['v1*'], OLD: ['v2*', '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*'],
@@ -523,11 +533,19 @@ def test_with_the_registry_on_only_what_the_registry_resolves(self):
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."""
+ """
+ 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*'], ['v1.0*']))
- with self.assertRaises(UnresolvedError):
- resolve('CodeSystem', ICD10, 'v1.0')
+ 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*']})
@@ -587,8 +605,8 @@ def test_a_hidden_repository_of_the_owner_is_listed_as_missing(self):
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):
- self.assertEqual(self.code_systems('/orgs/HL7', user)[COLLIDE], ['p1*'], user)
+ 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:
diff --git a/core/fhir/tests_resolve.py b/core/fhir/tests_resolve.py
index 49cf91527..d6c371186 100644
--- a/core/fhir/tests_resolve.py
+++ b/core/fhir/tests_resolve.py
@@ -143,8 +143,12 @@ def test_unknown_version(self):
error = self.assertUnresolved('CodeSystem', ICD10, 'nope')
self.assertEqual(error.version, 'nope')
- # the version of a repository that lost the resolution: the resolver never tries another one
- self.assertUnresolved('CodeSystem', ICD10, 'v1.0')
+ # the version of the repository that lost the resolution: without the registry, it holds the release named, so
+ # it is served (decision 9, core.fhir.tests_resolve_named_version); with it, the global entry decides
+ if self.registry:
+ self.assertUnresolved('CodeSystem', ICD10, 'v1.0')
+ else:
+ self.assertEqual(self.resolve('CodeSystem', ICD10, 'v1.0').id, self.sehat_icd_v1.id)
self.assertEqual(self.resolve('CodeSystem', ICD10, 'v1.0', {'org': 'sehatapp'}).id, self.sehat_icd_v1.id)
def test_named_version(self):
diff --git a/core/fhir/tests_resolve_named_version.py b/core/fhir/tests_resolve_named_version.py
new file mode 100644
index 000000000..5ede80b57
--- /dev/null
+++ b/core/fhir/tests_resolve_named_version.py
@@ -0,0 +1,720 @@
+"""
+A version named resolves to the repository that holds it (decision 9), with the URL registry toggle off and on. When
+the repository resolution picks for a canonical URL doesn't serve a release of the version named, the release of the
+lowest-id repository the base shows the caller with that canonical URL that holds one is served; an unreleased version
+(or HEAD) only from the first repository the base shows; never a repository or a version the caller isn't shown, and
+with no version named nothing changes. A registry entry still decides, and where resolution finds no repository at all
+it still finds none. resolve() itself; $lookup, CodeSystem and ValueSet $validate-code (a member's system and version
+too), $expand, $translate and search, GET and POST; TerminologyCapabilities; what a hidden holder costs; the 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, HEAD
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.resolve import UnresolvedError, resolve
+from core.fhir.tests_ops_lookup import name
+from core.fhir.tests_search import source, collection
+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, OrganizationURLRegistryFactory
+from core.url_registry.models import URLRegistry
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.models import UserProfile
+from core.users.tests.factories import UserProfileFactory
+
+ICD10 = 'http://hl7.org/fhir/sid/icd-10'
+ATC = 'http://www.whocc.no/atc'
+LOCAL = 'http://example.org/fhir/CodeSystem/local'
+SETS = 'http://example.org/fhir/ValueSet/sets'
+FIRST = 'http://example.org/fhir/CodeSystem/first-private'
+ONLY = 'http://example.org/fhir/CodeSystem/only-private'
+THREE = 'http://example.org/fhir/CodeSystem/three-holders'
+OPEN_HEAD = 'http://example.org/fhir/CodeSystem/open-head'
+
+
+def client_for(user=None):
+ client = APIClient()
+ if user:
+ client.force_authenticate(user)
+ return client
+
+
+def values(response):
+ """A Parameters response as {name: value} (the first of each name), each match as system|code|equivalence."""
+ found = {}
+ for item in response.data['parameter']:
+ if item['name'] == 'match':
+ parts = {part['name']: part for part in item['part']}
+ coding = parts['concept']['valueCoding']
+ found.setdefault('matches', []).append(
+ f"{coding['system']}|{coding['code']}|{parts['equivalence']['valueCode']}")
+ else:
+ value = next((value for key, value in item.items() if key.startswith('value')), None)
+ found.setdefault(item['name'], value)
+ return found
+
+
+class NamedVersionCorpusMixin: # pylint: disable=too-many-instance-attributes
+ """
+ ICD-10 held by WHO (first, the lower ids, as in production; ICD102019, with S20.4) and sehatapp (v1.0, with D64.2,
+ which maps to WHO's S20.4); ATC held by sehatapp first (2026-04-25), as in the local corpus, and WHO (2023AA, with
+ R02AB01). HL7's two code systems sharing a canonical (local-a's a1 with A1; local-b's b1 with B1, which maps to A1)
+ and Acme's third (c1). Acme's two value sets sharing a canonical: set-a's v1 holds S20.4, set-b's v2 holds D64.2.
+ With the registry, the global entries send each canonical to the first holder's owner.
+ """
+ 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.who = OrganizationFactory(mnemonic='WHO')
+ self.sehatapp = OrganizationFactory(mnemonic='sehatapp')
+ self.hl7 = OrganizationFactory(mnemonic='HL7')
+ self.acme = OrganizationFactory(mnemonic='Acme')
+ self.hl7_member = UserProfileFactory(username='hl7-member')
+ self.hl7.members.add(self.hl7_member)
+ self.outsider = UserProfileFactory(username='outsider')
+
+ self.who_icd = source(self.who, 'ICD-10-WHO', canonical_url=ICD10)
+ self.who_2019 = source(self.who, 'ICD-10-WHO', 'ICD102019', released=True, canonical_url=None)
+ self.s204 = ConceptFactory(parent=self.who_icd, mnemonic='S20.4', sources=[self.who_2019], names=[
+ name('Other superficial injuries of back wall of thorax', preferred=True)])
+ self.sehat_icd = source(self.sehatapp, 'ICD-10', canonical_url=ICD10)
+ self.sehat_v1 = source(self.sehatapp, 'ICD-10', 'v1.0', released=True, canonical_url=ICD10)
+ self.d642 = ConceptFactory(parent=self.sehat_icd, mnemonic='D64.2', sources=[self.sehat_v1], names=[
+ name('Secondary sideroblastic anaemia due to drugs and toxins', preferred=True)])
+ self.mapping(self.sehat_icd, 'D64.2', self.who_icd, 'S20.4', [self.sehat_v1])
+
+ self.sehat_atc = source(self.sehatapp, 'ATC', canonical_url=ATC)
+ self.sehat_atc_v = source(self.sehatapp, 'ATC', '2026-04-25', released=True, canonical_url=ATC)
+ self.who_atc = source(self.who, 'WHOATC', canonical_url=ATC)
+ self.who_atc_2023 = source(self.who, 'WHOATC', '2023AA', released=True, canonical_url=None)
+ ConceptFactory(parent=self.who_atc, mnemonic='R02AB01', sources=[self.who_atc_2023],
+ names=[name('neomycin', preferred=True)])
+
+ self.local_a = source(self.hl7, 'local-a', canonical_url=LOCAL)
+ self.local_a1 = source(self.hl7, 'local-a', 'a1', released=True, canonical_url=LOCAL)
+ ConceptFactory(parent=self.local_a, mnemonic='A1', sources=[self.local_a1],
+ names=[name('A one', preferred=True)])
+ self.local_b = source(self.hl7, 'local-b', canonical_url=LOCAL)
+ self.local_b1 = source(self.hl7, 'local-b', 'b1', released=True, canonical_url=LOCAL)
+ ConceptFactory(parent=self.local_b, mnemonic='B1', sources=[self.local_b1],
+ names=[name('B one', preferred=True)])
+ self.mapping(self.local_b, 'B1', self.local_a, 'A1', [self.local_b1])
+ self.local_c = source(self.acme, 'local-c', canonical_url=LOCAL)
+ self.local_c1 = source(self.acme, 'local-c', 'c1', released=True, canonical_url=LOCAL)
+
+ self.set_a = collection(self.acme, 'set-a', canonical_url=SETS)
+ self.set_a1 = self.value_set_release('set-a', 'v1', self.s204)
+ self.set_b = collection(self.acme, 'set-b', canonical_url=SETS)
+ self.set_b2 = self.value_set_release('set-b', 'v2', self.d642)
+
+ if self.registry:
+ for url, owner in ((ICD10, self.who), (ATC, self.who), (LOCAL, self.hl7), (SETS, self.acme)):
+ GlobalURLRegistryFactory(url=url, namespace=owner.uri)
+
+ def tearDown(self):
+ set_registry_toggle(False)
+ super().tearDown()
+
+ @staticmethod
+ def mapping(parent, from_code, to_source, to_code, releases):
+ return MappingFactory(
+ parent=parent, map_type='SAME-AS', from_concept=None, to_concept=None, from_source=parent,
+ from_source_url=parent.uri, from_concept_code=from_code, to_source=to_source, to_source_url=to_source.uri,
+ to_concept_code=to_code, sources=releases)
+
+ def value_set_release(self, mnemonic, version, *concepts):
+ release = collection(self.acme, mnemonic, version, released=True, canonical_url=SETS)
+ expansion = ExpansionFactory(mnemonic=f'autoexpand-{version}', collection_version=release)
+ expansion.concepts.add(*concepts)
+ release.expansion_uri = expansion.uri
+ release.save()
+ return release
+
+ def assertUnresolved(self, url, version=None, kwargs=None, user=None): # pylint: disable=invalid-name
+ with self.assertRaises(UnresolvedError) as context:
+ resolve('CodeSystem', url, version, kwargs, user)
+ return context.exception
+
+ def resolved(self, url, version=None, kwargs=None, user=None, resource_type='CodeSystem'): # pylint: disable=too-many-arguments
+ return resolve(resource_type, url, version, kwargs, user).id
+
+ @staticmethod
+ def get(operation, base='/fhir', user=None, resource='CodeSystem', **params): # pylint: disable=too-many-arguments
+ return client_for(user).get(f'{base}/{resource}/{operation}', params)
+
+ @staticmethod
+ def post(operation, parameters, base='/fhir', user=None, resource='CodeSystem'): # pylint: disable=too-many-arguments
+ return client_for(user).post(
+ f'{base}/{resource}/{operation}', json.dumps({'resourceType': 'Parameters', 'parameter': parameters}),
+ content_type='application/fhir+json')
+
+
+class NamedVersionResolveCases(NamedVersionCorpusMixin):
+ """resolve() with a version named, toggle off and on (the subclasses)."""
+
+ def test_a_version_another_repository_holds_resolves_to_its_holder(self):
+ """At /fhir/ without the registry, the lowest-id repository the base shows that holds the release; with it,
+ the global entry decides, as before."""
+ if self.registry:
+ self.assertUnresolved(ICD10, 'v1.0')
+ self.assertUnresolved(f'{ICD10}|v1.0')
+ self.assertUnresolved(ATC, '2026-04-25')
+ else:
+ self.assertEqual(self.resolved(ICD10, 'v1.0'), self.sehat_v1.id)
+ self.assertEqual(self.resolved(f'{ICD10}|v1.0'), self.sehat_v1.id)
+ self.assertEqual(self.resolved(ATC, '2026-04-25'), self.sehat_atc_v.id)
+ self.assertEqual(self.resolved(ATC, '2023AA'), self.who_atc_2023.id)
+ self.assertEqual(self.resolved(f'{ATC}|2023AA'), self.who_atc_2023.id)
+ self.assertEqual(self.resolved(ICD10, 'ICD102019'), self.who_2019.id)
+ # each owner base finds its own, as before
+ self.assertEqual(self.resolved(ICD10, 'v1.0', {'org': 'sehatapp'}), self.sehat_v1.id)
+ self.assertEqual(self.resolved(ATC, '2023AA', {'org': 'WHO'}), self.who_atc_2023.id)
+ self.assertUnresolved(ICD10, 'ICD102019', {'org': 'sehatapp'})
+
+ def test_the_holder_keeps_the_canonical_url_it_was_named_by(self):
+ instance = resolve('CodeSystem', ATC, '2023AA')
+ self.assertEqual((instance.canonical_url, instance.is_fqdn, instance.resolution_url), (ATC, True, ATC))
+ self.assertEqual(instance.repository_public_access, self.who_atc.public_access)
+
+ def test_with_no_version_named_nothing_changes(self):
+ self.assertEqual(self.resolved(ICD10), self.who_2019.id)
+ self.assertEqual(self.resolved(ATC), self.who_atc_2023.id if self.registry else self.sehat_atc_v.id)
+ self.assertEqual(self.resolved(LOCAL, kwargs={'org': 'HL7'}), self.local_a1.id)
+ self.assertEqual(self.resolved(LOCAL), self.local_a1.id)
+
+ def test_the_first_choice_keeps_a_release_it_holds(self):
+ """If several repositories hold the release, today's order between them: the first choice's own."""
+ own = source(self.who, 'ICD-10-WHO', 'v1.0', released=True)
+ self.assertEqual(self.resolved(ICD10, 'v1.0'), own.id)
+
+ def test_several_holders_keep_todays_order(self):
+ """The lowest-id repository holding the release, then the next once it no longer does."""
+ source(self.hl7, 'icd-copy', canonical_url=ICD10)
+ copy = source(self.hl7, 'icd-copy', 'v1.0', released=True, canonical_url=ICD10)
+ if self.registry:
+ self.assertUnresolved(ICD10, 'v1.0')
+ return
+ self.assertEqual(self.resolved(ICD10, 'v1.0'), self.sehat_v1.id)
+ self.sehat_v1.is_active = False
+ self.sehat_v1.save()
+ self.assertEqual(self.resolved(ICD10, 'v1.0'), copy.id)
+
+ def test_no_holder_keeps_todays_not_found(self):
+ error = self.assertUnresolved(ICD10, 'nope')
+ self.assertEqual((error.url, error.version), (ICD10, 'nope'))
+ self.assertEqual(str(error.detail), f"A definition for CodeSystem '{ICD10}' version 'nope' could not be found")
+ self.assertUnresolved(LOCAL, 'c1', {'org': 'HL7'}) # another owner's, at an owner base
+
+ def test_a_release_wins_over_an_unreleased_version_of_the_first_choice(self):
+ """
+ The first choice holds v2.0 unreleased and sehatapp a release of it: the release. Without one, the first
+ choice's unreleased version, as before (and with the registry, whose entry decides, always).
+ """
+ unreleased = source(self.who, 'ICD-10-WHO', 'v2.0')
+ release = source(self.sehatapp, 'ICD-10', 'v2.0', released=True, canonical_url=ICD10)
+ self.assertEqual(self.resolved(ICD10, 'v2.0'), unreleased.id if self.registry else release.id)
+ release.is_active = False
+ release.save()
+ self.assertEqual(self.resolved(ICD10, 'v2.0'), unreleased.id)
+
+ def test_another_repositorys_unreleased_version_is_never_served(self):
+ draft = source(self.sehatapp, 'ICD-10', 'draft', canonical_url=ICD10)
+ self.assertUnresolved(ICD10, 'draft')
+ self.assertEqual(self.resolved(ICD10, 'draft', {'org': 'sehatapp'}), draft.id)
+
+ def test_head_named_is_the_first_choices(self):
+ self.assertEqual(self.resolved(ICD10, HEAD), self.who_icd.id)
+ self.assertEqual(self.resolved(f'{ICD10}|HEAD'), self.who_icd.id)
+ self.assertEqual(self.resolved(LOCAL, HEAD, {'org': 'HL7'}), self.local_a.id)
+
+ def test_an_owner_base_picks_among_the_owners_code_systems(self):
+ """With the registry too: HL7's registry has no entry for LOCAL, so HL7's own code systems are the step that
+ picks. At /fhir/ without the registry every public holder is a candidate; with it, the global entry decides."""
+ for user in (None, self.outsider, self.hl7_member):
+ self.assertEqual(self.resolved(LOCAL, 'b1', {'org': 'HL7'}, user), self.local_b1.id, user)
+ self.assertUnresolved(LOCAL, 'c1', {'org': 'HL7'})
+ self.assertEqual(self.resolved(LOCAL, 'c1', {'org': 'Acme'}), self.local_c1.id)
+ self.assertUnresolved(LOCAL, 'b1', {'org': 'Acme'})
+ if self.registry:
+ self.assertUnresolved(LOCAL, 'b1')
+ self.assertUnresolved(LOCAL, 'c1')
+ else:
+ self.assertEqual(self.resolved(LOCAL, 'b1'), self.local_b1.id)
+ self.assertEqual(self.resolved(LOCAL, 'c1'), self.local_c1.id)
+
+ def test_an_owner_registry_entry_decides(self):
+ """Acme's registry sends LOCAL to HL7: there, HL7's first code system is the answer, whatever another holds."""
+ OrganizationURLRegistryFactory(organization=self.acme, url=LOCAL, namespace=self.hl7.uri)
+ acme = {'org': 'Acme'}
+ if self.registry:
+ self.assertEqual(self.resolved(LOCAL, 'a1', acme), self.local_a1.id)
+ self.assertUnresolved(LOCAL, 'b1', acme)
+ self.assertUnresolved(LOCAL, 'c1', acme)
+ else:
+ self.assertEqual(self.resolved(LOCAL, 'c1', acme), self.local_c1.id)
+ self.assertUnresolved(LOCAL, 'a1', acme)
+
+ def test_where_resolution_finds_no_repository_it_still_finds_none(self):
+ """With the registry, at /fhir/ a canonical the global registry doesn't hold stays unresolved, named version or
+ not; an owner base still finds its own. Without it, nothing is registered anyway."""
+ URLRegistry.objects.filter(url__in=[ICD10, LOCAL]).delete()
+ for version in ('v1.0', 'ICD102019'):
+ if self.registry:
+ self.assertUnresolved(ICD10, version)
+ else:
+ self.resolved(ICD10, version)
+ self.assertEqual(self.resolved(ICD10, 'v1.0', {'org': 'sehatapp'}), self.sehat_v1.id)
+ self.assertEqual(self.resolved(LOCAL, 'b1', {'org': 'HL7'}), self.local_b1.id)
+ self.assertUnresolved('http://example.org/fhir/CodeSystem/nobody', 'v1.0')
+
+ def test_a_retired_release_is_served_named_beside_a_live_one(self):
+ retired = source(self.sehatapp, 'ICD-10', 'v0.9', released=True, retired=True, canonical_url=ICD10)
+ if self.registry:
+ self.assertUnresolved(ICD10, 'v0.9')
+ else:
+ self.assertEqual(self.resolved(ICD10, 'v0.9'), retired.id)
+
+ def test_query_counts(self):
+ """
+ A hot path: the first choice serves the release named in the queries it always has (the toggle, the HEAD, the
+ version; the registry adds its entry and the namespace's owner). Only when it doesn't, one more: the holder's.
+ An owner base adds the owner; however many repositories hold the canonical.
+ """
+ extra = 2 if self.registry else 0
+ holder = 0 if self.registry else 1 # with the registry, the entry decides: no holder is looked for
+ with self.assertNumQueries(3 + extra):
+ resolve('CodeSystem', ICD10, 'ICD102019')
+ with self.assertNumQueries(3 + extra + holder):
+ self.assertEqual(resolve('CodeSystem', ATC, '2023AA').id, self.who_atc_2023.id)
+ with self.assertNumQueries(3 + extra + holder):
+ self.assertUnresolved(ICD10, 'nope')
+ owner = 2 if self.registry else 0 # the owner's registry entry, the owner again
+ with self.assertNumQueries(4 + owner):
+ resolve('CodeSystem', LOCAL, 'a1', {'org': 'HL7'})
+ with self.assertNumQueries(5 + owner):
+ self.assertEqual(resolve('CodeSystem', LOCAL, 'b1', {'org': 'HL7'}).id, self.local_b1.id)
+ for number in range(20):
+ org = OrganizationFactory(mnemonic=f'holder-{number}')
+ source(org, 'atc', canonical_url=ATC)
+ source(org, 'atc', '2023AA', released=True, canonical_url=ATC)
+ source(self.hl7, f'local-{number}', canonical_url=LOCAL)
+ source(self.hl7, f'local-{number}', 'b1', released=True, canonical_url=LOCAL)
+ with self.assertNumQueries(3 + extra + holder):
+ self.assertEqual(resolve('CodeSystem', ATC, '2023AA').id, self.who_atc_2023.id)
+ with self.assertNumQueries(5 + owner):
+ self.assertEqual(resolve('CodeSystem', LOCAL, 'b1', {'org': 'HL7'}).id, self.local_b1.id)
+
+
+class HiddenHolderCases(NamedVersionCorpusMixin):
+ """A repository or version the caller isn't shown never holds the version named: the same bytes and statements as
+ a world without it, at /fhir/ and at an owner base."""
+
+ def answers(self, requests):
+ """{request: (status, content, SQL statements)} for (base, user, operation, params) requests."""
+ found = {}
+ for base, user, operation, params in requests:
+ with CaptureQueriesContext(connection) as context:
+ response = self.get(operation, base=base, user=user, **params)
+ content = json.loads(response.content)
+ content.pop('meta', None) # a search Bundle's lastUpdated is the time it was made
+ found[(base, user.username if user else None, operation, tuple(sorted(params.items())))] = (
+ response.status_code, content, [query['sql'] for query in context.captured_queries])
+ return found
+
+ def test_at_the_global_base(self):
+ """A private repository with the canonical, a public one's private release, and a public release of a private
+ repository: none is found, for anyone, and the answers and statements are those without them."""
+ private_owner = UserProfileFactory(username='jdoe')
+ source(private_owner, 'icd-private', canonical_url=ICD10, public_access=ACCESS_TYPE_NONE)
+ source(private_owner, 'icd-private', 'p9', released=True, canonical_url=ICD10, public_access=ACCESS_TYPE_NONE)
+ source(self.sehatapp, 'ICD-10', 'v8', released=True, canonical_url=ICD10, public_access=ACCESS_TYPE_NONE)
+ moh = OrganizationFactory(mnemonic='MOH')
+ source(moh, 'icd-moh', canonical_url=ICD10, public_access=ACCESS_TYPE_NONE)
+ source(moh, 'icd-moh', 'v7', released=True, canonical_url=ICD10)
+ requests = [('/fhir', user, operation, {**params, 'version': version})
+ for user in (None, self.outsider, private_owner, self.admin) for version in ('p9', 'v8', 'v7')
+ for operation, params in (('$lookup', {'system': ICD10, 'code': 'X'}),
+ ('$validate-code', {'url': ICD10, 'code': 'X'}))]
+ with_them = self.answers(requests)
+ for (_, _, operation, _), (status, _, _) in with_them.items():
+ self.assertEqual(status, {'$lookup': 422, '$validate-code': 200}[operation])
+ searched = self.answers([('/fhir', None, '', {'url': ICD10, 'version': version}) for version in ('p9', 'v7')])
+ capabilities = client_for(self.admin).get('/fhir/metadata', {'mode': 'terminology'}).content
+ self.assertNotIn(b'p9', capabilities)
+ private_owner.source_set.all().delete()
+ moh.source_set.all().delete()
+ self.sehatapp.source_set.filter(version='v8').delete()
+ self.assertEqual(self.answers(requests), with_them)
+ self.assertEqual(client_for(self.admin).get('/fhir/metadata', {'mode': 'terminology'}).content, capabilities)
+ # search doesn't resolve: its answer is the same (its statements name every repository with the URL, as before)
+ self.assertEqual({key: answer[:2] for key, answer in self.answers(
+ [('/fhir', None, '', {'url': ICD10, 'version': version}) for version in ('p9', 'v7')]).items()},
+ {key: answer[:2] for key, answer in searched.items()})
+
+ def test_at_an_owner_base(self):
+ """HL7's private local-private holds p1: those who may not view it answer as if it weren't there; members and
+ staff are served it."""
+ private = source(self.hl7, 'local-private', canonical_url=LOCAL, public_access=ACCESS_TYPE_NONE)
+ p1 = source(self.hl7, 'local-private', 'p1', released=True, canonical_url=LOCAL, public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=private, mnemonic='P1', sources=[p1], names=[name('Private one', preferred=True)])
+ for user in (self.hl7_member, self.admin):
+ self.assertEqual(self.resolved(LOCAL, 'p1', {'org': 'HL7'}, user), p1.id, user)
+ self.assertEqual(self.get('$lookup', '/orgs/HL7', user, system=LOCAL, version='p1', code='P1').status_code,
+ 200, user)
+ requests = [('/orgs/HL7', user, '$lookup', {'system': LOCAL, 'version': 'p1', 'code': 'P1'})
+ for user in (None, self.outsider)]
+ requests += [('/orgs/HL7', user, '$validate-code', {'url': LOCAL, 'version': 'p1', 'code': 'P1'})
+ for user in (None, self.outsider)]
+ with_it = self.answers(requests)
+ capabilities = client_for(self.outsider).get('/orgs/HL7/metadata', {'mode': 'terminology'}).content
+ Source.objects.filter(organization=self.hl7, mnemonic='local-private').delete()
+ self.assertEqual(self.answers(requests), with_it)
+ self.assertEqual(client_for(self.outsider).get('/orgs/HL7/metadata', {'mode': 'terminology'}).content,
+ capabilities)
+
+ def test_a_hidden_first_choice_counts_as_missing(self): # pylint: disable=too-many-locals
+ """
+ Lab's private first-private is its lowest-id code system with the canonical. With a version named, those who
+ may not view it are answered from second-public as if first-private weren't there: its release, its unreleased
+ version, its HEAD, or not found. Anonymously, a release or HEAD in as many queries; otherwise one fewer, the
+ version of second-public the resolver reads when it picks it, as a hidden first choice resolves to nothing
+ before any version is read (decision A.3, as today). With none named, A.3 is unchanged: not found.
+ """
+ lab = OrganizationFactory(mnemonic='Lab')
+ lab_member = UserProfileFactory(username='lab-member')
+ lab.members.add(lab_member)
+ hidden = source(lab, 'first-private', canonical_url=FIRST, public_access=ACCESS_TYPE_NONE)
+ hidden_v1 = source(lab, 'first-private', 'v1', released=True, canonical_url=FIRST,
+ public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=hidden, mnemonic='C1', sources=[hidden_v1], names=[name('Hidden one', preferred=True)])
+ shown = source(lab, 'second-public', canonical_url=FIRST)
+ releases = [source(lab, 'second-public', version, released=True, canonical_url=FIRST)
+ for version in ('v1', 'v2')]
+ unreleased = source(lab, 'second-public', 'u1', canonical_url=FIRST)
+ ConceptFactory(parent=shown, mnemonic='C1', sources=[*releases, unreleased],
+ names=[name('Shown one', preferred=True)])
+ kwargs = {'org': 'Lab'}
+ self.assertEqual(self.resolved(FIRST, 'v1', kwargs, lab_member), hidden_v1.id)
+ expected = {'v1': releases[0], 'v2': releases[1], 'u1': unreleased, HEAD: shown}
+ for user in (None, self.outsider):
+ for version, instance in expected.items():
+ self.assertEqual(self.resolved(FIRST, version, kwargs, user), instance.id, (user, version))
+ self.assertUnresolved(FIRST, None, kwargs, user) # decision A.3
+
+ def answers():
+ found = {}
+ for user in (None, self.outsider):
+ for version in (*expected, 'nope'):
+ with CaptureQueriesContext(connection) as context:
+ response = self.get('$lookup', '/orgs/Lab', user, system=FIRST, version=version, code='C1')
+ found[(user, version)] = (response.status_code, response.content, len(context.captured_queries))
+ return found
+
+ with_it = answers()
+ Source.objects.filter(organization=lab, mnemonic='first-private').delete()
+ without = answers()
+ for key, (status, content, count) in with_it.items():
+ self.assertEqual((status, content), without[key][:2], key)
+ if key[0] is None: # anonymous: no membership to check
+ self.assertEqual(count, without[key][2] - (key[1] in ('u1', 'nope')), key)
+
+ def test_a_hidden_holder_alone_costs_what_an_unknown_canonical_does(self):
+ """Anonymous, at an owner base, with a version named: a private code system costs an unknown one's queries.
+ With the registry an owner-base match stops the fall-through to the global entry (decision A.3)."""
+ if self.registry:
+ self.skipTest('with the registry, an owner-base match stops the fall-through (decision A.3)')
+ lab = OrganizationFactory(mnemonic='Lab')
+ source(lab, 'only-private', canonical_url=ONLY, public_access=ACCESS_TYPE_NONE)
+ source(lab, 'only-private', 'x1', released=True, canonical_url=ONLY, public_access=ACCESS_TYPE_NONE)
+ counts = []
+ for system in (ONLY, 'http://example.org/fhir/CodeSystem/unknown'):
+ with CaptureQueriesContext(connection) as context:
+ response = self.get('$lookup', '/orgs/Lab', system=system, version='x1', code='X')
+ self.assertEqual(response.status_code, 422, system)
+ counts.append(len(context.captured_queries))
+ self.assertEqual(counts[0], counts[1])
+
+ def test_a_release_found_through_the_fallback_costs_what_no_hidden_repository_does(self):
+ """
+ Three of Lab's code systems share a canonical: private first-private (the lowest id) holds a released v2,
+ second-public holds no v2, third-public holds v2 released. Anonymously and for a signed-in non-member, v2
+ named is third-public's with and without first-private, byte for byte, so nothing in the answer says the
+ hidden one is there. The statement count still differs by one: anonymously one more without it, the version
+ read of second-public the resolver makes when it picks it, since a HEAD the caller isn't shown resolves to
+ nothing before any version is read; for a non-member, without the registry, one fewer, its check of the
+ right to the hidden repository. That is the A.3 residual of the shared resolver's early stop, not of this
+ rule: the same deltas were measured on the code before this rule, where each world answers 422 instead of
+ 200. Closing them needs a shared-resolver change.
+ """
+ lab = OrganizationFactory(mnemonic='Lab')
+ hidden = source(lab, 'first-private', canonical_url=THREE, public_access=ACCESS_TYPE_NONE)
+ ConceptFactory(parent=hidden, mnemonic='C1', names=[name('Hidden one', preferred=True)], sources=[source(
+ lab, 'first-private', 'v2', released=True, canonical_url=THREE, public_access=ACCESS_TYPE_NONE)])
+ second = source(lab, 'second-public', canonical_url=THREE)
+ ConceptFactory(parent=second, mnemonic='C1', names=[name('Second one', preferred=True)],
+ sources=[source(lab, 'second-public', 'v1', released=True, canonical_url=THREE)])
+ third = source(lab, 'third-public', canonical_url=THREE)
+ third_v2 = source(lab, 'third-public', 'v2', released=True, canonical_url=THREE)
+ ConceptFactory(parent=third, mnemonic='C1', sources=[third_v2], names=[name('Third one', preferred=True)])
+ self.assertEqual(self.resolved(THREE, 'v2', {'org': 'Lab'}), third_v2.id)
+
+ def answers():
+ found = {}
+ for user in (None, self.outsider):
+ with CaptureQueriesContext(connection) as context:
+ response = self.get('$lookup', '/orgs/Lab', user, system=THREE, version='v2', code='C1')
+ found[user] = (response.status_code, response.content, len(context.captured_queries))
+ return found
+
+ with_it = answers()
+ Source.objects.filter(organization=lab, mnemonic='first-private').delete()
+ without = answers()
+ deltas = {None: 1, self.outsider: 0 if self.registry else -1} # statements without it, less those with it
+ for user, (status, content, count) in with_it.items():
+ self.assertEqual(status, 200, user)
+ self.assertEqual((status, content), without[user][:2], user)
+ self.assertEqual(without[user][2] - count, deltas[user], user)
+
+ def test_a_repository_whose_releases_are_all_private_is_answered_as_a_missing_one(self):
+ """
+ Lab's first-open-head is its lowest-id code system with the canonical: its HEAD is public, every release of
+ it is private, and it holds no v2. second-public holds v2 released. A repository with no release the base
+ shows the caller is not shown at all, whatever version is named, so v2 named is second-public's with and
+ without first-open-head, byte for byte, anonymously and for a signed-in non-member, under either registry
+ state. It costs one statement more with it: the shared resolver shows its public HEAD, so it reads the v2
+ it doesn't have before the holder is looked for. Before this rule the same corpus answered 422 with it and
+ 200 without, in two statements fewer; the rule closes the difference in the answer and leaves the one
+ statement, which needs a shared-resolver change to close.
+ """
+ lab = OrganizationFactory(mnemonic='Lab')
+ first = source(lab, 'first-open-head', canonical_url=OPEN_HEAD)
+ ConceptFactory(parent=first, mnemonic='C1', names=[name('First one', preferred=True)], sources=[source(
+ lab, 'first-open-head', 'v1', released=True, canonical_url=OPEN_HEAD, public_access=ACCESS_TYPE_NONE)])
+ second = source(lab, 'second-public', canonical_url=OPEN_HEAD)
+ second_v2 = source(lab, 'second-public', 'v2', released=True, canonical_url=OPEN_HEAD)
+ ConceptFactory(parent=second, mnemonic='C1', sources=[second_v2], names=[name('Second one', preferred=True)])
+ self.assertEqual(self.resolved(OPEN_HEAD, 'v2', {'org': 'Lab'}), second_v2.id)
+
+ requests = [('/orgs/Lab', user, '$lookup', {'system': OPEN_HEAD, 'version': 'v2', 'code': 'C1'})
+ for user in (None, self.outsider)]
+ with_it = self.answers(requests)
+ Source.objects.filter(organization=lab, mnemonic='first-open-head').delete()
+ without = self.answers(requests)
+ for key, (status, content, sql) in with_it.items():
+ self.assertEqual(status, 200, key)
+ self.assertEqual((status, content), without[key][:2], key)
+ self.assertEqual(len(without[key][2]) - len(sql), -1, key) # one more with it: the v2 it doesn't have
+
+
+class NamedVersionOperationsCases(NamedVersionCorpusMixin):
+ """Every FHIR operation that resolves by canonical URL and version, GET and POST."""
+
+ def test_lookup(self):
+ for base, system, version, code, display, served in (
+ ('/fhir', ICD10, 'v1.0', 'D64.2', 'Secondary sideroblastic anaemia due to drugs and toxins',
+ not self.registry),
+ ('/fhir', ATC, '2023AA', 'R02AB01', 'neomycin', True),
+ ('/orgs/HL7', LOCAL, 'b1', 'B1', 'B one', True),
+ ('/fhir', LOCAL, 'b1', 'B1', 'B one', not self.registry)):
+ answers = [
+ self.get('$lookup', base, system=system, version=version, code=code),
+ self.get('$lookup', base, system=f'{system}|{version}', code=code),
+ self.post('$lookup', [{'name': 'system', 'valueUri': system}, {'name': 'code', 'valueCode': code},
+ {'name': 'version', 'valueString': version}], base),
+ self.post('$lookup', [{'name': 'coding', 'valueCoding': {
+ 'system': system, 'code': code, 'version': version}}], base)]
+ for response in answers:
+ if not served:
+ self.assertEqual(response.status_code, 422, (base, system, response.data))
+ continue
+ self.assertEqual(response.status_code, 200, (base, system, response.data))
+ answer = values(response)
+ self.assertEqual((answer['version'], answer['display'], answer['system']), (version, display, system))
+
+ def test_lookup_query_counts(self):
+ """$lookup's fixed number of queries (resolution, the concept, its names; the registry adds its entry and the
+ namespace's owner): a release its holder serves costs one more than the first choice's, however many
+ repositories hold the canonical URL."""
+ extra = 2 if self.registry else 0
+ holder = 0 if self.registry else 1 # with the registry, WHO's entry decides: WHOATC is the first choice
+ with self.assertNumQueries(5 + extra):
+ self.assertEqual(self.get('$lookup', system=ICD10, version='ICD102019', code='S20.4').status_code, 200)
+ for number in range(10):
+ org = OrganizationFactory(mnemonic=f'holder-{number}')
+ source(org, 'atc', canonical_url=ATC)
+ source(org, 'atc', '2023AA', released=True, canonical_url=ATC)
+ with self.assertNumQueries(5 + extra + holder):
+ self.assertEqual(self.get('$lookup', system=ATC, version='2023AA', code='R02AB01').status_code, 200)
+
+ def test_code_system_validate_code(self):
+ for base, url, version, code, served in (
+ ('/fhir', ICD10, 'v1.0', 'D64.2', not self.registry), ('/fhir', ATC, '2023AA', 'R02AB01', True),
+ ('/orgs/HL7', LOCAL, 'b1', 'B1', True)):
+ answers = [
+ self.get('$validate-code', base, url=url, version=version, code=code),
+ self.get('$validate-code', base, url=f'{url}|{version}', code=code),
+ self.post('$validate-code', [{'name': 'url', 'valueUri': url}, {'name': 'code', 'valueCode': code},
+ {'name': 'version', 'valueString': version}], base),
+ self.post('$validate-code', [{'name': 'url', 'valueUri': url}, {'name': 'coding', 'valueCoding': {
+ 'system': url, 'code': code, 'version': version}}], base)]
+ for response in answers:
+ self.assertEqual(response.status_code, 200, (base, url, response.data))
+ answer = values(response)
+ self.assertIs(answer['result'], served, (base, url, answer))
+ if served:
+ self.assertEqual(answer['version'], version)
+ else:
+ self.assertEqual(answer['x-caused-by-unknown-system'], f'{url}|{version}')
+
+ def test_value_set_validate_code(self):
+ """The value set valueSetVersion names: set-b's v2, which holds sehatapp's D64.2."""
+ for base, served in (('/fhir', not self.registry), ('/orgs/Acme', True)):
+ answers = [
+ self.get('$validate-code', base, resource='ValueSet', url=SETS, valueSetVersion='v2', system=ICD10,
+ code='D64.2'),
+ self.get('$validate-code', base, resource='ValueSet', url=f'{SETS}|v2', system=ICD10, code='D64.2'),
+ self.post('$validate-code', [
+ {'name': 'url', 'valueUri': SETS}, {'name': 'valueSetVersion', 'valueString': 'v2'},
+ {'name': 'coding', 'valueCoding': {'system': ICD10, 'code': 'D64.2'}}], base, resource='ValueSet')]
+ for response in answers:
+ if not served:
+ self.assertEqual(response.status_code, 422, (base, response.data))
+ continue
+ self.assertEqual(response.status_code, 200, (base, response.data))
+ answer = values(response)
+ self.assertEqual((answer['result'], answer['version']), (True, 'v1.0'), base)
+
+ def test_value_set_validate_code_resolves_a_members_system_and_version(self):
+ """D64.2 isn't in set-a's v1: the answer says what its code system, ICD-10 at systemVersion v1.0, says of it,
+ which is sehatapp's (without the registry; with it, the global entry's WHO doesn't hold v1.0)."""
+ answers = [
+ self.get('$validate-code', resource='ValueSet', url=SETS, valueSetVersion='v1', system=ICD10,
+ systemVersion='v1.0', code='D64.2'),
+ self.post('$validate-code', [
+ {'name': 'url', 'valueUri': SETS}, {'name': 'valueSetVersion', 'valueString': 'v1'},
+ {'name': 'coding', 'valueCoding': {'system': ICD10, 'code': 'D64.2', 'version': 'v1.0'}}],
+ resource='ValueSet')]
+ for response in answers:
+ self.assertEqual(response.status_code, 200, response.data)
+ answer = values(response)
+ self.assertIs(answer['result'], False)
+ if self.registry:
+ self.assertEqual(answer['x-unknown-system'], f'{ICD10}|v1.0')
+ else:
+ self.assertNotIn('x-unknown-system', answer)
+ self.assertEqual((answer['version'], answer['display']),
+ ('v1.0', 'Secondary sideroblastic anaemia due to drugs and toxins'))
+
+ def test_expand(self):
+ for base, served in (('/fhir', not self.registry), ('/orgs/Acme', True)):
+ answers = [
+ self.get('$expand', base, resource='ValueSet', url=SETS, valueSetVersion='v2'),
+ self.get('$expand', base, resource='ValueSet', url=f'{SETS}|v2'),
+ self.post('$expand', [{'name': 'url', 'valueUri': SETS},
+ {'name': 'valueSetVersion', 'valueString': 'v2'}], base, resource='ValueSet')]
+ for response in answers:
+ if not served:
+ self.assertEqual(response.status_code, 422, (base, response.data))
+ continue
+ self.assertEqual(response.status_code, 200, (base, response.data))
+ self.assertEqual((response.data['url'], response.data['version']), (SETS, 'v2'))
+ self.assertEqual([(item['system'], item['code']) for item in response.data['expansion']['contains']],
+ [(ICD10, 'D64.2')])
+ self.assertEqual(self.get('$expand', resource='ValueSet', url=SETS).data['version'], 'v1') # the default
+
+ def test_translate(self):
+ """The code system version a code's system and version name, whose own mappings are its map; and a ConceptMap
+ by url and conceptMapVersion."""
+ for base, system, version, code, match, served in (
+ ('/fhir', ICD10, 'v1.0', 'D64.2', f'{ICD10}|S20.4|equivalent', not self.registry),
+ ('/orgs/HL7', LOCAL, 'b1', 'B1', f'{LOCAL}|A1|equivalent', True)):
+ answers = [
+ client_for().get(f'{base}/ConceptMap/$translate', {'system': system, 'version': version, 'code': code}),
+ client_for().get(f'{base}/ConceptMap/$translate', {'system': f'{system}|{version}', 'code': code}),
+ client_for().post(f'{base}/ConceptMap/$translate', {'resourceType': 'Parameters', 'parameter': [
+ {'name': 'coding', 'valueCoding': {'system': system, 'code': code, 'version': version}}]},
+ format='json'),
+ client_for().get(f'{base}/ConceptMap/$translate', {
+ 'url': system, 'conceptMapVersion': version, 'system': system, 'code': code})]
+ for index, response in enumerate(answers):
+ if not served and index == 3: # the ConceptMap url names doesn't resolve
+ self.assertEqual(response.status_code, 404, response.data)
+ continue
+ self.assertEqual(response.status_code, 200, (base, index, response.data))
+ answer = values(response)
+ if served:
+ self.assertEqual((answer['result'], answer['matches']), (True, [match]), (base, index))
+ else:
+ self.assertEqual((answer['result'], answer['message']), (
+ False, f"No ConceptMap is available to translate from '{system}' to '(unspecified)'"))
+
+ def test_search_by_url_and_version_finds_the_holders_release(self):
+ """Search lists every version the base shows with the URL, so it finds the holder's as it always has."""
+ for resource, url, version, expected in (('CodeSystem', ICD10, 'v1.0', self.sehat_v1),
+ ('ValueSet', SETS, 'v2', self.set_b2)):
+ response = client_for().get(f'/fhir/{resource}/', {'url': url, 'version': version})
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual([entry['resource']['id'] for entry in response.data['entry']], [str(expected.id)])
+
+ def test_terminology_capabilities_lists_the_holders_versions_under_the_canonical(self):
+ """
+ Each canonical lists the releases its first choice serves, then those of the other repositories the base shows
+ with it that the operations now serve by name, the default unchanged; with the registry, where an entry
+ decides, the entry's repository's alone. Each listed version resolves.
+ """
+ def listed(fhir_base):
+ response = client_for().get(f'{fhir_base}/metadata', {'mode': 'terminology'})
+ return {item['uri']: [version['code'] + ('*' if version.get('isDefault') else '')
+ for version in item.get('version', [])] for item in response.data['codeSystem']}
+
+ at_fhir = listed('/fhir')
+ if self.registry:
+ self.assertEqual((at_fhir[ICD10], at_fhir[ATC], at_fhir[LOCAL]), (['ICD102019*'], ['2023AA*'], ['a1*']))
+ else:
+ self.assertEqual((at_fhir[ICD10], at_fhir[ATC], at_fhir[LOCAL]),
+ (['ICD102019*', 'v1.0'], ['2026-04-25*', '2023AA'], ['a1*', 'b1', 'c1']))
+ self.assertEqual(at_fhir['/orgs/sehatapp/CodeSystem/ICD-10/'], ['v1.0*'])
+ self.assertEqual(listed('/orgs/HL7')[LOCAL], ['a1*', 'b1'])
+ self.assertEqual(listed('/orgs/Acme')[LOCAL], ['c1*'])
+ for fhir_base, kwargs in (('/fhir', {}), ('/orgs/HL7', {'org': 'HL7'})):
+ for uri, codes in listed(fhir_base).items():
+ for code in codes:
+ instance = resolve('CodeSystem', uri, code.rstrip('*'), kwargs)
+ self.assertEqual(instance.version, code.rstrip('*'), (fhir_base, uri, code))
+ default = next(code.rstrip('*') for code in codes if code.endswith('*'))
+ self.assertEqual(resolve('CodeSystem', uri, None, kwargs).version, default, (fhir_base, uri))
+
+
+class NamedVersionResolveWithoutRegistryTest(NamedVersionResolveCases, OCLTestCase):
+ registry = False
+
+
+class NamedVersionResolveWithRegistryTest(NamedVersionResolveCases, OCLTestCase):
+ registry = True
+
+
+class HiddenHolderWithoutRegistryTest(HiddenHolderCases, OCLTestCase):
+ registry = False
+
+
+class HiddenHolderWithRegistryTest(HiddenHolderCases, OCLTestCase):
+ registry = True
+
+
+class NamedVersionOperationsWithoutRegistryTest(NamedVersionOperationsCases, OCLTestCase):
+ registry = False
+
+
+class NamedVersionOperationsWithRegistryTest(NamedVersionOperationsCases, OCLTestCase):
+ registry = True
diff --git a/core/fhir/tests_resolve_named_version_access.py b/core/fhir/tests_resolve_named_version_access.py
new file mode 100644
index 000000000..c0b5e3dd7
--- /dev/null
+++ b/core/fhir/tests_resolve_named_version_access.py
@@ -0,0 +1,574 @@
+"""
+What a base doesn't show a caller never reaches them through a version named (decision 9), and nothing in an answer
+names it. The corpus holds, under one canonical URL, a private first choice that holds the versions, a public one
+that holds some, and a public one that holds the rest; the same for value sets. Every operation that resolves a
+canonical URL and a version named is asked for each version, GET and POST, at /fhir/ and at an owner base, with the
+URL registry toggle off and on, anonymously, as a signed-in non-member, as a member and as staff, and the answers are
+compared with the hidden repository and without it. The other shapes a repository or a version can take are here too:
+a public repository whose named release is private, one with nothing released, a retired or inactive HEAD, a retired
+or deleted release, two owners sharing a canonical and a version name, a version named HEAD, and a registry entry
+pointing at a repository the caller can't see. TerminologyCapabilities is checked on its own: every version it names
+resolves for the caller it was built for, and nothing else is named.
+OpenConceptLab/ocl_online#451.
+"""
+import json
+
+from core.collections.models import Collection
+from core.collections.tests.factories import ExpansionFactory
+from core.common.constants import ACCESS_TYPE_NONE, HEAD
+from core.common.tests import OCLTestCase
+from core.concepts.tests.factories import ConceptFactory
+from core.fhir.resolve import UnresolvedError, resolve
+from core.fhir.tests_ops_lookup import name
+from core.fhir.tests_resolve_named_version import client_for
+from core.fhir.tests_search import collection, source
+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, OrganizationURLRegistryFactory
+from core.url_registry.tests_resolution import set_registry_toggle
+from core.users.models import UserProfile
+from core.users.tests.factories import UserProfileFactory
+
+HELD = 'http://example.org/fhir/CodeSystem/held'
+SETS = 'http://example.org/fhir/ValueSet/held'
+TARGETS = 'http://example.org/fhir/CodeSystem/targets'
+SHARED = 'http://example.org/fhir/CodeSystem/shared'
+POINTED = 'http://example.org/fhir/CodeSystem/pointed'
+OWNED = 'http://example.org/fhir/CodeSystem/owned'
+PRIVATE_NAMES = ('Hidden one', 'hidden-first', 'vs-hidden', '"HID"') # only those shown them may be told these
+# a version name a request supplies is echoed in the not-found it gets back, so it is no sign of what OCL holds
+
+
+def operation_requests(fhir_base, version):
+ """
+ [(key, method, path, params or body)] for every operation that resolves a canonical URL and a version named, at
+ `fhir_base`, naming `version`: $lookup, CodeSystem and ValueSet $validate-code (a member's system and version
+ too), $expand, $translate (a code's system and version, a Coding, and a ConceptMap by url and version), and
+ search by url and version. GET with the version in its own parameter and piped onto the url, and POST.
+ """
+ code_system, value_set, concept_map = (f'{fhir_base}/{kind}' for kind in ('CodeSystem', 'ValueSet', 'ConceptMap'))
+ return [
+ ('lookup', 'GET', f'{code_system}/$lookup', {'system': HELD, 'version': version, 'code': 'C1'}),
+ ('lookup-piped', 'GET', f'{code_system}/$lookup', {'system': f'{HELD}|{version}', 'code': 'C1'}),
+ ('lookup-post', 'POST', f'{code_system}/$lookup',
+ [{'name': 'coding', 'valueCoding': {'system': HELD, 'code': 'C1', 'version': version}}]),
+ ('cs-validate', 'GET', f'{code_system}/$validate-code', {'url': HELD, 'version': version, 'code': 'C1'}),
+ ('cs-validate-piped', 'GET', f'{code_system}/$validate-code', {'url': f'{HELD}|{version}', 'code': 'C1'}),
+ ('cs-validate-post', 'POST', f'{code_system}/$validate-code',
+ [{'name': 'url', 'valueUri': HELD}, {'name': 'version', 'valueString': version},
+ {'name': 'code', 'valueCode': 'C1'}]),
+ ('vs-validate', 'GET', f'{value_set}/$validate-code',
+ {'url': SETS, 'valueSetVersion': version, 'system': HELD, 'code': 'C1'}),
+ ('vs-validate-post', 'POST', f'{value_set}/$validate-code',
+ [{'name': 'url', 'valueUri': SETS}, {'name': 'valueSetVersion', 'valueString': version},
+ {'name': 'coding', 'valueCoding': {'system': HELD, 'code': 'C1'}}]),
+ ('vs-validate-system', 'GET', f'{value_set}/$validate-code',
+ {'url': SETS, 'valueSetVersion': version, 'system': HELD, 'systemVersion': version, 'code': 'C1'}),
+ ('vs-validate-outside', 'GET', f'{value_set}/$validate-code', # not a member: its code system is resolved
+ {'url': SETS, 'valueSetVersion': version, 'system': HELD, 'systemVersion': version, 'code': 'C9'}),
+ ('expand', 'GET', f'{value_set}/$expand', {'url': SETS, 'valueSetVersion': version}),
+ ('expand-piped', 'GET', f'{value_set}/$expand', {'url': f'{SETS}|{version}'}),
+ ('expand-post', 'POST', f'{value_set}/$expand',
+ [{'name': 'url', 'valueUri': SETS}, {'name': 'valueSetVersion', 'valueString': version}]),
+ ('translate', 'GET', f'{concept_map}/$translate', {'system': HELD, 'version': version, 'code': 'C1'}),
+ ('translate-piped', 'GET', f'{concept_map}/$translate', {'system': f'{HELD}|{version}', 'code': 'C1'}),
+ ('translate-coding', 'POST', f'{concept_map}/$translate',
+ [{'name': 'coding', 'valueCoding': {'system': HELD, 'code': 'C1', 'version': version}}]),
+ ('translate-map', 'GET', f'{concept_map}/$translate',
+ {'url': HELD, 'conceptMapVersion': version, 'system': HELD, 'code': 'C1'}),
+ ('search-cs', 'GET', f'{code_system}/', {'url': HELD, 'version': version}),
+ ('search-vs', 'GET', f'{value_set}/', {'url': SETS, 'version': version}),
+ ]
+
+
+def answers(requests, user=None):
+ """{key: (status, content)} for (key, method, path, params or body) requests, a Bundle's own meta dropped."""
+ client = client_for(user)
+ found = {}
+ for key, method, path, payload in requests:
+ if method == 'GET':
+ response = client.get(path, payload)
+ else:
+ response = client.post(path, json.dumps({'resourceType': 'Parameters', 'parameter': payload}),
+ content_type='application/fhir+json')
+ content = json.loads(response.content)
+ content.pop('meta', None) # a search Bundle's lastUpdated is the time it was made
+ found[key] = (response.status_code, content)
+ return found
+
+
+def terminology_capabilities(fhir_base, user=None):
+ return client_for(user).get(f'{fhir_base}/metadata', {'mode': 'terminology'})
+
+
+class AccessCorpusMixin:
+ """
+ Lab's three code systems share the canonical HELD: private hidden-first (the lowest id) holds v1 and secret,
+ public open-second holds v1 and a private v5 and p9, public open-third holds v2 and a public v5. Each has a C1,
+ with its own display and its own mapping into Lab's public targets, so an answer says which one served it. Lab's
+ two value sets share SETS the same way: private vs-hidden holds v1 and secret, public vs-open holds v2, whose
+ expansion holds hidden-first's C1 beside open-third's. Alpha and Beta share SHARED under the same version name
+ s1, Beta's privately. With the registry, a global entry sends each canonical to an owner that holds it.
+ """
+ registry = False
+
+ def setUp(self):
+ set_registry_toggle(self.registry)
+ super().setUp()
+ self.admin = UserProfile.objects.get(username='ocladmin')
+ self.lab = OrganizationFactory(mnemonic='Lab')
+ self.lab_member = UserProfileFactory(username='lab-member')
+ self.lab.members.add(self.lab_member)
+ self.outsider = UserProfileFactory(username='outsider')
+
+ self.targets = source(self.lab, 'targets', canonical_url=TARGETS)
+ targets_t1 = source(self.lab, 'targets', 't1', released=True, canonical_url=TARGETS)
+ for code in ('HID', 'SEC', 'THI'):
+ ConceptFactory(parent=self.targets, mnemonic=code, sources=[targets_t1],
+ names=[name(f'Target {code}', preferred=True)])
+
+ self.hidden, hidden_c1 = self.holder('hidden-first', 'Hidden one', 'HID', ['v1', 'secret'], private=True)
+ self.second, _ = self.holder('open-second', 'Second one', 'SEC', ['v1'], private_releases=['v5', 'p9'])
+ self.third, third_c1 = self.holder('open-third', 'Third one', 'THI', ['v2', 'v5'])
+
+ self.vs_hidden = collection(self.lab, 'vs-hidden', canonical_url=SETS, public_access=ACCESS_TYPE_NONE)
+ self.vs_hidden_v1 = self.value_set('vs-hidden', 'v1', hidden_c1, public_access=ACCESS_TYPE_NONE)
+ self.vs_hidden_secret = self.value_set('vs-hidden', 'secret', hidden_c1, public_access=ACCESS_TYPE_NONE)
+ self.vs_open = collection(self.lab, 'vs-open', canonical_url=SETS)
+ self.vs_open_v2 = self.value_set('vs-open', 'v2', third_c1, hidden_c1)
+
+ self.alpha = OrganizationFactory(mnemonic='Alpha')
+ self.beta = OrganizationFactory(mnemonic='Beta')
+ self.beta_member = UserProfileFactory(username='beta-member')
+ self.beta.members.add(self.beta_member)
+ source(self.beta, 'beta-shared', canonical_url=SHARED, public_access=ACCESS_TYPE_NONE)
+ self.beta_s1 = source(self.beta, 'beta-shared', 's1', released=True, canonical_url=SHARED,
+ public_access=ACCESS_TYPE_NONE)
+ source(self.alpha, 'alpha-shared', canonical_url=SHARED)
+ self.alpha_s1 = source(self.alpha, 'alpha-shared', 's1', released=True, canonical_url=SHARED)
+
+ if self.registry:
+ for url, owner in ((HELD, self.lab), (SETS, self.lab), (TARGETS, self.lab), (SHARED, self.alpha)):
+ GlobalURLRegistryFactory(url=url, namespace=owner.uri)
+
+ def tearDown(self):
+ set_registry_toggle(False)
+ super().tearDown()
+
+ def holder(self, mnemonic, display, target, releases, private=False, private_releases=()): # pylint: disable=too-many-arguments
+ """(HEAD, C1) for a code system of Lab with the canonical HELD, its releases, and a C1 mapping to `target`."""
+ access = {'public_access': ACCESS_TYPE_NONE} if private else {}
+ head = source(self.lab, mnemonic, canonical_url=HELD, **access)
+ rows = [source(self.lab, mnemonic, version, released=True, canonical_url=HELD, **access)
+ for version in releases]
+ rows += [source(self.lab, mnemonic, version, released=True, canonical_url=HELD,
+ public_access=ACCESS_TYPE_NONE) for version in private_releases]
+ concept = ConceptFactory(parent=head, mnemonic='C1', sources=rows, names=[name(display, preferred=True)])
+ MappingFactory(parent=head, map_type='SAME-AS', from_concept=None, to_concept=None, from_source=head,
+ from_source_url=head.uri, from_concept_code='C1', to_source=self.targets,
+ to_source_url=self.targets.uri, to_concept_code=target, sources=rows)
+ return head, concept
+
+ def value_set(self, mnemonic, version, *concepts, **kwargs):
+ release = collection(self.lab, mnemonic, version, released=True, canonical_url=SETS, **kwargs)
+ expansion = ExpansionFactory(mnemonic=f'autoexpand-{mnemonic}-{version}', collection_version=release)
+ expansion.concepts.add(*concepts)
+ release.expansion_uri = expansion.uri
+ release.save()
+ return release
+
+ def row(self, mnemonic, version):
+ return Source.objects.get(organization=self.lab, mnemonic=mnemonic, version=version)
+
+ def drop_the_hidden_repositories(self):
+ Source.objects.filter(organization=self.lab, mnemonic='hidden-first').delete()
+ Collection.objects.filter(organization=self.lab, mnemonic='vs-hidden').delete()
+
+ def resolved(self, url, version=None, kwargs=None, user=None, resource_type='CodeSystem'): # pylint: disable=too-many-arguments
+ return resolve(resource_type, url, version, kwargs, user).id
+
+ def assertUnresolved(self, url, version=None, kwargs=None, user=None, resource_type='CodeSystem'): # pylint: disable=invalid-name,too-many-arguments
+ with self.assertRaises(UnresolvedError):
+ resolve(resource_type, url, version, kwargs, user)
+
+
+class HiddenIsMissingCases(AccessCorpusMixin):
+ """A repository the base doesn't show the caller changes nothing in any answer it gives them."""
+
+ versions = ('v1', 'v2', 'secret', 'nope')
+
+ def requests_for(self, fhir_base):
+ return [(f'{version}:{key}', method, path, payload) for version in self.versions
+ for key, method, path, payload in operation_requests(fhir_base, version)]
+
+ def test_at_an_owner_base_every_operation_answers_as_if_they_were_not_there(self):
+ """
+ Anonymously and signed in as a non-member, at Lab's base, with the registry off and on: every operation that
+ resolves, for every version the hidden repositories hold and for versions they don't, answers the same bytes
+ with them and without them, and never names anything of theirs.
+ """
+ with_them = {}
+ for user in (None, self.outsider):
+ found = answers(self.requests_for('/orgs/Lab'), user)
+ body = json.dumps(found)
+ for private in PRIVATE_NAMES:
+ self.assertNotIn(private, body, user)
+ with_them[user] = found
+ self.drop_the_hidden_repositories()
+ for user, found in with_them.items():
+ after = answers(self.requests_for('/orgs/Lab'), user)
+ self.assertEqual([key for key in found if after[key] != found[key]], [], user)
+
+ def test_at_the_global_base_every_operation_answers_as_if_they_were_not_there(self):
+ """
+ The same at /fhir/, which shows public repositories only. With the registry a global entry decides, and the
+ entry's namespace is read as it is written: the private hidden-first is the lowest-id code system Lab holds
+ with the canonical, so with the registry on the entry stops there and nothing under HELD or SETS resolves
+ while it is there. That is the shared resolver's registry lookup, which decision 9 leaves alone; without the
+ registry the global base's own match passes it over and the answers are the same with it and without it.
+ """
+ with_them = {user: answers(self.requests_for('/fhir'), user) for user in (None, self.outsider)}
+ for user, found in with_them.items():
+ body = json.dumps(found)
+ for private in PRIVATE_NAMES:
+ self.assertNotIn(private, body, user)
+ self.drop_the_hidden_repositories()
+ for user, found in with_them.items():
+ after = answers(self.requests_for('/fhir'), user)
+ differing = [key for key in found if after[key] != found[key]]
+ self.assertEqual(differing, self.global_differences(), user)
+
+ def global_differences(self):
+ """
+ The requests /fhir/ answers differently with the hidden repositories and without them: none without the
+ registry, and with it those the entry would reach once hidden-first and vs-hidden are gone, which are the
+ versions open-second and vs-open hold (v1's code system operations, v2's value set ones). The shared
+ resolver reads an entry's namespace as it is written, so its lowest-id repository with the canonical is the
+ one the entry stops at whether or not the base shows it, and no other is tried; decision 9 leaves an entry
+ to decide. The same difference is there on the code before the rule.
+ """
+ if not self.registry:
+ return []
+ operations = {
+ 'v1': ('lookup', 'lookup-piped', 'lookup-post', 'cs-validate', 'cs-validate-piped', 'cs-validate-post',
+ 'translate', 'translate-piped', 'translate-coding', 'translate-map'),
+ 'v2': ('vs-validate', 'vs-validate-post', 'vs-validate-system', 'vs-validate-outside', 'expand',
+ 'expand-piped', 'expand-post')}
+ return [f'{version}:{key}' for version in self.versions for key in operations.get(version, ())]
+
+ def test_with_no_version_named_a_hidden_first_choice_still_decides(self):
+ """
+ With no version named the shared resolver stops at the lowest-id repository with the canonical, whether or
+ not the base shows it, so Lab's base answers not found while a world without hidden-first answers from
+ open-second. Decision 9 reads a version named, so it leaves this as it is; closing it needs a change in the
+ shared resolver. The same difference is there on the code before the rule.
+ """
+ lab = {'org': 'Lab'}
+ requests = [('cs', 'GET', '/orgs/Lab/CodeSystem/$lookup', {'system': HELD, 'code': 'C1'}),
+ ('vs', 'GET', '/orgs/Lab/ValueSet/$expand', {'url': SETS})]
+ with_them = {}
+ for user in (None, self.outsider):
+ self.assertUnresolved(HELD, None, lab, user)
+ self.assertUnresolved(SETS, None, lab, user, 'ValueSet')
+ with_them[user] = answers(requests, user)
+ self.assertEqual([status for status, _ in with_them[user].values()], [422, 422], user)
+ self.drop_the_hidden_repositories()
+ for user in (None, self.outsider):
+ self.assertEqual(self.resolved(HELD, None, lab, user), self.row('open-second', 'v1').id, user)
+ self.assertEqual([status for status, _ in answers(requests, user).values()], [200, 200], user)
+
+ def test_a_member_and_staff_are_served_what_the_hidden_repositories_hold(self):
+ """The positive control: the corpus does distinguish its holders, so the comparisons have something to show.
+ At Lab's base those shown the private repositories are served them; at /fhir/ nobody is."""
+ lab = {'org': 'Lab'}
+ for user in (self.lab_member, self.admin):
+ self.assertEqual(self.resolved(HELD, 'secret', lab, user), self.row('hidden-first', 'secret').id, user)
+ self.assertEqual(self.resolved(HELD, 'v1', lab, user), self.row('hidden-first', 'v1').id, user)
+ self.assertEqual(self.resolved(SETS, 'secret', lab, user, 'ValueSet'), self.vs_hidden_secret.id, user)
+ self.assertEqual(self.resolved(SETS, 'v1', lab, user, 'ValueSet'), self.vs_hidden_v1.id, user)
+ found = answers(operation_requests('/orgs/Lab', 'secret'), user)
+ self.assertEqual(found['lookup'][0], 200, user)
+ self.assertIn('Hidden one', json.dumps(found['lookup'][1]), user)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertUnresolved(HELD, 'secret', None, user)
+ self.assertUnresolved(SETS, 'secret', None, user, 'ValueSet')
+
+ def test_a_hidden_repository_never_blocks_a_shown_one(self):
+ """v1 is hidden-first's (the lowest id) and open-second's: those not shown the first are served the second's,
+ and v2, which only open-third holds, is open-third's."""
+ lab = {'org': 'Lab'}
+ for user in (None, self.outsider):
+ self.assertEqual(self.resolved(HELD, 'v1', lab, user), self.row('open-second', 'v1').id, user)
+ self.assertEqual(self.resolved(HELD, 'v2', lab, user), self.row('open-third', 'v2').id, user)
+ self.assertEqual(self.resolved(SETS, 'v2', lab, user, 'ValueSet'), self.vs_open_v2.id, user)
+ self.assertEqual(self.resolved(HELD, 'v1', lab, self.lab_member), self.row('hidden-first', 'v1').id)
+
+ def test_a_public_repositorys_private_release_is_never_served(self):
+ """open-second's v5 is private and open-third's is public: v5 named is open-third's to those not shown
+ open-second's, and open-second's to those shown it (the lower id). /fhir/ shows neither its private v5 nor,
+ with the registry, anything behind the entry's private first code system."""
+ lab = {'org': 'Lab'}
+ for user in (None, self.outsider):
+ self.assertEqual(self.resolved(HELD, 'v5', lab, user), self.row('open-third', 'v5').id, user)
+ for user in (self.lab_member, self.admin):
+ self.assertEqual(self.resolved(HELD, 'v5', lab, user), self.row('open-second', 'v5').id, user)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ if self.registry:
+ self.assertUnresolved(HELD, 'v5', None, user)
+ else:
+ self.assertEqual(self.resolved(HELD, 'v5', None, user), self.row('open-third', 'v5').id, user)
+
+ def test_an_expansion_lists_only_the_members_whose_code_system_the_caller_is_shown(self):
+ """vs-open's v2 holds hidden-first's C1 and open-third's: those not shown hidden-first are served the one,
+ those shown it both."""
+ for user, count in ((None, 1), (self.outsider, 1), (self.lab_member, 2), (self.admin, 2)):
+ response = client_for(user).get('/orgs/Lab/ValueSet/$expand', {'url': SETS, 'valueSetVersion': 'v2'})
+ self.assertEqual(response.status_code, 200, user)
+ self.assertEqual(len(response.data['expansion']['contains']), count, user)
+
+ def test_a_version_named_head_is_the_first_repository_the_base_shows(self):
+ """HEAD named is a repository's own HEAD, so it is the first one the base shows with a release it shows:
+ open-second's to a non-member, hidden-first's to a member."""
+ lab = {'org': 'Lab'}
+ for user in (None, self.outsider):
+ self.assertEqual(self.resolved(HELD, HEAD, lab, user), self.second.id, user)
+ for user in (self.lab_member, self.admin):
+ self.assertEqual(self.resolved(HELD, HEAD, lab, user), self.hidden.id, user)
+
+
+class RepositoryShapeCases(AccessCorpusMixin):
+ """The shapes a repository or a version can take that keep it out of a named version's answer."""
+
+ def test_a_repository_with_nothing_released_holds_no_version_named(self):
+ """open-draft's HEAD is public and d1, unreleased, is its only version: nobody is served d1, and
+ TerminologyCapabilities never names it."""
+ source(self.lab, 'open-draft', canonical_url=HELD)
+ source(self.lab, 'open-draft', 'd1', canonical_url=HELD)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertUnresolved(HELD, 'd1', {'org': 'Lab'}, user)
+ self.assertUnresolved(HELD, 'd1', None, user)
+ self.assertNotIn(b'"d1"', terminology_capabilities('/orgs/Lab', user).content)
+
+ def test_a_retired_or_inactive_head_holds_no_version_named(self):
+ """The resolver passes a retired HEAD over and an inactive one is no repository: their public releases are
+ not served under the canonical and are not named in TerminologyCapabilities."""
+ source(self.lab, 'open-retired', canonical_url=HELD, retired=True)
+ source(self.lab, 'open-retired', 'v7', released=True, canonical_url=HELD)
+ source(self.lab, 'open-inactive', canonical_url=HELD, is_active=False)
+ source(self.lab, 'open-inactive', 'v8', released=True, canonical_url=HELD)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ for version in ('v7', 'v8'):
+ self.assertUnresolved(HELD, version, {'org': 'Lab'}, user)
+ self.assertUnresolved(HELD, version, None, user)
+ body = terminology_capabilities('/orgs/Lab', user).content
+ self.assertNotIn(b'"v7"', body)
+ self.assertNotIn(b'"v8"', body)
+
+ def test_a_retired_release_is_served_beside_a_live_one_and_a_deleted_one_never_is(self):
+ retired = source(self.lab, 'open-third', 'v9', released=True, retired=True, canonical_url=HELD)
+ source(self.lab, 'open-third', 'v10', released=True, is_active=False, canonical_url=HELD)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertEqual(self.resolved(HELD, 'v9', {'org': 'Lab'}, user), retired.id, user)
+ self.assertUnresolved(HELD, 'v10', {'org': 'Lab'}, user)
+
+ def test_two_owners_sharing_a_canonical_and_a_version_name(self):
+ """Beta's beta-shared is private and holds s1, Alpha's alpha-shared is public and holds s1 too. /fhir/ and
+ Alpha's base serve Alpha's to everyone; Beta's base serves Beta's only to those shown it."""
+ for user in (None, self.outsider, self.beta_member, self.admin):
+ self.assertEqual(self.resolved(SHARED, 's1', None, user), self.alpha_s1.id, user)
+ self.assertEqual(self.resolved(SHARED, 's1', {'org': 'Alpha'}, user), self.alpha_s1.id, user)
+ for user in (None, self.outsider):
+ self.assertUnresolved(SHARED, 's1', {'org': 'Beta'}, user)
+ for user in (self.beta_member, self.admin):
+ self.assertEqual(self.resolved(SHARED, 's1', {'org': 'Beta'}, user), self.beta_s1.id, user)
+
+ def test_an_owner_base_never_reaches_another_owners_holder(self):
+ """Beta's public beta-open holds b2 under SHARED, which Alpha doesn't: Alpha's base finds nothing, Beta's
+ and /fhir/ find Beta's. The base comes from the route, so a version named never crosses it."""
+ source(self.beta, 'beta-open', canonical_url=SHARED)
+ b2 = source(self.beta, 'beta-open', 'b2', released=True, canonical_url=SHARED)
+ for user in (None, self.outsider, self.beta_member, self.admin):
+ self.assertUnresolved(SHARED, 'b2', {'org': 'Alpha'}, user)
+ self.assertEqual(self.resolved(SHARED, 'b2', {'org': 'Beta'}, user), b2.id, user)
+ if not self.registry: # with it, the global entry sends SHARED to Alpha, which holds no b2
+ self.assertEqual(self.resolved(SHARED, 'b2', None, user), b2.id, user)
+ else:
+ self.assertUnresolved(SHARED, 'b2', None, user)
+
+ def test_a_user_base_serves_the_users_own_by_the_callers_rights(self):
+ """jdoe's private jdoe-first (the lowest id) and public jdoe-second both hold u1 under one canonical: only
+ jdoe and staff are served the private one, everyone else the public one, and another owner's base finds
+ neither, and /fhir/ shows the public one to everyone, jdoe included. A user base reads the caller's rights
+ as an org base does."""
+ jdoe = UserProfileFactory(username='jdoe')
+ source(jdoe, 'jdoe-first', canonical_url=OWNED, public_access=ACCESS_TYPE_NONE)
+ first_u1 = source(jdoe, 'jdoe-first', 'u1', released=True, canonical_url=OWNED,
+ public_access=ACCESS_TYPE_NONE)
+ source(jdoe, 'jdoe-second', canonical_url=OWNED)
+ second_u1 = source(jdoe, 'jdoe-second', 'u1', released=True, canonical_url=OWNED)
+ base = {'user': 'jdoe'}
+ for user in (None, self.outsider, self.lab_member):
+ self.assertEqual(self.resolved(OWNED, 'u1', base, user), second_u1.id, user)
+ for user in (jdoe, self.admin):
+ self.assertEqual(self.resolved(OWNED, 'u1', base, user), first_u1.id, user)
+ for user in (None, self.outsider, jdoe, self.admin):
+ self.assertUnresolved(OWNED, 'u1', {'org': 'Lab'}, user) # another owner's base
+ if self.registry: # no global entry sends OWNED anywhere
+ self.assertUnresolved(OWNED, 'u1', None, user)
+ else: # /fhir/ shows the public one to everyone, jdoe included
+ self.assertEqual(self.resolved(OWNED, 'u1', None, user), second_u1.id, user)
+
+ def test_a_version_name_that_collides_with_a_repository_mnemonic(self):
+ """Lab's code system v2 shares the canonical and is named as open-third's release is: a version named is
+ read as a version, so v2 is open-third's release and r1 is the code system v2's."""
+ source(self.lab, 'v2', canonical_url=HELD)
+ r1 = source(self.lab, 'v2', 'r1', released=True, canonical_url=HELD)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertEqual(self.resolved(HELD, 'v2', {'org': 'Lab'}, user), self.row('open-third', 'v2').id, user)
+ self.assertEqual(self.resolved(HELD, 'r1', {'org': 'Lab'}, user), r1.id, user)
+
+ def test_a_registry_entry_pointing_where_the_caller_is_shown_nothing(self):
+ """
+ Lab's registry sends POINTED to Hid, whose code system is private, and Lab holds POINTED publicly too. With
+ the registry the entry decides: those not shown Hid's are answered not found and Lab's own is not tried,
+ staff is served Hid's. The answer is byte for byte the one a world where Hid holds nothing at all gives, so
+ it says nothing of Hid's code system. Without the registry Lab's own answers.
+ """
+ hid = OrganizationFactory(mnemonic='Hid')
+ source(hid, 'hid-pointed', canonical_url=POINTED, public_access=ACCESS_TYPE_NONE)
+ hidden_p1 = source(hid, 'hid-pointed', 'p1', released=True, canonical_url=POINTED,
+ public_access=ACCESS_TYPE_NONE)
+ source(self.lab, 'lab-pointed', canonical_url=POINTED)
+ lab_p1 = source(self.lab, 'lab-pointed', 'p1', released=True, canonical_url=POINTED)
+ OrganizationURLRegistryFactory(organization=self.lab, url=POINTED, namespace=hid.uri)
+ lab = {'org': 'Lab'}
+ if not self.registry:
+ for user in (None, self.outsider, self.admin):
+ self.assertEqual(self.resolved(POINTED, 'p1', lab, user), lab_p1.id, user)
+ return
+ with_it = {}
+ for user in (None, self.outsider):
+ self.assertUnresolved(POINTED, 'p1', lab, user)
+ with_it[user] = answers(pointed_requests(), user)
+ self.assertEqual(self.resolved(POINTED, 'p1', lab, self.admin), hidden_p1.id)
+ Source.objects.filter(organization=hid).delete()
+ for user in (None, self.outsider): # the entry still decides, and finds nothing: the same answer
+ self.assertUnresolved(POINTED, 'p1', lab, user)
+ self.assertEqual(answers(pointed_requests(), user), with_it[user], user)
+
+
+def pointed_requests():
+ """$lookup and CodeSystem $validate-code at Lab's base for POINTED's p1."""
+ return [('lookup', 'GET', '/orgs/Lab/CodeSystem/$lookup', {'system': POINTED, 'version': 'p1', 'code': 'C1'}),
+ ('validate', 'GET', '/orgs/Lab/CodeSystem/$validate-code',
+ {'url': POINTED, 'version': 'p1', 'code': 'C1'})]
+
+
+class CapabilitiesAccessCases(AccessCorpusMixin):
+ """TerminologyCapabilities names only what the base shows the caller, and every version it names resolves."""
+
+ def test_every_version_named_resolves_for_the_caller_it_was_built_for(self):
+ """
+ What TerminologyCapabilities names, the operations serve that caller, with one exception each way, both of
+ them the shared resolver's pick: a base names the versions of the lowest-id repository with a canonical that
+ it shows the caller, while with no version named the resolver stops at the lowest-id one there is, shown or
+ not, and with the registry on an entry's namespace is read the same way. So at Lab's base, where the
+ lowest-id code system with the canonical is private, those not shown it are named a default they are
+ answered not found for; and at /fhir/ with the registry, where the entry sends the canonical to Lab, every
+ caller is. Decision 9 reads a version named; both exceptions are there on the code before it.
+ """
+ self.assertEqual(self.unserved(), self.expected_unserved())
+
+ def expected_unserved(self):
+ callers = (None, 'outsider', 'lab-member', 'beta-member', 'ocladmin')
+ not_shown = (None, 'outsider', 'beta-member') # not shown Lab's private code systems
+ expected = [] if not self.registry else [
+ ('/fhir', caller, HELD, code, 'not found') for caller in callers for code in ('v1', None)]
+ return expected + [('/orgs/Lab', caller, HELD, None, 'not found') for caller in not_shown]
+
+ def unserved(self):
+ """[(base, caller, identifier, version, what resolve() answers)] for each version TerminologyCapabilities
+ names that resolve() doesn't serve that caller under that identifier, the default among them."""
+ found = []
+ for fhir_base, kwargs in (('/fhir', {}), ('/orgs/Lab', {'org': 'Lab'}), ('/orgs/Beta', {'org': 'Beta'})):
+ for user in (None, self.outsider, self.lab_member, self.beta_member, self.admin):
+ for identifier, codes, default in self.listed(fhir_base, user):
+ for code in (*codes, None):
+ try:
+ served = resolve('CodeSystem', identifier, code, kwargs, user).version
+ except UnresolvedError:
+ served = 'not found'
+ if served != (code or default):
+ found.append((fhir_base, getattr(user, 'username', None), identifier, code, served))
+ return found
+
+ def test_nothing_the_caller_isnt_shown_is_named(self):
+ """Lab's base names secret only to those shown the private repositories; /fhir/ names it to no one, and
+ Beta's base names Beta's s1 only to Beta's own."""
+ for user in (None, self.outsider):
+ body = terminology_capabilities('/orgs/Lab', user).content
+ self.assertNotIn(b'"secret"', body)
+ self.assertIn(b'"v1"', body)
+ self.assertEqual(self.codes('/orgs/Beta', user), {})
+ for user in (self.lab_member, self.admin):
+ self.assertIn(b'"secret"', terminology_capabilities('/orgs/Lab', user).content)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertNotIn(b'"secret"', terminology_capabilities('/fhir', user).content)
+ self.assertEqual(self.codes('/orgs/Beta', self.beta_member)[SHARED], ['s1'])
+
+ def test_a_private_release_of_a_public_repository_is_named_only_to_those_shown_it(self):
+ """p9 is open-second's and private, and no other code system holds it: Lab's base names it to those shown
+ it alone, /fhir/ to no one, and it resolves for exactly those it is named to."""
+ for user in (None, self.outsider):
+ self.assertNotIn(b'"p9"', terminology_capabilities('/orgs/Lab', user).content)
+ self.assertUnresolved(HELD, 'p9', {'org': 'Lab'}, user)
+ for user in (self.lab_member, self.admin):
+ self.assertIn(b'"p9"', terminology_capabilities('/orgs/Lab', user).content)
+ self.assertEqual(self.resolved(HELD, 'p9', {'org': 'Lab'}, user), self.row('open-second', 'p9').id, user)
+ for user in (None, self.outsider, self.lab_member, self.admin):
+ self.assertNotIn(b'"p9"', terminology_capabilities('/fhir', user).content)
+ self.assertUnresolved(HELD, 'p9', None, user)
+
+ def test_a_hidden_repository_changes_nothing_in_it(self):
+ before = {(fhir_base, user): terminology_capabilities(fhir_base, user).content
+ for fhir_base in ('/fhir', '/orgs/Lab') for user in (None, self.outsider)}
+ self.drop_the_hidden_repositories()
+ for (fhir_base, user), body in before.items():
+ self.assertEqual(terminology_capabilities(fhir_base, user).content, body, (fhir_base, user))
+
+ def codes(self, fhir_base, user):
+ return {item['uri']: [version['code'] for version in item.get('version', [])]
+ for item in terminology_capabilities(fhir_base, user).data.get('codeSystem', [])}
+
+ def listed(self, fhir_base, user):
+ """(identifier, codes, the default) for each code system TerminologyCapabilities names at `fhir_base`."""
+ response = terminology_capabilities(fhir_base, user)
+ self.assertEqual(response.status_code, 200, (fhir_base, user))
+ return [(item['uri'], [version['code'] for version in item.get('version', [])],
+ next(version['code'] for version in item.get('version', []) if version.get('isDefault')))
+ for item in response.data.get('codeSystem', [])]
+
+
+class HiddenIsMissingWithoutRegistryTest(HiddenIsMissingCases, OCLTestCase):
+ registry = False
+
+
+class HiddenIsMissingWithRegistryTest(HiddenIsMissingCases, OCLTestCase):
+ registry = True
+
+
+class RepositoryShapeWithoutRegistryTest(RepositoryShapeCases, OCLTestCase):
+ registry = False
+
+
+class RepositoryShapeWithRegistryTest(RepositoryShapeCases, OCLTestCase):
+ registry = True
+
+
+class CapabilitiesAccessWithoutRegistryTest(CapabilitiesAccessCases, OCLTestCase):
+ registry = False
+
+
+class CapabilitiesAccessWithRegistryTest(CapabilitiesAccessCases, OCLTestCase):
+ registry = True
diff --git a/core/url_registry/tests_resolution.py b/core/url_registry/tests_resolution.py
index f50f9c566..a057aae4e 100644
--- a/core/url_registry/tests_resolution.py
+++ b/core/url_registry/tests_resolution.py
@@ -586,12 +586,13 @@ def test_with_the_registry(self):
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 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.
+ 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, '', 8), (False, '?namespace=/orgs/org-b/', 9),
- (True, '', 11), (True, '?namespace=/orgs/org-b/', 12)):
+ 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):
From 4afaffba063cb2671e8888b9b66d8c71b8b1e12d Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 12/18] OpenConceptLab/ocl_online#451 | Value set members
stored as root concept rows report a holding release
$expand and ValueSet $validate-code can report a release for a member stored
as its concept's versioned-object row, which only HEAD holds. When no
visible live release holds the member's exact row, a release holding another
active version of that concept supplies the version.
A release holding the exact member takes precedence. Within each choice, the
expansion's explicit pin wins, then its evaluated pin, then the newest
release. Validation keeps the stored member's own display, so the coding
returned by expansion validates with that display.
Tests cover root and version rows, pins, named system versions, deleted
concept versions, unavailable releases and competing members with the same
code. They check expansion and validation together and preserve the query
count.
Work package WP10 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/ops/expand.py | 27 +++--
core/fhir/ops/validate_code.py | 41 ++++---
core/fhir/tests_ops_concept_versions.py | 155 ++++++++++++++++++++++++
3 files changed, 199 insertions(+), 24 deletions(-)
create mode 100644 core/fhir/tests_ops_concept_versions.py
diff --git a/core/fhir/ops/expand.py b/core/fhir/ops/expand.py
index ae3a66fbd..3ce0cc7c6 100644
--- a/core/fhir/ops/expand.py
+++ b/core/fhir/ops/expand.py
@@ -286,23 +286,32 @@ def matching(self):
def release(self):
"""
For a member: the release it is reported in, as {version, url (its own canonical URL)}: a live release the
- caller is shown that holds the concept, the expansion's explicit one first, then the one it evaluated, then the
- newest; None without one.
+ caller is shown that holds the member's row, the expansion's explicit one first, then the one it evaluated,
+ then the newest; failing that, one that holds another version of its concept, in the same order (an expansion
+ can hold a concept's versioned-object row, which only HEAD holds); None without one.
Newest is by the source's id (a repository's releases' ids follow their creation), so the lookup of the newest
- stops at the concept's first link it finds.
+ stops at the first link it finds. Each lookup runs only when the ones before it find nothing.
"""
releases = viewable_rows(Source, self.namespace, self.user).filter(
is_active=True, released=True, retired=False).exclude(version=HEAD).values('id')
- holding = Concept.sources.through.objects.filter(concept_id=OuterRef('id'), source_id__in=releases)
+ links = Concept.sources.through.objects.filter(source_id__in=releases)
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 = holding.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(links.annotate(
+
+ def in_order(holding):
+ pinned = holding.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 pinned, holding.order_by('-source_id')
+
+ return Coalesce(*(Subquery(holding.annotate(
release=JSONObject(version='source__version', url='source__canonical_url')).values('release')[:1],
- output_field=JSONField()) for links in (pinned, holding.order_by('-source_id'))), output_field=JSONField())
+ output_field=JSONField()) for holding in (
+ *in_order(links.filter(concept_id=OuterRef('id'))),
+ *in_order(links.filter(concept__versioned_object_id=OuterRef('versioned_object_id'),
+ concept__is_active=True)))),
+ output_field=JSONField())
def used(self):
"""Each code system the members come from, with each version they are reported in: system|version."""
diff --git a/core/fhir/ops/validate_code.py b/core/fhir/ops/validate_code.py
index d319a1d82..8a38df532 100644
--- a/core/fhir/ops/validate_code.py
+++ b/core/fhir/ops/validate_code.py
@@ -183,8 +183,10 @@ 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 release the caller is shown that holds the concept: the one the expansion pins
- (explicit), else the one it evaluated, else the newest; None without one, never HEAD or an unreleased version.
+ set. The version answered is a release the caller is shown that holds the member's row, else one that holds
+ another (not deleted) version of its concept (an expansion can hold a concept's versioned-object row, which only
+ HEAD holds): the one the expansion pins (explicit), else the one it evaluated, else the newest; 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.
@@ -192,33 +194,42 @@ def member(value_set, coding, namespace, user):
if not value_set.expansion_uri:
return None
concepts_named, versions_named = named_by(coding.system, viewable_rows(Source, namespace, user))
- concept_ids = list(Concept.objects.filter(
+ 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', flat=True))
- if concept_ids:
- concept_ids = list(Expansion.concepts.through.objects.filter(
- expansion__uri=value_set.expansion_uri, concept_id__in=concept_ids).values_list('concept_id', flat=True))
+ '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
- rows = Concept.sources.through.objects.filter(
- Exists(viewable_rows(Source, namespace, user).filter(id=OuterRef('source_id'))), versions_named,
- concept_id__in=concept_ids, source__is_active=True)
+ 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):
return Exists(versions.through.objects.filter(
expansion__uri=value_set.expansion_uri, source_id=OuterRef('source_id')))
- release = Q(source__released=True, source__retired=False) & ~Q(source__version=HEAD)
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', '-explicit', '-evaluated', '-source__created_at', 'concept_id').values_list(
- 'concept_id', 'source__version', 'release').first()
+ ).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
- concept = Concept.objects.filter(id=row[0]).select_related('parent').prefetch_related('names').first()
- return concept, row[1] if row[2] else 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):
diff --git a/core/fhir/tests_ops_concept_versions.py b/core/fhir/tests_ops_concept_versions.py
new file mode 100644
index 000000000..00edd2e66
--- /dev/null
+++ b/core/fhir/tests_ops_concept_versions.py
@@ -0,0 +1,155 @@
+"""
+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, rather than the version row a release holds. The release that holds
+another version of that concept answers: the expansion's explicit one first, then the one it evaluated, then the
+newest, as for a member a release holds itself. A member a release holds itself answers as before. $expand
+(contains.version, used-codesystem) and ValueSet $validate-code (version, and systemVersion naming the release), with
+the URL registry off and on. OpenConceptLab/ocl_online#451.
+"""
+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_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):
+ response = self.get('$expand', base=base, user=user, resource='ValueSet', url=DEMO_SET)
+ 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_release_the_expansion_pins(self):
+ self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
+ self.assertEqual(self.used(), [f'{DEMO}|1'])
+
+ def test_then_the_release_it_evaluated_then_the_newest(self):
+ self.stored.explicit_source_versions.clear()
+ self.stored.evaluated_source_versions.add(self.demo_v1)
+ self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
+ self.stored.evaluated_source_versions.clear()
+ self.assertEqual(self.members(), [('A', '2'), ('B', '2')]) # the newest the caller is shown
+ self.assertEqual(self.used(), [f'{DEMO}|2'])
+
+ 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': '1', 'display': 'Code A'})
+ self.assertEqual(self.validated(code='B', systemVersion='2')['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_v1).update(is_active=False)
+ self.assertEqual(self.members(), [('A', '2'), ('B', '1')])
+ self.assertEqual(self.validated(code='A', systemVersion='1')['result'], False)
+ self.assertEqual(self.validated(code='A'), {'result': True, 'version': '2', '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_the_statement_count_is_that_of_an_expansion_of_version_rows(self):
+ def statements():
+ with CaptureQueriesContext(connection) as context:
+ self.expanded()
+ return len(context.captured_queries)
+ with_roots = statements()
+ self.stored.concepts.clear()
+ self.stored.concepts.add(*(concept for concept in self.demo_v1.concepts.all()))
+ self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
+ self.assertEqual(statements(), with_roots)
+
+
+class ConceptVersionWithoutRegistryTest(ConceptVersionCases, OCLTestCase):
+ registry = False
+
+
+class ConceptVersionWithRegistryTest(ConceptVersionCases, OCLTestCase):
+ registry = True
From 7c2118c8e0c57bcf023998e96095435be8de7c56 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 13/18] OpenConceptLab/ocl_online#451 | FHIR operations reject
parameters with competing values and explain ConceptMap URI searches
A POSTed Parameters entry with more than one value[x], resource or part is
now a 400 OperationOutcome naming the parameter, including nested parts. A
primitive's _value[x] counts as its value[x], so its own extensions remain
valid while another value choice is refused.
CapabilityStatement documents that ConceptMap source-uri and target-uri
match the groups' systems, since ConceptMaps store no source or target value
set. Tests cover value order, nested and extension-only values, and the
search documentation at every base.
Work package WP11 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/adapters/r4.py | 12 ++++++++++--
core/fhir/base.py | 11 ++++++++++-
core/fhir/tests_base.py | 24 ++++++++++++++++++++++++
core/fhir/tests_capabilities.py | 11 +++++++++++
4 files changed, 55 insertions(+), 3 deletions(-)
diff --git a/core/fhir/adapters/r4.py b/core/fhir/adapters/r4.py
index 81e0a9631..e353cf191 100644
--- a/core/fhir/adapters/r4.py
+++ b/core/fhir/adapters/r4.py
@@ -57,12 +57,20 @@ def part_parameter(name, parts):
'_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."""
+ """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}')
- return {'name': name, 'definition': SEARCH_PARAMETER + definition, 'type': SEARCH_PARAMETER_TYPES[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):
diff --git a/core/fhir/base.py b/core/fhir/base.py
index d5a48b52d..3acec594b 100644
--- a/core/fhir/base.py
+++ b/core/fhir/base.py
@@ -450,7 +450,16 @@ def render(self, data, accepted_media_type=None, renderer_context=None):
def parameter_value(parameter):
- """A Parameters.parameter's value: its value[x], resource, or parts (as FhirParameters)."""
+ """
+ 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
diff --git a/core/fhir/tests_base.py b/core/fhir/tests_base.py
index 5d0b9e69c..269ab61f8 100644
--- a/core/fhir/tests_base.py
+++ b/core/fhir/tests_base.py
@@ -571,6 +571,30 @@ def test_a_posted_resource_that_is_not_parameters_is_invalid(self):
'/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()
diff --git a/core/fhir/tests_capabilities.py b/core/fhir/tests_capabilities.py
index a311f996c..d96eab630 100644
--- a/core/fhir/tests_capabilities.py
+++ b/core/fhir/tests_capabilities.py
@@ -146,6 +146,17 @@ def test_interactions_and_search_parameters_are_the_searchs(self):
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])
From 439a85c896576ce57fff69730fc2d77b8a93358d Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 14/18] OpenConceptLab/ocl_online#451 | Root concept members
report the newest visible release that holds the concept
$expand and ValueSet $validate-code now report a versioned-object member in
the newest visible live release holding an active version of its concept,
regardless of expansion pins. Such a row carries the concept's newest
content. Members whose exact rows belong to releases still follow the
explicit pin, then the evaluated pin, then the newest.
Expansion reads the links of the relevant repositories' newest releases
together and looks up older releases only for unresolved members. The page
and used-codesystem use the same rules for selecting releases. used-
codesystem reads every visible member's id and release (count=0 included);
full concepts and names are read only for the page.
Tests cover pins, several repositories, unavailable or deleted versions,
older holding releases and members with no holding release. They verify
expansion and validation agree, query counts do not grow with member count,
and members held in the newest releases need no individual fallback.
Work package WP12 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/ops/expand.py | 104 +++++++++++------
core/fhir/ops/validate_code.py | 26 +++--
core/fhir/tests_ops_concept_versions.py | 148 +++++++++++++++++++-----
3 files changed, 202 insertions(+), 76 deletions(-)
diff --git a/core/fhir/ops/expand.py b/core/fhir/ops/expand.py
index 3ce0cc7c6..016cbb49f 100644
--- a/core/fhir/ops/expand.py
+++ b/core/fhir/ops/expand.py
@@ -17,9 +17,10 @@
- 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 release the caller is shown that holds the concept, the
- one the expansion pins (explicit) first, then the one it evaluated, then the newest; none without one, never HEAD
- or an unreleased version (as $validate-code answers).
+ 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
@@ -30,8 +31,10 @@
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, the page read alone; all of them within
- SEARCH_TIMEOUT_MS together, past which the expansion is too costly (422).
+- 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. All queries
+ within SEARCH_TIMEOUT_MS together, past which the expansion is too costly (422).
"""
import re
@@ -283,57 +286,90 @@ def matching(self):
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):
"""
- For a member: the release it is reported in, as {version, url (its own canonical URL)}: a live release the
- caller is shown that holds the member's row, the expansion's explicit one first, then the one it evaluated,
- then the newest; failing that, one that holds another version of its concept, in the same order (an expansion
- can hold a concept's versioned-object row, which only HEAD holds); None without one.
- Newest is by the source's id (a repository's releases' ids follow their creation), so the lookup of the newest
- stops at the first link it finds. Each lookup runs only when the ones before it find nothing.
+ 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.
"""
- releases = viewable_rows(Source, self.namespace, self.user).filter(
- is_active=True, released=True, retired=False).exclude(version=HEAD).values('id')
- links = Concept.sources.through.objects.filter(source_id__in=releases)
+ 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))
-
- def in_order(holding):
- pinned = holding.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 pinned, holding.order_by('-source_id')
-
+ 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 (
- *in_order(links.filter(concept_id=OuterRef('id'))),
- *in_order(links.filter(concept__versioned_object_id=OuterRef('versioned_object_id'),
- concept__is_active=True)))),
+ 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 the members come from, with each version they are reported in: system|version."""
- rows = self.members().annotate(release=self.release()).values_list(
- 'parent__canonical_url', 'parent__uri', 'release').order_by().distinct()
- used = {code_system(url, uri, release) for url, uri, release in rows}
+ """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, matching):
"""
The page offset and count name, in a fixed order, with what each member is shown with. The page is chosen
- first, so what it is shown with is read for its members alone, however deep the offset.
+ first, so releases, concepts and names are read for its members alone, however deep the offset.
"""
offset = self.inputs.offset or 0
- page = matching.order_by('id').values('id')[offset:offset + self.inputs.count]
- concepts = Concept.objects.filter(id__in=page).annotate(release=self.release()).select_related('parent').only(
- 'id', 'mnemonic', 'retired', 'parent_id', 'parent__canonical_url', 'parent__uri',
+ page = Concept.objects.filter(id__in=matching.order_by('id').values('id')[offset:offset + self.inputs.count])
+ 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, concept.release)
+ 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,
diff --git a/core/fhir/ops/validate_code.py b/core/fhir/ops/validate_code.py
index 8a38df532..62314dae9 100644
--- a/core/fhir/ops/validate_code.py
+++ b/core/fhir/ops/validate_code.py
@@ -14,11 +14,12 @@
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 release the caller is shown that holds the concept: the expansion's explicit version, else the one
- it evaluated, else the newest; 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.
+ 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.
@@ -183,10 +184,11 @@ 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 release the caller is shown that holds the member's row, else one that holds
- another (not deleted) version of its concept (an expansion can hold a concept's versioned-object row, which only
- HEAD holds): the one the expansion pins (explicit), else the one it evaluated, else the newest; None without one,
- never HEAD or an unreleased version. The concept answered is the member itself, as $expand shows it.
+ 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.
@@ -216,8 +218,10 @@ def member(value_set, coding, namespace, user):
if coding.version:
rows = rows.filter(source__version=coding.version)
def pinned(versions):
- return Exists(versions.through.objects.filter(
- expansion__uri=value_set.expansion_uri, source_id=OuterRef('source_id')))
+ """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()),
diff --git a/core/fhir/tests_ops_concept_versions.py b/core/fhir/tests_ops_concept_versions.py
index 00edd2e66..2a5f88999 100644
--- a/core/fhir/tests_ops_concept_versions.py
+++ b/core/fhir/tests_ops_concept_versions.py
@@ -1,11 +1,13 @@
"""
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, rather than the version row a release holds. The release that holds
-another version of that concept answers: the expansion's explicit one first, then the one it evaluated, then the
-newest, as for a member a release holds itself. A member a release holds itself answers as before. $expand
-(contains.version, used-codesystem) and ValueSet $validate-code (version, and systemVersion naming the release), with
-the URL registry off and on. OpenConceptLab/ocl_online#451.
+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
@@ -14,6 +16,7 @@
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
@@ -56,8 +59,9 @@ def setUp(self):
for url in (DEMO, DEMO_SET):
GlobalURLRegistryFactory(url=url, namespace=self.acme.uri)
- def expanded(self, base='/fhir', user=None):
- response = self.get('$expand', base=base, user=user, resource='ValueSet', url=DEMO_SET)
+ 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']
@@ -74,18 +78,23 @@ def validated(self, system=DEMO, **params):
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_release_the_expansion_pins(self):
- self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
- self.assertEqual(self.used(), [f'{DEMO}|1'])
-
- def test_then_the_release_it_evaluated_then_the_newest(self):
- self.stored.explicit_source_versions.clear()
- self.stored.evaluated_source_versions.add(self.demo_v1)
- self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
- self.stored.evaluated_source_versions.clear()
- self.assertEqual(self.members(), [('A', '2'), ('B', '2')]) # the newest the caller is shown
+ 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)
@@ -95,8 +104,9 @@ def test_a_release_the_caller_is_not_shown_is_never_reported(self):
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': '1', 'display': 'Code A'})
- self.assertEqual(self.validated(code='B', systemVersion='2')['result'], True)
+ 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):
@@ -107,10 +117,10 @@ def test_a_coding_expand_returns_validates(self):
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_v1).update(is_active=False)
- self.assertEqual(self.members(), [('A', '2'), ('B', '1')])
- self.assertEqual(self.validated(code='A', systemVersion='1')['result'], False)
- self.assertEqual(self.validated(code='A'), {'result': True, 'version': '2', 'display': 'Code A'})
+ 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."""
@@ -135,16 +145,92 @@ def test_a_member_its_release_holds_outranks_another_concepts_pinned_version(sel
self.assertEqual(self.validated(code='A', systemVersion='1', display='Other A'),
{'result': True, 'version': '1', 'display': 'Other A'})
- def test_the_statement_count_is_that_of_an_expansion_of_version_rows(self):
- def statements():
+ 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:
- self.expanded()
+ 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)
- with_roots = statements()
- self.stored.concepts.clear()
- self.stored.concepts.add(*(concept for concept in self.demo_v1.concepts.all()))
- self.assertEqual(self.members(), [('A', '1'), ('B', '1')])
- self.assertEqual(statements(), with_roots)
+
+ 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):
From 211da06bbdfebd6fd47910f2116c0a8e7cb5a8aa Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 15/18] OpenConceptLab/ocl_online#451 | Filtered expansions
match members once for both the total and the page
$expand with a filter reads matching member ids once in id order. Their
number supplies total and a slice supplies the requested page, so selecting
the page no longer repeats the name filter. Unfiltered expansions keep their
existing queries.
Tests check a page larger than the matches, paging with an offset, count=0,
an offset beyond the matches and no matches. Each filtered request matches
names once, and an empty page reads no page concepts or names.
Work package WP13 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/ops/expand.py | 26 ++++++++++++++++---------
core/fhir/tests_ops_expand.py | 36 +++++++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 9 deletions(-)
diff --git a/core/fhir/ops/expand.py b/core/fhir/ops/expand.py
index 016cbb49f..19c72bac9 100644
--- a/core/fhir/ops/expand.py
+++ b/core/fhir/ops/expand.py
@@ -33,8 +33,9 @@
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. All queries
- within SEARCH_TIMEOUT_MS together, past which the expansion is too costly (422).
+ 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
@@ -354,13 +355,12 @@ def used(self):
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, matching):
+ def page(self, ids):
"""
- The page offset and count name, in a fixed order, with what each member is shown with. The page is chosen
- first, so releases, concepts and names are read for its members alone, however deep the offset.
+ 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.
"""
- offset = self.inputs.offset or 0
- page = Concept.objects.filter(id__in=matching.order_by('id').values('id')[offset:offset + self.inputs.count])
+ 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')
@@ -379,8 +379,16 @@ def page(self, matching):
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': matching.count(), 'offset': self.inputs.offset or 0, 'parameters': self.inputs.echo,
- 'used': self.used(), 'contains': self.page(matching) if self.inputs.count else [],
+ '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/tests_ops_expand.py b/core/fhir/tests_ops_expand.py
index 06f8cf6f4..213b36376 100644
--- a/core/fhir/tests_ops_expand.py
+++ b/core/fhir/tests_ops_expand.py
@@ -56,6 +56,11 @@ def issues_of(response):
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)
@@ -511,6 +516,37 @@ def test_xml(self):
# 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."""
From 77bd094c5f61876e61301bd3c3c927dcfd986783 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 12:23:24 -0400
Subject: [PATCH 16/18] OpenConceptLab/ocl_online#451 | Instance operations
serve named unreleased versions; capped reads say they're partial
At an owner base, the instance-level operations (CodeSystem $lookup and
$validate-code, ValueSet $validate-code and $expand) resolve a named version
the read doesn't find as their type-level operations do, with the same
visibility rule: a named, public, unreleased version is served, and one the
caller may not see answers exactly as a missing one. Released versions the
read finds keep their path and statement count, and reads still return
released versions only.
A version the resolver returns carries its repository's canonical URL, from
the HEAD it already reads. A resolved version accepts that URL as a system or
url even when its own canonical URL differs; answers name the version's own
canonical URL when it has one, else its repository's, so a version named by
an OCL path, or served unreleased at an owner base, no longer answers with
the path.
A ValueSet read builds compose from at most 1,000 concept references in id
order, and a ConceptMap read builds group from at most 1,000 live mappings;
each reads one more to know when it was cut and then tags meta SUBSETTED,
keeping the tags meta already has. The FHIR answer to disabled anonymous
access carries only its detail text. Unused parameter serializers,
FhirParameters.names() and MultipleMatchesError are removed.
Work package WP14 of OpenConceptLab/ocl_online#451.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/code_systems/serializers.py | 11 ----
core/common/models.py | 3 +-
core/concept_maps/serializers.py | 8 +--
core/fhir/base.py | 3 --
core/fhir/ops/concepts.py | 14 +++--
core/fhir/outcomes.py | 6 ---
core/fhir/resolve.py | 12 +++--
core/fhir/search.py | 11 ++--
core/fhir/tests_base.py | 3 +-
core/fhir/tests_middleware.py | 2 +-
core/fhir/tests_operation_bounds.py | 18 -------
core/fhir/tests_ops_expand.py | 13 +++++
core/fhir/tests_ops_lookup.py | 80 ++++++++++++++++++++++++++++
core/fhir/tests_ops_validate_code.py | 75 ++++++++++++++++++++++++++
core/fhir/tests_outcomes.py | 1 -
core/fhir/tests_search.py | 72 +++++++++++++++++++++++++
core/middlewares/middlewares.py | 3 +-
core/parameters/__init__.py | 0
core/parameters/serializers.py | 74 -------------------------
core/parameters/tests.py | 19 -------
core/value_sets/serializers.py | 7 +--
21 files changed, 279 insertions(+), 156 deletions(-)
delete mode 100644 core/parameters/__init__.py
delete mode 100644 core/parameters/serializers.py
delete mode 100644 core/parameters/tests.py
diff --git a/core/code_systems/serializers.py b/core/code_systems/serializers.py
index c63068b3e..c9164adea 100644
--- a/core/code_systems/serializers.py
+++ b/core/code_systems/serializers.py
@@ -15,23 +15,12 @@
from core.concepts.serializers import ConceptDetailSerializer
from core.fhir import search as fhir_search
from core.fhir.search import CanonicalUrlField, FhirRepositorySerializerMixin, TitleField
-from core.parameters.serializers import ParametersSerializer
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:
diff --git a/core/common/models.py b/core/common/models.py
index e61e71111..c8348e20a 100644
--- a/core/common/models.py
+++ b/core/common/models.py
@@ -1633,8 +1633,9 @@ def resolve_repo( # pylint: disable=too-many-arguments
elif instance.is_head:
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 access (its HEAD's), which FHIR checks with the version's
+ 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
diff --git a/core/concept_maps/serializers.py b/core/concept_maps/serializers.py
index 2f29fba5e..93868bf47 100644
--- a/core/concept_maps/serializers.py
+++ b/core/concept_maps/serializers.py
@@ -20,6 +20,7 @@
from core.sources.serializers import SourceCreateOrUpdateSerializer
logger = logging.getLogger('oclapi')
+MAPPING_LIMIT = 1000
class ConceptMapGroupField(serializers.Field):
@@ -60,12 +61,13 @@ def to_representation(self, value):
mappings = value.get_active_mappings() # neither retired nor deleted
for side in ('from', 'to'):
mappings = with_side(mappings, side, namespace, user)
- rows = mappings.order_by('id').values(
+ 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')))[:1000]
+ for field in ('source_url', 'source__uri', 'shown', 'canonical')))[:MAPPING_LIMIT + 1])
+ self.subsetted = len(rows) > MAPPING_LIMIT
groups, elements = {}, {}
- for row in rows:
+ 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'))
diff --git a/core/fhir/base.py b/core/fhir/base.py
index 3acec594b..05e8b40c3 100644
--- a/core/fhir/base.py
+++ b/core/fhir/base.py
@@ -510,9 +510,6 @@ def get(self, name, default=None):
def getlist(self, name):
return [value for key, value in self.items if key == name]
- def names(self):
- return list(dict.fromkeys(key for key, _ in self.items))
-
def __contains__(self, name):
return any(key == name for key, _ in self.items)
diff --git a/core/fhir/ops/concepts.py b/core/fhir/ops/concepts.py
index 1fecc3359..ed6438a71 100644
--- a/core/fhir/ops/concepts.py
+++ b/core/fhir/ops/concepts.py
@@ -104,15 +104,19 @@ def system_of(version, named=None):
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); or, at an owner base with HEAD
- named, HEAD through core.fhir.resolve, with its visibility rule (design D6). A global id names a release, so a
- HEAD named there is left to the caller's version check. Not found is a read's 404.
+ 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:
- return read_version(resource_type, instance_id, version, kwargs, user)
+ 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}/', HEAD, kwargs, user)
+ 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
diff --git a/core/fhir/outcomes.py b/core/fhir/outcomes.py
index ba3c56d8b..a77484893 100644
--- a/core/fhir/outcomes.py
+++ b/core/fhir/outcomes.py
@@ -197,12 +197,6 @@ class NotSupportedError(FhirError):
default_detail = 'The request uses something this server does not support.'
-class MultipleMatchesError(FhirError):
- status_code = status.HTTP_400_BAD_REQUEST
- issue_code = 'multiple-matches'
- default_detail = 'More than one resource matches the request.'
-
-
class TooCostlyError(FhirError):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
issue_code = 'too-costly'
diff --git a/core/fhir/resolve.py b/core/fhir/resolve.py
index de6852b4e..18aae9fe9 100644
--- a/core/fhir/resolve.py
+++ b/core/fhir/resolve.py
@@ -155,8 +155,8 @@ def held_version(repo_type, url, version, namespace, user):
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 is set on it, as the resolver sets it.
- One query, whatever the number of repositories; what the caller isn't shown is left out in it.
+ 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 = {}
@@ -175,7 +175,7 @@ def held_version(repo_type, url, version, namespace, user):
).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 = True, url
+ held.is_fqdn, held.resolution_url, held.repository_canonical_url = True, url, url
held.canonical_url = held.canonical_url or url
return held
@@ -213,6 +213,7 @@ def latest_public_release(instance):
**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
@@ -266,13 +267,16 @@ def is_public(public_access):
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.
+ 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
diff --git a/core/fhir/search.py b/core/fhir/search.py
index d32796f24..9f02b177a 100644
--- a/core/fhir/search.py
+++ b/core/fhir/search.py
@@ -735,12 +735,17 @@ 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 is also tagged SUBSETTED. meta keeps what the serializer put there (lastUpdated).
+ 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'):
- rep['meta'] = {**(rep.get('meta') or {}), 'tag': [SUBSETTED]}
+ 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/tests_base.py b/core/fhir/tests_base.py
index 269ab61f8..cbe9bacfd 100644
--- a/core/fhir/tests_base.py
+++ b/core/fhir/tests_base.py
@@ -519,7 +519,6 @@ def test_query_parameters(self):
self.assertEqual(params.get('system'), 'http://a')
self.assertEqual(params.getlist('property'), ['x', 'y'])
- self.assertEqual(params.names(), ['system', 'code', 'property'])
self.assertNotIn('_format', params)
self.assertIsNone(params.get('version'))
self.assertEqual(params.get('version', 'v1'), 'v1')
@@ -679,7 +678,7 @@ def get(self, request):
def post(self, _request):
params = self.get_fhir_parameters()
return Response({'resourceType': 'Parameters', 'parameter': [
- {'name': name, 'valueString': str(params.get(name))} for name in params.names()] + [
+ {'name': name, 'valueString': str(value)} for name, value in params.items] + [
{'name': 'base', 'valueUri': self.get_fhir_base_url()}]})
diff --git a/core/fhir/tests_middleware.py b/core/fhir/tests_middleware.py
index 557b9b13c..a3e7b80db 100644
--- a/core/fhir/tests_middleware.py
+++ b/core/fhir/tests_middleware.py
@@ -244,7 +244,7 @@ def test_anonymous_access_to_an_owner_base_is_a_403_outcome(self):
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.assertIn(RequireAuthenticationMiddleware.forbidden_response['upgrade_url'], issue['diagnostics'])
+ self.assertNotIn('diagnostics', issue)
def test_other_paths_keep_their_json_403(self):
response = self.answer('/orgs/CIEL/sources/')
diff --git a/core/fhir/tests_operation_bounds.py b/core/fhir/tests_operation_bounds.py
index 0ad7701c5..ccb81a586 100644
--- a/core/fhir/tests_operation_bounds.py
+++ b/core/fhir/tests_operation_bounds.py
@@ -17,7 +17,6 @@
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
@@ -357,20 +356,3 @@ def test_expand_takes_no_list_shortcut(self):
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))
-
- 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'})
diff --git a/core/fhir/tests_ops_expand.py b/core/fhir/tests_ops_expand.py
index 213b36376..0dbc18dcd 100644
--- a/core/fhir/tests_ops_expand.py
+++ b/core/fhir/tests_ops_expand.py
@@ -79,6 +79,19 @@ def register(self, url, owner):
# 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)
diff --git a/core/fhir/tests_ops_lookup.py b/core/fhir/tests_ops_lookup.py
index 70225493f..72015fdf3 100644
--- a/core/fhir/tests_ops_lookup.py
+++ b/core/fhir/tests_ops_lookup.py
@@ -427,6 +427,86 @@ def test_a_deleted_concept_version_is_not_found(self):
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."""
diff --git a/core/fhir/tests_ops_validate_code.py b/core/fhir/tests_ops_validate_code.py
index a950ba570..00234b0da 100644
--- a/core/fhir/tests_ops_validate_code.py
+++ b/core/fhir/tests_ops_validate_code.py
@@ -11,6 +11,7 @@
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
@@ -487,6 +488,80 @@ def test_value_set_query_counts(self):
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
diff --git a/core/fhir/tests_outcomes.py b/core/fhir/tests_outcomes.py
index 1779901cf..3dd4d2d20 100644
--- a/core/fhir/tests_outcomes.py
+++ b/core/fhir/tests_outcomes.py
@@ -25,7 +25,6 @@ def test_each_typed_error_has_its_code_tx_issue_type_and_status(self):
(outcomes.NotFoundError, 404, 'not-found', 'not-found'),
(outcomes.InvalidError, 400, 'invalid', 'invalid-data'),
(outcomes.NotSupportedError, 400, 'not-supported', 'not-supported'),
- (outcomes.MultipleMatchesError, 400, 'multiple-matches', 'invalid-data'),
(outcomes.TooCostlyError, 422, 'too-costly', 'too-costly'),
(outcomes.ProcessingError, 422, 'processing', 'invalid-data'),
]
diff --git a/core/fhir/tests_search.py b/core/fhir/tests_search.py
index eedbb8b8a..e73d24e1a 100644
--- a/core/fhir/tests_search.py
+++ b/core/fhir/tests_search.py
@@ -978,6 +978,78 @@ def read_queries(path):
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)
diff --git a/core/middlewares/middlewares.py b/core/middlewares/middlewares.py
index cc5c39c13..15eb94f7b 100644
--- a/core/middlewares/middlewares.py
+++ b/core/middlewares/middlewares.py
@@ -147,8 +147,7 @@ def __call__(self, request):
if is_fhir_path(request.path):
return outcome_response(request, operation_outcome(issue(
- 'login', self.forbidden_response['detail'],
- diagnostics=f"Upgrade: {self.forbidden_response['upgrade_url']}")), 403)
+ 'login', self.forbidden_response['detail'])), 403)
return JsonResponse(self.forbidden_response, status=403)
def is_request_allowed(self, request):
diff --git a/core/parameters/__init__.py b/core/parameters/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/core/parameters/serializers.py b/core/parameters/serializers.py
deleted file mode 100644
index 97da0cc34..000000000
--- a/core/parameters/serializers.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from rest_framework import serializers
-from rest_framework.fields import CharField, SerializerMethodField, BooleanField, IntegerField
-
-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}
diff --git a/core/parameters/tests.py b/core/parameters/tests.py
deleted file mode 100644
index 8dfdec4b9..000000000
--- a/core/parameters/tests.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from core.common.tests import OCLTestCase
-from core.parameters.serializers import ParametersSerializer
-
-
-class ParametersTest(OCLTestCase):
- class CustomParametersSerializer(ParametersSerializer):
- allowed_input_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)
- self.assertTrue(serializer.is_valid())
- self.assertIsInstance(serializer, self.CustomParametersSerializer)
- self.assertEqual(
- serializer.validated_data, {'parameters': {'url': 'https://openconceptlab.org', 'code': 'testCode'}})
diff --git a/core/value_sets/serializers.py b/core/value_sets/serializers.py
index bc37c20ad..8e3001e8a 100644
--- a/core/value_sets/serializers.py
+++ b/core/value_sets/serializers.py
@@ -15,6 +15,7 @@
from core.value_sets.constants import RESOURCE_TYPE
logger = logging.getLogger('oclapi')
+REFERENCE_LIMIT = 1000
class FilterValueSetSerializer(ReadSerializerMixin, serializers.Serializer):
@@ -109,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 = []
From ebe370c461cd5429afe56d6da05630c389476b66 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 17:47:14 -0400
Subject: [PATCH 17/18] OpenConceptLab/ocl_online#451 | Only /fhir/ and owner
bases are FHIR paths
A path is a FHIR path under /fhir/, or under an owner base (/orgs/{org}/,
/users/{user}/) when it names a FHIR resource type, metadata or $versions.
Other paths whose third segment is a resource type, such as a current user's
source named CodeSystem (/user/sources/CodeSystem/), keep their own error
bodies and statuses, and the API host serves them when FHIR has its own host.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---
core/fhir/base.py | 8 ++++----
core/fhir/tests_base.py | 3 ++-
core/fhir/tests_middleware.py | 3 ++-
3 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/core/fhir/base.py b/core/fhir/base.py
index 05e8b40c3..c79137a71 100644
--- a/core/fhir/base.py
+++ b/core/fhir/base.py
@@ -80,12 +80,12 @@ def xml_parser():
def is_fhir_path(path):
"""
- Whether a path is under a FHIR base: the global /fhir/, or at an owner base a FHIR resource type, its metadata or
- $versions.
+ 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[2] in RESOURCE_TYPES) or (
- len(segments) == 3 and segments[0] in ('orgs', 'users') and segments[2] in OWNER_BASE_PATHS)
+ 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):
diff --git a/core/fhir/tests_base.py b/core/fhir/tests_base.py
index cbe9bacfd..6e49bb2e5 100644
--- a/core/fhir/tests_base.py
+++ b/core/fhir/tests_base.py
@@ -610,7 +610,8 @@ 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/'):
+ for path in ('/', '/orgs/CIEL/', '/orgs/CIEL/sources/', '/fhirx/', '/version/', '/user/sources/CodeSystem/',
+ '/sources/x/ConceptMap/'):
self.assertFalse(base.is_fhir_path(path), path)
diff --git a/core/fhir/tests_middleware.py b/core/fhir/tests_middleware.py
index a3e7b80db..7fdf58307 100644
--- a/core/fhir/tests_middleware.py
+++ b/core/fhir/tests_middleware.py
@@ -44,7 +44,8 @@ def test_fhir_paths_off_the_fhir_host_are_a_404_outcome(self):
self.assertEqual(response['Content-Type'], 'application/fhir+json', path)
self.assertEqual(outcome_issue(response), 'error|not-found|not-found', path)
- self.assertEqual(self.answer('/orgs/CIEL/sources/').content, b'ok')
+ 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):
From f88a62b7256bd5033ce2f70e75e3184e05785450 Mon Sep 17 00:00:00 2001
From: Jonathan Payne
Date: Thu, 22 Oct 2026 23:47:13 -0400
Subject: [PATCH 18/18] OpenConceptLab/ocl_online#451 | The FHIR host serves
its own Swagger page of the terminology operations (#957)
* OpenConceptLab/ocl_online#451 | The FHIR host serves its own Swagger page of the terminology operations
On the FHIR host, /swagger/ (and swagger.json, swagger.yaml) describes the
FHIR terminology service alone: search and read of CodeSystem, ValueSet and
ConceptMap, $lookup, $validate-code, $expand, $translate, metadata and
$versions, at /fhir and the owner bases. Each operation is listed once, tagged
by resource type, with its query parameters, a Parameters body for POST, and
OperationOutcome errors. Writes and the slash and no-slash duplicates are left
out, and Try it out calls the FHIR host itself.
When FHIR has its own host, the API's Swagger leaves the FHIR paths out (they
answer 404 there) and links to the FHIR page. When FHIR shares the host, the
API's Swagger lists the same curated operations instead of the generated ones.
The other API operations are unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
* OpenConceptLab/ocl_online#451 | FHIR Swagger: say which inputs a coding replaces
When a coding or codeableConcept is given, the operations don't read the
separate inputs it stands for: code and system for $lookup (version still
selects the code system version), code and display for CodeSystem
$validate-code (url and version still name the code system), code, system,
systemVersion and display for ValueSet $validate-code, and code, system and
version for $translate. Each operation's coding parameter now names them, and
each of those inputs says it isn't read alongside a coding. A GET coding token
carries no version or display; a POSTed valueCoding does.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
* OpenConceptLab/ocl_online#451 | FHIR Swagger: $lookup's system still names an instance alongside a coding
At the type level, $lookup takes the code system from a coding, so a separate
system has no effect; at an instance, a separate system must still name that
code system, as without a coding. Its description now says so instead of
saying it isn't read.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01R8hw7w8753DDSCezrmrDA8
---------
Co-authored-by: Claude Opus 5.5
---
core/common/swagger.py | 29 +++
core/fhir/swagger.py | 304 ++++++++++++++++++++++++++++++++
core/fhir/tests_swagger.py | 251 ++++++++++++++++++++++++++
core/middlewares/middlewares.py | 9 +-
4 files changed, 590 insertions(+), 3 deletions(-)
create mode 100644 core/fhir/swagger.py
create mode 100644 core/fhir/tests_swagger.py
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/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_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/middlewares/middlewares.py b/core/middlewares/middlewares.py
index 15eb94f7b..635afae1c 100644
--- a/core/middlewares/middlewares.py
+++ b/core/middlewares/middlewares.py
@@ -19,6 +19,7 @@
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
@@ -298,14 +299,16 @@ 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
def __call__(self, request):
fhir_path = is_fhir_path(request.path)
if settings.FHIR_SUBDOMAIN:
- on_fhir_host = request.get_host().startswith(settings.FHIR_SUBDOMAIN + '.')
- if on_fhir_host and not fhir_path and request.path.strip('/').split('/')[0] != 'version':
+ 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 on_fhir_host and fhir_path:
+ if not fhir_host and fhir_path:
return self.not_found(request, 'The FHIR API is served on its own host.')
return self.get_response(request)