diff --git a/corehq/apps/enterprise/api/api.py b/corehq/apps/enterprise/api/api.py index e3703aae5edd..fc53b3102a7b 100644 --- a/corehq/apps/enterprise/api/api.py +++ b/corehq/apps/enterprise/api/api.py @@ -6,6 +6,7 @@ MobileUserResource, ODataFeedResource, WebUserResource, + SMSResource, ) v1_api = Api(api_name='v1') @@ -14,3 +15,4 @@ v1_api.register(MobileUserResource()) v1_api.register(FormSubmissionResource()) v1_api.register(ODataFeedResource()) +v1_api.register(SMSResource()) diff --git a/corehq/apps/enterprise/api/resources.py b/corehq/apps/enterprise/api/resources.py index 09d5b85d120d..1d49b526d323 100644 --- a/corehq/apps/enterprise/api/resources.py +++ b/corehq/apps/enterprise/api/resources.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext as _ from dateutil import tz +from datetime import timezone from tastypie import fields, http from tastypie.exceptions import ImmediateHttpResponse @@ -314,6 +315,50 @@ def get_primary_keys(self): return ('user_id',) +class SMSResource(ODataEnterpriseReportResource): + domain = fields.CharField() + num_sent = fields.IntegerField() + num_received = fields.IntegerField() + num_error = fields.IntegerField() + + REPORT_SLUG = EnterpriseReport.SMS + + def get_report_task(self, request): + start_date, end_date = get_date_range_from_request(request.GET) + + account = BillingAccount.get_account_by_domain(request.domain) + return generate_enterprise_report.s( + self.REPORT_SLUG, + account.id, + request.couch_user.username, + start_date=start_date, + end_date=end_date + ) + + def dehydrate(self, bundle): + bundle.data['domain'] = bundle.obj[0] + bundle.data['num_sent'] = bundle.obj[1] + bundle.data['num_received'] = bundle.obj[2] + bundle.data['num_error'] = bundle.obj[3] + + return bundle + + def get_primary_keys(self): + return ('domain',) + + +def get_date_range_from_request(request_dict): + start_date = request_dict.get('startdate', None) + if start_date: + start_date = str(datetime.fromisoformat(start_date).astimezone(timezone.utc)) + + end_date = request_dict.get('enddate', None) + if end_date: + end_date = str(datetime.fromisoformat(end_date).astimezone(timezone.utc)) + + return (start_date, end_date,) + + class ODataFeedResource(ODataEnterpriseReportResource): ''' A Resource for listing all Domain-level OData feeds which belong to the Enterprise. diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index 352119c6ea00..bbd1489da7ed 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -1,4 +1,5 @@ import re +from django.db.models import Count from datetime import datetime, timedelta from django.utils.translation import gettext as _ @@ -20,6 +21,7 @@ from corehq.apps.es import forms as form_es from corehq.apps.es.users import UserES from corehq.apps.export.dbaccessors import ODataExportFetcher +from corehq.apps.sms.models import SMS, OUTGOING, INCOMING from corehq.apps.users.dbaccessors import ( get_all_user_rows, get_mobile_user_count, @@ -34,6 +36,7 @@ class EnterpriseReport: MOBILE_USERS = 'mobile_users' FORM_SUBMISSIONS = 'form_submissions' ODATA_FEEDS = 'odata_feeds' + SMS = 'sms' DATE_ROW_FORMAT = '%Y/%m/%d %H:%M:%S' @@ -67,6 +70,8 @@ def create(cls, slug, account_id, couch_user, **kwargs): report = EnterpriseFormReport(account, couch_user, **kwargs) elif slug == cls.ODATA_FEEDS: report = EnterpriseODataReport(account, couch_user, **kwargs) + elif slug == cls.SMS: + report = EnterpriseSMSReport(account, couch_user, **kwargs) if report: report.slug = slug @@ -383,3 +388,67 @@ def _get_individual_export_rows(self, exports, export_line_counts): ) return rows + + +class EnterpriseSMSReport(EnterpriseReport): + title = gettext_lazy('SMS Usage') + MAX_DATE_RANGE_DAYS = 90 + + def __init__(self, account, couch_user, start_date=None, end_date=None, num_days=30): + super().__init__(account, couch_user) + + if not end_date: + end_date = datetime.utcnow() + elif isinstance(end_date, str): + end_date = datetime.fromisoformat(end_date) + + if start_date: + if isinstance(start_date, str): + start_date = datetime.fromisoformat(start_date) + self.datespan = DateSpan(start_date, end_date) + self.subtitle = _("{} to {}").format( + start_date.date(), + end_date.date(), + ) + else: + self.datespan = DateSpan(end_date - timedelta(days=num_days), end_date) + self.subtitle = _("past {} days").format(num_days) + + if self.datespan.enddate - self.datespan.startdate > timedelta(days=self.MAX_DATE_RANGE_DAYS): + raise TooMuchRequestedDataError( + _('Date ranges with more than {} days are not supported').format(self.MAX_DATE_RANGE_DAYS) + ) + + def total_for_domain(self, domain_obj): + query = SMS.objects.filter( + domain=domain_obj.name, + processed=True, + direction=OUTGOING, + error=False, + date__gte=self.datespan.startdate, + date__lt=self.datespan.enddate_adjusted + ) + + return query.count() + + @property + def headers(self): + headers = [_('Project Space'), _('# Sent'), _('# Received'), _('# Errors')] + + return headers + + def rows_for_domain(self, domain_obj): + results = SMS.objects.filter( + domain=domain_obj.name, + processed=True, + date__gte=self.datespan.startdate, + date__lt=self.datespan.enddate_adjusted + ).values('direction', 'error').annotate(direction_count=Count('pk')) + + num_sent = sum([result['direction_count'] for result in results + if result['direction'] == OUTGOING and not result['error']]) + num_received = sum([result['direction_count'] for result in results + if result['direction'] == INCOMING and not result['error']]) + num_errors = sum([result['direction_count'] for result in results if result['error']]) + + return [(domain_obj.name, num_sent, num_received, num_errors), ] diff --git a/corehq/apps/enterprise/static/enterprise/js/project_dashboard.js b/corehq/apps/enterprise/static/enterprise/js/project_dashboard.js index 43f0c3990c90..ce0a586b4ba6 100644 --- a/corehq/apps/enterprise/static/enterprise/js/project_dashboard.js +++ b/corehq/apps/enterprise/static/enterprise/js/project_dashboard.js @@ -60,7 +60,47 @@ hqDefine("enterprise/js/project_dashboard", [ return self; }; - var DateRangeModal = function (datePicker, presetOptions, maxDateRangeDays, tileDisplay) { + var SMSTile = function (datePicker) { + var self = {}; + self.endDate = ko.observable(moment().utc()); + self.startDate = ko.observable(self.endDate().clone().subtract(30, "days")); + self.presetType = ko.observable(PRESET_LAST_30); + self.customDateRangeDisplay = ko.observable(datePicker.optionsStore.input.value); + + self.presetText = ko.pureComputed(function () { + if (self.presetType() !== PRESET_CUSTOM) { + return dateRangePresetOptions.find(ele => ele.id === self.presetType()).text; + } else { + return self.customDateRangeDisplay(); + } + }); + + self.onApply = function (preset, startDate, endDate) { + self.startDate(startDate); + self.endDate(endDate); + self.presetType(preset); + self.customDateRangeDisplay(datePicker.optionsStore.input.value); + + updateDisplayTotal($("#sms"), { + "start_date": startDate.toISOString(), + "end_date": endDate.toISOString(), + }); + }; + + return self; + }; + + var DateRangeModal = function ($modal, datePicker, presetOptions, maxDateRangeDays, tileMap) { + let tileDisplay = null; + $modal.on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); + tileDisplay = tileMap[button.data('sender')]; + + self.presetType(tileDisplay.presetType()); + self.customStartDate(tileDisplay.startDate().clone()); + self.customEndDate(tileDisplay.endDate().clone()); + }); + var self = {}; self.presetOptions = presetOptions; self.presetType = ko.observable(PRESET_LAST_30); @@ -193,24 +233,29 @@ hqDefine("enterprise/js/project_dashboard", [ $(function () { const metricType = initialPageData.get('metric_type'); - const $dateRangeDisplay = $("#dateRangeDisplay"); - let dateRangeModal = null; - if ($dateRangeDisplay.length) { - const datePicker = tempusDominus.createDefaultDateRangePicker( - document.getElementById("id_date_range"), - moment().subtract(30, "days"), - moment() - ); - - const formSubmissionsDisplay = MobileFormSubmissionsTile(datePicker); - const maxDateRangeDays = initialPageData.get("max_date_range_days"); - dateRangeModal = DateRangeModal(datePicker, dateRangePresetOptions, maxDateRangeDays, formSubmissionsDisplay); - - $dateRangeDisplay.koApplyBindings(formSubmissionsDisplay); - $("#enterpriseFormsDaterange").koApplyBindings( - dateRangeModal - ); - } + const datePicker = tempusDominus.createDefaultDateRangePicker( + document.getElementById("id_date_range"), + moment().subtract(30, "days"), + moment() + ); + + const $dateRangeModal = $('#enterpriseFormsDaterange'); + + const formSubmissionsDisplay = MobileFormSubmissionsTile(datePicker); + const smsDisplay = SMSTile(datePicker); + const maxDateRangeDays = initialPageData.get("max_date_range_days"); + + const displayMap = { + "form_submission": formSubmissionsDisplay, + "sms": smsDisplay, + }; + const dateRangeModal = DateRangeModal($dateRangeModal, datePicker, dateRangePresetOptions, maxDateRangeDays, displayMap); + + $("#form_submission_dateRangeDisplay").koApplyBindings(formSubmissionsDisplay); + $("#sms_dateRangeDisplay").koApplyBindings(smsDisplay); + $dateRangeModal.koApplyBindings( + dateRangeModal + ); kissmetrics.track.event(`[${metricType}] Visited page`); $(".report-panel").each(function () { @@ -238,7 +283,9 @@ hqDefine("enterprise/js/project_dashboard", [ $button.enableButton(); }, }; - if (slug === "form_submissions") { + + const dateRangeSlugs = ["form_submissions", "sms"]; + if (dateRangeSlugs.includes(slug)) { requestParams["data"] = { "start_date": dateRangeModal.startDate().toISOString(), "end_date": dateRangeModal.endDate().toISOString(), diff --git a/corehq/apps/enterprise/templates/enterprise/partials/project_tile.html b/corehq/apps/enterprise/templates/enterprise/partials/project_tile.html index cc76e03f3010..334ca899dbf4 100644 --- a/corehq/apps/enterprise/templates/enterprise/partials/project_tile.html +++ b/corehq/apps/enterprise/templates/enterprise/partials/project_tile.html @@ -5,7 +5,9 @@
{{ report.title }}
{% if report.title == "Mobile Form Submissions" %} - + + {% elif report.title == "SMS Usage" %} + {% else %}
{{ report.subtitle|default:" " }}
{% endif %} diff --git a/corehq/apps/enterprise/views.py b/corehq/apps/enterprise/views.py index dbeb07012e3b..8f2c9354595e 100644 --- a/corehq/apps/enterprise/views.py +++ b/corehq/apps/enterprise/views.py @@ -91,6 +91,7 @@ def platform_overview(request, domain): EnterpriseReport.MOBILE_USERS, EnterpriseReport.FORM_SUBMISSIONS, EnterpriseReport.ODATA_FEEDS, + EnterpriseReport.SMS, )], 'metric_type': 'Platform Overview', }) @@ -131,8 +132,9 @@ def security_center(request, domain): @login_and_domain_required def enterprise_dashboard_total(request, domain, slug): kwargs = {} - if slug == EnterpriseReport.FORM_SUBMISSIONS: - kwargs = get_form_submission_report_kwargs(request) + date_range_slugs = [EnterpriseReport.FORM_SUBMISSIONS, EnterpriseReport.SMS] + if slug in date_range_slugs: + kwargs = get_date_range_kwargs(request) try: report = EnterpriseReport.create(slug, request.account.id, request.couch_user, **kwargs) except TooMuchRequestedDataError as e: @@ -179,8 +181,9 @@ def _get_export_filename(request, slug): @login_and_domain_required def enterprise_dashboard_email(request, domain, slug): kwargs = {} - if slug == EnterpriseReport.FORM_SUBMISSIONS: - kwargs = get_form_submission_report_kwargs(request) + date_range_slugs = [EnterpriseReport.FORM_SUBMISSIONS, EnterpriseReport.SMS] + if slug in date_range_slugs: + kwargs = get_date_range_kwargs(request) try: report = EnterpriseReport.create(slug, request.account.id, request.couch_user, **kwargs) except TooMuchRequestedDataError as e: @@ -196,7 +199,7 @@ def enterprise_dashboard_email(request, domain, slug): return JsonResponse({'message': message}) -def get_form_submission_report_kwargs(request): +def get_date_range_kwargs(request): kwargs = {} start_date = request.GET.get('start_date') end_date = request.GET.get('end_date')