diff --git a/corehq/apps/geospatial/views.py b/corehq/apps/geospatial/views.py index e77df0f138b4..e2a84dcdbd66 100644 --- a/corehq/apps/geospatial/views.py +++ b/corehq/apps/geospatial/views.py @@ -365,7 +365,7 @@ def __init__(self, request, domain, **kwargs): # override super class corehq.apps.reports.generic.GenericReportView init method to # avoid failures for missing expected properties for a report and keep only necessary properties self.request = request - self.request_params = json_request(self.request.GET) + self._request_params = json_request(self.request.GET) self.domain = domain def _base_query(self): diff --git a/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_migration.py b/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_migration.py index 687ab6f1d0ac..be671aba8760 100644 --- a/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_migration.py +++ b/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_migration.py @@ -10,6 +10,7 @@ get_all_javascript_paths_for_app, get_split_paths, get_short_path, + get_bootstrap5_path, ) from corehq.apps.hqwebapp.utils.management_commands import ( get_confirmation, @@ -142,7 +143,7 @@ def sanitize_bootstrap3_from_filename(self, filename): f"You specified '{filename}', which appears to be a Bootstrap 3 path!\n" f"This file cannot be marked as complete with this tool.\n\n" )) - filename = filename.replace('/bootstrap3/', '/bootstrap5/') + filename = get_bootstrap5_path(filename) confirm = get_confirmation(f"Did you mean '{filename}'?") if not confirm: self.stdout.write("Ok, aborting operation.\n\n") diff --git a/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_report.py b/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_report.py new file mode 100644 index 000000000000..e711db4da5e2 --- /dev/null +++ b/corehq/apps/hqwebapp/management/commands/complete_bootstrap5_report.py @@ -0,0 +1,168 @@ +from django.core.management import BaseCommand + +from corehq.apps.hqwebapp.utils.bootstrap.paths import get_bootstrap5_path +from corehq.apps.hqwebapp.utils.bootstrap.git import apply_commit, get_commit_string +from corehq.apps.hqwebapp.utils.bootstrap.reports.progress import ( + get_migrated_reports, + get_migrated_filters, + mark_filter_as_complete, + mark_report_as_complete, + get_migrated_filter_templates, + mark_filter_template_as_complete, +) +from corehq.apps.hqwebapp.utils.bootstrap.reports.stats import ( + get_report_class, + get_bootstrap5_reports, +) +from corehq.apps.hqwebapp.utils.management_commands import get_confirmation + + +class Command(BaseCommand): + help = "This command helps mark reports and associated filters (and their templates) as migrated." + + def add_arguments(self, parser): + parser.add_argument('report_class_name') + + def handle(self, report_class_name, **options): + self.stdout.write("\n\n") + report_class = get_report_class(report_class_name) + if report_class is None: + self.stdout.write(self.style.ERROR( + f"Could not find report {report_class_name}. Are you sure it exists?" + )) + return + + migrated_reports = get_migrated_reports() + if not self.is_safe_to_migrate_report(report_class_name, migrated_reports): + self.stdout.write(self.style.ERROR( + f"\nAborting migration of {report_class_name}...\n\n" + )) + return + + if report_class_name in migrated_reports: + self.stdout.write(self.style.WARNING( + f"The report {report_class_name} has already been marked as migrated." + )) + confirm = get_confirmation("Re-run report migration checks?", default='y') + else: + confirm = get_confirmation(f"Proceed with marking {report_class_name} as migrated?", default='y') + + if not confirm: + return + + if report_class.debug_bootstrap5: + self.stdout.write(self.style.ERROR( + f"Could not complete migration of {report_class.__name__} because " + f"`debug_bootstrap5` is still set to `True`.\n" + f"Please remove this property to continue." + )) + return + + self.migrate_report_and_filters(report_class) + self.stdout.write("\n\n") + + def is_safe_to_migrate_report(self, report_class_name, migrated_reports): + """ + Sometimes a report will be migrated that's then inherited by downstream reports. + This check ensures that this is not overlooked when marking a report as migrated, + and it's why we keep the list of intentionally migrated reports separated from + a dynamically generated one from `get_bootstrap5_reports()`. + + Either those reports must be migrated first OR they should have `use_bootstrap5 = False`, + to override the setting of the inherited report. + """ + bootstrap5_reports = set(get_bootstrap5_reports()) + intentionally_migrated_reports = set([report_class_name] + migrated_reports) + overlooked_reports = bootstrap5_reports.difference(intentionally_migrated_reports) + if not overlooked_reports: + return True + self.stdout.write(self.style.ERROR( + f"It is not safe to migrate {report_class_name}!" + )) + self.stdout.write("There are other reports that inherit from this report.") + self.stdout.write("You can either migrate these reports first OR " + "set use_bootstrap = False on them to continue migrating this report.\n\n") + self.stdout.write("\t" + "\n\t".join(overlooked_reports)) + return False + + def migrate_report_and_filters(self, report_class): + from corehq.apps.reports.generic import get_filter_class + self.stdout.write(self.style.MIGRATE_HEADING(f"\nMigrating {report_class.__name__}...")) + migrated_filters = get_migrated_filters() + migrated_filter_templates = get_migrated_filter_templates() + for field in report_class.fields: + if field not in migrated_filters: + filter_class = get_filter_class(field) + if not self.is_filter_migrated_prompts(field, filter_class, migrated_filter_templates): + return + + confirm_columns = get_confirmation( + f"Did you pass the value for use_bootstrap5 from the {report_class.__name__} to all " + f"`DataTablesColumn` instances related to that report?" + ) + if not confirm_columns: + self.stdout.write(self.style.ERROR( + f"Cannot mark {report_class.__name__} as complete until `DataTablesColumn` " + f"instances are updated." + )) + return + + confirm_sorting = get_confirmation( + f"Did you check to see if {report_class.__name__} sorting works as expected?" + ) + if not confirm_sorting: + self.stdout.write(self.style.ERROR( + f"Cannot mark {report_class.__name__} as complete until sorting is verified." + )) + return + + self.stdout.write( + self.style.SUCCESS(f"All done! {report_class.__name__} has been migrated to Bootstrap5!") + ) + mark_report_as_complete(report_class.__name__) + self.suggest_commit_message(f"Completed report: {report_class.__name__}.") + + def is_filter_migrated_prompts(self, field, filter_class, migrated_filter_templates): + if filter_class is None: + self.stdout.write(self.style.ERROR( + f"The filter {field} could not be found. Check report for errors.\n\n" + f"Did this field not show up? Check feature flags for report.\n" + )) + return False + self.stdout.write(self.style.MIGRATE_LABEL(f"\nChecking report filter: {filter_class.__name__}")) + confirm = get_confirmation( + "Did you test the filter to make sure it loads on the page without error and modifies " + "the report as expected?", default='y' + ) + if not confirm: + self.stdout.write(self.style.ERROR( + f"The filter {field} is not fully migrated yet." + )) + return False + mark_filter_as_complete(field) + + template = get_bootstrap5_path(filter_class.template) + if template not in migrated_filter_templates: + confirm = get_confirmation( + f"Did you migrate its template ({template})?", default='y' + ) + if not confirm: + self.stdout.write(self.style.ERROR( + f"The filter {field} template {template} is not fully migrated yet." + )) + return False + migrated_filter_templates.append(template) + mark_filter_template_as_complete(template) + return True + + def suggest_commit_message(self, message, show_apply_commit=False): + self.stdout.write("\nNow would be a good time to review changes with git and commit.") + if show_apply_commit: + confirm = get_confirmation("\nAutomatically commit these changes?", default='y') + if confirm: + apply_commit(message) + return + commit_string = get_commit_string(message) + self.stdout.write("\n\nSuggested command:\n") + self.stdout.write(self.style.MIGRATE_HEADING(commit_string)) + self.stdout.write("\n") diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/datatables_config.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/datatables_config.js.diff.txt index 4c3deaa70407..183af099775d 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/datatables_config.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/datatables_config.js.diff.txt @@ -1,6 +1,6 @@ --- +++ -@@ -1,340 +1,339 @@ +@@ -1,340 +1,308 @@ -hqDefine("reports/js/bootstrap3/datatables_config", [ - 'jquery', - 'underscore', @@ -33,35 +33,29 @@ - var loadingTemplate = _.template($(self.loadingTemplateSelector).html() || self.loadingText); - self.loadingText = loadingTemplate({}); +import $ from 'jquery'; ++ ++import 'datatables.net/js/jquery.dataTables'; ++import 'datatables.net-fixedcolumns/js/dataTables.fixedColumns'; ++import 'datatables.net-fixedcolumns-bs5/js/fixedColumns.bootstrap5'; ++ +import _ from 'underscore'; +import googleAnalytics from 'analytix/js/google'; +import {Tooltip} from 'bootstrap5'; + -+import 'datatables.bootstrap'; -+import 'datatables.fixedColumns'; -+ + +var HQReportDataTables = function (options) { + var self = {}; -+ self.dataTableElem = options.dataTableElem || '.datatable'; -+ self.paginationType = options.paginationType || 'bs_normal'; ++ self.dataTableElem = options.dataTableElem || '.table-hq-report'; + self.forcePageSize = options.forcePageSize || false; + self.defaultRows = options.defaultRows || 10; -+ self.startAtRowNum = options.startAtRowNum || 0; + self.showAllRowsOption = options.showAllRowsOption || false; -+ self.aoColumns = options.aoColumns; ++ self.columns = options.aoColumns; // todo eventually rename aoColumns outside of file + self.autoWidth = (options.autoWidth !== undefined) ? options.autoWidth : true; + self.defaultSort = (options.defaultSort !== undefined) ? options.defaultSort : true; + self.customSort = options.customSort || null; + self.ajaxParams = options.ajaxParams || {}; + self.ajaxSource = options.ajaxSource; -+ self.ajaxMethod = options.ajaxMethod || 'GET'; -+ self.loadingText = options.loadingText || " " + gettext("Loading"); -+ self.loadingTemplateSelector = options.loadingTemplateSelector; -+ if (self.loadingTemplateSelector !== undefined) { -+ var loadingTemplate = _.template($(self.loadingTemplateSelector).html() || self.loadingText); -+ self.loadingText = loadingTemplate({}); -+ } ++ self.loadingText = options.loadingText || gettext("Loading"); + self.emptyText = options.emptyText || gettext("No data available to display. " + + "Please try changing your filters."); + self.errorText = options.errorText || "" + gettext("Sorry!") + " " + @@ -73,7 +67,7 @@ + self.fixColsNumLeft = options.fixColsNumLeft || 1; + self.fixColsWidth = options.fixColsWidth || 100; + self.show_pagination = (options.show_pagination === undefined) ? true : options.bPaginate; -+ self.aaSorting = options.aaSorting || null; ++ self.order = options.aaSorting || null; // todo eventually rename aaSorting outside of file + // a list of functions to call back to after ajax. + // see user configurable charts for an example usage + self.successCallbacks = options.successCallbacks; @@ -393,129 +387,125 @@ - return 1; + applyBootstrapMagic(); + -+ var dataTablesDom = "frt<'row dataTables_control'<'col-sm-5'il><'col-sm-7 text-right'p>>"; ++ var dataTablesDom = "frt<'d-flex mb-1'<'p-2 ps-3'i><'p-2 ps-0'l><'ms-auto p-2 pe-3'p>>"; + $(self.dataTableElem).each(function () { -+ var params = { -+ sDom: dataTablesDom, -+ bPaginate: self.show_pagination, -+ sPaginationType: self.paginationType, -+ iDisplayLength: self.defaultRows, -+ bAutoWidth: self.autoWidth, -+ sScrollX: "100%", -+ bSort: self.defaultSort, -+ bFilter: self.includeFilter, ++ const params = { ++ dom: dataTablesDom, ++ filter: false, ++ paging: self.show_pagination, ++ pageLength: self.defaultRows, ++ autoWidth: self.autoWidth, ++ scrollX: "100%", ++ ordering: self.defaultSort, ++ searching: self.includeFilter, + }; -+ if (self.aaSorting !== null || self.customSort !== null) { -+ params.aaSorting = self.aaSorting || self.customSort; ++ if (self.order !== null || self.customSort !== null) { ++ params.order = self.order || self.customSort; + } + + if (self.ajaxSource) { -+ params.bServerSide = true; -+ params.bProcessing = true; -+ params.sAjaxSource = { ++ params.serverSide = true; ++ params.processing = true; ++ params.ajax = { + url: self.ajaxSource, -+ method: self.ajaxMethod, ++ method: 'POST', ++ data: function (data) { ++ // modify the query sent to server to include HQ Filters ++ self.addHqFiltersToServerSideQuery(data); ++ }, ++ error: function (jqXHR, statusText, errorThrown) { ++ $(".dataTables_processing").hide(); ++ if (jqXHR.status === 400) { ++ let errorMessage = self.badRequestErrorText; ++ if (jqXHR.responseText) { ++ errorMessage = "

" + gettext("Sorry!") + " " + jqXHR.responseText + "

"; ++ } ++ $(".dataTables_empty").html(errorMessage); ++ } else { ++ $(".dataTables_empty").html(self.errorText); ++ } ++ $(".dataTables_empty").show(); ++ if (self.errorCallbacks) { ++ for (let i = 0; i < self.errorCallbacks.length; i++) { ++ self.errorCallbacks[i](jqXHR, statusText, errorThrown); ++ } ++ } ++ }, + }; -+ params.bFilter = $(this).data('filter') || false; -+ self.fmtParams = function (defParams) { -+ var ajaxParams = $.isFunction(self.ajaxParams) ? self.ajaxParams() : self.ajaxParams; -+ for (var p in ajaxParams) { ++ params.searching = $(this).data('filter') || false; ++ self.addHqFiltersToServerSideQuery = function (data) { ++ let ajaxParams = $.isFunction(self.ajaxParams) ? self.ajaxParams() : self.ajaxParams; ++ data.hq = {}; ++ for (let p in ajaxParams) { + if (_.has(ajaxParams, p)) { -+ var currentParam = ajaxParams[p]; -+ if (_.isObject(currentParam.value)) { -+ for (var j = 0; j < currentParam.value.length; j++) { -+ defParams.push({ -+ name: currentParam.name, -+ value: currentParam.value[j], -+ }); -+ } -+ } else { -+ defParams.push(currentParam); -+ } ++ let param = ajaxParams[p]; ++ data.hq[param.name] = _.isArray(param.value) ? _.uniq(param.value) : param.value; + } + } -+ return defParams; ++ return data; + }; -+ params.fnServerData = function (sSource, aoData, fnCallback, oSettings) { -+ var customCallback = function (data) { -+ if (data.warning) { -+ throw new Error(data.warning); -+ } -+ var result = fnCallback(data); // this must be called first because datatables clears the tfoot of the table -+ var i; -+ if ('total_row' in data) { -+ self.render_footer_row('ajax_total_row', data['total_row']); -+ } -+ if ('statistics_rows' in data) { -+ for (i = 0; i < data.statistics_rows.length; i++) { -+ self.render_footer_row('ajax_stat_row-' + i, data.statistics_rows[i]); -+ } ++ ++ params.footerCallback = function (row, data, start, end, display) { ++ if ('total_row' in data) { ++ self.render_footer_row('ajax_total_row', data['total_row']); ++ } ++ if ('statistics_rows' in data) { ++ for (let i = 0; i < data.statistics_rows.length; i++) { ++ self.render_footer_row('ajax_stat_row-' + i, data.statistics_rows[i]); + } -+ applyBootstrapMagic(); -+ if (self.successCallbacks) { -+ for (i = 0; i < self.successCallbacks.length; i++) { -+ self.successCallbacks[i](data); -+ } ++ } ++ }; ++ ++ params.drawCallback = function () { ++ let api = this.api(), ++ data = api.ajax.json(); ++ ++ if (data.warning) { ++ throw new Error(data.warning); ++ } ++ applyBootstrapMagic(); ++ if ('context' in data) { ++ let iconPath = data['icon_path'] || $(".base-maps-data").data("icon_path"); ++ hqRequire(["reports/js/bootstrap5/maps_utils"], function (mapsUtils) { ++ mapsUtils.load(data['context'], iconPath); ++ }); ++ } ++ if (self.successCallbacks) { ++ for (let i = 0; i < self.successCallbacks.length; i++) { ++ self.successCallbacks[i](data); + } -+ return result; -+ }; -+ oSettings.jqXHR = $.ajax({ -+ "url": sSource.url, -+ "method": sSource.method, -+ "data": self.fmtParams(aoData), -+ "success": customCallback, -+ "error": function (jqXHR, textStatus, errorThrown) { -+ $(".dataTables_processing").hide(); -+ if (jqXHR.status === 400) { -+ var errorMessage = self.badRequestErrorText; -+ if (jqXHR.responseText) { -+ errorMessage = "

" + gettext("Sorry!") + " " + jqXHR.responseText + "

"; -+ } -+ $(".dataTables_empty").html(errorMessage); -+ } else { -+ $(".dataTables_empty").html(self.errorText); -+ } -+ $(".dataTables_empty").show(); -+ if (self.errorCallbacks) { -+ for (var i = 0; i < self.errorCallbacks.length; i++) { -+ self.errorCallbacks[i](jqXHR, textStatus, errorThrown); -+ } -+ } -+ }, -+ }); ++ } + }; + } -+ params.oLanguage = { -+ sProcessing: self.loadingText, -+ sLoadingRecords: self.loadingText, -+ sZeroRecords: self.emptyText, -+ }; -+ -+ params.fnDrawCallback = function (a,b,c) { -+ /* be able to set fnDrawCallback from outside here later */ -+ if (self.fnDrawCallback) { -+ self.fnDrawCallback(a,b,c); -+ } ++ params.language = { ++ lengthMenu: gettext("_MENU_ per page"), ++ processing: self.loadingText, ++ loadingRecords: self.loadingText, ++ zeroRecords: self.emptyText, + }; + -+ if (self.aoColumns) { -+ params.aoColumns = self.aoColumns; ++ if (self.columns) { ++ params.columns = self.columns; + } + + if (self.forcePageSize) { + // limit the page size option to just the default size + params.lengthMenu = [self.defaultRows]; + } -+ var datatable = $(this).dataTable(params); -+ if (!self.datatable) { -+ self.datatable = datatable; -+ } + + if (self.fixColumns) { + new $.fn.dataTable.FixedColumns(datatable, { + iLeftColumns: self.fixColsNumLeft, + iLeftWidth: self.fixColsWidth, + }); ++ params.fixedColumns = { ++ left: self.fixColsNumLeft, ++ width: self.fixColsWidth, ++ }; ++ } ++ let datatable = $(this).dataTable(params); ++ if (!self.datatable) { ++ self.datatable = datatable; + } + + // This fixes a display bug in some browsers where the pagination @@ -552,19 +542,12 @@ + $inputLabel.html($('').addClass("icon-search")); + } + -+ var $dataTablesLength = $(self.dataTableElem).parents('.dataTables_wrapper').find(".dataTables_length"), -+ $dataTablesInfo = $(self.dataTableElem).parents('.dataTables_wrapper').find(".dataTables_info"); -+ if ($dataTablesLength && $dataTablesInfo) { -+ var $selectField = $dataTablesLength.find("select"), -+ $selectLabel = $dataTablesLength.find("label"); -+ -+ $dataTablesLength.append($selectField); -+ $selectLabel.remove(); -+ $selectField.children().append(" per page"); ++ let $dataTablesLength = $(self.dataTableElem).parents('.dataTables_wrapper').find(".dataTables_length"); ++ if ($dataTablesLength) { ++ let $selectField = $dataTablesLength.find("select"); + if (self.showAllRowsOption) { + $selectField.append($('