diff --git a/corehq/apps/builds/utils.py b/corehq/apps/builds/utils.py index bb946ccde9bc..1407e40dac33 100644 --- a/corehq/apps/builds/utils.py +++ b/corehq/apps/builds/utils.py @@ -90,6 +90,14 @@ def get_build_time(version, cache=None): def is_out_of_date(version_in_use, latest_version): + """ + Check if the version in use is out of date compared to the latest version. + params: + version_in_use: str, the version in use + latest_version: str, the latest version + returns: + bool, True if the version in use is out of date, False otherwise + """ version_in_use_tuple = _parse_version(version_in_use) latest_version_tuple = _parse_version(latest_version) if not version_in_use_tuple or not latest_version_tuple: diff --git a/corehq/apps/enterprise/api/api.py b/corehq/apps/enterprise/api/api.py index a0c8508b5e54..b69fca82ff7f 100644 --- a/corehq/apps/enterprise/api/api.py +++ b/corehq/apps/enterprise/api/api.py @@ -12,6 +12,7 @@ SMSResource, APIUsageResource, TwoFactorAuthResource, + ApplicationVersionComplianceResource, ) v1_api = Api(api_name='v1') @@ -26,3 +27,4 @@ v1_api.register(SMSResource()) v1_api.register(APIUsageResource()) v1_api.register(TwoFactorAuthResource()) +v1_api.register(ApplicationVersionComplianceResource()) diff --git a/corehq/apps/enterprise/api/resources.py b/corehq/apps/enterprise/api/resources.py index 29cb9a22c5b6..c5d01a75ad3b 100644 --- a/corehq/apps/enterprise/api/resources.py +++ b/corehq/apps/enterprise/api/resources.py @@ -511,3 +511,26 @@ def dehydrate(self, bundle): def get_primary_keys(self): return ('web_user', 'api_key_name',) + + +class ApplicationVersionComplianceResource(ODataEnterpriseReportResource): + mobile_worker = fields.CharField() + domain = fields.CharField() + application = fields.CharField() + latest_version_available_when_last_used = fields.CharField() + version_in_use = fields.CharField() + last_used = fields.DateTimeField() + + REPORT_SLUG = EnterpriseReport.APP_VERSION_COMPLIANCE + + def dehydrate(self, bundle): + bundle.data['mobile_worker'] = bundle.obj[0] + bundle.data['domain'] = bundle.obj[1] + bundle.data['application'] = bundle.obj[2] + bundle.data['latest_version_available_when_last_used'] = bundle.obj[3] + bundle.data['version_in_use'] = bundle.obj[4] + bundle.data['last_used'] = self.convert_datetime(bundle.obj[5]) + return bundle + + def get_primary_keys(self): + return ('mobile_worker', 'application',) diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index 1864d2238a83..af733ead8e47 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.db.models import Count, Subquery, Q +from dimagi.ext.jsonobject import DateTimeProperty from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy @@ -21,7 +22,8 @@ ) from corehq.apps.accounting.models import BillingAccount from corehq.apps.accounting.utils import get_default_domain_url -from corehq.apps.app_manager.dbaccessors import get_brief_apps_in_domain +from corehq.apps.app_manager.dbaccessors import get_app_ids_in_domain, get_brief_apps_in_domain +from corehq.apps.app_manager.models import Application from corehq.apps.builds.utils import get_latest_version_at_time, is_out_of_date from corehq.apps.builds.models import CommCareBuildConfig from corehq.apps.domain.calculations import sms_in_last @@ -31,9 +33,7 @@ TooMuchRequestedDataError, ) from corehq.apps.enterprise.iterators import raise_after_max_elements -from corehq.apps.es import forms as form_es -from corehq.apps.es import filters -from corehq.apps.es.apps import AppES +from corehq.apps.es import AppES, filters, forms as form_es from corehq.apps.es.users import UserES from corehq.apps.export.dbaccessors import ODataExportFetcher from corehq.apps.reports.util import ( @@ -60,6 +60,7 @@ class EnterpriseReport(ABC): SMS = 'sms' API_USAGE = 'api_usage' TWO_FACTOR_AUTH = '2fa' + APP_VERSION_COMPLIANCE = 'app_version_compliance' DATE_ROW_FORMAT = '%Y/%m/%d %H:%M:%S' @@ -115,6 +116,8 @@ def create(cls, slug, account_id, couch_user, **kwargs): report = EnterpriseAPIReport(account, couch_user, **kwargs) elif slug == cls.TWO_FACTOR_AUTH: report = Enterprise2FAReport(account, couch_user, **kwargs) + elif slug == cls.APP_VERSION_COMPLIANCE: + report = EnterpriseAppVersionComplianceReport(account, couch_user, **kwargs) if report: report.slug = slug @@ -617,12 +620,6 @@ def rows_for_domain(self, domain, config, cache): return rows -def _format_percentage_for_enterprise_tile(dividend, divisor): - if not divisor: - return '--' - return f"{dividend / divisor * 100:.1f}%" - - class EnterpriseSMSReport(EnterpriseReport): title = gettext_lazy('SMS Usage') total_description = gettext_lazy('# of SMS Sent') @@ -749,3 +746,136 @@ def rows_for_domain(self, domain_obj): if domain_obj.two_factor_auth: return [] return [(domain_obj.name,)] + + +class EnterpriseAppVersionComplianceReport(EnterpriseReport): + title = gettext_lazy('Application Version Compliance') + total_description = gettext_lazy('The statistic of this tile is not currently supported') + + def __init__(self, account, couch_user): + super().__init__(account, couch_user) + self.builds_by_app_id = {} + self.build_info_cache = {} + + @property + def headers(self): + return [ + _('Mobile Worker'), + _('Project Space'), + _('Application'), + _('Latest Version Available When Last Used'), + _('Version in Use'), + _('Last Used [UTC]'), + ] + + @property + def rows(self): + rows = [] + for domain in self.account.get_domains(): + rows.extend(self.rows_for_domain(domain)) + return rows + + @property + def total(self): + # Skip the stat for this report due to performance issue + return '--' + + def rows_for_domain(self, domain): + rows = [] + app_name_by_id = {} + app_ids = get_app_ids_in_domain(domain) + + for build_and_latest_version in self.all_last_builds_with_latest_version(domain, app_ids): + version_in_use = str(build_and_latest_version['build']['build_version']) + latest_version = str(build_and_latest_version['latest_version']) + if is_out_of_date(version_in_use, latest_version): + app_id = build_and_latest_version['build']['app_id'] + if app_id not in app_name_by_id: + app_name_by_id[app_id] = Application.get_db().get(app_id).get('name') + rows.append([ + build_and_latest_version['username'], + domain, + app_name_by_id[app_id], + latest_version, + version_in_use, + self.format_date( + DateTimeProperty.deserialize( + build_and_latest_version['build']['build_version_date'] + ) + ), + ]) + + return rows + + def all_last_builds_with_latest_version(self, domain, app_ids): + user_query = (UserES() + .domain(domain) + .mobile_users() + .source([ + 'username', + 'reporting_metadata.last_builds', + ])) + for user in user_query.run().hits: + last_builds = user.get('reporting_metadata', {}).get('last_builds', []) + for build in last_builds: + app_id = build.get('app_id') + build_version = build.get('build_version') + if app_id not in app_ids or not build_version: + continue + build_version_date = DateTimeProperty.deserialize(build.get('build_version_date')) + latest_version = self.get_latest_build_version(domain, app_id, build_version_date) + yield { + 'username': user['username'], + 'build': build, + 'latest_version': latest_version, + } + + def get_latest_build_version(self, domain, app_id, at_datetime): + builds = self.get_app_builds(domain, app_id) + latest_build = self._find_latest_build_version_from_builds(builds, at_datetime) + + return latest_build + + def get_app_builds(self, domain, app_id): + if app_id in self.builds_by_app_id: + return self.builds_by_app_id[app_id] + + app_es = ( + AppES() + .domain(domain) + .is_build() + .app_id(app_id) + .sort('version', desc=True) + .is_released() + .source(['_id', 'version', 'last_released', 'built_on']) + ) + self.builds_by_app_id[app_id] = app_es.run().hits + return self.builds_by_app_id[app_id] + + def _find_latest_build_version_from_builds(self, all_builds, at_datetime): + for build_doc in all_builds: + build_info = self._get_build_info(build_doc) + if build_info['last_released'] <= at_datetime: + return build_info['version'] + return None + + def _get_build_info(self, build_doc): + build_id = build_doc['_id'] + build_info = self.build_info_cache.get(build_id) + if not build_info: + # last_released is added in 2019, build before 2019 don't have this field + # TODO: have a migration to populate last_released from built_on + # Then this code can be modified to use last_released only + released_date = build_doc.get('last_released') or build_doc['built_on'] + build_info = { + 'version': build_doc['version'], + 'last_released': DateTimeProperty.deserialize(released_date) + } + self.build_info_cache[build_id] = build_info + return build_info + + +def _format_percentage_for_enterprise_tile(dividend, divisor): + if not divisor: + return '--' + return f"{dividend / divisor * 100:.1f}%" diff --git a/corehq/apps/enterprise/views.py b/corehq/apps/enterprise/views.py index 89eb0e88dff7..16f93b38e936 100644 --- a/corehq/apps/enterprise/views.py +++ b/corehq/apps/enterprise/views.py @@ -94,7 +94,8 @@ def platform_overview(request, domain): 'reports': [EnterpriseReport.create(slug, request.account.id, request.couch_user) for slug in (EnterpriseReport.WEB_USERS, EnterpriseReport.MOBILE_USERS, - EnterpriseReport.COMMCARE_VERSION_COMPLIANCE,)]}, + EnterpriseReport.COMMCARE_VERSION_COMPLIANCE, + EnterpriseReport.APP_VERSION_COMPLIANCE,)]}, {'name': _('Data Management & Export'), 'reports': [EnterpriseReport.create(slug, request.account.id, request.couch_user) for slug in (EnterpriseReport.ODATA_FEEDS,