PageRenderTime 46ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 0ms

/corehq/motech/fhir/tasks.py

https://github.com/dimagi/commcare-hq
Python | 387 lines | 321 code | 39 blank | 27 comment | 51 complexity | 9726e25862ff6f97ae567ac6a4d3a9dc MD5 | raw file
Possible License(s): BSD-3-Clause, LGPL-2.1
  1. from collections import namedtuple
  2. from typing import Generator, List
  3. from uuid import uuid4
  4. from celery.schedules import crontab
  5. from celery.task import periodic_task, task
  6. from django.conf import settings
  7. from jsonpath_ng.ext.parser import parse as jsonpath_parse
  8. from casexml.apps.case.mock import CaseBlock
  9. from corehq import toggles
  10. from corehq.apps.hqcase.utils import submit_case_blocks
  11. from corehq.form_processor.exceptions import CaseNotFound
  12. from corehq.form_processor.models import CommCareCase
  13. from corehq.motech.const import (
  14. IMPORT_FREQUENCY_DAILY,
  15. IMPORT_FREQUENCY_MONTHLY,
  16. IMPORT_FREQUENCY_WEEKLY,
  17. )
  18. from corehq.motech.exceptions import ConfigurationError, RemoteAPIError
  19. from corehq.motech.requests import Requests
  20. from corehq.motech.utils import simplify_list
  21. from .bundle import get_bundle, get_next_url, iter_bundle
  22. from .const import SYSTEM_URI_CASE_ID, XMLNS_FHIR
  23. from .models import FHIRImportConfig, FHIRImportResourceType
  24. ParentInfo = namedtuple(
  25. 'ParentInfo',
  26. ['child_case_id', 'parent_ref', 'parent_resource_type'],
  27. )
  28. @periodic_task(
  29. run_every=crontab(hour=5, minute=5),
  30. queue=settings.CELERY_PERIODIC_QUEUE,
  31. )
  32. def run_daily_importers():
  33. for importer_id in (
  34. FHIRImportConfig.objects
  35. .filter(frequency=IMPORT_FREQUENCY_DAILY)
  36. .values_list('id', flat=True)
  37. ):
  38. run_importer.delay(importer_id)
  39. @periodic_task(
  40. run_every=crontab(hour=5, minute=5, day_of_week=6), # Saturday
  41. queue=settings.CELERY_PERIODIC_QUEUE,
  42. )
  43. def run_weekly_importers():
  44. for importer_id in (
  45. FHIRImportConfig.objects
  46. .filter(frequency=IMPORT_FREQUENCY_WEEKLY)
  47. .values_list('id', flat=True)
  48. ):
  49. run_importer.delay(importer_id)
  50. @periodic_task(
  51. run_every=crontab(hour=5, minute=5, day_of_month=1),
  52. queue=settings.CELERY_PERIODIC_QUEUE,
  53. )
  54. def run_monthly_importers():
  55. for importer_id in (
  56. FHIRImportConfig.objects
  57. .filter(frequency=IMPORT_FREQUENCY_MONTHLY)
  58. .values_list('id', flat=True)
  59. ):
  60. run_importer.delay(importer_id)
  61. @task(queue='background_queue', ignore_result=True)
  62. def run_importer(importer_id):
  63. """
  64. Poll remote API and import resources as CommCare cases.
  65. ServiceRequest resources are treated specially for workflows that
  66. handle referrals across systems like CommCare.
  67. """
  68. importer = (
  69. FHIRImportConfig.objects
  70. .select_related('connection_settings')
  71. .get(pk=importer_id)
  72. )
  73. if not toggles.FHIR_INTEGRATION.enabled(importer.domain):
  74. return
  75. requests = importer.connection_settings.get_requests()
  76. # TODO: Check service is online, else retry with exponential backoff
  77. child_cases = []
  78. for resource_type in (
  79. importer.resource_types
  80. .filter(import_related_only=False)
  81. .prefetch_related('jsonpaths_to_related_resource_types')
  82. .all()
  83. ):
  84. import_resource_type(requests, resource_type, child_cases)
  85. create_parent_indices(importer, child_cases)
  86. def import_resource_type(
  87. requests: Requests,
  88. resource_type: FHIRImportResourceType,
  89. child_cases: List[ParentInfo],
  90. ):
  91. try:
  92. for resource in iter_resources(requests, resource_type):
  93. import_resource(requests, resource_type, resource, child_cases)
  94. except Exception as err:
  95. requests.notify_exception(str(err))
  96. def iter_resources(
  97. requests: Requests,
  98. resource_type: FHIRImportResourceType,
  99. ) -> Generator:
  100. searchset_bundle = get_bundle(
  101. requests,
  102. endpoint=f"{resource_type.name}/",
  103. params=resource_type.search_params,
  104. )
  105. while True:
  106. yield from iter_bundle(searchset_bundle)
  107. url = get_next_url(searchset_bundle)
  108. if url:
  109. searchset_bundle = get_bundle(requests, url=url)
  110. else:
  111. break
  112. def import_resource(
  113. requests: Requests,
  114. resource_type: FHIRImportResourceType,
  115. resource: dict,
  116. child_cases: List[ParentInfo],
  117. ):
  118. if 'resourceType' not in resource:
  119. raise RemoteAPIError(
  120. "FHIR resource missing required property 'resourceType'"
  121. )
  122. if resource['resourceType'] != resource_type.name:
  123. raise RemoteAPIError(
  124. f"API request for resource type {resource_type.name!r} returned "
  125. f"resource type {resource['resourceType']!r}."
  126. )
  127. case_id = str(uuid4())
  128. if resource_type.name == 'ServiceRequest':
  129. try:
  130. resource = claim_service_request(requests, resource, case_id)
  131. except ServiceRequestNotActive:
  132. # ServiceRequests whose status is "active" are available for
  133. # CommCare to claim. If this ServiceRequest is no longer
  134. # active, then it is not available any more, and CommCare
  135. # should not import it.
  136. return
  137. case_block = build_case_block(resource_type, resource, case_id)
  138. submit_case_blocks(
  139. [case_block.as_text()],
  140. resource_type.domain,
  141. xmlns=XMLNS_FHIR,
  142. device_id=f'FHIRImportConfig-{resource_type.import_config.pk}',
  143. )
  144. import_related(
  145. requests,
  146. resource_type,
  147. resource,
  148. case_block.case_id,
  149. child_cases,
  150. )
  151. def claim_service_request(requests, service_request, case_id, attempt=0):
  152. """
  153. Uses `ETag`_ to prevent a race condition.
  154. .. _ETag: https://www.hl7.org/fhir/http.html#concurrency
  155. """
  156. endpoint = f"ServiceRequest/{service_request['id']}"
  157. response = requests.get(endpoint, raise_for_status=True)
  158. etag = response.headers['ETag']
  159. service_request = response.json()
  160. if service_request['status'] != 'active':
  161. raise ServiceRequestNotActive
  162. service_request['status'] = 'on-hold'
  163. service_request.setdefault('identifier', [])
  164. has_case_id = any(id_.get('system') == SYSTEM_URI_CASE_ID
  165. for id_ in service_request['identifier'])
  166. if not has_case_id:
  167. service_request['identifier'].append({
  168. 'system': SYSTEM_URI_CASE_ID,
  169. 'value': case_id,
  170. })
  171. headers = {'If-Match': etag}
  172. response = requests.put(endpoint, json=service_request, headers=headers)
  173. if 200 <= response.status_code < 300:
  174. return service_request
  175. if response.status_code == 412 and attempt < 3:
  176. # ETag didn't match. Try again.
  177. return claim_service_request(requests, service_request, case_id, attempt + 1)
  178. else:
  179. response.raise_for_status()
  180. def build_case_block(resource_type, resource, suggested_case_id):
  181. domain = resource_type.domain
  182. case_type = resource_type.case_type.name
  183. owner_id = resource_type.import_config.owner_id
  184. case = None
  185. case_id = get_case_id_or_none(resource)
  186. external_id = resource['id'] if 'id' in resource else CaseBlock.undefined
  187. if case_id:
  188. case = get_case_by_id(domain, case_id)
  189. # If we have a case_id we can be pretty sure we can get a case
  190. # ... unless it's been deleted. If so, fall back on external_id.
  191. if case is None and external_id != CaseBlock.undefined:
  192. # external_id will almost always be set.
  193. case = get_case_by_external_id(domain, external_id, case_type)
  194. caseblock_kwargs = get_caseblock_kwargs(resource_type, resource)
  195. return CaseBlock(
  196. create=case is None,
  197. case_id=case.case_id if case else suggested_case_id,
  198. owner_id=owner_id,
  199. case_type=case_type,
  200. date_opened=CaseBlock.undefined,
  201. external_id=external_id,
  202. **caseblock_kwargs,
  203. )
  204. def get_case_id_or_none(resource):
  205. """
  206. If ``resource`` has a CommCare case ID identifier, return its value,
  207. otherwise return None.
  208. """
  209. if 'identifier' in resource:
  210. case_id_identifier = [id_ for id_ in resource['identifier']
  211. if id_.get('system') == SYSTEM_URI_CASE_ID]
  212. if case_id_identifier:
  213. return case_id_identifier[0]['value']
  214. return None
  215. def get_case_by_id(domain, case_id):
  216. try:
  217. case = CommCareCase.objects.get_case(case_id, domain)
  218. except (CaseNotFound, KeyError):
  219. return None
  220. return case if case.domain == domain and not case.is_deleted else None
  221. def get_case_by_external_id(domain, external_id, case_type):
  222. try:
  223. case = CommCareCase.objects.get_case_by_external_id(
  224. domain, external_id, case_type, raise_multiple=True)
  225. except CommCareCase.MultipleObjectsReturned:
  226. return None
  227. return case if case is not None and not case.is_deleted else None
  228. def get_caseblock_kwargs(resource_type, resource):
  229. name_properties = {"name", "case_name"}
  230. kwargs = {
  231. 'case_name': get_name(resource),
  232. 'update': {}
  233. }
  234. for value_source in resource_type.iter_case_property_value_sources():
  235. value = value_source.get_import_value(resource)
  236. if value is not None:
  237. if value_source.case_property in name_properties:
  238. kwargs['case_name'] = value
  239. else:
  240. kwargs['update'][value_source.case_property] = value
  241. return kwargs
  242. def get_name(resource):
  243. """
  244. Returns a name, or a code, or an empty string.
  245. """
  246. if resource.get('name'):
  247. return resource['name'][0].get('text', '')
  248. if resource.get('code'):
  249. return resource['code'][0].get('text', '')
  250. return ''
  251. def import_related(
  252. requests: Requests,
  253. resource_type: FHIRImportResourceType,
  254. resource: dict,
  255. case_id: str,
  256. child_cases: List[ParentInfo],
  257. ):
  258. for rel in resource_type.jsonpaths_to_related_resource_types.all():
  259. jsonpath = jsonpath_parse(rel.jsonpath)
  260. reference = simplify_list([x.value for x in jsonpath.find(resource)])
  261. validate_parent_ref(reference, rel.related_resource_type)
  262. related_resource = get_resource(requests, reference)
  263. if rel.related_resource_is_parent:
  264. parent_info = ParentInfo(
  265. child_case_id=case_id,
  266. parent_ref=reference,
  267. parent_resource_type=rel.related_resource_type,
  268. )
  269. child_cases.append(parent_info)
  270. import_resource(
  271. requests,
  272. rel.related_resource_type,
  273. related_resource,
  274. child_cases,
  275. )
  276. def validate_parent_ref(parent_ref, parent_resource_type):
  277. """
  278. Validates that ``parent_ref`` is a relative reference with an
  279. expected resource type. e.g. "Patient/12345"
  280. """
  281. try:
  282. resource_type_name, resource_id = parent_ref.split('/')
  283. except (AttributeError, ValueError):
  284. raise ConfigurationError(
  285. f'Unexpected reference format {parent_ref!r}')
  286. if resource_type_name != parent_resource_type.name:
  287. raise ConfigurationError(
  288. 'Resource type does not match expected parent resource type')
  289. def get_resource(requests, reference):
  290. """
  291. Fetches a resource.
  292. ``reference`` must be a relative reference. e.g. "Patient/12345"
  293. """
  294. response = requests.get(endpoint=reference, raise_for_status=True)
  295. return response.json()
  296. def create_parent_indices(
  297. importer: FHIRImportConfig,
  298. child_cases: List[ParentInfo],
  299. ):
  300. """
  301. Creates parent-child relationships on imported cases.
  302. If ``ResourceTypeRelationship.related_resource_is_parent`` is
  303. ``True`` then this function will add an ``index`` on the child case
  304. to its parent case.
  305. """
  306. if not child_cases:
  307. return
  308. case_blocks = []
  309. for child_case_id, parent_ref, parent_resource_type in child_cases:
  310. resource_type_name, external_id = parent_ref.split('/')
  311. parent_case = get_case_by_external_id(
  312. parent_resource_type.domain,
  313. external_id,
  314. parent_resource_type.case_type.name,
  315. )
  316. if not parent_case:
  317. raise ConfigurationError(
  318. f'Case not found with external_id {external_id!r}')
  319. case_blocks.append(CaseBlock(
  320. child_case_id,
  321. index={'parent': (parent_case.type, parent_case.case_id)},
  322. ))
  323. submit_case_blocks(
  324. [cb.as_text() for cb in case_blocks],
  325. importer.domain,
  326. xmlns=XMLNS_FHIR,
  327. device_id=f'FHIRImportConfig-{importer.pk}',
  328. )
  329. class ServiceRequestNotActive(Exception):
  330. pass