diff --git a/corehq/apps/accounting/bootstrap/utils.py b/corehq/apps/accounting/bootstrap/utils.py index 3bea07c4f521..64e56f64a361 100644 --- a/corehq/apps/accounting/bootstrap/utils.py +++ b/corehq/apps/accounting/bootstrap/utils.py @@ -171,8 +171,9 @@ def _ensure_software_plan(plan_key, product, product_rate, verbose, apps): plan_opts = { 'name': plan_name, 'edition': plan_key.edition, - 'visibility': (SoftwarePlanVisibility.ANNUAL - if plan_key.is_annual_plan else SoftwarePlanVisibility.PUBLIC), + 'visibility': (SoftwarePlanVisibility.INTERNAL + if plan_key.edition == SoftwarePlanEdition.ENTERPRISE + else SoftwarePlanVisibility.PUBLIC), } if plan_key.is_annual_plan is not None: plan_opts['is_annual_plan'] = plan_key.is_annual_plan diff --git a/corehq/apps/accounting/migrations/0095_update_softwareplan_visibilities.py b/corehq/apps/accounting/migrations/0095_update_softwareplan_visibilities.py new file mode 100644 index 000000000000..c05fdc5c6938 --- /dev/null +++ b/corehq/apps/accounting/migrations/0095_update_softwareplan_visibilities.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from django.db import migrations, models + +from corehq.apps.accounting.models import SoftwarePlanVisibility + +ANNUAL = "ANNUAL" + + +def change_plan_visibilities(apps, schema_editor): + # one-time cleanup of existing software plans + SoftwarePlan = apps.get_model('accounting', 'SoftwarePlan') + + enterprise_names = ["Dimagi Only CommCare Enterprise Edition"] + enterprise_plans = SoftwarePlan.objects.filter(name__in=enterprise_names) + enterprise_plans.update(visibility=SoftwarePlanVisibility.INTERNAL, last_modified=datetime.now()) + + annual_plans = SoftwarePlan.objects.filter(visibility=ANNUAL) + annual_plans.update(visibility=SoftwarePlanVisibility.PUBLIC, last_modified=datetime.now()) + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounting", "0094_add_annual_softwareplans"), + ] + + operations = [ + migrations.RunPython(change_plan_visibilities), + migrations.AlterField( + model_name="softwareplan", + name="visibility", + field=models.CharField( + choices=[ + ("PUBLIC", "PUBLIC - Anyone can subscribe"), + ("INTERNAL", "INTERNAL - Dimagi must create subscription"), + ("TRIAL", "TRIAL- This is a Trial Plan"), + ("ARCHIVED", "ARCHIVED - hidden from subscription change forms"), + ], + default="INTERNAL", + max_length=10, + ), + ), + ] diff --git a/corehq/apps/accounting/models.py b/corehq/apps/accounting/models.py index 131b454eb6e6..df5296bd3d0d 100644 --- a/corehq/apps/accounting/models.py +++ b/corehq/apps/accounting/models.py @@ -171,14 +171,12 @@ class SoftwarePlanVisibility(object): PUBLIC = "PUBLIC" INTERNAL = "INTERNAL" TRIAL = "TRIAL" - ANNUAL = "ANNUAL" ARCHIVED = "ARCHIVED" CHOICES = ( (PUBLIC, "PUBLIC - Anyone can subscribe"), (INTERNAL, "INTERNAL - Dimagi must create subscription"), (TRIAL, "TRIAL- This is a Trial Plan"), (ARCHIVED, "ARCHIVED - hidden from subscription change forms"), - (ANNUAL, "ANNUAL - public plans that on annual pricing"), ) diff --git a/corehq/apps/accounting/tests/test_ensure_plans.py b/corehq/apps/accounting/tests/test_ensure_plans.py index d1af692a00ab..2ceef0f6d4ad 100644 --- a/corehq/apps/accounting/tests/test_ensure_plans.py +++ b/corehq/apps/accounting/tests/test_ensure_plans.py @@ -104,7 +104,8 @@ def _test_plan_versions_ensured(self, bootstrap_config): ) self.assertEqual(sms_feature_rate.per_excess_fee, 0) - expected_visibility = (SoftwarePlanVisibility.ANNUAL - if is_annual_plan else SoftwarePlanVisibility.PUBLIC) + expected_visibility = (SoftwarePlanVisibility.INTERNAL + if edition == SoftwarePlanEdition.ENTERPRISE + else SoftwarePlanVisibility.PUBLIC) self.assertEqual(software_plan_version.plan.visibility, expected_visibility) self.assertEqual(software_plan_version.plan.is_annual_plan, is_annual_plan) diff --git a/corehq/apps/api/resources/__init__.py b/corehq/apps/api/resources/__init__.py index 65ce4e44a099..2e1e1d2b0e4a 100644 --- a/corehq/apps/api/resources/__init__.py +++ b/corehq/apps/api/resources/__init__.py @@ -1,5 +1,6 @@ import json +from django.core.exceptions import ValidationError from django.http import HttpResponse from django.urls import NoReverseMatch @@ -114,6 +115,22 @@ def dispatch(self, request_type, request, **kwargs): content_type="application/json", status=401)) + def alter_deserialized_detail_data(self, request, data): + """Provide a hook for data validation + + Subclasses may implement ``validate_deserialized_data`` that + raises ``django.core.exceptions.ValidationError`` if the submitted + data is not valid. This is designed to work conveniently with + ``corehq.util.validation.JSONSchemaValidator``. + """ + data = super().alter_deserialized_detail_data(request, data) + if hasattr(self, "validate_deserialized_data"): + try: + self.validate_deserialized_data(data) + except ValidationError as error: + raise ImmediateHttpResponse(self.error_response(request, error.messages)) + return data + def get_required_privilege(self): return privileges.API_ACCESS diff --git a/corehq/apps/api/tests/lookup_table_resources.py b/corehq/apps/api/tests/lookup_table_resources.py index 2a36032d3e1f..11a97ddfb3c2 100644 --- a/corehq/apps/api/tests/lookup_table_resources.py +++ b/corehq/apps/api/tests/lookup_table_resources.py @@ -237,6 +237,35 @@ def test_update(self): self.assertEqual(data_type.fields[0].properties, ['lang', 'name']) self.assertEqual(data_type.item_attributes, ['X']) + def test_update_field_name(self): + lookup_table = { + "fields": [{"name": "property", "properties": ["value"]}], + "tag": "lookup_table", + } + + response = self._assert_auth_post_resource( + self.single_endpoint(self.data_type.id), json.dumps(lookup_table), method="PUT") + print(response.content) # for debugging errors + data_type = LookupTable.objects.get(id=self.data_type.id) + self.assertEqual(data_type.fields[0].field_name, 'property') + + def test_update_fails_with_two_field_names(self): + lookup_table = { + "fields": [{"name": "property", "field_name": "prop"}], + "tag": "lookup_table", + } + + response = self._assert_auth_post_resource( + self.single_endpoint(self.data_type.id), json.dumps(lookup_table), method="PUT") + self.assertEqual(response.status_code, 400) + errors = json.loads(response.content.decode("utf-8")) + print(errors) + self.assertIn("Failed validating 'not' in schema", errors[0]) + self.assertIn("{'not': {'required': ['field_name']}}", errors[0]) + self.assertIn("Failed validating 'not' in schema", errors[1]) + self.assertIn("{'not': {'required': ['name']}}", errors[1]) + self.assertEqual(len(errors), 2) + class TestLookupTableItemResourceV06(APIResourceTest): resource = LookupTableItemResource @@ -328,6 +357,58 @@ def test_update(self): 'cool_attr_value' ) + def test_create_with_bad_properties(self): + data_item_json = self._get_data_item_create() + data_item_json["fields"]["state_name"]["field_list"][0]["properties"] = [] + response = self._assert_auth_post_resource( + self.list_endpoint, + json.dumps(data_item_json), + content_type='application/json', + ) + self.assertEqual(response.status_code, 400) + errors = json.loads(response.content.decode("utf-8")) + print(errors) + self.assertIn("[] is not of type 'object':", errors[0]) + data_item = LookupTableRow.objects.filter(domain=self.domain.name).first() + self.assertIsNone(data_item) + + def test_update_field_value(self): + data_item = self._create_data_item() + data_item_update = self._get_data_item_update() + data_item_update["fields"]["state_name"]["field_list"][0] = { + "value": "Mass.", + "properties": {"lang": "en"}, + } + response = self._assert_auth_post_resource( + self.single_endpoint(data_item.id.hex), + json.dumps(data_item_update), + method="PUT", + ) + print(response.content) # for debugging errors + row = LookupTableRow.objects.filter(domain=self.domain.name).first() + self.assertEqual(row.fields["state_name"][0].value, 'Mass.') + + def test_update_fails_with_two_field_values(self): + data_item = self._create_data_item() + data_item_update = self._get_data_item_update() + data_item_update["fields"]["state_name"]["field_list"][0] = { + "value": "Mass.", + "field_value": "Mass...", + } + response = self._assert_auth_post_resource( + self.single_endpoint(data_item.id.hex), + json.dumps(data_item_update), + method="PUT", + ) + self.assertEqual(response.status_code, 400) + errors = json.loads(response.content.decode("utf-8")) + print(errors) + self.assertIn("Failed validating 'not' in schema", errors[0]) + self.assertIn("{'not': {'required': ['field_value']}}", errors[0]) + self.assertIn("Failed validating 'not' in schema", errors[1]) + self.assertIn("{'not': {'required': ['value']}}", errors[1]) + self.assertEqual(len(errors), 2) + class TestLookupTableItemResourceV05(TestLookupTableItemResourceV06): resource = LookupTableItemResource diff --git a/corehq/apps/app_execution/db_accessors.py b/corehq/apps/app_execution/db_accessors.py index de9ea05a9a5b..b23e281868ee 100644 --- a/corehq/apps/app_execution/db_accessors.py +++ b/corehq/apps/app_execution/db_accessors.py @@ -1,4 +1,7 @@ -from django.db.models import Avg, DateTimeField, DurationField, ExpressionWrapper, F, Max +from collections import defaultdict +from datetime import timedelta + +from django.db.models import Avg, Count, DateTimeField, DurationField, ExpressionWrapper, F, Max from django.db.models.functions import Trunc from corehq.apps.app_execution.models import AppExecutionLog, AppWorkflowConfig @@ -16,28 +19,77 @@ def get_avg_duration_data(domain, start, end, workflow_id=None): ).values("date", "workflow_id") .annotate(avg_duration=Avg('duration')) .annotate(max_duration=Max('duration')) - .order_by("workflow_id", "date") ) - data = [] - seen_workflows = set() + data = defaultdict(list) + seen_dates = defaultdict(set) for row in chart_logs: - if row["workflow_id"] not in seen_workflows: - seen_workflows.add(row["workflow_id"]) - data.append({ - "key": row["workflow_id"], - "values": [] - }) - data[-1]["values"].append({ + data[row["workflow_id"]].append({ "date": row["date"].isoformat(), "avg_duration": row["avg_duration"].total_seconds(), "max_duration": row["max_duration"].total_seconds(), }) + seen_dates[row["workflow_id"]].add(row["date"]) + + start = start.replace(minute=0, second=0, microsecond=0) + current = start + while current < end: + for workflow_id, dates in seen_dates.items(): + if current not in dates: + data[workflow_id].append({"date": current.isoformat(), "avg_duration": None, "max_duration": None}) + current += timedelta(hours=1) workflow_names = { workflow["id"]: workflow["name"] - for workflow in AppWorkflowConfig.objects.filter(id__in=seen_workflows).values("id", "name") + for workflow in AppWorkflowConfig.objects.filter(id__in=list(data)).values("id", "name") } - for workflow_data in data: - workflow_data["label"] = workflow_names[workflow_data["key"]] - return data + return [ + { + "key": workflow_id, + "label": workflow_names[workflow_id], + "values": sorted(data, key=lambda x: x["date"]) + } + for workflow_id, data in data.items() + ] + + +def get_status_data(domain, start, end, workflow_id=None): + query = AppExecutionLog.objects.filter(workflow__domain=domain, started__gte=start, started__lt=end) + if workflow_id: + query = query.filter(workflow_id=workflow_id) + + chart_logs = ( + query.annotate(date=Trunc("started", "hour", output_field=DateTimeField())) + .values("date", "success") + .annotate(count=Count("success")) + ) + + success = [] + error = [] + seen_success_dates = set() + seen_error_dates = set() + for row in chart_logs: + item = { + "date": row["date"].isoformat(), + "count": row["count"], + } + if row["success"]: + success.append(item) + seen_success_dates.add(row["date"]) + else: + error.append(item) + seen_error_dates.add(row["date"]) + + start = start.replace(minute=0, second=0, microsecond=0) + current = start + while current < end: + if current not in seen_error_dates: + error.append({"date": current.isoformat(), "count": 0}) + if current not in seen_success_dates: + success.append({"date": current.isoformat(), "count": 0}) + current += timedelta(hours=1) + + return [ + {"key": "Success", "values": sorted(success, key=lambda x: x["date"])}, + {"key": "Error", "values": sorted(error, key=lambda x: x["date"])}, + ] diff --git a/corehq/apps/app_execution/forms.py b/corehq/apps/app_execution/forms.py index 84bfc94258d2..4d5e7c491682 100644 --- a/corehq/apps/app_execution/forms.py +++ b/corehq/apps/app_execution/forms.py @@ -13,10 +13,10 @@ class AppWorkflowConfigForm(forms.ModelForm): - run_every = forms.IntegerField(min_value=1, required=False, label="Run Every (minutes)") - username = forms.CharField(max_length=255, label="Username", - help_text="Username of the user to run the workflow") - har_file = forms.FileField(label="HAR File", required=False) + run_every = forms.IntegerField(min_value=1, required=False, label=_("Run Every (minutes)")) + username = forms.CharField(max_length=255, label=_("Username"), + help_text=_("Username of the user to run the workflow")) + har_file = forms.FileField(label=_("HAR File"), required=False) class Meta: model = AppWorkflowConfig @@ -52,6 +52,7 @@ def __init__(self, request, *args, **kwargs): if request.user.is_superuser: fields += ["run_every", "notification_emails"] + har_help = _("HAR file recording should start with the selection of the app (navigate_menu_start).") self.helper.layout = crispy.Layout( crispy.Div( crispy.Div( @@ -59,23 +60,22 @@ def __init__(self, request, *args, **kwargs): css_class="col", ), crispy.Div( - crispy.HTML("

HAR file recording should start with the " - "selection of the app (navigate_menu_start).

"), + crispy.HTML(f"

{har_help}

"), "har_file", twbscrispy.StrictButton( - "Populate workflow from HAR file", + _("Populate workflow from HAR file"), type='submit', css_class='btn-secondary', name="import_har", value="1", formnovalidate=True, ), crispy.HTML("

 

"), - crispy.HTML("

Workflow:

"), + crispy.HTML(f"

{_('Workflow:')}

"), InlineField("workflow"), css_class="col" ), css_class="row mb-3" ), hqcrispy.FormActions( - twbscrispy.StrictButton("Save", type='submit', css_class='btn-primary') + twbscrispy.StrictButton(_("Save"), type='submit', css_class='btn-primary') ), ) @@ -98,7 +98,9 @@ def clean_app_id(self): try: get_brief_app(domain, app_id) except NoResultFound: - raise forms.ValidationError(f"App not found in domain: {domain}:{app_id}") + raise forms.ValidationError(_("App not found in domain: {domain}:{app_id}").format( + domain=domain, app_id=app_id + )) return app_id diff --git a/corehq/apps/app_execution/models.py b/corehq/apps/app_execution/models.py index 12f716cae364..0a75457267b9 100644 --- a/corehq/apps/app_execution/models.py +++ b/corehq/apps/app_execution/models.py @@ -11,6 +11,7 @@ from corehq.apps.app_manager.dbaccessors import get_brief_app from corehq.sql_db.functions import MakeInterval from corehq.util.jsonattrs import AttrsObject +from django.utils.translation import gettext_lazy class AppWorkflowManager(models.Manager): @@ -23,9 +24,9 @@ def get_due(self): class AppWorkflowConfig(models.Model): FORM_MODE_CHOICES = [ - (const.FORM_MODE_HUMAN, "Human: Answer each question individually and submit form"), - (const.FORM_MODE_NO_SUBMIT, "No Submit: Answer all questions but don't submit the form"), - (const.FORM_MODE_IGNORE, "Ignore: Do not complete or submit forms"), + (const.FORM_MODE_HUMAN, gettext_lazy("Human: Answer each question individually and submit form")), + (const.FORM_MODE_NO_SUBMIT, gettext_lazy("No Submit: Answer all questions but don't submit the form")), + (const.FORM_MODE_IGNORE, gettext_lazy("Ignore: Do not complete or submit forms")), ] name = models.CharField(max_length=255) domain = models.CharField(max_length=255) @@ -34,11 +35,12 @@ class AppWorkflowConfig(models.Model): django_user = models.ForeignKey(User, on_delete=models.CASCADE) workflow = AttrsObject(AppWorkflow) form_mode = models.CharField(max_length=255, choices=FORM_MODE_CHOICES) - sync_before_run = models.BooleanField(default=False, help_text="Sync user data before running") - run_every = models.IntegerField(help_text="Number of minutes between runs", null=True, blank=True) + sync_before_run = models.BooleanField(default=False, help_text=gettext_lazy("Sync user data before running")) + run_every = models.IntegerField( + help_text=gettext_lazy("Number of minutes between runs"), null=True, blank=True) last_run = models.DateTimeField(null=True, blank=True) notification_emails = ArrayField( - models.EmailField(), default=list, help_text="Emails to notify on failure", blank=True + models.EmailField(), default=list, help_text=gettext_lazy("Emails to notify on failure"), blank=True ) objects = AppWorkflowManager() diff --git a/corehq/apps/app_execution/static/app_execution/js/workflow_charts.js b/corehq/apps/app_execution/static/app_execution/js/workflow_charts.js new file mode 100644 index 000000000000..6312a3b44766 --- /dev/null +++ b/corehq/apps/app_execution/static/app_execution/js/workflow_charts.js @@ -0,0 +1,100 @@ +'use strict'; +hqDefine("app_execution/js/workflow_charts", [ + 'jquery', + 'moment/moment', + 'd3/d3.min', + 'nvd3/nv.d3.latest.min', // version 1.1.10 has a bug that affects line charts with multiple series +], function ( + $, moment, d3, nv +) { + + function getSeries(data, includeSeries) { + return includeSeries.map((seriesMeta) => { + return { + // include key in the label to differentiate between series with the same label + key: `${data.label}${seriesMeta.label}[${data.key}]`, + values: data.values.map((item) => { + return { + x: moment(item.date), + y: item[seriesMeta.key], + }; + }), + }; + }); + } + + function buildChart(yLabel) { + let chart = nv.models.lineChart() + .showYAxis(true) + .showXAxis(true); + + chart.yAxis + .axisLabel(yLabel); + chart.forceY(0); + chart.xScale(d3.time.scale()); + chart.margin({left: 80, bottom: 100}); + chart.xAxis.rotateLabels(-45) + .tickFormat(function (d) { + return moment(d).format("MMM DD [@] HH"); + }); + + nv.utils.windowResize(chart.update); + return chart; + } + + function setupTimingChart(data, includeSeries) { + const timingSeries = data.timing.flatMap((series) => getSeries(series, includeSeries)); + + nv.addGraph(() => { + let chart = buildChart(gettext("Seconds")); + chart.yAxis.tickFormat(d3.format(".1f")); + // remove the key from the label + chart.legend.key((d) => d.key.split("[")[0]); + chart.tooltip.keyFormatter((d) => { + return d.split("[")[0]; + }); + + d3.select('#timing_linechart svg') + .datum(timingSeries) + .call(chart); + + return chart; + }); + } + + function setupStatusChart(data) { + const colors = { + "Success": "#6dcc66", + "Error": "#f44", + }; + let seriesData = data.status.map((series) => { + return { + key: series.key, + values: series.values.map((item) => { + return { + x: moment(item.date), + y: item.count, + }; + }), + color: colors[series.key], + }; + }); + + nv.addGraph(() => { + let chart = buildChart(gettext("Chart")); + + d3.select('#status_barchart svg') + .datum(seriesData) + .call(chart); + + return chart; + }); + } + + $(document).ready(function () { + const data = JSON.parse(document.getElementById('chart_data').textContent); + const includeSeries = JSON.parse(document.getElementById('timingSeries').textContent); + setupTimingChart(data, includeSeries); + setupStatusChart(data); + }); +}); diff --git a/corehq/apps/app_execution/static/app_execution/js/workflow_logs.js b/corehq/apps/app_execution/static/app_execution/js/workflow_logs.js index 0bcca816ac71..c07d5b0e9527 100644 --- a/corehq/apps/app_execution/static/app_execution/js/workflow_logs.js +++ b/corehq/apps/app_execution/static/app_execution/js/workflow_logs.js @@ -1,28 +1,51 @@ +'use strict'; hqDefine("app_execution/js/workflow_logs", [ 'jquery', 'knockout', 'hqwebapp/js/initial_page_data', - 'app_execution/js/workflow_timing_chart', + 'hqwebapp/js/tempus_dominus', + 'app_execution/js/workflow_charts', 'hqwebapp/js/bootstrap5/components.ko', -], function ($, ko, initialPageData) { +], function ($, ko, initialPageData, hqTempusDominus) { let logsModel = function () { let self = {}; + self.statusFilter = ko.observable(""); + let allDatesText = gettext("Show All Dates"); + self.dateRange = ko.observable(allDatesText); self.items = ko.observableArray(); self.totalItems = ko.observable(initialPageData.get('total_items')); self.perPage = ko.observable(25); self.goToPage = function (page) { let params = {page: page, per_page: self.perPage()}; const url = initialPageData.reverse('app_execution:logs_json'); + if (self.statusFilter()) { + params.status = self.statusFilter(); + } + if (self.dateRange() && self.dateRange() !== allDatesText) { + const separator = hqTempusDominus.getDateRangeSeparator(), + dates = self.dateRange().split(separator); + params.startDate = dates[0]; + params.endDate = dates[1] || dates[0]; + } $.getJSON(url, params, function (data) { self.items(data.logs); }); }; + self.filter = ko.computed(() => { + self.statusFilter(); + if (self.dateRange().includes(hqTempusDominus.getDateRangeSeparator())) { + self.goToPage(1); + } + }).extend({throttle: 500}); + self.onLoad = function () { self.goToPage(1); }; + hqTempusDominus.createDefaultDateRangePicker(document.getElementById('id_date_range')); + return self; }; diff --git a/corehq/apps/app_execution/static/app_execution/js/workflow_timing_chart.js b/corehq/apps/app_execution/static/app_execution/js/workflow_timing_chart.js deleted file mode 100644 index 63fa7b9e7f91..000000000000 --- a/corehq/apps/app_execution/static/app_execution/js/workflow_timing_chart.js +++ /dev/null @@ -1,66 +0,0 @@ -// /* globals moment */ -hqDefine("app_execution/js/workflow_timing_chart", [ - 'jquery', - 'moment/moment', - 'd3/d3.min', - 'nvd3/nv.d3.latest.min', // version 1.1.10 has a bug that affects line charts with multiple series -], function ( - $, moment, d3, nv -) { - - function getSeries(data, includeSeries) { - return includeSeries.map((seriesMeta) => { - return { - // include key in the label to differentiate between series with the same label - label: `${data.label}${seriesMeta.label}`, - key: `${data.label}${seriesMeta.label}[${data.key}]`, - values: data.values.map((item) => { - return { - x: moment(item.date), - y: item[seriesMeta.key], - }; - }), - }; - }); - } - - function setupLineChart(data, includeSeries) { - const timingSeries = data.flatMap((series) => getSeries(series, includeSeries)); - nv.addGraph(function () { - let chart = nv.models.lineChart() - .showYAxis(true) - .showXAxis(true); - - chart.yAxis - .axisLabel('Seconds') - .tickFormat(d3.format(".1f")); - chart.forceY(0); - chart.xScale(d3.time.scale()); - chart.margin({left: 80, bottom: 100}); - chart.xAxis.rotateLabels(-45) - .tickFormat(function (d) { - return moment(d).format("MMM DD [@] HH"); - }); - - // remove the key from the label - chart.legend.key((d) => d.key.split("[")[0]); - chart.tooltip.keyFormatter((d) => { - return d.split("[")[0]; - }); - - d3.select('#timing_linechart svg') - .datum(timingSeries) - .call(chart); - - nv.utils.windowResize(chart.update); - return chart; - }); - - } - - $(document).ready(function () { - const chartData = JSON.parse(document.getElementById('chart_data').textContent); - const includeSeries = JSON.parse(document.getElementById('includeSeries').textContent); - setupLineChart(chartData, includeSeries); - }); -}); diff --git a/corehq/apps/app_execution/templates/app_execution/components/logs.html b/corehq/apps/app_execution/templates/app_execution/components/logs.html index 254a40b02d93..a0d174af09ca 100644 --- a/corehq/apps/app_execution/templates/app_execution/components/logs.html +++ b/corehq/apps/app_execution/templates/app_execution/components/logs.html @@ -1,19 +1,20 @@ +{% load i18n %}

Logs

{{ output }}
{% if success %}
- Success + {% trans "Success" %}
{% else %}
- Error: {{ error }} + {% trans "Error:" %} {{ error }}
{% endif %}
-

Workflow

+

{% trans "Workflow" %}

{{ workflow_json }}
diff --git a/corehq/apps/app_execution/templates/app_execution/workflow_list.html b/corehq/apps/app_execution/templates/app_execution/workflow_list.html index 014329d47f9f..a8a9f7555658 100644 --- a/corehq/apps/app_execution/templates/app_execution/workflow_list.html +++ b/corehq/apps/app_execution/templates/app_execution/workflow_list.html @@ -12,24 +12,30 @@ {% endcompress %} {% endblock %} -{% requirejs_main_b5 'app_execution/js/workflow_timing_chart' %} +{% requirejs_main_b5 'app_execution/js/workflow_charts' %} {% block page_content %}
Create New
-
-

{% trans "Average Timings Per Hour" %}

-
+
+
+

{% trans "Average Timings" %}

+ +
+
+

{% trans "Status" %}

+ +
- - - - - + + + + + @@ -54,10 +60,10 @@

{% trans "Average Timings Per Hour" %}

@@ -66,7 +72,7 @@

{% trans "Average Timings Per Hour" %}

NameApp NameUserLast RunLast 10 Runs{% translate 'Name' %}{% translate 'App Name' %}{% translate 'User' %}{% translate 'Last Run' %}{% translate 'Last 10 Runs' %}
-
{{ chart_data|json_script:"chart_data" }} - {% endblock %} diff --git a/corehq/apps/app_execution/templates/app_execution/workflow_log.html b/corehq/apps/app_execution/templates/app_execution/workflow_log.html index 9dd7e9d676c9..0114ee840793 100644 --- a/corehq/apps/app_execution/templates/app_execution/workflow_log.html +++ b/corehq/apps/app_execution/templates/app_execution/workflow_log.html @@ -2,17 +2,19 @@ {% load i18n %} {% block page_content %}
-

Workflow Execution Log for {{ log.config.name }}: {{ log.started|date }}

- Edit Workflow +

{% blocktrans with workflow_name=log.config.name date=log.started|date %} + Workflow Execution Log for {{ workflow_name }}: {{ date }} + {% endblocktrans %}

+ {% translate 'Edit Workflow' %}
-

Details

+

{% translate 'Details' %}

diff --git a/corehq/apps/app_execution/templates/app_execution/workflow_log_list.html b/corehq/apps/app_execution/templates/app_execution/workflow_log_list.html index 7ed76f988007..e36a2100ab8d 100644 --- a/corehq/apps/app_execution/templates/app_execution/workflow_log_list.html +++ b/corehq/apps/app_execution/templates/app_execution/workflow_log_list.html @@ -9,6 +9,10 @@ rel="stylesheet" media="all" href="{% static 'nvd3-1.8.6/build/nv.d3.css' %}" /> + {% endcompress %} {% endblock %} @@ -18,18 +22,37 @@ {% initial_page_data 'total_items' total %} {% registerurl "app_execution:logs_json" request.domain workflow.id %} -

Logs for workflow "{{ workflow.name }}"

-
-

{% trans "Average Timings Per Hour" %}

-
+

{% translate 'Logs for workflow' %} "{{ workflow.name }}"

+
+
+

{% translate "Average Timings" %}

+ +
+
+

{% translate "Log Status" %}

+ +
+
+ +
+ +
+
+ +
+
- - - + + + @@ -43,7 +66,7 @@

{% trans "Average Timings Per Hour" %}

- +
StatusStartedDuration{% translate 'Status' %}{% translate 'Started' %}{% translate 'Duration' %}
Details{% translate 'Details' %}
@@ -51,7 +74,10 @@

{% trans "Average Timings Per Hour" %}

params="goToPage: goToPage, totalItems: totalItems, perPage: perPage, onLoad: onLoad">
{{ chart_data|json_script:"chart_data" }} - {% endblock %} diff --git a/corehq/apps/app_execution/templates/app_execution/workflow_test.html b/corehq/apps/app_execution/templates/app_execution/workflow_test.html index 727dcb7be21c..3ac915e0568c 100644 --- a/corehq/apps/app_execution/templates/app_execution/workflow_test.html +++ b/corehq/apps/app_execution/templates/app_execution/workflow_test.html @@ -14,15 +14,18 @@ {% endblock %} {% block page_content %}
-

Testing {{ workflow.name }}

- Edit +

{% blocktrans with name=workflow.name %}Testing {{ name }}{% endblocktrans %}

+ {% translate 'Edit' %}
{% if result %} {% include "app_execution/components/logs.html" %} {% endif %}
{% csrf_token %} - +
{% endblock %} diff --git a/corehq/apps/app_execution/views.py b/corehq/apps/app_execution/views.py index f1562987ee45..39302c6c1bce 100644 --- a/corehq/apps/app_execution/views.py +++ b/corehq/apps/app_execution/views.py @@ -9,13 +9,16 @@ from corehq.apps.app_execution import const from corehq.apps.app_execution.api import execute_workflow from corehq.apps.app_execution.data_model import EXAMPLE_WORKFLOW -from corehq.apps.app_execution.db_accessors import get_avg_duration_data +from corehq.apps.app_execution.db_accessors import get_avg_duration_data, get_status_data from corehq.apps.app_execution.exceptions import AppExecutionError, FormplayerException from corehq.apps.app_execution.forms import AppWorkflowConfigForm from corehq.apps.app_execution.har_parser import parse_har_from_string from corehq.apps.app_execution.models import AppExecutionLog, AppWorkflowConfig from corehq.apps.domain.decorators import require_superuser_or_contractor from corehq.apps.hqwebapp.decorators import use_bootstrap5 +from corehq.util.timezones.utils import get_timezone_for_user +from corehq.util.view_utils import get_date_param +from django.utils.translation import gettext as _ @require_superuser_or_contractor @@ -24,13 +27,16 @@ def workflow_list(request, domain): workflows = AppWorkflowConfig.objects.filter(domain=domain) _augment_with_logs(workflows) utcnow = datetime.utcnow() - chart_data = get_avg_duration_data(domain, start=utcnow - relativedelta(months=1), end=utcnow) + start = utcnow - relativedelta(months=1) context = _get_context( request, - "Automatically Executed App Workflows", + _("Automatically Executed App Workflows"), reverse("app_execution:workflow_list", args=[domain]), workflows=workflows, - chart_data=chart_data + chart_data={ + "timing": get_avg_duration_data(domain, start=start, end=utcnow), + "status": get_status_data(domain, start=start, end=utcnow), + } ) return render(request, "app_execution/workflow_list.html", context) @@ -72,8 +78,11 @@ def new_workflow(request, domain): return redirect("app_execution:workflow_list", domain) context = _get_context( - request, "New App Workflow", reverse("app_execution:new_workflow", args=[domain]), - add_parent=True, form=form + request, + _("New App Workflow"), + reverse("app_execution:new_workflow", args=[domain]), + add_parent=True, + form=form ) return render(request, "app_execution/workflow_form.html", context) @@ -90,14 +99,15 @@ def edit_workflow(request, domain, pk): if import_har and har_file: form = _get_form_from_har(har_file.read(), request, instance=config) elif har_file: - messages.error(request, "You must use the 'Import HAR' button to upload a HAR file.") + messages.error(request, _("You must use the 'Import HAR' button to upload a HAR file.")) else: if form.is_valid(): form.save() return redirect("app_execution:workflow_list", domain) context = _get_context( - request, f"Edit App Workflow: {config.name}", reverse("app_execution:edit_workflow", args=[domain, pk]), + request, _("Edit App Workflow: {name}").format(name=config.name), + reverse("app_execution:edit_workflow", args=[domain, pk]), add_parent=True, form=form ) return render(request, "app_execution/workflow_form.html", context) @@ -111,7 +121,7 @@ def _get_form_from_har(har_data_string, request, instance=None): post_data["app_id"] = config.app_id post_data["workflow"] = AppWorkflowConfig.workflow_object_to_json_string(config.workflow) except Exception as e: - messages.error(request, "Unable to process HAR file: " + str(e)) + messages.error(request, _("Unable to process HAR file: {error}").format(error=str(e))) return AppWorkflowConfigForm(request, post_data, instance=instance) @@ -165,17 +175,18 @@ def _get_context(request, title, url, add_parent=False, **kwargs): @use_bootstrap5 def workflow_log_list(request, domain, pk): utcnow = datetime.utcnow() - chart_data = get_avg_duration_data( - domain, start=utcnow - relativedelta(months=1), end=utcnow, workflow_id=pk - ) + start = utcnow - relativedelta(months=1) context = _get_context( request, - "Automatically Executed App Workflow Logs", + _("Automatically Executed App Workflow Logs"), reverse("app_execution:workflow_logs", args=[domain, pk]), add_parent=True, workflow=AppWorkflowConfig.objects.get(id=pk), total=AppExecutionLog.objects.filter(workflow__domain=domain, workflow_id=pk).count(), - chart_data=chart_data + chart_data={ + "timing": get_avg_duration_data(domain, start=start, end=utcnow, workflow_id=pk), + "status": get_status_data(domain, start=start, end=utcnow, workflow_id=pk), + }, ) return render(request, "app_execution/workflow_log_list.html", context) @@ -183,12 +194,27 @@ def workflow_log_list(request, domain, pk): @require_superuser_or_contractor @use_bootstrap5 def workflow_logs_json(request, domain, pk): + status = request.GET.get('status', None) limit = int(request.GET.get('per_page', 10)) page = int(request.GET.get('page', 1)) skip = limit * (page - 1) - logs = AppExecutionLog.objects.filter( - workflow__domain=domain, workflow_id=pk - ).order_by("-started")[skip:skip + limit] + + timezone = get_timezone_for_user(request.couch_user, domain) + try: + start_date = get_date_param(request, 'startDate', timezone=timezone) + end_date = get_date_param(request, 'endDate', timezone=timezone) + except ValueError: + return JsonResponse({"error": _("Invalid date parameter")}) + + query = AppExecutionLog.objects.filter(workflow__domain=domain, workflow_id=pk) + if status: + query = query.filter(success=status == "success") + if start_date: + query = query.filter(started__gte=start_date) + if end_date: + query = query.filter(started__lte=datetime.combine(end_date, datetime.max.time())) + + logs = query.order_by("-started")[skip:skip + limit] return JsonResponse({ "logs": [ { @@ -212,7 +238,7 @@ def workflow_log(request, domain, pk): request, "app_execution/workflow_log.html", _get_context( request, - f"Workflow Log: {log.workflow.name}", + _("Workflow Log: {name}").format(name=log.workflow.name), reverse("app_execution:workflow_log", args=[domain, pk]), add_parent=True, log=log, diff --git a/corehq/apps/app_manager/models.py b/corehq/apps/app_manager/models.py index 9c839108c40a..c1d195048934 100644 --- a/corehq/apps/app_manager/models.py +++ b/corehq/apps/app_manager/models.py @@ -4188,6 +4188,7 @@ def assert_app_v2(self): split_screen_dynamic_search = BooleanProperty(default=False) persistent_menu = BooleanProperty(default=False) + show_breadcrumbs = BooleanProperty(default=True) @property def id(self): diff --git a/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml b/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml index 0eaf567d1a12..5fcf2b47a724 100644 --- a/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml +++ b/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml @@ -181,7 +181,15 @@ - id: persistent_menu name: Show Persistent Menu - description: Show a persistent menu instead of breadcrumbs. + description: Show the persistent menu on the side of the web app. + toggle: PERSISTENT_MENU_SETTING + widget: bool + default: false + since: '2.54' + +- id: show_breadcrumbs + name: Show Breadcrumbs + description: Show the breadcrumbs on top of the web app. toggle: PERSISTENT_MENU_SETTING widget: bool default: false diff --git a/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml b/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml index c04c8403a364..c8b81e34dfb6 100644 --- a/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml +++ b/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml @@ -55,6 +55,7 @@ collapse: true settings: - properties.logo_web_apps + - hq.show_breadcrumbs - hq.persistent_menu - title: 'Advanced' diff --git a/corehq/apps/app_manager/static_strings.py b/corehq/apps/app_manager/static_strings.py index a11c428ec035..c4a52be4528b 100644 --- a/corehq/apps/app_manager/static_strings.py +++ b/corehq/apps/app_manager/static_strings.py @@ -160,7 +160,9 @@ gettext_noop('Server User Registration'), gettext_noop('Set to skip if your deployment does not require users to register with the server. Note that this will likely result in OTA Restore and other features being unavailable.'), gettext_noop('Short'), - gettext_noop('Show a persistent menu instead of breadcrumbs.'), + gettext_noop('Show Breadcrumbs'), + gettext_noop('Show the breadcrumbs on top of the web app.'), + gettext_noop('Show the persistent menu on the side of the web app.'), gettext_noop('Show Persistent Menu'), gettext_noop('Simple (FOR TESTING ONLY: crashes with any unrecognized user-defined translations)'), gettext_noop('Skip'), diff --git a/corehq/apps/app_manager/templates/app_manager/profile.xml b/corehq/apps/app_manager/templates/app_manager/profile.xml index 9b970b705540..782bae17dcd4 100644 --- a/corehq/apps/app_manager/templates/app_manager/profile.xml +++ b/corehq/apps/app_manager/templates/app_manager/profile.xml @@ -28,7 +28,9 @@ {% if support_email %} {% endif %} - + + + {% for key, value in app_profile.properties.items %}{% if value != None %} diff --git a/corehq/apps/app_manager/tests/test_views.py b/corehq/apps/app_manager/tests/test_views.py index 55134957da0a..559763fe9aef 100644 --- a/corehq/apps/app_manager/tests/test_views.py +++ b/corehq/apps/app_manager/tests/test_views.py @@ -1,4 +1,5 @@ import base64 +import doctest import json import re from contextlib import contextmanager @@ -42,26 +43,50 @@ User = get_user_model() -@flag_enabled('CUSTOM_PROPERTIES') -@patch('corehq.apps.app_manager.models.validate_xform', return_value=None) -@es_test(requires=[app_adapter], setup_class=True) -class TestViews(TestCase): - app = None - build = None +class ViewsBase(TestCase): + domain = 'test-views-base' + username = 'dolores.umbridge' + password = 'bumblesn0re' @classmethod def setUpClass(cls): - super(TestViews, cls).setUpClass() - cls.project = Domain.get_or_create_with_name('app-manager-testviews-domain', is_active=True) - cls.username = 'cornelius' - cls.password = 'fudge' - cls.user = WebUser.create(cls.project.name, cls.username, cls.password, None, None, is_active=True) + super().setUpClass() + + cls.domain_obj = Domain.get_or_create_with_name( + cls.domain, + is_active=True, + ) + cls.build = add_build(version='2.7.0', build_number=20655) + + cls.user = WebUser.create( + cls.domain, + cls.username, + cls.password, + created_by=None, + created_via=None, + is_active=True, + ) cls.user.is_superuser = True cls.user.save() - cls.build = add_build(version='2.7.0', build_number=20655) + + @classmethod + def tearDownClass(cls): + cls.user.delete(cls.domain, deleted_by=None) + cls.build.delete() + cls.domain_obj.delete() + super().tearDownClass() + + +@flag_enabled('CUSTOM_PROPERTIES') +@patch('corehq.apps.app_manager.models.validate_xform', return_value=None) +@es_test(requires=[app_adapter], setup_class=True) +class TestViews(ViewsBase): + domain = 'app-manager-testviews-domain' + username = 'cornelius' + password = 'fudge' def setUp(self): - self.app = Application.new_app(self.project.name, "TestApp") + self.app = Application.new_app(self.domain, "TestApp") self.app.build_spec = BuildSpec.from_string('2.7.0/latest') self.client.login(username=self.username, password=self.password) @@ -69,13 +94,6 @@ def tearDown(self): if self.app._id: self.app.delete() - @classmethod - def tearDownClass(cls): - cls.user.delete(cls.project.name, deleted_by=None) - cls.build.delete() - cls.project.delete() - super(TestViews, cls).tearDownClass() - def test_download_file_bad_xform_404(self, mock): ''' This tests that the `download_file` view returns @@ -96,13 +114,13 @@ def test_download_file_bad_xform_404(self, mock): self.app.save() mock.side_effect = XFormValidationError('') - response = self.client.get(reverse('app_download_file', kwargs=dict(domain=self.project.name, + response = self.client.get(reverse('app_download_file', kwargs=dict(domain=self.domain, app_id=self.app.get_id, path='modules-0/forms-0.xml'))) self.assertEqual(response.status_code, 404) def test_edit_commcare_profile(self, mock): - app2 = Application.new_app(self.project.name, "TestApp2") + app2 = Application.new_app(self.domain, "TestApp2") app2.save() self.addCleanup(lambda: Application.get_db().delete_doc(app2.id)) data = { @@ -112,7 +130,7 @@ def test_edit_commcare_profile(self, mock): } } - response = self.client.post(reverse('edit_commcare_profile', args=[self.project.name, app2._id]), + response = self.client.post(reverse('edit_commcare_profile', args=[self.domain, app2._id]), json.dumps(data), content_type='application/json') @@ -128,7 +146,7 @@ def test_edit_commcare_profile(self, mock): } } - response = self.client.post(reverse('edit_commcare_profile', args=[self.project.name, app2._id]), + response = self.client.post(reverse('edit_commcare_profile', args=[self.domain, app2._id]), json.dumps(data), content_type='application/json') @@ -159,7 +177,7 @@ def test_basic_app(self, mock1, mock2): self._send_to_es(self.app) kwargs = { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, } self._test_status_codes([ @@ -174,7 +192,7 @@ def test_basic_app(self, mock1, mock2): self._send_to_es(build) content = self._json_content_from_get('current_app_version', { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, }) self.assertEqual(content['currentVersion'], 1) @@ -182,13 +200,13 @@ def test_basic_app(self, mock1, mock2): self._send_to_es(self.app) content = self._json_content_from_get('current_app_version', { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, }) self.assertEqual(content['currentVersion'], 2) content = self._json_content_from_get('paginate_releases', { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, }, {'limit': 5}) self.assertEqual(len(content['apps']), 1) @@ -219,7 +237,7 @@ def test_advanced_module(self, mock): module = self.app.add_module(AdvancedModule.new_module("Module0", "en")) self.app.save() self._test_status_codes(['view_module'], { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, 'module_unique_id': module.unique_id, }) @@ -228,7 +246,7 @@ def test_report_module(self, mockh): module = self.app.add_module(ReportModule.new_module("Module0", "en")) self.app.save() self._test_status_codes(['view_module'], { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, 'module_unique_id': module.unique_id, }) @@ -237,14 +255,14 @@ def test_shadow_module(self, mockh): module = self.app.add_module(ShadowModule.new_module("Module0", "en")) self.app.save() self._test_status_codes(['view_module'], { - 'domain': self.project.name, + 'domain': self.domain, 'app_id': self.app.id, 'module_unique_id': module.unique_id, }) def test_default_new_app(self, mock): response = self.client.get(reverse('default_new_app', kwargs={ - 'domain': self.project.name, + 'domain': self.domain, }), follow=False) self.assertEqual(response.status_code, 302) @@ -256,7 +274,7 @@ def test_default_new_app(self, mock): def test_get_apps_modules(self, mock): with apps_modules_setup(self): - apps_modules = get_apps_modules(self.project.name) + apps_modules = get_apps_modules(self.domain) names = sorted([a['name'] for a in apps_modules]) self.assertEqual( @@ -275,7 +293,7 @@ def test_get_apps_modules(self, mock): def test_get_apps_modules_doc_types(self, mock): with apps_modules_setup(self): apps_modules = get_apps_modules( - self.project.name, app_doc_types=('Application', 'LinkedApplication') + self.domain, app_doc_types=('Application', 'LinkedApplication') ) names = sorted([a['name'] for a in apps_modules]) self.assertEqual(names, ['LinkedApp', 'OtherApp', 'TestApp']) @@ -439,18 +457,18 @@ def apps_modules_setup(test_case): test_case.app.add_module(Module.new_module("Module0", "en")) test_case.app.save() - test_case.other_app = Application.new_app(test_case.project.name, "OtherApp") + test_case.other_app = Application.new_app(test_case.domain, "OtherApp") test_case.other_app.add_module(Module.new_module("Module0", "en")) test_case.other_app.save() - test_case.deleted_app = Application.new_app(test_case.project.name, "DeletedApp") + test_case.deleted_app = Application.new_app(test_case.domain, "DeletedApp") test_case.deleted_app.add_module(Module.new_module("Module0", "en")) test_case.deleted_app.save() test_case.deleted_app.delete_app() test_case.deleted_app.save() # delete_app() changes doc_type. This save() saves that. - test_case.linked_app = create_linked_app(test_case.project.name, test_case.app.id, - test_case.project.name, 'LinkedApp') + test_case.linked_app = create_linked_app(test_case.domain, test_case.app.id, + test_case.domain, 'LinkedApp') try: yield finally: @@ -459,6 +477,161 @@ def apps_modules_setup(test_case): Application.get_db().delete_doc(test_case.other_app.id) +@patch('corehq.apps.app_manager.models.validate_xform', return_value=None) +@es_test(requires=[app_adapter], setup_class=True) +class TestViewGeneric(ViewsBase): + domain = 'test-view-generic' + + def setUp(self): + self.client.login(username=self.username, password=self.password) + + self.app = Application.new_app(self.domain, "TestApp") + self.app.build_spec = BuildSpec.from_string('2.7.0/latest') + self.module = self.app.add_module(Module.new_module("Module0", "en")) + self.form = self.app.new_form( + self.module.id, "Form0", "en", + attachment=get_simple_form(xmlns='xmlns-0.0')) + self.app.save() + app_adapter.index(self.app, refresh=True) # Send to ES + + def tearDown(self): + self.app.delete() + + def test_view_app(self, mock1): + url = reverse('view_app', kwargs={ + 'domain': self.domain, + 'app_id': self.app.id, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context.keys(), self.expected_keys_app) + + def test_view_module(self, mock1): + url = reverse('view_module', kwargs={ + 'domain': self.domain, + 'app_id': self.app.id, + 'module_unique_id': self.module.unique_id, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context.keys(), self.expected_keys_module) + + def test_view_module_legacy(self, mock1): + url = reverse('view_module_legacy', kwargs={ + 'domain': self.domain, + 'app_id': self.app.id, + 'module_id': self.module.id, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context.keys(), self.expected_keys_module) + + def test_view_form(self, mock1): + url = reverse('view_form', kwargs={ + 'domain': self.domain, + 'app_id': self.app.id, + 'form_unique_id': self.form.unique_id, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context.keys(), self.expected_keys_form) + + def test_view_form_legacy(self, mock1): + url = reverse('view_form_legacy', kwargs={ + 'domain': self.domain, + 'app_id': self.app.id, + 'module_id': self.module.id, + 'form_id': self.form.id, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context.keys(), self.expected_keys_form) + + expected_keys_app = { + 'None', 'perms', 'practice_users', 'EULA_COMPLIANCE', 'bulk_ui_translation_form', + 'latest_released_version', 'show_live_preview', 'can_view_app_diff', 'bulk_app_translation_form', + 'sms_contacts', 'DEFAULT_MESSAGE_LEVELS', 'jquery_ui', 'PRIVACY_EMAIL', 'user', 'privileges', + 'MAPBOX_ACCESS_TOKEN', 'release_manager', 'WS4REDIS_HEARTBEAT', 'can_send_sms', 'tabs', 'base_template', + 'STATIC_URL', 'tab', 'smart_lang_display_enabled', 'latest_commcare_version', 'MEDIA_URL', 'element_id', + 'app', 'ko', 'BASE_MAIN', 'prompt_settings_url', 'is_remote_app', 'show_biometric', 'linked_name', + 'WEBSOCKET_URI', 'selected_form', 'module', 'MINIMUM_PASSWORD_LENGTH', 'MINIMUM_ZXCVBN_SCORE', + 'SUPPORT_EMAIL', 'app_view_options', 'show_advanced', 'role_version', 'custom_assertions', + 'is_app_settings_page', 'domain_names', 'latest_version_for_build_profiles', 'ANALYTICS_CONFIG', + 'csrf_token', 'LANGUAGE_CODE', 'app_name', 'requirejs_main', 'sub', 'is_saas_environment', + 'selected_module', 'add_ons_layout', 'is_dimagi_environment', 'TIME_ZONE', 'env', 'add_ons', + 'show_shadow_forms', 'can_edit_apps', 'ANALYTICS_IDS', 'active_tab', 'current_url_name', + 'show_release_mode', 'application_profile_url', 'linkable_domains', 'domain_links', + 'show_all_projects_link', 'releases_active', 'settings_active', 'menu', 'allow_report_an_issue', 'app_id', + 'INVOICING_CONTACT_EMAIL', 'False', 'show_mobile_ux_warning', 'IS_DOMAIN_BILLING_ADMIN', 'translations', + 'hq', 'SALES_EMAIL', 'linked_version', 'confirm', 'show_report_modules', 'lang', 'can_view_cloudcare', + 'title_block', 'CUSTOM_LOGO_URL', 'items', 'request', 'messages', 'build_profile_access', 'form', 'error', + 'alerts', 'prompt_settings_form', 'submenu', 'domain', 'enable_update_prompts', 'show_shadow_modules', + 'sentry', 'bulk_ui_translation_upload', 'toggles_dict', 'True', 'full_name', 'latest_build_id', + 'previews_dict', 'copy_app_form', 'show_status_page', 'is_linked_app', 'show_shadow_module_v1', + 'use_bootstrap5', 'limit_to_linked_domains', 'add_ons_privileges', 'LANGUAGE_BIDI', 'page_title_block', + 'LANGUAGES', 'underscore', 'analytics', 'block', 'app_subset', 'restrict_domain_creation', + 'login_template', 'enterprise_mode', 'mobile_ux_cookie_name', 'commcare_hq_names', 'langs', + 'title_context_block', 'timezone', 'helpers', 'has_mobile_workers', 'multimedia_state', + 'bulk_app_translation_upload', 'show_training_modules', 'forloop', 'secure_cookies', + } + + expected_keys_module = { + 'show_advanced', 'session_endpoints_enabled', 'show_advanced_settings', 'toggles_dict', + 'show_release_mode', 'linked_name', 'linked_version', 'latest_commcare_version', + 'nav_menu_media_specifics', 'user', 'TIME_ZONE', 'domain', 'module_brief', 'timezone', 'active_tab', + 'data_registry_enabled', 'confirm', 'messages', 'releases_active', 'show_status_page', + 'show_search_workflow', 'data_registries', 'label', 'underscore', 'forloop', 'show_shadow_modules', + 'requirejs_main', 'SUPPORT_EMAIL', 'valid_parents_for_child_module', 'parent_case_modules', + 'current_url_name', 'LANGUAGE_BIDI', 'DEFAULT_MESSAGE_LEVELS', 'show_report_modules', 'BASE_MAIN', + 'app_id', 'request', 'MINIMUM_PASSWORD_LENGTH', 'type', 'is_saas_environment', 'show_all_projects_link', + 'enterprise_mode', 'csrf_token', 'WS4REDIS_HEARTBEAT', 'is_dimagi_environment', 'domain_names', + 'IS_DOMAIN_BILLING_ADMIN', 'tabs', 'perms', 'show_training_modules', 'AUDIO_LABEL', + 'show_shadow_module_v1', 'practice_users', 'add_ons', 'module_icon', 'SALES_EMAIL', 'app', 'domain_links', + 'app_subset', 'show_biometric', 'case_list_form_options', 'MINIMUM_ZXCVBN_SCORE', 'ICON_LABEL', 'app_name', + 'linkable_domains', 'alerts', 'show_shadow_forms', 'data_registry_workflow_choices', 'use_bootstrap5', + 'title_block', 'login_template', 'base_template', 'MEDIA_URL', 'lang', 'show_live_preview', 'jquery_ui', + 'latest_version_for_build_profiles', 'edit_name_url', 'case_types', 'js_options', 'ko', 'privileges', + 'settings_active', 'commcare_hq_names', 'add_ons_layout', 'limit_to_linked_domains', 'module', 'True', + 'multimedia', 'MAPBOX_ACCESS_TOKEN', 'helpers', 'all_case_modules', 'LANGUAGES', 'mobile_ux_cookie_name', + 'allow_report_an_issue', 'ANALYTICS_CONFIG', 'custom_icon', 'page_title_block', 'INVOICING_CONTACT_EMAIL', + 'form', 'error', 'previews_dict', 'copy_app_form', 'LANGUAGE_CODE', 'menu', 'add_ons_privileges', + 'shadow_parent', 'restrict_domain_creation', 'show_mobile_ux_warning', 'WEBSOCKET_URI', 'PRIVACY_EMAIL', + 'custom_assertions', 'analytics', 'form_endpoint_options', 'title_context_block', 'secure_cookies', + 'langs', 'details', 'None', 'CUSTOM_LOGO_URL', 'hq', 'selected_form', 'slug', 'env', 'False', + 'ANALYTICS_IDS', 'STATIC_URL', 'selected_module', 'role_version', 'EULA_COMPLIANCE', 'sentry', + 'case_list_form_not_allowed_reasons', 'child_module_enabled', 'block', + } + + expected_keys_form = { + 'show_advanced', 'is_module_filter_enabled', 'session_endpoints_enabled', 'toggles_dict', + 'show_release_mode', 'linked_name', 'linked_version', 'latest_commcare_version', + 'nav_menu_media_specifics', 'user', 'TIME_ZONE', 'domain', 'case_config_options', 'timezone', + 'root_requires_same_case', 'active_tab', 'confirm', 'messages', 'releases_active', 'show_status_page', + 'form_filter_patterns', 'form_workflows', 'label', 'underscore', 'forloop', 'requirejs_main', + 'SUPPORT_EMAIL', 'current_url_name', 'LANGUAGE_BIDI', 'DEFAULT_MESSAGE_LEVELS', 'show_report_modules', + 'BASE_MAIN', 'xform_languages', 'app_id', 'request', 'allow_usercase', 'MINIMUM_PASSWORD_LENGTH', 'type', + 'is_saas_environment', 'show_all_projects_link', 'enterprise_mode', 'module_is_multi_select', 'csrf_token', + 'WS4REDIS_HEARTBEAT', 'nav_form', 'xform_validation_errored', 'allow_form_filtering', + 'is_dimagi_environment', 'domain_names', 'IS_DOMAIN_BILLING_ADMIN', 'tabs', 'perms', + 'show_training_modules', 'AUDIO_LABEL', 'show_shadow_module_v1', 'practice_users', 'add_ons', + 'module_icon', 'custom_instances', 'SALES_EMAIL', 'app', 'domain_links', 'form_errors', 'app_subset', + 'show_biometric', 'MINIMUM_ZXCVBN_SCORE', 'ICON_LABEL', 'app_name', 'linkable_domains', 'alerts', + 'show_shadow_forms', 'use_bootstrap5', 'form_icon', 'title_block', 'login_template', 'base_template', + 'MEDIA_URL', 'lang', 'show_live_preview', 'jquery_ui', 'latest_version_for_build_profiles', + 'edit_name_url', 'ko', 'privileges', 'settings_active', 'commcare_hq_names', 'add_ons_layout', + 'limit_to_linked_domains', 'module', 'is_case_list_form', 'True', 'multimedia', 'MAPBOX_ACCESS_TOKEN', + 'xform_validation_missing', 'helpers', 'LANGUAGES', 'mobile_ux_cookie_name', 'allow_report_an_issue', + 'ANALYTICS_CONFIG', 'is_training_module', 'custom_icon', 'page_title_block', 'INVOICING_CONTACT_EMAIL', + 'form', 'error', 'previews_dict', 'copy_app_form', 'LANGUAGE_CODE', 'menu', 'add_ons_privileges', + 'restrict_domain_creation', 'show_mobile_ux_warning', 'WEBSOCKET_URI', 'PRIVACY_EMAIL', + 'is_allowed_to_be_release_notes_form', 'custom_assertions', 'analytics', 'title_context_block', + 'secure_cookies', 'langs', 'None', 'CUSTOM_LOGO_URL', 'hq', 'allow_form_copy', 'selected_form', 'slug', + 'env', 'False', 'ANALYTICS_IDS', 'STATIC_URL', 'selected_module', 'role_version', 'is_usercase_in_use', + 'module_loads_registry_case', 'EULA_COMPLIANCE', 'sentry', 'show_shadow_modules', 'show_custom_ref', + 'block', + } + + class TestDownloadCaseSummaryViewByAPIKey(TestCase): """Test that the DownloadCaseSummaryView can be accessed with an API key.""" @@ -581,3 +754,10 @@ def test_correct_credentials(self): self.url, HTTP_AUTHORIZATION=f"Basic {invalid_credentials}" ) self.assertEqual(response.status_code, 401) + + +def test_doctests(): + import corehq.apps.app_manager.views.view_generic as module + + results = doctest.testmod(module) + assert results.failed == 0 diff --git a/corehq/apps/app_manager/util.py b/corehq/apps/app_manager/util.py index 5d3c7e63daab..36c80ba31a79 100644 --- a/corehq/apps/app_manager/util.py +++ b/corehq/apps/app_manager/util.py @@ -672,7 +672,11 @@ def get_latest_app_release_by_location(domain, location_id, app_id): Child location's setting takes precedence over parent """ from corehq.apps.app_manager.models import AppReleaseByLocation - location = SQLLocation.active_objects.get(location_id=location_id) + + try: + location = SQLLocation.active_objects.get(location_id=location_id) + except SQLLocation.DoesNotExist: + return None location_and_ancestor_ids = location.get_ancestors(include_self=True).values_list( 'location_id', flat=True).reverse() # get all active enabled releases and order by version desc to get one with the highest version in the end diff --git a/corehq/apps/app_manager/views/apps.py b/corehq/apps/app_manager/views/apps.py index 91c8fc7341c9..c9adbf1e0cd0 100644 --- a/corehq/apps/app_manager/views/apps.py +++ b/corehq/apps/app_manager/views/apps.py @@ -883,7 +883,8 @@ def _always_allowed(x): ('mobile_ucr_restore_version', None, _always_allowed), ('location_fixture_restore', None, _always_allowed), ('split_screen_dynamic_search', None, _always_allowed), - ('persistent_menu', None, _always_allowed) + ('persistent_menu', None, _always_allowed), + ('show_breadcrumbs', None, _always_allowed) ) for attribute, transformation, can_set_attr in easy_attrs: if should_edit(attribute): diff --git a/corehq/apps/app_manager/views/forms.py b/corehq/apps/app_manager/views/forms.py index d5e7ba08a36f..922de0d5fe53 100644 --- a/corehq/apps/app_manager/views/forms.py +++ b/corehq/apps/app_manager/views/forms.py @@ -649,7 +649,14 @@ def get_apps_modules(domain, current_app_id=None, current_module_id=None, app_do ] -def get_form_view_context_and_template(request, domain, form, langs, current_lang, messages=messages): +def get_form_view_context( + request, + domain, + form, + langs, + current_lang, + messages=messages, +): # HELPME # # This method has been flagged for refactoring due to its complexity and @@ -889,7 +896,7 @@ def commtrack_programs(): }) context.update({'case_config_options': case_config_options}) - return "app_manager/form_view.html", context + return context def _get_form_link_context(app, module, form, langs): diff --git a/corehq/apps/app_manager/views/modules.py b/corehq/apps/app_manager/views/modules.py index a02ed94acd5b..5689ad1d7dc6 100644 --- a/corehq/apps/app_manager/views/modules.py +++ b/corehq/apps/app_manager/views/modules.py @@ -147,6 +147,9 @@ def get_module_template(user, module): def get_module_view_context(request, app, module, lang=None): + # make sure all modules have unique ids + app.ensure_module_unique_ids(should_save=True) + context = { 'edit_name_url': reverse('edit_module_attr', args=[app.domain, app.id, module.unique_id, 'name']), 'show_search_workflow': ( diff --git a/corehq/apps/app_manager/views/view_generic.py b/corehq/apps/app_manager/views/view_generic.py index e3a5346b074d..634b79bce7bd 100644 --- a/corehq/apps/app_manager/views/view_generic.py +++ b/corehq/apps/app_manager/views/view_generic.py @@ -24,7 +24,7 @@ get_apps_base_context, ) from corehq.apps.app_manager.views.forms import ( - get_form_view_context_and_template, + get_form_view_context, ) from corehq.apps.app_manager.views.modules import ( get_module_template, @@ -53,28 +53,134 @@ @retry_resource(3) -def view_generic(request, domain, app_id, module_id=None, form_id=None, - copy_app_form=None, release_manager=False, - module_unique_id=None, form_unique_id=None): +def view_generic( + request, + domain, + app_id, + module_id=None, + form_id=None, + copy_app_form=None, + release_manager=False, + module_unique_id=None, + form_unique_id=None, +): """ This is the main view for the app. All other views redirect to here. - """ - # HELPME - # - # This method has been flagged for refactoring due to its complexity and - # frequency of touches in changesets - # - # If you are writing code that touches this method, your changeset - # should leave the method better than you found it. - # - # Please remove this flag when this method no longer triggers an 'E' or 'F' - # classification from the radon code static analysis - if form_id and not module_id and module_unique_id is None: return bail(request, domain, app_id) app = get_app(domain, app_id) + module, form = _get_module_and_form( + app, module_id, form_id, module_unique_id, form_unique_id + ) + _handle_bad_states( + request, + domain, + app_id, + app, + module, + form, + module_unique_id, + form_unique_id, + ) + + if app.copy_of: + # redirect to "main" app rather than specific build + return HttpResponseRedirect(reverse( + "view_app", args=[domain, app.copy_of] + )) + + if copy_app_form is None: + copy_app_form = CopyApplicationForm(domain, app) + + context = get_apps_base_context(request, domain, app) + context.update({ + 'module': module, + 'form': form, + }) + + lang = context['lang'] + if not module and hasattr(app, 'translations'): + context["translations"] = app.translations.get(lang, {}) + + if not app.is_remote_app(): + context.update({ + 'add_ons': add_ons.get_dict(request, app, module, form), + 'add_ons_privileges': add_ons.get_privileges_dict(request), + 'add_ons_layout': add_ons.get_layout(request), + }) + + if form: + template = "app_manager/form_view.html" + context.update(get_form_view_context( + request, + domain, + form, + langs=context['langs'], + current_lang=lang, + )) + elif module: + template = get_module_template(request.user, module) + context.update(get_module_view_context(request, app, module, lang)) + else: + template = 'app_manager/app_view_settings.html' + context.update(get_app_view_context(request, app)) + + if release_manager: + template = 'app_manager/app_view_release_manager.html' + context.update(get_releases_context(request, domain, app_id)) + + context['is_app_settings_page'] = not release_manager + + if form or module: + context.update(_get_multimedia_context( + request.user.username, + domain, + app, + module, + form, + lang, + )) + + context.update(_get_domain_context( + domain, + request.domain, + request.couch_user, + )) + + if ( + not is_remote_app(app) + and has_privilege(request, privileges.COMMCARE_LOGO_UPLOADER) + ): + context.update(_get_logo_uploader_context(domain, app_id, app)) + + context.update({ + 'error': request.GET.get('error', ''), + 'confirm': request.session.pop('CONFIRM', False), + 'copy_app_form': copy_app_form, + 'latest_commcare_version': get_commcare_versions(request.user)[-1], + 'show_live_preview': should_show_preview_app( + request, + app, + request.couch_user.username + ), + 'show_release_mode': + AppReleaseModeSetting.get_settings(domain).is_visible + }) + + response = render(request, template, context) + set_lang_cookie(response, lang) + return response + + +def _get_module_and_form( + app, + module_id, + form_id, + module_unique_id, + form_unique_id, +): module = form = None if module_id: @@ -90,7 +196,6 @@ def view_generic(request, domain, app_id, module_id=None, form_id=None, module = app.get_module_by_unique_id(module_unique_id) except ModuleNotFoundException: raise Http404() - module_id = module.id if form_id and module is not None: try: @@ -102,13 +207,24 @@ def view_generic(request, domain, app_id, module_id=None, form_id=None, form = app.get_form(form_unique_id) except FormNotFoundException: raise Http404() - form_id = form.id if form is not None and module is None: # this is the case where only the form_unique_id is given module = form.get_module() - module_id = module.id + return module, form + + +def _handle_bad_states( + request, + domain, + app_id, + app, + module, + form, + module_unique_id, + form_unique_id, +): # Application states that should no longer exist if app.application_version == APP_V1: _assert = soft_assert() @@ -117,233 +233,258 @@ def view_generic(request, domain, app_id, module_id=None, form_id=None, 'domain': domain, 'app': app, }) - if (form is not None and "usercase_preload" in getattr(form, "actions", {}) - and form.actions.usercase_preload.preload): + + if ( + form is not None + and "usercase_preload" in getattr(form, "actions", {}) + and form.actions.usercase_preload.preload + ): _assert = soft_assert(['dmiller' + '@' + 'dimagi.com']) _assert(False, 'User property easy refs + old-style config = bad', { 'domain': domain, 'app_id': app_id, - 'module_id': module_id, + 'module_id': module.id, 'module_unique_id': module_unique_id, - 'form_id': form_id, + 'form_id': form.id, 'form_unique_id': form_unique_id, }) - context = get_apps_base_context(request, domain, app) - if app.copy_of: - # redirect to "main" app rather than specific build - return HttpResponseRedirect(reverse( - "view_app", args=[domain, app.copy_of] - )) - context.update({ - 'module': module, - 'form': form, +def _get_multimedia_context( + username, + domain, + app, + module, + form, + lang, +): + """ + Returns multimedia context for forms and modules. + """ + multimedia_context = {} + uploaders = { + 'icon': MultimediaImageUploadController( + "hqimage", + reverse(ProcessImageFileUploadView.urlname, + args=[app.domain, app.get_id]) + ), + 'audio': MultimediaAudioUploadController( + "hqaudio", + reverse(ProcessAudioFileUploadView.urlname, + args=[app.domain, app.get_id]) + ), + } + multimedia_map = app.multimedia_map + if form or module: + multimedia_map = (form or module).get_relevant_multimedia_map(app) + multimedia_context.update({ + 'multimedia': { + "object_map": app.get_object_map(multimedia_map=multimedia_map), + 'upload_managers': uploaders, + 'upload_managers_js': { + type_: u.js_options for type_, u in uploaders.items() + }, + } }) - lang = context['lang'] - if not module and hasattr(app, 'translations'): - context.update({"translations": app.translations.get(lang, {})}) - - if not app.is_remote_app(): - context.update({ - 'add_ons': add_ons.get_dict(request, app, module, form), - 'add_ons_privileges': add_ons.get_privileges_dict(request), - 'add_ons_layout': add_ons.get_layout(request), - }) - - if form: - template, form_context = get_form_view_context_and_template( - request, domain, form, context['langs'], current_lang=lang - ) - context.update(form_context) - elif module: - template = get_module_template(request.user, module) - # make sure all modules have unique ids - app.ensure_module_unique_ids(should_save=True) - module_context = get_module_view_context(request, app, module, lang) - context.update(module_context) + if toggles.CUSTOM_ICON_BADGES.enabled(domain): + if module.custom_icon: + multimedia_context['module_icon'] = module.custom_icon + else: + multimedia_context['module_icon'] = CustomIcon() else: - context.update(get_app_view_context(request, app)) - - template = 'app_manager/app_view_settings.html' - if release_manager: - template = 'app_manager/app_view_release_manager.html' - if release_manager: - context.update(get_releases_context(request, domain, app_id)) - context.update({ - 'is_app_settings_page': not release_manager, + multimedia_context['module_icon'] = None + + multimedia_context['nav_menu_media_specifics'] = _get_specific_media( + username, + domain, + app, + module, + form, + lang, + ) + return multimedia_context + + +def _get_specific_media( + username, + domain, + app, + module, + form, + lang, +): + module_id = module.id if module else None + form_id = form.id if form else None + default_file_name = f'module{module_id}' + if form: + default_file_name = f'{default_file_name}_form{form_id}' + + specific_media = [{ + 'menu_refs': app.get_menu_media( + module, + form=form, + form_index=form_id, + to_language=lang, + ), + 'default_file_name': f'{default_file_name}_{lang}', + }] + + if ( + not form + and module + and not isinstance(module, ReportModule) + and module.uses_media() + ): + specific_media.append({ + 'menu_refs': app.get_case_list_form_media( + module, + to_language=lang, + ), + 'default_file_name': _make_file_name( + default_file_name, + 'case_list_form', + lang, + ), + 'qualifier': 'case_list_form_', }) - - # update multimedia context for forms and modules. - menu_host = form or module - if menu_host: - default_file_name = 'module%s' % module_id - if form: - default_file_name = '%s_form%s' % (default_file_name, form_id) - - specific_media = [{ - 'menu_refs': app.get_menu_media(module, form=form, form_index=form_id, to_language=lang), - 'default_file_name': '{name}_{lang}'.format(name=default_file_name, lang=lang), - }] - - if not form and module and not isinstance(module, ReportModule) and module.uses_media(): - def _make_name(suffix): - return "{default_name}_{suffix}_{lang}".format( - default_name=default_file_name, - suffix=suffix, - lang=lang, - ) - - specific_media.append({ - 'menu_refs': app.get_case_list_form_media(module, to_language=lang), - 'default_file_name': _make_name('case_list_form'), - 'qualifier': 'case_list_form_', - }) - specific_media.append({ - 'menu_refs': app.get_case_list_menu_item_media(module, to_language=lang), - 'default_file_name': _make_name('case_list_menu_item'), - 'qualifier': 'case_list-menu_item_', - }) - if (module and hasattr(module, 'search_config') and module.uses_media() - and toggles.USH_CASE_CLAIM_UPDATES.enabled(domain)): - specific_media.extend([ - { - 'menu_refs': app.get_case_search_label_media( - module, module.search_config.search_label, to_language=lang), - 'default_file_name': _make_name('case_search_label_item'), - 'qualifier': 'case_search-search_label_media_' - }, - { - 'menu_refs': app.get_case_search_label_media( - module, module.search_config.search_again_label, to_language=lang), - 'default_file_name': _make_name('case_search_again_label_item'), - 'qualifier': 'case_search-search_again_label_media_' - } - ]) - if (toggles.CASE_LIST_LOOKUP.enabled(request.user.username) or - toggles.CASE_LIST_LOOKUP.enabled(app.domain) or - toggles.BIOMETRIC_INTEGRATION.enabled(app.domain)): - specific_media.append({ - 'menu_refs': app.get_case_list_lookup_image(module), - 'default_file_name': '{}_case_list_lookup'.format(default_file_name), - 'qualifier': 'case-list-lookupcase', - }) - - if hasattr(module, 'product_details'): - specific_media.append({ - 'menu_refs': app.get_case_list_lookup_image(module, type='product'), - 'default_file_name': '{}_product_list_lookup'.format(default_file_name), - 'qualifier': 'case-list-lookupproduct', - }) - - uploaders = { - 'icon': MultimediaImageUploadController( - "hqimage", - reverse(ProcessImageFileUploadView.urlname, - args=[app.domain, app.get_id]) + specific_media.append({ + 'menu_refs': app.get_case_list_menu_item_media( + module, + to_language=lang, ), - 'audio': MultimediaAudioUploadController( - "hqaudio", reverse(ProcessAudioFileUploadView.urlname, - args=[app.domain, app.get_id]) + 'default_file_name': _make_file_name( + default_file_name, + 'case_list_menu_item', + lang, ), - } - - multimedia_map = app.multimedia_map - if form or module: - multimedia_map = (form or module).get_relevant_multimedia_map(app) - context.update({ - 'multimedia': { - "object_map": app.get_object_map(multimedia_map=multimedia_map), - 'upload_managers': uploaders, - 'upload_managers_js': {type: u.js_options for type, u in uploaders.items()}, - } + 'qualifier': 'case_list-menu_item_', }) + if ( + module + and hasattr(module, 'search_config') + and module.uses_media() + and toggles.USH_CASE_CLAIM_UPDATES.enabled(domain) + ): + specific_media.extend([ + { + 'menu_refs': app.get_case_search_label_media( + module, + module.search_config.search_label, + to_language=lang, + ), + 'default_file_name': _make_file_name( + default_file_name, + 'case_search_label_item', + lang, + ), + 'qualifier': 'case_search-search_label_media_' + }, + { + 'menu_refs': app.get_case_search_label_media( + module, + module.search_config.search_again_label, + to_language=lang, + ), + 'default_file_name': _make_file_name( + default_file_name, + 'case_search_again_label_item', + lang, + ), + 'qualifier': 'case_search-search_again_label_media_' + } + ]) + + if ( + toggles.CASE_LIST_LOOKUP.enabled(username) + or toggles.CASE_LIST_LOOKUP.enabled(app.domain) + or toggles.BIOMETRIC_INTEGRATION.enabled(app.domain) + ): + specific_media.append({ + 'menu_refs': app.get_case_list_lookup_image(module), + 'default_file_name': f'{default_file_name}_case_list_lookup', + 'qualifier': 'case-list-lookupcase', + }) - context['module_icon'] = None - if toggles.CUSTOM_ICON_BADGES.enabled(domain): - context['module_icon'] = module.custom_icon if module.custom_icon else CustomIcon() - context['nav_menu_media_specifics'] = specific_media - - error = request.GET.get('error', '') - - context.update({ - 'error': error, - 'app': app, - }) + if hasattr(module, 'product_details'): + specific_media.append({ + 'menu_refs': app.get_case_list_lookup_image( + module, + type='product', + ), + 'default_file_name': + f'{default_file_name}_product_list_lookup', + 'qualifier': 'case-list-lookupproduct', + }) + return specific_media - # Pass form for Copy Application to template - if copy_app_form is None: - copy_app_form = CopyApplicationForm(domain, app) +def _get_domain_context(domain, request_domain, couch_user): domain_names = { - d.name for d in Domain.active_for_user(request.couch_user) - if not (is_active_downstream_domain(request.domain) - and get_upstream_domain_link(request.domain).master_domain == d.name) + d.name for d in Domain.active_for_user(couch_user) + if not ( + is_active_downstream_domain(request_domain) + and get_upstream_domain_link(request_domain).master_domain == d.name + ) } - domain_names.add(request.domain) - # NOTE: The CopyApplicationForm checks for access to linked domains before displaying + domain_names.add(request_domain) + # NOTE: The CopyApplicationForm checks for access to linked domains + # before displaying linkable_domains = [] limit_to_linked_domains = True - if can_domain_access_linked_domains(request.domain): - linkable_domains = get_accessible_downstream_domains(domain, request.couch_user) - limit_to_linked_domains = not request.couch_user.is_superuser - context.update({ + if can_domain_access_linked_domains(request_domain): + linkable_domains = get_accessible_downstream_domains( + domain, + couch_user, + ) + limit_to_linked_domains = not couch_user.is_superuser + return { 'domain_names': sorted(domain_names), 'linkable_domains': sorted(linkable_domains), - 'limit_to_linked_domains': limit_to_linked_domains - }) - context.update({ - 'copy_app_form': copy_app_form, - }) + 'limit_to_linked_domains': limit_to_linked_domains, + } - context['latest_commcare_version'] = get_commcare_versions(request.user)[-1] - if not is_remote_app(app) and has_privilege(request, privileges.COMMCARE_LOGO_UPLOADER): - uploader_slugs = list(ANDROID_LOGO_PROPERTY_MAPPING.keys()) - from corehq.apps.hqmedia.controller import ( - MultimediaLogoUploadController, - ) - from corehq.apps.hqmedia.views import ProcessLogoFileUploadView - uploaders = [ - MultimediaLogoUploadController( - slug, - reverse( - ProcessLogoFileUploadView.urlname, - args=[domain, app_id, slug], - ) +def _get_logo_uploader_context(domain, app_id, app): + from corehq.apps.hqmedia.controller import ( + MultimediaLogoUploadController, + ) + from corehq.apps.hqmedia.views import ProcessLogoFileUploadView + + uploader_slugs = list(ANDROID_LOGO_PROPERTY_MAPPING.keys()) + uploaders = [ + MultimediaLogoUploadController( + slug, + reverse( + ProcessLogoFileUploadView.urlname, + args=[domain, app_id, slug], ) + ) + for slug in uploader_slugs + ] + return { + "uploaders": uploaders, + "uploaders_js": [u.js_options for u in uploaders], + "refs": { + slug: ApplicationMediaReference( + app.logo_refs.get(slug, {}).get("path", slug), + media_class=CommCareImage, + ).as_dict() for slug in uploader_slugs - ] - context.update({ - "uploaders": uploaders, - "uploaders_js": [u.js_options for u in uploaders], - "refs": { - slug: ApplicationMediaReference( - app.logo_refs.get(slug, {}).get("path", slug), - media_class=CommCareImage, - ).as_dict() - for slug in uploader_slugs - }, - "media_info": { - slug: app.logo_refs.get(slug) - for slug in uploader_slugs if app.logo_refs.get(slug) - }, - }) + }, + "media_info": { + slug: app.logo_refs.get(slug) + for slug in uploader_slugs if app.logo_refs.get(slug) + }, + } - context.update({ - 'show_live_preview': should_show_preview_app( - request, - app, - request.couch_user.username - ), - }) - confirm = request.session.pop('CONFIRM', False) - context.update({'confirm': confirm}) - context.update({'show_release_mode': AppReleaseModeSetting.get_settings(domain).is_visible}) +def _make_file_name(default_name, suffix, lang): + """ + Appends ``suffix`` and ``lang`` to ``default_name`` - response = render(request, template, context) + >>> _make_file_name('sir_lancelot', 'obe', 'fr') + 'sir_lancelot_obe_fr' - set_lang_cookie(response, lang) - return response + """ + return f'{default_name}_{suffix}_{lang}' diff --git a/corehq/apps/case_search/utils.py b/corehq/apps/case_search/utils.py index 03adadc217f2..35bcb71b0402 100644 --- a/corehq/apps/case_search/utils.py +++ b/corehq/apps/case_search/utils.py @@ -3,7 +3,6 @@ from collections import defaultdict from dataclasses import dataclass, field from functools import wraps -from io import BytesIO from django.utils.functional import cached_property from django.utils.translation import gettext as _ @@ -44,13 +43,11 @@ reverse_index_case_query, wrap_case_search_hit, ) -from corehq.apps.hqadmin.utils import get_download_url from corehq.apps.registry.exceptions import ( RegistryAccessException, RegistryNotFound, ) from corehq.apps.registry.helper import DataRegistryHelper -from corehq.util.dates import get_timestamp_for_filename from corehq.util.quickcache import quickcache from corehq.util.timer import TimingContext @@ -65,45 +62,31 @@ class CaseSearchProfiler: queries: list = field(default_factory=list) _query_number: int = 0 - def run_query(self, slug, es_query): - self._query_number += 1 - if self.debug_mode: - es_query = es_query.enable_profiling() - - tc = self.timing_context(f'run query #{self._query_number}: {slug}') - timer = tc.peek() - with tc: - results = es_query.run() - - if self.debug_mode: - self.queries.append({ - 'slug': slug, - 'query_number': self._query_number, - 'query': es_query.raw_query, - 'duration': timer.duration, - 'profile_url': self._get_profile_url(slug, self._query_number, results.raw.get('profile')), - }) - return results + def get_case_search_class(self, slug=None): + profiler = self + + class ProfiledCaseSearchES(CaseSearchES): + def run(self): + profiler._query_number += 1 + if profiler.debug_mode: + self.es_query['profile'] = True + + tc = profiler.timing_context(f'run query #{profiler._query_number}: {slug}') + timer = tc.peek() + with tc: + results = super().run() - def add_query(self, slug, es_query): - self._query_number += 1 - if self.debug_mode: - self.queries.append({ - 'slug': slug, - 'query_number': self._query_number, - 'query': es_query.raw_query, - 'duration': None, - 'profile_url': None, - }) - - @staticmethod - def _get_profile_url(slug, query_number, profile_json): - timestamp = get_timestamp_for_filename() - name = f'es_profile_{query_number}_{slug}_{timestamp}.json' - io = BytesIO() - io.write(json.dumps(profile_json).encode('utf-8')) - io.seek(0) - return get_download_url(io, name, content_type='application/json') + if profiler.debug_mode: + profiler.queries.append({ + 'slug': slug, + 'query_number': profiler._query_number, + 'query': self.raw_query, + 'duration': timer.duration, + 'profile_json': results.raw.pop('profile'), + }) + return results + + return ProfiledCaseSearchES def time_function(): @@ -171,7 +154,7 @@ def get_primary_case_search_results(helper, case_types, criteria, commcare_sort= notify_exception(None, str(e), details={'exception_type': type(e)}) raise CaseSearchUserError(str(e)) - results = helper.profiler.run_query('main', search_es) + results = search_es.run() with helper.profiler.timing_context('wrap_cases'): cases = [helper.wrap_case(hit, include_score=True) for hit in results.raw_hits] return cases @@ -195,9 +178,11 @@ def __init__(self, domain): self.domain = domain self.profiler = CaseSearchProfiler() - def get_base_queryset(self): + def get_base_queryset(self, slug=None): + # slug is only informational, used for profiling + _CaseSearchES = self.profiler.get_case_search_class(slug) # See case_search_bha.py docstring for context on index_name - return CaseSearchES(index=self.config.index_name or None).domain(self.domain) + return _CaseSearchES(index=self.config.index_name or None).domain(self.domain) def wrap_case(self, es_hit, include_score=False): return wrap_case_search_hit(es_hit, include_score=include_score) @@ -223,8 +208,9 @@ def __init__(self, domain, couch_user, registry_helper): self._couch_user = couch_user self._registry_helper = registry_helper - def get_base_queryset(self): - return CaseSearchES().domain(self._registry_helper.visible_domains) + def get_base_queryset(self, slug=None): + _CaseSearchES = self.profiler.get_case_search_class(slug) + return _CaseSearchES().domain(self._registry_helper.visible_domains) def wrap_case(self, es_hit, include_score=False): case = super().wrap_case(es_hit, include_score) @@ -257,7 +243,7 @@ def _get_initial_search_es(self): max_results = CASE_SEARCH_MAX_RESULTS if toggles.INCREASED_MAX_SEARCH_RESULTS.enabled(self.request_domain): max_results = 1500 - return (self.helper.get_base_queryset() + return (self.helper.get_base_queryset('main') .case_type(self.case_types) .is_closed(False) .size(max_results)) @@ -523,12 +509,10 @@ def get_child_case_types(app): @time_function() def get_child_case_results(helper, parent_case_ids, child_case_types=None): - query = helper.get_base_queryset().get_child_cases(parent_case_ids, "parent") + query = helper.get_base_queryset('get_child_case_results').get_child_cases(parent_case_ids, "parent") if child_case_types: query = query.case_type(child_case_types) - - results = helper.profiler.run_query('get_child_case_results', query) - return [helper.wrap_case(result) for result in results.hits] + return [helper.wrap_case(result) for result in query.run().hits] @time_function() @@ -542,9 +526,8 @@ def get_expanded_case_results(helper, custom_related_case_property, cases): @time_function() def _get_case_search_cases(helper, case_ids): - query = helper.get_base_queryset().case_ids(case_ids) - results = helper.profiler.run_query('_get_case_search_cases', query) - return [helper.wrap_case(result) for result in results.hits] + query = helper.get_base_queryset('_get_case_search_cases').case_ids(case_ids) + return [helper.wrap_case(result) for result in query.run().hits] # Warning: '_tag_is_related_case' may cause the relevant user-defined properties to be overwritten. diff --git a/corehq/apps/case_search/views.py b/corehq/apps/case_search/views.py index f27170d9aa12..b0ce5fa60213 100644 --- a/corehq/apps/case_search/views.py +++ b/corehq/apps/case_search/views.py @@ -1,18 +1,21 @@ import json import re +from io import BytesIO from django.http import Http404 from django.urls import reverse +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy -from django.utils.decorators import method_decorator from dimagi.utils.web import json_response from corehq.apps.case_search.models import case_search_enabled_for_domain from corehq.apps.case_search.utils import get_case_search_results_from_request from corehq.apps.domain.decorators import cls_require_superuser_or_contractor from corehq.apps.domain.views.base import BaseDomainView +from corehq.apps.hqadmin.utils import get_download_url from corehq.apps.hqwebapp.decorators import use_bootstrap5 +from corehq.util.dates import get_timestamp_for_filename from corehq.util.view_utils import BadRequest, json_error @@ -115,5 +118,16 @@ def post(self, request, *args, **kwargs): 'primary_count': profiler.primary_count, 'related_count': profiler.related_count, 'timing_data': profiler.timing_context.to_dict(), - 'queries': profiler.queries, + 'queries': [self._make_profile_downloadable(q) for q in profiler.queries], }) + + @staticmethod + def _make_profile_downloadable(query): + profile_json = query.pop('profile_json') + timestamp = get_timestamp_for_filename() + name = f"es_profile_{query['query_number']}_{query['slug']}_{timestamp}.json" + io = BytesIO() + io.write(json.dumps(profile_json).encode('utf-8')) + io.seek(0) + query['profile_url'] = get_download_url(io, name, content_type='application/json') + return query diff --git a/corehq/apps/case_search/xpath_functions/ancestor_functions.py b/corehq/apps/case_search/xpath_functions/ancestor_functions.py index 042b3f3fa587..985ed1f7804d 100644 --- a/corehq/apps/case_search/xpath_functions/ancestor_functions.py +++ b/corehq/apps/case_search/xpath_functions/ancestor_functions.py @@ -90,8 +90,7 @@ def _is_ancestor_path_expression(node): def _child_case_lookup(context, case_ids, identifier): """returns a list of all case_ids who have parents `case_id` with the relationship `identifier` """ - es_query = context.helper.get_base_queryset().get_child_cases(case_ids, identifier) - context.profiler.add_query('_child_case_lookup', es_query) + es_query = context.helper.get_base_queryset('_child_case_lookup').get_child_cases(case_ids, identifier) return es_query.get_ids() @@ -144,8 +143,7 @@ def _get_case_ids_from_ast_filter(context, filter_node): else: from corehq.apps.case_search.filter_dsl import build_filter_from_ast es_filter = build_filter_from_ast(filter_node, context) - es_query = context.helper.get_base_queryset().filter(es_filter) - context.profiler.add_query('_get_case_ids_from_ast_filter', es_query) + es_query = context.helper.get_base_queryset('_get_case_ids_from_ast_filter').filter(es_filter) if es_query.count() > MAX_RELATED_CASES: new_query = serialize(filter_node) raise TooManyRelatedCasesError( diff --git a/corehq/apps/case_search/xpath_functions/subcase_functions.py b/corehq/apps/case_search/xpath_functions/subcase_functions.py index b890a21465cf..b8f94b5bf5f2 100644 --- a/corehq/apps/case_search/xpath_functions/subcase_functions.py +++ b/corehq/apps/case_search/xpath_functions/subcase_functions.py @@ -99,8 +99,8 @@ def _run_subcase_query(subcase_query, context): else: subcase_filter = filters.match_all() - es_query = ( - context.helper.get_base_queryset() + return ( + context.helper.get_base_queryset('subcase_query') .nested( 'indices', queries.filtered( @@ -113,11 +113,9 @@ def _run_subcase_query(subcase_query, context): ) .filter(subcase_filter) .source(['indices.referenced_id', 'indices.identifier']) + .run().hits ) - results = context.profiler.run_query('subcase_query', es_query) - return results.hits - def _parse_normalize_subcase_query(node) -> SubCaseQuery: """Parse the subcase query and normalize it to the form 'subcase-count > N' or 'subcase-count = N' diff --git a/corehq/apps/fixtures/resources/v0_1.py b/corehq/apps/fixtures/resources/v0_1.py index 1b1924845a1d..d0878a027a60 100644 --- a/corehq/apps/fixtures/resources/v0_1.py +++ b/corehq/apps/fixtures/resources/v0_1.py @@ -19,6 +19,7 @@ ) from corehq.apps.fixtures.utils import clear_fixture_cache from corehq.apps.users.models import HqPermissions +from corehq.util.validation import JSONSchemaValidator def convert_fdt(fdi, type_cache=None): @@ -108,12 +109,55 @@ class Meta(CustomResourceMeta): class LookupTableResource(HqBaseResource): + """Lookup Table API resource + + Example ``fields`` format: + + "fields": [ + { + "field_name": "tree", + "properties": ["family"] + } + ] + + Example ``item_attributes`` format: + + "item_attributes": ["name", "height"] + """ id = UUIDField(attribute='id', readonly=True, unique=True) is_global = tp_f.BooleanField(attribute='is_global') tag = tp_f.CharField(attribute='tag') fields = tp_f.ListField(attribute='fields') item_attributes = tp_f.ListField(attribute='item_attributes') + validate_deserialized_data = JSONSchemaValidator({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^(field_)?name$": {"type": "string"}, + }, + "properties": { + "properties": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "additionalProperties": False, + "oneOf": [ + {"not": {"required": ["field_name"]}}, + {"not": {"required": ["name"]}}, + ], + }, + }, + "item_attributes": {"type": "array", "items": {"type": "string"}} + }, + }) + def dehydrate_fields(self, bundle): return [ { @@ -138,13 +182,15 @@ def obj_delete(self, bundle, **kwargs): clear_fixture_cache(kwargs['domain']) return ImmediateHttpResponse(response=HttpAccepted()) - def obj_create(self, bundle, request=None, **kwargs): - def adapt(field): - if "name" not in field and "field_name" in field: - field = field.copy() - field["name"] = field.pop("field_name") - return field + @staticmethod + def _adapt_field(field): + if "field_name" in field: + field = field.copy() + field["name"] = field.pop("field_name") + return field + def obj_create(self, bundle, request=None, **kwargs): + adapt = self._adapt_field tag = bundle.data.get("tag") if LookupTable.objects.domain_tag_exists(kwargs['domain'], tag): raise BadRequest(f"A lookup table with name {tag} already exists") @@ -177,7 +223,8 @@ def obj_update(self, bundle, **kwargs): if 'fields' in bundle.data: save = True - bundle.obj.fields = [TypeField(**f) for f in bundle.data['fields']] + adapt = self._adapt_field + bundle.obj.fields = [TypeField(**adapt(f)) for f in bundle.data['fields']] if 'item_attributes' in bundle.data: save = True @@ -218,11 +265,75 @@ def make_field(data): class LookupTableItemResource(HqBaseResource): + """Lookup Table Row API resource + + Example ``fields`` format: + + "fields": { + "tree": { + "field_list": [ + { + "field_value": "pine", + "properties": {"family": "Pinaceae"} + } + ] + } + } + + Note: the object containing "field_list" is superfluous and could + be replaced with the "field_list" property value. Maybe in a + API version? + + Example ``item_attributes`` format: + + "item_attributes": { + "name": "Western White Pine Tree", + "height": "30-50 meters", + } + """ id = UUIDField(attribute='id', readonly=True, unique=True) data_type_id = UUIDField(attribute='table_id') fields = FieldsDictField(attribute='fields') item_attributes = tp_f.DictField(attribute='item_attributes') + validate_deserialized_data = JSONSchemaValidator({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": {"field_list": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^(field_)?value$": {"type": "string"}, + }, + "properties": { + "properties": { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + }, + "additionalProperties": False, + "oneOf": [ + {"not": {"required": ["field_value"]}}, + {"not": {"required": ["value"]}}, + ], + }, + }}, + "additionalProperties": False, + }, + }, + "item_attributes": { + "type": "object", + "additionalProperties": {"type": "string"}, + } + }, + }) + # It appears that sort_key is not included in any user facing UI. It is only defined as # the order of rows in the excel file when uploaded. We'll keep this behavior by incrementing # the sort key on new item creations diff --git a/corehq/apps/hqcase/management/commands/ptop_preindex.py b/corehq/apps/hqcase/management/commands/ptop_preindex.py index e04496a19519..be999c07a0c4 100644 --- a/corehq/apps/hqcase/management/commands/ptop_preindex.py +++ b/corehq/apps/hqcase/management/commands/ptop_preindex.py @@ -84,7 +84,7 @@ def add_arguments(self, parser): def handle(self, **options): runs = [] - all_es_index_adapters = list(get_all_expected_es_indices()) + all_es_index_adapters = get_all_expected_es_indices(ignore_subindices=True) if options['reset']: indices_needing_reindex = all_es_index_adapters diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/hqModules.js b/corehq/apps/hqwebapp/static/hqwebapp/js/hqModules.js index 9c32dc6d7e4d..292d41083ace 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/hqModules.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/hqModules.js @@ -1,3 +1,10 @@ +/* + This file does a number of manipulations of global variables that are typically bad practice. + It does not use strict because it's included everywhere, and strictness is not fully tested. +*/ +/* eslint no-implicit-globals: 0 */ +/* eslint no-redeclare: 0 */ +/* eslint strict: 0 */ /* * hqModules provides a poor man's module system for js. It is not a module *loader*, * only a module *referencer*: "importing" a module doesn't automatically load it as @@ -76,26 +83,25 @@ function hqDefine(path, dependencies, moduleAccessor) { var args = []; for (var i = 0; i < dependencies.length; i++) { var dependency = dependencies[i]; - if (COMMCAREHQ_MODULES.hasOwnProperty(dependency)) { + if (Object.hasOwn(COMMCAREHQ_MODULES, dependency)) { args[i] = hqImport(dependency); - } else if (thirdPartyGlobals.hasOwnProperty(dependency)) { + } else if (Object.hasOwn(thirdPartyGlobals, dependency)) { args[i] = window[thirdPartyGlobals[dependency]]; } } - if (!COMMCAREHQ_MODULES.hasOwnProperty(path)) { + if (!Object.hasOwn(COMMCAREHQ_MODULES, path)) { if (path.match(/\.js$/)) { throw new Error("Error in '" + path + "': module names should not end in .js."); } COMMCAREHQ_MODULES[path] = factory.apply(undefined, args); - } - else { + } else { throw new Error("The module '" + path + "' has already been defined elsewhere."); } } }(moduleAccessor)); } if (typeof define === 'undefined') { - define = hqDefine; + define = hqDefine; // eslint-disable-line no-global-assign } // For use only with modules that are never used in a requirejs context. @@ -110,11 +116,11 @@ function hqImport(path) { // Support require calls within a module. Best practice is to require all dependencies // at module definition time, but this function can be used when doing so would // introduce a circular dependency. -function hqRequire(paths, callback) { +function hqRequire(paths, callback) { // eslint-disable-line no-unused-vars if (typeof define === 'function' && define.amd && window.USE_REQUIREJS) { // HQ's requirejs build process (build_requirejs.py) replaces hqRequire calls with // require calls, so it's important that this do nothing but pass through to require - require(paths, callback); + require(paths, callback); // eslint-disable-line no-undef } else { var args = []; for (var i = 0; i < paths.length; i++) { diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss index 240872a81194..9f23e33527ca 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss @@ -116,9 +116,9 @@ $dimagi-mango: #FC5F36; // Base color overrides -$blue: #5D70D2; -$green: #3FA12A; -$red: $dimagi-sunset; +$blue: darken(#5D70D2, 20%); +$green: darken(#3FA12A, 10%); +$red: darken($dimagi-sunset, 20%); $teal: #01A2A9; $yellow: $dimagi-marigold; $indigo: $dimagi-indigo; diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables.style.diff.txt index 35ee6f00e6c9..3a61dd2ee756 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables.style.diff.txt @@ -223,9 +223,9 @@ + + +// Base color overrides -+$blue: #5D70D2; -+$green: #3FA12A; -+$red: $dimagi-sunset; ++$blue: darken(#5D70D2, 20%); ++$green: darken(#3FA12A, 10%); ++$red: darken($dimagi-sunset, 20%); +$teal: #01A2A9; +$yellow: $dimagi-marigold; +$indigo: $dimagi-indigo; diff --git a/corehq/apps/locations/templates/locations/location_types.html b/corehq/apps/locations/templates/locations/location_types.html index 1c1abcec3f40..2a04442b7972 100644 --- a/corehq/apps/locations/templates/locations/location_types.html +++ b/corehq/apps/locations/templates/locations/location_types.html @@ -157,15 +157,17 @@

{% trans "Organization Levels" %}

{% endblocktrans %}"> - - {% trans "View Child Data to Level" %} - - - + {% if request|toggle_enabled:"USH_RESTORE_FILE_LOCATION_CASE_SYNC_RESTRICTION" %} + + {% trans "View Child Data to Level" %} + + + + {% endif %} @@ -243,16 +245,18 @@

{% trans "Organization Levels" %}

- - - + {% if request|toggle_enabled:"USH_RESTORE_FILE_LOCATION_CASE_SYNC_RESTRICTION" %} + + + + {% endif %}