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())
39 changes: 39 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,44 @@ 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 = request.GET.get('startdate', None)
Copy link
Contributor

Choose a reason for hiding this comment

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

util function for 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.

if start_date:
start_date = str(datetime.fromisoformat(start_date).astimezone(timezone.utc))

end_date = request.GET.get('enddate', None)
if end_date:
end_date = str(datetime.fromisoformat(end_date).astimezone(timezone.utc))

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',)


class ODataFeedResource(ODataEnterpriseReportResource):
'''
A Resource for listing all Domain-level OData feeds which belong to the Enterprise.
Expand Down
61 changes: 61 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,59 @@ def _get_individual_export_rows(self, exports, export_line_counts):
)

return rows


class EnterpriseSMSReport(EnterpriseReport):
title = gettext_lazy('SMS Sent')
Copy link
Contributor

Choose a reason for hiding this comment

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

Change the title to align with the design doc

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in fc4fccd

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,
direction=OUTGOING,
date__gte=self.datespan.startdate,
date__lt=self.datespan.enddate_adjusted
)

return query.count()

@property
def headers(self):
headers = [_('Project Space Name'), _('# Sent'), _('# Received'), _('# Errors')]
Copy link
Contributor

Choose a reason for hiding this comment

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

Simplify the header to be Project Space

Copy link
Contributor Author

Choose a reason for hiding this comment

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


return headers

def rows_for_domain(self, domain_obj):
results = SMS.objects.filter(domain=domain_obj.name) \
.values('direction', 'error').annotate(direction_count=Count('pk'))

num_sent = sum([result['direction_count'] for result in results if result['direction'] == OUTGOING])
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we only include sms that is succesfully sent and received? If we don't charge them, then we should not include errored sms in num_sent and num_received

Copy link
Contributor

Choose a reason for hiding this comment

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

total_for_domain should also align with the number in report, when people sum up num_sent for all domains

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 1adcaf3

num_received = sum([result['direction_count'] for result in results if result['direction'] == INCOMING])
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 Sent" %}
<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