class ILSGatewayConfig(Document): enabled = BooleanProperty(default=False) domain = StringProperty() url = StringProperty(default="http://ilsgateway.com/api/v0_1") username = StringProperty() password = StringProperty() steady_sync = BooleanProperty(default=False) all_stock_data = BooleanProperty(default=False) @classmethod def for_domain(cls, name): try: mapping = DocDomainMapping.objects.get(domain_name=name, doc_type='ILSGatewayConfig') return cls.get(docid=mapping.doc_id) except DocDomainMapping.DoesNotExist: return None @classmethod def get_all_configs(cls): mappings = DocDomainMapping.objects.filter(doc_type='ILSGatewayConfig') configs = [cls.get(docid=mapping.doc_id) for mapping in mappings] return configs @classmethod def get_all_enabled_domains(cls): configs = cls.get_all_configs() return [ c.domain for c in filter(lambda config: config.enabled, configs) ] @classmethod def get_all_steady_sync_configs(cls): return [ config for config in cls.get_all_configs() if config.steady_sync ] @property def is_configured(self): return True if self.enabled and self.url and self.password and self.username else False def save(self, **params): super(ILSGatewayConfig, self).save(**params) try: DocDomainMapping.objects.get(doc_id=self._id, domain_name=self.domain, doc_type="ILSGatewayConfig") except DocDomainMapping.DoesNotExist: DocDomainMapping.objects.create(doc_id=self._id, domain_name=self.domain, doc_type='ILSGatewayConfig') add_to_module_map(self.domain, 'custom.ilsgateway')
class ReportMeta(DocumentSchema): # `True` if this report was initially constructed by the report builder. created_by_builder = BooleanProperty(default=False) report_builder_version = StringProperty(default="") # `True` if this report was ever edited in the advanced JSON UIs (after June 7, 2016) edited_manually = BooleanProperty(default=False) last_modified = DateTimeProperty() builder_report_type = StringProperty( choices=['chart', 'list', 'table', 'worker', 'map']) builder_source_type = StringProperty( choices=REPORT_BUILDER_DATA_SOURCE_TYPE_VALUES) # If this is a linked report, this is the ID of the report this pulls from master_id = StringProperty()
class CallCenterProperties(DocumentSchema): enabled = BooleanProperty(default=False) use_fixtures = BooleanProperty(default=True) case_owner_id = StringProperty() use_user_location_as_owner = BooleanProperty(default=False) user_location_ancestor_level = IntegerProperty(default=0) case_type = StringProperty() def fixtures_are_active(self): return self.enabled and self.use_fixtures def config_is_valid(self): return (self.use_user_location_as_owner or self.case_owner_id) and self.case_type
class LicenseAgreement(DocumentSchema): signed = BooleanProperty(default=False) type = StringProperty() date = DateTimeProperty() user_id = StringProperty() user_ip = StringProperty() version = StringProperty()
class CustomDataField(JsonObject): slug = StringProperty() is_required = BooleanProperty() label = StringProperty() choices = StringListProperty() regex = StringProperty() regex_msg = StringProperty()
class CDotWeeklySchedule(OldDocument): """Weekly schedule where each day has a username""" schedule_id = StringProperty(default=make_uuid) sunday = StringProperty() monday = StringProperty() tuesday = StringProperty() wednesday = StringProperty() thursday = StringProperty() friday = StringProperty() saturday = StringProperty() comment = StringProperty() deprecated = BooleanProperty(default=False) started = OldDateTimeProperty(default=datetime.utcnow, required=True) ended = OldDateTimeProperty() created_by = StringProperty() # user id edited_by = StringProperty() # user id @property def is_current(self): now = datetime.utcnow() return self.started <= now and (self.ended is None or self.ended > now) class Meta: app_label = 'pact'
class Dhis2Connection(Document): domain = StringProperty() server_url = StringProperty() username = StringProperty() password = StringProperty() skip_cert_verify = BooleanProperty(default=False) @classmethod def wrap(cls, data): data.pop('log_level', None) return super(Dhis2Connection, cls).wrap(data) def save(self, *args, **kwargs): # Save to SQL model, created = SQLDhis2Connection.objects.update_or_create( domain=self.domain, defaults={ 'server_url': self.server_url, 'username': self.username, 'password': self.password, 'skip_cert_verify': self.skip_cert_verify, } ) # Save to couch super().save(*args, **kwargs)
class Deployment(DocumentSchema, UpdatableSchema): city = StringProperty() countries = StringListProperty() region = StringProperty( ) # e.g. US, LAC, SA, Sub-saharn Africa, East Africa, West Africa, Southeast Asia) description = StringProperty() public = BooleanProperty(default=False)
class ApplicationAccess(QuickCachedDocumentMixin, Document): """ This is used to control which users/groups can access which applications on cloudcare. """ domain = StringProperty() app_groups = SchemaListProperty(AppGroup, default=[]) restrict = BooleanProperty(default=False)
class StudySettings(DocumentSchema): is_ws_enabled = BooleanProperty() url = StringProperty() username = StringProperty() password = StringProperty() protocol_id = StringProperty() metadata = StringProperty() # Required when web service is not enabled
class ExportColumn(DocumentSchema): """ A column configuration, for export """ index = StringProperty() display = StringProperty() # signature: transform(val, doc) -> val transform = SerializableFunctionProperty(default=None) tag = StringProperty() is_sensitive = BooleanProperty(default=False) show = BooleanProperty(default=False) @classmethod def wrap(self, data): if 'is_sensitive' not in data and data.get('transform', None): data['is_sensitive'] = True if 'doc_type' in data and \ self.__name__ == ExportColumn.__name__ and \ self.__name__ != data['doc_type']: if data['doc_type'] in column_types: return column_types[data['doc_type']].wrap(data) else: raise ResourceNotFound('Unknown column type: %s', data) else: return super(ExportColumn, self).wrap(data) def get_display(self): return u'{primary}{extra}'.format( primary=self.display, extra=" [sensitive]" if self.is_sensitive else '' ) def to_config_format(self, selected=True): return { "index": self.index, "display": self.display, "transform": self.transform.dumps() if self.transform else None, "is_sensitive": self.is_sensitive, "selected": selected, "tag": self.tag, "show": self.show, "doc_type": self.doc_type, "options": [], "allOptions": None, }
class FormRepeater(Repeater): """ Record that forms should be repeated to a new url """ payload_generator_classes = (FormRepeaterXMLPayloadGenerator, FormRepeaterJsonPayloadGenerator) include_app_id_param = BooleanProperty(default=True) white_listed_form_xmlns = StringListProperty( default=[]) # empty value means all form xmlns are accepted friendly_name = _("Forward Forms") @memoized def payload_doc(self, repeat_record): return FormAccessors(repeat_record.domain).get_form( repeat_record.payload_id) @property def form_class_name(self): """ FormRepeater and its subclasses use the same form for editing """ return 'FormRepeater' def allowed_to_forward(self, payload): return (payload.xmlns != DEVICE_LOG_XMLNS and (not self.white_listed_form_xmlns or payload.xmlns in self.white_listed_form_xmlns)) def get_url(self, repeat_record): url = super(FormRepeater, self).get_url(repeat_record) if not self.include_app_id_param: return url else: # adapted from http://stackoverflow.com/a/2506477/10840 url_parts = list(urlparse(url)) query = parse_qsl(url_parts[4]) try: query.append( ("app_id", self.payload_doc(repeat_record).app_id)) except (XFormNotFound, ResourceNotFound): return None url_parts[4] = urlencode(query) return urlunparse(url_parts) def get_headers(self, repeat_record): headers = super(FormRepeater, self).get_headers(repeat_record) headers.update({ "received-on": self.payload_doc(repeat_record).received_on.isoformat() + "Z" }) return headers def __str__(self): return "forwarding forms to: %s" % self.url
class DataSourceBuildInformation(DocumentSchema): """ A class to encapsulate meta information about the process through which its DataSourceConfiguration was configured and built. """ # Either the case type or the form xmlns that this data source is based on. source_id = StringProperty() # The app that the form belongs to, or the app that was used to infer the case properties. app_id = StringProperty() # The version of the app at the time of the data source's configuration. app_version = IntegerProperty() # True if the data source has been built, that is, if the corresponding SQL table has been populated. finished = BooleanProperty(default=False) # Start time of the most recent build SQL table celery task. initiated = DateTimeProperty() # same as previous attributes but used for rebuilding tables in place finished_in_place = BooleanProperty(default=False) initiated_in_place = DateTimeProperty()
class Dhis2Connection(Document): domain = StringProperty() server_url = StringProperty() username = StringProperty() password = StringProperty() skip_cert_verify = BooleanProperty(default=False) @classmethod def wrap(cls, data): data.pop('log_level', None) return super(Dhis2Connection, cls).wrap(data)
class WisePillDeviceEvent(Document): """ One DeviceEvent is created each time a device sends data that is forwarded to the CommCareHQ WisePill API (/wisepill/device/). """ domain = StringProperty() data = StringProperty() received_on = DateTimeProperty() # Document _id of the case representing the device that sent this data in case_id = StringProperty() processed = BooleanProperty() @property @memoized def data_as_dict(self): """ Convert 'a=b,c=d' to {'a': 'b', 'c': 'd'} """ result = {} if isinstance(self.data, str): items = self.data.strip().split(',') for item in items: parts = item.partition('=') key = parts[0].strip().upper() value = parts[2].strip() if value: result[key] = value return result @property def serial_number(self): return self.data_as_dict.get('SN', None) @property def timestamp(self): raw = self.data_as_dict.get('T', None) if isinstance(raw, str) and len(raw) == 12: return "20%s-%s-%s %s:%s:%s" % ( raw[4:6], raw[2:4], raw[0:2], raw[6:8], raw[8:10], raw[10:12], ) else: return None @classmethod def get_all_ids(cls): result = cls.view('wisepill/device_event', include_docs=False) return [row['id'] for row in result]
class VerifiedNumber(Document): """ There should only be one VerifiedNumber entry per (owner_doc_type, owner_id), and each VerifiedNumber.phone_number should be unique across all entries. """ domain = StringProperty() owner_doc_type = StringProperty() owner_id = StringProperty() phone_number = StringProperty() backend_id = StringProperty( ) # the name of a MobileBackend (can be domain-level or system-level) ivr_backend_id = StringProperty() # points to a MobileBackend verified = BooleanProperty() contact_last_modified = DateTimeProperty()
class SavedBasicExport(BlobMixin, Document): """ A cache of an export that lives in couch. Doesn't do anything smart, just works off an index """ configuration = SchemaProperty(ExportConfiguration) last_updated = DateTimeProperty() last_accessed = DateTimeProperty() is_safe = BooleanProperty(default=False) _blobdb_type_code = CODES.basic_export @property def size(self): try: return self.blobs[self.get_attachment_name()].content_length except KeyError: return 0 def has_file(self): return self.get_attachment_name() in self.blobs def get_attachment_name(self): # obfuscate this because couch doesn't like attachments that start with underscores return hashlib.md5( six.text_type( self.configuration.filename).encode('utf-8')).hexdigest() def set_payload(self, payload): # According to @esoergel this code is slated for removal in the near # future, so I didn't think it was worth it to try to pass the domain # in here. self.put_attachment(payload, self.get_attachment_name(), domain=UNKNOWN_DOMAIN) def get_payload(self, stream=False): return self.fetch_attachment(self.get_attachment_name(), stream=stream, return_bytes=True) @classmethod def by_index(cls, index): return SavedBasicExport.view( "couchexport/saved_exports", key=json.dumps(index), include_docs=True, reduce=False, ).all()
class ApplicationAccess(QuickCachedDocumentMixin, Document): """ This is used to control which users/groups can access which applications on cloudcare. """ domain = StringProperty() app_groups = SchemaListProperty(AppGroup, default=[]) restrict = BooleanProperty(default=False) @classmethod def get_by_domain(cls, domain): from corehq.apps.cloudcare.dbaccessors import get_application_access_for_domain self = get_application_access_for_domain(domain) return self or cls(domain=domain) def clear_caches(self): from corehq.apps.cloudcare.dbaccessors import get_application_access_for_domain get_application_access_for_domain.clear(self.domain) super(ApplicationAccess, self).clear_caches() def user_can_access_app(self, user, app): user_id = user['_id'] app_id = app['_id'] if not self.restrict or user['doc_type'] == 'WebUser': return True app_group = None for app_group in self.app_groups: if app_group.app_id in (app_id, app['copy_of'] or ()): break if app_group: return Group.user_in_group(user_id, app_group.group_id) else: return False @classmethod def get_template_json(cls, domain, apps): app_ids = dict([(app['_id'], app) for app in apps]) self = ApplicationAccess.get_by_domain(domain) j = self.to_json() merged_access_list = [] for a in j['app_groups']: app_id = a['app_id'] if app_id in app_ids: merged_access_list.append(a) del app_ids[app_id] for app in app_ids.values(): merged_access_list.append({'app_id': app['_id'], 'group_id': None}) j['app_groups'] = merged_access_list return j
class RegistrationRequest(Document): tos_confirmed = BooleanProperty(default=False) request_time = DateTimeProperty() request_ip = StringProperty() activation_guid = StringProperty() confirm_time = DateTimeProperty() confirm_ip = StringProperty() domain = StringProperty() new_user_username = StringProperty() requesting_user_username = StringProperty() @property @memoized def project(self): return Domain.get_by_name(self.domain) @classmethod def get_by_guid(cls, guid): result = cls.view("registration/requests_by_guid", key=guid, reduce=False, include_docs=True).first() return result @classmethod def get_requests_today(cls): today = datetime.datetime.utcnow() yesterday = today - datetime.timedelta(1) result = cls.view("registration/requests_by_time", startkey=yesterday.isoformat(), endkey=today.isoformat(), reduce=True).all() if not result: return 0 return result[0]['value'] @classmethod def get_request_for_username(cls, username): result = cls.view("registration/requests_by_username", key=username, reduce=False, include_docs=True).first() return result
class InternalProperties(DocumentSchema, UpdatableSchema): """ Project properties that should only be visible/editable by superusers """ sf_contract_id = StringProperty() sf_account_id = StringProperty() commcare_edition = StringProperty( choices=['', "plus", "community", "standard", "pro", "advanced", "enterprise"], default="community" ) initiative = StringListProperty() workshop_region = StringProperty() project_state = StringProperty(choices=["", "POC", "transition", "at-scale"], default="") self_started = BooleanProperty(default=True) area = StringProperty() sub_area = StringProperty() using_adm = BooleanProperty() using_call_center = BooleanProperty() custom_eula = BooleanProperty() can_use_data = BooleanProperty(default=True) notes = StringProperty() organization_name = StringProperty() platform = StringListProperty() project_manager = StringProperty() phone_model = StringProperty() goal_time_period = IntegerProperty() goal_followup_rate = DecimalProperty() # intentionally different from and commtrack_enabled so that FMs can change commtrack_domain = BooleanProperty() performance_threshold = IntegerProperty() experienced_threshold = IntegerProperty() amplifies_workers = StringProperty( choices=[AMPLIFIES_YES, AMPLIFIES_NO, AMPLIFIES_NOT_SET], default=AMPLIFIES_NOT_SET ) amplifies_project = StringProperty( choices=[AMPLIFIES_YES, AMPLIFIES_NO, AMPLIFIES_NOT_SET], default=AMPLIFIES_NOT_SET ) business_unit = StringProperty(choices=BUSINESS_UNITS + [""], default="") data_access_threshold = IntegerProperty() partner_technical_competency = IntegerProperty() support_prioritization = IntegerProperty() gs_continued_involvement = StringProperty() technical_complexity = StringProperty() app_design_comments = StringProperty() training_materials = StringProperty() partner_comments = StringProperty() partner_contact = StringProperty() dimagi_contact = StringProperty()
class ComputedDocumentMixin(DocumentSchema): """ Use this mixin for things like CommCareCase or XFormInstance documents that take advantage of indicator definitions. computed_ is namespaced and may look like the following for indicators: computed_: { mvp_indicators: { indicator_slug: { version: 1, value: "foo" } } } """ computed_ = DictProperty() computed_modified_on_ = DateTimeProperty() # a flag for the indicator pillows so that there aren't any Document Update Conflicts initial_processing_complete = BooleanProperty()
class SavedBasicExport(BlobMixin, Document): """ A cache of an export that lives in couch. Doesn't do anything smart, just works off an index """ configuration = SchemaProperty(ExportConfiguration) last_updated = DateTimeProperty() last_accessed = DateTimeProperty() is_safe = BooleanProperty(default=False) @property def size(self): try: return self.blobs[self.get_attachment_name()].content_length except KeyError: return 0 def has_file(self): return self.get_attachment_name() in self.blobs def get_attachment_name(self): # obfuscate this because couch doesn't like attachments that start with underscores return hashlib.md5( six.text_type( self.configuration.filename).encode('utf-8')).hexdigest() def set_payload(self, payload): self.put_attachment(payload, self.get_attachment_name()) def get_payload(self, stream=False): return self.fetch_attachment(self.get_attachment_name(), stream=stream) @classmethod def by_index(cls, index): return SavedBasicExport.view( "couchexport/saved_exports", key=json.dumps(index), include_docs=True, reduce=False, ).all()
class PatientFinder(DocumentSchema): """ Subclasses of the PatientFinder class implement particular strategies for finding OpenMRS patients that suit a particular project. (WeightedPropertyPatientFinder was first subclass to be written. A future project with stronger emphasis on patient names might use Levenshtein distance, for example.) Subclasses must implement the `find_patients()` method. """ # Whether to create a new patient if no patients are found create_missing = BooleanProperty(default=False) @classmethod def wrap(cls, data): if cls is PatientFinder: return {sub._doc_type: sub for sub in recurse_subclasses(cls) }[data['doc_type']].wrap(data) else: return super(PatientFinder, cls).wrap(data) def find_patients(self, requests, case, case_config): """ Given a case, search OpenMRS for possible matches. Return the best results. Subclasses must define "best". If just one result is returned, it will be chosen. NOTE:: False positives can result in overwriting one patient with the data of another. It is definitely better to return no results or multiple results than to return a single invalid result. Returned results should be logged. """ raise NotImplementedError
class CallCenterProperties(DocumentSchema): enabled = BooleanProperty(default=False) use_fixtures = BooleanProperty(default=True) case_owner_id = StringProperty() use_user_location_as_owner = BooleanProperty(default=False) user_location_ancestor_level = IntegerProperty(default=0) case_type = StringProperty() form_datasource_enabled = BooleanProperty(default=True) case_datasource_enabled = BooleanProperty(default=True) case_actions_datasource_enabled = BooleanProperty(default=True) def fixtures_are_active(self): return self.enabled and self.use_fixtures def config_is_valid(self): return (self.use_user_location_as_owner or self.case_owner_id) and self.case_type def update_from_app_config(self, config): """Update datasources enabled based on app config. Follows similar logic to CallCenterIndicators :returns: True if changes were made """ pre = (self.form_datasource_enabled, self.case_datasource_enabled, self.case_actions_datasource_enabled) self.form_datasource_enabled = config.forms_submitted.enabled or bool( config.custom_form) self.case_datasource_enabled = (config.cases_total.enabled or config.cases_opened.enabled or config.cases_closed.enabled) self.case_actions_datasource_enabled = config.cases_active.enabled post = (self.form_datasource_enabled, self.case_datasource_enabled, self.case_actions_datasource_enabled) return pre != post
class FHIRRepeater(CaseRepeater): class Meta: app_label = 'repeaters' friendly_name = _('Forward Cases to a FHIR API') payload_generator_classes = (FormDictPayloadGenerator, ) include_app_id_param = False _has_config = False fhir_version = StringProperty(default=FHIR_VERSION_4_0_1) patient_registration_enabled = BooleanProperty(default=True) patient_search_enabled = BooleanProperty(default=False) @memoized def payload_doc(self, repeat_record): return FormAccessors(repeat_record.domain).get_form( repeat_record.payload_id) @property def form_class_name(self): # The class name used to determine which edit form to use return self.__class__.__name__ @classmethod def available_for_domain(cls, domain): return (domain_has_privilege(domain, DATA_FORWARDING) and FHIR_INTEGRATION.enabled(domain)) def allowed_to_forward(self, payload): # When we update a case's external_id to their ID on a remote # FHIR service, the form is submitted with XMLNS_FHIR. This # check makes sure that we don't send the update back to FHIR. return payload.xmlns != XMLNS_FHIR def send_request(self, repeat_record, payload): """ Generates FHIR resources from ``payload``, and sends them as a FHIR transaction bundle. If there are patients that need to be registered, that is done first. Returns an HTTP response-like object. If the payload has nothing to send, returns True. """ requests = self.connection_settings.get_requests( repeat_record.payload_id) infos, resource_types = self.get_infos_resource_types( payload, self.fhir_version, ) try: resources = get_info_resource_list(infos, resource_types) resources = register_patients( requests, resources, self.patient_registration_enabled, self.patient_search_enabled, self._id, ) response = send_resources( requests, resources, self.fhir_version, self._id, ) except Exception as err: requests.notify_exception(str(err)) return RepeaterResponse(400, 'Bad Request', pformat_json(str(err))) return response def get_infos_resource_types( self, form_json: dict, fhir_version: str, ) -> Tuple[List[CaseTriggerInfo], Dict[str, FHIRResourceType]]: form_question_values = get_form_question_values(form_json) case_blocks = extract_case_blocks(form_json) cases_by_id = _get_cases_by_id(self.domain, case_blocks) resource_types_by_case_type = _get_resource_types_by_case_type( self.domain, fhir_version, cases_by_id.values(), ) case_trigger_info_list = [] for case_block in case_blocks: try: case = cases_by_id[case_block['@case_id']] except KeyError: form_id = form_json[TAG_FORM][TAG_META]['instanceID'] raise CaseNotFound( f"Form {form_id!r} touches case {case_block['@case_id']!r} " "but that case is not found.") try: resource_type = resource_types_by_case_type[case.type] except KeyError: # The case type is not mapped to a FHIR resource type. # This case is not meant to be represented as a FHIR # resource. continue case_trigger_info_list.append( get_case_trigger_info( case, resource_type, case_block, form_question_values, ))
class EWSGhanaConfig(Document): enabled = BooleanProperty(default=False) domain = StringProperty() url = StringProperty(default="http://ewsghana.com/api/v0_1") username = StringProperty() password = StringProperty() steady_sync = BooleanProperty(default=False) all_stock_data = BooleanProperty(default=False) @classmethod def for_domain(cls, name): try: mapping = DocDomainMapping.objects.get(domain_name=name, doc_type='EWSGhanaConfig') return cls.get(docid=mapping.doc_id) except DocDomainMapping.DoesNotExist: return None @classmethod def get_all_configs(cls): mappings = DocDomainMapping.objects.filter(doc_type='EWSGhanaConfig') configs = [cls.get(docid=mapping.doc_id) for mapping in mappings] return configs @classmethod def get_all_steady_sync_configs(cls): return [ config for config in cls.get_all_configs() if config.steady_sync ] @classmethod def get_all_enabled_domains(cls): configs = cls.get_all_configs() return [ c.domain for c in filter(lambda config: config.enabled, configs) ] @property def is_configured(self): return True if self.enabled and self.url and self.password and self.username else False def save(self, **params): super(EWSGhanaConfig, self).save(**params) self.update_toggle() try: DocDomainMapping.objects.get(doc_id=self._id, domain_name=self.domain, doc_type="EWSGhanaConfig") except DocDomainMapping.DoesNotExist: DocDomainMapping.objects.create(doc_id=self._id, domain_name=self.domain, doc_type='EWSGhanaConfig') add_to_module_map(self.domain, 'custom.ewsghana') def update_toggle(self): """ This turns on the special stock handler when EWS is enabled. """ if self.enabled: STOCK_AND_RECEIPT_SMS_HANDLER.set(self.domain, True, NAMESPACE_DOMAIN) class Meta: app_label = 'ewsghana'
class Repeater(QuickCachedDocumentMixin, Document): """ Represents the configuration of a repeater. Will specify the URL to forward to and other properties of the configuration. """ base_doc = 'Repeater' domain = StringProperty() url = StringProperty() format = StringProperty() auth_type = StringProperty(choices=(BASIC_AUTH, DIGEST_AUTH, OAUTH1), required=False) username = StringProperty() password = StringProperty() skip_cert_verify = BooleanProperty(default=False) friendly_name = _("Data") paused = BooleanProperty(default=False) payload_generator_classes = () @classmethod def get_custom_url(cls, domain): return None @classmethod def available_for_domain(cls, domain): """Returns whether this repeater can be used by a particular domain """ return True def get_pending_record_count(self): return get_pending_repeat_record_count(self.domain, self._id) def get_failure_record_count(self): return get_failure_repeat_record_count(self.domain, self._id) def get_success_record_count(self): return get_success_repeat_record_count(self.domain, self._id) def get_cancelled_record_count(self): return get_cancelled_repeat_record_count(self.domain, self._id) def _format_or_default_format(self): from corehq.motech.repeaters.repeater_generators import RegisterGenerator return self.format or RegisterGenerator.default_format_by_repeater(self.__class__) def _get_payload_generator(self, payload_format): from corehq.motech.repeaters.repeater_generators import RegisterGenerator gen = RegisterGenerator.generator_class_by_repeater_format(self.__class__, payload_format) return gen(self) @property @memoized def generator(self): return self._get_payload_generator(self._format_or_default_format()) def payload_doc(self, repeat_record): raise NotImplementedError @memoized def get_payload(self, repeat_record): return self.generator.get_payload(repeat_record, self.payload_doc(repeat_record)) def get_attempt_info(self, repeat_record): return None def register(self, payload, next_check=None): if not self.allowed_to_forward(payload): return now = datetime.utcnow() repeat_record = RepeatRecord( repeater_id=self.get_id, repeater_type=self.doc_type, domain=self.domain, registered_on=now, next_check=next_check or now, payload_id=payload.get_id ) repeat_record.save() return repeat_record def allowed_to_forward(self, payload): """ Return True/False depending on whether the payload meets forawrding criteria or not """ return True def clear_caches(self): super(Repeater, self).clear_caches() # Also expire for cases repeater is fetched using Repeater class. # The quick cache called in clear_cache also check on relies of doc class # so in case the class is set as Repeater it is not expired like in edit forms. # So expire it explicitly here with Repeater class as well. Repeater.get.clear(Repeater, self._id) if self.__class__ == Repeater: cls = self.get_class_from_doc_type(self.doc_type) else: cls = self.__class__ # clear cls.by_domain (i.e. filtered by doc type) Repeater.by_domain.clear(cls, self.domain) # clear Repeater.by_domain (i.e. not filtered by doc type) Repeater.by_domain.clear(Repeater, self.domain) @classmethod @quickcache(['cls.__name__', 'domain'], timeout=5 * 60, memoize_timeout=10) def by_domain(cls, domain): key = [domain] if cls.__name__ in get_all_repeater_types(): key.append(cls.__name__) elif cls.__name__ == Repeater.__name__: # In this case the wrap function delegates to the # appropriate sub-repeater types. pass else: # Any repeater type can be posted to the API, and the installed apps # determine whether we actually know about it. # But if we do not know about it, then may as well return nothing now return [] raw_docs = cls.view('repeaters/repeaters', startkey=key, endkey=key + [{}], include_docs=True, reduce=False, wrap_doc=False ) return [cls.wrap(repeater_doc['doc']) for repeater_doc in raw_docs if cls.get_class_from_doc_type(repeater_doc['doc']['doc_type'])] @classmethod def wrap(cls, data): if cls.__name__ == Repeater.__name__: cls_ = cls.get_class_from_doc_type(data['doc_type']) if cls_: return cls_.wrap(data) else: raise ResourceNotFound('Unknown repeater type: %s' % data) else: return super(Repeater, cls).wrap(data) @staticmethod def get_class_from_doc_type(doc_type): doc_type = doc_type.replace(DELETED, '') repeater_types = get_all_repeater_types() if doc_type in repeater_types: return repeater_types[doc_type] else: return None def retire(self): if DELETED not in self['doc_type']: self['doc_type'] += DELETED if DELETED not in self['base_doc']: self['base_doc'] += DELETED self.paused = False self.save() def pause(self): self.paused = True self.save() def resume(self): self.paused = False self.save() def get_url(self, repeat_record): # to be overridden return self.url def allow_retries(self, response): """Whether to requeue the repeater when it fails """ return True def get_headers(self, repeat_record): # to be overridden return self.generator.get_headers() @property def plaintext_password(self): if self.password.startswith('${algo}$'.format(algo=ALGO_AES)): ciphertext = self.password.split('$', 2)[2] return b64_aes_decrypt(ciphertext) return self.password def get_auth(self): if self.auth_type == BASIC_AUTH: return HTTPBasicAuth(self.username, self.plaintext_password) elif self.auth_type == DIGEST_AUTH: return HTTPDigestAuth(self.username, self.plaintext_password) return None @property def verify(self): return not self.skip_cert_verify def send_request(self, repeat_record, payload): headers = self.get_headers(repeat_record) auth = self.get_auth() url = self.get_url(repeat_record) return simple_post(payload, url, headers=headers, timeout=POST_TIMEOUT, auth=auth, verify=self.verify) def fire_for_record(self, repeat_record): payload = self.get_payload(repeat_record) try: response = self.send_request(repeat_record, payload) except (Timeout, ConnectionError) as error: log_repeater_timeout_in_datadog(self.domain) return self.handle_response(RequestConnectionError(error), repeat_record) except Exception as e: return self.handle_response(e, repeat_record) else: return self.handle_response(response, repeat_record) def handle_response(self, result, repeat_record): """ route the result to the success, failure, or exception handlers result may be either a response object or an exception """ if isinstance(result, Exception): attempt = repeat_record.handle_exception(result) self.generator.handle_exception(result, repeat_record) elif 200 <= result.status_code < 300: attempt = repeat_record.handle_success(result) self.generator.handle_success(result, self.payload_doc(repeat_record), repeat_record) else: attempt = repeat_record.handle_failure(result) self.generator.handle_failure(result, self.payload_doc(repeat_record), repeat_record) return attempt @property def form_class_name(self): """ Return the name of the class whose edit form this class uses. (Most classes that extend CaseRepeater, and all classes that extend FormRepeater, use the same form.) """ return self.__class__.__name__
class RepeatRecord(Document): """ An record of a particular instance of something that needs to be forwarded with a link to the proper repeater object """ domain = StringProperty() repeater_id = StringProperty() repeater_type = StringProperty() payload_id = StringProperty() overall_tries = IntegerProperty(default=0) max_possible_tries = IntegerProperty(default=3) attempts = ListProperty(RepeatRecordAttempt) cancelled = BooleanProperty(default=False) registered_on = DateTimeProperty() last_checked = DateTimeProperty() failure_reason = StringProperty() next_check = DateTimeProperty() succeeded = BooleanProperty(default=False) @property def record_id(self): return self._id @classmethod def wrap(cls, data): should_bootstrap_attempts = ('attempts' not in data) self = super(RepeatRecord, cls).wrap(data) if should_bootstrap_attempts and self.last_checked: assert not self.attempts self.attempts = [RepeatRecordAttempt( cancelled=self.cancelled, datetime=self.last_checked, failure_reason=self.failure_reason, success_response=None, next_check=self.next_check, succeeded=self.succeeded, )] return self @property @memoized def repeater(self): try: return Repeater.get(self.repeater_id) except ResourceNotFound: return None @property def url(self): warnings.warn("RepeatRecord.url is deprecated. Use Repeater.get_url instead", DeprecationWarning) if self.repeater: return self.repeater.get_url(self) @property def state(self): state = RECORD_PENDING_STATE if self.succeeded: state = RECORD_SUCCESS_STATE elif self.cancelled: state = RECORD_CANCELLED_STATE elif self.failure_reason: state = RECORD_FAILURE_STATE return state @classmethod def all(cls, domain=None, due_before=None, limit=None): json_now = json_format_datetime(due_before or datetime.utcnow()) repeat_records = RepeatRecord.view("repeaters/repeat_records_by_next_check", startkey=[domain], endkey=[domain, json_now, {}], include_docs=True, reduce=False, limit=limit, ) return repeat_records @classmethod def count(cls, domain=None): results = RepeatRecord.view("repeaters/repeat_records_by_next_check", startkey=[domain], endkey=[domain, {}], reduce=True, ).one() return results['value'] if results else 0 def add_attempt(self, attempt): self.attempts.append(attempt) self.last_checked = attempt.datetime self.next_check = attempt.next_check self.succeeded = attempt.succeeded self.cancelled = attempt.cancelled self.failure_reason = attempt.failure_reason def get_numbered_attempts(self): for i, attempt in enumerate(self.attempts): yield i + 1, attempt def postpone_by(self, duration): self.last_checked = datetime.utcnow() self.next_check = self.last_checked + duration self.save() def make_set_next_try_attempt(self, failure_reason): # we use an exponential back-off to avoid submitting to bad urls # too frequently. assert self.succeeded is False assert self.next_check is not None window = timedelta(minutes=0) if self.last_checked: window = self.next_check - self.last_checked window += (window // 2) # window *= 1.5 if window < MIN_RETRY_WAIT: window = MIN_RETRY_WAIT elif window > MAX_RETRY_WAIT: window = MAX_RETRY_WAIT now = datetime.utcnow() return RepeatRecordAttempt( cancelled=False, datetime=now, failure_reason=failure_reason, success_response=None, next_check=now + window, succeeded=False, ) def try_now(self): # try when we haven't succeeded and either we've # never checked, or it's time to check again return not self.succeeded def get_payload(self): return self.repeater.get_payload(self) def get_attempt_info(self): return self.repeater.get_attempt_info(self) def handle_payload_exception(self, exception): now = datetime.utcnow() return RepeatRecordAttempt( cancelled=True, datetime=now, failure_reason=six.text_type(exception), success_response=None, next_check=None, succeeded=False, ) def fire(self, force_send=False): if self.try_now() or force_send: self.overall_tries += 1 try: attempt = self.repeater.fire_for_record(self) except Exception as e: log_repeater_error_in_datadog(self.domain, status_code=None, repeater_type=self.repeater_type) attempt = self.handle_payload_exception(e) raise finally: # pycharm warns attempt might not be defined. # that'll only happen if fire_for_record raise a non-Exception exception (e.g. SIGINT) # or handle_payload_exception raises an exception. I'm okay with that. -DMR self.add_attempt(attempt) self.save() @staticmethod def _format_response(response): return '{}: {}.\n{}'.format( response.status_code, response.reason, getattr(response, 'content', None)) def handle_success(self, response): """Do something with the response if the repeater succeeds """ now = datetime.utcnow() log_repeater_success_in_datadog( self.domain, response.status_code if response else None, self.repeater_type ) return RepeatRecordAttempt( cancelled=False, datetime=now, failure_reason=None, success_response=self._format_response(response) if response else None, next_check=None, succeeded=True, info=self.get_attempt_info(), ) def handle_failure(self, response): """Do something with the response if the repeater fails """ return self._make_failure_attempt(self._format_response(response), response) def handle_exception(self, exception): """handle internal exceptions """ return self._make_failure_attempt(six.text_type(exception), None) def _make_failure_attempt(self, reason, response): log_repeater_error_in_datadog(self.domain, response.status_code if response else None, self.repeater_type) if self.repeater.allow_retries(response) and self.overall_tries < self.max_possible_tries: return self.make_set_next_try_attempt(reason) else: now = datetime.utcnow() return RepeatRecordAttempt( cancelled=True, datetime=now, failure_reason=reason, success_response=None, next_check=None, succeeded=False, info=self.get_attempt_info(), ) def cancel(self): self.next_check = None self.cancelled = True def requeue(self): self.cancelled = False self.succeeded = False self.failure_reason = '' self.overall_tries = 0 self.next_check = datetime.utcnow()
class OpenmrsCaseConfig(DocumentSchema): # "patient_identifiers": { # "e2b966d0-1d5f-11e0-b929-000c29ad1d07": { # "case_property": "nid" # }, # "uuid": { # "case_property": "openmrs_uuid", # } # } patient_identifiers = DictProperty() # The patient_identifiers that are considered reliable # "match_on_ids": ["uuid", "e2b966d0-1d5f-11e0-b929-000c29ad1d07", match_on_ids = ListProperty() # "person_properties": { # "gender": { # "case_property": "gender" # }, # "birthdate": { # "case_property": "dob" # } # } person_properties = DictProperty() # "patient_finder": { # "doc_type": "WeightedPropertyPatientFinder", # "searchable_properties": ["nid", "family_name"], # "property_weights": [ # {"case_property": "nid", "weight": 0.9}, # // if "match_type" is not given it defaults to "exact" # {"case_property": "family_name", "weight": 0.4}, # { # "case_property": "given_name", # "weight": 0.3, # "match_type": "levenshtein", # // levenshtein function takes edit_distance / len # "match_params": [0.2] # // i.e. 0.2 (20%) is one edit for every 5 characters # // e.g. "Riyaz" matches "Riaz" but not "Riazz" # }, # {"case_property": "city", "weight": 0.2}, # { # "case_property": "dob", # "weight": 0.3, # "match_type": "days_diff", # // days_diff matches based on days difference from given date # "match_params": [364] # } # ] # } patient_finder = PatientFinder(required=False) # "person_preferred_name": { # "givenName": { # "case_property": "given_name" # }, # "middleName": { # "case_property": "middle_name" # }, # "familyName": { # "case_property": "family_name" # } # } person_preferred_name = DictProperty() # "person_preferred_address": { # "address1": { # "case_property": "address_1" # }, # "address2": { # "case_property": "address_2" # }, # "cityVillage": { # "case_property": "city" # } # } person_preferred_address = DictProperty() # "person_attributes": { # "c1f4239f-3f10-11e4-adec-0800271c1b75": { # "case_property": "caste" # }, # "c1f455e7-3f10-11e4-adec-0800271c1b75": { # "case_property": "class", # "value_map": { # "sc": "c1fcd1c6-3f10-11e4-adec-0800271c1b75", # "general": "c1fc20ab-3f10-11e4-adec-0800271c1b75", # "obc": "c1fb51cc-3f10-11e4-adec-0800271c1b75", # "other_caste": "c207073d-3f10-11e4-adec-0800271c1b75", # "st": "c20478b6-3f10-11e4-adec-0800271c1b75" # } # } # } person_attributes = DictProperty() # Create cases when importing via the Atom feed import_creates_cases = BooleanProperty(default=True) # If we ever need to disable updating cases, ``import_updates_cases`` # could be added here. Similarly, we could replace # ``patient_finder.create_missing`` with ``export_creates_patients`` # and ``export_updates_patients`` @classmethod def wrap(cls, data): if 'id_matchers' in data: # Convert legacy id_matchers to patient_identifiers. e.g. # [{'doc_type': 'IdMatcher' # 'identifier_type_id': 'e2b966d0-1d5f-11e0-b929-000c29ad1d07', # 'case_property': 'nid'}] # to # {'e2b966d0-1d5f-11e0-b929-000c29ad1d07': {'doc_type': 'CaseProperty', 'case_property': 'nid'}}, patient_identifiers = { m['identifier_type_id']: { 'doc_type': 'CaseProperty', 'case_property': m['case_property'] } for m in data['id_matchers'] } data['patient_identifiers'] = patient_identifiers data['match_on_ids'] = list(patient_identifiers) data.pop('id_matchers') # Set default data types for known properties for property_, value_source in chain( data.get('person_properties', {}).items(), data.get('person_preferred_name', {}).items(), data.get('person_preferred_address', {}).items(), ): data_type = OPENMRS_PROPERTIES[property_] value_source.setdefault('external_data_type', data_type) return super(OpenmrsCaseConfig, cls).wrap(data)
class OpenmrsRepeater(CaseRepeater): """ ``OpenmrsRepeater`` is responsible for updating OpenMRS patients with changes made to cases in CommCare. It is also responsible for creating OpenMRS "visits", "encounters" and "observations" when a corresponding visit form is submitted in CommCare. The ``OpenmrsRepeater`` class is different from most repeater classes in three details: 1. It has a case type and it updates the OpenMRS equivalent of cases like the ``CaseRepeater`` class, but it reads forms like the ``FormRepeater`` class. So it subclasses ``CaseRepeater`` but its payload format is ``form_json``. 2. It makes many API calls for each payload. 3. It can have a location. """ class Meta(object): app_label = 'repeaters' include_app_id_param = False friendly_name = _("Forward to OpenMRS") payload_generator_classes = (FormRepeaterJsonPayloadGenerator, ) location_id = StringProperty(default='') openmrs_config = SchemaProperty(OpenmrsConfig) _has_config = True # self.white_listed_case_types must have exactly one case type set # for Atom feed integration to add cases for OpenMRS patients. # self.location_id must be set to determine their case owner. The # owner is set to the first CommCareUser instance found at that # location. atom_feed_enabled = BooleanProperty(default=False) atom_feed_status = SchemaDictProperty(AtomFeedStatus) def __init__(self, *args, **kwargs): super(OpenmrsRepeater, self).__init__(*args, **kwargs) def __eq__(self, other): return (isinstance(other, self.__class__) and self.get_id == other.get_id) def __str__(self): return Repeater.__str__(self) @classmethod def wrap(cls, data): if 'atom_feed_last_polled_at' in data: data['atom_feed_status'] = { ATOM_FEED_NAME_PATIENT: { 'last_polled_at': data.pop('atom_feed_last_polled_at'), 'last_page': data.pop('atom_feed_last_page', None), } } return super(OpenmrsRepeater, cls).wrap(data) @cached_property def requests(self): # Used by atom_feed module and views that don't have a payload # associated with the request return self.get_requests() def get_requests(self, payload_id=None): return Requests( self.domain, self.url, self.username, self.plaintext_password, verify=self.verify, notify_addresses=self.notify_addresses, payload_id=payload_id, ) @cached_property def first_user(self): return get_one_commcare_user_at_location(self.domain, self.location_id) @memoized def payload_doc(self, repeat_record): return FormAccessors(repeat_record.domain).get_form( repeat_record.payload_id) @property def form_class_name(self): """ The class name used to determine which edit form to use """ return self.__class__.__name__ @classmethod def available_for_domain(cls, domain): return OPENMRS_INTEGRATION.enabled(domain) def allowed_to_forward(self, payload): """ Forward the payload if ... * it did not come from OpenMRS, and * CaseRepeater says it's OK for the case types and users of any of the payload's cases, and * this repeater forwards to the right OpenMRS server for any of the payload's cases. :param payload: An XFormInstance (not a case) """ if payload.xmlns == XMLNS_OPENMRS: # payload came from OpenMRS. Don't send it back. return False case_blocks = extract_case_blocks(payload) case_ids = [case_block['@case_id'] for case_block in case_blocks] cases = CaseAccessors(payload.domain).get_cases(case_ids, ordered=True) if not any( CaseRepeater.allowed_to_forward(self, case) for case in cases): # If none of the case updates in the payload are allowed to # be forwarded, drop it. return False if not self.location_id: # If this repeater does not have a location, all payloads # should go to it. return True repeaters = [ repeater for case in cases for repeater in get_case_location_ancestor_repeaters(case) ] # If this repeater points to the wrong OpenMRS server for this # payload then let the right repeater handle it. return self in repeaters def get_payload(self, repeat_record): payload = super(OpenmrsRepeater, self).get_payload(repeat_record) return json.loads(payload) def send_request(self, repeat_record, payload): value_source_configs: Iterable[JsonDict] = chain( self.openmrs_config.case_config.patient_identifiers.values(), self.openmrs_config.case_config.person_properties.values(), self.openmrs_config.case_config.person_preferred_name.values(), self.openmrs_config.case_config.person_preferred_address.values(), self.openmrs_config.case_config.person_attributes.values(), ) case_trigger_infos = get_relevant_case_updates_from_form_json( self.domain, payload, case_types=self.white_listed_case_types, extra_fields=[ conf["case_property"] for conf in value_source_configs if "case_property" in conf ], form_question_values=get_form_question_values(payload), ) requests = self.get_requests(payload_id=repeat_record.payload_id) try: response = send_openmrs_data( requests, self.domain, payload, self.openmrs_config, case_trigger_infos, ) except Exception as err: requests.notify_exception(str(err)) return OpenmrsResponse(400, 'Bad Request', pformat_json(str(err))) return response