PageRenderTime 60ms CodeModel.GetById 24ms RepoModel.GetById 1ms app.codeStats 0ms

/corehq/apps/userreports/reports/builder/forms.py

https://github.com/dimagi/commcare-hq
Python | 1226 lines | 1100 code | 70 blank | 56 comment | 58 complexity | 388565bec0bfd96a4bba848703c2a466 MD5 | raw file
Possible License(s): BSD-3-Clause, LGPL-2.1
  1. import datetime
  2. import uuid
  3. from abc import ABCMeta, abstractmethod
  4. from collections import OrderedDict, namedtuple
  5. from django import forms
  6. from django.conf import settings
  7. from django.utils.translation import gettext as _
  8. from crispy_forms import layout as crispy
  9. from crispy_forms.bootstrap import StrictButton
  10. from crispy_forms.helper import FormHelper
  11. from memoized import memoized
  12. from corehq.apps.app_manager.app_schemas.case_properties import (
  13. get_case_properties,
  14. )
  15. from corehq.apps.app_manager.fields import ApplicationDataSourceUIHelper
  16. from corehq.apps.app_manager.models import Application
  17. from corehq.apps.app_manager.xform import XForm
  18. from corehq.apps.case_search.const import COMMCARE_PROJECT
  19. from corehq.apps.data_dictionary.util import get_data_dict_props_by_case_type
  20. from corehq.apps.domain.models import DomainAuditRecordEntry
  21. from corehq.apps.hqwebapp import crispy as hqcrispy
  22. from corehq.apps.registry.helper import DataRegistryHelper
  23. from corehq.apps.registry.utils import RegistryPermissionCheck
  24. from corehq.apps.userreports import tasks
  25. from corehq.apps.userreports.app_manager.data_source_meta import (
  26. APP_DATA_SOURCE_TYPE_VALUES,
  27. DATA_SOURCE_TYPE_RAW,
  28. REPORT_BUILDER_DATA_SOURCE_TYPE_VALUES,
  29. DATA_SOURCE_TYPE_CASE, DATA_SOURCE_TYPE_FORM,
  30. make_case_data_source_filter, make_form_data_source_filter,
  31. )
  32. from corehq.apps.userreports.app_manager.helpers import clean_table_name
  33. from corehq.apps.userreports.const import DATA_SOURCE_MISSING_APP_ERROR_MESSAGE, LENIENT_MAXIMUM_EXPANSION
  34. from corehq.apps.userreports.exceptions import BadBuilderConfigError
  35. from corehq.apps.userreports.models import (
  36. DataSourceBuildInformation,
  37. DataSourceConfiguration,
  38. DataSourceMeta,
  39. ReportConfiguration,
  40. ReportMeta,
  41. get_datasource_config_infer_type,
  42. guess_data_source_type,
  43. RegistryDataSourceConfiguration, RegistryReportConfiguration,
  44. )
  45. from corehq.apps.userreports.reports.builder import (
  46. DEFAULT_CASE_PROPERTY_DATATYPES,
  47. FORM_METADATA_PROPERTIES,
  48. get_filter_format_from_question_type,
  49. const)
  50. from corehq.apps.userreports.reports.builder.columns import (
  51. CasePropertyColumnOption,
  52. CountColumn,
  53. FormMetaColumnOption,
  54. MultiselectQuestionColumnOption,
  55. OwnernameComputedCasePropertyOption,
  56. QuestionColumnOption,
  57. RawPropertyColumnOption,
  58. UsernameComputedCasePropertyOption,
  59. )
  60. from corehq.apps.userreports.reports.builder.const import (
  61. COMPUTED_OWNER_LOCATION_PROPERTY_ID,
  62. COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID,
  63. COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID,
  64. COMPUTED_OWNER_NAME_PROPERTY_ID,
  65. COMPUTED_USER_NAME_PROPERTY_ID,
  66. PROPERTY_TYPE_CASE_PROP,
  67. PROPERTY_TYPE_META,
  68. PROPERTY_TYPE_QUESTION,
  69. PROPERTY_TYPE_RAW,
  70. UCR_AGG_AVG,
  71. UCR_AGG_EXPAND,
  72. UCR_AGG_SIMPLE,
  73. UCR_AGG_SUM,
  74. UI_AGG_AVERAGE,
  75. UI_AGG_COUNT_PER_CHOICE,
  76. UI_AGG_GROUP_BY,
  77. UI_AGG_SUM,
  78. )
  79. from corehq.apps.userreports.reports.builder.filter_formats import get_pre_filter_format
  80. from corehq.apps.userreports.reports.builder.sources import (
  81. get_source_type_from_report_config,
  82. )
  83. from corehq.apps.userreports.sql import get_column_name
  84. from corehq.apps.userreports.ui.fields import JsonField
  85. from corehq.apps.userreports.util import has_report_builder_access, get_ucr_datasource_config_by_id
  86. from corehq.toggles import (
  87. SHOW_RAW_DATA_SOURCES_IN_REPORT_BUILDER,
  88. SHOW_OWNER_LOCATION_PROPERTY_IN_REPORT_BUILDER,
  89. OVERRIDE_EXPANDED_COLUMN_LIMIT_IN_REPORT_BUILDER,
  90. SHOW_IDS_IN_REPORT_BUILDER,
  91. DATA_REGISTRY_UCR
  92. )
  93. from dimagi.utils.couch.undo import undo_delete
  94. STATIC_CASE_PROPS = [
  95. "closed",
  96. "closed_on",
  97. "modified_on",
  98. "name",
  99. "opened_on",
  100. "owner_id",
  101. "user_id",
  102. ]
  103. # PostgreSQL limit = 1600. Sane limit = 500?
  104. MAX_COLUMNS = 500
  105. TEMP_DATA_SOURCE_LIFESPAN = 24 * 60 * 60
  106. SAMPLE_DATA_MAX_ROWS = 100
  107. class FilterField(JsonField):
  108. """
  109. A form field with a little bit of validation for report builder report
  110. filter configuration.
  111. """
  112. def validate(self, value):
  113. super(FilterField, self).validate(value)
  114. for filter_conf in value:
  115. if filter_conf.get('format', None) not in (list(const.REPORT_BUILDER_FILTER_TYPE_MAP) + [""]):
  116. raise forms.ValidationError("Invalid filter format!")
  117. class DataSourceProperty(object):
  118. """
  119. A container class for information about data source properties
  120. Class attributes:
  121. type -- either "case_property", "question", "meta", or "raw"
  122. id -- A string that uniquely identifies this property. For question based
  123. properties this is the question id, for case based properties this is
  124. the case property name.
  125. text -- A human readable representation of the property source. For
  126. questions this is the question label.
  127. source -- For questions, this is a dict representing the question as returned
  128. by Xform.get_questions(), for case properties and form metadata it is just
  129. the name of the property.
  130. data_types
  131. """
  132. def __init__(self, type, id, text, source, data_types):
  133. self._type = type
  134. self._id = id
  135. self._text = text
  136. self._source = source
  137. self._data_types = data_types
  138. def to_view_model(self):
  139. """
  140. Return a dictionary representation to be used by the js
  141. """
  142. return {
  143. "type": self._type,
  144. "id": self._id,
  145. "text": self._text,
  146. "source": self._source,
  147. }
  148. def get_text(self):
  149. return self._text
  150. def get_type(self):
  151. return self._type
  152. def get_id(self):
  153. return self._id
  154. def get_source(self):
  155. return self._source
  156. def to_report_column_option(self):
  157. if self._type == PROPERTY_TYPE_QUESTION:
  158. if self._source['type'] == "MSelect":
  159. return MultiselectQuestionColumnOption(self._id, self._text, self._source)
  160. else:
  161. return QuestionColumnOption(self._id, self._data_types, self._text, self._source)
  162. elif self._type == PROPERTY_TYPE_META:
  163. return FormMetaColumnOption(self._id, self._data_types, self._text, self._source)
  164. elif self._type == PROPERTY_TYPE_CASE_PROP:
  165. if self._id in (
  166. COMPUTED_OWNER_NAME_PROPERTY_ID,
  167. COMPUTED_OWNER_LOCATION_PROPERTY_ID,
  168. COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID,
  169. COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID
  170. ):
  171. return OwnernameComputedCasePropertyOption(self._id, self._data_types, self._text)
  172. elif self._id == COMPUTED_USER_NAME_PROPERTY_ID:
  173. return UsernameComputedCasePropertyOption(self._id, self._data_types, self._text)
  174. else:
  175. return CasePropertyColumnOption(self._id, self._data_types, self._text)
  176. else:
  177. assert self._type == PROPERTY_TYPE_RAW
  178. return RawPropertyColumnOption(self._id, self._data_types, self._text, self._source)
  179. def _get_filter_format(self, filter_configuration):
  180. """
  181. Return the UCR filter type that should be used for the given filter configuration (passed from the UI).
  182. """
  183. selected_filter_type = filter_configuration['format']
  184. if not selected_filter_type or self._type in ('question', 'meta'):
  185. if self._type == 'question':
  186. filter_format = get_filter_format_from_question_type(self._source['type'])
  187. else:
  188. assert self._type == 'meta'
  189. filter_format = get_filter_format_from_question_type(self._source[1])
  190. else:
  191. filter_format = const.REPORT_BUILDER_FILTER_TYPE_MAP[selected_filter_type]
  192. return filter_format
  193. def _get_ui_aggregation_for_filter_format(self, filter_format):
  194. """
  195. ColumnOption._get_indicator(aggregation) uses the aggregation type to determine what data type the
  196. indicator should be. Therefore, we need to convert filter formats to aggregation types so that we can
  197. create the correct type of indicator.
  198. """
  199. if filter_format == "numeric":
  200. return UI_AGG_SUM # This could also be UI_AGG_AVERAGE, just needs to force numeric
  201. else:
  202. return UI_AGG_GROUP_BY
  203. def to_report_filter(self, configuration, index):
  204. """
  205. Return a UCR report filter configuration for the given configuration.
  206. :param configuration: dictionary representing options selected in UI.
  207. :param index: Index of this filter in the list of filters configured by the user.
  208. :return:
  209. """
  210. filter_format = self._get_filter_format(configuration)
  211. ui_aggregation = self._get_ui_aggregation_for_filter_format(filter_format)
  212. column_id = self.to_report_column_option().get_indicators(ui_aggregation)[0]['column_id']
  213. filter = {
  214. "field": column_id,
  215. "slug": "{}_{}".format(column_id, index),
  216. "display": configuration["display_text"],
  217. "type": filter_format
  218. }
  219. if configuration['format'] == const.FORMAT_DATE:
  220. filter.update({'compare_as_string': True})
  221. if filter_format == 'dynamic_choice_list' and self._id == COMPUTED_OWNER_NAME_PROPERTY_ID:
  222. filter.update({"choice_provider": {"type": "owner"}})
  223. if filter_format == 'dynamic_choice_list' and self._id == COMPUTED_USER_NAME_PROPERTY_ID:
  224. filter.update({"choice_provider": {"type": "user"}})
  225. if filter_format == 'dynamic_choice_list' and self._id == COMPUTED_OWNER_LOCATION_PROPERTY_ID:
  226. filter.update({"choice_provider": {"type": "location"}})
  227. if filter_format == 'dynamic_choice_list' and self._id == COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID:
  228. filter.update({"choice_provider": {"type": "location", "include_descendants": True}})
  229. if filter_format == 'dynamic_choice_list' and self._id == COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID:
  230. filter.update({"choice_provider": {"type": "location", "include_descendants": True, "show_all_locations": True}})
  231. if filter_format == 'dynamic_choice_list' and self._id == COMMCARE_PROJECT:
  232. filter.update({"choice_provider": {"type": COMMCARE_PROJECT}})
  233. if configuration.get('pre_value') or configuration.get('pre_operator'):
  234. filter.update({
  235. 'type': 'pre', # type could have been "date"
  236. 'pre_operator': configuration.get('pre_operator', None),
  237. 'pre_value': configuration.get('pre_value', []),
  238. })
  239. if configuration['format'] == const.PRE_FILTER_VALUE_IS_EMPTY:
  240. filter.update({
  241. 'type': 'pre',
  242. 'pre_operator': "",
  243. 'pre_value': "", # for now assume strings - this may not always work but None crashes
  244. })
  245. if configuration['format'] == const.PRE_FILTER_VALUE_EXISTS:
  246. filter.update({
  247. 'type': 'pre',
  248. 'pre_operator': "!=",
  249. 'pre_value': "",
  250. })
  251. if configuration['format'] == const.PRE_FILTER_VALUE_NOT_EQUAL:
  252. filter.update({
  253. 'type': 'pre',
  254. 'pre_operator': "distinct from",
  255. # pre_value already set by "pre" clause
  256. })
  257. return filter
  258. def to_report_filter_indicator(self, configuration):
  259. """
  260. Return the indicator that would correspond to the given filter configuration
  261. """
  262. filter_format = self._get_filter_format(configuration)
  263. ui_aggregation = self._get_ui_aggregation_for_filter_format(filter_format)
  264. return self.to_report_column_option()._get_indicator(ui_aggregation)
  265. class ReportBuilderDataSourceInterface(metaclass=ABCMeta):
  266. """
  267. Abstract interface to a data source in report builder.
  268. A data source could be an (app, form), (app, case_type), or (registry, case_type) pair (see
  269. ManagedReportBuilderDataSourceHelper), or it can be a real UCR data source (see UnmanagedDataSourceHelper)
  270. """
  271. @property
  272. @abstractmethod
  273. def report_config_class(self):
  274. """Return the report class type"""
  275. raise NotImplementedError
  276. @property
  277. @abstractmethod
  278. def uses_managed_data_source(self):
  279. """
  280. Whether this interface uses a managed data source.
  281. If true, the data source will be created / modified with the report, and the
  282. temporary data source workflow will be enabled.
  283. If false, the data source is assumed to exist and be available as self.data_source_id.
  284. :return:
  285. """
  286. raise NotImplementedError
  287. @property
  288. @abstractmethod
  289. def data_source_properties(self):
  290. """
  291. A dictionary containing the various properties that may be used as indicators
  292. or columns in the data source or report.
  293. Keys are strings that uniquely identify properties.
  294. Values are DataSourceProperty instances.
  295. >> self.data_source_properties
  296. {
  297. "/data/question1": DataSourceProperty(
  298. type="question",
  299. id="/data/question1",
  300. text="Enter the child's name",
  301. source={
  302. 'repeat': None,
  303. 'group': None,
  304. 'value': '/data/question1',
  305. 'label': 'question1',
  306. 'tag': 'input',
  307. 'type': 'Text'
  308. },
  309. data_types=["string"]
  310. ),
  311. "meta/deviceID": DataSourceProperty(
  312. type="meta",
  313. id="meta/deviceID",
  314. text="deviceID",
  315. source=("deviceID", "string"),
  316. data_types=["string"]
  317. )
  318. }
  319. """
  320. pass
  321. @property
  322. @abstractmethod
  323. def report_column_options(self):
  324. pass
  325. class ManagedReportBuilderDataSourceHelper(ReportBuilderDataSourceInterface):
  326. """Abstract class that represents the interface required for building managed
  327. data sources
  328. When configuring a report, one can use ManagedReportBuilderDataSourceHelper to determine some
  329. of the properties of the required report data source, such as:
  330. - referenced doc type
  331. - filter
  332. - indicators
  333. """
  334. def __init__(self, domain, source_type, source_id):
  335. assert (source_type in ['case', 'form'])
  336. self.domain = domain
  337. self.source_type = source_type
  338. # case type or form ID
  339. self.source_id = source_id
  340. @property
  341. def uses_managed_data_source(self):
  342. return True
  343. @property
  344. def report_config_class(self):
  345. return RegistryReportConfiguration if self.uses_registry_data_source else ReportConfiguration
  346. @property
  347. def uses_registry_data_source(self):
  348. """
  349. Whether this interface uses a registry data source.
  350. If true, it will use RegistryDataSourceConfiguration.
  351. If false, it uses DataSourceConfiguration.
  352. :return:
  353. """
  354. raise NotImplementedError
  355. @property
  356. @abstractmethod
  357. def source_doc_type(self):
  358. """Return the doc_type the datasource.referenced_doc_type"""
  359. raise NotImplementedError
  360. @property
  361. @abstractmethod
  362. def filter(self):
  363. """
  364. Return the filter configuration for the DataSourceConfiguration.
  365. """
  366. raise NotImplementedError
  367. @abstractmethod
  368. def base_item_expression(self, is_multiselect_chart_report, multiselect_field=None):
  369. raise NotImplementedError
  370. def indicators(self, columns, filters, as_dict=False):
  371. """
  372. Return a list of indicators to be used in a data source configuration that supports the given columns and
  373. indicators.
  374. :param columns: A list of objects representing columns in the report.
  375. Each object has a "property" and "calculation" key
  376. :param filters: A list of filter configuration objects
  377. """
  378. indicators = OrderedDict()
  379. for column in columns:
  380. # Property is only set if the column exists in report_column_options
  381. if column['property']:
  382. column_option = self.report_column_options[column['property']]
  383. for indicator in column_option.get_indicators(column['calculation']):
  384. # A column may have multiple indicators. e.g. "Group By" and "Count Per Choice" aggregations
  385. # will use one indicator for the field's string value, and "Sum" and "Average" aggregations
  386. # will use a second indicator for the field's numerical value. "column_id" includes the
  387. # indicator's data type, so it is unique per indicator ... except for choice list indicators,
  388. # because they get expanded to one column per choice. The column_id of choice columns will end
  389. # up unique because they will include a slug of the choice value. Here "column_id + type" is
  390. # unique.
  391. indicator_key = (indicator['column_id'], indicator['type'])
  392. indicators.setdefault(indicator_key, indicator)
  393. for filter_ in filters:
  394. # Property is only set if the filter exists in report_column_options
  395. if filter_['property']:
  396. property_ = self.data_source_properties[filter_['property']]
  397. indicator = property_.to_report_filter_indicator(filter_)
  398. indicator_key = (indicator['column_id'], indicator['type'])
  399. indicators.setdefault(indicator_key, indicator)
  400. if as_dict:
  401. return indicators
  402. return list(indicators.values())
  403. def all_possible_indicators(self, required_columns, required_filters):
  404. """
  405. Will generate a set of possible indicators for the datasource making sure to include the
  406. provided columns and filters
  407. """
  408. indicators = self.indicators(required_columns, required_filters, as_dict=True)
  409. for column_option in self.report_column_options.values():
  410. for agg in column_option.aggregation_options:
  411. for indicator in column_option.get_indicators(agg):
  412. indicator_key = (indicator['column_id'], indicator['type'])
  413. indicators.setdefault(indicator_key, indicator)
  414. return list(indicators.values())[:MAX_COLUMNS]
  415. @property
  416. @abstractmethod
  417. def data_source_properties(self):
  418. raise NotImplementedError
  419. @property
  420. @memoized
  421. def report_column_options(self):
  422. options = OrderedDict()
  423. for id_, prop in self.data_source_properties.items():
  424. options[id_] = prop.to_report_column_option()
  425. # NOTE: Count columns aren't useful for table reports. But we need it in the column options because
  426. # the options are currently static, after loading the report builder a user can switch to an aggregated
  427. # report.
  428. count_col = CountColumn("Number of Cases" if self.source_type == "case" else "Number of Forms")
  429. options[count_col.get_property()] = count_col
  430. return options
  431. @property
  432. @abstractmethod
  433. def data_source_name(self):
  434. raise NotImplementedError
  435. def construct_data_source(self, table_id, **kwargs):
  436. return DataSourceConfiguration(domain=self.domain, table_id=table_id, **kwargs)
  437. def _ds_config_kwargs(self, indicators, is_multiselect_chart_report=False, multiselect_field=None):
  438. if is_multiselect_chart_report:
  439. base_item_expression = self.base_item_expression(True, multiselect_field)
  440. else:
  441. base_item_expression = self.base_item_expression(False)
  442. return dict(
  443. display_name=self.data_source_name,
  444. referenced_doc_type=self.source_doc_type,
  445. configured_filter=self.filter,
  446. configured_indicators=indicators,
  447. base_item_expression=base_item_expression,
  448. meta=DataSourceMeta(build=self._get_data_source_build_information())
  449. )
  450. def _get_data_source_build_information(self):
  451. raise NotImplementedError
  452. def get_temp_datasource_constructor_kwargs(self, required_columns, required_filters):
  453. indicators = self._remove_defaults_from_indicators(
  454. self.all_possible_indicators(required_columns, required_filters)
  455. )
  456. return self._ds_config_kwargs(indicators)
  457. def get_datasource_constructor_kwargs(self, columns, filters,
  458. is_multiselect_chart_report=False, multiselect_field=None):
  459. indicators = self._remove_defaults_from_indicators(
  460. self.indicators(columns, filters)
  461. )
  462. return self._ds_config_kwargs(indicators, is_multiselect_chart_report, multiselect_field)
  463. def _remove_defaults_from_indicators(self, indicators):
  464. defaults = self._get_datasource_default_columns()
  465. return [
  466. indicator for indicator in indicators
  467. if indicator['column_id'] not in defaults
  468. ]
  469. def _get_datasource_default_columns(self):
  470. return {
  471. column.id
  472. for indicator in DataSourceConfiguration().default_indicators
  473. for column in indicator.get_columns()
  474. }
  475. class UnmanagedDataSourceHelper(ReportBuilderDataSourceInterface):
  476. """
  477. A ReportBuilderDataSourceInterface that encapsulates an existing data source.
  478. """
  479. def __init__(self, domain, app, source_type, source_id):
  480. assert source_type == 'data_source'
  481. self.domain = domain
  482. self.app = app
  483. self.source_type = source_type
  484. # source_id is the ID of a UCR data source
  485. self.data_source_id = source_id
  486. @property
  487. def uses_managed_data_source(self):
  488. return False
  489. @property
  490. def report_config_class(self):
  491. return {
  492. "DataSourceConfiguration": ReportConfiguration,
  493. "RegistryDataSourceConfiguration": RegistryReportConfiguration,
  494. }[self.data_source.doc_type]
  495. @property
  496. @memoized
  497. def data_source(self):
  498. return get_datasource_config_infer_type(self.data_source_id, self.domain)[0]
  499. @property
  500. def data_source_properties(self):
  501. def _data_source_property_from_ucr_column(column):
  502. # note: using column ID as the display text is a bummer but we don't have a a better
  503. # way to easily access a readable name for these yet
  504. return DataSourceProperty(
  505. type=PROPERTY_TYPE_RAW,
  506. id=column.id,
  507. text=column.id,
  508. source=column.id,
  509. data_types=[column.datatype],
  510. )
  511. properties = OrderedDict()
  512. for column in self.data_source.get_columns():
  513. properties[column.id] = _data_source_property_from_ucr_column(column)
  514. return properties
  515. @property
  516. def report_column_options(self):
  517. options = OrderedDict()
  518. for id_, prop in self.data_source_properties.items():
  519. options[id_] = prop.to_report_column_option()
  520. return options
  521. class ApplicationFormDataSourceHelper(ManagedReportBuilderDataSourceHelper):
  522. def __init__(self, domain, app, source_type, source_id):
  523. assert source_type == 'form'
  524. self.app = app
  525. super().__init__(domain, source_type, source_id)
  526. self.source_form = self.app.get_form(source_id)
  527. self.source_xform = XForm(self.source_form.source, domain=app.domain)
  528. def base_item_expression(self, is_multiselect_chart_report, multiselect_field=None):
  529. """
  530. Return the base_item_expression for the DataSourceConfiguration.
  531. Normally this is {}, but if this is a data source for a chart report that is aggregated by a multiselect
  532. question, then we want one row per multiselect answer.
  533. :param is_multiselect_chart_report: True if the data source will be used for a chart report aggregated by
  534. a multiselect question.
  535. :param multiselect_field: The field that the multiselect aggregated report is aggregated by.
  536. :return: A base item expression.
  537. """
  538. if not is_multiselect_chart_report:
  539. return {}
  540. else:
  541. assert multiselect_field, "multiselect_field is required if is_multiselect_chart_report is True"
  542. property = self.data_source_properties[multiselect_field]
  543. path = ['form'] + property.get_source()['value'].split('/')[2:]
  544. choices = [c['value'] for c in property.get_source()['options']]
  545. def sub_doc(path):
  546. if not path:
  547. return {"type": "property_name", "property_name": "choice"}
  548. else:
  549. return {
  550. "type": "dict",
  551. "properties": {
  552. path[0]: sub_doc(path[1:])
  553. }
  554. }
  555. return {
  556. "type": "map_items",
  557. "items_expression": {
  558. "type": "iterator",
  559. "expressions": [
  560. {
  561. "type": "dict",
  562. "properties": {
  563. "choice": c,
  564. "doc": {"type": "identity"}
  565. }
  566. }
  567. for c in choices
  568. ],
  569. "test": {
  570. "type": "boolean_expression",
  571. "expression": {
  572. "type": "property_path",
  573. "property_path": ["doc"] + path
  574. },
  575. "operator": "in_multi",
  576. "property_value": {"type": "property_name", "property_name": "choice"}
  577. }
  578. },
  579. "map_expression": sub_doc(path)
  580. }
  581. @property
  582. def source_doc_type(self):
  583. return 'XFormInstance'
  584. @property
  585. def uses_registry_data_source(self):
  586. return False
  587. @property
  588. @memoized
  589. def filter(self):
  590. return make_form_data_source_filter(
  591. self.source_xform.data_node.tag_xmlns, self.source_form.get_app().get_id)
  592. @property
  593. @memoized
  594. def data_source_properties(self):
  595. property_map = {
  596. 'username': _('User Name'),
  597. 'userID': _('User ID'),
  598. 'timeStart': _('Date Form Started'),
  599. 'timeEnd': _('Date Form Completed'),
  600. }
  601. properties = OrderedDict()
  602. questions = self.source_xform.get_questions([], exclude_select_with_itemsets=True)
  603. for prop in FORM_METADATA_PROPERTIES:
  604. question_type = prop[1]
  605. data_type = {
  606. "DateTime": "datetime",
  607. "Text": "string",
  608. }[question_type]
  609. properties[prop[0]] = DataSourceProperty(
  610. type=PROPERTY_TYPE_META,
  611. id=prop[0],
  612. text=property_map.get(prop[0], prop[0]),
  613. source=prop,
  614. data_types=[data_type]
  615. )
  616. for question in questions:
  617. if question['type'] == "DataBindOnly":
  618. data_types = ["string", "decimal", "datetime"]
  619. elif question['type'] in ("Int", "Double", "Long"):
  620. data_types = ["decimal"]
  621. else:
  622. data_types = ["string"]
  623. properties[question['value']] = DataSourceProperty(
  624. type=PROPERTY_TYPE_QUESTION,
  625. id=question['value'],
  626. text=question['label'],
  627. source=question,
  628. data_types=data_types,
  629. )
  630. if self.source_form.get_app().auto_gps_capture:
  631. properties['location'] = DataSourceProperty(
  632. type=PROPERTY_TYPE_META,
  633. id='location',
  634. text='location',
  635. source=(['location', '#text'], 'Text'),
  636. data_types=["string"],
  637. )
  638. return properties
  639. def _get_data_source_build_information(self):
  640. return DataSourceBuildInformation(
  641. source_id=self.source_id,
  642. app_id=self.app._id,
  643. app_version=self.app.version,
  644. )
  645. @property
  646. @memoized
  647. def data_source_name(self):
  648. today = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
  649. return "{} (v{}) {}".format(self.source_form.default_name(), self.app.version, today)
  650. class CaseDataSourceHelper(ManagedReportBuilderDataSourceHelper):
  651. """
  652. A ReportBuilderDataSourceInterface specifically for when source_type = 'case'.
  653. """
  654. @property
  655. def source_doc_type(self):
  656. return 'CommCareCase'
  657. @property
  658. @memoized
  659. def filter(self):
  660. return make_case_data_source_filter(self.source_id)
  661. def base_item_expression(self, is_multiselect_chart_report, multiselect_field=None):
  662. assert not is_multiselect_chart_report
  663. return {}
  664. @property
  665. @memoized
  666. def data_source_properties(self):
  667. property_map = {
  668. 'closed': _('Case Closed'),
  669. 'user_id': _('User ID Last Updating Case'),
  670. 'owner_name': _('Case Owner'),
  671. 'mobile worker': _('Mobile Worker Last Updating Case'),
  672. 'case_id': _('Case ID')
  673. }
  674. properties = OrderedDict()
  675. for property in self.case_properties:
  676. if property in DEFAULT_CASE_PROPERTY_DATATYPES:
  677. data_types = DEFAULT_CASE_PROPERTY_DATATYPES[property]
  678. else:
  679. data_types = ["string", "decimal", "datetime"]
  680. properties[property] = DataSourceProperty(
  681. type=PROPERTY_TYPE_CASE_PROP,
  682. id=property,
  683. text=property_map.get(property, property.replace('_', ' ')),
  684. source=property,
  685. data_types=data_types,
  686. )
  687. properties[COMPUTED_OWNER_NAME_PROPERTY_ID] = self._get_owner_name_pseudo_property()
  688. properties[COMPUTED_USER_NAME_PROPERTY_ID] = self._get_user_name_pseudo_property()
  689. if SHOW_IDS_IN_REPORT_BUILDER.enabled(self.domain):
  690. properties['case_id'] = self._get_case_id_pseudo_property()
  691. if SHOW_OWNER_LOCATION_PROPERTY_IN_REPORT_BUILDER.enabled(self.domain):
  692. properties[COMPUTED_OWNER_LOCATION_PROPERTY_ID] = self._get_owner_location_pseudo_property()
  693. properties[COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID] = \
  694. self._get_owner_location_with_descendants_pseudo_property()
  695. properties[COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID] = \
  696. self._get_owner_location_archived_with_descendants_pseudo_property()
  697. return properties
  698. @staticmethod
  699. def _get_case_id_pseudo_property():
  700. return DataSourceProperty(
  701. type=PROPERTY_TYPE_CASE_PROP,
  702. id='case_id',
  703. text=_('Case ID'),
  704. source='case_id',
  705. data_types=["string"],
  706. )
  707. @staticmethod
  708. def _get_owner_name_pseudo_property():
  709. # owner_name is a special pseudo-case property for which
  710. # the report builder will create a related_doc indicator based
  711. # on the owner_id of the case.
  712. return DataSourceProperty(
  713. type=PROPERTY_TYPE_CASE_PROP,
  714. id=COMPUTED_OWNER_NAME_PROPERTY_ID,
  715. text=_('Case Owner'),
  716. source=COMPUTED_OWNER_NAME_PROPERTY_ID,
  717. data_types=["string"],
  718. )
  719. @classmethod
  720. def _get_owner_location_pseudo_property(cls):
  721. # owner_location is a special pseudo-case property for which
  722. # the report builder reference the owner_id, but treat it as a location
  723. return DataSourceProperty(
  724. type=PROPERTY_TYPE_CASE_PROP,
  725. id=COMPUTED_OWNER_LOCATION_PROPERTY_ID,
  726. text=_('Case Owner (Location)'),
  727. source=COMPUTED_OWNER_LOCATION_PROPERTY_ID,
  728. data_types=["string"],
  729. )
  730. @classmethod
  731. def _get_owner_location_with_descendants_pseudo_property(cls):
  732. # similar to the location property but also include descendants
  733. return DataSourceProperty(
  734. type=PROPERTY_TYPE_CASE_PROP,
  735. id=COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID,
  736. text=_('Case Owner (Location w/ Descendants)'),
  737. source=COMPUTED_OWNER_LOCATION_WITH_DESENDANTS_PROPERTY_ID,
  738. data_types=["string"],
  739. )
  740. @classmethod
  741. def _get_owner_location_archived_with_descendants_pseudo_property(cls):
  742. # similar to the location property but also include descendants
  743. return DataSourceProperty(
  744. type=PROPERTY_TYPE_CASE_PROP,
  745. id=COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID,
  746. text=_('Case Owner (Location w/ Descendants and Archived Locations)'),
  747. source=COMPUTED_OWNER_LOCATION_ARCHIVED_WITH_DESCENDANTS_PROPERTY_ID,
  748. data_types=["string"],
  749. )
  750. @staticmethod
  751. def _get_user_name_pseudo_property():
  752. # user_name is a special pseudo case property for which
  753. # the report builder will create a related_doc indicator based on the
  754. # user_id of the case
  755. return DataSourceProperty(
  756. type=PROPERTY_TYPE_CASE_PROP,
  757. id=COMPUTED_USER_NAME_PROPERTY_ID,
  758. text=_('Mobile Worker Last Updating Case'),
  759. source=COMPUTED_USER_NAME_PROPERTY_ID,
  760. data_types=["string"],
  761. )
  762. class ApplicationCaseDataSourceHelper(CaseDataSourceHelper):
  763. def __init__(self, domain, app, source_type, source_id):
  764. self.app = app
  765. assert source_type == 'case'
  766. super().__init__(domain, source_type, source_id)
  767. prop_map = get_case_properties(
  768. self.app, [self.source_id], defaults=list(DEFAULT_CASE_PROPERTY_DATATYPES),
  769. include_parent_properties=True,
  770. )
  771. self.case_properties = sorted(set(prop_map[self.source_id]) | {'closed', 'closed_on'})
  772. def _get_data_source_build_information(self):
  773. return DataSourceBuildInformation(
  774. source_id=self.source_id,
  775. app_id=self.app._id,
  776. app_version=self.app.version,
  777. )
  778. @property
  779. def uses_registry_data_source(self):
  780. return False
  781. @property
  782. @memoized
  783. def data_source_name(self):
  784. today = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
  785. return "{} (v{}) {}".format(self.source_id, self.app.version, today)
  786. class RegistryCaseDataSourceHelper(CaseDataSourceHelper):
  787. def __init__(self, domain, registry_slug, source_type, source_id):
  788. assert source_type == 'case'
  789. self.registry_slug = registry_slug
  790. super().__init__(domain, source_type, source_id)
  791. registry_helper = DataRegistryHelper(self.domain, registry_slug=self.registry_slug)
  792. owning_domain = registry_helper.registry.domain
  793. prop_map = get_data_dict_props_by_case_type(owning_domain)
  794. self.case_properties = sorted(
  795. set(prop_map[self.source_id]) | {'closed', 'closed_on'}
  796. )
  797. def _get_data_source_build_information(self):
  798. return DataSourceBuildInformation(
  799. source_id=self.source_id,
  800. registry_slug=self.registry_slug,
  801. )
  802. @property
  803. def uses_registry_data_source(self):
  804. return True
  805. @property
  806. @memoized
  807. def data_source_name(self):
  808. today = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
  809. return "{} {} {}".format(self.source_id, self.registry_slug, today)
  810. @property
  811. def data_source_properties(self):
  812. properties = super().data_source_properties
  813. properties[COMMCARE_PROJECT] = DataSourceProperty(
  814. type=PROPERTY_TYPE_RAW,
  815. id=COMMCARE_PROJECT,
  816. text=_('CommCare Project'),
  817. source='commcare_project',
  818. data_types=["string"],
  819. )
  820. return properties
  821. def construct_data_source(self, table_id, **kwargs):
  822. return RegistryDataSourceConfiguration(
  823. domain=self.domain,
  824. table_id=table_id,
  825. registry_slug=self.registry_slug,
  826. **kwargs
  827. )
  828. def _get_datasource_default_columns(self):
  829. return {
  830. column.id
  831. for indicator in RegistryDataSourceConfiguration().default_indicators
  832. for column in indicator.get_columns()
  833. }
  834. def get_data_source_interface(domain, app, source_type, source_id, registry_slug):
  835. if registry_slug is not None and source_type == DATA_SOURCE_TYPE_CASE:
  836. return RegistryCaseDataSourceHelper(domain, registry_slug, source_type, source_id)
  837. if source_type in APP_DATA_SOURCE_TYPE_VALUES:
  838. helper = {
  839. DATA_SOURCE_TYPE_CASE: ApplicationCaseDataSourceHelper,
  840. DATA_SOURCE_TYPE_FORM: ApplicationFormDataSourceHelper
  841. }[source_type]
  842. return helper(domain, app, source_type, source_id)
  843. else:
  844. return UnmanagedDataSourceHelper(domain, app, source_type, source_id)
  845. class DataSourceForm(forms.Form):
  846. report_name = forms.CharField()
  847. def __init__(self, domain, max_allowed_reports, request_user, *args, **kwargs):
  848. super(DataSourceForm, self).__init__(*args, **kwargs)
  849. self.domain = domain
  850. self.max_allowed_reports = max_allowed_reports
  851. self.request_user = request_user
  852. self.registry_permission_checker = RegistryPermissionCheck(self.domain, self.request_user)
  853. # TODO: Map reports.
  854. self.app_source_helper = ApplicationDataSourceUIHelper(
  855. enable_raw=SHOW_RAW_DATA_SOURCES_IN_REPORT_BUILDER.enabled(self.domain),
  856. enable_registry=(DATA_REGISTRY_UCR.enabled(self.domain)
  857. and self.registry_permission_checker.can_view_some_data_registry_contents()),
  858. registry_permission_checker=self.registry_permission_checker
  859. )
  860. self.app_source_helper.bootstrap(self.domain)
  861. self.fields.update(self.app_source_helper.get_fields())
  862. self.helper = FormHelper()
  863. self.helper.form_class = "form form-horizontal"
  864. self.helper.form_id = "report-builder-form"
  865. self.helper.label_class = 'col-sm-3 col-md-2 col-lg-2'
  866. self.helper.field_class = 'col-sm-9 col-md-8 col-lg-6'
  867. self.helper.layout = crispy.Layout(
  868. crispy.Fieldset(
  869. _('Report'),
  870. hqcrispy.FieldWithHelpBubble(
  871. 'report_name',
  872. help_bubble_text=_(
  873. 'Web users will see this name in the "Reports" section of CommCareHQ and can click to '
  874. 'view the report'
  875. )
  876. )
  877. ),
  878. self.get_data_layout(),
  879. hqcrispy.FormActions(
  880. StrictButton(
  881. _('Next'),
  882. type="submit",
  883. css_id='js-next-data-source',
  884. css_class="btn-primary",
  885. )
  886. ),
  887. )
  888. def get_data_layout(self):
  889. if not (DATA_REGISTRY_UCR.enabled(self.domain)
  890. and self.registry_permission_checker.can_view_some_data_registry_contents()):
  891. return crispy.Fieldset(
  892. _('Data'), *self.app_source_helper.get_crispy_fields(),
  893. )
  894. else:
  895. help_texts = self.app_source_helper.get_crispy_filed_help_texts()
  896. return crispy.Fieldset(
  897. _('Data'),
  898. hqcrispy.FieldWithHelpBubble('source_type', help_bubble_text=help_texts['source_type']),
  899. crispy.Div(
  900. crispy.HTML('<input type="radio" name="project_data" id="one_project" '
  901. 'value="isDataFromOneProject" data-bind="checked: isDataFromOneProject,'
  902. ' checkedValue: \'true\'" class="project_data-option"/>'
  903. '<label for="one_project" class="project_data-label">%s</label>'
  904. % _("Data From My Project Space")),
  905. crispy.Div(
  906. hqcrispy.FieldWithHelpBubble('application', help_bubble_text=help_texts['application']),
  907. style="padding-left: 50px;"
  908. ),
  909. crispy.HTML('<input type="radio" name="project_data" id="many_projects" '
  910. 'value="isDataFromOneProject" data-bind="checked: isDataFromOneProject, '
  911. 'checkedValue: \'false\'" class="project_data-option"/><label '
  912. 'for="many_projects" class="project_data-label">%s</label>'
  913. % _("Data From My Project Space And Others")),
  914. crispy.Div(
  915. hqcrispy.FieldWithHelpBubble('registry_slug', help_bubble_text=help_texts['registry_slug']),
  916. style="padding-left: 50px;"
  917. ),
  918. ),
  919. hqcrispy.FieldWithHelpBubble('source', help_bubble_text=help_texts['source']),
  920. )
  921. @property
  922. def sources_map(self):
  923. return self.app_source_helper.all_sources
  924. @property
  925. def dropdown_map(self):
  926. return self.app_source_helper.app_and_registry_sources
  927. def get_selected_source(self):
  928. return self.app_source_helper.get_app_source(self.cleaned_data)
  929. def clean(self):
  930. """
  931. Raise a validation error if there are already 5 data sources and this
  932. report won't be able to use one of the existing ones.
  933. """
  934. cleaned_data = super(DataSourceForm, self).clean()
  935. existing_reports = ReportConfiguration.by_domain(self.domain)
  936. builder_reports = [report for report in existing_reports if report.report_meta.created_by_builder]
  937. if has_report_builder_access(self.domain) and len(builder_reports) >= self.max_allowed_reports:
  938. # Don't show the warning when domain does not have report buidler access, because this is just a
  939. # preview and the report will not be saved.
  940. raise forms.ValidationError(_(
  941. "Too many reports!\n"
  942. "Creating this report would cause you to go over the maximum "
  943. "number of report builder reports allowed in this domain. Your "
  944. "limit is {number}. "
  945. "To continue, delete another report and try again. "
  946. ).format(number=self.max_allowed_reports))
  947. return cleaned_data
  948. _shared_properties = ['exists_in_current_version', 'display_text', 'property', 'data_source_field']
  949. UserFilterViewModel = namedtuple("UserFilterViewModel", _shared_properties + ['format'])
  950. DefaultFilterViewModel = namedtuple("DefaultFilterViewModel",
  951. _shared_properties + ['format', 'pre_value', 'pre_operator'])
  952. ColumnViewModel = namedtuple("ColumnViewModel", _shared_properties + ['calculation'])
  953. class ConfigureNewReportBase(forms.Form):
  954. user_filters = FilterField(required=False)
  955. default_filters = FilterField(required=False)
  956. report_title = forms.CharField(widget=forms.HiddenInput, required=False)
  957. report_description = forms.CharField(widget=forms.HiddenInput, required=False)
  958. def __init__(self, domain, report_name, app_id, source_type, report_source_id, existing_report=None, registry_slug=None,
  959. *args, **kwargs):
  960. """
  961. This form can be used to create a new ReportConfiguration, or to modify
  962. an existing one if existing_report is set.
  963. """
  964. super(ConfigureNewReportBase, self).__init__(*args, **kwargs)
  965. self.existing_report = existing_report
  966. self.domain = domain
  967. if self.existing_report:
  968. self._bootstrap(self.existing_report)
  969. else:
  970. self.registry_slug = registry_slug
  971. self.report_name = report_name
  972. assert source_type in REPORT_BUILDER_DATA_SOURCE_TYPE_VALUES
  973. self.source_type = source_type
  974. self.report_source_id = report_source_id
  975. self.app = Application.get(app_id) if app_id else None
  976. if self.app:
  977. assert self.domain == self.app.domain
  978. self.ds_builder = get_data_source_interface(
  979. self.domain, self.app, self.source_type, self.report_source_id, self.registry_slug
  980. )
  981. self.report_column_options = self.ds_builder.report_column_options
  982. self.data_source_properties = self.ds_builder.data_source_properties
  983. self._report_columns_by_column_id = {}
  984. for column in self.report_column_options.values():
  985. for agg in column.aggregation_options:
  986. indicators = column.get_indicators(agg)
  987. for i in indicators:
  988. self._report_columns_by_column_id[i['column_id']] = column
  989. def _bootstrap(self, existing_report):
  990. """
  991. Use an existing report to initialize some of the instance variables of this
  992. form. This method is used when editing an existing report.
  993. """
  994. self.report_name = existing_report.title
  995. self.source_type = get_source_type_from_report_config(existing_report)
  996. assert self.domain == existing_report.domain
  997. if self.source_type in APP_DATA_SOURCE_TYPE_VALUES:
  998. self.report_source_id = existing_report.config.meta.build.source_id
  999. app_id = existing_report.config.meta.build.app_id
  1000. self.registry_slug = existing_report.config.meta.build.registry_slug
  1001. self.app = None
  1002. if app_id:
  1003. self.app = Application.get(app_id)
  1004. elif not self.registry_slug:
  1005. raise BadBuilderConfigError(DATA_SOURCE_MISSING_APP_ERROR_MESSAGE)
  1006. else:
  1007. assert self.source_type == DATA_SOURCE_TYPE_RAW
  1008. self.report_source_id = existing_report.config_id
  1009. self.app = self.registry_slug = None
  1010. @property
  1011. def _configured_columns(self):
  1012. """
  1013. To be used by ManagedReportBuilderDataSourceHelper.indicators()
  1014. """
  1015. configured_columns = self.cleaned_data['columns']
  1016. location = self.cleaned_data.get("location")
  1017. if location and all(location != c.get('property')
  1018. for c in configured_columns):
  1019. configured_columns += [{
  1020. "property": location,
  1021. "calculation": UI_AGG_GROUP_BY # Not aggregated
  1022. }]
  1023. return configured_columns
  1024. def _get_data_source_configuration_kwargs(self):
  1025. filters = self.cleaned_data['user_filters'] + self.cleaned_data['default_filters']
  1026. ms_field = self._report_aggregation_cols[0] if self._is_multiselect_chart_report else None
  1027. return self.ds_builder.get_datasource_constructor_kwargs(self._configured_columns,
  1028. filters,
  1029. self._is_multiselect_chart_report,
  1030. ms_field)
  1031. def _build_data_source(self):
  1032. data_source_config = self.ds_builder.construct_data_source(
  1033. # The uuid gets truncated, so it's not really universally unique.
  1034. table_id=clean_table_name(self.domain, str(uuid.uuid4().hex)),
  1035. **self._get_data_source_configuration_kwargs()
  1036. )
  1037. data_source_config.validate()
  1038. data_source_config.save()
  1039. tasks.rebuild_indicators.delay(data_source_config._id, source="report_builder", domain=data_source_config.domain)
  1040. return data_source_config._id
  1041. def update_report(self):
  1042. self._update_data_source_if_necessary()
  1043. self.existing_report.aggregation_columns = self._report_aggregation_cols
  1044. self.existing_report.columns = self._get_report_columns()
  1045. self.existing_report.filters = self._report_filters
  1046. self.existing_report.configured_charts = self._report_charts
  1047. self.existing_report.title = self.cleaned_data['report_title'] or _("Report Builder Report")
  1048. self.existing_report.description = self.cleaned_data['report_description']
  1049. self.existing_report.validate()
  1050. self.existing_report.save()
  1051. DomainAuditRecordEntry.update_calculations(self.domain, 'cp_n_reports_edited')
  1052. return self.existing_report
  1053. def _update_data_source_if_necessary(self):
  1054. if self.ds_builder.uses_managed_data_source:
  1055. data_source = get_ucr_datasource_config_by_id(self.existing_report.config_id)
  1056. if data_source.get_report_count() > 1:
  1057. # If another report is pointing at this data source, create a new
  1058. # data source for this report so that we can change the indicators
  1059. # without worrying about breaking another report.
  1060. data_source_config_id = self._build_data_source()
  1061. self.existing_report.config_id = data_source_config_id
  1062. else:
  1063. indicators = self.ds_builder.indicators(
  1064. self._configured_columns,
  1065. self.cleaned_data['user_filters'] + self.cleaned_data['default_filters'],
  1066. )
  1067. if data_source.configured_indicators != indicators:
  1068. for property_name, value in self._get_data_source_configuration_kwargs().items():
  1069. setattr(data_source, propert