Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Enterprise SMS Report #35437

Merged
merged 11 commits into from
Dec 4, 2024
2 changes: 2 additions & 0 deletions corehq/apps/enterprise/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
MobileUserResource,
ODataFeedResource,
WebUserResource,
SMSResource,
)

v1_api = Api(api_name='v1')
Expand All @@ -14,3 +15,4 @@
v1_api.register(MobileUserResource())
v1_api.register(FormSubmissionResource())
v1_api.register(ODataFeedResource())
v1_api.register(SMSResource())
45 changes: 45 additions & 0 deletions corehq/apps/enterprise/api/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions corehq/apps/enterprise/enterprise.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
from django.db.models import Count
from datetime import datetime, timedelta

from django.utils.translation import gettext as _
Expand All @@ -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,
Expand All @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make it as a util function so other tiles can reuse it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I did this in the iterators PR, which is still in review. Re-adding it here would cause conflicts, so I'm going to wait until that PR is merged for this functionality

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,
jingcheng16 marked this conversation as resolved.
Show resolved Hide resolved
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), ]
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,47 @@ hqDefine("enterprise/js/enterprise_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);
Expand Down Expand Up @@ -198,12 +238,21 @@ hqDefine("enterprise/js/enterprise_dashboard", [
moment()
);

const $dateRangeModal = $('#enterpriseFormsDaterange');

const formSubmissionsDisplay = MobileFormSubmissionsTile(datePicker);
const smsDisplay = SMSTile(datePicker);
const maxDateRangeDays = initialPageData.get("max_date_range_days");
const dateRangeModal = DateRangeModal(datePicker, dateRangePresetOptions, maxDateRangeDays, formSubmissionsDisplay);

$("#dateRangeDisplay").koApplyBindings(formSubmissionsDisplay);
$("#enterpriseFormsDaterange").koApplyBindings(
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
);

Expand Down Expand Up @@ -233,7 +282,9 @@ hqDefine("enterprise/js/enterprise_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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
<div class="card-header">
<div class="fs-4">{{ report.title }}</div>
{% if report.title == "Mobile Form Submissions" %}
<button id="dateRangeDisplay" type="button" data-bind="text: presetText" data-bs-toggle="modal" data-bs-target="#enterpriseFormsDaterange" class="btn btn-link fs-6">&nbsp;</button>
<button id="form_submission_dateRangeDisplay" type="button" data-bind="text: presetText" data-bs-toggle="modal" data-bs-target="#enterpriseFormsDaterange" data-sender="form_submission" class="btn btn-link fs-6">&nbsp;</button>
{% elif report.title == "SMS Usage" %}
<button id="sms_dateRangeDisplay" type="button" data-bind="text: presetText" data-bs-toggle="modal" data-bs-target="#enterpriseFormsDaterange" data-sender="sms" class="btn btn-link fs-6">&nbsp;</button>
{% else %}
<div class="form-control-plaintext fs-6">{{ report.subtitle|default:"&nbsp;" }}</div>
{% endif %}
Expand Down
13 changes: 8 additions & 5 deletions corehq/apps/enterprise/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def enterprise_dashboard(request, domain):
EnterpriseReport.MOBILE_USERS,
EnterpriseReport.FORM_SUBMISSIONS,
EnterpriseReport.ODATA_FEEDS,
EnterpriseReport.SMS,
)],
'current_page': {
'page_name': _('Enterprise Dashboard'),
Expand All @@ -93,8 +94,9 @@ def enterprise_dashboard(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:
Expand Down Expand Up @@ -141,8 +143,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:
Expand All @@ -158,7 +161,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')
Expand Down
Loading