PageRenderTime 77ms CodeModel.GetById 35ms RepoModel.GetById 0ms app.codeStats 1ms

/corehq/apps/userreports/views.py

https://github.com/dimagi/commcare-hq
Python | 1285 lines | 1242 code | 41 blank | 2 comment | 31 complexity | 7251b64a6f73135e52a9687298e1f9c0 MD5 | raw file
Possible License(s): BSD-3-Clause, LGPL-2.1
  1. import datetime
  2. import functools
  3. import json
  4. import os
  5. import re
  6. import tempfile
  7. from collections import OrderedDict, namedtuple
  8. from django.conf import settings
  9. from django.contrib import messages
  10. from django.db import IntegrityError
  11. from django.http import HttpResponse, HttpResponseRedirect
  12. from django.http.response import Http404, JsonResponse
  13. from django.shortcuts import render
  14. from django.utils.decorators import method_decorator
  15. from django.utils.http import urlencode
  16. from django.utils.translation import gettext as _
  17. from django.utils.translation import gettext_lazy
  18. from django.views.decorators.http import require_POST
  19. from django.views.generic import View
  20. import six.moves.urllib.error
  21. import six.moves.urllib.parse
  22. import six.moves.urllib.request
  23. from couchdbkit.exceptions import ResourceNotFound
  24. from memoized import memoized
  25. from sqlalchemy import exc, types
  26. from sqlalchemy.exc import ProgrammingError
  27. from couchexport.export import export_from_tables
  28. from couchexport.files import Temp
  29. from couchexport.models import Format
  30. from couchexport.shortcuts import export_response
  31. from dimagi.utils.couch.undo import (
  32. get_deleted_doc_type,
  33. is_deleted,
  34. soft_delete,
  35. undo_delete,
  36. )
  37. from dimagi.utils.logging import notify_exception
  38. from dimagi.utils.web import json_response
  39. from no_exceptions.exceptions import HttpException
  40. from pillowtop.dao.exceptions import DocumentNotFoundError
  41. from corehq import toggles
  42. from corehq.apps.accounting.models import Subscription
  43. from corehq.apps.analytics.tasks import (
  44. HUBSPOT_SAVED_UCR_FORM_ID,
  45. send_hubspot_form,
  46. track_workflow,
  47. update_hubspot_properties,
  48. )
  49. from corehq.apps.app_manager.models import Application
  50. from corehq.apps.app_manager.util import purge_report_from_mobile_ucr
  51. from corehq.apps.change_feed.data_sources import (
  52. get_document_store_for_doc_type,
  53. )
  54. from corehq.apps.domain.decorators import (
  55. api_auth_with_scope,
  56. login_and_domain_required,
  57. )
  58. from corehq.apps.domain.models import AllowedUCRExpressionSettings, Domain
  59. from corehq.apps.domain.views.base import BaseDomainView
  60. from corehq.apps.hqwebapp.decorators import (
  61. use_datatables,
  62. use_daterangepicker,
  63. use_jquery_ui,
  64. use_multiselect,
  65. use_nvd3,
  66. )
  67. from corehq.apps.hqwebapp.tasks import send_mail_async
  68. from corehq.apps.hqwebapp.templatetags.hq_shared_tags import toggle_enabled
  69. from corehq.apps.hqwebapp.views import CRUDPaginatedViewMixin
  70. from corehq.apps.linked_domain.util import is_linked_report
  71. from corehq.apps.locations.permissions import conditionally_location_safe
  72. from corehq.apps.registry.helper import DataRegistryHelper
  73. from corehq.apps.registry.utils import RegistryPermissionCheck
  74. from corehq.apps.reports.daterange import get_simple_dateranges
  75. from corehq.apps.reports.dispatcher import cls_to_view_login_and_domain
  76. from corehq.apps.saved_reports.models import ReportConfig
  77. from corehq.apps.settings.views import BaseProjectDataView
  78. from corehq.apps.userreports.app_manager.data_source_meta import (
  79. DATA_SOURCE_TYPE_CASE,
  80. DATA_SOURCE_TYPE_RAW,
  81. )
  82. from corehq.apps.userreports.app_manager.helpers import (
  83. get_case_data_source,
  84. get_form_data_source,
  85. )
  86. from corehq.apps.userreports.const import (
  87. DATA_SOURCE_MISSING_APP_ERROR_MESSAGE,
  88. DATA_SOURCE_NOT_FOUND_ERROR_MESSAGE,
  89. NAMED_EXPRESSION_PREFIX,
  90. NAMED_FILTER_PREFIX,
  91. REPORT_BUILDER_EVENTS_KEY,
  92. TEMP_REPORT_PREFIX,
  93. )
  94. from corehq.apps.userreports.dbaccessors import get_datasources_for_domain
  95. from corehq.apps.userreports.exceptions import (
  96. BadBuilderConfigError,
  97. BadSpecError,
  98. DataSourceConfigurationNotFoundError,
  99. ReportConfigurationNotFoundError,
  100. TableNotFoundWarning,
  101. UserQueryError,
  102. translate_programming_error,
  103. )
  104. from corehq.apps.userreports.expressions.factory import ExpressionFactory
  105. from corehq.apps.userreports.filters.factory import FilterFactory
  106. from corehq.apps.userreports.forms import (
  107. UCRExpressionForm,
  108. UCRExpressionUpdateForm,
  109. )
  110. from corehq.apps.userreports.indicators.factory import IndicatorFactory
  111. from corehq.apps.userreports.models import (
  112. DataSourceConfiguration,
  113. RegistryDataSourceConfiguration,
  114. RegistryReportConfiguration,
  115. ReportConfiguration,
  116. StaticReportConfiguration,
  117. UCRExpression,
  118. get_datasource_config,
  119. get_report_config,
  120. id_is_static,
  121. is_data_registry_report,
  122. report_config_id_is_static,
  123. )
  124. from corehq.apps.userreports.rebuild import DataSourceResumeHelper
  125. from corehq.apps.userreports.reports.builder.forms import (
  126. ConfigureListReportForm,
  127. ConfigureMapReportForm,
  128. ConfigureTableReportForm,
  129. DataSourceForm,
  130. get_data_source_interface,
  131. )
  132. from corehq.apps.userreports.reports.builder.sources import (
  133. get_source_type_from_report_config,
  134. )
  135. from corehq.apps.userreports.reports.filters.choice_providers import (
  136. ChoiceQueryContext,
  137. )
  138. from corehq.apps.userreports.reports.util import report_has_location_filter
  139. from corehq.apps.userreports.reports.view import (
  140. ConfigurableReportView,
  141. delete_report_config,
  142. )
  143. from corehq.apps.userreports.specs import EvaluationContext, FactoryContext
  144. from corehq.apps.userreports.tasks import (
  145. rebuild_indicators,
  146. rebuild_indicators_in_place,
  147. resume_building_indicators,
  148. )
  149. from corehq.apps.userreports.ui.forms import (
  150. ConfigurableDataSourceEditForm,
  151. ConfigurableDataSourceFromAppForm,
  152. ConfigurableReportEditForm,
  153. )
  154. from corehq.apps.userreports.util import (
  155. add_event,
  156. allowed_report_builder_reports,
  157. get_indicator_adapter,
  158. get_referring_apps,
  159. has_report_builder_access,
  160. has_report_builder_add_on_privilege,
  161. number_of_report_builder_reports,
  162. )
  163. from corehq.apps.users.decorators import (
  164. get_permission_name,
  165. require_permission,
  166. )
  167. from corehq.apps.users.models import Permissions
  168. from corehq.tabs.tabclasses import ProjectReportsTab
  169. from corehq.util import reverse
  170. from corehq.util.couch import get_document_or_404
  171. from corehq.util.quickcache import quickcache
  172. from corehq.util.soft_assert import soft_assert
  173. def get_datasource_config_or_404(config_id, domain):
  174. try:
  175. return get_datasource_config(config_id, domain)
  176. except DataSourceConfigurationNotFoundError:
  177. raise Http404
  178. def get_report_config_or_404(config_id, domain):
  179. try:
  180. return get_report_config(config_id, domain)
  181. except ReportConfigurationNotFoundError:
  182. raise Http404
  183. def swallow_programming_errors(fn):
  184. @functools.wraps(fn)
  185. def decorated(request, domain, *args, **kwargs):
  186. try:
  187. return fn(request, domain, *args, **kwargs)
  188. except ProgrammingError as e:
  189. if settings.DEBUG:
  190. raise
  191. messages.error(
  192. request,
  193. _('There was a problem processing your request. '
  194. 'If you have recently modified your report data source please try again in a few minutes.'
  195. '<br><br>Technical details:<br>{}'.format(e)),
  196. extra_tags='html',
  197. )
  198. return HttpResponseRedirect(reverse('configurable_reports_home', args=[domain]))
  199. return decorated
  200. @method_decorator(toggles.USER_CONFIGURABLE_REPORTS.required_decorator(), name='dispatch')
  201. class BaseUserConfigReportsView(BaseDomainView):
  202. section_name = gettext_lazy("Configurable Reports")
  203. @property
  204. def main_context(self):
  205. static_reports = list(StaticReportConfiguration.by_domain(self.domain))
  206. context = super(BaseUserConfigReportsView, self).main_context
  207. context.update({
  208. 'reports': (
  209. ReportConfiguration.by_domain(self.domain)
  210. + RegistryReportConfiguration.by_domain(self.domain)
  211. + static_reports
  212. ),
  213. 'data_sources': get_datasources_for_domain(self.domain, include_static=True),
  214. 'use_updated_ucr_naming': toggle_enabled(self.request, toggles.UCR_UPDATED_NAMING)
  215. })
  216. if toggle_enabled(self.request, toggles.AGGREGATE_UCRS):
  217. from corehq.apps.aggregate_ucrs.models import AggregateTableDefinition
  218. context['aggregate_data_sources'] = AggregateTableDefinition.objects.filter(domain=self.domain)
  219. return context
  220. @property
  221. def section_url(self):
  222. return reverse(UserConfigReportsHomeView.urlname, args=(self.domain,))
  223. @property
  224. def page_url(self):
  225. return reverse(self.urlname, args=(self.domain,))
  226. def dispatch(self, *args, **kwargs):
  227. allow_access_to_ucrs = (
  228. hasattr(self.request, 'couch_user') and self.request.couch_user.has_permission(
  229. self.domain,
  230. get_permission_name(Permissions.edit_ucrs)
  231. )
  232. )
  233. if toggles.UCR_UPDATED_NAMING.enabled(self.domain):
  234. self.section_name = gettext_lazy("Custom Web Reports")
  235. if allow_access_to_ucrs:
  236. return super().dispatch(*args, **kwargs)
  237. raise Http404()
  238. class UserConfigReportsHomeView(BaseUserConfigReportsView):
  239. urlname = 'configurable_reports_home'
  240. template_name = 'userreports/configurable_reports_home.html'
  241. page_title = gettext_lazy("Reports Home")
  242. class BaseEditConfigReportView(BaseUserConfigReportsView):
  243. template_name = 'userreports/edit_report_config.html'
  244. @use_multiselect
  245. def dispatch(self, *args, **kwargs):
  246. return super().dispatch(*args, **kwargs)
  247. @property
  248. def report_id(self):
  249. return self.kwargs.get('report_id')
  250. @property
  251. def page_url(self):
  252. if self.report_id:
  253. return reverse(self.urlname, args=(self.domain, self.report_id,))
  254. return super(BaseEditConfigReportView, self).page_url
  255. @property
  256. def page_context(self):
  257. return {
  258. 'form': self.edit_form,
  259. 'report': self.config,
  260. 'referring_apps': get_referring_apps(self.domain, self.report_id),
  261. }
  262. @property
  263. @memoized
  264. def config(self):
  265. if self.report_id is None:
  266. return ReportConfiguration(domain=self.domain)
  267. return get_report_config_or_404(self.report_id, self.domain)[0]
  268. @property
  269. def read_only(self):
  270. if self.report_id is not None:
  271. return (report_config_id_is_static(self.report_id)
  272. or is_linked_report(self.config)
  273. or (
  274. is_data_registry_report(self.config)
  275. and not toggles.DATA_REGISTRY_UCR.enabled(self.domain)
  276. ))
  277. return False
  278. @property
  279. @memoized
  280. def edit_form(self):
  281. if self.request.method == 'POST':
  282. return ConfigurableReportEditForm(
  283. self.domain, self.config, self.read_only,
  284. data=self.request.POST)
  285. return ConfigurableReportEditForm(self.domain, self.config, self.read_only)
  286. def post(self, request, *args, **kwargs):
  287. if self.edit_form.is_valid():
  288. self.edit_form.save(commit=True)
  289. messages.success(request, _('Report "{}" saved!').format(self.config.title))
  290. return HttpResponseRedirect(reverse(
  291. 'edit_configurable_report', args=[self.domain, self.config._id])
  292. )
  293. return self.get(request, *args, **kwargs)
  294. class EditConfigReportView(BaseEditConfigReportView):
  295. urlname = 'edit_configurable_report'
  296. page_title = gettext_lazy("Edit Report")
  297. class CreateConfigReportView(BaseEditConfigReportView):
  298. urlname = 'create_configurable_report'
  299. page_title = gettext_lazy("Create Report")
  300. class ReportBuilderView(BaseDomainView):
  301. @method_decorator(require_permission(Permissions.edit_reports))
  302. @cls_to_view_login_and_domain
  303. @use_daterangepicker
  304. @use_datatables
  305. def dispatch(self, request, *args, **kwargs):
  306. return super(ReportBuilderView, self).dispatch(request, *args, **kwargs)
  307. @property
  308. def main_context(self):
  309. main_context = super(ReportBuilderView, self).main_context
  310. allowed_num_reports = allowed_report_builder_reports(self.request)
  311. main_context.update({
  312. 'has_report_builder_access': has_report_builder_access(self.request),
  313. 'at_report_limit': number_of_report_builder_reports(self.domain) >= allowed_num_reports,
  314. 'report_limit': allowed_num_reports,
  315. 'paywall_url': paywall_home(self.domain),
  316. 'pricing_page_url': settings.PRICING_PAGE_URL,
  317. })
  318. return main_context
  319. @property
  320. def section_name(self):
  321. return _("Report Builder")
  322. @property
  323. def section_url(self):
  324. return reverse(ReportBuilderDataSourceSelect.urlname, args=[self.domain])
  325. @quickcache(["domain"], timeout=0, memoize_timeout=4)
  326. def paywall_home(domain):
  327. """
  328. Return the url for the page in the report builder paywall that users
  329. in the given domain should be directed to upon clicking "+ Create new report"
  330. """
  331. domain_obj = Domain.get_by_name(domain, strict=True)
  332. if domain_obj.requested_report_builder_subscription:
  333. return reverse(ReportBuilderPaywallActivatingSubscription.urlname, args=[domain])
  334. else:
  335. return reverse(ReportBuilderPaywallPricing.urlname, args=[domain])
  336. class ReportBuilderPaywallBase(BaseDomainView):
  337. page_title = gettext_lazy('Subscribe')
  338. @property
  339. def section_name(self):
  340. return _("Report Builder")
  341. @property
  342. def section_url(self):
  343. return paywall_home(self.domain)
  344. @property
  345. @memoized
  346. def plan_name(self):
  347. return Subscription.get_subscribed_plan_by_domain(self.domain).plan.name
  348. class ReportBuilderPaywallPricing(ReportBuilderPaywallBase):
  349. template_name = "userreports/paywall/pricing.html"
  350. urlname = 'report_builder_paywall_pricing'
  351. page_title = gettext_lazy('Pricing')
  352. @property
  353. def page_context(self):
  354. context = super(ReportBuilderPaywallPricing, self).page_context
  355. max_allowed_reports = allowed_report_builder_reports(self.request)
  356. num_builder_reports = number_of_report_builder_reports(self.domain)
  357. context.update({
  358. 'has_report_builder_access': has_report_builder_access(self.request),
  359. 'at_report_limit': num_builder_reports >= max_allowed_reports,
  360. 'max_allowed_reports': max_allowed_reports,
  361. 'pricing_page_url': settings.PRICING_PAGE_URL,
  362. })
  363. return context
  364. class ReportBuilderPaywallActivatingSubscription(ReportBuilderPaywallBase):
  365. template_name = "userreports/paywall/activating_subscription.html"
  366. urlname = 'report_builder_paywall_activating_subscription'
  367. def post(self, request, domain, *args, **kwargs):
  368. self.domain_object.requested_report_builder_subscription.append(request.user.username)
  369. self.domain_object.save()
  370. send_mail_async.delay(
  371. "Report Builder Subscription Request: {}".format(domain),
  372. "User {} in the {} domain has requested a report builder subscription."
  373. " Current subscription is '{}'.".format(
  374. request.user.username,
  375. domain,
  376. self.plan_name
  377. ),
  378. settings.DEFAULT_FROM_EMAIL,
  379. [settings.SALES_EMAIL],
  380. )
  381. update_hubspot_properties.delay(request.couch_user.get_id, {'report_builder_subscription_request': 'yes'})
  382. return self.get(request, domain, *args, **kwargs)
  383. class ReportBuilderDataSourceSelect(ReportBuilderView):
  384. template_name = 'userreports/reportbuilder/data_source_select.html'
  385. page_title = gettext_lazy('Create Report')
  386. urlname = 'report_builder_select_source'
  387. @property
  388. def page_context(self):
  389. context = {
  390. "sources_map": self.form.sources_map,
  391. "domain": self.domain,
  392. 'report': {"title": _("Create New Report")},
  393. 'form': self.form,
  394. "dropdown_map": self.form.dropdown_map,
  395. }
  396. return context
  397. @property
  398. @memoized
  399. def form(self):
  400. max_allowed_reports = allowed_report_builder_reports(self.request)
  401. if self.request.method == 'POST':
  402. return DataSourceForm(self.domain, max_allowed_reports, self.request.couch_user, self.request.POST)
  403. return DataSourceForm(self.domain, max_allowed_reports, self.request.couch_user)
  404. def post(self, request, *args, **kwargs):
  405. if self.form.is_valid():
  406. app_source = self.form.get_selected_source()
  407. track_workflow(
  408. request.user.email,
  409. "Successfully submitted the first part of the Report Builder "
  410. "wizard where you give your report a name and choose a data source"
  411. )
  412. add_event(request, [
  413. "Report Builder",
  414. "Successful Click on Next (Data Source)",
  415. app_source.source_type,
  416. ])
  417. get_params = {
  418. 'report_name': self.form.cleaned_data['report_name'],
  419. 'application': app_source.application,
  420. 'source_type': app_source.source_type,
  421. 'source': app_source.source,
  422. }
  423. registry_permission_checker = RegistryPermissionCheck(self.domain, self.request.couch_user)
  424. if app_source.registry_slug != '' and \
  425. registry_permission_checker.can_view_some_data_registry_contents():
  426. get_params['registry_slug'] = app_source.registry_slug
  427. return HttpResponseRedirect(
  428. reverse(ConfigureReport.urlname, args=[self.domain], params=get_params)
  429. )
  430. else:
  431. return self.get(request, *args, **kwargs)
  432. class EditReportInBuilder(View):
  433. def dispatch(self, request, *args, **kwargs):
  434. report_id = kwargs['report_id']
  435. report = get_document_or_404(ReportConfiguration, request.domain, report_id,
  436. additional_doc_types=["RegistryReportConfiguration"])
  437. if report.report_meta.created_by_builder:
  438. try:
  439. return ConfigureReport.as_view(existing_report=report)(request, *args, **kwargs)
  440. except BadBuilderConfigError as e:
  441. messages.error(request, str(e))
  442. return HttpResponseRedirect(reverse(ConfigurableReportView.slug, args=[request.domain, report_id]))
  443. raise Http404("Report was not created by the report builder")
  444. class ConfigureReport(ReportBuilderView):
  445. urlname = 'configure_report'
  446. page_title = gettext_lazy("Configure Report")
  447. template_name = "userreports/reportbuilder/configure_report.html"
  448. report_title = '{}'
  449. existing_report = None
  450. @use_jquery_ui
  451. @use_datatables
  452. @use_nvd3
  453. @use_multiselect
  454. def dispatch(self, request, *args, **kwargs):
  455. if self.existing_report:
  456. self.source_type = get_source_type_from_report_config(self.existing_report)
  457. if self.source_type != DATA_SOURCE_TYPE_RAW:
  458. self.source_id = self.existing_report.config.meta.build.source_id
  459. self.app_id = self.existing_report.config.meta.build.app_id
  460. self.app = Application.get(self.app_id) if self.app_id else None
  461. self.registry_slug = self.existing_report.config.meta.build.registry_slug
  462. else:
  463. self.source_id = self.existing_report.config_id
  464. self.app_id = self.app = self.registry_slug = None
  465. else:
  466. self.registry_slug = self.request.GET.get('registry_slug', None)
  467. self.app_id = self.request.GET.get('application', None)
  468. self.source_id = self.request.GET['source']
  469. if self.registry_slug:
  470. helper = DataRegistryHelper(self.domain, registry_slug=self.registry_slug)
  471. helper.check_data_access(request.couch_user, [self.source_id], case_domain=self.domain)
  472. self.source_type = DATA_SOURCE_TYPE_CASE
  473. self.app = None
  474. else:
  475. self.app = Application.get(self.app_id)
  476. self.source_type = self.request.GET['source_type']
  477. if self.registry_slug and not toggles.DATA_REGISTRY_UCR.enabled(self.domain):
  478. return self.render_error_response(
  479. _("Creating or Editing Data Registry Reports are not enabled for this project."),
  480. allow_delete=False
  481. )
  482. if not self.app_id and self.source_type != DATA_SOURCE_TYPE_RAW and not self.registry_slug:
  483. raise BadBuilderConfigError(DATA_SOURCE_MISSING_APP_ERROR_MESSAGE)
  484. try:
  485. data_source_interface = get_data_source_interface(
  486. self.domain, self.app, self.source_type, self.source_id, self.registry_slug
  487. )
  488. except ResourceNotFound:
  489. return self.render_error_response(DATA_SOURCE_NOT_FOUND_ERROR_MESSAGE)
  490. self._populate_data_source_properties_from_interface(data_source_interface)
  491. return super(ConfigureReport, self).dispatch(request, *args, **kwargs)
  492. def render_error_response(self, message, allow_delete=None):
  493. if self.existing_report:
  494. context = {
  495. 'allow_delete': self.existing_report.get_id and not self.existing_report.is_static
  496. }
  497. else:
  498. context = {}
  499. if allow_delete is not None:
  500. context['allow_delete'] = allow_delete
  501. context['error_message'] = message
  502. context.update(self.main_context)
  503. return render(self.request, 'userreports/report_error.html', context)
  504. @property
  505. def page_name(self):
  506. title = self._get_report_name()
  507. return _(self.report_title).format(title)
  508. @property
  509. def report_description(self):
  510. if self.existing_report:
  511. return self.existing_report.description or None
  512. return None
  513. def _populate_data_source_properties_from_interface(self, data_source_interface):
  514. self._properties_by_column_id = {}
  515. for p in data_source_interface.data_source_properties.values():
  516. column = p.to_report_column_option()
  517. for agg in column.aggregation_options:
  518. indicators = column.get_indicators(agg)
  519. for i in indicators:
  520. self._properties_by_column_id[i['column_id']] = p
  521. def _get_report_name(self, request=None):
  522. if self.existing_report:
  523. return self.existing_report.title
  524. else:
  525. request = request or self.request
  526. return request.GET.get('report_name', '')
  527. def _get_existing_report_type(self):
  528. if self.existing_report:
  529. type_ = "list"
  530. if self.existing_report.aggregation_columns != ["doc_id"]:
  531. type_ = "table"
  532. if self.existing_report.map_config:
  533. type_ = "map"
  534. return type_
  535. def _get_property_id_by_indicator_id(self, indicator_column_id):
  536. """
  537. Return the data source property id corresponding to the given data
  538. source indicator column id.
  539. :param indicator_column_id: The column_id field of a data source indicator
  540. configuration dictionary
  541. :return: A DataSourceProperty property id, e.g. "/data/question1"
  542. """
  543. data_source_property = self._properties_by_column_id.get(indicator_column_id)
  544. if data_source_property:
  545. return data_source_property.get_id()
  546. def _get_initial_location(self, report_form):
  547. if self.existing_report:
  548. cols = [col for col in self.existing_report.report_columns if col.type == 'location']
  549. if cols:
  550. indicator_id = cols[0].field
  551. return report_form._get_property_id_by_indicator_id(indicator_id)
  552. def _get_initial_chart_type(self):
  553. if self.existing_report:
  554. if self.existing_report.configured_charts:
  555. type_ = self.existing_report.configured_charts[0]['type']
  556. if type_ == "multibar":
  557. return "bar"
  558. if type_ == "pie":
  559. return "pie"
  560. def _get_column_options(self, report_form):
  561. options = OrderedDict()
  562. for option in report_form.report_column_options.values():
  563. key = option.get_uniquenss_key()
  564. if key in options:
  565. options[key].append(option)
  566. else:
  567. options[key] = [option]
  568. @property
  569. def page_context(self):
  570. form_type = _get_form_type(self._get_existing_report_type())
  571. report_form = form_type(
  572. self.domain, self.page_name, self.app_id, self.source_type, self.source_id, self.existing_report, self.registry_slug,
  573. )
  574. temp_ds_id = report_form.create_temp_data_source_if_necessary(self.request.user.username)
  575. return {
  576. 'existing_report': self.existing_report,
  577. 'report_description': self.report_description,
  578. 'report_title': self.page_name,
  579. 'existing_report_type': self._get_existing_report_type(),
  580. 'column_options': [p.to_view_model() for p in report_form.report_column_options.values()],
  581. # TODO: Consider renaming this because it's more like "possible" data source props
  582. 'data_source_properties': [p.to_view_model() for p in report_form.data_source_properties.values()],
  583. 'initial_user_filters': [f._asdict() for f in report_form.initial_user_filters],
  584. 'initial_default_filters': [f._asdict() for f in report_form.initial_default_filters],
  585. 'initial_columns': [c._asdict() for c in report_form.initial_columns],
  586. 'initial_location': self._get_initial_location(report_form),
  587. 'initial_chart_type': self._get_initial_chart_type(),
  588. 'source_type': self.source_type,
  589. 'source_id': self.source_id,
  590. 'application': self.app_id,
  591. 'registry_slug': self.registry_slug,
  592. 'report_preview_url': reverse(ReportPreview.urlname,
  593. args=[self.domain, temp_ds_id]),
  594. 'preview_datasource_id': temp_ds_id,
  595. 'report_builder_events': self.request.session.pop(REPORT_BUILDER_EVENTS_KEY, []),
  596. 'MAPBOX_ACCESS_TOKEN': settings.MAPBOX_ACCESS_TOKEN,
  597. 'date_range_options': [r._asdict() for r in get_simple_dateranges()],
  598. }
  599. def _get_bound_form(self, report_data):
  600. form_class = _get_form_type(report_data['report_type'])
  601. return form_class(
  602. self.domain,
  603. self._get_report_name(),
  604. self.app._id if self.app else None,
  605. self.source_type,
  606. self.source_id,
  607. self.existing_report,
  608. self.registry_slug,
  609. report_data
  610. )
  611. def post(self, request, domain, *args, **kwargs):
  612. if not has_report_builder_access(request):
  613. raise Http404
  614. report_data = json.loads(request.body.decode('utf-8'))
  615. if report_data['existing_report'] and not self.existing_report:
  616. # This is the case if the user has clicked "Save" for a second time from the new report page
  617. # i.e. the user created a report with the first click, but didn't navigate to the report view page
  618. self.existing_report = ReportConfiguration.get(report_data['existing_report'])
  619. _munge_report_data(report_data)
  620. bound_form = self._get_bound_form(report_data)
  621. if bound_form.is_valid():
  622. if self.existing_report:
  623. report_configuration = bound_form.update_report()
  624. else:
  625. self._confirm_report_limit()
  626. try:
  627. report_configuration = bound_form.create_report()
  628. except BadSpecError as err:
  629. messages.error(self.request, str(err))
  630. notify_exception(self.request, str(err), details={
  631. 'domain': self.domain,
  632. 'report_form_class': bound_form.__class__.__name__,
  633. 'report_type': bound_form.report_type,
  634. 'group_by': getattr(bound_form, 'group_by', 'Not set'),
  635. 'user_filters': getattr(bound_form, 'user_filters', 'Not set'),
  636. 'default_filters': getattr(bound_form, 'default_filters', 'Not set'),
  637. })
  638. return self.get(request, domain, *args, **kwargs)
  639. else:
  640. ProjectReportsTab.clear_dropdown_cache(domain, request.couch_user)
  641. self._delete_temp_data_source(report_data)
  642. send_hubspot_form(HUBSPOT_SAVED_UCR_FORM_ID, request)
  643. return json_response({
  644. 'report_url': reverse(ConfigurableReportView.slug, args=[self.domain, report_configuration._id]),
  645. 'report_id': report_configuration._id,
  646. })
  647. def _delete_temp_data_source(self, report_data):
  648. if report_data.get("delete_temp_data_source", False):
  649. delete_data_source_shared(self.domain, report_data["preview_data_source_id"])
  650. def _confirm_report_limit(self):
  651. """
  652. This method is used to confirm that the user is not creating more reports
  653. than they are allowed.
  654. The user is normally turned back earlier in the process, but this check
  655. is necessary in case they navigated directly to this view either
  656. maliciously or with a bookmark perhaps.
  657. """
  658. if (number_of_report_builder_reports(self.domain) >=
  659. allowed_report_builder_reports(self.request)):
  660. raise Http404()
  661. def update_report_description(request, domain, report_id):
  662. new_description = request.POST['value']
  663. report = get_document_or_404(ReportConfiguration, domain, report_id,
  664. additional_doc_types=["RegistryReportConfiguration"])
  665. report.description = new_description
  666. report.save()
  667. return json_response({})
  668. def _get_form_type(report_type):
  669. assert report_type in (None, "list", "table", "chart", "map")
  670. if report_type == "list" or report_type is None:
  671. return ConfigureListReportForm
  672. if report_type == "table":
  673. return ConfigureTableReportForm
  674. if report_type == "map":
  675. return ConfigureMapReportForm
  676. def _munge_report_data(report_data):
  677. """
  678. Split aggregation columns out of report_data and into
  679. :param report_data:
  680. :return:
  681. """
  682. report_data['columns'] = json.dumps(report_data['columns'])
  683. report_data['user_filters'] = json.dumps(report_data['user_filters'])
  684. report_data['default_filters'] = json.dumps(report_data['default_filters'])
  685. class ReportPreview(BaseDomainView):
  686. urlname = 'report_preview'
  687. def post(self, request, domain, data_source):
  688. report_data = json.loads(six.moves.urllib.parse.unquote(request.body.decode('utf-8')))
  689. form_class = _get_form_type(report_data['report_type'])
  690. # ignore user filters
  691. report_data['user_filters'] = []
  692. _munge_report_data(report_data)
  693. bound_form = form_class(
  694. domain,
  695. '{}_{}_{}'.format(TEMP_REPORT_PREFIX, self.domain, data_source),
  696. report_data['app'],
  697. report_data['source_type'],
  698. report_data['source_id'],
  699. None,
  700. report_data['registry_slug'],
  701. report_data
  702. )
  703. if bound_form.is_valid():
  704. try:
  705. temp_report = bound_form.create_temp_report(data_source, self.request.user.username)
  706. with delete_report_config(temp_report) as report_config:
  707. response_data = ConfigurableReportView.report_preview_data(self.domain, report_config)
  708. if response_data:
  709. return json_response(response_data)
  710. except BadBuilderConfigError as e:
  711. return json_response({'status': 'error', 'message': str(e)}, status_code=400)
  712. else:
  713. return json_response({
  714. 'status': 'error',
  715. 'message': 'Invalid report configuration',
  716. 'errors': bound_form.errors,
  717. }, status_code=400)
  718. def _assert_report_delete_privileges(request):
  719. if not (toggle_enabled(request, toggles.USER_CONFIGURABLE_REPORTS)
  720. or toggle_enabled(request, toggles.REPORT_BUILDER)
  721. or toggle_enabled(request, toggles.REPORT_BUILDER_BETA_GROUP)
  722. or has_report_builder_add_on_privilege(request)):
  723. raise Http404()
  724. @login_and_domain_required
  725. @require_permission(Permissions.edit_reports)
  726. def delete_report(request, domain, report_id):
  727. _assert_report_delete_privileges(request)
  728. config = get_document_or_404(ReportConfiguration, domain, report_id,
  729. additional_doc_types=["RegistryReportConfiguration"])
  730. # Delete the data source too if it's not being used by any other reports.
  731. try:
  732. data_source, __ = get_datasource_config(config.config_id, domain)
  733. except DataSourceConfigurationNotFoundError:
  734. # It's possible the data source has already been deleted, but that's fine with us.
  735. pass
  736. else:
  737. if data_source.get_report_count() <= 1:
  738. # No other reports reference this data source.
  739. data_source.deactivate(initiated_by=request.user.username)
  740. soft_delete(config)
  741. did_purge_something = purge_report_from_mobile_ucr(config)
  742. messages.success(
  743. request,
  744. _('Report "{name}" has been deleted. <a href="{url}" class="post-link">Undo</a>').format(
  745. name=config.title,
  746. url=reverse('undo_delete_configurable_report', args=[domain, config._id]),
  747. ),
  748. extra_tags='html'
  749. )
  750. report_configs = ReportConfig.by_domain_and_owner(
  751. domain, request.couch_user.get_id, "configurable")
  752. for rc in report_configs:
  753. if rc.subreport_slug == config.get_id:
  754. rc.delete()
  755. if did_purge_something:
  756. messages.warning(
  757. request,
  758. _("This report was used in one or more applications. "
  759. "It has been removed from there too.")
  760. )
  761. ProjectReportsTab.clear_dropdown_cache(domain, request.couch_user)
  762. redirect = request.GET.get("redirect", None)
  763. if not redirect:
  764. redirect = reverse('configurable_reports_home', args=[domain])
  765. return HttpResponseRedirect(redirect)
  766. @login_and_domain_required
  767. @require_permission(Permissions.edit_reports)
  768. def undelete_report(request, domain, report_id):
  769. _assert_report_delete_privileges(request)
  770. config = get_document_or_404(ReportConfiguration, domain, report_id, additional_doc_types=[
  771. get_deleted_doc_type(ReportConfiguration),
  772. RegistryReportConfiguration.doc_type,
  773. get_deleted_doc_type(RegistryReportConfiguration)
  774. ])
  775. if config and is_deleted(config):
  776. undo_delete(config)
  777. messages.success(
  778. request,
  779. _('Successfully restored report "{name}"').format(name=config.title)
  780. )
  781. else:
  782. messages.info(request, _('Report "{name}" not deleted.').format(name=config.title))
  783. return HttpResponseRedirect(reverse(ConfigurableReportView.slug, args=[request.domain, report_id]))
  784. class ImportConfigReportView(BaseUserConfigReportsView):
  785. page_title = gettext_lazy("Import Report")
  786. template_name = "userreports/import_report.html"
  787. urlname = 'import_configurable_report'
  788. @property
  789. def spec(self):
  790. if self.request.method == "POST":
  791. return self.request.POST['report_spec']
  792. return ''
  793. def post(self, request, *args, **kwargs):
  794. try:
  795. json_spec = json.loads(self.spec)
  796. if '_id' in json_spec:
  797. del json_spec['_id']
  798. json_spec['domain'] = self.domain
  799. report = ReportConfiguration.wrap(json_spec)
  800. report.validate()
  801. report.save()
  802. messages.success(request, _('Report created!'))
  803. return HttpResponseRedirect(reverse(
  804. EditConfigReportView.urlname, args=[self.domain, report._id]
  805. ))
  806. except (ValueError, BadSpecError) as e:
  807. messages.error(request, _('Bad report source: {}').format(e))
  808. return self.get(request, *args, **kwargs)
  809. @property
  810. def page_context(self):
  811. return {
  812. 'spec': self.spec,
  813. }
  814. @login_and_domain_required
  815. @toggles.USER_CONFIGURABLE_REPORTS.required_decorator()
  816. def report_source_json(request, domain, report_id):
  817. config, _ = get_report_config_or_404(report_id, domain)
  818. config._doc.pop('_rev', None)
  819. return json_response(config)
  820. class ExpressionDebuggerView(BaseUserConfigReportsView):
  821. urlname = 'expression_debugger'
  822. template_name = 'userreports/expression_debugger.html'
  823. page_title = gettext_lazy("Expression Debugger")
  824. @property
  825. def main_context(self):
  826. context = super().main_context
  827. if toggle_enabled(self.request, toggles.UCR_EXPRESSION_REGISTRY):
  828. context['ucr_expressions'] = UCRExpression.objects.filter(domain=self.domain)
  829. return context
  830. class DataSourceDebuggerView(BaseUserConfigReportsView):
  831. urlname = 'expression_debugger'
  832. template_name = 'userreports/data_source_debugger.html'
  833. page_title = gettext_lazy("Data Source Debugger")
  834. def dispatch(self, *args, **kwargs):
  835. if toggles.UCR_UPDATED_NAMING.enabled(self.domain):
  836. self.page_title = gettext_lazy("Custom Web Report Source Debugger")
  837. return super().dispatch(*args, **kwargs)
  838. @login_and_domain_required
  839. @toggles.USER_CONFIGURABLE_REPORTS.required_decorator()
  840. def evaluate_expression(request, domain):
  841. input_type = request.POST['input_type']
  842. data_source_id = request.POST['data_source']
  843. expression_text = request.POST.get('expression')
  844. ucr_expression_id = request.POST.get('ucr_expression_id')
  845. try:
  846. factory_context = _get_factory_context(domain, data_source_id)
  847. parsed_expression = _get_parsed_expression(domain, factory_context, expression_text, ucr_expression_id)
  848. if input_type == 'doc':
  849. doc_type = request.POST['doc_type']
  850. doc_id = request.POST['doc_id']
  851. doc = _get_document(domain, doc_type, doc_id)
  852. else:
  853. doc = json.loads(request.POST['raw_doc'])
  854. result = parsed_expression(doc, EvaluationContext(doc))
  855. return JsonResponse({"result": result})
  856. except HttpException as e:
  857. return JsonResponse({'message': e.message}, status=e.status)
  858. except Exception as e:
  859. return JsonResponse({"error": str(e)}, status=500)
  860. def _get_factory_context(domain, data_source_id):
  861. if data_source_id:
  862. try:
  863. data_source = get_datasource_config(data_source_id, domain)[0]
  864. return data_source.get_factory_context()
  865. except DataSourceConfigurationNotFoundError:
  866. raise HttpException(
  867. 404, _("Data source with id {} not found in domain {}.").format(data_source_id, domain)
  868. )
  869. return FactoryContext.empty(domain=domain)
  870. def _get_parsed_expression(domain, factory_context, expression_text, expression_id):
  871. if expression_id:
  872. try:
  873. expression_model = UCRExpression.objects.get(domain=domain, id=expression_id)
  874. return expression_model.wrapped_definition(factory_context)
  875. except UCRExpression.DoesNotExist:
  876. raise HttpException(404, _("Expression not found"))
  877. except BadSpecError as e:
  878. raise HttpException(400, _("Problem with expression: {}").format(e))
  879. try:
  880. expression_json = json.loads(expression_text)
  881. return ExpressionFactory.from_spec(
  882. expression_json,
  883. context=factory_context
  884. )
  885. except BadSpecError as e:
  886. raise HttpException(400, _("Problem with expression: {}").format(e))
  887. def _get_document(domain, doc_type, doc_id):
  888. try:
  889. usable_type = {
  890. 'form': 'XFormInstance',
  891. 'case': 'CommCareCase',
  892. }.get(doc_type, 'Unknown')
  893. document_store = get_document_store_for_doc_type(
  894. domain, usable_type, load_source="eval_expression")
  895. return document_store.get_document(doc_id)
  896. except DocumentNotFoundError:
  897. raise HttpException(404, _("{} with id {} not found in domain {}.").format(doc_type, doc_id, domain))
  898. @login_and_domain_required
  899. @toggles.USER_CONFIGURABLE_REPORTS.required_decorator()
  900. def evaluate_data_source(request, domain):
  901. data_source_id = request.POST['data_source']
  902. docs_id = request.POST['docs_id']
  903. try:
  904. data_source = get_datasource_config(data_source_id, domain)[0]
  905. except DataSourceConfigurationNotFoundError:
  906. return JsonResponse(
  907. {"error": _("Data source with id {} not found in domain {}.").format(
  908. data_source_id, domain
  909. )},
  910. status=404,
  911. )
  912. docs_id = [doc_id.strip() for doc_id in docs_id.split(',')]
  913. document_store = get_document_store_for_doc_type(
  914. domain, data_source.referenced_doc_type, load_source="eval_data_source")
  915. rows = []
  916. docs = 0
  917. for doc in document_store.iter_documents(docs_id):
  918. docs += 1
  919. for row in data_source.get_all_values(doc):
  920. rows.append({i.column.database_column_name.decode(): i.value for i in row})
  921. if not docs:
  922. return JsonResponse(data={'error': _('No documents found. Check the IDs and try again.')}, status=404)
  923. data = {
  924. 'rows': rows,
  925. 'db_rows': [],
  926. 'columns': [
  927. column.database_column_name.decode() for column in data_source.get_columns()
  928. ],
  929. }
  930. try:
  931. adapter = get_indicator_adapter(data_source)
  932. table = adapter.get_table()
  933. query = adapter.get_query_object().filter(table.c.doc_id.in_(docs_id))
  934. db_rows = [
  935. {column.name: getattr(row, column.name) for column in table.columns}
  936. for row in query
  937. ]
  938. data['db_rows'] = db_rows
  939. except ProgrammingError as e:
  940. err = translate_programming_error(e)
  941. if err and isinstance(err, TableNotFoundWarning):
  942. data['db_error'] = _("Datasource table does not exist. Try rebuilding the datasource.")
  943. else:
  944. data['db_error'] = _("Error querying database for data.")
  945. return JsonResponse(data=data)
  946. class CreateDataSourceFromAppView(BaseUserConfigReportsView):
  947. urlname = 'create_configurable_data_source_from_app'
  948. template_name = "userreports/data_source_from_app.html"
  949. page_title = gettext_lazy("Create Data Source from Application")
  950. @property
  951. @memoized
  952. def form(self):
  953. if self.request.method == 'POST':
  954. return ConfigurableDataSourceFromAppForm(self.domain, self.request.POST)
  955. return ConfigurableDataSourceFromAppForm(self.domain)
  956. def post(self, request, *args, **kwargs):
  957. if self.form.is_valid():
  958. app_source = self.form.app_source_helper.get_app_source(self.form.cleaned_data)
  959. app = Application.get(app_source.application)
  960. if app_source.source_type == 'case':
  961. data_source = get_case_data_source(app, app_source.source)
  962. data_source.save()
  963. messages.success(request, _("Data source created for '{}'".format(app_source.source)))
  964. else:
  965. assert app_source.source_type == 'form'
  966. xform = app.get_form(app_source.source)
  967. data_source = get_form_data_source(app, xform)
  968. data_source.save()
  969. messages.success(request, _("Data source created for '{}'".format(xform.default_name())))
  970. return HttpResponseRedirect(reverse(
  971. EditDataSourceView.urlname, args=[self.domain, data_source._id]
  972. ))
  973. return self.get(request, *args, **kwargs)
  974. @property
  975. def page_context(self):
  976. return {
  977. 'sources_map': self.form.app_source_helper.all_sources,
  978. 'form': self.form,
  979. }
  980. class BaseEditDataSourceView(BaseUserConfigReportsView):
  981. template_name = 'userreports/edit_data_source.html'
  982. @property
  983. def page_context(self):
  984. is_rebuilding = (
  985. self.config.meta.build.initiated
  986. and (
  987. not self.config.meta.build.finished
  988. and not self.config.meta.build.rebuilt_asynchronously
  989. )
  990. )
  991. is_rebuilding_inplace = (
  992. self.config.meta.build.initiated_in_place
  993. and not self.config.meta.build.finished_in_place
  994. )
  995. allowed_ucr_expression = AllowedUCRExpressionSettings.get_allowed_ucr_expressions(self.request.domain)
  996. return {
  997. 'form': self.edit_form,
  998. 'data_source': self.config,
  999. 'read_only': self.read_only,
  1000. 'used_by_reports': self.get_reports(),
  1001. 'is_rebuilding': is_rebuilding,
  1002. 'is_rebuilding_inplace': is_rebuilding_inplace,
  1003. 'allowed_ucr_expressions': allowed_ucr_expression,
  1004. }
  1005. @property
  1006. def page_url(self):
  1007. if self.config_id:
  1008. return reverse(self.urlname, args=(self.domain, self.config_id,))
  1009. return super(BaseEditDataSourceView, self).page_url
  1010. @property
  1011. def config_id(self):
  1012. return self.kwargs.get('config_id')
  1013. @property
  1014. def read_only(self):
  1015. return id_is_static(self.config_id) if self.config_id is not None else False
  1016. @property
  1017. @memoized
  1018. def config(self):
  1019. if self.config_id is None:
  1020. return DataSourceConfiguration(domain=self.domain)
  1021. return get_datasource_config_or_404(self.config_id, self.domain)[0]
  1022. @property
  1023. @memoized
  1024. def edit_form(self):
  1025. if self.request.method == 'POST':
  1026. return ConfigurableDataSourceEditForm(
  1027. self.domain,
  1028. self.config,
  1029. self.read_only,
  1030. data=self.request.POST
  1031. )
  1032. return ConfigurableDataSourceEditForm(
  1033. self.domain, self.config, self.read_only
  1034. )
  1035. def post(self, request, *args, **kwargs):
  1036. try:
  1037. if self.edit_form.is_valid():
  1038. config = self.edit_form.save(commit=True)
  1039. messages.success(request, _('Data source "{}" saved!').format(
  1040. config.display_name
  1041. ))
  1042. if self.config_id is None:
  1043. return HttpResponseRedirect(reverse(
  1044. EditDataSourceView.urlname, args=[self.domain, config._id])
  1045. )
  1046. except BadSpecError as e:
  1047. messages.error(request, str(e))
  1048. return self.get(request, *args, **kwargs)
  1049. def get_reports(self):
  1050. reports = StaticReportConfiguration.by_domain(self.domain)
  1051. reports += ReportConfiguration.by_domain(self.domain)
  1052. ret = []
  1053. for report in reports:
  1054. try:
  1055. if report.table_id == self.config.table_id:
  1056. ret.append(report)
  1057. except DataSourceConfigurationNotFoundError:
  1058. _soft_assert = soft_assert(to=[
  1059. '{}@{}'.format(name, 'dimagi.com')
  1060. for name in ['cellowitz', 'frener']
  1061. ])
  1062. _soft_assert(False, "Report {} on domain {} attempted to reference deleted table".format(
  1063. report._id, self.domain
  1064. ))
  1065. return ret
  1066. def get(self, request, *args, **kwargs):
  1067. if self.config.is_deactivated:
  1068. messages.info(
  1069. request, _(
  1070. 'Data source "{}" has no associated table.\n'
  1071. 'Click "Rebuild Data Source" to recreate the table.'
  1072. ).format(self.config.display_name)
  1073. )
  1074. return super(BaseEditDataSourceView, self).get(request, *args, **kwargs)
  1075. class CreateDataSourceView(BaseEditDataSourceView):
  1076. urlname = 'create_configurable_data_source'
  1077. page_title = gettext_lazy("Create Data Source")
  1078. class EditDataSourceView(BaseEditDataSourceView):
  1079. urlname = 'edit_configurable_data_source'
  1080. page_title = gettext_lazy("Edit Data Source")
  1081. @property
  1082. def page_name(self):
  1083. return "Edit {}".format(self.config.display_name)
  1084. @toggles.USER_CONFIGURABLE_REPORTS.required_decorator()
  1085. @require_POST
  1086. def delete_data_source(request, domain, config_id):
  1087. try:
  1088. delete_data_source_shared(domain, config_id, request)
  1089. except BadSpecError as err:
  1090. err_text = f"Unable to delete this Web Report Source because {str(err)}"
  1091. messages.error(request, err_text)
  1092. return HttpResponseRedirect(reverse(
  1093. EditDataSourceView.urlname, args=[domain, config_id]
  1094. ))
  1095. return HttpResponseRedirect(reverse('configurable_reports_home', args=[domain]))
  1096. def delete_data_source_shared(domain, config_id, request=None):
  1097. config = get_document_or_404(DataSourceConfiguration, domain, config_id,
  1098. additional_doc_types=["RegistryDataSourceConfiguration"])
  1099. adapter = get_indicator_adapter(config)
  1100. username = request.user.username if request else None
  1101. skip = not request # skip logging when we remove temporary tables
  1102. adapter.drop_table(initiated_by=username, source='delete_data_source', skip_log=skip)
  1103. soft_delete(config)
  1104. if request:
  1105. messages.success(
  1106. request,
  1107. _('Data source "{name}" has been deleted. <a href="{url}" class="post-link">Undo</a>').format(
  1108. name=config.display_name,
  1109. url=reverse('undo_delete_data_source', args=[domain, config._id]),
  1110. ),
  1111. extra_tags='html'
  1112. )
  1113. @toggles.USER_CONFIGURABLE_REPORTS.required_decorator()
  1114. @require_POST
  1115. def undelete_data_sour