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

[B5] Support for migrating report views to Bootstrap 5 #35648

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5559228
add utils for bootstrap 5 paths
biyeun May 13, 2024
d88dce2
add ability to set a report to use_bootstrap5
biyeun May 13, 2024
dc75863
pass use_bootstrap5 status to filters so the right template is chosen
biyeun May 13, 2024
8eebc73
create utility to get filter class individually
biyeun Jan 21, 2025
1920056
B5: add report debug tools and way to track completion
biyeun May 13, 2024
6a1e31e
update bootstrap 5 migration guide for migrating report views
biyeun May 13, 2024
aed103c
datatables_config (B5) - update how datatables gets imported
biyeun Jan 20, 2025
526871e
update datatables_config (B5) to work with datatables 1.10+
biyeun Jan 20, 2025
fd531a8
loading template no longer supported in datatables 1.10+
biyeun Jan 20, 2025
1b6784a
add todo about custom sort for future investigation
biyeun Jan 20, 2025
ea5459f
update how reports handles datatables server side params after 1.10
biyeun Jan 8, 2025
27a510b
update the data returned by reports view to datatables
biyeun May 13, 2024
b194f34
update how sorting block in generated for datatables > 1.10
biyeun May 13, 2024
67edabf
update request parameter fetching for reports using Datatables 1.10+
biyeun Jan 13, 2025
531232d
replace hide css class with d-none
biyeun Jan 8, 2025
2592342
fix Popover initialization
biyeun Jan 8, 2025
64acb67
update bootstrap5 tooltip
biyeun Jan 9, 2025
9d81717
update bootstrap5 plugins
biyeun Jan 9, 2025
bb6b277
update tooltip bootstrap5 component usage
biyeun Jan 9, 2025
bef4052
update bootstrap3 path refs in bootstrap5 files
biyeun Jan 9, 2025
97f1717
update field and form group html
biyeun Jan 10, 2025
7b7bdff
update stateful button usage
biyeun Jan 10, 2025
c54b538
fix popover usage
biyeun Jan 20, 2025
a65804f
update visible class/state for filter accordion on B5 pages
biyeun Jan 20, 2025
5564c33
update label to badge (bootstrap 5) in js usage
biyeun Jan 20, 2025
aead15e
translate
biyeun Jan 20, 2025
a337c15
update formatting and migrate core report templates to bootstrap5
biyeun Jan 10, 2025
d1323b1
have email report form support usage with bootstrap 5 pages
biyeun Jan 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion corehq/apps/geospatial/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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(field, filter_class, migrated_filter_templates):
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(self, field, filter_class, migrated_filter_templates):
Copy link
Contributor

Choose a reason for hiding this comment

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

A more descriptive name, like flag_unmigrated_filter or is_filter_migrated_prompt?

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")
25 changes: 24 additions & 1 deletion corehq/apps/hqwebapp/tests/utils/test_bootstrap_paths.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from testil import eq
from corehq.apps.hqwebapp.utils.bootstrap.paths import is_ignored_path
from corehq.apps.hqwebapp.utils.bootstrap.paths import (
is_ignored_path,
get_bootstrap5_path,
is_bootstrap3_path,
)


def test_is_ignored_path_true():
Expand All @@ -12,3 +16,22 @@ def test_is_ignored_path_false():
path = "/path/to/corehq/apps/builds/templates/builds/base_builds.html"
app_name = "builds"
eq(is_ignored_path(app_name, path), False)


def test_get_bootstrap5_path():
bootstrap3_path = "reports/bootstrap3/base_template.html"
bootstrap5_path = "reports/bootstrap5/base_template.html"
eq(get_bootstrap5_path(bootstrap3_path), bootstrap5_path)


def test_get_bootstrap5_path_none():
eq(get_bootstrap5_path(None), None)


def test_is_bootstrap3_path():
bootstrap3_path = "reports/bootstrap3/base_template.html"
eq(is_bootstrap3_path(bootstrap3_path), True)


def test_is_bootstrap3_path_false_with_none():
eq(is_bootstrap3_path(None), False)
12 changes: 12 additions & 0 deletions corehq/apps/hqwebapp/utils/bootstrap/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ def is_bootstrap5_path(path):
return '/bootstrap5/' in str(path)


def is_bootstrap3_path(path):
if path is None:
return False
return '/bootstrap3/' in path


def is_mocha_path(path):
return str(path).endswith('mocha.html')

Expand Down Expand Up @@ -125,3 +131,9 @@ def get_split_folders(paths, include_root=False):
path.replace(str(COREHQ_BASE_DIR), '') for path in split_folders
}
return split_folders


def get_bootstrap5_path(bootstrap3_path):
if bootstrap3_path is None:
return None
return bootstrap3_path.replace('/bootstrap3/', '/bootstrap5/')
Empty file.
93 changes: 93 additions & 0 deletions corehq/apps/hqwebapp/utils/bootstrap/reports/debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from corehq.apps.hqwebapp.utils.bootstrap.paths import is_bootstrap3_path, get_bootstrap5_path
from corehq.apps.hqwebapp.utils.bootstrap.reports.progress import (
get_migrated_filters,
get_migrated_filter_templates,
)

REPORT_TEMPLATE_PROPERTIES = [
('template_base', 'base_template'),
('template_async_base', 'base_template_async'),
('template_report', 'report_template_path'),
('template_report_partial', 'report_partial_path'),
('template_filters', 'base_template_filters'),
]
COMMON_REPORT_TEMPLATES = [
"reports/async/bootstrap3/default.html",
"reports/bootstrap3/base_template.html",
"reports/standard/bootstrap3/base_template.html",
"reports/bootstrap3/tabular.html",
]


class Color:
CYAN = '\033[96m'
GREEN = '\033[92m'
WARNING = '\033[93m'
BOLD = '\033[1m'
ENDC = '\033[0m'


def _name(instance):
return instance.__class__.__name__


def reports_bootstrap5_template_debugger(report_instance):
Copy link
Member Author

Choose a reason for hiding this comment

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

example output of what it looks like in the console:

Screenshot 2025-01-21 at 3 25 59 PM
Screenshot 2025-01-21 at 3 24 51 PM

has_issues = False
print(f"\n\n{Color.CYAN}{Color.BOLD}DEBUGGING Bootstrap 5 in "
f"{_name(report_instance)} Report{Color.ENDC}\n")

print(f"{Color.BOLD}Checking for report template issues...{Color.ENDC}")
for class_property, class_variable in REPORT_TEMPLATE_PROPERTIES:
property_issues = show_report_property_issues(report_instance, class_property)
variable_issues = show_report_class_variable_issues(report_instance, class_variable)
has_issues = has_issues or property_issues or variable_issues

if not has_issues:
print(f"\n{Color.GREEN}{Color.BOLD}Did not find any issues!{Color.ENDC}\n")

show_report_filters_templates(report_instance)
print("When migration is complete, remember to run:\n")
print(f"{Color.BOLD}manage.py complete_bootstrap5_report {_name(report_instance)}{Color.ENDC}\n\n\n\n")


def show_report_property_issues(report_instance, report_property):
report_property_value = getattr(report_instance, report_property)
if is_bootstrap3_path(report_property_value):
print(f"\n{Color.WARNING}{Color.BOLD}def {report_property}"
f"\nreturns {report_property_value}{Color.ENDC}")
print(f"\nCheck if any overrides of {Color.BOLD}{report_property}{Color.ENDC} "
f"in {Color.BOLD}{_name(report_instance)}{Color.ENDC} return a bootstrap3 template.\n\n")
return True
return False


def show_report_class_variable_issues(report_instance, related_variable):
related_variable_value = getattr(report_instance, related_variable)
if (is_bootstrap3_path(related_variable_value)
and related_variable_value not in COMMON_REPORT_TEMPLATES):
print(f"\n{Color.WARNING}{Color.BOLD}{related_variable} "
f"= {related_variable_value}{Color.ENDC}")
print(f"\nEnsure that {Color.BOLD}{_name(report_instance)}.{related_variable}{Color.ENDC} "
f"is not assigned to a bootstrap3 template.\n\n")
return True
return False


def show_report_filters_templates(report_instance):
from corehq.apps.reports.generic import get_filter_class
print(f"\n{Color.BOLD}Checking for un-migrated report filters and templates:{Color.ENDC}\n")
has_pending_migrations = False
migrated_filters = get_migrated_filters()
migrated_filter_templates = get_migrated_filter_templates()
for field in report_instance.fields:
if field not in migrated_filters:
filter_class = get_filter_class(field)
filter_template = get_bootstrap5_path(filter_class.template)
print(f"{Color.WARNING}{Color.BOLD}{filter_class.__name__}{Color.ENDC}")
if filter_template not in migrated_filter_templates:
print(f"\t{filter_template}\n")
has_pending_migrations = True

if not has_pending_migrations:
print(f"{Color.GREEN}{Color.BOLD}No migrations needed!{Color.ENDC}\n")
print("\n\n")
58 changes: 58 additions & 0 deletions corehq/apps/hqwebapp/utils/bootstrap/reports/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import json

from corehq.apps.hqwebapp.utils.bootstrap.paths import COREHQ_BASE_DIR

PATH_TO_PROGRESS_NOTES = 'apps/hqwebapp/utils/bootstrap/reports/progress'


def get_progress_file_path():
return COREHQ_BASE_DIR / PATH_TO_PROGRESS_NOTES / "bootstrap3_to_5.json"


def get_progress_data():
with open(get_progress_file_path(), 'r') as f:
return json.loads(f.read())


def update_progress_data(data):
for key in data.keys():
data[key] = sorted(data[key])
data_string = json.dumps(data, indent=2)
with open(get_progress_file_path(), "w") as f:
f.writelines(data_string + '\n')


def _mark_as_complete(item, category):
progres_data = get_progress_data()
if item not in progres_data[category]:
progres_data[category].append(item)
update_progress_data(progres_data)


def _get_category_list(category):
progres_data = get_progress_data()
return progres_data[category]


def mark_report_as_complete(report_name):
_mark_as_complete(report_name, "reports")


def mark_filter_as_complete(filter_path):
_mark_as_complete(filter_path, "filters")


def mark_filter_template_as_complete(filter_template):
_mark_as_complete(filter_template, "filter_templates")


def get_migrated_reports():
return _get_category_list("reports")


def get_migrated_filters():
return _get_category_list("filters")


def get_migrated_filter_templates():
return _get_category_list("filter_templates")
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"reports": [],
"filters": [],
"filter_templates": []
}
Loading
Loading