diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f0bd7b582fe1a..15bc91f737ea7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -58,7 +58,7 @@ jobs: fetch-depth: 0 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65e475589414d..c6a3ff8901374 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,7 @@ jobs: NOSE_DIVIDED_WE_RUN: ${{ matrix.NOSE_DIVIDED_WE_RUN }} JS_SETUP: yes KAFKA_HOSTNAME: kafka + STRIPE_PRIVATE_KEY: ${{ secrets.STRIPE_PRIVATE_KEY }} run: scripts/docker test --noinput --stop -v --divided-we-run=${{ matrix.NOSE_DIVIDED_WE_RUN }} --divide-depth=1 --with-timing --with-flaky --threshold=10 --max-test-time=29 - name: "Codecov upload" env: diff --git a/DEV_SETUP.md b/DEV_SETUP.md index d3443abd73f96..4967007911038 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -532,10 +532,12 @@ $ ./manage.py create_kafka_topics $ env CCHQ_IS_FRESH_INSTALL=1 ./manage.py migrate --noinput ``` -If you are using a partitioned database, populate the additional databases too: +If you are using a partitioned database, populate the additional +databases too, and configure PL/Proxy: ```sh $ env CCHQ_IS_FRESH_INSTALL=1 ./manage.py migrate_multi --noinput +$ ./manage.py configure_pl_proxy_cluster --create_only ``` You should run `./manage.py migrate` frequently, but only use the environment diff --git a/Dockerfile b/Dockerfile index 1adc0e31b6f90..92c14bfac2a29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ RUN git config --global url."https://".insteadOf git:// \ # this keeps the image size down, make sure to set in mocha-headless-chrome options # executablePath: 'google-chrome-unstable' -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true +ENV PUPPETEER_SKIP_DOWNLOAD true RUN npm -g install \ yarn \ diff --git a/Gruntfile.js b/Gruntfile.js index 4bdc471d32c34..064448591b1bd 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -73,7 +73,7 @@ module.exports = function (grunt) { }; // For running in docker/travis - if (process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) { + if (process.env.PUPPETEER_SKIP_DOWNLOAD) { runnerOptions.executablePath = 'google-chrome-unstable'; } diff --git a/corehq/apps/accounting/forms.py b/corehq/apps/accounting/forms.py index 84a2063cd26f4..84c7a50cfb4c5 100644 --- a/corehq/apps/accounting/forms.py +++ b/corehq/apps/accounting/forms.py @@ -625,10 +625,10 @@ def __init__(self, subscription, account_id, web_user, *args, **kwargs): is_most_recent_version = subscription.plan_version.plan.get_version() == subscription.plan_version most_recent_version_text = ("is most recent version" if is_most_recent_version else "not most recent version") - self.fields['most_recent_version'].initial = most_recent_version_text + self.fields['most_recent_version'].initial = is_most_recent_version most_recent_version_field = hqcrispy.B3TextField( 'most_recent_version', - self.fields['most_recent_version'].initial + most_recent_version_text ) self.fields['domain'].choices = [ (subscription.subscriber.domain, subscription.subscriber.domain) diff --git a/corehq/apps/accounting/interface.py b/corehq/apps/accounting/interface.py index 11428d13fd406..dbecd76698850 100644 --- a/corehq/apps/accounting/interface.py +++ b/corehq/apps/accounting/interface.py @@ -945,7 +945,7 @@ class CustomerInvoiceInterface(InvoiceInterfaceBase): 'corehq.apps.accounting.interface.IsHiddenFilter', ] - account = None + subscription = None @property def headers(self): diff --git a/corehq/apps/accounting/invoicing.py b/corehq/apps/accounting/invoicing.py index 1addb5c206dc7..b07e1e843f7c6 100644 --- a/corehq/apps/accounting/invoicing.py +++ b/corehq/apps/accounting/invoicing.py @@ -849,6 +849,7 @@ def _end_date_count_sms(self): @property @memoized def unit_cost(self): + """Return aggregate cost of all the excess SMS""" total_excess = Decimal('0.0') if self.is_within_monthly_limit: return total_excess diff --git a/corehq/apps/accounting/models.py b/corehq/apps/accounting/models.py index 380cee2ebf0aa..c09e5bde8fe1f 100644 --- a/corehq/apps/accounting/models.py +++ b/corehq/apps/accounting/models.py @@ -19,6 +19,7 @@ import stripe from django_prbac.models import Role from memoized import memoized +from corehq.apps.accounting.utils.stripe import charge_through_stripe from corehq.apps.domain.shortcuts import publish_domain_saved from dimagi.ext.couchdbkit import ( @@ -562,6 +563,8 @@ def _send_autopay_card_removed_email(self, new_user, domain): email, render_to_string('accounting/email/autopay_card_removed.html', context), text_content=strip_tags(render_to_string('accounting/email/autopay_card_removed.html', context)), + domain=domain, + use_domain_gateway=True, ) def _send_autopay_card_added_email(self, domain): @@ -593,6 +596,8 @@ def _send_autopay_card_added_email(self, domain): email, render_to_string('accounting/email/invoice_autopay_setup.html', context), text_content=strip_tags(render_to_string('accounting/email/invoice_autopay_setup.html', context)), + domain=domain, + use_domain_gateway=True, ) @staticmethod @@ -3770,18 +3775,33 @@ def _remove_card_from_all_accounts(self, card): account.remove_autopay_user() def create_card(self, stripe_token, billing_account, domain, autopay=False): + """ + Creates and associates a new card with the Stripe customer. + + This method uses a Stripe token (usually generated on the client side) + to securely create a new card and associate it with the customer + represented by this instance. Additionally, if the 'autopay' flag is + set to True, it sets the card to be used for automatic payments for + a specific billing account and domain. + + Parameters: + - stripe_token (str): The token representing the card details, typically + generated using Stripe.js on the client side. + - billing_account (BillingAccount): The account for which the card might + be set for automatic payments. + - domain (str): The domain associated with the billing account. + - autopay (bool, optional): Flag indicating if the card should be set for + automatic payments. Default is False. + + Returns: + - card (stripe.Card): The newly created Stripe card object. + """ customer = self.customer card = customer.cards.create(card=stripe_token) - self.set_default_card(card) if autopay: self.set_autopay(card, billing_account, domain) return card - def set_default_card(self, card): - self.customer.default_card = card - self.customer.save() - return card - def set_autopay(self, card, billing_account, domain): """ Sets the auto_pay status on the card for a billing account @@ -3838,11 +3858,10 @@ def _auto_pay_card_metadata_key(billing_account): def create_charge(self, card, amount_in_dollars, description, idempotency_key=None): """ Charges a stripe card and returns a transaction id """ - amount_in_cents = int((amount_in_dollars * Decimal('100')).quantize(Decimal(10))) - transaction_record = stripe.Charge.create( + transaction_record = charge_through_stripe( card=card, customer=self.customer, - amount=amount_in_cents, + amount_in_dollars=amount_in_dollars, currency=settings.DEFAULT_CURRENCY, description=description, idempotency_key=idempotency_key diff --git a/corehq/apps/accounting/payment_handlers.py b/corehq/apps/accounting/payment_handlers.py index ebcc4c38099bd..4e5d15b6b72c0 100644 --- a/corehq/apps/accounting/payment_handlers.py +++ b/corehq/apps/accounting/payment_handlers.py @@ -21,6 +21,7 @@ log_accounting_error, log_accounting_info, ) +from corehq.apps.accounting.utils.stripe import charge_through_stripe from corehq.const import USER_DATE_FORMAT stripe.api_key = settings.STRIPE_PRIVATE_KEY @@ -60,11 +61,6 @@ def update_credits(self, payment_record): """ raise NotImplementedError("you must implement update_credits") - @staticmethod - def get_amount_in_cents(amount): - amt_cents = amount * Decimal('100') - return int(amt_cents.quantize(Decimal(10))) - def update_payment_information(self, account): account.last_payment_method = LastPayment.CC_ONE_TIME account.pre_or_post_pay = PreOrPostPay.POSTPAY @@ -182,10 +178,10 @@ def cost_item_name(self): return _("Invoice #%s") % self.invoice.id def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description="Payment for Invoice %s" % self.invoice.invoice_number, ) @@ -244,10 +240,10 @@ def cost_item_name(self): return _('Bulk Payment for project space %s' % self.domain) def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description=self.cost_item_name, ) @@ -345,10 +341,10 @@ def get_charge_amount(self, request): return Decimal(request.POST['amount']) def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description="Payment for %s" % self.cost_item_name, ) diff --git a/corehq/apps/accounting/tests/generator.py b/corehq/apps/accounting/tests/generator.py index 6ec71c72cccc3..23d695880e164 100644 --- a/corehq/apps/accounting/tests/generator.py +++ b/corehq/apps/accounting/tests/generator.py @@ -114,7 +114,8 @@ def subscribable_plan_version(edition=SoftwarePlanEdition.STANDARD): @unit_testing_only def generate_domain_subscription(account, domain, date_start, date_end, - plan_version=None, service_type=SubscriptionType.NOT_SET, is_active=False): + plan_version=None, service_type=SubscriptionType.NOT_SET, + is_active=False, do_not_invoice=False): subscriber, _ = Subscriber.objects.get_or_create(domain=domain.name) subscription = Subscription( account=account, @@ -124,6 +125,7 @@ def generate_domain_subscription(account, domain, date_start, date_end, date_end=date_end, service_type=service_type, is_active=is_active, + do_not_invoice=do_not_invoice ) subscription.save() return subscription diff --git a/corehq/apps/accounting/tests/test_autopay_with_api.py b/corehq/apps/accounting/tests/test_autopay_with_api.py new file mode 100644 index 0000000000000..acf09d892edfd --- /dev/null +++ b/corehq/apps/accounting/tests/test_autopay_with_api.py @@ -0,0 +1,148 @@ +from django.core import mail + +from django_prbac.models import Role +import stripe + +from dimagi.utils.dates import add_months_to_date + +from corehq.apps.accounting import tasks, utils +from corehq.apps.accounting.models import ( + Invoice, + PaymentRecord, + SoftwarePlan, + SoftwarePlanVersion, + SoftwareProductRate, + StripePaymentMethod, +) +from corehq.apps.accounting.payment_handlers import ( + AutoPayInvoicePaymentHandler, +) +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.tests.test_invoicing import BaseInvoiceTestCase +from django.db import transaction +from unittest import SkipTest +from django.conf import settings + + +class TestBillingAutoPay(BaseInvoiceTestCase): + + @classmethod + def setUpClass(cls): + super(TestBillingAutoPay, cls).setUpClass() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + cls._generate_autopayable_entities() + cls._generate_non_autopayable_entities() + cls._generate_invoices() + + @classmethod + def _generate_autopayable_entities(cls): + """ + Create account, domain and subscription linked to the autopay user that have autopay enabled + """ + cls.autopay_account = cls.account + cls.autopay_account.created_by_domain = cls.domain + cls.autopay_account.save() + web_user = generator.arbitrary_user(domain_name=cls.domain.name, is_active=True, is_webuser=True) + cls.autopay_user_email = web_user.email + cls.stripe_customer = stripe.Customer.create(email=cls.autopay_user_email) + cls.addClassCleanup(cls.stripe_customer.delete) + cls.payment_method = StripePaymentMethod(web_user=cls.autopay_user_email, + customer_id=cls.stripe_customer.id) + # cls.payment_method.save() + cls.card = cls.payment_method.create_card('tok_visa', cls.autopay_account, None) + cls.payment_method.set_autopay(cls.card, cls.autopay_account, cls.domain) + cls.payment_method.save() + cls.autopay_account.update_autopay_user(cls.autopay_user_email, cls.domain) + + @classmethod + def _generate_non_autopayable_entities(cls): + """ + Create account, domain, and subscription linked to the autopay user, but that don't have autopay enabled + """ + cls.non_autopay_account = generator.billing_account( + web_user_creator=generator.create_arbitrary_web_user_name(is_dimagi=True), + web_user_contact=cls.autopay_user_email + ) + cls.non_autopay_domain = generator.arbitrary_domain() + cls.addClassCleanup(cls.non_autopay_domain.delete) + # Non-autopay subscription has same parameters as the autopayable subscription + cheap_plan = SoftwarePlan.objects.create(name='cheap') + cheap_product_rate = SoftwareProductRate.objects.create(monthly_fee=100, name=cheap_plan.name) + cheap_plan_version = SoftwarePlanVersion.objects.create( + plan=cheap_plan, + product_rate=cheap_product_rate, + role=Role.objects.first(), + ) + cls.non_autopay_subscription = generator.generate_domain_subscription( + cls.non_autopay_account, + cls.non_autopay_domain, + plan_version=cheap_plan_version, + date_start=cls.subscription.date_start, + date_end=add_months_to_date(cls.subscription.date_start, cls.subscription_length), + ) + + @classmethod + def _generate_invoices(cls): + """ + Create invoices for both autopayable and non-autopayable subscriptions + """ + # invoice date is 2 months before the end of the subscription (this is arbitrary) + invoice_date = utils.months_from_date(cls.subscription.date_start, cls.subscription_length - 2) + tasks.calculate_users_in_all_domains(invoice_date) + tasks.generate_invoices_based_on_date(invoice_date) + + def test_get_autopayable_invoices(self): + """ + Invoice.autopayable_invoices() should return invoices that can be automatically paid + """ + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + + autopayable_invoices = Invoice.autopayable_invoices(date_due) + + self.assertCountEqual(autopayable_invoices, autopayable_invoice) + + def test_get_autopayable_invoices_returns_nothing(self): + """ + Invoice.autopayable_invoices() should not return invoices if the customer does not have an autopay method + """ + not_autopayable_invoice = Invoice.objects.filter(subscription=self.non_autopay_subscription) + date_due = not_autopayable_invoice.first().date_due + autopayable_invoices = Invoice.autopayable_invoices(date_due) + self.assertCountEqual(autopayable_invoices, []) + + def test_pay_autopayable_invoices(self): + original_outbox_length = len(mail.outbox) + + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + + AutoPayInvoicePaymentHandler().pay_autopayable_invoices(date_due) + self.assertAlmostEqual(autopayable_invoice.first().get_total(), 0) + self.assertEqual(len(PaymentRecord.objects.all()), 1) + self.assertEqual(len(mail.outbox), original_outbox_length + 1) + + def test_double_charge_is_prevented_and_only_one_payment_record_created(self): + self.original_outbox_length = len(mail.outbox) + invoice = Invoice.objects.get(subscription=self.subscription) + original_amount = invoice.balance + self._run_autopay() + # Add balance to the same invoice so it gets paid again + invoice = Invoice.objects.get(subscription=self.subscription) + invoice.balance = original_amount + invoice.save() + # Run autopay again to test no double charge + with transaction.atomic(), self.assertLogs(level='ERROR') as log_cm: + self._run_autopay() + self.assertIn("[BILLING] [Autopay] Attempt to double charge invoice", "\n".join(log_cm.output)) + self.assertEqual(len(PaymentRecord.objects.all()), 1) + self.assertEqual(len(mail.outbox), self.original_outbox_length + 1) + + def _run_autopay(self): + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + AutoPayInvoicePaymentHandler().pay_autopayable_invoices(date_due) diff --git a/corehq/apps/accounting/tests/test_customer_invoicing.py b/corehq/apps/accounting/tests/test_customer_invoicing.py index d0a50adb73ff2..7ce1bbab09c26 100644 --- a/corehq/apps/accounting/tests/test_customer_invoicing.py +++ b/corehq/apps/accounting/tests/test_customer_invoicing.py @@ -37,6 +37,7 @@ arbitrary_sms_billables_for_domain, ) from corehq.util.dates import get_previous_month_date_range +from corehq.apps.domain.shortcuts import create_domain class BaseCustomerInvoiceCase(BaseAccountingTest): @@ -47,153 +48,126 @@ class BaseCustomerInvoiceCase(BaseAccountingTest): def setUpClass(cls): super(BaseCustomerInvoiceCase, cls).setUpClass() + # In the test, we want to setup the senario mimic how Ops will setup enterprise account + # Only one subscription should have do_not_invoice=False, this subscription will be the main subscription + # All other subscriptions should have do_not_invoice=True and the same plan as the main subscription has + if cls.is_using_test_plans: generator.bootstrap_test_software_plan_versions() + cls.addClassCleanup(utils.clear_plan_version_cache) cls.billing_contact = generator.create_arbitrary_web_user_name() cls.dimagi_user = generator.create_arbitrary_web_user_name(is_dimagi=True) cls.account = generator.billing_account( cls.dimagi_user, cls.billing_contact) - cls.domain = generator.arbitrary_domain() cls.account.is_customer_billing_account = True cls.account.save() cls.advanced_plan = DefaultProductPlan.get_default_plan_version(edition=SoftwarePlanEdition.ADVANCED) cls.advanced_plan.plan.is_customer_software_plan = True - cls.subscription_length = 15 # months - subscription_start_date = date(2016, 2, 23) - subscription_end_date = add_months_to_date(subscription_start_date, cls.subscription_length) - cls.subscription = generator.generate_domain_subscription( - cls.account, - cls.domain, - date_start=subscription_start_date, - date_end=subscription_end_date, - ) + # This will be the domain with the main subscription + cls.main_domain = cls._create_domain("main domain") + cls.main_subscription_length = 15 # months + main_subscription_start_date = date(2016, 2, 23) + main_subscription_end_date = add_months_to_date(main_subscription_start_date, cls.main_subscription_length) - advanced_subscription_end_date = add_months_to_date(subscription_end_date, 2) - cls.domain2 = generator.arbitrary_domain() - cls.sub2 = generator.generate_domain_subscription( + cls.main_subscription = generator.generate_domain_subscription( cls.account, - cls.domain2, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=cls.advanced_plan + cls.main_domain, + date_start=main_subscription_start_date, + date_end=main_subscription_end_date, + plan_version=cls.advanced_plan, ) - cls.domain3 = generator.arbitrary_domain() - cls.sub3 = generator.generate_domain_subscription( + cls.non_main_subscription_length = 10 # months + non_main_subscription_end_date = add_months_to_date(main_subscription_start_date, + cls.non_main_subscription_length) + cls.non_main_domain1 = cls._create_domain("non main domain 1") + cls.non_main_sub1 = generator.generate_domain_subscription( cls.account, - cls.domain3, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=cls.advanced_plan + cls.non_main_domain1, + date_start=main_subscription_start_date, + date_end=non_main_subscription_end_date, + plan_version=cls.advanced_plan, + do_not_invoice=True ) - # This subscription should not be included in any customer invoices in these tests - cls.domain_community = generator.arbitrary_domain() - cls.sub3 = generator.generate_domain_subscription( + cls.non_main_domain2 = cls._create_domain("non main domain 2") + cls.non_main_sub2 = generator.generate_domain_subscription( cls.account, - cls.domain3, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=DefaultProductPlan.get_default_plan_version(edition=SoftwarePlanEdition.COMMUNITY) + cls.non_main_domain2, + date_start=main_subscription_start_date, + date_end=non_main_subscription_end_date, + plan_version=cls.advanced_plan, + do_not_invoice=True ) - def tearDown(self): - for user in self.domain.all_users(): - user.delete(self.domain.name, deleted_by=None) - - for user in self.domain2.all_users(): - user.delete(self.domain2.name, deleted_by=None) - - for user in self.domain3.all_users(): - user.delete(self.domain3.name, deleted_by=None) - - for user in self.domain_community.all_users(): - user.delete(self.domain_community.name, deleted_by=None) + def cleanUpUser(self): + for user in self.main_domain.all_users(): + user.delete(self.main_domain.name, deleted_by=None) - if self.is_using_test_plans: - utils.clear_plan_version_cache() + for user in self.non_main_domain1.all_users(): + user.delete(self.non_main_domain1.name, deleted_by=None) - super(BaseAccountingTest, self).tearDown() + for user in self.non_main_domain2.all_users(): + user.delete(self.non_main_domain2.name, deleted_by=None) @classmethod - def tearDownClass(cls): - cls.domain.delete() - cls.domain2.delete() - cls.domain3.delete() - cls.domain_community.delete() - - super(BaseCustomerInvoiceCase, cls).tearDownClass() + def _create_domain(cls, name): + domain_obj = create_domain(name) + cls.addClassCleanup(domain_obj.delete) + return domain_obj class TestCustomerInvoice(BaseCustomerInvoiceCase): def test_multiple_subscription_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(3, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(3, self.non_main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertGreater(invoice.balance, Decimal('0.0000')) - self.assertEqual(invoice.account, self.account) - - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - - num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.subscription.plan_version.feature_rates.count() + - self.sub2.plan_version.feature_rates.count()) - - def test_only_invoice_active_subscriptions(self): - """ - Test that only active subscriptions are invoiced. - Two subscriptions of the same plan only create one product line item and one set of feature line items - """ - invoice_date = utils.months_from_date(self.sub2.date_end, 1) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('851.6200')) + self.assertEqual(invoice.balance, Decimal('1200.0000')) self.assertEqual(invoice.account, self.account) num_product_line_items = invoice.lineitem_set.get_products().count() self.assertEqual(num_product_line_items, 1) num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.sub2.plan_version.feature_rates.count()) + self.assertEqual(num_feature_line_items, self.main_subscription.plan_version.feature_rates.count()) def test_no_invoice_before_start(self): """ Test that an invoice is not created if its subscriptions didn't start in the previous month. """ - calculate_users_in_all_domains(self.subscription.date_start) - tasks.generate_invoices_based_on_date(self.subscription.date_start) + calculate_users_in_all_domains(self.main_subscription.date_start) + tasks.generate_invoices_based_on_date(self.main_subscription.date_start) self.assertEqual(CustomerInvoice.objects.count(), 0) def test_no_invoice_after_end(self): """ No invoices should be generated for the months after the end date of the subscriptions. """ - invoice_date = utils.months_from_date(self.sub2.date_end, 2) + invoice_date = utils.months_from_date(self.main_subscription.date_end, 2) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 0) def test_deleted_domain_in_multiple_subscription_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, 2) + """ + Test the customer invoice can still be created after one of the domains was deleted + """ + invoice_date = utils.months_from_date(self.main_subscription.date_start, 2) domain_to_be_deleted = generator.arbitrary_domain() generator.generate_domain_subscription( self.account, domain_to_be_deleted, - date_start=self.sub2.date_start, - date_end=self.sub2.date_end, + date_start=self.main_subscription.date_start, + date_end=self.main_subscription.date_end, plan_version=self.advanced_plan ) domain_to_be_deleted.delete(leave_tombstone=True) @@ -205,7 +179,7 @@ def test_deleted_domain_in_multiple_subscription_invoice(self): invoice = CustomerInvoice.objects.first() num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) + self.assertEqual(num_product_line_items, 1) class TestProductLineItem(BaseCustomerInvoiceCase): @@ -216,29 +190,28 @@ class TestProductLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestProductLineItem, self).setUp() - self.product_rate = self.subscription.plan_version.product_rate + self.product_rate = self.main_subscription.plan_version.product_rate def test_product_line_items(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - product_line_items = invoice.lineitem_set.get_products() - self.assertEqual(product_line_items.count(), 2) - product_descriptions = [line_item.base_description for line_item in product_line_items] - self.assertItemsEqual(product_descriptions, ['One month of CommCare Advanced Edition Software Plan.', - 'One month of CommCare Standard Edition Software Plan.']) - product_costs = [line_item.base_cost for line_item in product_line_items] - self.assertItemsEqual(product_costs, [self.product_rate.monthly_fee, - self.advanced_plan.product_rate.monthly_fee]) + product_line_item_count = invoice.lineitem_set.get_products().count() + self.assertEqual(product_line_item_count, 1) + product_line_item = invoice.lineitem_set.get_products().first() + product_description = product_line_item.base_description + self.assertEqual(product_description, 'One month of CommCare Advanced Edition Software Plan.') + product_cost = product_line_item.base_cost + self.assertEqual(product_cost, self.product_rate.monthly_fee) def test_product_line_items_in_quarterly_invoice(self): self.account.invoicing_plan = InvoicingPlan.QUARTERLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) for months_before_invoice_date in range(3): user_date = date(invoice_date.year, invoice_date.month, 1) user_date -= relativedelta.relativedelta(months=months_before_invoice_date) @@ -247,19 +220,18 @@ def test_product_line_items_in_quarterly_invoice(self): self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('4500.0000')) + self.assertEqual(invoice.balance, Decimal('3600.0000')) self.assertEqual(invoice.account, self.account) - # There should be two product line items, with 3 months billed for each - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - for product_line_item in invoice.lineitem_set.get_products().all(): - self.assertEqual(product_line_item.quantity, 3) + num_product_line_item = invoice.lineitem_set.get_products().count() + self.assertEqual(num_product_line_item, 1) + product_line_item = invoice.lineitem_set.get_products().first() + self.assertEqual(product_line_item.quantity, 3) def test_product_line_items_in_yearly_invoice(self): self.account.invoicing_plan = InvoicingPlan.YEARLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) for months_before_invoice_date in range(12): user_date = date(invoice_date.year, invoice_date.month, 1) user_date -= relativedelta.relativedelta(months=months_before_invoice_date) @@ -268,75 +240,43 @@ def test_product_line_items_in_yearly_invoice(self): self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('18000.0000')) + self.assertEqual(invoice.balance, Decimal('14400.0000')) self.assertEqual(invoice.account, self.account) - # There should be two product line items, with 3 months billed for each - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - for product_line_item in invoice.lineitem_set.get_products().all(): - self.assertEqual(product_line_item.quantity, 12) - - def test_subscriptions_marked_do_not_invoice_not_included(self): - self.subscription.do_not_invoice = True - - invoice_date = utils.months_from_date(self.sub2.date_end, 1) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('851.6200')) - self.assertEqual(invoice.account, self.account) - - product_line_items = invoice.lineitem_set.get_products() - self.assertEqual(product_line_items.count(), 1) - self.assertEqual( - product_line_items.first().base_description, - None - ) - self.assertEqual( - product_line_items.first().unit_description, - '22 days of CommCare Advanced Edition Software Plan. (Jul 1 - Jul 22)' - ) - - num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.sub2.plan_version.feature_rates.count()) + num_product_line_items = invoice.lineitem_set.get_products() + self.assertEqual(num_product_line_items.count(), 1) + product_line_item = invoice.lineitem_set.get_products().first() + self.assertEqual(product_line_item.quantity, 12) def test_account_level_product_credits(self): CreditLine.add_credit( - amount=self.subscription.plan_version.product_rate.monthly_fee / 2, + amount=self.main_subscription.plan_version.product_rate.monthly_fee / 2, account=self.account, is_product=True ) - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1350.0000')) + self.assertEqual(invoice.balance, Decimal('600.0000')) def test_subscription_level_product_credits(self): CreditLine.add_credit( - self.subscription.plan_version.product_rate.monthly_fee / 2, - is_product=True, - subscription=self.subscription - ) - CreditLine.add_credit( - self.sub2.plan_version.product_rate.monthly_fee / 4, + self.main_subscription.plan_version.product_rate.monthly_fee / 2, is_product=True, - subscription=self.sub2, + subscription=self.main_subscription ) - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1050.0000')) + self.assertEqual(invoice.balance, Decimal('600.0000')) class TestUserLineItem(BaseCustomerInvoiceCase): @@ -345,42 +285,45 @@ class TestUserLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestUserLineItem, self).setUp() - self.user_rate = self.subscription.plan_version.feature_rates \ + self.user_rate = self.main_subscription.plan_version.feature_rates \ .filter(feature__feature_type=FeatureType.USER).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.USER).get() - self.invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + self.invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.non_main_subscription_length)) def test_under_limit(self): - num_users = random.randint(0, self.user_rate.monthly_limit) - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) + num_users_main_domain = random.randint(0, self.user_rate.monthly_limit / 2) + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) - num_users_advanced = random.randint(0, self.advanced_rate.monthly_limit) - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_non_main_domain1 = random.randint(0, self.user_rate.monthly_limit / 2) + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1500.0000')) + self.assertEqual(invoice.balance, Decimal('1200.0000')) user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) - for user_line_item in user_line_items: - self.assertEqual(user_line_item.quantity, 0) - self.assertEqual(user_line_item.subtotal, Decimal('0.0000')) - self.assertEqual(user_line_item.total, Decimal('0.0000')) - self.assertIsNone(user_line_item.base_description) - self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) - self.assertIsNone(user_line_item.unit_description) - self.assertEqual(user_line_item.unit_cost, Decimal('1.0000')) + self.assertEqual(user_line_items.count(), 1) + user_line_item = invoice.lineitem_set.get_feature_by_type(FeatureType.USER).first() + self.assertEqual(user_line_item.quantity, 0) + self.assertEqual(user_line_item.subtotal, Decimal('0.0000')) + self.assertEqual(user_line_item.total, Decimal('0.0000')) + self.assertIsNone(user_line_item.base_description) + self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) + self.assertIsNone(user_line_item.unit_description) + self.assertEqual(user_line_item.unit_cost, Decimal('1.0000')) def test_over_limit(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) + num_users_main_domain = self.user_rate.monthly_limit + 1 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + + num_users_non_main_domain1 = self.user_rate.monthly_limit + 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + self.addCleanup(self.cleanUpUser) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -388,100 +331,107 @@ def test_over_limit(self): invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) - for user_line_item in user_line_items: - self.assertIsNone(user_line_item.base_description) - self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) - num_to_charge = num_users - self.user_rate.monthly_limit - self.assertEqual(num_to_charge, user_line_item.quantity) - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.unit_cost, self.user_rate.per_excess_fee) - self.assertEqual(user_line_item.total, self.user_rate.per_excess_fee * num_to_charge) - self.assertEqual(user_line_item.subtotal, self.user_rate.per_excess_fee * num_to_charge) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.unit_cost, self.advanced_rate.per_excess_fee) - self.assertEqual(user_line_item.total, self.advanced_rate.per_excess_fee * num_to_charge) - self.assertEqual(user_line_item.subtotal, self.advanced_rate.per_excess_fee * num_to_charge) - - def test_account_level_user_credits(self): + self.assertEqual(user_line_items.count(), 1) + user_line_item = invoice.lineitem_set.get_feature_by_type(FeatureType.USER).first() + self.assertIsNone(user_line_item.base_description) + self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + self.assertEqual(num_to_charge, user_line_item.quantity) + self.assertEqual(user_line_item.unit_cost, self.user_rate.per_excess_fee) + self.assertEqual(user_line_item.total, self.user_rate.per_excess_fee * num_to_charge) + self.assertEqual(user_line_item.subtotal, self.user_rate.per_excess_fee * num_to_charge) + + def test_balance_reflects_credit_deduction_for_account_level_user_credits(self): # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_main_domain = self.user_rate.monthly_limit + 10 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + num_users_non_main_domain1 = 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) # Cover the cost of 1 User CreditLine.add_credit( - amount=Decimal(2.0000), + amount=Decimal(1.0000), feature_type=FeatureType.USER, account=self.account, ) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) + + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1509.0000)) - - def test_subscription_level_user_credits(self): + self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 1) + + # Comment out until subscription level bug is fixed + # def test_balance_reflects_credit_deduction_for_multiple_subscription_level_user_credit(self): + # # Add User usage + # num_users_main_domain = self.user_rate.monthly_limit + 10 + # generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + # num_users_non_main_domain1 = 10 + # generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + # self.addCleanup(self.cleanUpUser) + + # # Cover the cost of 2 User for main subscription + # CreditLine.add_credit( + # amount=Decimal(2.0000), + # feature_type=FeatureType.USER, + # subscription=self.main_subscription + # ) + + # # Cover the cost of 5 User for non main subscription 1 + # CreditLine.add_credit( + # amount=Decimal(5.0000), + # feature_type=FeatureType.USER, + # subscription=self.non_main_sub1 + # ) + + # num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + + # calculate_users_in_all_domains(self.invoice_date) + # tasks.generate_invoices_based_on_date(self.invoice_date) + # self.assertEqual(CustomerInvoice.objects.count(), 1) + # invoice = CustomerInvoice.objects.first() + # self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 7) + + def test_balance_reflects_credit_deduction_for_single_subscription_level_user_credit(self): # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_main_domain = self.user_rate.monthly_limit + 10 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + num_users_non_main_domain1 = 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) - # Cover the cost of 1 User on the Standard subscription + # Cover the cost of 2 User CreditLine.add_credit( amount=Decimal(2.0000), feature_type=FeatureType.USER, - subscription=self.subscription - ) - # Cover the cost of 5 Users on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(10.0000), - feature_type=FeatureType.USER, - subscription=self.sub2 + subscription=self.main_subscription ) + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1500.0000)) - - def test_one_subscription_level_user_credit(self): - # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - - # Cover the cost of 2 Users on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(4.0000), - feature_type=FeatureType.USER, - subscription=self.sub2 - ) - - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1507.0000)) + self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 2) class TestSmsLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestSmsLineItem, self).setUp() - self.sms_rate = self.subscription.plan_version.feature_rates.filter( + self.sms_rate = self.main_subscription.plan_version.feature_rates.filter( feature__feature_type=FeatureType.SMS ).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.SMS).get() self.invoice_date = utils.months_from_date( - self.subscription.date_start, random.randint(2, self.subscription_length) + self.main_subscription.date_start, random.randint(2, self.non_main_subscription_length) ) self.sms_date = utils.months_from_date(self.invoice_date, -1) @@ -490,17 +440,17 @@ def tearDown(self): super(TestSmsLineItem, self).tearDown() def test_under_limit(self): - num_sms = self.sms_rate.monthly_limit // 2 + num_sms_main_domain = self.sms_rate.monthly_limit // 2 arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms, direction=INCOMING + self.main_domain, self.sms_date, num_sms_main_domain, direction=INCOMING ) - num_sms_advanced = self.advanced_rate.monthly_limit // 2 + num_sms_non_main_domain1 = self.sms_rate.monthly_limit // 2 arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced, direction=INCOMING + self.non_main_domain1, self.sms_date, num_sms_non_main_domain1, direction=INCOMING ) sms_line_items = self._create_sms_line_items() - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) @@ -511,98 +461,96 @@ def test_under_limit(self): self.assertEqual(sms_line_item.total, Decimal('0.0000')) def test_over_limit(self): - num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + num_sms_main_domain = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) + main_domain_billables = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms_main_domain ) - num_sms_advanced = random.randint(self.advanced_rate.monthly_limit + 1, - self.advanced_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + num_sms_non_main_domain1 = random.randint(self.sms_rate.monthly_limit + 1, + self.sms_rate.monthly_limit + 2) + non_main_domain1_billables = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms_non_main_domain1 ) sms_line_items = self._create_sms_line_items() - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + non_main_domain1_billables[self.sms_rate.monthly_limit:] + + main_domain_billables + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) - def test_subscription_level_sms_credits(self): + # Comment out until subscription level credit bug is fixed + # def test_balance_reflects_credit_deduction_for_multiple_subscription_level_sms_credits(self): + # # Add SMS usage + # arbitrary_sms_billables_for_domain( + # self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 + # ) + # arbitrary_sms_billables_for_domain( + # self.non_main_domain1, self.sms_date, num_sms=10 + # ) + + # # Cover the cost of 1 SMS for main subscription + # CreditLine.add_credit( + # amount=Decimal(0.7500), + # feature_type=FeatureType.SMS, + # subscription=self.main_subscription + # ) + + # # Cover the cost of 1 SMS for non main subscription 1 + # CreditLine.add_credit( + # amount=Decimal(0.7500), + # feature_type=FeatureType.SMS, + # subscription=self.non_main_sub1 + # ) + + # calculate_users_in_all_domains(self.invoice_date) + # tasks.generate_invoices_based_on_date(self.invoice_date) + # self.assertEqual(CustomerInvoice.objects.count(), 1) + # invoice = CustomerInvoice.objects.first() + # self.assertEqual(invoice.balance, Decimal('1206.7500')) + + def test_balance_reflects_credit_deduction_for_single_subscription_level_sms_credits(self): # Add SMS usage arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 + self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 ) arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 + self.non_main_domain1, self.sms_date, num_sms=10 ) - # Cover the cost of 1 SMS on the Standard subscription - CreditLine.add_credit( - amount=Decimal(0.7500), - feature_type=FeatureType.SMS, - subscription=self.subscription - ) - # Cover the cost of 10 SMS on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(7.5000), - feature_type=FeatureType.SMS, - subscription=self.sub2, - ) - - calculate_users_in_all_domains(self.invoice_date) - tasks.generate_invoices_based_on_date(self.invoice_date) - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1500.0000')) - - def test_one_subscription_level_sms_credit(self): - # Add SMS usage - arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 - ) - arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 - ) - - # Cover the cost of 1 SMS on the Standard subscription + # Cover the cost of 1 SMS CreditLine.add_credit( amount=Decimal(0.7500), feature_type=FeatureType.SMS, - subscription=self.subscription + subscription=self.main_subscription ) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1507.5000')) + self.assertEqual(invoice.balance, Decimal('1207.5000')) - def test_account_level_sms_credits(self): + def test_balance_reflects_credit_deduction_for_account_level_sms_credits(self): # Add SMS usage arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 + self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 ) arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 + self.non_main_domain1, self.sms_date, num_sms=10 ) # Cover the cost of 1 SMS CreditLine.add_credit( - amount=Decimal(0.5000), + amount=Decimal(0.7500), feature_type=FeatureType.SMS, account=self.account, ) @@ -611,7 +559,7 @@ def test_account_level_sms_credits(self): tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1507.7500')) + self.assertEqual(invoice.balance, Decimal('1207.5000')) def _create_sms_line_items(self): calculate_users_in_all_domains(self.invoice_date) @@ -635,104 +583,80 @@ class TestQuarterlyInvoicing(BaseCustomerInvoiceCase): def setUp(self): super(TestQuarterlyInvoicing, self).setUp() - self.user_rate = self.subscription.plan_version.feature_rates \ + self.user_rate = self.main_subscription.plan_version.feature_rates \ .filter(feature__feature_type=FeatureType.USER).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.USER).get() self.initialize_domain_user_history_objects() - self.sms_rate = self.subscription.plan_version.feature_rates.filter( - feature__feature_type=FeatureType.SMS - ).get() - self.advanced_sms_rate = self.advanced_plan.feature_rates.filter( + self.sms_rate = self.main_subscription.plan_version.feature_rates.filter( feature__feature_type=FeatureType.SMS ).get() self.invoice_date = utils.months_from_date( - self.subscription.date_start, random.randint(2, self.subscription_length) + self.main_subscription.date_start, random.randint(3, self.non_main_subscription_length) ) self.sms_date = utils.months_from_date(self.invoice_date, -1) def initialize_domain_user_history_objects(self): record_dates = [] - month_end = self.subscription.date_end - while month_end > self.subscription.date_start: + month_end = self.main_subscription.date_end + while month_end > self.main_subscription.date_start: record_dates.append(month_end) _, month_end = get_previous_month_date_range(month_end) - num_users = self.user_rate.monthly_limit + 1 + self.num_users = self.user_rate.monthly_limit + 1 for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain, - num_users=num_users, + domain=self.main_domain, + num_users=self.num_users, record_date=record_date ) - num_users = self.advanced_rate.monthly_limit + 2 for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain2, - num_users=num_users, + domain=self.non_main_domain1, + num_users=self.num_users, record_date=record_date ) for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain3, + domain=self.non_main_domain2, num_users=0, record_date=record_date ) def test_user_over_limit_in_quarterly_invoice(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - - num_users_advanced = self.advanced_rate.monthly_limit + 2 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - self.account.invoicing_plan = InvoicingPlan.QUARTERLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) - tasks.generate_invoices_based_on_date(invoice_date) + tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) + num_excess_users_quarterly = (self.num_users * 2 - self.user_rate.monthly_limit) * 3 + self.assertEqual(user_line_items.count(), 1) for user_line_item in user_line_items: - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.quantity, 3) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.quantity, 6) + self.assertEqual(user_line_item.quantity, num_excess_users_quarterly) def test_user_over_limit_in_yearly_invoice(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - - num_users_advanced = self.advanced_rate.monthly_limit + 2 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - self.account.invoicing_plan = InvoicingPlan.YEARLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) + num_excess_users_quarterly = (self.num_users * 2 - self.user_rate.monthly_limit) * 12 + self.assertEqual(user_line_items.count(), 1) for user_line_item in user_line_items: - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.quantity, 12) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.quantity, 24) + self.assertEqual(user_line_item.quantity, num_excess_users_quarterly) def test_sms_over_limit_in_quarterly_invoice(self): num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + billables_main_domain = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms ) - num_sms_advanced = random.randint(self.advanced_sms_rate.monthly_limit + 1, - self.advanced_sms_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + billables_non_main_domain1 = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms ) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -740,34 +664,28 @@ def test_sms_over_limit_in_quarterly_invoice(self): invoice = CustomerInvoice.objects.first() sms_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.SMS) - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_sms_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_sms_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + billables_non_main_domain1[self.sms_rate.monthly_limit:] + + billables_main_domain + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) def test_sms_over_limit_in_yearly_invoice(self): num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + billables_main_domain = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms ) - num_sms_advanced = random.randint(self.advanced_sms_rate.monthly_limit + 1, - self.advanced_sms_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + billables_non_main_domain1 = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms ) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -775,22 +693,18 @@ def test_sms_over_limit_in_yearly_invoice(self): invoice = CustomerInvoice.objects.first() sms_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.SMS) - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_sms_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_sms_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + billables_non_main_domain1[self.sms_rate.monthly_limit:] + + billables_main_domain + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) diff --git a/corehq/apps/accounting/tests/test_race_condition_is_prevented.py b/corehq/apps/accounting/tests/test_race_condition_is_prevented.py index c2beceb2c1c48..b3a62b041c9b6 100644 --- a/corehq/apps/accounting/tests/test_race_condition_is_prevented.py +++ b/corehq/apps/accounting/tests/test_race_condition_is_prevented.py @@ -38,8 +38,8 @@ def test_unique_constraint_prevents_duplicate_invoice(self): class UniqueConstraintCustomerInvoiceTest(BaseCustomerInvoiceCase): def test_unique_constraint_prevents_duplicate_customer_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(3, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(3, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) with self.assertLogs(level='ERROR') as log_cm: diff --git a/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py b/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py new file mode 100644 index 0000000000000..fe1bc0daf4ca8 --- /dev/null +++ b/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py @@ -0,0 +1,220 @@ +from decimal import Decimal +from django.conf import settings +import stripe +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.tests.base_tests import BaseAccountingTest +from corehq.apps.accounting.models import StripePaymentMethod +from unittest import SkipTest +from unittest.mock import patch + + +class TestStripePaymentMethod(BaseAccountingTest): + + def setUp(self): + super(TestStripePaymentMethod, self).setUp() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + stripe.api_key = settings.STRIPE_PRIVATE_KEY + + self.web_user_email = "test_web_user@gmail.com" + self.dimagi_user_email = "test_dimagi_user@dimagi.com" + self.billing_account = generator.billing_account(self.dimagi_user_email, self.web_user_email) + + self.stripe_customer = stripe.Customer.create(email=self.web_user_email) + self.addCleanup(self.stripe_customer.delete) + self.payment_method = StripePaymentMethod(web_user=self.web_user_email, + customer_id=self.stripe_customer.id) + self.payment_method.save() + # Stripe suggest using test tokens, see https://stripe.com/docs/testing. + self.card = self.payment_method.create_card('tok_visa', self.billing_account, None) + + self.currency = generator.init_default_currency() + + def test_setup_autopay_for_first_time(self): + self.assertEqual(self.billing_account.auto_pay_user, None) + self.assertFalse(self.billing_account.auto_pay_enabled) + + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + def test_replace_card_for_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + # Replace autopay card + other_web_user = "another_web_user@gmail.com" + other_stripe_customer = stripe.Customer.create(email=other_web_user) + self.addCleanup(other_stripe_customer.delete) + other_payment_method = StripePaymentMethod(web_user=other_web_user, customer_id=other_stripe_customer.id) + other_payment_method.save() + other_stripe_card = other_payment_method.create_card('tok_mastercard', self.billing_account, None, True) + + self.assertEqual(self.billing_account.auto_pay_user, other_web_user) + self.assertTrue(other_stripe_card.metadata["auto_pay_{}".format(self.billing_account.id)]) + # The old autopay card should be removed from this billing account + card = self.payment_method.all_cards[0] + self.assertFalse(card.metadata["auto_pay_{}".format(self.billing_account.id)] == 'True') + + def test_same_card_used_by_multiple_billing_accounts(self): + billing_account_2 = generator.billing_account(self.dimagi_user_email, self.web_user_email) + + # Use the card for first billing account + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + # Use the same card for the second billing account + self.payment_method.set_autopay(self.card, billing_account_2, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True', + "auto_pay_{}".format(billing_account_2.id): 'True'}) + + def test_unset_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + + self.payment_method.unset_autopay(self.card, self.billing_account) + + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'False'}) + self.assertIsNone(self.billing_account.auto_pay_user) + self.assertFalse(self.billing_account.auto_pay_enabled) + + def test_get_stripe_customer_if_existed(self): + customer = self.payment_method._get_or_create_stripe_customer() + self.assertEqual(customer.id, self.stripe_customer.id) + + def test_create_stripe_customer_if_not_existed(self): + web_user_email = generator.create_arbitrary_web_user_name() + payment_method = StripePaymentMethod(web_user=web_user_email) + customer = payment_method._get_or_create_stripe_customer() + self.assertEqual(customer.email, web_user_email) + self.addCleanup(customer.delete) + + def test_all_cards_raise_authentication_error_when_stripe_key_is_wrong(self): + stripe.api_key = "12345678" + with self.assertRaises(stripe.error.AuthenticationError): + self.payment_method.all_cards + + def test_all_cards_return_the_correct_collection_of_cards_for_a_customer(self): + # Get the payment methods that is associated with the customer + payment_methods = stripe.PaymentMethod.list( + customer=self.stripe_customer.id, + type="card", + ) + cards = self.payment_method.all_cards + actual_card_ids = [card.fingerprint for card in cards] + expected_card_ids = [payment_method.card.fingerprint for payment_method in payment_methods] + self.assertCountEqual(actual_card_ids, expected_card_ids) + + def test_all_cards_return_empty_array_for_customer_have_no_cards(self): + payment_method = StripePaymentMethod(web_user="no_card@gmail.com") + payment_method.save() + cards = payment_method.all_cards + self.assertEqual(len(cards), 0) + + def test_all_cards_return_empty_array_if_no_stripe_key(self): + stripe.api_key = None + with patch.object(settings, "STRIPE_PRIVATE_KEY", None): + self.assertEqual(len(self.payment_method.all_cards), 0) + + def test_all_cards_serialized_return_the_correct_property_of_a_card(self): + cards = self.payment_method.all_cards_serialized(self.billing_account) + card = cards[0] + self.assertEqual(card["brand"], "Visa") + self.assertEqual(card["last4"], "4242") + # stripe might change exp date for the test card, so we don't assert absolute value + self.assertIn("exp_month", card) + self.assertIn("exp_year", card) + self.assertIn("token", card) + self.assertFalse(card["is_autopay"]) + + def test_get_card_return_the_correct_card_object(self): + # TODO: Should I rename get_card parameter to card_id? + card = self.payment_method.get_card(self.card.id) + self.assertEqual(card.id, self.card.id) + + def test_get_autopay_card_when_no_autopay_card(self): + self.assertEqual(self.billing_account.auto_pay_user, None) + self.assertFalse(self.billing_account.auto_pay_enabled) + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNone(result) + + def test_get_autopay_card_when_only_one_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertEqual(result.id, self.card.id) + + def test_get_autopay_card_when_one_of_many_card_is_autopay(self): + card2 = self.payment_method.create_card('tok_discover', self.billing_account, None, True) + card3 = self.payment_method.create_card('tok_amex', self.billing_account, None, True) + self.addCleanup(card2.delete) + self.addCleanup(card3.delete) + + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNotNone(result.id, card2.id) + + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNotNone(result.id, card3.id) + + def test_remove_card_successful(self): + self.assertEqual(len(self.payment_method.customer.cards), 1) + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.id, self.payment_method.get_autopay_card(self.billing_account).id) + + self.payment_method.remove_card(self.card.id) + self.assertEqual(len(self.payment_method.customer.cards), 0) + self.billing_account.refresh_from_db() + self.assertIsNone(self.billing_account.auto_pay_user) + self.assertFalse(self.billing_account.auto_pay_enabled) + + def test_remove_card_non_existent(self): + with self.assertRaises(stripe.error.InvalidRequestError): + self.payment_method.remove_card("non_existent_card_id") + + def test_create_card_creates_card(self): + created_card = self.payment_method.create_card('tok_discover', self.billing_account, None) + self.addCleanup(created_card.delete) + self.assertIsNotNone(created_card) + self.assertEqual(created_card.brand, 'Discover') + + def test_create_charge_success(self): + description = "Test charge" + amount_in_dollars = Decimal('100') + # Perform the charge + transaction_id = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description + ) + # Verify the charge was successful by retrieving the charge from Stripe + charge = stripe.Charge.retrieve(transaction_id) + self.assertIsNotNone(charge) + self.assertEqual(charge.amount, int(amount_in_dollars * Decimal('100'))) + self.assertEqual(charge.currency, self.currency.code.lower()) + self.assertEqual(charge.description, description) + + def test_create_charge_with_idempotency_key(self): + description = "Test charge" + amount_in_dollars = Decimal('100') + # Idempotency key ensures that the charge can be retried without fear of double charging + idempotency_key = 'test_idempotency_key_12345' + transaction_id_first_attempt = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description, + idempotency_key=idempotency_key + ) + transaction_id_second_attempt = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description, + idempotency_key=idempotency_key + ) + self.assertEqual(transaction_id_first_attempt, transaction_id_second_attempt) diff --git a/corehq/apps/accounting/tests/test_stripe_utils_with_api.py b/corehq/apps/accounting/tests/test_stripe_utils_with_api.py new file mode 100644 index 0000000000000..6bbbc7e3fb042 --- /dev/null +++ b/corehq/apps/accounting/tests/test_stripe_utils_with_api.py @@ -0,0 +1,59 @@ +from decimal import Decimal +from django.test import TestCase +import stripe +from corehq.apps.accounting.models import StripePaymentMethod +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.utils.stripe import get_customer_cards, charge_through_stripe +from unittest import SkipTest +from django.conf import settings + + +class StripeUtilsTests(TestCase): + + def setUp(self): + super().setUp() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + self.web_user_email = "test@example.com" + + # Set up Stripe customer with a card + self.stripe_customer = stripe.Customer.create(email=self.web_user_email) + self.addCleanup(self.stripe_customer.delete) + + # Create a corresponding local StripePaymentMethod instance + self.dimagi_user_email = "test_dimagi_user@dimagi.com" + self.billing_account = generator.billing_account(self.dimagi_user_email, self.web_user_email) + self.payment_method = StripePaymentMethod.objects.create( + web_user=self.web_user_email, + customer_id=self.stripe_customer.id + ) + self.payment_method.save() + self.card = self.payment_method.create_card('tok_visa', self.billing_account, None) + self.payment_method.create_card('tok_discover', self.billing_account, None) + + def test_get_customer_cards(self): + cards = get_customer_cards(self.web_user_email) + self.assertIsNotNone(cards) + self.assertEqual(len(cards['data']), 2) + self.assertEqual(cards['data'][0]['last4'], '4242') + self.assertEqual(cards['data'][0]['brand'], 'Visa') + self.assertEqual(cards['data'][0].id, self.card.id) + + def test_charge_through_stripe_successful(self): + amount_in_dollars = Decimal('10.00') + currency = 'usd' + description = 'Test charge' + charge = charge_through_stripe( + card=self.card.id, + customer=self.stripe_customer.id, + amount_in_dollars=amount_in_dollars, + currency=currency, + description=description + ) + self.assertIsNotNone(charge) + self.assertEqual(charge.amount, amount_in_dollars * 100) # Stripe uses cents + self.assertEqual(charge.currency, currency) + self.assertEqual(charge.description, description) diff --git a/corehq/apps/accounting/utils/__init__.py b/corehq/apps/accounting/utils/__init__.py index 6d4786c07ffd7..91e4d64a8afa5 100644 --- a/corehq/apps/accounting/utils/__init__.py +++ b/corehq/apps/accounting/utils/__init__.py @@ -218,28 +218,6 @@ def fmt_dollar_amount(decimal_value): return _("USD %s") % quantize_accounting_decimal(decimal_value) -def get_customer_cards(username, domain): - from corehq.apps.accounting.models import ( - StripePaymentMethod, PaymentMethodType, - ) - import stripe - try: - payment_method = StripePaymentMethod.objects.get( - web_user=username, - method_type=PaymentMethodType.STRIPE - ) - stripe_customer = payment_method.customer - return dict(stripe_customer.cards) - except StripePaymentMethod.DoesNotExist: - pass - except stripe.error.AuthenticationError: - if not settings.STRIPE_PRIVATE_KEY: - log_accounting_info("Private key is not defined in settings") - else: - raise - return None - - def is_accounting_admin(user): accounting_privilege = Role.get_privilege(privileges.ACCOUNTING_ADMIN) if accounting_privilege is None: diff --git a/corehq/apps/accounting/utils/stripe.py b/corehq/apps/accounting/utils/stripe.py new file mode 100644 index 0000000000000..43d20fbc46ed2 --- /dev/null +++ b/corehq/apps/accounting/utils/stripe.py @@ -0,0 +1,51 @@ +from decimal import ROUND_DOWN, Decimal +from django.conf import settings +from corehq.apps.accounting.utils import log_accounting_info +import stripe + + +def get_customer_cards(username): + from corehq.apps.accounting.models import StripePaymentMethod, PaymentMethodType + + try: + payment_method = StripePaymentMethod.objects.get( + web_user=username, + method_type=PaymentMethodType.STRIPE + ) + stripe_customer = payment_method.customer + return dict(stripe_customer.cards) + except StripePaymentMethod.DoesNotExist: + pass + except stripe.error.AuthenticationError: + if not settings.STRIPE_PRIVATE_KEY: + log_accounting_info("Private key is not defined in settings") + else: + raise + return None + + +def charge_through_stripe(card, customer, amount_in_dollars, currency, description, idempotency_key=None): + """ + Creates a charge on a customer's card using Stripe's payment processing service. + + This function is a simple wrapper around the Stripe API's `Charge.create` method. + + Parameters: + - card (str): The card token or ID representing the payment source to be charged. + - customer (str): The ID of the stripe customer to whom the card belongs. + - amount_in_dollars (Decimal): The amount to charge, represented as a Decimal in dollars. + - currency (str): The three-letter ISO currency code representing the currency of the charge. + - description (str): An arbitrary string attached to the charge, for describing the transaction. + - idempotency_key (str, optional): A unique key that ensures idempotence of the charge. + + """ + amount_in_cents = int((amount_in_dollars * Decimal('100')).quantize(Decimal('1'), rounding=ROUND_DOWN)) + + return stripe.Charge.create( + card=card, + customer=customer, + amount=amount_in_cents, + currency=currency, + description=description, + idempotency_key=idempotency_key, + ) diff --git a/corehq/apps/analytics/static/analytix/js/cta_forms.js b/corehq/apps/analytics/static/analytix/js/cta_forms.js index eab98d3cd5983..3042b20dd9fcc 100644 --- a/corehq/apps/analytics/static/analytix/js/cta_forms.js +++ b/corehq/apps/analytics/static/analytix/js/cta_forms.js @@ -4,7 +4,7 @@ hqDefine('analytix/js/cta_forms', [ 'underscore', 'hqwebapp/js/initial_page_data', 'hqwebapp/js/assert_properties', - 'hqwebapp/js/validators.ko', // needed for validation of startDate and endDate + 'hqwebapp/js/bootstrap3/validators.ko', // needed for validation of startDate and endDate 'intl-tel-input/build/js/intlTelInput.min', ], function ( $, diff --git a/corehq/apps/analytics/tasks.py b/corehq/apps/analytics/tasks.py index ad82b7ca4cc4a..c8242ec070bea 100644 --- a/corehq/apps/analytics/tasks.py +++ b/corehq/apps/analytics/tasks.py @@ -63,7 +63,7 @@ from corehq.util.decorators import analytics_task from corehq.util.metrics import metrics_counter, metrics_gauge from corehq.util.metrics.const import MPM_LIVESUM, MPM_MAX -from corehq.util.soft_assert import soft_assert +from dimagi.utils.logging import notify_error logger = logging.getLogger('analytics') logger.setLevel('DEBUG') @@ -374,7 +374,7 @@ def send_hubspot_form(form_id, request, user=None, extra_fields=None): @analytics_task() def send_hubspot_form_task(form_id, web_user_id, hubspot_cookie, meta, - extra_fields=None): + extra_fields=None): web_user = WebUser.get_by_user_id(web_user_id) _send_form_to_hubspot(form_id, web_user, hubspot_cookie, meta, extra_fields=extra_fields) @@ -442,7 +442,8 @@ def _no_nonascii_unicode(value): res = km.record( email, event, - {_no_nonascii_unicode(k): _no_nonascii_unicode(v) for k, v in properties.items()} if properties else {}, + {_no_nonascii_unicode(k): _no_nonascii_unicode(v) for k, v in properties.items()} + if properties else {}, timestamp ) log_response("KM", {'email': email, 'event': event, 'properties': properties, 'timestamp': timestamp}, res) @@ -678,11 +679,8 @@ def submit_data_to_hub_and_kiss(submit_json): try: dispatcher(submit_json) except requests.exceptions.HTTPError as e: - soft_assert(to=settings.SAAS_OPS_EMAIL, send_to_ops=False)( - False, - 'Error submitting periodic analytics data to Hubspot or Kissmetrics', - {'response': e.response.content.decode('utf-8')} - ) + notify_error("Error submitting periodic analytics data to Hubspot or Kissmetrics", + details=e.response.content.decode('utf-8')) except Exception as e: notify_exception(None, "{msg}: {exc}".format(msg=error_message, exc=e)) @@ -777,10 +775,10 @@ def _is_paying_subscription(subscription, plan_version): ProBonoStatus.YES, ProBonoStatus.DISCOUNTED, ] - return (plan_version.plan.visibility != SoftwarePlanVisibility.TRIAL and - subscription.service_type not in NON_PAYING_SERVICE_TYPES and - subscription.pro_bono_status not in NON_PAYING_PRO_BONO_STATUSES and - plan_version.plan.edition != SoftwarePlanEdition.COMMUNITY) + return (plan_version.plan.visibility != SoftwarePlanVisibility.TRIAL + and subscription.service_type not in NON_PAYING_SERVICE_TYPES + and subscription.pro_bono_status not in NON_PAYING_PRO_BONO_STATUSES + and plan_version.plan.edition != SoftwarePlanEdition.COMMUNITY) # Note: using "yes" and "no" instead of True and False because spec calls # for using these values. (True is just converted to "True" in KISSmetrics) diff --git a/corehq/apps/analytics/templates/analytics/initial/appcues.html b/corehq/apps/analytics/templates/analytics/initial/appcues.html index de1595b80851f..7445aff084a89 100644 --- a/corehq/apps/analytics/templates/analytics/initial/appcues.html +++ b/corehq/apps/analytics/templates/analytics/initial/appcues.html @@ -6,10 +6,11 @@ {% elif is_saas_environment %} {% comment %} HACK: project/domain_links is a hacky way for us to ensure appcues is being shown on the ERM/MRM page. + /cloudcare/apps/v2/ is a way for us to ensure appcues is being shown on Web Apps. If we remove these checks so all users see appcues, we risk going over our appcues user cap This is intended to be a short-term fix as we search for tour alternatives. {% endcomment %} - {% if not request.user.is_authenticated or request.couch_user.days_since_created < 31 or '/project/domain_links/' in request.path %} + {% if not request.user.is_authenticated or request.couch_user.days_since_created < 31 or '/project/domain_links/' in request.path or '/cloudcare/apps/v2/' in request.path%} {% initial_analytics_data 'appcues.apiId' ANALYTICS_IDS.APPCUES_ID %} {% endif %} {% endif %} diff --git a/corehq/apps/api/decorators.py b/corehq/apps/api/decorators.py index aa8a656ee57d0..f4e4a47b5ef42 100644 --- a/corehq/apps/api/decorators.py +++ b/corehq/apps/api/decorators.py @@ -55,7 +55,7 @@ def wrapped_view(request, *args, **kwargs): throttle = get_hq_throttle() should_be_throttled = throttle.should_be_throttled(identifier) if should_be_throttled: - return HttpResponse(status=429) + return HttpResponse(status=429, headers={'Retry-After': throttle.retry_after(identifier)}) throttle.accessed(identifier, url=request.get_full_path(), request_method=request.method.lower()) return view(request, *args, **kwargs) return wrapped_view diff --git a/corehq/apps/api/resources/meta.py b/corehq/apps/api/resources/meta.py index 700ef3a826cb0..ff0c8a9b3b445 100644 --- a/corehq/apps/api/resources/meta.py +++ b/corehq/apps/api/resources/meta.py @@ -44,6 +44,8 @@ def should_be_throttled(self, identifier, **kwargs): return not api_rate_limiter.allow_usage(identifier.domain) + def retry_after(self, identifier): + return api_rate_limiter.get_retry_after(scope=identifier.domain) def accessed(self, identifier, **kwargs): """ diff --git a/corehq/apps/api/resources/v0_1.py b/corehq/apps/api/resources/v0_1.py index 444583ee445a6..e70805a1fea58 100644 --- a/corehq/apps/api/resources/v0_1.py +++ b/corehq/apps/api/resources/v0_1.py @@ -104,7 +104,7 @@ def dehydrate(self, bundle): return super(UserResource, self).dehydrate(bundle) def dehydrate_user_data(self, bundle): - user_data = bundle.obj.metadata + user_data = bundle.obj.get_user_data(bundle.obj.domain).to_dict() if self.determine_format(bundle.request) == 'application/xml': # attribute names can't start with digits in xml user_data = {k: v for k, v in user_data.items() if not k[0].isdigit()} diff --git a/corehq/apps/api/resources/v0_5.py b/corehq/apps/api/resources/v0_5.py index addda5bfa8e53..ddd7132d38c1e 100644 --- a/corehq/apps/api/resources/v0_5.py +++ b/corehq/apps/api/resources/v0_5.py @@ -141,7 +141,8 @@ MOCK_BULK_USER_ES = None -EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT = 200 +EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT = 1000 +EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT = 10000 def user_es_call(domain, q, fields, size, start_at): @@ -1358,8 +1359,8 @@ def get_datasource_data(request, config_id, domain): datasource_adapter = get_indicator_adapter(config, load_source='export_data_source') request_params = get_request_params(request).params request_params["limit"] = request.GET.dict().get("limit", EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT) - if int(request_params["limit"]) > EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT: - request_params["limit"] = EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT + if int(request_params["limit"]) > EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT: + request_params["limit"] = EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT query = cursor_based_query_for_datasource(request_params, datasource_adapter) data = response_for_cursor_based_pagination(request, query, request_params, datasource_adapter) return JsonResponse(data) diff --git a/corehq/apps/api/tests/test_user_updates.py b/corehq/apps/api/tests/test_user_updates.py index 5ed862605a3b1..955618af93e03 100644 --- a/corehq/apps/api/tests/test_user_updates.py +++ b/corehq/apps/api/tests/test_user_updates.py @@ -106,16 +106,20 @@ def test_update_phone_numbers_updates_default(self): self.assertEqual(self.user.default_phone_number, '50253311399') def test_update_user_data_succeeds(self): - self.user.update_metadata({'custom_data': "initial custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "initial custom data" update(self.user, 'user_data', {'custom_data': 'updated custom data'}) - self.assertEqual(self.user.metadata["custom_data"], "updated custom data") + self.assertEqual(self.user.get_user_data(self.domain)["custom_data"], "updated custom data") def test_update_user_data_raises_exception_if_profile_conflict(self): profile_id = self._setup_profile() with self.assertRaises(UpdateUserException) as cm: update(self.user, 'user_data', {PROFILE_SLUG: profile_id, 'conflicting_field': 'no'}) + self.assertEqual(cm.exception.message, "'conflicting_field' cannot be set directly") - self.assertEqual(cm.exception.message, 'metadata properties conflict with profile: conflicting_field') + def test_profile_not_found(self): + with self.assertRaises(UpdateUserException) as cm: + update(self.user, 'user_data', {PROFILE_SLUG: 123456}) + self.assertEqual(cm.exception.message, "User data profile not found") def test_update_groups_succeeds(self): group = Group({"name": "test", "domain": self.user.domain}) @@ -283,7 +287,7 @@ def test_update_phone_numbers_does_not_log_no_change(self): self.assertNotIn('phone_numbers', self.user_change_logger.change_messages.keys()) def test_update_user_data_logs_change(self): - self.user.update_metadata({'custom_data': "initial custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "initial custom data" update(self.user, 'user_data', @@ -293,7 +297,7 @@ def test_update_user_data_logs_change(self): self.assertIn('user_data', self.user_change_logger.fields_changed.keys()) def test_update_user_data_does_not_log_no_change(self): - self.user.update_metadata({'custom_data': "unchanged custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "unchanged custom data" update(self.user, 'user_data', {'custom_data': 'unchanged custom data'}) self.assertNotIn('user_data', self.user_change_logger.fields_changed.keys()) diff --git a/corehq/apps/api/tests/user_resources.py b/corehq/apps/api/tests/user_resources.py index 19ec86897405d..08ecd25f5ec1b 100644 --- a/corehq/apps/api/tests/user_resources.py +++ b/corehq/apps/api/tests/user_resources.py @@ -100,7 +100,7 @@ def test_get_list(self): 'last_name': '', 'phone_numbers': [], 'resource_uri': '/a/qwerty/api/v0.5/user/{}/'.format(backend_id), - 'user_data': {'commcare_project': 'qwerty'}, + 'user_data': {'commcare_project': 'qwerty', PROFILE_SLUG: ''}, 'username': 'fake_user' }) @@ -126,7 +126,7 @@ def test_get_single(self): 'last_name': '', 'phone_numbers': [], 'resource_uri': '/a/qwerty/api/v0.5/user/{}/'.format(backend_id), - 'user_data': {'commcare_project': 'qwerty'}, + 'user_data': {'commcare_project': 'qwerty', PROFILE_SLUG: ''}, 'username': 'fake_user', }) @@ -169,7 +169,7 @@ def test_create(self): self.assertEqual(user_back.email, "jdoe@example.org") self.assertEqual(user_back.language, "en") self.assertEqual(user_back.get_group_ids()[0], group._id) - self.assertEqual(user_back.user_data["chw_id"], "13/43/DFA") + self.assertEqual(user_back.get_user_data(self.domain.name)["chw_id"], "13/43/DFA") self.assertEqual(user_back.default_phone_number, "50253311399") @flag_enabled('COMMCARE_CONNECT') @@ -283,9 +283,10 @@ def test_update(self): self.assertEqual(modified.email, "tlast@example.org") self.assertEqual(modified.language, "pol") self.assertEqual(modified.get_group_ids()[0], group._id) - self.assertEqual(modified.metadata["chw_id"], "13/43/DFA") - self.assertEqual(modified.metadata[PROFILE_SLUG], self.profile.id) - self.assertEqual(modified.metadata["imaginary"], "yes") + user_data = modified.get_user_data(self.domain.name) + self.assertEqual(user_data["chw_id"], "13/43/DFA") + self.assertEqual(user_data.profile_id, self.profile.id) + self.assertEqual(user_data["imaginary"], "yes") self.assertEqual(modified.default_phone_number, "50253311399") # test user history audit @@ -298,11 +299,7 @@ def test_update(self): 'language': 'pol', 'last_name': 'last', 'first_name': 'test', - 'user_data': { - 'chw_id': '13/43/DFA', - 'commcare_profile': self.profile.id, - 'commcare_project': 'qwerty' - } + 'user_data': {'chw_id': '13/43/DFA'}, } ) self.assertTrue("50253311398" in diff --git a/corehq/apps/api/user_updates.py b/corehq/apps/api/user_updates.py index 402d1352402f6..bbb6ad188121d 100644 --- a/corehq/apps/api/user_updates.py +++ b/corehq/apps/api/user_updates.py @@ -4,6 +4,7 @@ from dimagi.utils.couch.bulk import get_docs from corehq.apps.api.exceptions import UpdateUserException +from corehq.apps.custom_data_fields.models import PROFILE_SLUG from corehq.apps.domain.forms import clean_password from corehq.apps.domain.models import Domain from corehq.apps.groups.models import Group @@ -11,6 +12,7 @@ from corehq.apps.user_importer.helpers import find_differences_in_list from corehq.apps.users.audit.change_messages import UserChangeMessage from corehq.apps.users.models_role import UserRole +from corehq.apps.users.user_data import UserDataError def update(user, field, value, user_change_logger=None): @@ -107,15 +109,17 @@ def _update_groups(user, group_ids, user_change_logger): user_change_logger.add_info(UserChangeMessage.groups_info(groups)) -def _update_user_data(user, user_data, user_change_logger): - original_user_data = user.metadata.copy() +def _update_user_data(user, new_user_data, user_change_logger): try: - user.update_metadata(user_data) - except ValueError as e: + profile_id = new_user_data.pop(PROFILE_SLUG, ...) + changed = user.get_user_data(user.domain).update(new_user_data, profile_id=profile_id) + except UserDataError as e: raise UpdateUserException(str(e)) - if user_change_logger and original_user_data != user.user_data: - user_change_logger.add_changes({'user_data': user.user_data}) + if user_change_logger and changed: + user_change_logger.add_changes({ + 'user_data': user.get_user_data(user.domain).raw + }) def _update_user_role(user, role, user_change_logger): diff --git a/corehq/apps/app_manager/app_schemas/case_properties.py b/corehq/apps/app_manager/app_schemas/case_properties.py index 722d6d0e13344..c8c5812d6b688 100644 --- a/corehq/apps/app_manager/app_schemas/case_properties.py +++ b/corehq/apps/app_manager/app_schemas/case_properties.py @@ -508,6 +508,7 @@ def get_usercase_properties(app): return {USERCASE_TYPE: []} +@quickcache(vary_on=['domain', 'include_parent_properties', 'exclude_deprecated_properties']) def all_case_properties_by_domain(domain, include_parent_properties=True, exclude_deprecated_properties=True): builder = ParentCasePropertyBuilder.for_domain( domain, include_parent_properties=include_parent_properties, diff --git a/corehq/apps/app_manager/helpers/validators.py b/corehq/apps/app_manager/helpers/validators.py index 133d742f03008..9f4f980919aa9 100644 --- a/corehq/apps/app_manager/helpers/validators.py +++ b/corehq/apps/app_manager/helpers/validators.py @@ -559,6 +559,8 @@ class ModuleDetailValidatorMixin(object): __invalid_tile_configuration_type: str = "invalid tile configuration" + __invalid_clickable_icon_configuration: str = "invalid clickable icon configuration" + def _validate_fields_with_format( self, format_value: str, @@ -576,6 +578,18 @@ def _validate_fields_with_format( .format(format_display, fields_with_address_format_str)) }) + def _validate_clickable_icons( + self, + columns: list, + errors: list + ): + for field in [c.field for c in columns if c.format == 'clickable-icon' and c.endpoint_action_id == '']: + errors.append({ + 'type': self.__invalid_clickable_icon_configuration, + 'module': self.get_module_info(), + 'reason': _('Column/Field "{}": Clickable Icons require a form to be configured.'.format(field)) + }) + ''' Validation logic common to basic and shadow modules, which both have detail configuration. ''' @@ -619,6 +633,7 @@ def validate_details_for_build(self): }) self._validate_fields_with_format('address', 'Address', detail.columns, errors) self._validate_fields_with_format('address-popup', 'Address Popup', detail.columns, errors) + self._validate_clickable_icons(detail.columns, errors) if detail.has_persistent_tile() and self.module.report_context_tile: errors.append({ diff --git a/corehq/apps/app_manager/models.py b/corehq/apps/app_manager/models.py index 4de6057671549..369abc077d263 100644 --- a/corehq/apps/app_manager/models.py +++ b/corehq/apps/app_manager/models.py @@ -65,6 +65,7 @@ remote_app, ) from corehq.apps.app_manager.app_schemas.case_properties import ( + all_case_properties_by_domain, get_all_case_properties, get_usercase_properties, ) @@ -1042,6 +1043,7 @@ class FormBase(DocumentSchema): is_release_notes_form = BooleanProperty(default=False) enable_release_notes = BooleanProperty(default=False) session_endpoint_id = StringProperty(exclude_if_none=True) # See toggles.SESSION_ENDPOINTS + respect_relevancy = BooleanProperty(default=True) # computed datums IDs that are allowed in endpoints function_datum_endpoints = StringListProperty() @@ -1305,8 +1307,6 @@ def is_auto_submitting_form(self, case_type=None): if case_type is None: return False - if self.get_module().case_type != case_type: - return False if not self.requires_case(): return False @@ -1846,13 +1846,16 @@ class DetailColumn(IndexedSchema): useXpathExpression = BooleanProperty(default=False) format = StringProperty(exclude_if_none=True) - grid_x = IntegerProperty(exclude_if_none=True) - grid_y = IntegerProperty(exclude_if_none=True) + # Only applies to custom case list tile. grid_x and grid_y are zero-based values + # representing the starting row and column. + grid_x = IntegerProperty(required=False) + grid_y = IntegerProperty(required=False) width = IntegerProperty(exclude_if_none=True) height = IntegerProperty(exclude_if_none=True) horizontal_align = StringProperty(exclude_if_none=True) vertical_align = StringProperty(exclude_if_none=True) font_size = StringProperty(exclude_if_none=True) + show_border = BooleanProperty(exclude_if_none=True) enum = SchemaListProperty(MappingItem) graph_configuration = SchemaProperty(GraphConfiguration) @@ -2166,6 +2169,7 @@ class CaseSearch(DocumentSchema): title_label = LabelProperty(default={}) description = LabelProperty(default={}) include_all_related_cases = BooleanProperty(default=False) + dynamic_search = BooleanProperty(default=False) # case property referencing another case's ID custom_related_case_property = StringProperty(exclude_if_none=True) @@ -3417,7 +3421,8 @@ class CustomDataAutoFilter(ReportAppFilter): def get_filter_value(self, user, ui_filter): from corehq.apps.reports_core.filters import Choice - return Choice(value=user.metadata[self.custom_data_property], display=None) + user_data = user.get_user_data(getattr(user, 'current_domain', user.domain)) + return Choice(value=user_data[self.custom_data_property], display=None) class StaticChoiceFilter(ReportAppFilter): @@ -4157,6 +4162,7 @@ def assert_app_v2(self): default=const.DEFAULT_LOCATION_FIXTURE_OPTION, choices=const.LOCATION_FIXTURE_OPTIONS, required=False ) + split_screen_dynamic_search = BooleanProperty(default=False) @property def id(self): @@ -4508,6 +4514,10 @@ def save(self, response_json=None, increment_version=None, **params): # expire cache unless new application self.global_app_config.clear_version_caches() get_all_case_properties.clear(self) + all_case_properties_by_domain.clear(self.domain, True, True) + all_case_properties_by_domain.clear(self.domain, True, False) + all_case_properties_by_domain.clear(self.domain, False, True) + all_case_properties_by_domain.clear(self.domain, False, False) get_usercase_properties.clear(self) get_app_languages.clear(self.domain) get_apps_in_domain.clear(self.domain, True) diff --git a/corehq/apps/app_manager/static/app_manager/js/details/column.js b/corehq/apps/app_manager/static/app_manager/js/details/column.js index 1362b2f896349..6cd499bbf9e78 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/column.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/column.js @@ -40,6 +40,7 @@ hqDefine("app_manager/js/details/column", function () { horizontal_align: "left", vertical_align: "start", font_size: "medium", + show_border: false, }; _.each(_.keys(defaults), function (key) { self.original[key] = self.original[key] || defaults[key]; @@ -50,12 +51,14 @@ hqDefine("app_manager/js/details/column", function () { self.case_tile_field = ko.observable(self.original.case_tile_field); self.coordinatesVisible = ko.observable(true); - self.tileRowMax = ko.observable(7); + self.tileRowMax = ko.observable(7); // set dynamically by screen self.tileColumnMax = ko.observable(13); - self.tileRowStart = ko.observable(self.original.grid_y || 1); - self.tileRowOptions = [""].concat(_.range(1, self.tileRowMax())); - self.tileColumnStart = ko.observable(self.original.grid_x || 1); - self.tileColumnOptions = [""].concat(_.range(1, self.tileColumnMax())); + self.tileRowStart = ko.observable(self.original.grid_y + 1 || 1); // converts from 0 to 1-based for UI + self.tileRowOptions = ko.computed(function () { + return _.range(1, self.tileRowMax()); + }); + self.tileColumnStart = ko.observable(self.original.grid_x + 1 || 1); // converts from 0 to 1-based for UI + self.tileColumnOptions = _.range(1, self.tileColumnMax()); self.tileWidth = ko.observable(self.original.width || self.tileRowMax() - 1); self.tileWidthOptions = ko.computed(function () { return _.range(1, self.tileColumnMax() + 1 - (self.tileColumnStart() || 1)); @@ -67,12 +70,26 @@ hqDefine("app_manager/js/details/column", function () { self.horizontalAlign = ko.observable(self.original.horizontal_align || 'left'); self.horizontalAlignOptions = ['left', 'center', 'right']; - self.verticalAlign = ko.observable(self.original.vertial_align || 'start'); + self.verticalAlign = ko.observable(self.original.vertical_align || 'start'); self.verticalAlignOptions = ['start', 'center', 'end']; self.fontSize = ko.observable(self.original.font_size || 'medium'); self.fontSizeOptions = ['small', 'medium', 'large']; + self.showBorder = ko.observable(self.original.show_border || false); + + self.openStyleModal = function () { + const $modalDiv = $(document.createElement("div")); + $modalDiv.attr("data-bind", "template: 'style_configuration_modal'"); + $modalDiv.koApplyBindings(self); + const $modal = $modalDiv.find('.modal'); + $modal.appendTo('body'); + $modal.modal('show'); + $modal.on('hidden.bs.modal', function () { + $modal.remove(); + }); + }; + self.tileRowEnd = ko.computed(function () { return Number(self.tileRowStart()) + Number(self.tileHeight()); }); @@ -268,7 +285,7 @@ hqDefine("app_manager/js/details/column", function () { formEndpoints.forEach(([, endpoint]) => { if (endpoint.module_name !== moduleName) { moduleName = endpoint.module_name; - formEndpointOptions.push({groupName: moduleName}); + formEndpointOptions.push({groupName: `${moduleName} (${endpoint.module_case_type})`}); } formEndpointOptions.push({value: endpoint.id, label: endpoint.form_name}); }); @@ -339,6 +356,7 @@ hqDefine("app_manager/js/details/column", function () { self.horizontalAlign.subscribe(fireChange); self.verticalAlign.subscribe(fireChange); self.fontSize.subscribe(fireChange); + self.showBorder.subscribe(fireChange); self.$format = $('
').append(self.format.ui); self.$format.find("select").css("margin-bottom", "5px"); @@ -423,13 +441,14 @@ hqDefine("app_manager/js/details/column", function () { column.date_format = self.date_extra.val(); column.enum = self.enum_extra.getItems(); column.endpoint_action_id = self.action_form_extra.val() === "-1" ? null : self.action_form_extra.val(); - column.grid_x = self.tileColumnStart(); - column.grid_y = self.tileRowStart(); + column.grid_x = self.tileColumnStart() - 1; + column.grid_y = self.tileRowStart() - 1; column.height = self.tileHeight(); column.width = self.tileWidth(); column.horizontal_align = self.horizontalAlign(); - column.vertial_align = self.verticalAlign(); + column.vertical_align = self.verticalAlign(); column.font_size = self.fontSize(); + column.show_border = self.showBorder(); column.graph_configuration = self.format.val() === "graph" ? self.graph_extra.val() : null; column.late_flag = parseInt(self.late_flag_extra.val(), 10); column.time_ago_interval = parseFloat(self.time_ago_extra.val()); diff --git a/corehq/apps/app_manager/static/app_manager/js/details/screen.js b/corehq/apps/app_manager/static/app_manager/js/details/screen.js index 2a825ad49c4f9..a2ac0d74d2bc1 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/screen.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/screen.js @@ -413,6 +413,18 @@ hqDefine("app_manager/js/details/screen", function () { self.initColumnAsColumn(self.columns()[i]); } + self.caseTileRowMax = ko.computed(() => _.max([self.columns().length + 1, 7])); + self.caseTileRowMax.subscribe(function (newValue) { + self.updateTileRowMaxForColumns(newValue); + }); + + self.updateTileRowMaxForColumns = function (newValue) { + _.each(self.columns(), function (column) { + column.tileRowMax(newValue); + }); + }; + self.updateTileRowMaxForColumns(self.caseTileRowMax()); + self.saveButton = hqImport("hqwebapp/js/bootstrap3/main").initSaveButton({ unsavedMessage: gettext('You have unsaved detail screen configurations.'), save: function () { diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt b/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt index c1e2d819bbe6f..fc83a92e881fd 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt @@ -1007,4 +1007,4 @@ │ ├─ buffer-crc32@~0.2.3 │ └─ fd-slicer@~1.1.0 └─ yocto-queue@0.1.0 -Done in 0.14s. +Done in 0.15s. diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js b/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js index 0fa1d4c43f252..8b00efeac17ad 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js @@ -28684,9 +28684,11 @@ define('vellum/tsv',[ }); define('vellum/exporter',[ - 'vellum/tsv' + 'vellum/tsv', + 'vellum/richText' ], function ( - tsv + tsv, + richText ) { // todo: abstract out IText stuff into part of the plugin interface var generateExportTSV = function (form) { @@ -28775,7 +28777,7 @@ define('vellum/exporter',[ row["Hint Text"] = defaultOrNothing(mug.p.hintItext, defaultLanguage, 'default'); row["Help Text"] = defaultOrNothing(mug.p.helpItext, defaultLanguage, 'default'); - row.Comment = mug.p.comment; + row.Comment = richText.sanitizeInput(mug.p.comment); // make sure there aren't any null values for (var prop in row) { @@ -48665,7 +48667,7 @@ define('vellum/core',[ form = this.data.core.form, mugs = multiselect ? mug : [mug], $baseToolbar = $(question_toolbar({ - comment: multiselect ? '' : mug.p.comment, + comment: multiselect ? '' : richText.sanitizeInput(mug.p.comment), isDeleteable: mugs && mugs.length && _.every(mugs, function (mug) { return _this.isMugRemoveable(mug, mug.hashtagPath); }), diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt b/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt index c15bca43149e9..12cf6d4328a58 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt @@ -1 +1 @@ -9f539058684ab5d834fcad120e5b41c9550536b6 +9d2f2db789028bd2901ddfdc676c0b28879165a4 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 d491bc41eaaa0..a4bda2535bf65 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 @@ -169,3 +169,12 @@ - ['only_flat_fixture', 'Only Flat Fixture'] - ['only_hierarchical_fixture', 'Only Hierarchical Fixture'] default: 'project_default' + + +- id: split_screen_dynamic_search + name: Dynamic Search for Split Screen Case Search + description: Enable searching as input values change after initial Split Screen Case Search + toggle: DYNAMICALLY_UPDATE_SEARCH_RESULTS + widget: bool + default: false + since: '2.54' 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 9cec383a6b4aa..28cfaef5afb0e 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 @@ -61,6 +61,7 @@ - hq.translation_strategy - hq.mobile_ucr_restore_version - hq.location_fixture_restore + - hq.split_screen_dynamic_search - title: 'Disabled' id: app-settings-disabled diff --git a/corehq/apps/app_manager/static_strings.py b/corehq/apps/app_manager/static_strings.py index 708ae16f92205..5419b5e5101a0 100644 --- a/corehq/apps/app_manager/static_strings.py +++ b/corehq/apps/app_manager/static_strings.py @@ -73,9 +73,11 @@ gettext_noop('Disable'), gettext_noop('Disabled'), gettext_noop('Display root menu as a list or grid. Read more on the Help Site.'), + gettext_noop('Dynamic Search for Split Screen Case Search'), gettext_noop('Enable Menu Display Setting Per-Module'), gettext_noop('Enable'), gettext_noop('Enabled'), + gettext_noop('Enable searching as input values change after initial Split Screen Case Search'), gettext_noop('For mobile map displays, chooses a base tileset for the underlying map layer'), gettext_noop('Forms Menu Display'), gettext_noop('Forms are Never Removed'), diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json new file mode 100644 index 0000000000000..821d79e53f9b0 --- /dev/null +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json @@ -0,0 +1,21 @@ +{ + "slug": "bha_referrals", + "filename": "bha_referrals.xml", + "has_map": false, + "fields": ["header", "top_left", "top_center", "top_right", + "middle0_left", "middle0_right", "middle1_left", "middle1_right", + "middle2", "bottom_left_img", "bottom_right"], + "grid": { + "header": {"height": 1, "width": 12, "x": 0, "y": 0, "horz-align":"left", "vert-align":"center"}, + "top_left": {"height": 1, "width": 4, "x": 0, "y": 1, "horz-align":"left", "vert-align":"center"}, + "top_center": {"height": 1, "width": 4, "x": 4, "y": 1, "horz-align":"left", "vert-align":"center"}, + "top_right": {"height": 1, "width": 4, "x": 8, "y": 1, "horz-align":"left", "vert-align":"center"}, + "middle0_left": {"height": 1, "width": 4, "x": 0, "y": 2, "horz-align":"left", "vert-align":"center"}, + "middle0_right": {"height": 1, "width": 8, "x": 4, "y": 2, "horz-align":"left", "vert-align":"center"}, + "middle1_left": {"height": 1, "width": 6, "x": 0, "y": 3, "horz-align":"left", "vert-align":"center"}, + "middle1_right": {"height": 1, "width": 6, "x": 6, "y": 3, "horz-align":"left", "vert-align":"center"}, + "middle2": {"height": 1, "width": 12, "x": 0, "y": 4, "horz-align":"left", "vert-align":"center"}, + "bottom_left_img": {"height": 1, "width": 1, "x": 0, "y": 5, "horz-align":"center", "vert-align":"start"}, + "bottom_right": {"height": 1, "width": 11, "x": 1, "y": 5, "horz-align":"left", "vert-align":"start"} + } +} \ No newline at end of file diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml new file mode 100644 index 0000000000000..9abd4cae34d0e --- /dev/null +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml @@ -0,0 +1,216 @@ + + + <text> + <locale id="{title_text_id}"/> + </text> + + + + +
+ + + +
+ + {header[endpoint_action]} +
+ + + +
+ + + +
+ + {top_left[endpoint_action]} +
+ + + +
+ + + +
+ + {top_center[endpoint_action]} +
+ + + +
+ + + +
+ + {top_right[endpoint_action]} +
+ + + +
+ + + +
+ + {middle0_left[endpoint_action]} +
+ + + +
+ + + +
+ + {middle0_right[endpoint_action]} +
+ + + + +
+ + + +
+ + {middle1_left[endpoint_action]} +
+ + + +
+ + + +
+ + {middle1_right[endpoint_action]} +
+ + + +
+ + + +
+ + {middle2[endpoint_action]} +
+ + + +
+ +
+ + {bottom_left_img[endpoint_action]} +
+ + + +
+ + + +
+ + {bottom_right[endpoint_action]} +
+ +
diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/icon_text_grid.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/icon_text_grid.xml index 0166f3ec34bca..687eaba9541ed 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/icon_text_grid.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/icon_text_grid.xml @@ -19,6 +19,7 @@ + {top_left_image[endpoint_action]} @@ -38,6 +39,7 @@ + {top_left_text[endpoint_action]} @@ -54,6 +56,7 @@ + {top_right_image[endpoint_action]} @@ -73,6 +76,7 @@ + {top_right_text[endpoint_action]} @@ -89,6 +93,7 @@ + {middle_left_image[endpoint_action]} @@ -108,6 +113,7 @@ + {middle_left_text[endpoint_action]} @@ -124,6 +130,7 @@ + {middle_right_image[endpoint_action]} @@ -143,6 +150,7 @@ + {middle_right_text[endpoint_action]} @@ -159,6 +167,7 @@ + {bottom_left_image[endpoint_action]} @@ -178,5 +187,6 @@ + {bottom_left_text[endpoint_action]} diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_3X_two_4X_one_2X.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_3X_two_4X_one_2X.xml index 8d767056624fd..6e5f3ed462263 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_3X_two_4X_one_2X.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_3X_two_4X_one_2X.xml @@ -6,7 +6,7 @@ -
@@ -21,10 +21,11 @@ + {header[endpoint_action]} -
@@ -39,10 +40,11 @@ + {top[endpoint_action]} -
@@ -57,6 +59,7 @@ + {middle0[endpoint_action]} @@ -90,6 +93,7 @@ + {middle1_right[endpoint_action]} @@ -123,6 +127,7 @@ + {middle2_right[endpoint_action]} @@ -156,6 +161,7 @@ + {middle3_right[endpoint_action]} @@ -192,7 +198,7 @@ -
@@ -207,10 +213,11 @@ + {middle5[endpoint_action]} -
@@ -225,6 +232,7 @@ + {bottom[endpoint_action]} @@ -257,4 +265,36 @@ + + +
+ + +
+ +
+ + + +
+ + +
+ +
+ diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_one_two.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_one_two.xml index 44b90fdf108a7..68ae51423b606 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_one_two.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_one_two.xml @@ -28,6 +28,7 @@ + {title[endpoint_action]} @@ -53,6 +54,7 @@ + {top[endpoint_action]} @@ -78,6 +80,7 @@ + {bottom_left[endpoint_action]} @@ -103,6 +106,7 @@ + {bottom_right[endpoint_action]} diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one.xml index 72af2fcd21bbc..7681306f690f0 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one.xml @@ -21,6 +21,7 @@ + {header[endpoint_action]} @@ -51,6 +52,7 @@ + {map_popup[endpoint_action]} @@ -69,6 +71,7 @@ + {top_left[endpoint_action]} @@ -87,6 +90,7 @@ + {top_right[endpoint_action]} @@ -105,6 +109,7 @@ + {bottom[endpoint_action]} diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one_one.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one_one.xml index 6737f5a8e6972..3b802f2f15c9f 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one_one.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_one_one.xml @@ -21,6 +21,7 @@ + {header[endpoint_action]} @@ -69,6 +70,7 @@ + {top_left[endpoint_action]} @@ -87,6 +89,7 @@ + {top_right[endpoint_action]} @@ -106,6 +109,7 @@ + {middle[endpoint_action]} @@ -124,6 +128,7 @@ + {bottom[endpoint_action]} diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_two.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_two.xml index ae4bc832e3643..9149430476b01 100644 --- a/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_two.xml +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/one_two_two.xml @@ -26,6 +26,7 @@ + {image[endpoint_action]} @@ -45,6 +46,7 @@ + {header[endpoint_action]} @@ -64,6 +66,7 @@ + {top_left[endpoint_action]} @@ -83,6 +86,7 @@ + {top_right[endpoint_action]} @@ -102,6 +106,7 @@ + {bottom_left[endpoint_action]} @@ -121,5 +126,6 @@ + {bottom_right[endpoint_action]} diff --git a/corehq/apps/app_manager/suite_xml/features/case_tiles.py b/corehq/apps/app_manager/suite_xml/features/case_tiles.py index 861c1d8861d1c..8428f6b06c1e1 100644 --- a/corehq/apps/app_manager/suite_xml/features/case_tiles.py +++ b/corehq/apps/app_manager/suite_xml/features/case_tiles.py @@ -10,7 +10,9 @@ from corehq.apps.app_manager import id_strings from corehq.apps.app_manager.exceptions import SuiteError -from corehq.apps.app_manager.suite_xml.xml_models import Detail, XPathVariable, Text, TileGroup, Style +from corehq.apps.app_manager.suite_xml.xml_models import ( + Detail, XPathVariable, Text, TileGroup, Style, EndpointAction +) from corehq.apps.app_manager.util import ( module_offers_search, module_uses_inline_search, @@ -29,6 +31,7 @@ class CaseTileTemplates(models.TextChoices): "and map")) ONE_TWO_TWO = ("one_two_two", _("Title row, second row with two cells, third row with two cells")) ICON_TEXT_GRID = ("icon_text_grid", _("2 x 3 grid of image and text")) + BHA_REFERRALS = ("bha_referrals", _("BHA Referrals")) @dataclass @@ -94,7 +97,8 @@ def build_case_tile_detail(self): grid_height=column_info.column.height, grid_width=column_info.column.width, horz_align=column_info.column.horizontal_align, vert_align=column_info.column.vertical_align, - font_size=column_info.column.font_size) + font_size=column_info.column.font_size, + show_border=column_info.column.show_border) fields = get_column_generator( self.app, self.module, self.detail, detail_type=self.detail_type, @@ -186,8 +190,12 @@ def _get_column_context(self, column): } context['variables'] = '' - if column.format in ["enum", "conditional-enum", "enum-image"]: + if column.format in ["enum", "conditional-enum", "enum-image", "clickable-icon"]: context["variables"] = self._get_enum_variables(column) + + context['endpoint_action'] = '' + if column.endpoint_action_id: + context["endpoint_action"] = self._get_endpoint_action(column.endpoint_action_id) return context def _get_xpath_function(self, column): @@ -216,6 +224,11 @@ def _get_enum_variables(self, column): ) return ''.join([bytes(variable).decode('utf-8') for variable in variables]) + def _get_endpoint_action(self, endpoint_action_id): + endpoint = EndpointAction(endpoint_id=endpoint_action_id, background="true").serialize() + decoded = bytes(endpoint).decode('utf-8') + return decoded + @property @memoized def _case_tile_template_string(self): diff --git a/corehq/apps/app_manager/suite_xml/post_process/endpoints.py b/corehq/apps/app_manager/suite_xml/post_process/endpoints.py index e47685efd87d2..be9b42322ecf2 100644 --- a/corehq/apps/app_manager/suite_xml/post_process/endpoints.py +++ b/corehq/apps/app_manager/suite_xml/post_process/endpoints.py @@ -48,7 +48,8 @@ def update_suite(self): for form in module.get_suite_forms(): if form.session_endpoint_id: self.suite.endpoints.append(self._make_session_endpoint( - form.session_endpoint_id, module, form)) + form.session_endpoint_id, module, form, + respect_relevancy=getattr(form, 'respect_relevancy', None))) elif module.session_endpoint_id: for form in module.get_suite_forms(): endpoint = next( @@ -57,7 +58,8 @@ def update_suite(self): self.suite.endpoints.append(self._make_session_endpoint( endpoint.session_endpoint_id, module, form)) - def _make_session_endpoint(self, endpoint_id, module, form=None, should_add_last_selection_datum=True): + def _make_session_endpoint(self, endpoint_id, module, form=None, should_add_last_selection_datum=True, + respect_relevancy=None): stack = Stack() children = self.get_frame_children(module, form) argument_ids = self.get_argument_ids(children, form, should_add_last_selection_datum) @@ -102,11 +104,14 @@ def get_child(child_id): else: arguments.append(Argument(id=arg_id)) - return SessionEndpoint( + endpoint = SessionEndpoint( id=endpoint_id, arguments=arguments, - stack=stack, + stack=stack ) + if respect_relevancy is False: + endpoint.respect_relevancy = False + return endpoint def get_argument_ids(self, frame_children, form=None, should_add_last_selection_datum=True): diff --git a/corehq/apps/app_manager/suite_xml/post_process/remote_requests.py b/corehq/apps/app_manager/suite_xml/post_process/remote_requests.py index 679484addcf43..caaded95e4f74 100644 --- a/corehq/apps/app_manager/suite_xml/post_process/remote_requests.py +++ b/corehq/apps/app_manager/suite_xml/post_process/remote_requests.py @@ -211,6 +211,7 @@ def build_remote_request_queries(self): data=self._remote_request_query_datums, prompts=self.build_query_prompts(), default_search=self.module.search_config.default_search, + dynamic_search=self.app.split_screen_dynamic_search and not self.module.is_auto_select(), ) ] diff --git a/corehq/apps/app_manager/suite_xml/sections/details.py b/corehq/apps/app_manager/suite_xml/sections/details.py index 77db6fec955b7..b4acfbf203822 100644 --- a/corehq/apps/app_manager/suite_xml/sections/details.py +++ b/corehq/apps/app_manager/suite_xml/sections/details.py @@ -485,6 +485,7 @@ def _get_persistent_case_context_detail(module, xml): grid_width=12, grid_x=0, grid_y=0, + show_border=False, ), header=Header(text=Text()), template=Template(text=Text(xpath_function=xml)), @@ -507,6 +508,7 @@ def _get_report_context_tile_detail(): grid_width=12, grid_x=0, grid_y=0, + show_border=False, ), header=Header(text=Text()), template=Template(text=Text(xpath=TextXPath( diff --git a/corehq/apps/app_manager/suite_xml/xml_models.py b/corehq/apps/app_manager/suite_xml/xml_models.py index d36b19896a1cc..2a3b13cc1194e 100644 --- a/corehq/apps/app_manager/suite_xml/xml_models.py +++ b/corehq/apps/app_manager/suite_xml/xml_models.py @@ -536,6 +536,8 @@ class SessionEndpoint(IdNode): arguments = NodeListField('argument', Argument) stack = NodeField('stack', Stack) + respect_relevancy = BooleanField('@respect-relevancy', required=False) + class Assertion(XmlObject): ROOT_NAME = 'assert' @@ -581,6 +583,7 @@ class RemoteRequestQuery(OrderedXmlObject, XmlObject): data = NodeListField('data', QueryData) prompts = NodeListField('prompt', QueryPrompt) default_search = BooleanField("@default_search") + dynamic_search = BooleanField("@dynamic_search") @property def id(self): @@ -789,6 +792,7 @@ class Style(XmlObject): grid_width = StringField("grid/@grid-width") grid_x = StringField("grid/@grid-x") grid_y = StringField("grid/@grid-y") + show_border = BooleanField("@show-border") class Extra(XmlObject): diff --git a/corehq/apps/app_manager/templates/app_manager/module_view.html b/corehq/apps/app_manager/templates/app_manager/module_view.html index bff7e67d2dc9d..27fddb9bab947 100644 --- a/corehq/apps/app_manager/templates/app_manager/module_view.html +++ b/corehq/apps/app_manager/templates/app_manager/module_view.html @@ -59,6 +59,7 @@ {% include 'hqwebapp/partials/bootstrap3/key_value_mapping.html' %} {% include 'app_manager/partials/modules/graph_configuration_modal.html' %} + {% include 'app_manager/partials/modules/style_configuration_modal.html' %} {% endblock %} {% block pre_form_content %} diff --git a/corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html b/corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html new file mode 100644 index 0000000000000..c4ea50cdd4dcf --- /dev/null +++ b/corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html @@ -0,0 +1,46 @@ +{% load i18n %} +{% load hq_shared_tags %} + +
+ +
+ +
+
+
+ +
+ + +
+
diff --git a/corehq/apps/app_manager/templates/app_manager/partials/forms/form_tab_settings.html b/corehq/apps/app_manager/templates/app_manager/partials/forms/form_tab_settings.html index 565675e94d642..2ab63d09232fa 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/forms/form_tab_settings.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/forms/form_tab_settings.html @@ -79,7 +79,7 @@ {% endif %} {% if session_endpoints_enabled %} - {% include "app_manager/partials/session_endpoints.html" with module_or_form=form %} + {% include "app_manager/partials/form_session_endpoint.html" with form=form %} {% include "app_manager/partials/function_datum_endpoints.html" with module_or_form=form %} {% endif %} diff --git a/corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html b/corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html similarity index 91% rename from corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html rename to corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html index b7d662db62eda..17aff1eb668b6 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html @@ -17,6 +17,6 @@ type="text" id="session_endpoint_id" name="session_endpoint_id" - value="{{ module_or_form.session_endpoint_id|default:'' }}" /> + value="{{ module.session_endpoint_id|default:'' }}" />
diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html index 1b65729e25c96..621eca8383228 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html @@ -19,7 +19,7 @@ {% trans "Row/Column" %} {% trans "Width/Height" %} - {% trans "Align/Text Size" %} + {% trans "Style" %} @@ -76,9 +76,7 @@ - - - + diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/module_view_settings.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/module_view_settings.html index 18352c50d8204..8ee870e72e496 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/modules/module_view_settings.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/module_view_settings.html @@ -255,7 +255,7 @@ {% endif %} {% if session_endpoints_enabled %} - {% include "app_manager/partials/session_endpoints.html" with module_or_form=module %} + {% include "app_manager/partials/module_session_endpoint.html" with module=module %} {% if not module.is_surveys %} {% include "app_manager/partials/case_list_session_endpoint.html" with module=module %} {% endif %} diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/style_configuration_modal.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/style_configuration_modal.html new file mode 100644 index 0000000000000..993c3c976e964 --- /dev/null +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/style_configuration_modal.html @@ -0,0 +1,66 @@ +{% load i18n %} +
+ +
+ + diff --git a/corehq/apps/app_manager/tests/data/suite/multi_select_case_list/basic_remote_request.xml b/corehq/apps/app_manager/tests/data/suite/multi_select_case_list/basic_remote_request.xml index 39a305fd26788..8c93d51d4e96d 100644 --- a/corehq/apps/app_manager/tests/data/suite/multi_select_case_list/basic_remote_request.xml +++ b/corehq/apps/app_manager/tests/data/suite/multi_select_case_list/basic_remote_request.xml @@ -19,7 +19,7 @@ - + <text> <locale id="case_search.m0.inputs"/> diff --git a/corehq/apps/app_manager/tests/data/suite/remote_request.xml b/corehq/apps/app_manager/tests/data/suite/remote_request.xml index 73e9de0405136..0098985d8bc8f 100644 --- a/corehq/apps/app_manager/tests/data/suite/remote_request.xml +++ b/corehq/apps/app_manager/tests/data/suite/remote_request.xml @@ -23,6 +23,7 @@ <session> <query url="https://www.example.com/a/test_domain/phone/search/123/" default_search="false" + dynamic_search="false" storage-instance="results" template="case"> <title> diff --git a/corehq/apps/app_manager/tests/data/suite/remote_request_custom_detail.xml b/corehq/apps/app_manager/tests/data/suite/remote_request_custom_detail.xml index 20ecc8cbf90da..a08f777f5e4a8 100644 --- a/corehq/apps/app_manager/tests/data/suite/remote_request_custom_detail.xml +++ b/corehq/apps/app_manager/tests/data/suite/remote_request_custom_detail.xml @@ -21,6 +21,7 @@ <session> <query url="https://www.example.com/a/test_domain/phone/search/123/" default_search="false" + dynamic_search="false" storage-instance="results" template="case"> <title> diff --git a/corehq/apps/app_manager/tests/data/suite/search_config_blacklisted_owners.xml b/corehq/apps/app_manager/tests/data/suite/search_config_blacklisted_owners.xml index 45cd81dc2c965..0e1c67f761f17 100644 --- a/corehq/apps/app_manager/tests/data/suite/search_config_blacklisted_owners.xml +++ b/corehq/apps/app_manager/tests/data/suite/search_config_blacklisted_owners.xml @@ -20,6 +20,7 @@ <session> <query url="https://www.example.com/a/test_domain/phone/search/123/" default_search="false" + dynamic_search="false" storage-instance="results" template="case"> <title> diff --git a/corehq/apps/app_manager/tests/data/suite/search_config_default_only.xml b/corehq/apps/app_manager/tests/data/suite/search_config_default_only.xml index d1d5e9d268460..1023e5e715334 100644 --- a/corehq/apps/app_manager/tests/data/suite/search_config_default_only.xml +++ b/corehq/apps/app_manager/tests/data/suite/search_config_default_only.xml @@ -21,6 +21,7 @@ <session> <query url="https://www.example.com/a/test_domain/phone/search/123/" default_search="false" + dynamic_search="false" storage-instance="results" template="case"> <title> diff --git a/corehq/apps/app_manager/tests/data/suite/smart_link_remote_request.xml b/corehq/apps/app_manager/tests/data/suite/smart_link_remote_request.xml index e88881f60d095..97ca48c49a6db 100644 --- a/corehq/apps/app_manager/tests/data/suite/smart_link_remote_request.xml +++ b/corehq/apps/app_manager/tests/data/suite/smart_link_remote_request.xml @@ -15,7 +15,7 @@ <instance id="commcaresession" src="jr://instance/session"/> <instance id="results" src="jr://instance/remote/results"/> <session> - <query default_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/{app_id}/"> + <query default_search="false" dynamic_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/{app_id}/"> <title> <text> <locale id="case_search.m1.inputs"/> diff --git a/corehq/apps/app_manager/tests/data/suite_inline_search/shadow_module_entry.xml b/corehq/apps/app_manager/tests/data/suite_inline_search/shadow_module_entry.xml index 9993236aa4780..cc51281d29f49 100644 --- a/corehq/apps/app_manager/tests/data/suite_inline_search/shadow_module_entry.xml +++ b/corehq/apps/app_manager/tests/data/suite_inline_search/shadow_module_entry.xml @@ -15,7 +15,7 @@ <instance id="commcaresession" src="jr://instance/session"/> <instance id="results:inline" src="jr://instance/remote/results:inline"/> <session> - <query default_search="false" storage-instance="results:inline" template="case" + <query default_search="false" dynamic_search="false" storage-instance="results:inline" template="case" url="http://localhost:8000/a/test_domain/phone/search/456/"> <title> <text> diff --git a/corehq/apps/app_manager/tests/data/suite_registry/form_link_followup_module_registry.xml b/corehq/apps/app_manager/tests/data/suite_registry/form_link_followup_module_registry.xml index 051788c7edaec..0caeb6a2d5423 100644 --- a/corehq/apps/app_manager/tests/data/suite_registry/form_link_followup_module_registry.xml +++ b/corehq/apps/app_manager/tests/data/suite_registry/form_link_followup_module_registry.xml @@ -10,7 +10,7 @@ <instance id="item-list:colors" src="jr://fixture/item-list:colors"/> <instance id="results" src="jr://instance/remote/results"/> <session> - <query url="http://localhost:8000/a/test_domain/phone/search/123/" storage-instance="results" template="case" default_search="false"> + <query url="http://localhost:8000/a/test_domain/phone/search/123/" storage-instance="results" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> diff --git a/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_entry.xml b/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_entry.xml index 2d920218df227..eb4605c98554b 100644 --- a/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_entry.xml +++ b/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_entry.xml @@ -10,7 +10,7 @@ <instance id="item-list:textures" src="jr://fixture/item-list:textures"/> <instance id="results" src="jr://instance/remote/results"/> <session> - <query default_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/456/"> + <query default_search="false" dynamic_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/456/"> <title> <text> <locale id="case_search.m1.inputs"/> diff --git a/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_remote_request.xml b/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_remote_request.xml index 2cc8383c1e393..51878f0d5977c 100644 --- a/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_remote_request.xml +++ b/corehq/apps/app_manager/tests/data/suite_registry/shadow_module_remote_request.xml @@ -15,7 +15,7 @@ <instance id="item-list:textures" src="jr://fixture/item-list:textures"/> <instance id="results" src="jr://instance/remote/results"/> <session> - <query default_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/456/"> + <query default_search="false" dynamic_search="false" storage-instance="results" template="case" url="https://www.example.com/a/test_domain/phone/search/456/"> <title> <text> <locale id="case_search.m1.inputs"/> diff --git a/corehq/apps/app_manager/tests/test_advanced_suite.py b/corehq/apps/app_manager/tests/test_advanced_suite.py index 31d8c4d45978b..b447b94cb9faf 100644 --- a/corehq/apps/app_manager/tests/test_advanced_suite.py +++ b/corehq/apps/app_manager/tests/test_advanced_suite.py @@ -146,7 +146,8 @@ def test_advanced_suite_auto_select_with_filter(self, *args): <text> <locale id="modules.m1"/> </text> - <command id="m1-f0" relevant="instance('casedb')/casedb/case[@case_id=instance('commcaresession')/session/data/case_id_case_clinic]/edd = '123'"/> + <command id="m1-f0" relevant="instance('casedb')/casedb/""" +\ + """case[@case_id=instance('commcaresession')/session/data/case_id_case_clinic]/edd = '123'"/> <command id="m1-f1"/> <command id="m1-f2"/> <command id="m1-case-list"/> @@ -161,7 +162,8 @@ def test_advanced_suite_load_case_from_fixture(self, *args): case_tag="adherence", case_type="clinic", load_case_from_fixture=LoadCaseFromFixture( - fixture_nodeset="instance('item-list:table_tag')/calendar/year/month/day[@date > 735992 and @date < 736000]", + fixture_nodeset="instance('item-list:table_tag')/calendar/year/month" + "/day[@date > 735992 and @date < 736000]", fixture_tag="selected_date", fixture_variable="./@date", case_property="adherence_event_date", @@ -178,7 +180,8 @@ def test_advanced_suite_load_case_from_fixture_with_arbitrary_datum(self, *args) case_tag="adherence", case_type="clinic", load_case_from_fixture=LoadCaseFromFixture( - fixture_nodeset="instance('item-list:table_tag')/calendar/year/month/day[@date > 735992 and @date < 736000]", + fixture_nodeset="instance('item-list:table_tag')/calendar/year/month/" + "day[@date > 735992 and @date < 736000]", fixture_tag="selected_date", fixture_variable="./@date", case_property="adherence_event_date", @@ -228,8 +231,10 @@ def test_advanced_suite_load_case_from_fixture_with_report_fixture(self, *args): ) )) suite = app.create_suite() - self.assertXmlPartialEqual(self.get_xml('load_case_from_report_fixture_session'), suite, './entry[2]/session') - self.assertXmlPartialEqual(self.get_xml('load_case_from_report_fixture_instance'), suite, './entry[2]/instance') + self.assertXmlPartialEqual(self.get_xml('load_case_from_report_fixture_session'), suite, + './entry[2]/session') + self.assertXmlPartialEqual(self.get_xml('load_case_from_report_fixture_instance'), suite, + './entry[2]/instance') def test_advanced_suite_load_from_fixture(self, *args): nodeset = "instance('item-list:table_tag')/calendar/year/month/day[@date > 735992 and @date < 736000]" @@ -375,7 +380,8 @@ def test_advanced_module_remote_request(self, *args): <partial> <remote-request> <post url="http://localhost:8000/a/domain/phone/claim-case/" - relevant="count(instance('casedb')/casedb/case[@case_id=instance('commcaresession')/session/data/search_case_id]) = 0"> + relevant="count(instance('casedb')/casedb/""" +\ + """case[@case_id=instance('commcaresession')/session/data/search_case_id]) = 0"> <data key="case_id" ref="instance('commcaresession')/session/data/search_case_id"/> </post> <command id="search_command.m0"> @@ -390,7 +396,7 @@ def test_advanced_module_remote_request(self, *args): <instance id="results" src="jr://instance/remote/results"/> <session> <query url="http://localhost:8000/a/domain/phone/search/123/" - storage-instance="results" template="case" default_search="false"> + storage-instance="results" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> diff --git a/corehq/apps/app_manager/tests/test_build_errors.py b/corehq/apps/app_manager/tests/test_build_errors.py index a405b6b76dfe4..3795381a1d70d 100644 --- a/corehq/apps/app_manager/tests/test_build_errors.py +++ b/corehq/apps/app_manager/tests/test_build_errors.py @@ -2,7 +2,7 @@ import os from unittest.mock import patch -from django.test import SimpleTestCase +from django.test import TestCase from corehq import privileges from corehq.apps.app_manager.const import ( @@ -27,7 +27,7 @@ @patch('corehq.apps.app_manager.models.validate_xform', return_value=None) @patch('corehq.apps.app_manager.helpers.validators.domain_has_privilege', return_value=True) -class BuildErrorsTest(SimpleTestCase): +class BuildErrorsTest(TestCase): @staticmethod def _clean_unique_id(errors): @@ -147,6 +147,26 @@ def test_case_tile_configuration_errors(self, *args): self._clean_unique_id(errors) self.assertIn(case_tile_error, errors) + def test_clickable_icon_configuration_errors(self, *args): + case_tile_error = { + 'type': "invalid clickable icon configuration", + 'module': {'id': 0, 'name': {'en': 'first module'}}, + 'reason': 'Column/Field "field": Clickable Icons require a form to be configured.' + } + factory = AppFactory(build_version='2.51.0') + app = factory.app + module = factory.new_basic_module('first', 'case', with_form=False) + module.case_details.short.columns.append(DetailColumn( + format='clickable-icon', + field='field', + header={'en': 'Column'}, + model='case', + )) + + errors = app.validate_app() + self._clean_unique_id(errors) + self.assertIn(case_tile_error, errors) + def test_case_list_form_advanced_module_different_case_config(self, *args): case_tile_error = { 'type': "all forms in case list module must load the same cases", diff --git a/corehq/apps/app_manager/tests/test_report_config.py b/corehq/apps/app_manager/tests/test_report_config.py index 3554a70d46239..8d6d64fac0155 100644 --- a/corehq/apps/app_manager/tests/test_report_config.py +++ b/corehq/apps/app_manager/tests/test_report_config.py @@ -504,7 +504,7 @@ def test_liveness_fixture(self): <text/> -
diff --git a/corehq/apps/app_manager/tests/test_suite_case_tiles.py b/corehq/apps/app_manager/tests/test_suite_case_tiles.py index 19342b95a3e57..206dd390223e5 100644 --- a/corehq/apps/app_manager/tests/test_suite_case_tiles.py +++ b/corehq/apps/app_manager/tests/test_suite_case_tiles.py @@ -368,7 +368,7 @@ def test_persistent_case_name_in_forms(self, *args): -
diff --git a/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py b/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py index f429ce39acbe6..e63df755867e4 100644 --- a/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py +++ b/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py @@ -21,7 +21,8 @@ def add_columns_for_case_details(_module, format='plain'): grid_x=1, grid_y=1, width=3, - height=1 + height=1, + show_border=False ), ] @@ -41,7 +42,7 @@ def test_custom_case_tile(self, *args): """ -
@@ -73,7 +74,7 @@ def test_custom_case_tile_address(self, *args): """ -
diff --git a/corehq/apps/app_manager/tests/test_suite_inline_search.py b/corehq/apps/app_manager/tests/test_suite_inline_search.py index b877c9a8807bc..9d9441be01263 100644 --- a/corehq/apps/app_manager/tests/test_suite_inline_search.py +++ b/corehq/apps/app_manager/tests/test_suite_inline_search.py @@ -103,7 +103,7 @@ def test_inline_search(self): + storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false" dynamic_search="false"> <text> <locale id="case_search.m0.inputs"/> @@ -146,7 +146,7 @@ def test_inline_search_case_list_item(self): <instance id="results:inline" src="jr://instance/remote/results:inline"/> <session> <query url="http://localhost:8000/a/test_domain/phone/search/123/" - storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false"> + storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> @@ -201,7 +201,7 @@ def test_inline_search_multi_select(self): <session> <query url="http://localhost:8000/a/test_domain/phone/search/123/" storage-instance="{RESULTS_INSTANCE_INLINE}" - template="case" default_search="false"> + template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> @@ -395,7 +395,7 @@ def test_inline_search_with_parent_select(self): nodeset="instance('casedb')/casedb/case[@case_type='case'][@status='open']" value="./@case_id" detail-select="m2_case_short"/> <query url="http://localhost:8000/a/test_domain/phone/search/123/" - storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false"> + storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> @@ -564,7 +564,7 @@ def test_child_module_with_inline_search_entry(self): <datum id="case_id" nodeset="instance('casedb')/casedb/case[@case_type='case'][@status='open']" value="./@case_id" detail-select="m0_case_short"/> <query url="http://localhost:8000/a/test_domain/phone/search/123/" - storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false"> + storage-instance="{RESULTS_INSTANCE_INLINE}" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m1.inputs"/> @@ -667,7 +667,7 @@ def _expected_entry_query(self, module, custom_instance): <instance id="{custom_instance}" src="jr://instance/remote/{custom_instance}"/> <session> <query url="http://localhost:8000/a/test_domain/phone/search/123/" - storage-instance="{custom_instance}" template="case" default_search="false"> + storage-instance="{custom_instance}" template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.{module}.inputs"/> diff --git a/corehq/apps/app_manager/tests/test_suite_registry_search.py b/corehq/apps/app_manager/tests/test_suite_registry_search.py index aedb650085d92..18cb77548b965 100644 --- a/corehq/apps/app_manager/tests/test_suite_registry_search.py +++ b/corehq/apps/app_manager/tests/test_suite_registry_search.py @@ -103,7 +103,7 @@ def test_search_data_registry(self, *args): <partial> <session> <query url="http://localhost:8000/a/test_domain/phone/search/123/" storage-instance="results" - template="case" default_search="false"> + template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> @@ -551,7 +551,7 @@ def test_inline_search_with_data_registry(self): <instance id="results:inline" src="jr://instance/remote/results:inline"/> <session> <query url="http://localhost:8000/a/test_domain/phone/search/123/" storage-instance="{RESULTS_INSTANCE_INLINE}" - template="case" default_search="false"> + template="case" default_search="false" dynamic_search="false"> <title> <text> <locale id="case_search.m0.inputs"/> diff --git a/corehq/apps/app_manager/tests/test_suite_session_endpoints.py b/corehq/apps/app_manager/tests/test_suite_session_endpoints.py index a6134b0d5b9fa..8caeccf55af47 100644 --- a/corehq/apps/app_manager/tests/test_suite_session_endpoints.py +++ b/corehq/apps/app_manager/tests/test_suite_session_endpoints.py @@ -627,6 +627,36 @@ def test_shadow_module(self): del self.factory.app.modules[0] + def test_session_endpoint_respect_relevancy_on_followup_form(self): + self.form.session_endpoint_id = 'my_form' + self.form.respect_relevancy = False + self.factory.form_requires_case(self.form, case_type=self.parent_case_type) + with patch('corehq.util.view_utils.get_url_base') as get_url_base_patch: + get_url_base_patch.return_value = 'https://www.example.com' + suite = self.factory.app.create_suite() + self.assertXmlPartialEqual( + """ + <partial> + <endpoint id="my_form" respect-relevancy="false"> + <argument id="case_id"/> + <stack> + <push> + <datum id="case_id" value="$case_id"/> + <command value="'claim_command.my_form.case_id'"/> + </push> + <push> + <command value="'m0'"/> + <datum id="case_id" value="$case_id"/> + <command value="'m0-f0'"/> + </push> + </stack> + </endpoint> + </partial> + """, + suite, + "./endpoint", + ) + @patch_validate_xform() @patch_get_xform_resource_overrides() diff --git a/corehq/apps/app_manager/tests/test_suite_split_screen_case_search.py b/corehq/apps/app_manager/tests/test_suite_split_screen_case_search.py index 89fc3fb2cb182..7aa856fd13e27 100644 --- a/corehq/apps/app_manager/tests/test_suite_split_screen_case_search.py +++ b/corehq/apps/app_manager/tests/test_suite_split_screen_case_search.py @@ -1,10 +1,15 @@ +from unittest.mock import patch + from django.test import SimpleTestCase +from corehq.apps.app_manager.models import Module, Application, CaseSearch, CaseSearchProperty from corehq.apps.app_manager.tests.app_factory import AppFactory from corehq.apps.app_manager.tests.util import ( SuiteMixin, patch_get_xform_resource_overrides, ) +from corehq.apps.builds.models import BuildSpec +from corehq.tests.util.xml import parse_normalize from corehq.util.test_utils import flag_enabled @@ -35,3 +40,41 @@ def test_split_screen_case_search_removes_search_again(self): suite, "./detail[@id='m0_search_short']/action[display/text/locale[@id='case_list_form.m0']]" ) + + +@patch_get_xform_resource_overrides() +class DynamicSearchSuiteTest(SimpleTestCase, SuiteMixin): + file_path = ('data', 'suite') + + def setUp(self): + self.app = Application.new_app("domain", "Untitled Application") + self.app._id = '123' + self.app.split_screen_dynamic_search = True + self.app.build_spec = BuildSpec(version='2.53.0', build_number=1) + self.module = self.app.add_module(Module.new_module("Followup", None)) + + self.module.search_config = CaseSearch( + properties=[ + CaseSearchProperty(name='name', label={'en': 'Name'}), + ] + ) + self.module.assign_references() + # wrap to have assign_references called + self.app = Application.wrap(self.app.to_json()) + self.module = self.app.modules[0] + + @flag_enabled('SPLIT_SCREEN_CASE_SEARCH') + @flag_enabled('DYNAMICALLY_UPDATE_SEARCH_RESULTS') + def test_dynamic_search_suite(self): + suite = self.app.create_suite() + suite = parse_normalize(suite, to_string=False) + self.assertEqual("true", suite.xpath("./remote-request[1]/session/query/@dynamic_search")[0]) + + @patch('corehq.apps.app_manager.models.ModuleBase.is_auto_select', return_value=True) + @flag_enabled('SPLIT_SCREEN_CASE_SEARCH') + @flag_enabled('DYNAMICALLY_UPDATE_SEARCH_RESULTS') + def test_dynamic_search_suite_disable_with_auto_select(self, mock): + suite = self.app.create_suite() + suite = parse_normalize(suite, to_string=False) + self.assertEqual(True, self.module.is_auto_select()) + self.assertEqual("false", suite.xpath("./remote-request[1]/session/query/@dynamic_search")[0]) diff --git a/corehq/apps/app_manager/views/apps.py b/corehq/apps/app_manager/views/apps.py index 6a54079148b9f..6902d46f6e4e5 100644 --- a/corehq/apps/app_manager/views/apps.py +++ b/corehq/apps/app_manager/views/apps.py @@ -58,7 +58,6 @@ ApplicationBase, DeleteApplicationRecord, ExchangeApplication, - Form, Module, ModuleNotFoundException, app_template_dir, @@ -84,7 +83,6 @@ update_linked_app, validate_langs, ) -from corehq.apps.app_manager.xform import XFormException from corehq.apps.builds.models import BuildSpec, CommCareBuildConfig from corehq.apps.cloudcare.views import FormplayerMain from corehq.apps.dashboard.views import DomainDashboardView @@ -99,7 +97,6 @@ from corehq.apps.hqwebapp.templatetags.hq_shared_tags import toggle_enabled from corehq.apps.hqwebapp.utils import get_bulk_upload_form from corehq.apps.linked_domain.applications import create_linked_app -from corehq.apps.linked_domain.dbaccessors import is_active_upstream_domain from corehq.apps.linked_domain.exceptions import RemoteRequestError from corehq.apps.translations.models import Translation from corehq.apps.users.dbaccessors import ( @@ -226,7 +223,8 @@ def get_app_view_context(request, app): if (app.get_doc_type() == 'Application' and toggles.CUSTOM_PROPERTIES.enabled(request.domain) and 'custom_properties' in getattr(app, 'profile', {})): - custom_properties_array = [{'key': p[0], 'value': p[1]} for p in app.profile.get('custom_properties').items()] + custom_properties_array = [{'key': p[0], 'value': p[1]} for p in + app.profile.get('custom_properties').items()] app_view_options.update({'customProperties': custom_properties_array}) context.update({ 'app_view_options': app_view_options, @@ -249,10 +247,8 @@ def _get_setting(setting_type, setting_id): # get setting dict from settings_layout if not settings_layout: return None - matched = [x for x in [ - setting for section in settings_layout - for setting in section['settings'] - ] if x['type'] == setting_type and x['id'] == setting_id] + matched = [x for x in [setting for section in settings_layout for setting in section['settings']] + if x['type'] == setting_type and x['id'] == setting_id] if matched: return matched[0] else: @@ -567,7 +563,6 @@ def _build_sample_app(app): return copy - @require_can_edit_apps def app_exchange(request, domain): template = "app_manager/app_exchange.html" @@ -656,7 +651,7 @@ def import_app(request, domain): if not valid_request: return render(request, template, {'domain': domain}) - assert(source is not None) + assert (source is not None) app = import_app_util(source, domain, {'name': name}, request=request) return back_to_main(request, domain, app_id=app._id) @@ -679,8 +674,8 @@ def import_app(request, domain): if app_id: app = get_app(None, app_id) - assert(app.get_doc_type() in ('Application', 'RemoteApp')) - assert(request.couch_user.is_member_of(app.domain)) + assert (app.get_doc_type() in ('Application', 'RemoteApp')) + assert (request.couch_user.is_member_of(app.domain)) else: app = None @@ -887,6 +882,7 @@ def _always_allowed(x): ('custom_base_url', None, _always_allowed), ('mobile_ucr_restore_version', None, _always_allowed), ('location_fixture_restore', None, _always_allowed), + ('split_screen_dynamic_search', 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 fa2055af16f13..c70373978995f 100644 --- a/corehq/apps/app_manager/views/forms.py +++ b/corehq/apps/app_manager/views/forms.py @@ -455,6 +455,9 @@ def should_edit(attribute): raw_endpoint_id = request.POST['session_endpoint_id'] set_session_endpoint(form, raw_endpoint_id, app) + if should_edit('access_hidden_forms'): + form.respect_relevancy = not ('true' in request.POST.getlist('access_hidden_forms')) + if should_edit('function_datum_endpoints'): if request.POST['function_datum_endpoints']: form.function_datum_endpoints = request.POST['function_datum_endpoints'].replace(" ", "").split(",") diff --git a/corehq/apps/app_manager/views/modules.py b/corehq/apps/app_manager/views/modules.py index b55b8cc03364c..205a3919103c8 100644 --- a/corehq/apps/app_manager/views/modules.py +++ b/corehq/apps/app_manager/views/modules.py @@ -278,6 +278,7 @@ def _get_shared_module_view_context(request, app, module, case_property_builder, 'inline_search': module.search_config.inline_search, 'instance_name': module.search_config.instance_name or "", 'include_all_related_cases': module.search_config.include_all_related_cases, + 'dynamic_search': app.split_screen_dynamic_search, }, }, } @@ -530,7 +531,8 @@ def _form_endpoint_options(app, module, lang=None): { "id": form.session_endpoint_id, "form_name": trans(form.name, langs), - "module_name": trans(mod.name, langs) + "module_name": trans(mod.name, langs), + "module_case_type": mod.case_type } for mod in app.get_modules() for form in mod.get_forms() if form.session_endpoint_id and form.is_auto_submitting_form(module.case_type) @@ -1369,7 +1371,8 @@ def _check_xpath(xpath, location): custom_related_case_property=search_properties.get('custom_related_case_property', ""), inline_search=search_properties.get('inline_search', False), instance_name=instance_name, - include_all_related_cases=search_properties.get('include_all_related_cases', False) + include_all_related_cases=search_properties.get('include_all_related_cases', False), + dynamic_search=app.split_screen_dynamic_search and not module.is_auto_select(), ) resp = {} diff --git a/corehq/apps/app_manager/views/utils.py b/corehq/apps/app_manager/views/utils.py index c892ce6f85df4..799e2eebf8949 100644 --- a/corehq/apps/app_manager/views/utils.py +++ b/corehq/apps/app_manager/views/utils.py @@ -329,11 +329,20 @@ def update_linked_app_and_notify(domain, app_id, master_app_id, user_id, email): send_html_email_async.delay(subject, email, _( "Something went wrong updating your linked app. " "Our team has been notified and will monitor the situation. " - "Please try again, and if the problem persists report it as an issue.")) + "Please try again, and if the problem persists report it as an issue."), + domain=domain, + use_domain_gateway=True, + ) raise else: message = _("Your linked application was successfully updated to the latest version.") - send_html_email_async.delay(subject, email, message) + send_html_email_async.delay( + subject, + email, + message, + domain=domain, + use_domain_gateway=True, + ) def update_linked_app(app, master_app_id_or_build, user_id): diff --git a/corehq/apps/callcenter/sync_usercase.py b/corehq/apps/callcenter/sync_usercase.py index e9d209087c5ae..be8c63874c88c 100644 --- a/corehq/apps/callcenter/sync_usercase.py +++ b/corehq/apps/callcenter/sync_usercase.py @@ -101,7 +101,7 @@ def _domain_has_new_fields(domain, field_names): def _get_sync_usercase_helper(user, domain, case_type, owner_id, case=None): - fields = _get_user_case_fields(user, case_type, owner_id) + fields = _get_user_case_fields(user, case_type, owner_id, domain) case = case or CommCareCase.objects.get_case_by_external_id(domain, user.user_id, case_type) close = user.to_be_deleted() or not user.is_active user_case_helper = _UserCaseHelper(domain, owner_id, user.user_id) @@ -121,7 +121,7 @@ def case_should_be_reopened(case, user_case_should_be_closed): return user_case_helper -def _get_user_case_fields(user, case_type, owner_id): +def _get_user_case_fields(user, case_type, owner_id, domain): def valid_element_name(name): try: @@ -131,7 +131,7 @@ def valid_element_name(name): return False # remove any keys that aren't valid XML element names - fields = {k: v for k, v in user.metadata.items() if + fields = {k: v for k, v in user.get_user_data(domain).items() if valid_element_name(k)} # language or phone_number can be null and will break # case submission diff --git a/corehq/apps/callcenter/tests/test_utils.py b/corehq/apps/callcenter/tests/test_utils.py index 6b654533a3d3d..8c62dfbe8262b 100644 --- a/corehq/apps/callcenter/tests/test_utils.py +++ b/corehq/apps/callcenter/tests/test_utils.py @@ -132,7 +132,7 @@ def test_sync_custom_user_data(self): ) profile.save() - self.user.update_metadata({ + self.user.get_user_data(self.domain.name).update({ '': 'blank_key', 'blank_val': '', 'ok': 'good', @@ -140,8 +140,7 @@ def test_sync_custom_user_data(self): '8starts_with_a_number': '0', 'xml_starts_with_xml': '0', '._starts_with_punctuation': '0', - PROFILE_SLUG: profile.id, - }) + }, profile_id=profile.id) sync_usercases(self.user, self.domain.name) case = self._get_user_case() self.assertIsNotNone(case) @@ -149,7 +148,10 @@ def test_sync_custom_user_data(self): self.assertEqual(case.get_case_property('ok'), 'good') self.assertEqual(case.get_case_property(PROFILE_SLUG), str(profile.id)) self.assertEqual(case.get_case_property('from_profile'), 'yes') - self.user.pop_metadata(PROFILE_SLUG) + self.user.get_user_data(TEST_DOMAIN).profile_id = None + sync_usercases(self.user, self.domain.name) + case = self._get_user_case() + self.assertEqual(case.get_case_property(PROFILE_SLUG), '') definition.delete() def test_get_call_center_cases_for_user(self): @@ -222,7 +224,8 @@ def setUpClass(cls): cls.domain.save() def setUp(self): - self.user = CommCareUser.create(TEST_DOMAIN, 'user1', '***', None, None, commit=False) # Don't commit yet + self.user = CommCareUser.create(TEST_DOMAIN, format_username('user1', TEST_DOMAIN), + '***', None, None, commit=False) # Don't commit yet def tearDown(self): self.user.delete(self.domain.name, deleted_by=None) @@ -237,9 +240,7 @@ def test_sync_usercase_custom_user_data_on_create(self): """ Custom user data should be synced when the user is created """ - self.user.update_metadata({ - 'completed_training': 'yes', - }) + self.user.get_user_data(self.domain.name)['completed_training'] = 'yes' self.user.save() case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertIsNotNone(case) @@ -249,13 +250,9 @@ def test_sync_usercase_custom_user_data_on_update(self): """ Custom user data should be synced when the user is updated """ - self.user.update_metadata({ - 'completed_training': 'no', - }) + self.user.get_user_data(self.domain.name)['completed_training'] = 'no' self.user.save() - self.user.update_metadata({ - 'completed_training': 'yes', - }) + self.user.get_user_data(self.domain.name)['completed_training'] = 'yes' self.user.save() case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertEqual(case.dynamic_case_properties()['completed_training'], 'yes') @@ -265,7 +262,7 @@ def test_sync_usercase_overwrite_hq_props(self): """ Test that setting custom user data for owner_id and case_type don't change the case """ - self.user.update_metadata({ + self.user.get_user_data(self.domain.name).update({ 'owner_id': 'someone else', 'case_type': 'bob', }) @@ -309,7 +306,7 @@ def test_update_deactivated_user(self): user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertTrue(user_case.closed) - self.user.update_metadata({'foo': 'bar'}) + self.user.get_user_data(self.domain.name)['foo'] = 'bar' self.user.save() user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertTrue(user_case.closed) @@ -331,7 +328,7 @@ def test_update_and_reactivate_in_one_save(self): user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertTrue(user_case.closed) - self.user.update_metadata({'foo': 'bar'}) + self.user.get_user_data(self.domain.name)['foo'] = 'bar' self.user.is_active = True self.user.save() user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) @@ -339,9 +336,7 @@ def test_update_and_reactivate_in_one_save(self): self.assertEqual(user_case.dynamic_case_properties()['foo'], 'bar') def test_update_no_change(self): - self.user.update_metadata({ - 'numeric': 123, - }) + self.user.get_user_data(self.domain.name)['numeric'] = 123 self.user.save() user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertIsNotNone(user_case) @@ -352,7 +347,6 @@ def test_update_no_change(self): self.assertEqual(1, len(user_case.xform_ids)) def test_bulk_upload_usercases(self): - self.user.username = format_username('bushy_top', TEST_DOMAIN) self.user.save() upload_record = UserUploadRecord.objects.create( @@ -387,6 +381,7 @@ def test_bulk_upload_usercases(self): upload_record_id=upload_record.pk, ) self.assertEqual(results['errors'], []) + self.assertEqual([r['flag'] for r in results['rows']], ['updated', 'created']) old_user_case = CommCareCase.objects.get_case_by_external_id(TEST_DOMAIN, self.user._id, USERCASE_TYPE) self.assertEqual(old_user_case.owner_id, self.user.get_id) diff --git a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/const.js b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/const.js index d22bcdcea94bc..42f2d18fb9567 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/const.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/const.js @@ -38,6 +38,7 @@ hqDefine("cloudcare/js/form_entry/const", function () { PER_ROW: '-per-row', TEXT_ALIGN_CENTER: 'text-align-center', TEXT_ALIGN_RIGHT: 'text-align-right', + BUTTON_SELECT: 'button-select', // Note it's important to differentiate these two NO_PENDING_ANSWER: undefined, diff --git a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js index 9325ad3eb3998..4aa5f7ee6d171 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js @@ -215,17 +215,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { return null; }; - self.helpText = function () { - if (isPassword) { - return gettext('Password'); - } - switch (self.datatype) { - case constants.BARCODE: - return gettext('Barcode'); - default: - return gettext('Free response'); - } - }; self.enableReceiver(question, options); } FreeTextEntry.prototype = Object.create(EntrySingleAnswer.prototype); @@ -302,7 +291,7 @@ hqDefine("cloudcare/js/form_entry/entries", function () { divId: self.entryId, itemCallback: self.geocoderItemCallback, clearCallBack: self.geocoderOnClearCallback, - inputOnKeyDown: self._inputOnKeyDown + inputOnKeyDown: self._inputOnKeyDown, }); }; @@ -338,10 +327,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { return null; }; - self.helpText = function () { - return gettext('Number'); - }; - self.enableReceiver(question, options); } IntEntry.prototype = Object.create(FreeTextEntry.prototype); @@ -370,10 +355,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { return (!(/^[+-]?\d*(\.\d+)?$/.test(rawAnswer)) ? "This does not appear to be a valid phone/numeric number" : null); }; - this.helpText = function () { - return gettext('Phone number or Numeric ID'); - }; - this.enableReceiver(question, options); } PhoneEntry.prototype = Object.create(FreeTextEntry.prototype); @@ -398,10 +379,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { } return null; }; - - this.helpText = function () { - return gettext('Decimal'); - }; } FloatEntry.prototype = Object.create(IntEntry.prototype); FloatEntry.prototype.constructor = IntEntry; @@ -459,10 +436,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { self.templateType = 'multidropdown'; self.placeholderText = gettext('Please choose an item'); - self.helpText = function () { - return ""; - }; - self.afterRender = function () { select2ify(self, {}); }; @@ -523,6 +496,32 @@ hqDefine("cloudcare/js/form_entry/entries", function () { self.rawAnswer(constants.NO_ANSWER); }; + /** + * Represents a single button that cycles through choices + */ + function ButtonSelectEntry(question, options) { + var self = this; + SingleSelectEntry.call(this, question, options); + self.templateType = 'button'; + + self.buttonLabel = function () { + const choices = self.choices(); + const answer = self.answer() || 0; + return answer < choices.length ? choices[answer] : choices[0]; + }; + + self.onClick = function () { + const answer = self.answer(); + if (answer && answer < self.choices().length) { + self.answer(answer + 1); + } else { + self.answer(1); + } + }; + } + ButtonSelectEntry.prototype = Object.create(SingleSelectEntry.prototype); + ButtonSelectEntry.prototype.constructor = SingleSelectEntry; + /** * This is used for the labels and inputs in a Combined Multiple Choice question in a Question * List Group. It is also used for labels in a Combined Checkbox question. @@ -569,10 +568,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { self.templateType = 'dropdown'; self.placeholderText = gettext('Please choose an item'); - self.helpText = function () { - return ""; - }; - self.options = ko.computed(function () { return [{text: "", id: undefined}].concat(_.map(question.choices(), function (choice, idx) { return { @@ -618,10 +613,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { // Specifies the type of matching we will do when a user types a query self.matchType = options.matchType; - self.helpText = function () { - return gettext('Combobox'); - }; - self.additionalSelect2Options = function () { return { matcher: function (params, option) { @@ -791,16 +782,11 @@ hqDefine("cloudcare/js/form_entry/entries", function () { function TimeEntry(question, options) { this.templateType = 'time'; - let is12Hour = false; if (question.style) { if (question.stylesContains(constants.TIME_12_HOUR)) { this.clientFormat = 'h:mm a'; - is12Hour = true; } } - this.helpText = function () { - return is12Hour ? gettext("12-hour clock") : gettext("24-hour clock"); - }; DateTimeEntryBase.call(this, question, options); } TimeEntry.prototype = Object.create(DateTimeEntryBase.prototype); @@ -965,11 +951,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { var self = this; FileEntry.call(this, question, options); self.accept = "image/*,.pdf"; - - self.helpText = function () { - return gettext("Upload image"); - }; - } ImageEntry.prototype = Object.create(FileEntry.prototype); ImageEntry.prototype.constructor = FileEntry; @@ -981,11 +962,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { var self = this; FileEntry.call(this, question, options); self.accept = "audio/*"; - - self.helpText = function () { - return gettext("Upload audio file"); - }; - } AudioEntry.prototype = Object.create(FileEntry.prototype); AudioEntry.prototype.constructor = FileEntry; @@ -997,11 +973,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { var self = this; FileEntry.call(this, question, options); self.accept = "video/*"; - - self.helpText = function () { - return gettext("Upload video file"); - }; - } VideoEntry.prototype = Object.create(FileEntry.prototype); VideoEntry.prototype.constructor = FileEntry; @@ -1052,10 +1023,6 @@ hqDefine("cloudcare/js/form_entry/entries", function () { self.$canvas[0].width = width; self.$canvas[0].height = width / aspectRatio; }; - - self.helpText = function () { - return gettext("Draw signature"); - }; } SignatureEntry.prototype = Object.create(FileEntry.prototype); SignatureEntry.prototype.constructor = FileEntry; @@ -1168,13 +1135,9 @@ hqDefine("cloudcare/js/form_entry/entries", function () { var options = {}; var isMinimal = false; var isCombobox = false; - var isLabel = false; + var isButton = false; + var isChoiceLabel = false; var hideLabel = false; - var style; - - if (question.style) { - style = ko.utils.unwrapObservable(question.style.raw); - } var displayOptions = _getDisplayOptions(question); var isPhoneMode = ko.utils.unwrapObservable(displayOptions.phoneMode); @@ -1225,13 +1188,10 @@ hqDefine("cloudcare/js/form_entry/entries", function () { break; case constants.SELECT: isMinimal = question.stylesContains(constants.MINIMAL); - if (style) { - isCombobox = question.stylesContains(constants.COMBOBOX); - } - if (style) { - isLabel = question.stylesContains(constants.LABEL) || question.stylesContains(constants.LIST_NOLABEL); - hideLabel = question.stylesContains(constants.LIST_NOLABEL); - } + isCombobox = question.stylesContains(constants.COMBOBOX); + isButton = question.stylesContains(constants.BUTTON_SELECT); + isChoiceLabel = question.stylesContains(constants.LABEL) || question.stylesContains(constants.LIST_NOLABEL); + hideLabel = question.stylesContains(constants.LIST_NOLABEL); if (isMinimal) { entry = new DropdownEntry(question, {}); @@ -1249,7 +1209,9 @@ hqDefine("cloudcare/js/form_entry/entries", function () { matchType: question.style.raw().split(' ')[1], receiveStyle: receiveStyle, }); - } else if (isLabel) { + } else if (isButton) { + entry = new ButtonSelectEntry(question, {}); + } else if (isChoiceLabel) { entry = new ChoiceLabelEntry(question, { hideLabel: hideLabel, }); @@ -1273,14 +1235,12 @@ hqDefine("cloudcare/js/form_entry/entries", function () { break; case constants.MULTI_SELECT: isMinimal = question.stylesContains(constants.MINIMAL); - if (style) { - isLabel = question.stylesContains(constants.LABEL); - hideLabel = question.stylesContains(constants.LIST_NOLABEL); - } + isChoiceLabel = question.stylesContains(constants.LABEL); + hideLabel = question.stylesContains(constants.LIST_NOLABEL); if (isMinimal) { entry = new MultiDropdownEntry(question, {}); - } else if (isLabel) { + } else if (isChoiceLabel) { entry = new ChoiceLabelEntry(question, { hideLabel: false, }); @@ -1398,6 +1358,7 @@ hqDefine("cloudcare/js/form_entry/entries", function () { getEntry: getEntry, AddressEntry: AddressEntry, AudioEntry: AudioEntry, + ButtonSelectEntry: ButtonSelectEntry, ComboboxEntry: ComboboxEntry, DateEntry: DateEntry, DropdownEntry: DropdownEntry, diff --git a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/form_ui.js b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/form_ui.js index 0e90a1df8cdb2..a211b8cebc629 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/form_ui.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/form_ui.js @@ -241,6 +241,36 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { ko.mapping.fromJS(json, mapping, self); }; + /** + * Calculates background color for nested Group and Repeat headers. + * Recursively determines nesting level (considering only Group and Repeat), + * starting at 0 for the Form level and cycling colors for each level. + * + * @returns {string} - Background color for the header's nesting level. + */ + Container.prototype.headerBackgroundColor = function () { + let currentNode = this; + let nestedDepthCount = 0; + while (currentNode.parent) { + let isCollapsibleGroup = currentNode.type() === constants.GROUP_TYPE && currentNode.collapsible; + if (isCollapsibleGroup || currentNode.type() === constants.REPEAT_TYPE) { + nestedDepthCount += 1; + } + currentNode = currentNode.parent; + } + + // Colors are ordered from lightest to darkest with the lightest color for the highest level. + // Colors are based on Bootstrap provided tint/shades of #5D70D2 (CommCare Cornflower Blue) + // shade(#5D70D2, 20%): #4a5aa8 + // shade(#5D70D2, 40%): #38437e + // shade(#5D70D2, 60%); #252d54 + const repeatColor = ["#4a5aa8", "#38437e", "#252d54"]; + const repeatColorCount = repeatColor.length; + const index = (nestedDepthCount - 1) % repeatColorCount; + + return repeatColor[index]; + }; + /** * Recursively groups sequential "question" items in a nested JSON structure. * @@ -474,6 +504,19 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { }); }; + self.getTranslation = function (translationKey, defaultTranslation) { + // Find the root level element which contains the translations. + var translations = self.translations; + + if (translations) { + var translationText = ko.toJS(translations[translationKey]); + if (translationText) { + return translationText; + } + } + return defaultTranslation; + }; + self.afterRender = function () { $(document).on("click", ".help-text-trigger", function (event) { event.preventDefault(); @@ -602,6 +645,13 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { const hasLabel = !!ko.utils.unwrapObservable(self.caption_markdown) || !!self.caption(); return hasChildren && hasLabel; }; + + self.headerBackgroundColor = function () { + if (self.isRepetition || !self.collapsible) { + return ''; + } + return Container.prototype.headerBackgroundColor.call(self); + }; } Group.prototype = Object.create(Container.prototype); Group.prototype.constructor = Container; @@ -633,20 +683,6 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { $.publish('formplayer.dirty'); $('.add').trigger('blur'); }; - - self.getTranslation = function (translationKey, defaultTranslation) { - // Find the root level element which contains the translations. - var curParent = getParentForm(self); - var translations = curParent.translations; - - if (translations) { - var addNewRepeatTranslation = ko.toJS(translations[translationKey]); - if (addNewRepeatTranslation) { - return addNewRepeatTranslation; - } - } - return defaultTranslation; - }; } Repeat.prototype = Object.create(Container.prototype); Repeat.prototype.constructor = Container; @@ -707,9 +743,6 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { self.dirty = ko.computed(function () { return self.pendingAnswer() !== constants.NO_PENDING_ANSWER; }); - self.clean = ko.computed(function () { - return !self.dirty() && !self.error() && !self.serverError() && self.hasAnswered; - }); self.hasError = ko.computed(function () { return (self.error() || self.serverError()) && !self.dirty(); }); @@ -726,7 +759,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { return self.error() === null && self.serverError() === null; }; - self.is_select = (self.datatype() === 'select' || self.datatype() === 'multiselect'); + self.isButton = self.datatype() === 'select' && self.stylesContains(constants.BUTTON_SELECT); self.isLabel = self.datatype() === 'info'; self.entry = entries.getEntry(self); self.entryTemplate = function () { @@ -829,15 +862,16 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () { Question.prototype.setWidths = function () { const columnWidth = Question.calculateColumnWidthForPerRowStyle(this.style); + const perRowPattern = new RegExp(`\\d+${constants.PER_ROW}(\\s|$)`); - if (columnWidth === constants.GRID_COLUMNS) { - this.controlWidth = constants.CONTROL_WIDTH; - this.labelWidth = constants.LABEL_WIDTH; - this.questionTileWidth = constants.FULL_WIDTH; - } else { + if (this.stylesContains(perRowPattern)) { this.controlWidth = constants.FULL_WIDTH; this.labelWidth = constants.FULL_WIDTH; this.questionTileWidth = `col-sm-${columnWidth}`; + } else { + this.controlWidth = constants.CONTROL_WIDTH; + this.labelWidth = constants.LABEL_WIDTH; + this.questionTileWidth = constants.FULL_WIDTH; } }; diff --git a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/entries_spec.js b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/entries_spec.js index 5a43e2c200fe9..a13b85bebe2e7 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/entries_spec.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/entries_spec.js @@ -323,6 +323,38 @@ hqDefine("cloudcare/js/form_entry/spec/entries_spec", function () { assert.isNull(entry.rawAnswer()); }); + it('Should return ButtonSelectEntry', function () { + questionJSON.datatype = constants.SELECT; + questionJSON.style = { raw: constants.BUTTON_SELECT }; + questionJSON.choices = ['a', 'b']; + questionJSON.answer = 1; + + var entry = formUI.Question(questionJSON).entry; + assert.isTrue(entry instanceof entries.ButtonSelectEntry); + assert.equal(entry.templateType, 'button'); + assert.equal(entry.rawAnswer(), 'a'); + }); + + it('Should cycle through ButtonSelect choices on click', function () { + questionJSON.datatype = constants.SELECT; + questionJSON.style = { raw: constants.BUTTON_SELECT }; + questionJSON.choices = ['a', 'b', 'c']; + questionJSON.answer = 1; + + var entry = formUI.Question(questionJSON).entry; + // value 'a' shows label 'b' to indicate what will be selected when clicked + assert.equal(entry.rawAnswer(), 'a'); + assert.equal(entry.buttonLabel(), 'b'); + + entry.onClick(); + assert.equal(entry.rawAnswer(), 'b'); + assert.equal(entry.buttonLabel(), 'c'); + + entry.onClick(); + assert.equal(entry.rawAnswer(), 'c'); + assert.equal(entry.buttonLabel(), 'a'); + }); + it('Should return DateEntry', function () { questionJSON.datatype = constants.DATE; questionJSON.answer = '1990-09-26'; diff --git a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/form_ui_spec.js b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/form_ui_spec.js index e52428d6c9a57..88ed5db91bc27 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/form_ui_spec.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/form_entry/spec/form_ui_spec.js @@ -113,6 +113,43 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () { assert.equal(form.children()[3].children().length, 1); // [q3] }); + it('Should calculate nested background header color', function () { + let styleObj = {raw: 'group-collapse'}; + let g0 = fixtures.groupJSON({ + style: styleObj, + }); + let g1 = fixtures.groupJSON({ + style: styleObj, + }); + let r1 = fixtures.repeatNestJSON(); + g1.children[0].style = styleObj; + r1.children[0].style = styleObj; + g1.children[0].children.push(r1); + g0.children[0].children.push(g1); + + /* Group (collapsible) [g0] + -Group [g0-0] + -Question + -Question + -Group (collapsible) [g1] + -Group (collapsible) [g1-0] + -Question + -Question + -Repeat [r1] + - Group (collapsible) [r1-0] + -Question + */ + formJSON.tree = [g0]; + let form = formUI.Form(formJSON); + + assert.equal(form.children()[0].headerBackgroundColor(), '#4a5aa8'); //[g0] + assert.equal(form.children()[0].children()[0].headerBackgroundColor(), ''); //[g0-0] + assert.equal(form.children()[0].children()[0].children()[2].headerBackgroundColor(), '#38437e'); //[g1] + assert.equal(form.children()[0].children()[0].children()[2].children()[0].headerBackgroundColor(), '#252d54'); //[g1-0] + assert.equal(form.children()[0].children()[0].children()[2].children()[0].children()[2].headerBackgroundColor(), '#4a5aa8'); //[r1] + assert.equal(form.children()[0].children()[0].children()[2].children()[0].children()[2].children()[0].headerBackgroundColor(), ''); //[r1-0] + }); + it('Should reconcile question choices', function () { formJSON.tree = [questionJSON]; var form = formUI.Form(formJSON), diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js index ffbd82b0e6741..437235349281b 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js @@ -10,7 +10,8 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { formEntryUtils = hqImport("cloudcare/js/form_entry/utils"), FormplayerFrontend = hqImport("cloudcare/js/formplayer/app"), formplayerUtils = hqImport("cloudcare/js/formplayer/utils/utils"), - ProgressBar = hqImport("cloudcare/js/formplayer/layout/views/progress_bar"); + ProgressBar = hqImport("cloudcare/js/formplayer/layout/views/progress_bar"), + initialPageData = hqImport("hqwebapp/js/initial_page_data"); var API = { queryFormplayer: function (params, route) { @@ -54,22 +55,26 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { }, gettext('Waiting for server progress')); } else if (_.has(response, 'exception')) { FormplayerFrontend.trigger('clearProgress'); - FormplayerFrontend.trigger( - 'showError', - response.exception, - response.type === 'html' - ); - - var currentUrl = FormplayerFrontend.getCurrentRoute(); - if (FormplayerFrontend.lastError === currentUrl) { - FormplayerFrontend.lastError = null; - FormplayerFrontend.trigger('navigateHome'); + if (params.clickedIcon && response.statusCode === 404) { + parsedMenus.removeCaseRow = true; + defer.resolve(parsedMenus); } else { - FormplayerFrontend.lastError = currentUrl; - FormplayerFrontend.trigger('navigation:back'); + FormplayerFrontend.trigger( + 'showError', + response.exception, + response.type === 'html' + ); + + var currentUrl = FormplayerFrontend.getCurrentRoute(); + if (FormplayerFrontend.lastError === currentUrl) { + FormplayerFrontend.lastError = null; + FormplayerFrontend.trigger('navigateHome'); + } else { + FormplayerFrontend.lastError = currentUrl; + FormplayerFrontend.trigger('navigation:back'); + } + defer.reject(); } - defer.reject(); - } else { if (response.smartLinkRedirect) { if (user.environment === constants.PREVIEW_APP_ENVIRONMENT) { @@ -147,6 +152,8 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { "tz_offset_millis": timezoneOffsetMillis, "tz_from_browser": tzFromBrowser, "selected_values": params.selectedValues, + "isShortDetail": params.isShortDetail, + "isRefreshCaseSearch": params.isRefreshCaseSearch, }; options.data = JSON.stringify(data); options.url = formplayerUrl + '/' + route; @@ -162,7 +169,21 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { message: "[request] " + route, data: _.pick(sentryData, _.identity), }); - menus.fetch($.extend(true, {}, options)); + + var callStartTime = performance.now(); + menus.fetch($.extend(true, {}, options)).always(function () { + if (data.query_data && data.query_data.results && data.query_data.results.initiatedBy === "dynamicSearch") { + var callEndTime = performance.now(); + var callResponseTime = callEndTime - callStartTime; + $.ajax(initialPageData.reverse('api_histogram_metrics'), { + method: 'POST', + data: {responseTime: callResponseTime, metrics: "commcare.dynamic_search.response_time"}, + error: function () { + console.log("API call failed to record metrics"); + }, + }); + } + }); }); return defer.promise(); @@ -179,7 +200,7 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { } var progressView = ProgressBar({ - progressMessage: gettext("Switching project spaces..."), + progressMessage: gettext("Loading..."), }); FormplayerFrontend.regions.getRegion('loadingProgress').show(progressView); @@ -199,9 +220,15 @@ hqDefine("cloudcare/js/formplayer/menus/api", function () { return API.queryFormplayer(options, "get_endpoint"); }); - FormplayerFrontend.getChannel().reply("entity:get:details", function (options, isPersistent) { + FormplayerFrontend.getChannel().reply("icon:click", function (options) { + return API.queryFormplayer(options, "get_endpoint"); + }); + + FormplayerFrontend.getChannel().reply("entity:get:details", function (options, isPersistent, isShortDetail, isRefreshCaseSearch) { options.isPersistent = isPersistent; options.preview = FormplayerFrontend.currentUser.displayOptions.singleAppMode; + options.isShortDetail = isShortDetail; + options.isRefreshCaseSearch = isRefreshCaseSearch; return API.queryFormplayer(options, 'get_details'); }); diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/collections.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/collections.js index bc5a1ff279529..be7b4017ae0d0 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/collections.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/collections.js @@ -32,6 +32,7 @@ hqDefine("cloudcare/js/formplayer/menus/collections", function () { 'title', 'type', 'noItemsText', + 'dynamicSearch', ], entityProperties: [ diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js index 8510628f73fc7..2e9c11e56c05a 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js @@ -96,7 +96,7 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { if (!isPersistent) { urlObject.addSelection(caseId); } - var fetchingDetails = FormplayerFrontend.getChannel().request("entity:get:details", urlObject, isPersistent); + var fetchingDetails = FormplayerFrontend.getChannel().request("entity:get:details", urlObject, isPersistent, false); $.when(fetchingDetails).done(function (detailResponse) { showDetail(detailResponse, detailIndex, caseId, isMultiSelect); }).fail(function () { @@ -134,7 +134,9 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { collection: queryCollection, title: menuResponse.title, description: menuResponse.description, + hasDynamicSearch: queryResponse.dynamicSearch, sidebarEnabled: true, + disableDynamicSearch: !sessionStorage.submitPerformed, }).render() ); } else if (sidebarEnabled && menuResponse.type === "query") { @@ -143,6 +145,7 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { collection: menuResponse, title: menuResponse.title, description: menuResponse.description, + hasDynamicSearch: menuResponse.dynamicSearch, sidebarEnabled: true, disableDynamicSearch: true, }).render() diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js index e65c04b7ec3f2..2fd2c888cf177 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js @@ -134,6 +134,7 @@ hqDefine("cloudcare/js/formplayer/menus/utils", function () { sortIndices: menuResponse.sortIndices, isMultiSelect: menuResponse.multiSelect, multiSelectMaxSelectValue: menuResponse.maxSelectValue, + dynamicSearch: menuResponse.dynamicSearch, endpointActions: menuResponse.endpointActions, }; }; diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js index d269096f0ad65..dc72eaccb09b8 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js @@ -146,6 +146,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { }; // generate the case tile's style block and insert const buildCellLayout = function (tiles, styles, prefix) { + const borderInTile = Boolean(_.find(styles, s => s.showBorder)); const tileModels = _.chain(tiles || []) .map(function (tile, idx) { if (tile === null || tile === undefined) { @@ -158,6 +159,8 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { fontStyle: tile.fontSize, verticalAlign: getValidFieldAlignment(style.verticalAlign), horizontalAlign: getValidFieldAlignment(style.horizontalAlign), + showBorder: style.showBorder, + borderInTile: borderInTile, }; }) .filter(function (tile) { @@ -230,7 +233,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { template: _.template($("#case-view-item-template").html() || ""), ui: { - clickIcon: ".module-icon.btn", + clickIcon: ".module-icon.clickable-icon", selectRow: ".select-row-checkbox", showMore: ".show-more", }, @@ -245,6 +248,10 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { 'keypress @ui.showMore': 'showMoreAction', }, + modelEvents: { + "change": "modelChanged", + }, + initialize: function () { const self = this; self.isMultiSelect = this.options.isMultiSelect; @@ -268,65 +275,80 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { iconClick: function (e) { e.stopImmediatePropagation(); - const origin = window.location.origin; - const user = FormplayerFrontend.getChannel().request('currentUser'); - const appId = formplayerUtils.currentUrlToObject().appId; - const currentApp = FormplayerFrontend.getChannel().request("appselect:getApp", appId); - // Confirms we are getting the app id, not build id - const currentAppId = currentApp.attributes["copy_of"] ? currentApp.attributes["copy_of"] : currentApp.attributes["_id"] - const domain = user.domain; - const caseId = this.model.get('id'); - const fieldIndex = $(e.currentTarget).parent().index(); + const fieldIndex = this.getFieldIndexFromEvent(e); const urlTemplate = this.options.endpointActions[fieldIndex]['urlTemplate']; - const actionUrl = origin + urlTemplate - .replace("{domain}", domain) - .replace("{appid}", currentAppId) - .replace("{case_id}", caseId); + const isBackground = this.options.endpointActions[fieldIndex]['background']; + let caseId; + if (this.options.headerRowIndices && !$(e.target).closest('.group-rows').length) { + caseId = this.model.get('groupKey'); + } else { + caseId = this.model.get('id'); + } + // Grab endpoint id from urlTemplate + const temp = urlTemplate.substring(0, urlTemplate.indexOf('?') - 1); + const endpointId = temp.substring(temp.lastIndexOf('/') + 1); + const endpointArg = urlTemplate.substring(urlTemplate.indexOf('?') + 1, urlTemplate.lastIndexOf('=')); e.target.className += " disabled"; - this.iconIframe(e, actionUrl); + this.clickableIconRequest(e, endpointId, caseId, endpointArg, isBackground); + }, + + getFieldIndexFromEvent: function (e) { + return $(e.currentTarget).parent().parent().children('.module-case-list-column').index($(e.currentTarget).parent()); }, - iconIframe: function (e, url) { + clickableIconRequest: function (e, endpointId, caseId, endpointArg, isBackground) { + const self = this; const clickedIcon = e.target; clickedIcon.classList.add("disabled"); clickedIcon.style.display = 'none'; - const tableData = clickedIcon.closest('td'); - const spinnerElement = $(tableData).find('i'); + const spinnerElement = $(clickedIcon).siblings('i'); spinnerElement[0].style.display = ''; - const iconIframe = document.createElement('iframe'); - iconIframe.style.display = 'none'; - $(iconIframe).attr('id', 'icon-iframe') - iconIframe.src = encodeURI(url); - document.body.appendChild(iconIframe); - - $('iframe').on('load', function(){ - // Get success or error message from iframe and pass to main window - const notificationsElement = $("#icon-iframe").contents().find("#cloudcare-notifications"); - notificationsElement.on('DOMNodeInserted', function(e) { - if ($(e.target).hasClass('alert')) { - const alertCollection = $(e.target); - const succeeded = alertCollection[0].classList.contains('alert-success'); - let message; - if (succeeded) { - message = notificationsElement.find('.alert-success').find('p').text(); - FormplayerFrontend.trigger('showSuccess', gettext(message)); - } else { - const messageElement = notificationsElement.find('.alert-danger'); - // Todo: standardize structures of success and error alert elements - message = messageElement.contents().filter(function(){ - return this.nodeType == Node.TEXT_NODE; - })[0].nodeValue; - FormplayerFrontend.trigger('showError', gettext(message)); - } - clickedIcon.classList.remove("disabled"); - clickedIcon.style.display = ''; - spinnerElement[0].style.display = 'none'; - iconIframe.parentNode.removeChild(iconIframe); - } - }) + const currentUrlToObject = formplayerUtils.currentUrlToObject(); + currentUrlToObject.endpointArgs = {[endpointArg]: caseId}; + currentUrlToObject.endpointId = endpointId; + currentUrlToObject.isBackground = isBackground; + + function resetIcon() { + clickedIcon.classList.remove("disabled"); + clickedIcon.style.display = ''; + spinnerElement[0].style.display = 'none'; + } + + $.when(FormplayerFrontend.getChannel().request("icon:click", currentUrlToObject)).done(function () { + self.reloadCase(self.model.get('id')); + resetIcon(); + }).fail(function () { + resetIcon(); }); }, + reloadCase: function (caseId) { + const self = this; + const urlObject = formplayerUtils.currentUrlToObject(); + urlObject.addSelection(caseId); + urlObject.clickedIcon = true; + const fetchingDetails = FormplayerFrontend.getChannel().request("entity:get:details", urlObject, false, true, true, true); + $.when(fetchingDetails).done(function (detailResponse) { + self.updateModelFromDetailResponse(caseId, detailResponse); + }).fail(function () { + console.log('could not get case details'); + }); + }, + + updateModelFromDetailResponse: function (caseId, detailResponse) { + if (detailResponse.removeCaseRow) { + this.destroy(); + } else { + this.model.set("data", detailResponse.models[0].attributes.details); + } + }, + + modelChanged: function () { + if (!this.model.get('updating')) { + this.render(); + } + }, + rowClick: function (e) { if (!( e.target.classList.contains('module-case-list-column-checkbox') || // multiselect checkbox @@ -390,7 +412,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { isMultiSelect: this.options.isMultiSelect, renderMarkdown: markdown.render, resolveUri: function (uri) { - return FormplayerFrontend.getChannel().request('resourceMap', uri, appId); + return FormplayerFrontend.getChannel().request('resourceMap', uri.trim(), appId); }, }; }, @@ -425,6 +447,19 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { dict['prefix'] = this.options.prefix; return dict; }, + + updateModelFromDetailResponse: function (caseId, detailResponse) { + if (detailResponse.removeCaseRow) { + this.destroy(); + } else { + CaseTileView.__super__.updateModelFromDetailResponse.apply(this, [caseId, detailResponse]); + } + }, + + getFieldIndexFromEvent: function (e) { + CaseTileView.__super__.getFieldIndexFromEvent.apply(this, [e]); + return $(e.currentTarget).parent().index(); + }, }); const CaseTileGroupedView = CaseTileView.extend({ @@ -437,33 +472,60 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { const data = this.options.model.get('data'); const headerRowIndices = this.options.headerRowIndices; + dict['indexedHeaderData'] = headerRowIndices.reduce((acc, index) => { acc[index] = data[index]; return acc; }, {}); - dict['indexedRowDataList'] = this.getIndexedRowDataList(); return dict; }, + getFieldIndexFromEvent: function (e) { + const fieldIndex = CaseTileGroupedView.__super__.getFieldIndexFromEvent.apply(this, [e]); + if ($(e.target).closest('.group-rows').length) { + return this.options.bodyRowIndices[fieldIndex]; + } else { + return this.options.headerRowIndices[fieldIndex]; + } + }, + getIndexedRowDataList: function () { let indexedRowDataList = []; for (let model of this.options.groupModelsList) { - let indexedRowData = model.get('data') - .reduce((acc, data, i) => { - if (!this.options.headerRowIndices.includes(i) && - this.options.styles[i].widthHint !== 0) { - acc[i] = data; - } - return acc; - }, {}); - if (Object.keys(indexedRowData).length !== 0) { - indexedRowDataList.push(indexedRowData); + if (model.id === this.model.get('updatedCaseId')) { + indexedRowDataList.push(this.model.get('updatedRowData')); + } else { + let indexedRowData = model.get('data') + .reduce((acc, data, i) => { + if (this.options.bodyRowIndices.includes(i)) { + acc[i] = data; + } + return acc; + }, {}); + if (Object.keys(indexedRowData).length !== 0) { + indexedRowDataList.push(indexedRowData); + } } } return indexedRowDataList; }, + + updateModelFromDetailResponse: function (caseId, detailResponse) { + if (detailResponse.removeCaseRow) { + this.destroy(); + } else { + this.model.set('updating', true); + CaseTileGroupedView.__super__.updateModelFromDetailResponse.apply(this, [caseId, detailResponse]); + this.model.set('updatedCaseId', caseId); + this.model.set('updatedRowData', this.options.bodyRowIndices.reduce((acc, index) => { + acc[index] = detailResponse.models[0].attributes.details[index]; + return acc; + }, {})); + this.model.set('updating', false); + } + }, }); const PersistentCaseTileView = CaseTileView.extend({ @@ -489,12 +551,14 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { casesPerPageLimit: '.per-page-limit', searchMoreButton: '#search-more', scrollToBottomButton: '#scroll-to-bottom', + mapShowHideButton: '#hide-map-button', }; }; const CaseListViewEvents = function () { return { 'click @ui.actionButton': 'caseListAction', + 'click @ui.mapShowHideButton': 'showHideMap', 'click @ui.searchButton': 'caseListSearch', 'click @ui.paginators': 'paginateAction', 'click @ui.paginationGoButton': 'paginationGoAction', @@ -518,7 +582,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { childViewOptions: function () { return { styles: this.options.styles, - endpointActions: this.options.endpointActions + endpointActions: this.options.endpointActions, }; }, @@ -655,6 +719,24 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { } }, + showHideMap: function (e) { + const mapDiv = $('#module-case-list-map'); + const moduleCaseList = $('#module-case-list'); + const hideButton = $('#hide-map-button'); + if (!mapDiv.hasClass('hide')) { + mapDiv.addClass('hide'); + moduleCaseList.removeClass('col-md-7 col-md-pull-5').addClass('col-md'); + hideButton.text(gettext('Show Map')); + $(e.target).attr('aria-expanded', 'false'); + } else { + mapDiv.removeClass('hide'); + moduleCaseList.addClass('col-md-7 col-md-pull-5').removeClass('col-md'); + hideButton.text(gettext('Hide Map')); + $(e.target).attr('aria-expanded', 'true'); + } + + }, + _allCaseIds: function () { const caseIds = []; this.children.each(function (childView) { @@ -1065,11 +1147,18 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { } } - let groupHeaderRows = this.options.collection.groupHeaderRows; + const groupHeaderRows = this.options.collection.groupHeaderRows; // select the indices of the tile fields that are part of the header rows - this.headerRowIndices = this.options.collection.tiles - .map((tile, index) => ({tile: tile, index: index})) - .filter((tile) => tile.tile && tile.tile.gridY < groupHeaderRows) + + const isHeaderRow = (y) => y < groupHeaderRows; + const tileAndIndex = this.options.collection.tiles + .map((tile, index) => ({tile: tile, index: index})); + + this.headerRowIndices = tileAndIndex + .filter((tile) => tile.tile && isHeaderRow(tile.tile.gridY)) + .map((tile) => tile.index); + this.bodyRowIndices = tileAndIndex + .filter((tile) => tile.tile && !isHeaderRow(tile.tile.gridY)) .map((tile) => tile.index); }, @@ -1078,6 +1167,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { dict.groupHeaderRows = this.options.collection.groupHeaderRows; dict.groupModelsList = this.groupedModels[model.get("groupKey")]; dict.headerRowIndices = this.headerRowIndices; + dict.bodyRowIndices = this.bodyRowIndices; return dict; }, }); @@ -1222,7 +1312,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { const appId = formplayerUtils.currentUrlToObject().appId; return { resolveUri: function (uri) { - return FormplayerFrontend.getChannel().request('resourceMap', uri, appId); + return FormplayerFrontend.getChannel().request('resourceMap', uri.trim(), appId); }, }; }, diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views/query.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views/query.js index 75ebc55135e4f..1bf54ef89d6dc 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views/query.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views/query.js @@ -81,6 +81,9 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { kissmetrics.track.event("Accessibility Tracking - Geocoder Interaction in Case Search"); model.set('value', item.place_name); initMapboxWidget(model); + const geocoderValues = JSON.parse(sessionStorage.geocoderValues); + geocoderValues[model.id] = item.place_name; + sessionStorage.geocoderValues = JSON.stringify(geocoderValues); var broadcastObj = formEntryUtils.getBroadcastObject(item); $.publish(addressTopic, broadcastObj); return item.place_name; @@ -149,6 +152,13 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { $(function () { kissmetrics.track.event("Accessibility Tracking - Geocoder Seen in Case Search"); }); + const queryKey = sessionStorage.queryKey; + const storedGeocoderValues = sessionStorage.geocoderValues; + const geoValues = storedGeocoderValues ? JSON.parse(storedGeocoderValues) : {}; + if (!("queryKey" in geoValues) || geoValues["queryKey"] !== queryKey) { + geoValues["queryKey"] = queryKey; + sessionStorage.geocoderValues = JSON.stringify(geoValues); + } if ($field.find('.mapboxgl-ctrl-geocoder--input').length === 0) { if (!initialPageData.get("has_geocoder_privs")) { $("#" + inputId).addClass('unsupported alert alert-warning'); @@ -168,8 +178,9 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { divEl.css("width", "100%"); } - if (model.get('value')) { - $field.find('.mapboxgl-ctrl-geocoder--input').val(model.get('value')); + const geocoderValues = JSON.parse(sessionStorage.geocoderValues); + if (geocoderValues[id]) { + $field.find('.mapboxgl-ctrl-geocoder--input').val(geocoderValues[id]); } }; @@ -303,6 +314,7 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { self.model.set('error', null); self.errorMessage = null; self.model.set('searchForBlank', false); + sessionStorage.removeItem('geocoderValues'); if (self.ui.date.length) { self.ui.date.data("DateTimePicker").clear(); } @@ -341,14 +353,12 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { changeDateQueryField: function (e) { this.model.set('value', $(e.currentTarget).val()); - if (this.dynamicSearchEnabled) { - var useDynamicSearch = Date(this.model._previousAttributes.value) !== Date($(e.currentTarget).val()); - } + var useDynamicSearch = Date(this.model._previousAttributes.value) !== Date($(e.currentTarget).val()); this.notifyParentOfFieldChange(e, useDynamicSearch); this.parentView.setStickyQueryInputs(); }, - notifyParentOfFieldChange: function (e, useDynamicSearch = false) { + notifyParentOfFieldChange: function (e, useDynamicSearch = true) { if (this.model.get('input') === 'address') { // Geocoder doesn't have a real value, doesn't need to be sent to formplayer return; @@ -459,14 +469,15 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { initialize: function (options) { this.parentModel = options.collection.models || []; - - this.dynamicSearchEnabled = options.disableDynamicSearch ? false : - (toggles.toggleEnabled('DYNAMICALLY_UPDATE_SEARCH_RESULTS') && this.options.sidebarEnabled); + this.dynamicSearchEnabled = options.hasDynamicSearch && this.options.sidebarEnabled; this.smallScreenListener = cloudcareUtils.smallScreenListener(smallScreenEnabled => { this.handleSmallScreenChange(smallScreenEnabled); }); this.smallScreenListener.listen(); + + this.dynamicSearchEnabled = !(options.disableDynamicSearch || this.smallScreenEnabled) && + (toggles.toggleEnabled('DYNAMICALLY_UPDATE_SEARCH_RESULTS') && this.options.sidebarEnabled); }, templateContext: function () { @@ -565,17 +576,19 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { self.performSubmit(); }, - performSubmit: function () { + performSubmit: function (initiatedBy) { var self = this; self.validateAllFields().done(function () { FormplayerFrontend.trigger( "menu:query", self.getAnswers(), - self.options.sidebarEnabled + self.options.sidebarEnabled, + initiatedBy ); if (self.smallScreenEnabled && self.options.sidebarEnabled) { $('#sidebar-region').collapse('hide'); } + sessionStorage.submitPerformed = true; }); }, @@ -588,7 +601,7 @@ hqDefine("cloudcare/js/formplayer/menus/views/query", function () { } }); if (invalidRequiredFields.length === 0) { - self.performSubmit(); + self.performSubmit("dynamicSearch"); } }, diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/router.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/router.js index d56f30b57934d..433aff639f9b9 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/router.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/router.js @@ -199,12 +199,13 @@ hqDefine("cloudcare/js/formplayer/router", function () { API.listMenus(); }); - FormplayerFrontend.on("menu:query", function (queryDict, sidebarEnabled) { + FormplayerFrontend.on("menu:query", function (queryDict, sidebarEnabled, initiatedBy) { var urlObject = utils.currentUrlToObject(); var queryObject = _.extend( { inputs: queryDict, execute: true, + initiatedBy: initiatedBy, }, // force manual search in split screen case search for workflow compatibility sidebarEnabled ? { forceManualSearch: true } : {} diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/utils/utils.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/utils/utils.js index 54b9e7e5cd027..4e42bfa856973 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/utils/utils.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/utils/utils.js @@ -264,15 +264,23 @@ hqDefine("cloudcare/js/formplayer/utils/utils", function () { this.sortIndex = null; }; - this.setQueryData = function ({ inputs, execute, forceManualSearch}) { + this.setQueryData = function ({ inputs, execute, forceManualSearch, initiatedBy}) { var selections = Utils.currentUrlToObject().selections; this.queryData = this.queryData || {}; - this.queryData[sessionStorage.queryKey] = _.defaults({ + + const queryDataEntry = _.defaults({ inputs: inputs, execute: execute, force_manual_search: forceManualSearch, selections: selections, }, this.queryData[sessionStorage.queryKey]); + + if (initiatedBy !== null && initiatedBy !== undefined) { + queryDataEntry.initiatedBy = initiatedBy; + } + + this.queryData[sessionStorage.queryKey] = queryDataEntry; + this.page = null; this.search = null; }; @@ -294,10 +302,13 @@ hqDefine("cloudcare/js/formplayer/utils/utils", function () { this.search = null; this.queryData = null; this.sessionId = null; + sessionStorage.removeItem('submitPerformed'); + sessionStorage.removeItem('geocoderValues'); }; this.onSubmit = function () { sessionStorage.removeItem('selectedValues'); + sessionStorage.removeItem('geocoderValues'); this.page = null; this.sortIndex = null; this.search = null; @@ -325,6 +336,7 @@ hqDefine("cloudcare/js/formplayer/utils/utils", function () { this.search = null; this.sortIndex = null; sessionStorage.removeItem('selectedValues'); + sessionStorage.removeItem('geocoderValues'); this.sessionId = null; }; diff --git a/corehq/apps/cloudcare/static/cloudcare/js/utils.js b/corehq/apps/cloudcare/static/cloudcare/js/utils.js index 755e759f3bb2c..c5f193768d57f 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/utils.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/utils.js @@ -37,6 +37,11 @@ hqDefine('cloudcare/js/utils', [ var showError = function (message, $el, reportToHq) { message = getErrorMessage(message); + // Make message more user friendly since html isn't useful here + if (message.includes('500') && message.includes('<!DOCTYPE html>')) { + message = 'Sorry, something went wrong. Please try again in a few minutes. ' + + 'If this problem persists, please report it to CommCare Support.'; + } _show(message, $el, null, "alert alert-danger"); if (reportToHq === undefined || reportToHq) { reportFormplayerErrorToHQ({ diff --git a/corehq/apps/cloudcare/static/cloudcare/less/markdown-table.less b/corehq/apps/cloudcare/static/cloudcare/less/markdown-table.less index 031a5dd8b90c8..1629bfee102f2 100644 --- a/corehq/apps/cloudcare/static/cloudcare/less/markdown-table.less +++ b/corehq/apps/cloudcare/static/cloudcare/less/markdown-table.less @@ -1,26 +1,17 @@ .webapp-markdown-output { table { padding: 0; - td { - border: 1px solid #cccccc; - text-align: left; - margin: 0; - padding: 6px 13px; - &:first-child { - margin-top: 0; - } - &:last-child { - margin-bottom: 0; - } - } tr { - border-top: 1px solid #cccccc; background-color: white; margin: 0; padding: 0; - th { - font-weight: bold; - border: 1px solid #cccccc; + + &:nth-child(2n) { + background-color: #f8f8f8; + } + + th, td { + border: 1px solid #ccc; text-align: left; margin: 0; padding: 6px 13px; @@ -31,8 +22,10 @@ margin-bottom: 0; } } - &:nth-child(2n) { - background-color: #f8f8f8; + + th { + font-weight: bold; + background-color: #ddd; // override tr background color } } } diff --git a/corehq/apps/cloudcare/templates/block_web_apps.html b/corehq/apps/cloudcare/templates/block_web_apps.html new file mode 100644 index 0000000000000..e879902c73d5c --- /dev/null +++ b/corehq/apps/cloudcare/templates/block_web_apps.html @@ -0,0 +1,11 @@ +{% extends "error_base.html" %} +{% load hq_shared_tags %} +{% load i18n %} +{% block title %} + {% trans "Blocked" %} +{% endblock %} + +{% block page_name %}{% trans "You are currently blocked from using Web Apps" %}{% endblock %} +{% block page_content %} +<p class="error">{{ error_message }}</p> +{% endblock %} diff --git a/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html b/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html index e5930c62d12f7..163ef0f4e364c 100644 --- a/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html +++ b/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html @@ -88,6 +88,7 @@ {% registerurl 'report_formplayer_error' request.domain %} {% registerurl 'report_sentry_error' request.domain %} {% registerurl 'dialer_view' request.domain %} + {% registerurl 'api_histogram_metrics' request.domain %} {% if integrations.gaen_otp_enabled %} {% registerurl 'gaen_otp_view' request.domain %} {% endif %} diff --git a/corehq/apps/cloudcare/templates/form_entry/templates.html b/corehq/apps/cloudcare/templates/form_entry/templates.html index ba412a61e9204..96fd625ef9193 100644 --- a/corehq/apps/cloudcare/templates/form_entry/templates.html +++ b/corehq/apps/cloudcare/templates/form_entry/templates.html @@ -27,7 +27,10 @@ 'tabindex': isVisibleGroup() ? '0' : '-1' }, click: toggleChildren, - event: {keypress: keyPressAction}"> + event: {keypress: keyPressAction}, + style: {'background-color': headerBackgroundColor(), + 'color': headerBackgroundColor() ? 'white' : '', + }"> <div data-bind="ifnot: collapsible"> <legend> <span class="caption webapp-markdown-output" @@ -46,7 +49,7 @@ css: {'fa-angle-double-right': !showChildren(), 'fa-angle-double-down': showChildren()}, "></i> </div> - <span class="caption" data-bind="html: caption(), attr: {id: captionId()}"></span><!-- Markdown interferes with header styling --> + <span class="webapp-markdown-output caption" data-bind="html: caption_markdown() || caption(), attr: {id: captionId()}"></span> <i class="fa fa-warning text-danger pull-right" data-bind="visible: hasError() && !showChildren()"></i> <button class="btn btn-danger del pull-right" href="#" data-bind=" visible: isRepetition, @@ -201,7 +204,7 @@ <h1 class="title" data-bind="text: title, visible: !showInFormNavigation()"></h1 <script type="text/html" id="question-fullform-ko-template"> <div data-bind="class:questionTileWidth"> - <!-- ko if: datatype() !== 'info' --> + <!-- ko if: !isLabel && !isButton --> <div class="q form-group" data-bind=" css: { error: error, @@ -252,11 +255,6 @@ <h1 class="title" data-bind="text: title, visible: !showInFormNavigation()"></h1 <!-- /ko --> </label> <div class="widget-container controls" data-bind="css: controlWidth"> - <div class="loading"> - <i class="fa fa-check text-success" data-bind="visible: clean "></i> - <i class="fa fa-spin fa-refresh" data-bind="visible: dirty"></i> - <i class="fa fa-warning text-danger clickable" data-bind="visible: hasError, click: triggerAnswer"></i> - </div> <div class="widget" data-bind=" template: { name: entryTemplate, data: entry, afterRender: afterRender }, css: { 'has-error': hasError } @@ -284,10 +282,10 @@ <h1 class="title" data-bind="text: title, visible: !showInFormNavigation()"></h1 on: $root.forceRequiredVisible, }">{% trans 'Sorry, this response is required!' %}</div> <!-- /ko --> - <!-- ko if: datatype() === 'info' --> - <div class="info panel panel-default"> + <!-- ko if: isLabel --> + <div class="form-group"> {# appearance attributes TEXT_ALIGN_CENTER TEXT_ALIGN_RIGHT #} - <div class="panel-body" data-bind="css: { + <div class="info col-sm-12" data-bind="css: { 'text-center': stylesContains('text-align-center'), 'text-right': stylesContains('text-align-right'), }"> @@ -312,12 +310,31 @@ <h1 class="title" data-bind="text: title, visible: !showInFormNavigation()"></h1 </div> </div> <!-- /ko --> + <!-- ko if: isButton --> + {# appearance attributes TEXT_ALIGN_CENTER TEXT_ALIGN_RIGHT #} + <div class="q form-group" data-bind=" + css: { + error: error, + 'text-center': stylesContains('text-align-center'), + 'text-right': stylesContains('text-align-right') + }"> + <div class="widget-container controls col-sm-12"> + <div class="widget" data-bind=" + template: { name: entryTemplate, data: entry, afterRender: afterRender }, + css: { 'has-error': hasError } + "></div> + </div> + </div> + <!-- /ko --> </div> </script> <script type="text/html" id="repeat-juncture-fullform-ko-template"> <div class="panel panel-default rep"> - <div class="panel-heading"> + <div class="panel-heading" data-bind="style: { + 'background-color': headerBackgroundColor(), + 'color': headerBackgroundColor() ? 'white' : '', + }"> <h3 class="caption" data-bind="html: header" tabindex="0"></h3> <span class="ix" data-bind="text: ixInfo($data)"></span> </div> @@ -332,7 +349,7 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> data-bind="click: newRepeat" id="repeat-add-new"> <i class="fa fa-plus"></i> - <!-- ko text: getTranslation('repeat.dialog.add.new', '{% trans_html_attr 'Add new repeat' %}') --><!-- /ko --> + <!-- ko text: $root.getTranslation('repeat.dialog.add.new', '{% trans_html_attr 'Add new repeat' %}') --><!-- /ko --> </button> </div> </div> @@ -368,9 +385,6 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> </script> <script type="text/html" id="text-entry-ko-template"> - <span class="help-block type sr-only" data-bind=" - text: helpText() - "></span> <textarea class="textfield form-control vertical-resize" data-bind=" value: $data.rawAnswer, valueUpdate: valueUpdate, @@ -380,13 +394,9 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> 'aria-required': $parent.required() ? 'true' : 'false', }, "></textarea> - <span class="help-block type" aria-hidden="true" data-bind=" - text: helpText() - "></span> </script> <script type="text/html" id="password-entry-ko-template"> - <span class="help-block type sr-only" data-bind="text: helpText()"></span> <input type="password" class="form-control" data-bind=" value: $data.rawAnswer, valueUpdate: valueUpdate, @@ -395,13 +405,9 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> 'aria-required': $parent.required() ? 'true' : 'false', }, "/> - <span class="help-block type" aria-hidden="true" data-bind="text: helpText()"></span> </script> <script type="text/html" id="str-entry-ko-template"> - <span class="help-block type sr-only" data-bind=" - text: helpText() - "></span> <input autocomplete="off" type="text" class="form-control" data-bind=" value: $data.rawAnswer, valueUpdate: valueUpdate, @@ -412,9 +418,6 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> 'aria-required': $parent.required() ? 'true' : 'false', } "/> - <span class="help-block type" aria-hidden="true" data-bind=" - text: helpText() - "></span> </script> <script type="text/html" id="unsupported-entry-ko-template"> <div class="unsupported alert alert-warning"> @@ -443,7 +446,6 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> </script> <script type="text/html" id="file-entry-ko-template"> - <span class="help-block type sr-only" data-bind="text: helpText()"></span> <input type="file" data-bind=" value: $data.rawAnswer, attr: { @@ -452,14 +454,14 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> accept: accept, }, "/> - <span class="help-block type" aria-hidden="true" data-bind="text: helpText()"></span> - <button class="btn btn-default btn-xs pull-right" data-bind="click: onClear"> - {% trans "Clear" %} + <button class="btn btn-default btn-xs pull-right" data-bind=" + click: onClear, + text: $root.getTranslation('upload.clear.title', '{% trans_html_attr 'Clear' %}'), + "> </button> </script> <script type="text/html" id="signature-entry-ko-template"> - <span class="help-block type sr-only" data-bind="text: helpText()"></span> <div data-bind="attr: { id: entryId + '-wrapper' }"> <canvas data-bind=" attr: { @@ -480,7 +482,6 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> accept: accept, }, "/> - <span class="help-block type" aria-hidden="true" data-bind="text: helpText()"></span> </script> <script type="text/html" id="address-entry-ko-template"> @@ -547,11 +548,12 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> </div> </fieldset> </script> +<script type="text/html" id="button-entry-ko-template"> + <button class="btn btn-default" data-bind="click: onClick, attr: {id: entryId}"> + <span data-bind="renderMarkdown: buttonLabel()"></span> + </button> +</script> <script type="text/html" id="dropdown-entry-ko-template"> - <span class="help-block type sr-only" data-bind=" - text: helpText(), - visible: helpText(), - "></span> <select class="form-control" data-bind=" foreach: options, value: rawAnswer, @@ -563,16 +565,8 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> "> <option data-bind="value: id, text: text"></option> </select> - <span class="help-block type" aria-hidden="true" data-bind=" - text: helpText(), - visible: helpText(), - "></span> </script> <script type="text/html" id="multidropdown-entry-ko-template"> - <span class="help-block type sr-only" data-bind=" - text: helpText(), - visible: helpText(), - "></span> <select multiple class="form-control" data-bind=" options: choices, selectedOptions: rawAnswer, @@ -583,10 +577,6 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> valueAllowUnset: true, "> </select> - <span class="help-block type" aria-hidden="true" data-bind=" - text: helpText(), - visible: helpText(), - "></span> </script> <script type="text/html" id="choice-label-entry-ko-template"> <div class="row"> @@ -632,12 +622,10 @@ <h3 class="caption" data-bind="html: header" tabindex="0"></h3> </script> <script type="text/html" id="time-entry-ko-template"> - <span class="help-block sr-only" data-bind="text: helpText()"></span> <div class="input-group"> <input type="text" class="form-control" data-bind="attr: { id: entryId, 'aria-required': $parent.required() ? 'true' : 'false' }"/> <span class="input-group-addon"><i class="fa fa-clock-o"></i></span> </div> - <span class="help-block" aria-hidden="true" data-bind="text: helpText()"></span> </script> <script type="text/html" id="ethiopian-date-entry-ko-template"> diff --git a/corehq/apps/cloudcare/templates/formplayer/case_list.html b/corehq/apps/cloudcare/templates/formplayer/case_list.html index 4602a34fcb8fc..32769539db165 100644 --- a/corehq/apps/cloudcare/templates/formplayer/case_list.html +++ b/corehq/apps/cloudcare/templates/formplayer/case_list.html @@ -6,17 +6,25 @@ <button id="scroll-to-bottom" class="btn btn-lg btn-circle btn-primary hidden-md hidden-lg" type="button" aria-label="{% trans 'Scroll to bottom' %}" style="display: none;"> <i class="fa fa-lg fa-arrow-down" aria-hidden="true"></i> </button> - <div class="page-header menu-header" id="case-list-menu-header"> - <% if (sidebarEnabled) {%> + <div class="page-header menu-header clearfix" id="case-list-menu-header"> + <div class="pull-right"> + <% if (showMap) { %> + <button id="hide-map-button" class="btn btn-primary pull-right" type="button" + aria-expanded="true" aria-controls="module-case-list-map"> + {% trans "Hide Map" %} + </button> + <% } %> + <% if (sidebarEnabled) { %> <button id="search-more" class="btn btn-primary visible-xs visible-sm pull-right" type="button" data-toggle="collapse" data-target="#sidebar-region" aria-expanded="false" aria-controls="sidebar-region"> {% trans "Refine search" %} </button> <% } %> - <% if (title.length > 0) {%> - <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> + </div> + <% if (title.length > 0) { %> + <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <% } %> - <% if (sidebarEnabled && description.length > 0) {%> + <% if (sidebarEnabled && description.length > 0) { %> <div aria-label="<%- description %>" tabindex="0" class="query-description"><%= description %></div> <% } %> </div> @@ -97,7 +105,7 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <% if (actions || isMultiSelect) { %> <div class="case-list-actions"> <% if (isMultiSelect) { %> - <button type="button" class="btn btn-success btn-lg formplayer-request" disabled="true" id="multi-select-continue-btn">{% trans "Continue" %} (<span id="multi-select-btn-text"><%- selectedCaseIds.length %></span>)</button> + <button type="button" class="btn btn-success btn-lg formplayer-request" disabled="true" id="multi-select-continue-btn">{% trans "Continue" %} <span id="multi-select-btn-text" class="badge"><%- selectedCaseIds.length %></span></button> <% } %> <% if (actions) { %> <% _.each(actions, function(action, index) { %> @@ -127,7 +135,7 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <td class="module-case-list-column"><img class="module-icon" src="<%- resolveUri(datum) %>"/></td> <% } else if (styles[index].displayFormat === 'ClickableIcon') { %> <td class="module-case-list-column"> - <img class="module-icon btn" src="<%- resolveUri(datum) %>"/> + <img class="module-icon clickable-icon" src="<%- resolveUri(datum) %>"/> <i class="fa fa-spin fa-spinner" style="display:none"></i> </td> <% } else if (styles[index].displayFormat === 'Markdown') { %> @@ -153,17 +161,17 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <div class="<%- prefix %>-grid-style-<%- index %> box"> <% if (styles[index].displayFormat === 'Image') { if(resolveUri(datum)) { %> - <img class="module-icon" src="<%- resolveUri(datum) %>"/> + <img class="module-icon" src="<%- resolveUri(datum) %>"/> <% } %> <% } else if (styles[index].displayFormat === 'ClickableIcon') { if(resolveUri(datum)) { %> - <img class="module-icon btn" src="<%- resolveUri(datum) %>"/> - <i class="fa fa-spin fa-spinner" style="display:none"></i> + <img class="module-icon clickable-icon" src="<%- resolveUri(datum) %>"/> + <i class="fa fa-spin fa-spinner" style="display:none"></i> <% } %> <% } else if(styles[index].widthHint === 0) { %> - <div style="display:none;"><%- datum %></div> + <div style="display:none;"><%- datum %></div> <% } else { %> - <div class="webapp-markdown-output"><%= renderMarkdown(datum) %></div> + <div class="webapp-markdown-output"><%= renderMarkdown(datum) %></div> <% } %> </div> <% }); %> @@ -183,7 +191,8 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <div class="<%- prefix %>-grid-style-<%- index %> box" > <% if (styles[index].displayFormat === 'ClickableIcon') { if(resolveUri(datum)) { %> - <img class="module-icon" style="max-width:100%; max-height:100%;" src="<%- resolveUri(datum) %>"/> + <img class="module-icon clickable-icon" style="max-width:100%; max-height:100%;" src="<%- resolveUri(datum) %>"/> + <i class="fa fa-spin fa-spinner" style="display:none"></i> <% } %> <% } else if (styles[index].displayFormat === 'Image') { if(resolveUri(datum)) { %> @@ -208,7 +217,12 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> if(resolveUri(datum)) { %> <img class="module-icon" style="max-width:100%; max-height:100%;" src="<%- resolveUri(datum) %>"/> <% } %> - <% } else { %> + <% } else if (styles[index].displayFormat === 'ClickableIcon') { + if(resolveUri(datum)) { %> + <img class="module-icon clickable-icon" style="max-width:100%; max-height:100%;" src="<%- resolveUri(datum) %>"/> + <i class="fa fa-spin fa-spinner" style="display:none"></i> + <% } %> + <% } else { %> <div class="webapp-markdown-output"><%= renderMarkdown(datum) %></div> <% } %> </div> @@ -221,13 +235,30 @@ <h1 aria-label="<%- title %>" tabindex="0" class="page-title"><%- title %></h1> <script type="text/template" id="cell-layout-style-template"> <% _.each(models, function(model){ %> - .<%- model.id %> { - grid-area: <%- model.gridStyle %>; - font-size: <%- model.fontStyle %>; - justify-self: <%- model.horizontalAlign %>; - text-align: <%- model.horizontalAlign %>; - align-self: <%- model.verticalAlign %>; - } + .<%- model.id %> { + grid-area: <%- model.gridStyle %>; + font-size: <%- model.fontStyle %>; + text-align: <%- model.horizontalAlign %>; + <% if (!model.borderInTile) { %> + justify-self: <%- model.horizontalAlign %>; + align-self: <%- model.verticalAlign %>; + <% } else { %> + <% if (model.showBorder) { %> + border: 1px solid #685c53; + border-radius: 8px; + padding-top: 5px; + padding-bottom: 0px; + padding-left: 5px; + padding-right: 5px; + justify-self: stretch; + margin: 2px; + <% } else { %> + margin: 7px; + justify-self: <%- model.horizontalAlign %>; + align-self: <%- model.verticalAlign %>; + <% } %> + <% } %> + } <% }); %> </script> diff --git a/corehq/apps/cloudcare/templates/preview_app/block_app_preview.html b/corehq/apps/cloudcare/templates/preview_app/block_app_preview.html new file mode 100644 index 0000000000000..add521d94f87f --- /dev/null +++ b/corehq/apps/cloudcare/templates/preview_app/block_app_preview.html @@ -0,0 +1,10 @@ +{% extends "formplayer-common/base.html" %} +{% load hq_shared_tags %} +{% load compress %} +{% load i18n %} + +{% block body %} +<div class="alert alert-warning"> + <p>{{ error_message }}</p> +</div> +{% endblock %} diff --git a/corehq/apps/cloudcare/tests/test_esaccessors.py b/corehq/apps/cloudcare/tests/test_esaccessors.py index f15300fc8d556..493584c2e135a 100644 --- a/corehq/apps/cloudcare/tests/test_esaccessors.py +++ b/corehq/apps/cloudcare/tests/test_esaccessors.py @@ -1,7 +1,7 @@ import uuid from unittest.mock import MagicMock, patch -from django.test import SimpleTestCase +from django.test import TestCase from corehq.apps.cloudcare.esaccessors import login_as_user_query from corehq.apps.es.tests.utils import es_test @@ -10,7 +10,7 @@ @es_test(requires=[user_adapter]) -class TestLoginAsUserQuery(SimpleTestCase): +class TestLoginAsUserQuery(TestCase): @classmethod def setUpClass(cls): @@ -22,15 +22,23 @@ def setUpClass(cls): cls.domain = 'user-esaccessors-test' def _send_user_to_es(self, _id=None, username=None, user_data=None): - user = CommCareUser( + user = CommCareUser.create( domain=self.domain, username=username or self.username, + password='password', + created_by=None, + created_via=None, _id=_id or uuid.uuid4().hex, first_name=self.first_name, last_name=self.last_name, - user_data=user_data or {}, is_active=True, + ) + user.save() + self.addCleanup(user.delete, self.domain, deleted_by=None) + if user_data: + user.get_user_data(self.domain).update(user_data) + user.get_user_data(self.domain).save() with patch('corehq.apps.groups.dbaccessors.get_group_id_name_map_by_user', return_value=[]): user_adapter.index(user, refresh=True) diff --git a/corehq/apps/cloudcare/tests/test_session.py b/corehq/apps/cloudcare/tests/test_session.py index baa4ae275fdbe..735cc256aa25d 100644 --- a/corehq/apps/cloudcare/tests/test_session.py +++ b/corehq/apps/cloudcare/tests/test_session.py @@ -6,24 +6,22 @@ get_user_contributions_to_touchforms_session, ) from corehq.apps.custom_data_fields.models import ( + PROFILE_SLUG, CustomDataFieldsDefinition, CustomDataFieldsProfile, Field, - PROFILE_SLUG, ) -from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView from corehq.apps.users.models import CommCareUser, WebUser +from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView from corehq.form_processor.models import CommCareCase class SessionUtilsTest(TestCase): def test_load_session_data_for_mobile_worker(self): - user = CommCareUser( - domain='cloudcare-tests', - username='worker@cloudcare-tests.commcarehq.org', - _id=uuid.uuid4().hex - ) + user = CommCareUser.create('cloudcare-tests', 'worker@cloudcare-tests.commcarehq.org', + 'password', None, None) + self.addCleanup(user.delete, None, None) data = get_user_contributions_to_touchforms_session('cloudcare-tests', user) self.assertEqual('worker', data['username']) self.assertEqual(user._id, data['user_id']) @@ -31,14 +29,15 @@ def test_load_session_data_for_mobile_worker(self): self.assertTrue(data['user_data']['commcare_project'], 'cloudcare-tests') def test_default_user_data(self): - user = CommCareUser( - domain='cloudcare-tests', - username='worker@cloudcare-tests.commcarehq.org', - _id=uuid.uuid4().hex - ) + user = CommCareUser.create('cloudcare-tests', 'worker@cloudcare-tests.commcarehq.org', + 'password', None, None) + self.addCleanup(user.delete, None, None) + user_data = get_user_contributions_to_touchforms_session('cloudcare-tests', user)['user_data'] - for key in ['commcare_first_name', 'commcare_last_name', 'commcare_phone_number']: - self.assertEqual(None, user_data[key]) + self.assertEqual('', user_data['commcare_first_name']) + self.assertEqual('', user_data['commcare_last_name']) + self.assertEqual(None, user_data['commcare_phone_number']) + user.first_name = 'first' user.last_name = 'last' user_data = get_user_contributions_to_touchforms_session('cloudcare-tests', user)['user_data'] @@ -62,7 +61,7 @@ def test_user_data_profile(self): None, None, uuid=uuid.uuid4().hex, - metadata={PROFILE_SLUG: profile.id}, + user_data={PROFILE_SLUG: profile.id}, ) self.addCleanup(user.delete, None, None) user_data = get_user_contributions_to_touchforms_session('cloudcare-tests', user)['user_data'] @@ -70,10 +69,7 @@ def test_user_data_profile(self): self.assertEqual('supernova', user_data['word']) def test_load_session_data_for_web_user(self): - user = WebUser( - username='web-user@example.com', - _id=uuid.uuid4().hex - ) + user = WebUser.create(None, 'web-user@example.com', '123', None, None) data = get_user_contributions_to_touchforms_session('cloudcare-tests', user) self.assertEqual('web-user@example.com', data['username']) self.assertEqual(user._id, data['user_id']) diff --git a/corehq/apps/cloudcare/tests/test_utils.py b/corehq/apps/cloudcare/tests/test_utils.py new file mode 100644 index 0000000000000..42c1881dfa264 --- /dev/null +++ b/corehq/apps/cloudcare/tests/test_utils.py @@ -0,0 +1,80 @@ +from django.test import TestCase + +from corehq.apps.app_manager.models import Application, ReportModule, ReportAppConfig +from corehq.apps.cloudcare.utils import should_restrict_web_apps_usage +from corehq.apps.domain.shortcuts import create_domain +from corehq.util.test_utils import flag_disabled, flag_enabled + + +class TestShouldRestrictWebAppsUsage(TestCase): + + @flag_disabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_disabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_is_under_limit_and_neither_toggle_is_enable(self): + self._create_app_with_reports(report_count=0) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_disabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_disabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_exceeds_limit_and_neither_toggle_is_enabled(self): + self._create_app_with_reports(report_count=2) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_enabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_disabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_exceeds_limit_and_ALLOW_WEB_APPS_RESTRICTION_is_enabled(self): + self._create_app_with_reports(report_count=2) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_disabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_enabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_exceeds_limit_and_MOBILE_UCR_is_enabled(self): + self._create_app_with_reports(report_count=2) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_enabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_enabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_is_under_limit_and_both_flags_are_enabled(self): + self._create_app_with_reports(report_count=1) + with self.settings(MAX_MOBILE_UCR_LIMIT=2): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_enabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_enabled("MOBILE_UCR") + def test_returns_false_if_domain_ucr_count_equals_limit_and_both_flags_are_enabled(self): + self._create_app_with_reports(report_count=1) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertFalse(result) + + @flag_enabled("ALLOW_WEB_APPS_RESTRICTION") + @flag_enabled("MOBILE_UCR") + def test_returns_true_if_domain_ucr_count_exceeds_limit_and_both_flags_are_enabled(self): + self._create_app_with_reports(report_count=2) + with self.settings(MAX_MOBILE_UCR_LIMIT=1): + result = should_restrict_web_apps_usage(self.domain.name) + self.assertTrue(result) + + def _create_app_with_reports(self, report_count=1): + configs = [] + for idx in range(report_count): + configs.append(ReportAppConfig(report_id=f'{idx}')) + report_module = ReportModule(name={"test": "Reports"}, report_configs=configs) + self.app = Application(domain=self.domain.name, version=1, modules=[report_module]) + self.app.save() + self.addCleanup(self.app.delete) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.domain = create_domain('test-web-apps-restriction') + cls.addClassCleanup(cls.domain.delete) diff --git a/corehq/apps/cloudcare/urls.py b/corehq/apps/cloudcare/urls.py index 55c79adf2afd9..38e3e01895a98 100644 --- a/corehq/apps/cloudcare/urls.py +++ b/corehq/apps/cloudcare/urls.py @@ -8,11 +8,12 @@ LoginAsUsers, PreviewAppView, ReadableQuestions, + BlockWebAppsView, default, report_formplayer_error, - report_sentry_error + report_sentry_error, api_histogram_metrics ) -from corehq.apps.hqwebapp.decorators import waf_allow +from corehq.apps.hqwebapp.decorators import use_bootstrap5, waf_allow app_urls = [ url(r'^v2/$', FormplayerMain.as_view(), name=FormplayerMain.urlname), @@ -25,11 +26,14 @@ url(r'^preview_app/(?P<app_id>[\w-]+)/$', PreviewAppView.as_view(), name=PreviewAppView.urlname), url(r'^report_formplayer_error', report_formplayer_error, name='report_formplayer_error'), url(r'^report_sentry_error', report_sentry_error, name='report_sentry_error'), + url(r'^block_web_apps/$', use_bootstrap5(BlockWebAppsView.as_view()), name=BlockWebAppsView.urlname), ] api_urls = [ url(r'^login_as/users/$', LoginAsUsers.as_view(), name=LoginAsUsers.urlname), - url(r'^readable_questions/$', waf_allow('XSS_BODY')(ReadableQuestions.as_view()), name=ReadableQuestions.urlname), + url(r'^readable_questions/$', + waf_allow('XSS_BODY')(ReadableQuestions.as_view()), + name=ReadableQuestions.urlname), ] # used in settings urls @@ -37,10 +41,15 @@ url(r'^app/', EditCloudcareUserPermissionsView.as_view(), name=EditCloudcareUserPermissionsView.urlname), ] +metrics_urls = [ + url(r'^record_api_metrics', api_histogram_metrics, name="api_histogram_metrics") +] + urlpatterns = [ url(r'^$', default, name='cloudcare_default'), url(r'^apps/', include(app_urls)), url(r'^api/', include(api_urls)), + url(r'^metrics/', include(metrics_urls)), ] diff --git a/corehq/apps/cloudcare/utils.py b/corehq/apps/cloudcare/utils.py index d0f5f1ace1d8e..52ede3a461103 100644 --- a/corehq/apps/cloudcare/utils.py +++ b/corehq/apps/cloudcare/utils.py @@ -1,9 +1,13 @@ import json +from django.conf import settings from django.urls import reverse from six.moves.urllib.parse import quote +from corehq import toggles +from corehq.apps.app_manager.dbaccessors import get_apps_in_domain + def should_show_preview_app(request, app, username): return not app.is_remote_app() @@ -31,3 +35,26 @@ def webapps_module_case_form(domain, app_id, module_id, case_id, form_id): def webapps_module(domain, app_id, module_id): return _webapps_url(domain, app_id, selections=[module_id]) + + +def should_restrict_web_apps_usage(domain): + """ + This check is only applicable to domains that have both the MOBILE_UCR and ALLOW_WEB_APPS_RESTRICTION + feature flags enabled. + Checks the number of UCRs referenced across all applications in a domain + :returns: True if the total number exceeds the limit set in settings.MAX_MOBILE_UCR_LIMIT + """ + if not toggles.MOBILE_UCR.enabled(domain): + return False + + if not toggles.ALLOW_WEB_APPS_RESTRICTION.enabled(domain): + return False + + apps = get_apps_in_domain(domain, include_remote=False) + ucrs = [ + ucr + for app in apps + for module in app.get_report_modules() + for ucr in module.report_configs + ] + return len(ucrs) > settings.MAX_MOBILE_UCR_LIMIT diff --git a/corehq/apps/cloudcare/views.py b/corehq/apps/cloudcare/views.py index 057711e7f55f7..ddb2f61b6c2c2 100644 --- a/corehq/apps/cloudcare/views.py +++ b/corehq/apps/cloudcare/views.py @@ -12,12 +12,16 @@ HttpResponseRedirect, JsonResponse, ) -from django.shortcuts import render +from django.shortcuts import ( + redirect, + render +) from django.template.loader import render_to_string from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from django.views.generic import View from django.views.generic.base import TemplateView from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -25,6 +29,7 @@ import urllib.parse from text_unidecode import unidecode +from corehq.apps.domain.views.base import BaseDomainView from corehq.apps.formplayer_api.utils import get_formplayer_url from corehq.util.metrics import metrics_counter from couchforms.const import VALID_ATTACHMENT_FILE_EXTENSION_MAP @@ -58,6 +63,7 @@ from corehq.apps.cloudcare.decorators import require_cloudcare_access from corehq.apps.cloudcare.esaccessors import login_as_user_query from corehq.apps.cloudcare.models import SQLAppGroup +from corehq.apps.cloudcare.utils import should_restrict_web_apps_usage from corehq.apps.domain.decorators import ( domain_admin_required, login_and_domain_required, @@ -77,6 +83,7 @@ from corehq.apps.users.util import format_username from corehq.apps.users.views import BaseUserSettingsView from corehq.apps.integration.util import integration_contexts +from corehq.util.metrics import metrics_histogram from xml2json.lib import xml2json from langcodes import get_name @@ -163,6 +170,9 @@ def set_cookie(response): return request.couch_user, set_cookie def get(self, request, domain): + if should_restrict_web_apps_usage(domain): + return redirect('block_web_apps', domain=domain) + option = request.GET.get('option') if option == 'apps': return self.get_option_apps(request, domain) @@ -293,6 +303,9 @@ class PreviewAppView(TemplateView): @use_daterangepicker @xframe_options_sameorigin def get(self, request, *args, **kwargs): + if should_restrict_web_apps_usage(request.domain): + context = get_context_for_ucr_limit_error(request.domain) + return render(request, 'preview_app/block_app_preview.html', context) app = get_app(request.domain, kwargs.pop('app_id')) return self.render_to_response({ 'app': _format_app_doc(app.to_json()), @@ -367,7 +380,7 @@ def _format_user(self, user_json): user = CouchUser.wrap_correctly(user_json) formatted_user = { 'username': user.raw_username, - 'customFields': user.metadata, + 'customFields': user.get_user_data(self.domain).to_dict(), 'first_name': user.first_name, 'last_name': user.last_name, 'phoneNumbers': user.phone_numbers, @@ -605,3 +618,43 @@ def _fail(error): }) cloudcare_state = json.dumps(state) return HttpResponseRedirect(reverse(FormplayerMain.urlname, args=[domain]) + "#" + cloudcare_state) + + +class BlockWebAppsView(BaseDomainView): + + urlname = 'block_web_apps' + template_name = 'block_web_apps.html' + + def get(self, request, *args, **kwargs): + context = get_context_for_ucr_limit_error(request.domain) + return render(request, self.template_name, context) + + +def get_context_for_ucr_limit_error(domain): + return { + 'domain': domain, + 'ucr_limit': settings.MAX_MOBILE_UCR_LIMIT, + 'error_message': _("""You have the MOBILE_UCR feature flag enabled, and have exceeded the maximum limit + of {ucr_limit} total User Configurable Reports used across all of your applications. + To resolve, you must remove references to UCRs in your applications until you are under + the limit. If you believe this is a mistake, please reach out to support. + """).format(ucr_limit=settings.MAX_MOBILE_UCR_LIMIT) + } + + +@login_and_domain_required +@require_POST +def api_histogram_metrics(request, domain): + request_dict = request.POST + + metric_name = request_dict.get("metrics") + duration = float(request_dict.get("responseTime")) + + if metric_name and duration: + metrics_histogram(metric_name, + duration, + bucket_tag='duration_bucket', + buckets=(1000, 2000, 5000), + bucket_unit='ms', + tags={'domain': domain}) + return HttpResponse("Success!!") diff --git a/corehq/apps/commtrack/tests/util.py b/corehq/apps/commtrack/tests/util.py index fe6b053d0fea5..70cb95e70272d 100644 --- a/corehq/apps/commtrack/tests/util.py +++ b/corehq/apps/commtrack/tests/util.py @@ -61,7 +61,6 @@ def bootstrap_user(setup, username=TEST_USER, domain=TEST_DOMAIN, backend=TEST_BACKEND, first_name='', last_name='', home_loc=None, user_data=None, ): - user_data = user_data or {} user = CommCareUser.create( domain, username, @@ -69,7 +68,7 @@ def bootstrap_user(setup, username=TEST_USER, domain=TEST_DOMAIN, created_by=None, created_via=None, phone_numbers=[TEST_NUMBER], - metadata=user_data, + user_data=user_data, first_name=first_name, last_name=last_name ) diff --git a/corehq/apps/custom_data_fields/models.py b/corehq/apps/custom_data_fields/models.py index 642fc9d82b04b..cc2c1c69cbf75 100644 --- a/corehq/apps/custom_data_fields/models.py +++ b/corehq/apps/custom_data_fields/models.py @@ -183,7 +183,7 @@ def _user_query(self): UserES().domain(self.definition.domain) .mobile_users() .show_inactive() - .metadata(PROFILE_SLUG, self.id) + .user_data(PROFILE_SLUG, self.id) ) def to_json(self): diff --git a/corehq/apps/custom_data_fields/tests/test_profiles.py b/corehq/apps/custom_data_fields/tests/test_profiles.py index 383615685dcb4..019ffd8815f1c 100644 --- a/corehq/apps/custom_data_fields/tests/test_profiles.py +++ b/corehq/apps/custom_data_fields/tests/test_profiles.py @@ -97,9 +97,9 @@ def test_get_profiles(self): @es_test(requires=[user_adapter]) @sync_users_to_es() def test_users_assigned(self): - user = CommCareUser.create(self.domain, 'pentagon', '*****', None, None, metadata={ - PROFILE_SLUG: self.profile5.id, - }) + user = CommCareUser.create(self.domain, 'pentagon', '*****', None, None) + user.get_user_data(self.domain).profile_id = self.profile5.id + user.save() manager.index_refresh(user_adapter.index_name) self.addCleanup(user.delete, self.domain, deleted_by=None) diff --git a/corehq/apps/data_dictionary/management/commands/populate_case_prop_groups.py b/corehq/apps/data_dictionary/management/commands/populate_case_prop_groups.py deleted file mode 100644 index 9063fdf286c84..0000000000000 --- a/corehq/apps/data_dictionary/management/commands/populate_case_prop_groups.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.core.management.base import BaseCommand -from django.db.models import Q, F -from corehq.apps.data_dictionary.models import CaseProperty, CasePropertyGroup - -from corehq.apps.domain.models import Domain -from corehq.util.log import with_progress_bar - - -class Command(BaseCommand): - help = "Populates groups from caseproperty model to casepropertygroups model" - - def add_arguments(self, parser): - parser.add_argument('domains', nargs='*', - help="Domain name(s). If blank, will generate for all domains") - - def handle(self, **options): - domains = options['domains'] or [d['key'] for d in Domain.get_all(include_docs=False)] - print("Populating groups for {} domains".format(len(domains))) - - for domain in with_progress_bar(domains): - remove_out_of_sync_prop_and_groups(domain) - populate_case_prop_groups(domain) - - -def populate_case_prop_groups(domain): - filter_kwargs = {"case_type__domain": domain, "group_obj__isnull": True} - case_props = CaseProperty.objects.exclude(group__exact="").filter(**filter_kwargs) - - for case_prop in case_props: - group, created = CasePropertyGroup.objects.get_or_create( - name=case_prop.group_name, - case_type=case_prop.case_type - ) - case_prop.group_obj = group - case_prop.save() - - -def remove_out_of_sync_prop_and_groups(domain): - # Reset properties that a different value in group column than in group object name. - properties_out_of_sync = (CaseProperty.objects - .filter(case_type__domain=domain, group_obj__isnull=False) - .filter(~Q(group_obj__name=F('group')))) - print("Reset out of sync groups for {} properties".format(len(properties_out_of_sync))) - for prop in properties_out_of_sync: - print("Reset group for: {} in case_type: {}, domain: {}".format( - prop.name, prop.case_type.name, domain - )) - prop.group_obj = None - prop.save() - - # Remove groups which dont have any properties - group_without_properties = CasePropertyGroup.objects.filter(case_type__domain=domain, property__isnull=True) - print("Removing {} groups without properties".format(len(group_without_properties))) - for group in group_without_properties: - print("Removing group: {} in case_type: {}, domain: {}".format( - group.name, group.case_type.name, domain - )) - group.delete() diff --git a/corehq/apps/data_dictionary/migrations/0016_remove_case_property_group_and_rename_group_obj_caseproperty_group.py b/corehq/apps/data_dictionary/migrations/0016_remove_case_property_group_and_rename_group_obj_caseproperty_group.py new file mode 100644 index 0000000000000..365badc6f4928 --- /dev/null +++ b/corehq/apps/data_dictionary/migrations/0016_remove_case_property_group_and_rename_group_obj_caseproperty_group.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-10-25 10:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_dictionary', '0015_casetype_is_deprecated'), + ] + + operations = [ + migrations.RemoveField( + model_name='caseproperty', + name='group', + ), + migrations.RenameField( + model_name='caseproperty', + old_name='group_obj', + new_name='group', + ), + ] diff --git a/corehq/apps/data_dictionary/models.py b/corehq/apps/data_dictionary/models.py index 0294743509fd8..101c2cae60a93 100644 --- a/corehq/apps/data_dictionary/models.py +++ b/corehq/apps/data_dictionary/models.py @@ -93,9 +93,8 @@ class DataType(models.TextChoices): default=DataType.UNDEFINED, blank=True, ) - group = models.TextField(default='', blank=True) index = models.IntegerField(default=0, blank=True) - group_obj = models.ForeignKey( + group = models.ForeignKey( CasePropertyGroup, on_delete=models.CASCADE, related_name='properties', @@ -163,8 +162,8 @@ def valid_values_message(self): @property def group_name(self): - if self.group_obj: - return self.group_obj.name + if self.group: + return self.group.name class CasePropertyAllowedValue(models.Model): diff --git a/corehq/apps/data_dictionary/static/data_dictionary/js/data_dictionary.js b/corehq/apps/data_dictionary/static/data_dictionary/js/data_dictionary.js index a3d80ce1865ec..7925c34bece75 100644 --- a/corehq/apps/data_dictionary/static/data_dictionary/js/data_dictionary.js +++ b/corehq/apps/data_dictionary/static/data_dictionary/js/data_dictionary.js @@ -41,7 +41,7 @@ hqDefine("data_dictionary/js/data_dictionary", [ groupObj.toBeDeprecated.subscribe(changeSaveButton); for (let prop of group.properties) { - const isGeoCaseProp = (self.geoCaseProp === prop.name); + const isGeoCaseProp = (self.geoCaseProp === prop.name && prop.data_type === 'gps'); var propObj = propertyListItem(prop.name, prop.label, false, group.name, self.name, prop.data_type, prop.description, prop.allowed_values, prop.fhir_resource_prop_path, prop.deprecated, prop.removeFHIRResourcePropertyPath, isGeoCaseProp); @@ -102,7 +102,7 @@ hqDefine("data_dictionary/js/data_dictionary", [ self.fhirResourcePropPath = ko.observable(fhirResourcePropPath); self.originalResourcePropPath = fhirResourcePropPath; self.deprecated = ko.observable(deprecated || false); - self.isGeoCaseProp = isGeoCaseProp; + self.isGeoCaseProp = ko.observable(isGeoCaseProp); self.removeFHIRResourcePropertyPath = ko.observable(removeFHIRResourcePropertyPath || false); let subTitle; if (toggles.toggleEnabled("CASE_IMPORT_DATA_DICTIONARY_VALIDATION")) { @@ -128,7 +128,7 @@ hqDefine("data_dictionary/js/data_dictionary", [ }; self.deprecateProperty = function () { - if (toggles.toggleEnabled('GEOSPATIAL') && self.isGeoCaseProp) { + if (toggles.toggleEnabled('GEOSPATIAL') && self.isGeoCaseProp()) { self.confirmGeospatialDeprecation(); } else { self.deprecated(true); diff --git a/corehq/apps/data_dictionary/templates/data_dictionary/base.html b/corehq/apps/data_dictionary/templates/data_dictionary/base.html index 6cd6e06bbe343..02e1c995d468f 100644 --- a/corehq/apps/data_dictionary/templates/data_dictionary/base.html +++ b/corehq/apps/data_dictionary/templates/data_dictionary/base.html @@ -269,14 +269,23 @@ <h3 data-bind="text: $root.activeCaseType()" style="display: inline-block;"></h3 </div> {% if request|toggle_enabled:"CASE_IMPORT_DATA_DICTIONARY_VALIDATION" %} <div class="row-item main-form"> - <select class="form-control" + <select class="form-control" + data-bind=" + options: $root.availableDataTypes, + optionsCaption: 'Select a data type', + optionsText: 'display', + optionsValue: 'value', + value: dataType, + disable: isGeoCaseProp,"> + </select> + <span class="hq-help" style="height: fit-content" data-bind=" - options: $root.availableDataTypes, - optionsCaption: 'Select a data type', - optionsText: 'display', - optionsValue: 'value', - value: dataType, - "></select> + popover: { + content: '{% blocktrans %}This GPS case property is currently being used to store the geolocation for cases, so the data type cannot be changed.{% endblocktrans %}', + trigger: 'hover' }, + visible: isGeoCaseProp"> + <i class="fa fa-question-circle icon-question-sign"></i> + </span> </div> {% endif %} <div class="row-item main-form"> diff --git a/corehq/apps/data_dictionary/tests/test_view.py b/corehq/apps/data_dictionary/tests/test_view.py index c0ac0968b504e..69bb90d1a3733 100644 --- a/corehq/apps/data_dictionary/tests/test_view.py +++ b/corehq/apps/data_dictionary/tests/test_view.py @@ -32,9 +32,9 @@ def setUpClass(cls): cls.case_type_obj.save() CaseProperty(case_type=cls.case_type_obj, name='property').save() - group_obj = CasePropertyGroup(case_type=cls.case_type_obj, name='group') - group_obj.id = 1 - group_obj.save() + group = CasePropertyGroup(case_type=cls.case_type_obj, name='group') + group.id = 1 + group.save() @classmethod def tearDownClass(cls): @@ -176,8 +176,7 @@ def test_allowed_values_with_invalid_one(self): def test_update_with_group_name(self): prop = self._get_property() - self.assertEqual(prop.group, '') - self.assertIsNone(prop.group_obj) + self.assertIsNone(prop.group) post_data = { "groups": '[{"id": 1, "caseType": "caseType", "name": "group", "description": ""}]', "properties": '[{"caseType": "caseType", "name": "property", "group": "group"}]' @@ -185,14 +184,13 @@ def test_update_with_group_name(self): response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) prop = self._get_property() - self.assertEqual(prop.group_obj.name, 'group') - self.assertIsNotNone(prop.group_obj) + self.assertEqual(prop.group.name, 'group') + self.assertIsNotNone(prop.group) def test_update_with_no_group_name(self): prop = self._get_property() group = CasePropertyGroup.objects.filter(case_type=self.case_type_obj, name='group').first() - prop.group = group.name - prop.group_obj = group + prop.group = group prop.save() post_data = { "groups": '[]', @@ -201,7 +199,7 @@ def test_update_with_no_group_name(self): response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) prop = self._get_property() - self.assertIsNone(prop.group_obj) + self.assertIsNone(prop.group) @privilege_enabled(privileges.DATA_DICTIONARY) @@ -332,13 +330,13 @@ def setUpClass(cls): cls.couch_user.save() cls.case_type_obj = CaseType(name='caseType', domain=cls.domain_name) cls.case_type_obj.save() - cls.case_prop_group_obj = CasePropertyGroup(case_type=cls.case_type_obj, name='group') - cls.case_prop_group_obj.save() + cls.case_prop_group = CasePropertyGroup(case_type=cls.case_type_obj, name='group') + cls.case_prop_group.save() cls.case_prop_obj = CaseProperty( case_type=cls.case_type_obj, name='property', data_type='number', - group_obj=cls.case_prop_group_obj + group=cls.case_prop_group ) cls.case_prop_obj.save() cls.client = Client() @@ -369,7 +367,7 @@ def test_get_json_success(self, *args): "fhir_resource_type": None, "groups": [ { - "id": self.case_prop_group_obj.id, + "id": self.case_prop_group.id, "name": "group", "description": "", "deprecated": False, diff --git a/corehq/apps/data_dictionary/util.py b/corehq/apps/data_dictionary/util.py index 049057b97f320..5b4d3dc19e25b 100644 --- a/corehq/apps/data_dictionary/util.py +++ b/corehq/apps/data_dictionary/util.py @@ -177,24 +177,24 @@ def save_case_property_group(id, name, case_type, domain, description, index, de case_type_obj = CaseType.objects.get(domain=domain, name=case_type) if id is not None: - group_obj = CasePropertyGroup.objects.get(id=id, case_type=case_type_obj) + group = CasePropertyGroup.objects.get(id=id, case_type=case_type_obj) else: - group_obj = CasePropertyGroup(case_type=case_type_obj) + group = CasePropertyGroup(case_type=case_type_obj) - group_obj.name = name + group.name = name if description is not None: - group_obj.description = description + group.description = description if index is not None: - group_obj.index = index + group.index = index if deprecated is not None: - group_obj.deprecated = deprecated + group.deprecated = deprecated try: - group_obj.full_clean(validate_unique=True) + group.full_clean(validate_unique=True) except ValidationError as e: return str(e) - group_obj.save() + group.save() def save_case_property(name, case_type, domain=None, data_type=None, @@ -219,9 +219,9 @@ def save_case_property(name, case_type, domain=None, data_type=None, prop.description = description if group: - prop.group_obj, created = CasePropertyGroup.objects.get_or_create(name=group, case_type=prop.case_type) + prop.group, created = CasePropertyGroup.objects.get_or_create(name=group, case_type=prop.case_type) else: - prop.group_obj = None + prop.group = None if deprecated is not None: prop.deprecated = deprecated diff --git a/corehq/apps/data_dictionary/views.py b/corehq/apps/data_dictionary/views.py index b963a0a13ba41..cb868804a65f1 100644 --- a/corehq/apps/data_dictionary/views.py +++ b/corehq/apps/data_dictionary/views.py @@ -60,7 +60,7 @@ def data_dictionary_json(request, domain, case_type_name=None): fhir_resource_prop_by_case_prop = {} queryset = CaseType.objects.filter(domain=domain).prefetch_related( Prefetch('groups', queryset=CasePropertyGroup.objects.order_by('index')), - Prefetch('properties', queryset=CaseProperty.objects.order_by('group_obj_id', 'index')), + Prefetch('properties', queryset=CaseProperty.objects.order_by('group_id', 'index')), Prefetch('properties__allowed_values', queryset=CasePropertyAllowedValue.objects.order_by('allowed_value')) ) if toggles.FHIR_INTEGRATION.enabled(domain): @@ -106,7 +106,7 @@ def data_dictionary_json(request, domain, case_type_name=None): for prop in props ] for group, props in itertools.groupby( - case_type.properties.all(), key=attrgetter('group_obj_id') + case_type.properties.all(), key=attrgetter('group_id') ) } for group in case_type.groups.all(): diff --git a/corehq/apps/data_interfaces/forms.py b/corehq/apps/data_interfaces/forms.py index 6a0a067ed6178..f62053e72472d 100644 --- a/corehq/apps/data_interfaces/forms.py +++ b/corehq/apps/data_interfaces/forms.py @@ -430,7 +430,8 @@ def __init__(self, domain, *args, **kwargs): Fieldset( _("Case Filters") if self.show_fieldset_title else "", HTML( - '<p class="help-block alert alert-info"><i class="fa fa-info-circle"></i> %s</p>' % self.fieldset_help_text + '<p class="help-block alert alert-info"><i class="fa fa-info-circle"></i> %s</p>' + % self.fieldset_help_text ), hidden_bound_field('filter_on_server_modified', 'filterOnServerModified'), hidden_bound_field('server_modified_boundary', 'serverModifiedBoundary'), @@ -548,9 +549,9 @@ def clean_property_match_definitions(self): self._json_fail_hard() if ( - 'property_name' not in obj or - 'property_value' not in obj or - 'match_type' not in obj + 'property_name' not in obj + or 'property_value' not in obj + or 'match_type' not in obj ): self._json_fail_hard() @@ -626,11 +627,9 @@ def clean_location_filter_definition(self): self._json_fail_hard() if value: - location_def = value[0] - if not location_def.get('include_child_locations'): - location_def['include_child_locations'] = False - - return location_def + if not value.get('include_child_locations'): + value['include_child_locations'] = False + return value return '' def clean_ucr_filter_definitions(self): @@ -828,9 +827,9 @@ def clean_properties_to_update(self): self._json_fail_hard() if ( - 'name' not in obj or - 'value_type' not in obj or - 'value' not in obj + 'name' not in obj + or 'value_type' not in obj + or 'value' not in obj ): self._json_fail_hard() @@ -884,15 +883,15 @@ def clean_custom_action_definitions(self): def clean(self): cleaned_data = super(CaseRuleActionsForm, self).clean() if ( - 'close_case' in cleaned_data and - 'properties_to_update' in cleaned_data and - 'custom_action_definitions' in cleaned_data + 'close_case' in cleaned_data + and 'properties_to_update' in cleaned_data + and 'custom_action_definitions' in cleaned_data ): # All fields passed individual validation if ( - not cleaned_data['close_case'] and - not cleaned_data['properties_to_update'] and - not cleaned_data['custom_action_definitions'] + not cleaned_data['close_case'] + and not cleaned_data['properties_to_update'] + and not cleaned_data['custom_action_definitions'] ): raise ValidationError(_("Please specify at least one action.")) diff --git a/corehq/apps/data_interfaces/static/data_interfaces/js/case_rule_criteria.js b/corehq/apps/data_interfaces/static/data_interfaces/js/case_rule_criteria.js index 218c245b8ca8e..6c5c60ffe4f4b 100644 --- a/corehq/apps/data_interfaces/static/data_interfaces/js/case_rule_criteria.js +++ b/corehq/apps/data_interfaces/static/data_interfaces/js/case_rule_criteria.js @@ -76,18 +76,18 @@ hqDefine("data_interfaces/js/case_rule_criteria", [ match_type: value.match_type() || '', }); } else if (value.koTemplateId === 'advanced-date-case-property-filter') { - var property_value = value.property_value(); - if ($.isNumeric(property_value) && value.plus_minus() === '-') { + var propertyValue = value.property_value(); + if ($.isNumeric(propertyValue) && value.plus_minus() === '-') { // The value of plus_minus tells us if we should negate the number // given in property_value(). We only attempt to do this if it // actually represents a number. If it doesn't, let the django // validation catch it. - property_value = -1 * Number.parseInt(property_value); - property_value = property_value.toString(); + propertyValue = -1 * Number.parseInt(propertyValue); + propertyValue = propertyValue.toString(); } result.push({ property_name: value.property_name() || '', - property_value: property_value || '', + property_value: propertyValue || '', match_type: value.match_type() || '', }); } @@ -106,13 +106,14 @@ hqDefine("data_interfaces/js/case_rule_criteria", [ }); self.locationFilterDefinition = ko.computed(function () { - var result = []; + var result = undefined; $.each(self.criteria(), function (index, value) { if (value.koTemplateId === 'locations-filter') { - result.push({ + result = { location_id: value.location_id() || '', include_child_locations: value.include_child_locations() || '', - }); + }; + return false; // break -- only a single location is supported } }); return JSON.stringify(result); diff --git a/corehq/apps/data_interfaces/tasks.py b/corehq/apps/data_interfaces/tasks.py index accd489ef3406..6ba5db95cad7c 100644 --- a/corehq/apps/data_interfaces/tasks.py +++ b/corehq/apps/data_interfaces/tasks.py @@ -295,6 +295,8 @@ def _send_email(): user.get_email(), render_to_string("data_interfaces/partials/case_reassign_complete_email.html", context), text_content=text_content, + domain=domain, + use_domain_gateway=True, ) _send_email() @@ -339,6 +341,8 @@ def _send_email(): user.get_email(), render_to_string("data_interfaces/partials/case_copy_complete_email.html", context), text_content=text_content, + domain=domain, + use_domain_gateway=True, ) _send_email() diff --git a/corehq/apps/data_interfaces/tests/test_forms.py b/corehq/apps/data_interfaces/tests/test_forms.py new file mode 100644 index 0000000000000..354e37cbfd951 --- /dev/null +++ b/corehq/apps/data_interfaces/tests/test_forms.py @@ -0,0 +1,48 @@ +import json +from unittest.mock import patch +from django.test import SimpleTestCase +from corehq.apps.data_interfaces.forms import CaseRuleCriteriaForm +from corehq.apps.data_interfaces import forms + + +class CaseRuleCriteriaFormTests(SimpleTestCase): + def test_validation_with_fully_specified_location(self): + post_data = self._create_form_input(location_filter={ + 'name': 'TestLocation', + 'location_id': '123', + 'include_child_locations': False, + }) + + form = CaseRuleCriteriaForm('test-domain', post_data, rule=None) + form.is_valid() + + def test_form_is_valid_without_location(self): + post_data = self._create_form_input(location_filter='') + + form = CaseRuleCriteriaForm('test-domain', post_data, rule=None) + self.assertTrue(form.is_valid()) + + def setUp(self): + case_types_patcher = patch.object(forms, 'get_case_types_for_domain') + self.get_case_types = case_types_patcher.start() + self.get_case_types.return_value = ['test-case'] + self.addCleanup(case_types_patcher.stop) + + def _create_form_input(self, location_filter=None): + return { + 'criteria-case_type': 'test-case', + 'criteria-property_match_definitions': json.dumps([ + { + 'property_name': 'city', + 'property_value': 'Boston', + 'match_type': 'EQUAL' + } + ]), + 'criteria-custom_match_definitions': json.dumps([]), + 'criteria-location_filter_definition': json.dumps(location_filter or ''), + 'criteria-ucr_filter_definitions': json.dumps([]), + 'criteria-criteria_operator': 'ALL', + 'criteria-filter_on_server_modified': 'false', + 'criteria-server_modified_boundary': '', + 'criteria-filter_on_closed_parent': 'false', + } diff --git a/corehq/apps/domain/auth.py b/corehq/apps/domain/auth.py index 7582b65c4462f..d462dda3dd31c 100644 --- a/corehq/apps/domain/auth.py +++ b/corehq/apps/domain/auth.py @@ -362,3 +362,30 @@ def authenticate(self, request, username, password): if (couch_user.username != link.commcare_user.username): return None return link.commcare_user + + +def user_can_access_domain_specific_pages(request): + """ + An active logged-in user can access domain specific pages if + domain is active & + they are a member of the domain or + a superuser and domain does not restrict superusers from access + """ + from corehq.apps.domain.decorators import ( + _ensure_request_couch_user, + _ensure_request_project, + active_user_logged_in, + ) + + if not active_user_logged_in(request): + return False + + project = _ensure_request_project(request) + if not (project and project.is_active): + return False + + couch_user = _ensure_request_couch_user(request) + if not couch_user: + return False + + return couch_user.is_member_of(project) or (couch_user.is_superuser and not project.restrict_superusers) diff --git a/corehq/apps/domain/decorators.py b/corehq/apps/domain/decorators.py index 350e80b96a00e..9d360c2ff1b85 100644 --- a/corehq/apps/domain/decorators.py +++ b/corehq/apps/domain/decorators.py @@ -65,9 +65,13 @@ def load_domain(req, domain): domain_name = normalize_domain_name(domain) + _store_project_on_request(req, domain_name) + return domain_name, req.project + + +def _store_project_on_request(request, domain_name): domain_obj = Domain.get_by_name(domain_name) - req.project = domain_obj - return domain_name, domain_obj + request.project = domain_obj def redirect_for_login_or_domain(request, login_url=None): @@ -86,7 +90,7 @@ def call_view(): return view_func(req, domain_name, *args, **kwargs) msg = _('The domain "{domain}" was not found.').format(domain=domain_name) raise Http404(msg) - if not (user.is_authenticated and user.is_active): + if not (active_user_logged_in(req)): login_url = reverse('domain_login', kwargs={'domain': domain_name}) return redirect_for_login_or_domain(req, login_url=login_url) @@ -174,6 +178,17 @@ def _ensure_request_couch_user(request): return couch_user +def _ensure_request_project(request): + project = getattr(request, 'project', None) + if not project and hasattr(request, 'domain'): + _store_project_on_request(request, request.domain) + return project + + +def active_user_logged_in(request): + return request.user.is_authenticated and request.user.is_active + + class LoginAndDomainMixin(object): @method_decorator(login_and_domain_required) diff --git a/corehq/apps/domain/deletion.py b/corehq/apps/domain/deletion.py index 1e756a0d84bfe..43c016945ca9c 100644 --- a/corehq/apps/domain/deletion.py +++ b/corehq/apps/domain/deletion.py @@ -349,6 +349,7 @@ def _delete_demo_user_restores(domain_name): 'StockLevelsConfig', 'StockRestoreConfig', ]), ModelDeletion('consumption', 'DefaultConsumption', 'domain'), + ModelDeletion('users', 'SQLUserData', 'domain'), ModelDeletion('custom_data_fields', 'CustomDataFieldsDefinition', 'domain', [ 'CustomDataFieldsProfile', 'Field', ]), @@ -458,8 +459,6 @@ def _delete_demo_user_restores(domain_name): ]), ModelDeletion('repeaters', 'Repeater', 'domain'), ModelDeletion('motech', 'ConnectionSettings', 'domain'), - ModelDeletion('repeaters', 'SQLRepeatRecord', 'domain'), - ModelDeletion('repeaters', 'SQLRepeatRecordAttempt', 'repeat_record__domain'), ModelDeletion('couchforms', 'UnfinishedSubmissionStub', 'domain'), ModelDeletion('couchforms', 'UnfinishedArchiveStub', 'domain'), ModelDeletion('fixtures', 'LookupTable', 'domain'), diff --git a/corehq/apps/domain/forms.py b/corehq/apps/domain/forms.py index dc2e50b9c1b1e..74e6b9c536353 100644 --- a/corehq/apps/domain/forms.py +++ b/corehq/apps/domain/forms.py @@ -804,6 +804,11 @@ class PrivacySecurityForm(forms.Form): help_text=gettext_lazy("Mobile Workers will never be locked out of their account, regardless" "of the number of failed attempts") ) + allow_invite_email_only = BooleanField( + label=gettext_lazy("During sign up, only allow the email address the invitation was sent to"), + required=False, + help_text=gettext_lazy("Disables the email field on the sign up page") + ) def __init__(self, *args, **kwargs): user_name = kwargs.pop('user_name') @@ -861,6 +866,7 @@ def save(self, domain_obj): domain_obj.hipaa_compliant = self.cleaned_data.get('hipaa_compliant', False) domain_obj.ga_opt_out = self.cleaned_data.get('ga_opt_out', False) domain_obj.disable_mobile_login_lockout = self.cleaned_data.get('disable_mobile_login_lockout', False) + domain_obj.allow_invite_email_only = self.cleaned_data.get('allow_invite_email_only', False) domain_obj.save() @@ -2655,3 +2661,28 @@ def clean_version(self): if not self.cleaned_data.get('version'): self.add_error('version', _("Please select version")) return self.cleaned_data.get('version') + + +class DomainAlertForm(forms.Form): + text = CharField( + label="Text", + widget=forms.Textarea, + required=True, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = hqcrispy.HQFormHelper(self) + self.helper.layout = Layout( + crispy.Fieldset( + _('Add New Alert'), + *self.fields + ), + hqcrispy.FormActions( + StrictButton( + _('Save'), + type='submit', + css_class='btn-primary disable-on-submit' + ) + ) + ) diff --git a/corehq/apps/domain/models.py b/corehq/apps/domain/models.py index dc8b374510546..4fc57ab8f49a8 100644 --- a/corehq/apps/domain/models.py +++ b/corehq/apps/domain/models.py @@ -439,6 +439,7 @@ class Domain(QuickCachedDocumentMixin, BlobMixin, Document, SnapshotMixin): two_factor_auth = BooleanProperty(default=False) strong_mobile_passwords = BooleanProperty(default=False) disable_mobile_login_lockout = BooleanProperty(default=False) + allow_invite_email_only = BooleanProperty(default=False) requested_report_builder_subscription = StringListProperty() @@ -448,7 +449,6 @@ class Domain(QuickCachedDocumentMixin, BlobMixin, Document, SnapshotMixin): ga_opt_out = BooleanProperty(default=False) orphan_case_alerts_warning = BooleanProperty(default=False) - @classmethod def wrap(cls, data): # for domains that still use original_doc @@ -968,7 +968,10 @@ def email_to_request(self): _('Transfer of ownership for CommCare project space.'), self.to_user.get_email(), html_content, - text_content=text_content) + text_content=text_content, + domain=self.domain, + use_domain_gateway=True, + ) def email_from_request(self): context = self.as_dict() @@ -985,7 +988,10 @@ def email_from_request(self): _('Transfer of ownership for CommCare project space.'), self.from_user.get_email(), html_content, - text_content=text_content) + text_content=text_content, + domain=self.domain, + use_domain_gateway=True, + ) @requires_active_transfer def transfer_domain(self, by_user, *args, transfer_via=None, **kwargs): diff --git a/corehq/apps/domain/static/domain/js/manage_alerts.js b/corehq/apps/domain/static/domain/js/manage_alerts.js new file mode 100644 index 0000000000000..85d3c369a54ac --- /dev/null +++ b/corehq/apps/domain/static/domain/js/manage_alerts.js @@ -0,0 +1,10 @@ +hqDefine("domain/js/manage_alerts",[ + 'jquery', + 'hqwebapp/js/initial_page_data', +], function ($, initialPageData) { + $(function () { + $('#ko-alert-container').koApplyBindings({ + 'alerts': initialPageData.get('alerts'), + }); + }); +}); diff --git a/corehq/apps/domain/templates/domain/admin/manage_alerts.html b/corehq/apps/domain/templates/domain/admin/manage_alerts.html new file mode 100644 index 0000000000000..1d33ba2865627 --- /dev/null +++ b/corehq/apps/domain/templates/domain/admin/manage_alerts.html @@ -0,0 +1,89 @@ +{% extends "hqwebapp/bootstrap3/base_section.html" %} +{% load crispy_forms_tags %} +{% load hq_shared_tags %} +{% load i18n %} + +{% requirejs_main "domain/js/manage_alerts" %} + +{% block page_content %} + {% initial_page_data 'alerts' alerts %} + + <form method="post"> + {% crispy form %} + </form> + <div id="ko-alert-container"> + <h3> + {% trans "Available Alerts" %} + </h3> + <table class="table"> + <thead> + <tr> + <th> + {% trans "Message" %} + </th> + <th> + {% trans "Added By" %} + </th> + <th> + {% trans "Activate or De-activate" %} + </th> + <th> + {% trans "Delete" %} + </th> + </tr> + </thead> + {% if alerts %} + <tbody data-bind="foreach: alerts"> + <tr> + <td> + <div class="alert alert-warning" + data-bind="html: html"></div> + </td> + <td data-bind="text: created_by_user"> + </td> + <td> + <form action="{% url 'update_domain_alert_status' domain %}" method="post"> + {% csrf_token %} + <input name="alert_id" + type="hidden" + data-bind="value: id"> + <button type="submit" + class="btn btn-primary" + name="command" + value="activate" + data-bind="visible: !active"> + <span>{% trans "Activate Alert" %}</span> + </button> + <button type="submit" + class="btn btn-outline-danger" + name="command" + value="deactivate" + data-bind="visible: active"> + {% trans "De-activate Alert" %} + </button> + </form> + </td> + <td> + <form action="{% url 'delete_domain_alert' domain %}" method="post"> + {% csrf_token %} + <input name="alert_id" + type="hidden" + data-bind="value: id"> + <button type="submit" + class="btn btn-danger"> + {% trans "Delete" %} + </button> + </form> + </td> + </tr> + </tbody> + {% else %} + <tbody> + <tr> + <td>{% trans "No alerts added yet for the project." %}</td> + </tr> + </tbody> + {% endif %} + </table> + </div> +{% endblock %} diff --git a/corehq/apps/domain/templates/domain/email/domain_invite.txt b/corehq/apps/domain/templates/domain/email/domain_invite.txt index a57ba25a1ff25..ce9356de0e648 100644 --- a/corehq/apps/domain/templates/domain/email/domain_invite.txt +++ b/corehq/apps/domain/templates/domain/email/domain_invite.txt @@ -2,7 +2,7 @@ {% blocktrans %}Hey there,{% endblocktrans %} {% blocktrans %} - {{ inviter }} has invited you to join the {{ domain }} project space at CommCare HQ. + {{ inviter }} has invited you to join the {{ domain }} project at CommCare HQ. This invitation expires in {{ days }} day(s). {% endblocktrans %} diff --git a/corehq/apps/domain/templates/login_and_password/login.html b/corehq/apps/domain/templates/login_and_password/login.html index 4edcf54cadb22..fe0325943dbde 100644 --- a/corehq/apps/domain/templates/login_and_password/login.html +++ b/corehq/apps/domain/templates/login_and_password/login.html @@ -9,7 +9,8 @@ {% block background_content %} <div class="bg-container"> - <div class="bg-full-cover-fixed bg-registration"></div> + <div class="bg-full-cover-fixed bg-registration b-lazy" + data-src="{% static 'hqwebapp/images/molly.jpg' %}"></div> <div class="bg-overlay"></div> </div> {% endblock %} diff --git a/corehq/apps/domain/tests/test_auth.py b/corehq/apps/domain/tests/test_auth.py new file mode 100644 index 0000000000000..8d20df5ba5be1 --- /dev/null +++ b/corehq/apps/domain/tests/test_auth.py @@ -0,0 +1,66 @@ +from django.http.request import HttpRequest +from django.test import SimpleTestCase + +from unittest.mock import patch + +from corehq.apps.domain.auth import user_can_access_domain_specific_pages +from corehq.apps.domain.models import Domain +from corehq.apps.users.models import CouchUser + + +class TestUserCanAccessDomainSpecificPages(SimpleTestCase): + def test_request_with_no_logged_in_user(self, *args): + request = HttpRequest() + + with patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=False): + self.assertFalse(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + def test_request_with_no_project(self, *args): + request = HttpRequest() + + with patch('corehq.apps.domain.decorators._ensure_request_project', return_value=None): + self.assertFalse(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + def test_request_with_inactive_project(self, *args): + request = HttpRequest() + project = Domain(is_active=False) + + with patch('corehq.apps.domain.decorators._ensure_request_project', return_value=project): + self.assertFalse(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + @patch('corehq.apps.domain.decorators._ensure_request_project', return_value=Domain(is_active=True)) + def test_request_with_no_couch_user(self, *args): + request = HttpRequest() + + self.assertFalse(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + @patch('corehq.apps.domain.decorators._ensure_request_project', return_value=Domain(is_active=True)) + @patch('corehq.apps.domain.decorators._ensure_request_couch_user', return_value=CouchUser()) + def test_request_for_missing_domain_membership_for_non_superuser(self, *args): + request = HttpRequest() + + self.assertFalse(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + @patch('corehq.apps.domain.decorators._ensure_request_project', return_value=Domain(is_active=True)) + def test_request_for_missing_domain_membership_for_superuser(self, *args): + request = HttpRequest() + + couch_user = CouchUser() + couch_user.is_superuser = True + + with patch('corehq.apps.domain.decorators._ensure_request_couch_user', return_value=couch_user): + self.assertTrue(user_can_access_domain_specific_pages(request)) + + @patch('corehq.apps.domain.decorators.active_user_logged_in', return_value=True) + @patch('corehq.apps.domain.decorators._ensure_request_project', return_value=Domain(is_active=True)) + @patch('corehq.apps.domain.decorators._ensure_request_couch_user', return_value=CouchUser()) + def test_request_for_valid_domain_membership_for_non_superuser(self, *args): + request = HttpRequest() + + with patch('corehq.apps.users.models.CouchUser.is_member_of', return_value=True): + self.assertTrue(user_can_access_domain_specific_pages(request)) diff --git a/corehq/apps/domain/tests/test_delete_domain.py b/corehq/apps/domain/tests/test_delete_domain.py index 3f9585fb80fc5..45098985a217b 100644 --- a/corehq/apps/domain/tests/test_delete_domain.py +++ b/corehq/apps/domain/tests/test_delete_domain.py @@ -4,6 +4,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from io import BytesIO +from unittest.mock import patch from django.contrib.auth.models import User from django.core.management import call_command @@ -11,7 +12,6 @@ from django.test import TestCase from dateutil.relativedelta import relativedelta -from unittest.mock import patch from casexml.apps.phone.models import SyncLogSQL from couchforms.models import UnfinishedSubmissionStub @@ -51,9 +51,16 @@ from corehq.apps.cloudcare.models import ApplicationAccess from corehq.apps.commtrack.models import CommtrackConfig from corehq.apps.consumption.models import DefaultConsumption -from corehq.apps.custom_data_fields.models import CustomDataFieldsDefinition +from corehq.apps.custom_data_fields.models import ( + CustomDataFieldsDefinition, + CustomDataFieldsProfile, +) from corehq.apps.data_analytics.models import GIRRow, MALTRow -from corehq.apps.data_dictionary.models import CaseProperty, CasePropertyAllowedValue, CaseType +from corehq.apps.data_dictionary.models import ( + CaseProperty, + CasePropertyAllowedValue, + CaseType, +) from corehq.apps.data_interfaces.models import ( AutomaticUpdateRule, CaseRuleAction, @@ -109,25 +116,23 @@ from corehq.apps.users.audit.change_messages import UserChangeMessage from corehq.apps.users.models import ( DomainRequest, + HqPermissions, Invitation, PermissionInfo, - HqPermissions, RoleAssignableBy, RolePermission, - UserRole, UserHistory, + UserRole, WebUser, ) +from corehq.apps.users.user_data import SQLUserData from corehq.apps.users.util import SYSTEM_USER_ID from corehq.apps.zapier.consts import EventTypes from corehq.apps.zapier.models import ZapierSubscription from corehq.blobs import CODES, NotFound, get_blob_db from corehq.form_processor.backends.sql.dbaccessors import doc_type_to_state from corehq.form_processor.models import CommCareCase, XFormInstance -from corehq.form_processor.tests.utils import ( - create_case, - create_form_for_test, -) +from corehq.form_processor.tests.utils import create_case, create_form_for_test from corehq.motech.models import ConnectionSettings, RequestLog from corehq.motech.repeaters.const import RECORD_SUCCESS_STATE from corehq.motech.repeaters.models import ( @@ -344,7 +349,7 @@ def test_form_deletion(self): def _assert_queryset_count(self, queryset_list, count): for queryset in queryset_list: - self.assertEqual(queryset.count(), count) + self.assertEqual(queryset.count(), count, queryset.query) def _assert_aggregate_ucr_count(self, domain_name, count): self._assert_queryset_count([ @@ -498,19 +503,22 @@ def test_consumption(self): self._assert_consumption_counts(self.domain.name, 0) self._assert_consumption_counts(self.domain2.name, 1) - def _assert_custom_data_fields_counts(self, domain_name, count): - self._assert_queryset_count([ - CustomDataFieldsDefinition.objects.filter(domain=domain_name), - ], count) - - def test_custom_data_fields(self): + def test_user_data_cascading(self): for domain_name in [self.domain.name, self.domain2.name]: - CustomDataFieldsDefinition.get_or_create(domain_name, 'UserFields') + user = User.objects.create(username=f'mobileuser@{domain_name}.{HQ_ACCOUNT_ROOT}') + definition = CustomDataFieldsDefinition.get_or_create(domain_name, 'UserFields') + profile = CustomDataFieldsProfile.objects.create(name='myprofile', definition=definition) + SQLUserData.objects.create(domain=domain_name, user_id='123', django_user=user, + profile=profile, data={}) + + models = [User, CustomDataFieldsDefinition, CustomDataFieldsProfile, SQLUserData] + for model in models: + self.assertEqual(model.objects.count(), 2) self.domain.delete() - self._assert_custom_data_fields_counts(self.domain.name, 0) - self._assert_custom_data_fields_counts(self.domain2.name, 1) + for model in models: + self.assertEqual(model.objects.count(), 1) def _assert_data_analytics_counts(self, domain_name, count): self._assert_queryset_count([ diff --git a/corehq/apps/domain/tests/test_deletion_models.py b/corehq/apps/domain/tests/test_deletion_models.py index 6cafaa75c76ff..2ae0aac447af4 100644 --- a/corehq/apps/domain/tests/test_deletion_models.py +++ b/corehq/apps/domain/tests/test_deletion_models.py @@ -59,6 +59,8 @@ 'fixtures.UserLookupTableStatus', 'fixtures.LookupTableRow', # handled by cascading delete 'fixtures.LookupTableRowOwner', # handled by cascading delete + 'repeaters.SQLRepeatRecord', # handled by cascading delete + 'repeaters.SQLRepeatRecordAttempt', # handled by cascading delete 'sms.MigrationStatus', 'util.BouncedEmail', 'util.ComplaintBounceMeta', diff --git a/corehq/apps/domain/tests/test_forms.py b/corehq/apps/domain/tests/test_forms.py index 98cfc87a74b54..a4d2c0827ff5e 100644 --- a/corehq/apps/domain/tests/test_forms.py +++ b/corehq/apps/domain/tests/test_forms.py @@ -24,7 +24,8 @@ def test_visible_fields(self): 'restrict_superusers', 'secure_submissions', 'allow_domain_requests', - 'disable_mobile_login_lockout' + 'disable_mobile_login_lockout', + 'allow_invite_email_only' ]) @patch.object(forms.HIPAA_COMPLIANCE_CHECKBOX, 'enabled', return_value=True) diff --git a/corehq/apps/domain/tests/test_views.py b/corehq/apps/domain/tests/test_views.py index 957bf406bf7ef..ba783068e6afb 100644 --- a/corehq/apps/domain/tests/test_views.py +++ b/corehq/apps/domain/tests/test_views.py @@ -1,5 +1,6 @@ from contextlib import contextmanager +from django.contrib.messages import get_messages from django.test import TestCase from django.test.client import Client from django.urls import reverse @@ -12,9 +13,12 @@ from corehq.apps.accounting.utils import clear_plan_version_cache from corehq.apps.app_manager.models import Application from corehq.apps.domain.models import Domain +from corehq.apps.domain.views.settings import ManageDomainAlertsView +from corehq.apps.hqwebapp.models import Alert from corehq.apps.users.models import WebUser from corehq.motech.models import ConnectionSettings from corehq.motech.repeaters.models import AppStructureRepeater +from corehq.util.test_utils import flag_enabled class TestDomainViews(TestCase, DomainSubscriptionMixin): @@ -111,6 +115,249 @@ def test_autocomplete_disabled(self): self.verify(False, "/accounts/password_reset_email/", "email") +class TestBaseDomainAlertView(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.domain_name = 'gotham' + cls.domain = Domain(name=cls.domain_name, is_active=True) + cls.domain.save() + cls.addClassCleanup(cls.domain.delete) + + cls.other_domain_name = 'krypton' + + cls.username = 'batman@gotham.com' + cls.password = '*******' + cls.user = WebUser.create(cls.domain_name, cls.username, cls.password, + created_by=None, created_via=None, is_admin=True) + cls.addClassCleanup(cls.user.delete, deleted_by_domain=cls.domain_name, deleted_by=None) + + cls.domain_alert = cls._create_alert_for_domain(cls.domain_name, 'Test Alert 1!', cls.username) + cls.other_domain_alert = cls._create_alert_for_domain(cls.other_domain_name, 'Test Alert 2!', cls.username) + + @staticmethod + def _create_alert_for_domain(domain, alert_text, username): + return Alert.objects.create( + text=alert_text, + domains=[domain], + created_by_domain=domain, + created_by_user=username + ) + + def setUp(self): + super().setUp() + self.client = Client() + self.client.login(username=self.username, password=self.password) + + +class TestManageDomainAlertsView(TestBaseDomainAlertView): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.url = reverse(ManageDomainAlertsView.urlname, kwargs={ + 'domain': cls.domain_name, + }) + + def test_feature_flag_access_only(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_only_domain_alerts_listed(self): + alert = self.domain_alert + + response = self.client.get(self.url) + self.assertListEqual( + response.context['alerts'], + [ + {'active': False, 'html': 'Test Alert 1!', 'id': alert.id, 'created_by_user': self.username} + ] + ) + self.assertEqual(response.status_code, 200) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_creating_new_alert(self): + self.assertEqual(Alert.objects.count(), 2) + + response = self.client.post( + self.url, + data={ + 'text': 'New Alert!', + }, + ) + + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert saved!') + self.assertEqual(response.status_code, 302) + + self.assertEqual(Alert.objects.count(), 3) + + new_alert = Alert.objects.order_by('pk').last() + self.assertEqual(new_alert.html, "New Alert!") + self.assertEqual(new_alert.created_by_domain, self.domain.name) + self.assertListEqual(new_alert.domains, [self.domain.name]) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_creating_new_alert_with_errors(self): + self.assertEqual(Alert.objects.count(), 2) + + response = self.client.post( + self.url, + data={ + 'text': '', + }, + ) + + self.assertEqual(Alert.objects.count(), 2) + + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'There was an error saving your alert. Please try again!') + self.assertEqual(response.status_code, 200) + + +class TestUpdateDomainAlertStatusView(TestBaseDomainAlertView): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.url = reverse('update_domain_alert_status', kwargs={ + 'domain': cls.domain_name, + }) + + def test_feature_flag_access_only(self): + response = self.client.post(self.url) + self.assertEqual(response.status_code, 404) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_post_access_only(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_apply_command_with_missing_alert_id(self): + with self.assertRaisesMessage(AssertionError, 'Missing alert ID'): + self.client.post( + self.url, + data={ + 'command': 'activate', + }, + ) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_apply_command_with_missing_alert(self): + response = self.client.post( + self.url, + data={ + 'command': 'activate', + 'alert_id': 0, + }, + ) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert not found!') + self.assertEqual(response.status_code, 302) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_apply_command_with_invalid_command(self): + response = self.client.post( + self.url, + data={ + 'command': 'elevate', + 'alert_id': self.domain_alert.id, + }, + ) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Unexpected update received. Alert not updated!') + self.assertEqual(response.status_code, 302) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_apply_command_with_valid_command(self): + alert = self._create_alert_for_domain(self.domain, "New Alert!", self.username) + + self.assertFalse(alert.active) + + response = self.client.post( + self.url, + data={ + 'command': 'activate', + 'alert_id': alert.id, + }, + ) + + alert.refresh_from_db() + self.assertTrue(alert.active) + + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert updated!') + self.assertEqual(response.status_code, 302) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_apply_command_with_other_doamin_alert(self): + response = self.client.post( + self.url, + data={ + 'command': 'activate', + 'alert_id': self.other_domain_alert.id, + }, + ) + + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert not found!') + self.assertEqual(response.status_code, 302) + + +class TestDeleteDomainAlertView(TestBaseDomainAlertView): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.url = reverse('delete_domain_alert', kwargs={ + 'domain': cls.domain_name, + }) + + def test_feature_flag_access_only(self): + response = self.client.post(self.url) + self.assertEqual(response.status_code, 404) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_post_access_only(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_with_missing_alert_id(self): + with self.assertRaisesMessage(AssertionError, 'Missing alert ID'): + self.client.post( + self.url, + data={}, + ) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_with_missing_alert(self): + response = self.client.post( + self.url, + data={ + 'alert_id': 0, + }, + ) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert not found!') + self.assertEqual(response.status_code, 302) + + @flag_enabled('CUSTOM_DOMAIN_BANNER_ALERTS') + def test_delete(self): + response = self.client.post( + self.url, + data={ + 'alert_id': self.domain_alert.id, + }, + ) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(messages[0].message, 'Alert was removed!') + self.assertEqual(response.status_code, 302) + + @contextmanager def domain_fixture(domain_name, allow_domain_requests=False): domain = Domain(name=domain_name, is_active=True) diff --git a/corehq/apps/domain/urls.py b/corehq/apps/domain/urls.py index 6d93b583b36da..4ec32727bd111 100644 --- a/corehq/apps/domain/urls.py +++ b/corehq/apps/domain/urls.py @@ -61,12 +61,15 @@ toggle_release_restriction_by_app_profile, ) from corehq.apps.domain.views.settings import ( + delete_domain_alert, + update_domain_alert_status, CaseSearchConfigView, DefaultProjectSettingsView, EditBasicProjectInfoView, EditMyProjectSettingsView, EditPrivacySecurityView, FeaturePreviewsView, + ManageDomainAlertsView, ManageDomainMobileWorkersView, CustomPasswordResetView, RecoveryMeasuresHistory, @@ -189,6 +192,9 @@ name=EditInternalCalculationsView.urlname), url(r'^internal/calculated_properties/$', calculated_properties, name='calculated_properties'), url(r'^previews/$', FeaturePreviewsView.as_view(), name=FeaturePreviewsView.urlname), + url(r'^alerts/$', ManageDomainAlertsView.as_view(), name=ManageDomainAlertsView.urlname), + url(r'^alerts/delete/$', delete_domain_alert, name='delete_domain_alert'), + url(r'^alerts/update_status/$', update_domain_alert_status, name='update_domain_alert_status'), url(r'^manage_mobile_workers/$', ManageDomainMobileWorkersView.as_view(), name=ManageDomainMobileWorkersView.urlname), url(r'^flags/$', FlagsAndPrivilegesView.as_view(), name=FlagsAndPrivilegesView.urlname), diff --git a/corehq/apps/domain/views/accounting.py b/corehq/apps/domain/views/accounting.py index 93d05ad7531ba..8b89ed98a6004 100644 --- a/corehq/apps/domain/views/accounting.py +++ b/corehq/apps/domain/views/accounting.py @@ -83,13 +83,13 @@ from corehq.apps.accounting.utils import ( fmt_dollar_amount, get_change_status, - get_customer_cards, is_downgrade, log_accounting_error, quantize_accounting_decimal, get_paused_plan_context, pause_current_subscription, ) +from corehq.apps.accounting.utils.stripe import get_customer_cards from corehq.apps.domain.decorators import ( login_and_domain_required, require_superuser, @@ -227,7 +227,7 @@ def plan(self): cards = None trial_length = None if subscription: - cards = get_customer_cards(self.request.user.username, self.domain) + cards = get_customer_cards(self.request.user.username) date_end = (subscription.date_end.strftime(USER_DATE_FORMAT) if subscription.date_end is not None else "--") @@ -457,7 +457,7 @@ class DomainBillingStatementsView(DomainAccountingSettings, CRUDPaginatedViewMix @property def stripe_cards(self): - return get_customer_cards(self.request.user.username, self.domain) + return get_customer_cards(self.request.user.username) @property def show_hidden(self): @@ -1150,7 +1150,6 @@ class SelectedAnnualPlanView(SelectPlanView): template_name = 'domain/selected_annual_plan.html' urlname = 'annual_plan_request_quote' step_title = gettext_lazy("Contact Dimagi") - edition = None @property def steps(self): diff --git a/corehq/apps/domain/views/internal.py b/corehq/apps/domain/views/internal.py index a415de161489e..3de9ede8262ae 100644 --- a/corehq/apps/domain/views/internal.py +++ b/corehq/apps/domain/views/internal.py @@ -188,8 +188,10 @@ def post(self, request, *args, **kwargs): self.internal_settings_form.cleaned_data['active_ucr_expressions'], old_ucr_permissions, ) - eula_props_changed = (bool(old_attrs.custom_eula) != bool(self.domain_object.internal.custom_eula) - or bool(old_attrs.can_use_data) != bool(self.domain_object.internal.can_use_data)) + eula_props_changed = ( + bool(old_attrs.custom_eula) != bool(self.domain_object.internal.custom_eula) + or bool(old_attrs.can_use_data) != bool(self.domain_object.internal.can_use_data) + ) if eula_props_changed and settings.EULA_CHANGE_EMAIL: message = '\n'.join([ @@ -314,10 +316,10 @@ def get_project_limits_context(name_limiter_tuple_list, scope=None): def _get_rate_limits(scope, rate_limiter): return [ - {'key': scope + ' ' + key, 'current_usage': int(current_usage), 'limit': int(limit), + {'key': scope + ' ' + rate_counter.key, 'current_usage': int(current_usage), 'limit': int(limit), 'percent_usage': round(100 * current_usage / limit, 1)} for scope, limits in rate_limiter.iter_rates(scope) - for key, current_usage, limit in limits + for rate_counter, current_usage, limit in limits ] diff --git a/corehq/apps/domain/views/settings.py b/corehq/apps/domain/views/settings.py index 3e0ee9d75e61a..42ca8e6271435 100644 --- a/corehq/apps/domain/views/settings.py +++ b/corehq/apps/domain/views/settings.py @@ -1,5 +1,6 @@ import json from collections import defaultdict +from functools import cached_property from django.conf import settings from django.contrib import messages @@ -13,6 +14,7 @@ from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy +from django.views.decorators.http import require_POST from couchdbkit import ResourceNotFound from django_prbac.utils import has_privilege @@ -41,6 +43,7 @@ from corehq.apps.domain.forms import ( USE_LOCATION_CHOICE, USE_PARENT_LOCATION_CHOICE, + DomainAlertForm, DomainGlobalSettingsForm, DomainMetadataForm, PrivacySecurityForm, @@ -49,6 +52,7 @@ ) from corehq.apps.domain.models import Domain from corehq.apps.domain.views.base import BaseDomainView +from corehq.apps.hqwebapp.models import Alert from corehq.apps.hqwebapp.signals import clear_login_attempts from corehq.apps.locations.permissions import location_safe from corehq.apps.ota.models import MobileRecoveryMeasure @@ -290,6 +294,7 @@ def privacy_form(self): "strong_mobile_passwords": self.domain_object.strong_mobile_passwords, "ga_opt_out": self.domain_object.ga_opt_out, "disable_mobile_login_lockout": self.domain_object.disable_mobile_login_lockout, + "allow_invite_email_only": self.domain_object.allow_invite_email_only, } if self.request.method == 'POST': return PrivacySecurityForm(self.request.POST, initial=initial, @@ -517,3 +522,105 @@ class ManageDomainMobileWorkersView(ManageMobileWorkersMixin, BaseAdminProjectSe page_title = gettext_lazy("Manage Mobile Workers") template_name = 'enterprise/manage_mobile_workers.html' urlname = 'domain_manage_mobile_workers' + + +@method_decorator(toggles.CUSTOM_DOMAIN_BANNER_ALERTS.required_decorator(), name='dispatch') +class ManageDomainAlertsView(BaseAdminProjectSettingsView): + template_name = 'domain/admin/manage_alerts.html' + urlname = 'domain_manage_alerts' + page_title = gettext_lazy("Manage Project Alerts") + + @property + def page_context(self): + return { + 'form': self.form, + 'alerts': [ + { + 'active': alert.active, + 'html': alert.html, + 'id': alert.id, + 'created_by_user': alert.created_by_user, + } + for alert in Alert.objects.filter(created_by_domain=self.domain) + ] + } + + @cached_property + def form(self): + if self.request.method == 'POST': + return DomainAlertForm(self.request.POST) + return DomainAlertForm() + + def post(self, request, *args, **kwargs): + if self.form.is_valid(): + self._create_alert() + messages.success(request, _("Alert saved!")) + else: + messages.error(request, _("There was an error saving your alert. Please try again!")) + return self.get(request, *args, **kwargs) + return HttpResponseRedirect(self.page_url) + + def _create_alert(self): + Alert.objects.create( + created_by_domain=self.domain, + domains=[self.domain], + text=self.form.cleaned_data['text'], + created_by_user=self.request.couch_user.username, + ) + + +@toggles.CUSTOM_DOMAIN_BANNER_ALERTS.required_decorator() +@domain_admin_required +@require_POST +def update_domain_alert_status(request, domain): + alert_id = request.POST.get('alert_id') + assert alert_id, 'Missing alert ID' + + alert = _load_alert(alert_id, domain) + if not alert: + messages.error(request, _("Alert not found!")) + else: + _apply_update(request, alert) + return HttpResponseRedirect(reverse(ManageDomainAlertsView.urlname, kwargs={'domain': domain})) + + +@toggles.CUSTOM_DOMAIN_BANNER_ALERTS.required_decorator() +@domain_admin_required +@require_POST +def delete_domain_alert(request, domain): + alert_id = request.POST.get('alert_id') + assert alert_id, 'Missing alert ID' + alert = _load_alert(alert_id, domain) + if not alert: + messages.error(request, _("Alert not found!")) + else: + alert.delete() + messages.success(request, _("Alert was removed!")) + return HttpResponseRedirect(reverse(ManageDomainAlertsView.urlname, kwargs={'domain': domain})) + + +def _load_alert(alert_id, domain): + try: + return Alert.objects.get( + created_by_domain=domain, + id=alert_id + ) + except Alert.DoesNotExist: + return None + + +def _apply_update(request, alert): + command = request.POST.get('command') + if command in ['activate', 'deactivate']: + _update_alert(alert, command) + messages.success(request, _("Alert updated!")) + else: + messages.error(request, _("Unexpected update received. Alert not updated!")) + + +def _update_alert(alert, command): + if command == 'activate': + alert.active = True + elif command == 'deactivate': + alert.active = False + alert.save(update_fields=['active']) diff --git a/corehq/apps/dump_reload/sql/dump.py b/corehq/apps/dump_reload/sql/dump.py index 507ac2cf2ebc5..0caae3d1a2287 100644 --- a/corehq/apps/dump_reload/sql/dump.py +++ b/corehq/apps/dump_reload/sql/dump.py @@ -160,6 +160,7 @@ FilteredModelIteratorBuilder('users.RoleAssignableBy', SimpleFilter('role__domain')), FilteredModelIteratorBuilder('users.RolePermission', SimpleFilter('role__domain')), FilteredModelIteratorBuilder('users.UserRole', SimpleFilter('domain')), + FilteredModelIteratorBuilder('users.SQLUserData', SimpleFilter('domain')), FilteredModelIteratorBuilder('locations.LocationFixtureConfiguration', SimpleFilter('domain')), FilteredModelIteratorBuilder('commtrack.CommtrackConfig', SimpleFilter('domain')), FilteredModelIteratorBuilder('commtrack.ActionConfig', SimpleFilter('commtrack_config__domain')), @@ -214,6 +215,7 @@ FilteredModelIteratorBuilder('generic_inbound.RequestLog', SimpleFilter('domain')), FilteredModelIteratorBuilder('generic_inbound.ProcessingAttempt', SimpleFilter('log__domain')), FilteredModelIteratorBuilder('geospatial.GeoPolygon', SimpleFilter('domain')), + FilteredModelIteratorBuilder('hqwebapp.Alert', SimpleFilter('created_by_domain')), FilteredModelIteratorBuilder('domain.AppReleaseModeSetting', SimpleFilter('domain')), FilteredModelIteratorBuilder('events.Event', SimpleFilter('domain')), FilteredModelIteratorBuilder('events.AttendanceTrackingConfig', SimpleFilter('domain')), diff --git a/corehq/apps/dump_reload/tests/test_dump_models.py b/corehq/apps/dump_reload/tests/test_dump_models.py index 62028c85d4e7d..0ad058f3b1043 100644 --- a/corehq/apps/dump_reload/tests/test_dump_models.py +++ b/corehq/apps/dump_reload/tests/test_dump_models.py @@ -66,7 +66,6 @@ "hqadmin.HistoricalPillowCheckpoint", "hqadmin.HqDeploy", "hqwebapp.HQOauthApplication", - "hqwebapp.MaintenanceAlert", "hqwebapp.UserAccessLog", "hqwebapp.UserAgent", "notifications.DismissedUINotify", diff --git a/corehq/apps/email/forms.py b/corehq/apps/email/forms.py index ddc3bf8ae4b32..2621429ce526a 100644 --- a/corehq/apps/email/forms.py +++ b/corehq/apps/email/forms.py @@ -18,10 +18,10 @@ class EmailSMTPSettingsForm(forms.ModelForm): widget=forms.PasswordInput(render_value=True), ) - server = forms.URLField( + server = forms.CharField( label=_('Server'), required=True, - help_text=_('e.g. "https://smtp.example.com"'), + help_text=_('e.g. "smtp.example.com"'), ) port = forms.IntegerField( @@ -32,10 +32,16 @@ class EmailSMTPSettingsForm(forms.ModelForm): ) from_email = forms.EmailField( - label=_("Sender's email"), + label=_("Sender's Email"), required=True, ) + return_path_email = forms.EmailField( + label=_("Return Path Email"), + required=False, + help_text=_("The email address to which message bounces and complaints should be sent") + ) + use_this_gateway = forms.BooleanField( label=_("Use Gateway?"), required=False, @@ -73,6 +79,7 @@ class Meta: 'server', 'port', 'from_email', + 'return_path_email', 'use_this_gateway', 'use_tracking_headers', 'sns_secret', @@ -99,6 +106,7 @@ def helper(self): crispy.Field('server'), crispy.Field('port'), crispy.Field('from_email'), + crispy.Field('return_path_email'), twbscrispy.PrependedText('use_tracking_headers', ''), crispy.Field('sns_secret'), crispy.Field('ses_config_set_name'), diff --git a/corehq/apps/email/migrations/0002_emailsettings_return_path_email.py b/corehq/apps/email/migrations/0002_emailsettings_return_path_email.py new file mode 100644 index 0000000000000..dbafd31bed2ed --- /dev/null +++ b/corehq/apps/email/migrations/0002_emailsettings_return_path_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-10-20 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('email', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='emailsettings', + name='return_path_email', + field=models.EmailField(default='', max_length=254), + ), + ] diff --git a/corehq/apps/email/models.py b/corehq/apps/email/models.py index f197be7b859dc..d6e1a5a7643db 100644 --- a/corehq/apps/email/models.py +++ b/corehq/apps/email/models.py @@ -11,6 +11,7 @@ class EmailSettings(models.Model): server = models.CharField(max_length=255) port = models.IntegerField(default=0) from_email = models.EmailField() + return_path_email = models.EmailField(default='') use_this_gateway = models.BooleanField(default=False) use_tracking_headers = models.BooleanField(default=False) sns_secret = models.CharField(max_length=100) diff --git a/corehq/apps/email/tests/test_email_form.py b/corehq/apps/email/tests/test_email_form.py index 1bf41f20ace18..3ddf1fd433cf8 100644 --- a/corehq/apps/email/tests/test_email_form.py +++ b/corehq/apps/email/tests/test_email_form.py @@ -55,19 +55,6 @@ def test_clean_from_email(self): self.assertFalse(form.is_valid()) self.assertIn('from_email', form.errors) - def test_clean_server(self): - form_data = EmailSMTPSettingsFormTests._get_valid_form_data() - - # Valid server format - form = EmailSMTPSettingsForm(data=form_data) - self.assertTrue(form.is_valid()) - - # Invalid server format - form_data.update({'server': 'server'}) - form = EmailSMTPSettingsForm(data=form_data) - self.assertFalse(form.is_valid()) - self.assertIn('server', form.errors) - @staticmethod def _get_valid_form_data(): return { diff --git a/corehq/apps/enterprise/forms.py b/corehq/apps/enterprise/forms.py index 6637e97a18bc3..a36d5b0b1d8b1 100644 --- a/corehq/apps/enterprise/forms.py +++ b/corehq/apps/enterprise/forms.py @@ -249,6 +249,7 @@ class EnterpriseManageMobileWorkersForm(forms.Form): (120, gettext_lazy("120 days")), (150, gettext_lazy("150 days")), (180, gettext_lazy("180 days")), + (365, gettext_lazy("365 days")), ), help_text=gettext_lazy( "Mobile workers who have not submitted a form after these many " diff --git a/corehq/apps/enterprise/tasks.py b/corehq/apps/enterprise/tasks.py index ede838160fc3b..3e746bea123de 100644 --- a/corehq/apps/enterprise/tasks.py +++ b/corehq/apps/enterprise/tasks.py @@ -24,7 +24,7 @@ @task(serializer='pickle', queue="email_queue") -def email_enterprise_report(domain, slug, couch_user): +def email_enterprise_report(domain: str, slug, couch_user): account = BillingAccount.get_account_by_domain(domain) report = EnterpriseReport.create(slug, account.id, couch_user) @@ -48,7 +48,13 @@ def email_enterprise_report(domain, slug, couch_user): body = "The enterprise report you requested for the account {} is ready.<br>" \ "You can download the data at the following link: {}<br><br>" \ "Please remember that this link will only be active for 24 hours.".format(account.name, link) - send_html_email_async(subject, couch_user.get_email(), body) + send_html_email_async( + subject, + couch_user.get_email(), + body, + domain=domain, + use_domain_gateway=True, + ) @task diff --git a/corehq/apps/enterprise/tests/test_enterprise_tasks.py b/corehq/apps/enterprise/tests/test_enterprise_tasks.py index 9c351860cb7f5..db3935cb61d35 100644 --- a/corehq/apps/enterprise/tests/test_enterprise_tasks.py +++ b/corehq/apps/enterprise/tests/test_enterprise_tasks.py @@ -41,10 +41,11 @@ def test_email_report_domains_successful(self, mock_send): mock_redis_client.expire.return_value = MagicMock(return_value=None) with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client): - email_enterprise_report(self.domain, EnterpriseReport.DOMAINS, self.couch_user) + email_enterprise_report(self.domain.name, EnterpriseReport.DOMAINS, self.couch_user) expected_title = "Enterprise Dashboard: Project Spaces" - mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY) + mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY, + domain=self.domain.name, use_domain_gateway=True) @patch('corehq.apps.enterprise.tasks.send_html_email_async') def test_email_report_web_users(self, mock_send): @@ -57,10 +58,11 @@ def test_email_report_web_users(self, mock_send): mock_redis_client.expire.return_value = MagicMock(return_value=None) with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client): - email_enterprise_report(self.domain, EnterpriseReport.WEB_USERS, self.couch_user) + email_enterprise_report(self.domain.name, EnterpriseReport.WEB_USERS, self.couch_user) expected_title = "Enterprise Dashboard: Web Users" - mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY) + mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY, + domain=self.domain.name, use_domain_gateway=True) @patch('corehq.apps.enterprise.tasks.send_html_email_async') def test_email_report_mobile_users(self, mock_send): @@ -73,10 +75,11 @@ def test_email_report_mobile_users(self, mock_send): mock_redis_client.expire.return_value = MagicMock(return_value=None) with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client): - email_enterprise_report(self.domain, EnterpriseReport.MOBILE_USERS, self.couch_user) + email_enterprise_report(self.domain.name, EnterpriseReport.MOBILE_USERS, self.couch_user) expected_title = "Enterprise Dashboard: Mobile Workers" - mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY) + mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY, + domain=self.domain.name, use_domain_gateway=True) @patch('corehq.apps.enterprise.tasks.send_html_email_async') def test_email_report_form_submissions(self, mock_send): @@ -89,10 +92,11 @@ def test_email_report_form_submissions(self, mock_send): mock_redis_client.expire.return_value = MagicMock(return_value=None) with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client): - email_enterprise_report(self.domain, EnterpriseReport.FORM_SUBMISSIONS, self.couch_user) + email_enterprise_report(self.domain.name, EnterpriseReport.FORM_SUBMISSIONS, self.couch_user) expected_title = "Enterprise Dashboard: Mobile Form Submissions" - mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY) + mock_send.assert_called_with(expected_title, self.couch_user.username, mock.ANY, + domain=self.domain.name, use_domain_gateway=True) def test_email_report_unknown_type_fails(self): """ @@ -103,6 +107,6 @@ def test_email_report_unknown_type_fails(self): mock_redis_client.set.return_value = MagicMock(return_value=None) mock_redis_client.expire.return_value = MagicMock(return_value=None) - with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client),\ + with patch('corehq.apps.enterprise.tasks.get_redis_client', return_val=mock_redis_client), \ self.assertRaises(EnterpriseReportError): - email_enterprise_report(self.domain, 'unknown', self.couch_user) + email_enterprise_report(self.domain.name, 'unknown', self.couch_user) diff --git a/corehq/apps/enterprise/views.py b/corehq/apps/enterprise/views.py index 471d6d7721d40..4b00e75fe6dbe 100644 --- a/corehq/apps/enterprise/views.py +++ b/corehq/apps/enterprise/views.py @@ -23,10 +23,7 @@ from corehq.apps.accounting.decorators import always_allow_project_access from corehq.apps.enterprise.decorators import require_enterprise_admin from corehq.apps.enterprise.mixins import ManageMobileWorkersMixin -from corehq.apps.enterprise.models import ( - EnterprisePermissions, - EnterpriseMobileWorkerSettings, -) +from corehq.apps.enterprise.models import EnterprisePermissions from corehq.apps.enterprise.tasks import clear_enterprise_permissions_cache_for_all_users from couchexport.export import Format from dimagi.utils.couch.cache.cache_core import get_redis_client @@ -35,9 +32,9 @@ from corehq.apps.accounting.models import ( CustomerInvoice, CustomerBillingRecord, - BillingAccount, ) -from corehq.apps.accounting.utils import get_customer_cards, quantize_accounting_decimal, log_accounting_error +from corehq.apps.accounting.utils.stripe import get_customer_cards +from corehq.apps.accounting.utils import quantize_accounting_decimal, log_accounting_error from corehq.apps.domain.decorators import ( login_and_domain_required, require_superuser, @@ -48,10 +45,7 @@ from corehq.apps.enterprise.enterprise import EnterpriseReport -from corehq.apps.enterprise.forms import ( - EnterpriseSettingsForm, - EnterpriseManageMobileWorkersForm, -) +from corehq.apps.enterprise.forms import EnterpriseSettingsForm from corehq.apps.enterprise.tasks import email_enterprise_report from corehq.apps.export.utils import get_default_export_settings_if_available @@ -194,7 +188,7 @@ class EnterpriseBillingStatementsView(DomainAccountingSettings, CRUDPaginatedVie @property def stripe_cards(self): - return get_customer_cards(self.request.user.username, self.domain) + return get_customer_cards(self.request.user.username) @property def show_hidden(self): diff --git a/corehq/apps/es/REINDEX_PROCESS.md b/corehq/apps/es/REINDEX_PROCESS.md new file mode 100644 index 0000000000000..a650e58784a35 --- /dev/null +++ b/corehq/apps/es/REINDEX_PROCESS.md @@ -0,0 +1,454 @@ +# Elasticsearch Reindex Process + +This document outlines the process for reindexing an Elasticsearch index in CommCare HQ. The process would typically involve: +1. Creating a secondary index via migrations. ES migrations can be created by following [this](./README.rst#creating-elasticsearch-index-migrations) guide. +2. Updating [Secondary Index Names](./const.py#L27-L57) with the new secondary index names. +3. Multiplexing the HQ index adapters, so that data is written to both primary and secondary indices simultaneously using ElasticSyncMultiplexer. +4. Reindexing the secondary index using the `elastic_sync_multiplexed` utility. +5. Swapping the indices. +6. Turning off the multiplexer +7. Deleting the older index. +8. Updating [Index Names](https://github.com/dimagi/commcare-hq/blob/26ddc8f18f9a1c60c2aae6e09ecea4c4e6647758/corehq/apps/es/const.py#L27-L57), and setting primary index name to secondary index name and secondary index name to None. + + +### Prerequisites: + +1. Ensure that there is enough free space available in your cluster. The command to estimate disk space required for reindexing is: + +```sh +cchq <env> django-manage elastic_sync_multiplexed estimated_size_for_reindex +``` + +The output should look something like this + +``` +Index CName | Index Name | Size on Disk | Doc Count +-------------------- | ------------------------------ | -------------------- | ------------------------------ +apps | apps-20230524 | 2.34 GB | 366291 +cases | cases-20230524 | 9.69 GB | 16354312 +case_search | case-search-20230524 | 76.97 GB | 346089228 +domains | domains-20230524 | 6.61 MB | 1491 +forms | forms-20230524 | 16.32 GB | 5506362 +groups | groups-20230524 | 739.49 KB | 466 +sms | sms-20230524 | 216.47 MB | 387294 +users | users-20230524 | 293.24 MB | 310403 + + + +Minimum free disk space recommended before starting the reindex: 126.99 GB + +``` + +2. Check disk usage on each node + +```sh +cchq <env> run-shell-command <es_data_nodes> "df -h /opt/data" -b +``` + +This will return disk usage for each node. You can check if the cumulative available space across all nodes is greater than the total recommended space from the `estimated_size_for_reindex` output. + +``` +10.201.41.256 | CHANGED | rc=0 >> +Filesystem Size Used Avail Use% Mounted on +/dev/nvme1n1 79G 19G 56G 26% /opt/data +10.201.40.228 | CHANGED | rc=0 >> +Filesystem Size Used Avail Use% Mounted on +/dev/nvme1n1 79G 20G 55G 27% /opt/data +10.201.41.254 | CHANGED | rc=0 >> +Filesystem Size Used Avail Use% Mounted on +/dev/nvme1n1 79G 19G 56G 26% /opt/data +``` +In this case the available free space on all data nodes (56+55+56=167 GB) is greater than the recommended (126.99 GB) so the reindex can proceed. You can follow [Reindexing All Indices At Once](#reindexing-all-indices-at-once) + process. + + If available disk size is less than recommended free space and it is not possible to increase the disk size then you can follow [Reindexing One Index At A Time](#reindexing-one-index-at-a-time) to reindex the indices. + +3. Ensure that the new secondary indices are created and [corehq.apps.es.const.py](./const.py) has updated `HQ_<index_cname>_SECONDARY_INDEX_NAME` variables with the new secondary index names that were created in migrations. + +### Reindexing All Indices At Once. + + +1. In commcare cloud update `/environments/<env>/public.yml` with the following settings: + ``` + ES_APPS_INDEX_MULTIPLEXED = True + ES_CASE_SEARCH_INDEX_MULTIPLEXED = True + ES_CASES_INDEX_MULTIPLEXED = True + ES_DOMAINS_INDEX_MULTIPLEXED = True + ES_FORMS_INDEX_MULTIPLEXED = True + ES_GROUPS_INDEX_MULTIPLEXED = True + ES_SMS_INDEX_MULTIPLEXED = True + ES_USERS_INDEX_MULTIPLEXED = True + ``` + +2. From control machine, run `update-config` and restart commcare services so that new settings are applied. + ``` + cchq <env> update-config + cchq <env> service commcare restart + ``` + After these settings are applied all the adpaters will start writing to both primary and secondary indices simultaneously. Reads will still happen from the primary indices. + +3. The following steps should be repeated for each value of the following values of <index_cname> - 'apps', 'cases', 'case_search', 'domains', 'forms', 'groups', 'sms', 'users'. + + 1. Start the reindex process + ``` + ./manage.py elastic_sync_multiplexed start <index_cname> + ``` + It is advised to run the reindex command in a tmux session as it might take a long time and can be detached/re-attached as needed for monitoring progress. + + Note down the `task_number` that is displayed by the command. It should be a numeric ID and will be required to verify the reindex process + + 2. Verify reindex is completed by querying the logs (only needed for Elasticsearch 2) and ensuring that doc count matches between primary and secondary indices. + + 1. Logs can be queried by running: + + ``` + cchq <env> run-shell-command elasticsearch "grep '<task_id>.*ReindexResponse' /opt/data/elasticsearch*/logs/*.log" + ``` + + This command will query the Elasticsearch logs on all data nodes to find any log entries containing the ReindexResponse for the given task_id. The log should look something like: + + ``` + [2023-10-23 08:59:37,648][INFO] [tasks] 29216 finished with response ReindexResponse[took=1.8s,updated=0,created=1111,batches=2,versionConflicts=0,noops=0,retries=0,throttledUntil=0s,indexing_failures=[],search_failures=[]] + ``` + + Ensure that `search_failures` and `indexing_failures` are empty lists. + + 2. Then check doc counts between primary and secondary indices using: + + ``` + cchq env django-manage elastic_sync_multiplexed display_doc_counts <index_cname> + ``` + + This command will display the document counts for both the primary and secondary indices for a given index. If the doc count matches between the two and there are no errors in the reindex logs, then reindexing is complete for that index. + + Please note that for high frequency indices like case_search, cases, and forms the counts may not match perfectly. In such cases, ensure the difference in counts is small (within one hundred) and there are no errors in reindex logs. + + 3. After the index has been reindexed, for every index cname, run the following commands to cleanup tombstones and set the correct replica count for the newly created index. + ``` + ./manage.py elastic_sync_multiplexed cleanup <index_cname> + ``` + + ``` + ./manage.py elastic_sync_multiplexed set_replicas <index_cname> + ``` + +4. At this point we should have a secondary index for every primary index. Writes will continue to happen to both primary and secondary indices simultaneously. Reads will still happen from the primary index. + +5. To switch the reads from the primary index to the new secondary index, we need to swap the indices. +To swap the indexes we need to follow the following steps: + + 1. Stop the pillows + + ``` + cchq <env> service pillowtop stop + ``` + 2. Copy the checkpoint ids for the pillows that depend on the index you are about to swap. For every index_cname run: + + ``` + cchq <env> django-manage elastic_sync_multiplexed copy_checkpoints <index_cname> + ``` + 3. Swap the indexes in settings to make the primary index the secondary index and vice versa. + Update `environments/<env>/public.yml`, set + ``` + ES_APPS_INDEX_SWAPPED = True + ES_CASE_SEARCH_INDEX_SWAPPED = True + ES_CASES_INDEX_SWAPPED = True + ES_DOMAINS_INDEX_SWAPPED = True + ES_FORMS_INDEX_SWAPPED = True + ES_GROUPS_INDEX_SWAPPED = True + ES_SMS_INDEX_SWAPPED = True + ES_USERS_INDEX_SWAPPED = True + ``` + 4. From the control machine, run `update-config` and restart commcare services so that the new settings are applied. This will also restart the pillows that were stopped in the previous step. + ``` + cchq <env> update-config + cchq <env> service commcare restart + ``` + After this step, the indexes will be swapped and reads will now happen from the newly created secondary indices. + + 5. Open CommCare HQ to test features that interact with Elasticsearch. One example is submitting a form and then ensuring that that form submission appears in relevant reports. If you have metrics setup for pillows, verify that the change feed is looking good. + + 6. It is recommended to keep the indices in this state for at least 2 working days. This will provide a safe window to fall back if needed. + +6. When you are confident that things are working fine with the new index, you are all set to turn off the multiplexer settings. Update `environments/<env>/public.yml` with following values: + ``` + ES_APPS_INDEX_MULTIPLEXED = False + ES_CASE_SEARCH_INDEX_MULTIPLEXED = False + ES_CASES_INDEX_MULTIPLEXED = False + ES_DOMAINS_INDEX_MULTIPLEXED = False + ES_FORMS_INDEX_MULTIPLEXED = False + ES_GROUPS_INDEX_MULTIPLEXED = False + ES_SMS_INDEX_MULTIPLEXED = False + ES_USERS_INDEX_MULTIPLEXED = False + ``` + + From the control machine, run `update-config` and restart commcare services so that new settings are applied. + + ``` + cchq <env> update-config + cchq <env> service commcare restart + ``` + +7. At this stage, writes have been stopped on the older indices. The older indices are eligible for deletion. It is recommended to wait for 6 hours after the multiplexer is turned off. The older indices can be deleted by running. + + ``` + cchq <env> django-manage elastic_sync_multiplexed delete <index_cname> + ``` +8. Delete any lingering residual indices by running + +``` +cchq <env> django-manage elastic_sync_multiplexed remove_residual_indices +``` +These indices are safe to delete and are not used in any functionality of CommCare HQ. + +9. Congratulations :tada: You have successfully created new indexes that are active on CommCare HQ. + +10. Update [Index Names](./const.py#L27-L57), set Primary index names to secondary index name and secondary index names to `None`. + +11. Set Index swapped variables to False + + ``` + ES_APPS_INDEX_SWAPPED = False + ES_CASE_SEARCH_INDEX_SWAPPED = False + ES_CASES_INDEX_SWAPPED = False + ES_DOMAINS_INDEX_SWAPPED = False + ES_FORMS_INDEX_SWAPPED = False + ES_GROUPS_INDEX_SWAPPED = False + ES_SMS_INDEX_SWAPPED = False + ES_USERS_INDEX_SWAPPED = False + + ``` + +12. Before deploying the changes in Step 10, run `update-config`. + + ``` + cchq <env> update-config + ``` + +13. Deploy Commcare HQ. + + ``` + cchq <env> deploy + ``` + + +### Reindexing One Index At A Time + +When there isn't enough space to accomodate duplicate data for all the indices, then we will multiplex, reindex and swap one index at a time. Then we will turn off the multiplexer, swap the index, and delete the older index. We will then repeat this process for all of the indices as described below. + +1. To turn on multiplexer one index at a time, repeat the following steps by replacing <index_cname> with the following values + + ``` + 'apps', 'cases', 'case_search', 'domains', 'forms', 'groups', 'sms', 'users' + ``` + + 1. Update `environments/<env>/public.yml`. + ``` + ES_<index_cname>_INDEX_MULTIPLEXED = True + ``` + 2. Apply the changes and restart commcare service + + ``` + cchq <env> update-config + cchq <env> service commcare restart + ``` + 3. Start the reindex process for <index_cname> + ``` + ./manage.py elastic_sync_multiplexed start <index_cname> + ``` + It is advised to run the reindex command in a tmux session as it might take a long time and can be detached/re-attached as needed for monitoring progress. + + Note down the `task_number` that is displayed by the command. It should be a numeric ID and will be required to verify the reindex process + + 4. Verify reindex is completed by querying the logs (only needed for Elasticsearch 2) and ensuring that the doc count matches between primary and secondary indices. + + 1. Logs can be queried by running: + + ``` + cchq <env> run-shell-command elasticsearch "grep '<task_number>.*ReindexResponse' /opt/data/elasticsearch*/logs/*.log" + ``` + + This command will query the Elasticsearch logs on all data nodes to find any log entries containing the ReindexResponse for the given task_number. The log should look something like: + + ``` + [2023-10-23 08:59:37,648][INFO] [tasks] 29216 finished with response ReindexResponse[took=1.8s,updated=0,created=1111,batches=2,versionConflicts=0,noops=0,retries=0,throttledUntil=0s,indexing_failures=[],search_failures=[]] + ``` + + Ensure that `search_failures` and `indexing_failures` are empty lists. + + 2. Then check doc counts between primary and secondary indices using: + + ``` + cchq env django-manage elastic_sync_multiplexed display_doc_counts <index_cname> + ``` + + This command will display the document counts for both the primary and secondary indices for a given index. If the doc count matches between the two and there are no errors in the reindex logs, then reindexing is complete for that index. + + Please note that for high frequency indices like case_search, cases, and forms, the counts may not match perfectly. In such cases, ensure the difference in counts is small (within one hundred) and there are no errors in reindex logs. + + 5. After the index has been reindexed, we need to cleanup tombstones and set the correct replica count for the newly created index. + ``` + ./manage.py elastic_sync_multiplexed cleanup <index_cname> + ``` + + ``` + ./manage.py elastic_sync_multiplexed set_replicas <index_cname> + ``` + 6. At this step we should have a secondary index for `<index_cname>` which has the same data as the primary index. On index `<index_cname>` writes will continue to happen to both primary and secondary indices simultaneously. Reads will still hit the primary index. + + 7. To switch the reads from the primary index to the new secondary index, we need to swap the index. + To swap the index we need to follow the following steps: + + 1. Stop the pillows + + ``` + cchq <env> service pillowtop stop + ``` + 2. Copy the checkpoint ids for the pillows that depend on the index you are about to swap. + + ``` + cchq <env> django-manage elastic_sync_multiplexed copy_checkpoints <index_cname> + ``` + 3. We will now swap the index in the settings file to make the primary index as secondary and vice versa. + Update `environments/<env>/public.yml`, set + ``` + ES_<index_cname>_INDEX_SWAPPED = True + ``` + + 4. From the control machine, run update-config and restart commcare services so that the new settings are applied. This will also restart the pillows that were stopped in the previous step. + ``` + cchq <env> update-config + cchq <env> service commcare restart + ``` + After this step, the indexes will be swapped and reads will now happen from the newly created secondary index. + + 5. Look around in CommcareHQ, test out things dealing with the index that was swapped + + 6. It is recommended to keep the index in this state for at least 1 working day. This will provide a safe window to fall back if needed. + + 8. When you are confident that things are working fine with the new index, you are all set to turn off the multiplexer settings. Update `environments/<env>/public.yml` with following values: + ``` + ES_<index_cname>_INDEX_MULTIPLEXED = False + ``` + 9. For index <index_cname>, writes have been stopped on the old primary index. The older index is eligible for deletion. It is recommended to wait for 6 hours after the multiplexer is turned off. The older index can be deleted by running. + + ``` + cchq <env> django-manage elastic_sync_multiplexed delete <index_cname> + ``` + This will free up the space on machine and you are ready to reindex another index. + + 10. Go back to Step 1 and repeat the process with a different index_cname + +2. Delete any lingering residual indices by running + +``` +cchq <env> django-manage elastic_sync_multiplexed remove_residual_indices +``` +These indices are safe to delete and are not used in any functionality of CommCareHQ. + +3. Congratulations :tada: You have successfully created new indexes that are active on CommcareHQ. + +4. Update [Index Names](./const.py#L27-L57), set primary index names to secondary index name and secondary index names to `None`. + +5. Set Index swapped variables to False + + ``` + ES_APPS_INDEX_SWAPPED = False + ES_CASE_SEARCH_INDEX_SWAPPED = False + ES_CASES_INDEX_SWAPPED = False + ES_DOMAINS_INDEX_SWAPPED = False + ES_FORMS_INDEX_SWAPPED = False + ES_GROUPS_INDEX_SWAPPED = False + ES_SMS_INDEX_SWAPPED = False + ES_USERS_INDEX_SWAPPED = False + + ``` + +6. Before deploying the changes in Step 10, run `update-config`. + + ``` + cchq <env> update-config + ``` + +7. Deploy Commcare HQ. + + ``` + cchq <env> deploy + ``` + + +### Common Issues Resolutions During Reindex + +#### ReIndex logs does not have error but doc counts don't match + +If the reindex log has no errors but the doc counts don't match, it might be the case that one of the Elasticsearch machine ran out of memory and got restarted. + +You can verify this by querying the logs - +``` +cchq <env> run-shell-command elasticsearch 'grep -r -i "java.lang.OutOfMemoryError" /opt/data/elasticsearch-*/logs -A10' +``` +By default elasticsearch uses a batch size of 1000 and if there are big docs then this can cause memory issues if cluster is running low on memory. You can try decreasing the `batch_size` to a smaller number while starting the reindex process. + +``` +./manage.py elastic_sync_multiplexed start <index_cname> --batch_size <batch size> +``` + +This might increase the reindex time but can avoid OOM errors. + +#### ReIndex logs have BulkItemResponseFailure + +If the reindex logs have `BulkItemResponse$Failure` then it can be because of `_id` being present in `_source` objects. You can query detailed logs by running : + +``` +cchq production run-shell-command elasticsearch 'grep -r -i "failed to execute bulk item (index) index" /opt/data/elasticsearch-2.4.6/logs -A20' +``` + +If the logs contains following error + +``` +MapperParsingException[Field [_id] is a metadata field and cannot be added inside a document. Use the index API request parameters] +``` + +The issue can be fixed by passing `--purge-ids` argument to the `reindex` command. This will remove `_id` from documents during reindexing to avoid these errors. + +``` +./manage.py elastic_sync_multiplexed start <index_cname> --purge-ids +``` + +#### Unable to assign replicas + +While assigning replicas if you get the following error in es logs - + +``` +[2023-10-03 14:31:09,981][INFO ][cluster.routing.allocation.decider] [esmaster_a1] low disk watermark [85%] exceeded on [Ap_smchPTLinFxB2BPWpJw][es-machine-env][/opt/data/elasticsearch-2.4.6/data/enves-2.x/nodes/0] free: 258.4gb[12.5%], replicas will not be assigned to this node +``` + +You can increase the watermark settings to a higher value like: + +``` +curl -X PUT "http://<cluster_ip>:9200/_cluster/settings" -H "Content-Type: application/json" -d '{ + "persistent": { + "cluster.routing.allocation.disk.watermark.low": "95%", + "cluster.routing.allocation.disk.watermark.high": "97.5%" + } +}' +``` + +This will update the Elasticsearch cluster settings to set the disk watermark thresholds higher (to 95% low and 97.5% high from default), allowing replicas to be assigned even when disk usage reaches those levels. + +After the replicas are assigned, you can reset the disk watermark thresholds back to the default values by running: + +``` +curl -X PUT "http://<cluster_ip>:9200/_cluster/settings" -H "Content-Type: application/json" -d '{ + "persistent": { + "cluster.routing.allocation.disk.watermark.low": "85%", + "cluster.routing.allocation.disk.watermark.high": "90%" + } +}' +``` + +If reindex fails with out of memory errors, then batch size can be decreased while running reindex + +``` + ./manage.py elastic_sync_multiplexed start <index_cname> --batch_size <batch size> +``` \ No newline at end of file diff --git a/corehq/apps/es/aggregations.py b/corehq/apps/es/aggregations.py index 752f2d7c01d64..07eeab5f22f0f 100644 --- a/corehq/apps/es/aggregations.py +++ b/corehq/apps/es/aggregations.py @@ -586,6 +586,22 @@ def __init__(self, name, field, precision): } +class GeoBoundsAggregation(Aggregation): + """ + A metric aggregation that computes the bounding box containing all + geo_point values for a field. + + More info: `Geo Bounds Aggregation <https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-metrics-geobounds-aggregation.html>`_ + """ # noqa: E501 + type = 'geo_bounds' + + def __init__(self, name, field): + self.name = name + self.body = { + 'field': field, + } + + class NestedAggregation(Aggregation): """ A special single bucket aggregation that enables aggregating nested documents. diff --git a/corehq/apps/es/case_search.py b/corehq/apps/es/case_search.py index 30a9aa62ba271..e9a17fe4535d8 100644 --- a/corehq/apps/es/case_search.py +++ b/corehq/apps/es/case_search.py @@ -64,6 +64,10 @@ def builtin_filters(self): external_id, indexed_on, case_property_missing, + filters.geo_bounding_box, + filters.geo_polygon, + filters.geo_shape, # Available in Elasticsearch 8+ + filters.geo_grid, # Available in Elasticsearch 8+ ] + super(CaseSearchES, self).builtin_filters def case_property_query(self, case_property_name, value, clause=queries.MUST, fuzzy=False): diff --git a/corehq/apps/es/filters.py b/corehq/apps/es/filters.py index a6bf7ea4a1d28..3f0ae31158ed9 100644 --- a/corehq/apps/es/filters.py +++ b/corehq/apps/es/filters.py @@ -137,37 +137,51 @@ def regexp(field, regex): def geo_bounding_box(field, top_left, bottom_right): """ Only return geopoints stored in ``field`` that are located within - the bounding box defined by GeoPoints ``top_left`` and - ``bottom_right``. + the bounding box defined by ``top_left`` and ``bottom_right``. - :param field: The field where geopoints are stored - :param top_left: The GeoPoint of the top left of the bounding box, - a string in the format "latitude longitude" or "latitude - longitude altitude accuracy" - :param bottom_right: The GeoPoint of the bottom right of the - bounding box - :return: A filter dict + ``top_left`` and ``bottom_right`` accept a range of data types and + formats. + + More info: `Geo Bounding Box Query <https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-geo-bounding-box-query.html>`_ + """ # noqa: E501 + return { + 'geo_bounding_box': { + field: { + 'top_left': top_left, + 'bottom_right': bottom_right, + } + } + } + + +def geo_polygon(field, points): + """ + Filters ``geo_point`` values in ``field`` that fall within the + polygon described by the list of ``points``. + + More info: `Geo Polygon Query <https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-geo-polygon-query.html>`_ + + :param field: A field with Elasticsearch data type ``geo_point``. + :param points: A list of points that describe a polygon. + Elasticsearch supports a range of formats for list items. + :return: A filter dict. """ # noqa: E501 - from couchforms.geopoint import GeoPoint - - top_left_geo = GeoPoint.from_string(top_left, flexible=True) - bottom_right_geo = GeoPoint.from_string(bottom_right, flexible=True) - shape = { - 'type': 'envelope', - 'coordinates': [ - [float(top_left_geo.longitude), float(top_left_geo.latitude)], - [float(bottom_right_geo.longitude), float(bottom_right_geo.latitude)] - ] + # NOTE: Deprecated in Elasticsearch 7.12 + return { + 'geo_polygon': { + field: { + 'points': points, + } + } } - return geo_shape(field, shape, relation='within') def geo_shape(field, shape, relation='intersects'): """ - Filters cases by case properties indexed using the the geo_point + Filters cases by case properties indexed using the ``geo_point`` type. - More info: `The Geoshape query reference <https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html>`_ + More info: `The Geoshape query reference <https://www.elastic.co/guide/en/elasticsearch/reference/8.10/query-dsl-geo-shape-query.html>`_ :param field: The field where geopoints are stored :param shape: A shape definition given in GeoJSON geometry format. @@ -176,6 +190,26 @@ def geo_shape(field, shape, relation='intersects'): property values. :return: A filter definition """ # noqa: E501 + # NOTE: Available in Elasticsearch 8+. + # + # The geoshape query is available in Elasticsearch 5.6, but only + # supports the `geo_shape` type (not the `geo_point` type), which + # CommCare HQ does not use. + + # TODO: After Elasticsearch is upgraded, switch from geo_polygon to + # geo_shape. (Norman, 2023-11-01) + # e.g. + # + # geo_polygon(field, points) + # + # becomes + # + # shape = { + # 'type': 'polygon', + # 'coordinates': points, + # } + # geo_shape(field, shape, relation='within') + # return { "geo_shape": { field: { @@ -184,3 +218,17 @@ def geo_shape(field, shape, relation='intersects'): } } } + + +def geo_grid(field, geohash): + """ + Filters cases by the geohash grid cell in which they are located. + """ + # Available in Elasticsearch 8+ + return { + "geo_grid": { + field: { + "geohash": geohash + } + } + } diff --git a/corehq/apps/es/tests/test_case_search_es.py b/corehq/apps/es/tests/test_case_search_es.py index 864fd453d29d8..b39798ebb3741 100644 --- a/corehq/apps/es/tests/test_case_search_es.py +++ b/corehq/apps/es/tests/test_case_search_es.py @@ -1,6 +1,6 @@ import uuid from datetime import date, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytz from django.test import TestCase @@ -8,7 +8,7 @@ from couchforms.geopoint import GeoPoint -from corehq.apps.case_search.const import IS_RELATED_CASE, RELEVANCE_SCORE +from corehq.apps.case_search.const import RELEVANCE_SCORE from corehq.apps.case_search.models import CaseSearchConfig from corehq.apps.case_search.xpath_functions.comparison import adjust_input_date_by_timezone from corehq.apps.es import queries @@ -261,7 +261,6 @@ def test_wrap_case_search_hit_include_score(self): case = wrap_case_search_hit(self.make_hit(), include_score=True) self.assertEqual(case.case_json[RELEVANCE_SCORE], "1.095") - @staticmethod def make_hit(): return { @@ -516,7 +515,7 @@ def test_case_property_query(self): @flag_enabled('USH_CASE_CLAIM_UPDATES') @patch('corehq.pillows.case_search.get_gps_properties', return_value={'coords'}) - def test_geopoint_query(self, _): + def test_geopoint_query_for_gps_properties(self, _): self._bootstrap_cases_in_es_for_domain(self.domain, [ {'_id': 'c1', 'coords': "42.373611 -71.110558 0 0"}, {'_id': 'c2', 'coords': "42 Wallaby Way"}, @@ -528,6 +527,18 @@ def test_geopoint_query(self, _): ).get_ids() self.assertItemsEqual(res, ['c3', 'c4']) + @flag_enabled('GEOSPATIAL') + @patch('corehq.pillows.case_search.get_geo_case_property', return_value='domain_coord') + def test_geopoint_query_for_domain_geo_case_property(self, *args): + self._bootstrap_cases_in_es_for_domain(self.domain, [ + {'_id': 'c1', 'domain_coord': "42 Wallaby Way"}, + {'_id': 'c2', 'domain_coord': "-33.856159 151.215256 0 0"}, + ]) + res = CaseSearchES().domain(self.domain).set_query( + case_property_geo_distance('domain_coord', GeoPoint(-33.1, 151.8), kilometers=1000), + ).get_ids() + self.assertItemsEqual(res, ['c2']) + def test_starts_with_query(self): self._assert_query_runs_correctly( self.domain, diff --git a/corehq/apps/es/tests/test_filters.py b/corehq/apps/es/tests/test_filters.py index f7559375371c4..d11e731da6449 100644 --- a/corehq/apps/es/tests/test_filters.py +++ b/corehq/apps/es/tests/test_filters.py @@ -182,16 +182,10 @@ def test_geo_bounding_box(self): } }, { - "geo_shape": { + "geo_bounding_box": { "location": { - "shape": { - "type": "envelope", - "coordinates": [ - [-74.1, 40.73], - [-71.12, 40.01] - ] - }, - "relation": "within" + "top_left": "40.73 -74.1", + "bottom_right": "40.01 -71.12", } } }, @@ -216,13 +210,13 @@ def test_geo_bounding_box(self): ) def test_geo_shape(self): - shape = { - 'type': 'envelope', - # NOTE: coordinate order is longitude, latitude (X, Y) - 'coordinates': [[-74.1, 40.73], [-71.12, 40.01]] - } + points_list = [ + {"lat": 40.73, "lon": -74.1}, + {"lat": 40.01, "lon": -71.12}, + ] + query = CaseSearchES().filter( - filters.geo_shape('case_gps', shape) + filters.geo_shape('case_gps', points_list) ) json_output = { "query": { @@ -231,13 +225,16 @@ def test_geo_shape(self): { "geo_shape": { "case_gps": { - "shape": { - "type": "envelope", - "coordinates": [ - [-74.1, 40.73], - [-71.12, 40.01] - ] - }, + "shape": [ + { + "lat": 40.73, + "lon": -74.1 + }, + { + "lat": 40.01, + "lon": -71.12 + } + ], "relation": "intersects" } } @@ -259,6 +256,38 @@ def test_geo_shape(self): validate_query=False, ) + def test_geo_grid(self): + query = CaseSearchES().filter( + filters.geo_grid('location', 'u0') + ) + json_output = { + "query": { + "bool": { + "filter": [ + { + "geo_grid": { + "location": { + "geohash": "u0" + } + } + }, + { + "match_all": {} + } + ], + "must": { + "match_all": {} + } + } + }, + "size": SIZE_LIMIT + } + self.checkQuery( + query, + json_output, + validate_query=False, + ) + @es_test class TestSourceFiltering(ElasticTestMixin, SimpleTestCase): diff --git a/corehq/apps/es/tests/test_user_es.py b/corehq/apps/es/tests/test_user_es.py index c1d985ffb0766..44649621a203a 100644 --- a/corehq/apps/es/tests/test_user_es.py +++ b/corehq/apps/es/tests/test_user_es.py @@ -20,20 +20,20 @@ def setUpClass(cls): cls.domain_obj = create_domain(cls.domain) with sync_users_to_es(): - cls._create_mobile_worker('stark', metadata={'sigil': 'direwolf', 'seat': 'Winterfell'}) - cls._create_mobile_worker('lannister', metadata={'sigil': 'lion', 'seat': 'Casterly Rock'}) - cls._create_mobile_worker('targaryen', metadata={'sigil': 'dragon', 'false_sigil': 'direwolf'}) + cls._create_mobile_worker('stark', user_data={'sigil': 'direwolf', 'seat': 'Winterfell'}) + cls._create_mobile_worker('lannister', user_data={'sigil': 'lion', 'seat': 'Casterly Rock'}) + cls._create_mobile_worker('targaryen', user_data={'sigil': 'dragon', 'false_sigil': 'direwolf'}) manager.index_refresh(user_adapter.index_name) @classmethod - def _create_mobile_worker(cls, username, metadata): + def _create_mobile_worker(cls, username, user_data): CommCareUser.create( domain=cls.domain, username=username, password="*****", created_by=None, created_via=None, - metadata=metadata, + user_data=user_data, ) @classmethod @@ -42,20 +42,20 @@ def tearDownClass(cls): cls.domain_obj.delete() super().tearDownClass() - def test_basic_metadata_query(self): - direwolf_families = UserES().metadata('sigil', 'direwolf').values_list('username', flat=True) + def test_basic_user_data_query(self): + direwolf_families = UserES().user_data('sigil', 'direwolf').values_list('username', flat=True) self.assertEqual(direwolf_families, ['stark']) - def test_chained_metadata_queries_where_both_match(self): + def test_chained_user_data_queries_where_both_match(self): direwolf_families = (UserES() - .metadata('sigil', 'direwolf') - .metadata('seat', 'Winterfell') + .user_data('sigil', 'direwolf') + .user_data('seat', 'Winterfell') .values_list('username', flat=True)) self.assertEqual(direwolf_families, ['stark']) - def test_chained_metadata_queries_with_only_one_match(self): + def test_chained_user_data_queries_with_only_one_match(self): direwolf_families = (UserES() - .metadata('sigil', 'direwolf') - .metadata('seat', 'Casterly Rock') + .user_data('sigil', 'direwolf') + .user_data('seat', 'Casterly Rock') .values_list('username', flat=True)) self.assertEqual(direwolf_families, []) diff --git a/corehq/apps/es/users.py b/corehq/apps/es/users.py index 6b7552ebbdaf1..b93b8722a6a0d 100644 --- a/corehq/apps/es/users.py +++ b/corehq/apps/es/users.py @@ -50,7 +50,6 @@ def builtin_filters(self): mobile_users, web_users, user_ids, - primary_location, location, last_logged_in, analytics_enabled, @@ -58,8 +57,8 @@ def builtin_filters(self): role_id, is_active, username, - metadata, - missing_or_empty_metadata_property, + user_data, + missing_or_empty_user_data_property, ] + super(UserES, self).builtin_filters def show_inactive(self): @@ -105,9 +104,10 @@ def _from_dict(self, user_dict): user_dict['__group_ids'] = [res.id for res in results] user_dict['__group_names'] = [res.name for res in results] user_dict['user_data_es'] = [] - if 'user_data' in user_dict: + if user_dict.get('base_doc') == 'CouchUser' and user_dict['doc_type'] == 'CommCareUser': user_obj = self.model_cls.wrap_correctly(user_dict) - for key, value in user_obj.metadata.items(): + user_data = user_obj.get_user_data(user_obj.domain) + for key, value in user_data.items(): user_dict['user_data_es'].append({ 'key': key, 'value': value, @@ -195,22 +195,10 @@ def user_ids(user_ids): return filters.term("_id", list(user_ids)) -def primary_location(location_id): - # by primary location - return filters.OR( - filters.AND(mobile_users(), filters.term('location_id', location_id)), - filters.AND( - web_users(), - filters.term('domain_memberships.location_id', location_id) - ), - ) - - def location(location_id): # by any assigned-location primary or not return filters.OR( filters.AND(mobile_users(), filters.term('assigned_location_ids', location_id)), - # todo; this actually doesn't get applied since the below field is not indexed filters.AND( web_users(), filters.term('domain_memberships.assigned_location_ids', location_id) @@ -233,10 +221,7 @@ def is_active(active=True): return filters.term("is_active", active) -def metadata(key, value): - # Note that this dict is stored in ES under the `user_data` field, and - # transformed into a queryable format (in ES) as `user_data_es`, but it's - # referenced in python as `metadata` +def user_data(key, value): return queries.nested( 'user_data_es', filters.AND( @@ -246,9 +231,9 @@ def metadata(key, value): ) -def _missing_metadata_property(property_name): +def _missing_user_data_property(property_name): """ - A metadata property doesn't exist. + A user_data property doesn't exist. """ return filters.NOT( queries.nested( @@ -258,9 +243,9 @@ def _missing_metadata_property(property_name): ) -def _missing_metadata_value(property_name): +def _missing_user_data_value(property_name): """ - A metadata property exists but has an empty string value. + A user_data property exists but has an empty string value. """ return queries.nested( 'user_data_es', @@ -273,11 +258,11 @@ def _missing_metadata_value(property_name): ) -def missing_or_empty_metadata_property(property_name): +def missing_or_empty_user_data_property(property_name): """ - A metadata property doesn't exist, or does exist but has an empty string value. + A user_data property doesn't exist, or does exist but has an empty string value. """ return filters.OR( - _missing_metadata_property(property_name), - _missing_metadata_value(property_name), + _missing_user_data_property(property_name), + _missing_user_data_value(property_name), ) diff --git a/corehq/apps/export/management/commands/process_skipped_pages.py b/corehq/apps/export/management/commands/process_skipped_pages.py index ce2d6930e3d4b..0f376afd22601 100644 --- a/corehq/apps/export/management/commands/process_skipped_pages.py +++ b/corehq/apps/export/management/commands/process_skipped_pages.py @@ -105,6 +105,7 @@ def compile_final_zip(self, error_pages, export_archive_path, export_instance, s final_dir, orig_name = os.path.split(export_archive_path) if not error_pages: fd, final_path = tempfile.mkstemp() + os.close(fd) else: final_name = 'INCOMPLETE_{}_{}.zip'.format(orig_name, datetime.utcnow().isoformat()) final_path = os.path.join(final_dir, final_name) diff --git a/corehq/apps/export/static/export/js/export_list.js b/corehq/apps/export/static/export/js/export_list.js index c4457d622cee8..a56d048268d72 100644 --- a/corehq/apps/export/static/export/js/export_list.js +++ b/corehq/apps/export/static/export/js/export_list.js @@ -20,7 +20,7 @@ hqDefine("export/js/export_list", [ 'analytix/js/google', 'analytix/js/kissmetrix', 'export/js/utils', - 'hqwebapp/js/validators.ko', // needed for validation of startDate and endDate + 'hqwebapp/js/bootstrap3/validators.ko', // needed for validation of startDate and endDate 'hqwebapp/js/bootstrap3/components.ko', // pagination & feedback widget 'select2/dist/js/select2.full.min', ], function ( diff --git a/corehq/apps/export/static/export/js/models.js b/corehq/apps/export/static/export/js/models.js index 47180361dc56a..5aaa91c450e7b 100644 --- a/corehq/apps/export/static/export/js/models.js +++ b/corehq/apps/export/static/export/js/models.js @@ -14,7 +14,7 @@ hqDefine('export/js/models', [ 'analytix/js/kissmetrix', 'export/js/const', 'export/js/utils', - 'hqwebapp/js/validators.ko', // needed for validation of customPathString + 'hqwebapp/js/bootstrap3/validators.ko', // needed for validation of customPathString 'hqwebapp/js/bootstrap3/knockout_bindings.ko', // needed for multirow_sortable binding ], function ( $, diff --git a/corehq/apps/fixtures/tasks.py b/corehq/apps/fixtures/tasks.py index c21adc5b985b4..02ab0235d3032 100644 --- a/corehq/apps/fixtures/tasks.py +++ b/corehq/apps/fixtures/tasks.py @@ -1,6 +1,5 @@ import datetime -from django.conf import settings from django.template.loader import render_to_string from soil import DownloadBase @@ -46,7 +45,8 @@ def send_upload_fixture_complete_email(email, domain, time_start, time_end, mess email, render_to_string('fixtures/upload_complete.html', context), render_to_string('fixtures/upload_complete.txt', context), - email_from=settings.DEFAULT_FROM_EMAIL + domain=domain, + use_domain_gateway=True, ) return diff --git a/corehq/apps/geospatial/README.md b/corehq/apps/geospatial/README.md index b1876de77e151..2ece6bdd11bd2 100644 --- a/corehq/apps/geospatial/README.md +++ b/corehq/apps/geospatial/README.md @@ -1,18 +1,98 @@ -# Case Grouping +Geospatial Features +=================== -There are various configuration settings available for deciding how case grouping is done. These parameters are saved in the `GeoConfig` model which is linked to a domain. -It is important to note however, that not all available parameters will be used for case grouping. The parameters that actually get used is determined by the chosen grouping -method. Mainly, these are: -1. Min/Max Grouping - Grouping is done by specifying the minimum and maximum number of cases that each group may have. -2. Target Size Grouping - Grouping is done by specifying how many groups should be created. Cases will then evenly get distributed into groups to meet the target number of groups. +Geospatial features allow the management of cases and mobile workers +based on their geographical location. Case location is stored in a +configurable case property, which defaults to "gps_point". Mobile +worker location is stored in user data, also with the name "gps_point". -# Setup Test Data +Case Grouping +------------- + +There are various configuration settings available for deciding how case +grouping is done. These parameters are saved in the `GeoConfig` model +which is linked to a domain. It is important to note however, that not +all available parameters will be used for case grouping. The parameters +that actually get used is determined by the chosen grouping method. +Mainly, these are: + +1. Min/Max Grouping - Grouping is done by specifying the minimum and + maximum number of cases that each group may have. + +2. Target Size Grouping - Grouping is done by specifying how many groups + should be created. Cases will then evenly get distributed into groups + to meet the target number of groups. + + +CaseGroupingReport pagination +----------------------------- + +The `CaseGroupingReport` class uses Elasticsearch +[GeoHash Grid Aggregation][1] to group cases into buckets. + +Elasticsearch [bucket aggregations][2] create buckets of documents, +where each bucket corresponds to a property that determines whether a +document falls into that bucket. + +The buckets of GeoHash Grid Aggregation are cells in a grid. Each cell +has a GeoHash, which is like a ZIP code or a postal code, in that it +represents a geographical area. If a document's GeoPoint is in a +GeoHash's geographical area, then Elasticsearch places it in the +corresponding bucket. For more information on GeoHash grid cells, see +the Elasticsearch docs on [GeoHash cell dimensions][3]. + +GeoHash Grid Aggregation buckets look like this: +``` +[ + { + "key": "u17", + "doc_count": 3 + }, + { + "key": "u09", + "doc_count": 2 + }, + { + "key": "u15", + "doc_count": 1 + } +] +``` +In this example, "key" is a GeoHash of length 3, and "doc_count" gives +the number of documents in each bucket, or GeoHash grid cell. + +For `CaseGroupingReport`, buckets are pages. So pagination simply flips +from one bucket to the next. + + +Setting Up Test Data +-------------------- + +To populate test data for any domain, you could simply do a bulk upload +for cases with the following columns -To populate test data for any domain, you could simply do a bulk upload for cases with the following columns 1. case_id: Blank for new cases + 2. name: (Optional) Add a name for each case. Remove column if not using -3. gps_point: GPS coordinate for the case that has latitude, longitude, altitude and accuracy separated by an empty space. Example: `9.9999952 3.2859413 393.2 4.36`. This is the case property saved on a case to capture its location and is configurable with default value being `gps_point`, so good to check Geospatial Configuration Settings page for the project to confirm the case property being used before doing the upload. If its different, then this column should use that case property instead of `gps_point` -4. owner_name: (Optional) To assign case to a mobile worker, simply add worker username here. Remove column if not using -For dimagi devs looking for bulk data, you could use any of the excel sheets available in https://dimagi-dev.atlassian.net/browse/SC-3051 +3. gps_point: GPS coordinate for the case that has latitude, longitude, + altitude and accuracy separated by an empty space. Example: + `9.9999952 3.2859413 393.2 4.36`. This is the case property saved on + a case to capture its location and is configurable with default + value being `gps_point`, so good to check Geospatial Configuration + Settings page for the project to confirm the case property being + used before doing the upload. If its different, then this column + should use that case property instead of `gps_point` + +4. owner_name: (Optional) To assign case to a mobile worker, simply add + worker username here. Remove column if not using. + +For Dimagi devs looking for bulk data, you could use any of the Excel +sheets available in Jira ticket [SC-3051][4]. + + +[1]: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html +[2]: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket.html +[3]: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator +[4]: https://dimagi-dev.atlassian.net/browse/SC-3051 diff --git a/corehq/apps/geospatial/es.py b/corehq/apps/geospatial/es.py index e2ec1b8dd7e2f..b0d0f01f6e05d 100644 --- a/corehq/apps/geospatial/es.py +++ b/corehq/apps/geospatial/es.py @@ -4,13 +4,17 @@ from corehq.apps.es import filters from corehq.apps.es.aggregations import ( FilterAggregation, + GeoBoundsAggregation, GeohashGridAggregation, NestedAggregation, ) from corehq.apps.es.case_search import PROPERTY_GEOPOINT_VALUE, PROPERTY_KEY from corehq.apps.geospatial.const import MAX_GEOHASH_DOC_COUNT -AGG_NAME = 'geohashes' +CASE_PROPERTIES_AGG = 'case_properties' +CASE_PROPERTY_AGG = 'case_property' +GEOHASHES_AGG = 'geohashes' +BUCKET_CASES_AGG = 'bucket_cases' def find_precision(query, case_property): @@ -50,6 +54,7 @@ def get_max_doc_count(query, case_property, precision): # # 'aggregations': { # 'case_properties': { + # 'doc_count': 66, # 'case_property': { # 'doc_count': 6, # 'geohashes': { @@ -70,32 +75,46 @@ def get_max_doc_count(query, case_property, precision): # } # } # } + # }, + # 'hits': { + # 'hits': [], + # 'max_score': 0.0, + # 'total': 6 # } buckets = ( queryset.raw['aggregations'] - ['case_properties'] - ['case_property'] - [AGG_NAME] + [CASE_PROPERTIES_AGG] + [CASE_PROPERTY_AGG] + [GEOHASHES_AGG] ['buckets'] ) return max(bucket['doc_count'] for bucket in buckets) if buckets else 0 def apply_geohash_agg(query, case_property, precision): - nested_agg = NestedAggregation('case_properties', CASE_PROPERTIES_PATH) + nested_agg = NestedAggregation( + name=CASE_PROPERTIES_AGG, + path=CASE_PROPERTIES_PATH, + ) filter_agg = FilterAggregation( - 'case_property', - filters.term(PROPERTY_KEY, case_property), + name=CASE_PROPERTY_AGG, + filter=filters.term(PROPERTY_KEY, case_property), ) geohash_agg = GeohashGridAggregation( - AGG_NAME, - PROPERTY_GEOPOINT_VALUE, - precision, + name=GEOHASHES_AGG, + field=PROPERTY_GEOPOINT_VALUE, + precision=precision, + ) + geobounds_agg = GeoBoundsAggregation( + name=BUCKET_CASES_AGG, + field=PROPERTY_GEOPOINT_VALUE, ) return query.aggregation( nested_agg.aggregation( filter_agg.aggregation( - geohash_agg + geohash_agg.aggregation( + geobounds_agg + ) ) ) ) diff --git a/corehq/apps/geospatial/models.py b/corehq/apps/geospatial/models.py index 2f1d167353831..f63c7e2ec7f21 100644 --- a/corehq/apps/geospatial/models.py +++ b/corehq/apps/geospatial/models.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext as _ from corehq.apps.geospatial.const import GPS_POINT_CASE_PROPERTY +from corehq.apps.geospatial.routing_solvers import pulp class GeoPolygon(models.Model): @@ -23,6 +24,11 @@ class GeoConfig(models.Model): MIN_MAX_GROUPING = 'min_max_grouping' TARGET_SIZE_GROUPING = 'target_size_grouping' + VALID_DISBURSEMENT_ALGORITHM_CLASSES = { + RADIAL_ALGORITHM: pulp.RadialDistanceSolver, + ROAD_NETWORK_ALGORITHM: pulp.RoadNetworkSolver, + } + VALID_LOCATION_SOURCES = [ CUSTOM_USER_PROPERTY, ASSIGNED_LOCATION, @@ -69,3 +75,9 @@ def _clear_caches(self): get_geo_case_property.clear(self.domain) get_geo_user_property.clear(self.domain) + + @property + def disbursement_solver(self): + return self.VALID_DISBURSEMENT_ALGORITHM_CLASSES[ + self.selected_disbursement_algorithm + ] diff --git a/corehq/apps/geospatial/reports.py b/corehq/apps/geospatial/reports.py index 48dac13500b5e..a8d754ce01f11 100644 --- a/corehq/apps/geospatial/reports.py +++ b/corehq/apps/geospatial/reports.py @@ -11,19 +11,35 @@ from corehq.apps.case_search.const import CASE_PROPERTIES_PATH from corehq.apps.es import CaseSearchES, filters -from corehq.apps.es.case_search import wrap_case_search_hit +from corehq.apps.es.case_search import ( + PROPERTY_GEOPOINT_VALUE, + PROPERTY_KEY, + wrap_case_search_hit, +) from corehq.apps.reports.standard import ProjectReport from corehq.apps.reports.standard.cases.basic import CaseListMixin from corehq.apps.reports.standard.cases.data_sources import CaseDisplayES +from corehq.util.quickcache import quickcache from .dispatchers import CaseManagementMapDispatcher -from .es import apply_geohash_agg, find_precision +from .es import ( + BUCKET_CASES_AGG, + CASE_PROPERTIES_AGG, + CASE_PROPERTY_AGG, + GEOHASHES_AGG, + apply_geohash_agg, + find_precision, +) from .models import GeoPolygon -from .utils import get_geo_case_property +from .utils import ( + geojson_to_es_geoshape, + get_geo_case_property, + validate_geometry, +) class BaseCaseMapReport(ProjectReport, CaseListMixin): - section_name = gettext_noop("Geospatial") + section_name = gettext_noop("Data") dispatcher = CaseManagementMapDispatcher @@ -36,6 +52,10 @@ def template_context(self): context.update({ 'mapbox_access_token': settings.MAPBOX_ACCESS_TOKEN, 'case_row_order': {val.html: idx for idx, val in enumerate(self.headers)}, + 'saved_polygons': [ + {'id': p.id, 'name': p.name, 'geo_json': p.geo_json} + for p in GeoPolygon.objects.filter(domain=self.domain).all() + ], }) return context @@ -53,34 +73,21 @@ def headers(self): headers.custom_sort = [[2, 'desc']] return headers - @property - def rows(self): + def _get_geo_location(self, case): geo_case_property = get_geo_case_property(self.domain) + geo_point = case.get_case_property(geo_case_property) + if not geo_point: + return - def _get_geo_location(case): - case_obj = wrap_case_search_hit(case) - geo_point = case_obj.get_case_property(geo_case_property) - if not geo_point: - return + try: + geo_point = GeoPoint.from_string(geo_point, flexible=True) + return {"lat": geo_point.latitude, "lng": geo_point.longitude} + except BadValueError: + return None - try: - geo_point = GeoPoint.from_string(geo_point, flexible=True) - return {"lat": geo_point.latitude, "lng": geo_point.longitude} - except BadValueError: - return None - - cases = [] - for row in self.es_results['hits'].get('hits', []): - display = CaseDisplayES( - self.get_case(row), self.timezone, self.individual - ) - coordinates = _get_geo_location(self.get_case(row)) - cases.append([ - display.case_id, - coordinates, - display.case_link - ]) - return cases + @property + def rows(self): + raise NotImplementedError() class CaseManagementMap(BaseCaseMapReport): @@ -94,15 +101,20 @@ def default_report_url(self): return reverse('geospatial_default', args=[self.request.project.name]) @property - def template_context(self): - context = super(CaseManagementMap, self).template_context - context.update({ - 'saved_polygons': [ - {'id': p.id, 'name': p.name, 'geo_json': p.geo_json} - for p in GeoPolygon.objects.filter(domain=self.domain).all() - ] - }) - return context + def rows(self): + cases = [] + for row in self.es_results['hits'].get('hits', []): + display = CaseDisplayES( + self.get_case(row), self.timezone, self.individual + ) + case = wrap_case_search_hit(row) + coordinates = self._get_geo_location(case) + cases.append([ + display.case_id, + coordinates, + display.case_link + ]) + return cases class CaseGroupingReport(BaseCaseMapReport): @@ -112,58 +124,244 @@ class CaseGroupingReport(BaseCaseMapReport): base_template = 'geospatial/case_grouping_map_base.html' report_template_path = 'case_grouping_map.html' - def _build_query(self): - query = super()._build_query() - case_property = get_geo_case_property(self.domain) + default_rows = 1 + force_page_size = True - # NOTE: ASSUMES polygon is available in request.POST['feature'] - if 'feature' in self.request.POST: - # Filter cases by a shape set by the user - geojson = json.loads(self.request.POST['feature']) - shape = geojson_to_es_geoshape(geojson) - relation = 'within' if shape['type'] == 'polygon' else 'intersects' - query.nested( - CASE_PROPERTIES_PATH, - filters.geo_shape( - field=case_property, - shape=shape, - relation=relation, - ) + def _base_query(self): + # Override function to skip default pagination + return self.search_class().domain(self.domain) + + @property + def rows(self): + """ + Returns cases for the current bucket/page + + Each page is a bucket of filtered cases grouped together. + We first load all buckets of filtered cases, + and then find the current bucket via index/page number to get the geohash + And then we filter cases simply for the geohash corresponding to the bucket + """ + buckets = self._get_buckets() + if not buckets: + return [] + + # self.pagination.start is the page number + # (self.pagination.count is always treated as 1) + bucket = buckets[self.pagination.start] + # Example bucket: + # { + # 'key': 't0' + # 'doc_count': 1, + # 'bucket_cases': { + # 'bounds': { + # 'bottom_right': { + # 'lat': 4.912349972873926, + # 'lon': 52.374080987647176, + # }, + # 'top_left': { + # 'lat': 4.912349972873926, + # 'lon': 52.374080987647176, + # }, + # }, + # }, + # } + bounds = bucket[BUCKET_CASES_AGG]['bounds'] + + # If there is only one case in the bucket, then the bounds will + # be the geo_point of that case, and top_left and bottom_right + # will be the same. + # + # If they are the same, then the geo_bounding_box filter will + # error in Elasticsearch 5.6. (This is not a problem in + # Elasticsearch 8+, where we can use the bucket key, which is + # its geohash, to select the cases in the bucket.) + # + # So we shift the top left and bottom right by 0.000_01 degrees, + # or roughly 1 metre. + if bounds['top_left']['lat'] == bounds['bottom_right']['lat']: + bounds['top_left']['lat'] += 0.000_01 + bounds['bottom_right']['lat'] -= 0.000_01 + if bounds['top_left']['lon'] == bounds['bottom_right']['lon']: + bounds['top_left']['lon'] -= 0.000_01 + bounds['bottom_right']['lon'] += 0.000_01 + + query = super()._build_query() # `super()` so as not to filter + filters_ = [filters.geo_bounding_box( + field=PROPERTY_GEOPOINT_VALUE, + top_left=bounds['top_left'], + bottom_right=bounds['bottom_right'], + )] + if self.request.GET.get('features'): + features_filter = self._get_filter_for_features( + self.request.GET['features'] + ) + filters_.append(features_filter) + query = self._filter_query(query, filters_) + es_results = query.run().raw + + cases = [] + for row in es_results['hits'].get('hits', []): + display = CaseDisplayES( + self.get_case(row), self.timezone, self.individual ) + case = wrap_case_search_hit(row) + coordinates = self._get_geo_location(case) + cases.append([ + display.case_id, + coordinates, + display.case_link + ]) + return cases - # Apply geohash grid aggregation + @quickcache(['self.domain', 'self.shared_pagination_GET_params'], timeout=15 * 60) + def _get_buckets(self): + query = self._build_query() + query = self._aggregate_query(query) + es_results = query.run().raw + if es_results is None: + return [] + return ( + es_results['aggregations'] + [CASE_PROPERTIES_AGG] + [CASE_PROPERTY_AGG] + [GEOHASHES_AGG] + ['buckets'] + ) + + def _aggregate_query(self, query): + """ + Returns ``query`` with geohash grid aggregation applied. + """ + case_property = get_geo_case_property(self.domain) if 'precision' in self.request.GET: precision = self.request.GET['precision'] else: precision = find_precision(query, case_property) + return apply_geohash_agg(query, case_property, precision) - query = apply_geohash_agg(query, case_property, precision) - return query + @property + def total_records(self): + """ + Returns the number of buckets. + We are showing buckets of cases so, + total number of records = number of buckets. + """ + buckets = self._get_buckets() + return len(buckets) -def geojson_to_es_geoshape(geojson): - """ - Given a GeoJSON dict, returns a GeoJSON Geometry dict, with "type" - given as an Elasticsearch type (i.e. in lowercase). - - More info: - - * `The GeoJSON specification (RFC 7946) <https://datatracker.ietf.org/doc/html/rfc7946>`_ - * `Elasticsearch types <https://www.elastic.co/guide/en/elasticsearch/reference/5.6/geo-shape.html#input-structure>`_ - - """ # noqa: E501 - supported_types = ( - 'Point', - 'LineString', - 'Polygon', # We expect this, but we get the others for free - 'MultiPoint', - 'MultiLineString', - 'MultiPolygon', - # GeometryCollection is not supported - ) - assert geojson['geometry']['type'] in supported_types, \ - f"{geojson['geometry']['type']} is not a supported geometry type" - return { - 'type': geojson['geometry']['type'].lower(), - 'coordinates': geojson['geometry']['coordinates'], - } + def _build_query(self): + """ + Returns a filtered, unaggregated, unpaginated ESQuery. + """ + query = super()._build_query() + if self.request.GET.get('features'): + features_filter = self._get_filter_for_features( + self.request.GET['features'] + ) + query = self._filter_query(query, [features_filter]) + return query + + @staticmethod + def _get_filter_for_features(features_json): + """ + Returns an Elasticsearch filter to select for cases within the + polygons defined by GeoJSON ``features_json``. + + Raises ValueError on invalid ``features_json``. + + Example value of features:: + + { + "1fe8e9a47059aa0d24d3bb518dd32cec": { + "id": "1fe8e9a47059aa0d24d3bb518dd32cec", + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ /* exterior ring */ + [ + 1.7739302693154002, + 6.30270638391498 + ], + /* At least three more points, given + counterclockwise. The last point will equal the + first. */ + ], + /* interior rings / holes. Points are given + clockwise. */ + ] + } + }, + "e732a9da883ad59534ff7b6284eeff4a": { + "id": "e732a9da883ad59534ff7b6284eeff4a", + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 0.7089909368813494, + 7.1851152290118705 + ], + /* ... */ + ] + ] + } + } + } + + """ + try: + features = json.loads(features_json) + except json.JSONDecodeError: + raise ValueError(f'{features_json!r} parameter is not valid JSON') + polygon_filters = [] + for feature in features.values(): + validate_geometry(feature['geometry']) + polygon = geojson_to_es_geoshape(feature) + # The first list of coordinates is the exterior ring, and + # the rest are interior rings, i.e. holes. + # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6 + exterior_coordinates = polygon['coordinates'][0] + exterior_filter = filters.geo_polygon( + field=PROPERTY_GEOPOINT_VALUE, + points=exterior_coordinates, + ) + if len(polygon['coordinates']) > 1: + # Use AND NOT to exclude holes from the polygon. (Using + # the geo_shape filter in Elasticsearch 8+, this should + # be unnecessary.) + interior_filters = [] + for interior_coordinates in polygon['coordinates'][1:]: + hole = filters.geo_polygon( + field=PROPERTY_GEOPOINT_VALUE, + points=interior_coordinates, + ) + interior_filters.append(filters.NOT(hole)) + polygon_filters.append(filters.AND( + exterior_filter, + *interior_filters + )) + else: + polygon_filters.append(exterior_filter) + return filters.OR(*polygon_filters) + + def _filter_query(self, query, filters_): + """ + Prepends the geo case property name filter to a list of + ``filters_``, and filters ``query`` by ``filters_``. + """ + if filters_: + case_property = get_geo_case_property(self.domain) + filters_.insert(0, filters.term( + field=PROPERTY_KEY, + value=case_property + )) + query = query.nested( + path=CASE_PROPERTIES_PATH, + filter_=filters.AND(*filters_) + ) + return query diff --git a/corehq/apps/geospatial/routing_solvers/base.py b/corehq/apps/geospatial/routing_solvers/base.py new file mode 100644 index 0000000000000..f0aa2e3718b9d --- /dev/null +++ b/corehq/apps/geospatial/routing_solvers/base.py @@ -0,0 +1,14 @@ + +class DisbursementAlgorithmSolverInterface: + + def __init__(self, request_json): + self.request_json = request_json + + def solve(self): + """ + The solve method implementation should return either the results if it's readily + available or a poll_id which can be used to poll for the results. + If the results are available the poll_id is expected to be None and vice versa. + :returns: a tuple formatted as (<poll_id>, <results>) + """ + raise NotImplementedError() diff --git a/corehq/apps/geospatial/routing_solvers/mapbox_optimize.py b/corehq/apps/geospatial/routing_solvers/mapbox_optimize.py index 2563cd6747e5e..ead7cbeecc67b 100644 --- a/corehq/apps/geospatial/routing_solvers/mapbox_optimize.py +++ b/corehq/apps/geospatial/routing_solvers/mapbox_optimize.py @@ -2,6 +2,7 @@ import jsonschema import requests from django.conf import settings +from corehq.apps.geospatial.routing_solvers.base import DisbursementAlgorithmSolverInterface def validate_routing_request(request_json): @@ -127,3 +128,9 @@ def routing_status(poll_id): location_ids.pop(-1) mapping_dict[user_id] = location_ids return mapping_dict + + +class MapboxVRPSolver(DisbursementAlgorithmSolverInterface): + + def solve(self): + return submit_routing_request(self.request_json), None diff --git a/corehq/apps/geospatial/routing_solvers/ortools.py b/corehq/apps/geospatial/routing_solvers/pulp.py similarity index 51% rename from corehq/apps/geospatial/routing_solvers/ortools.py rename to corehq/apps/geospatial/routing_solvers/pulp.py index eef4a7eeb29b5..d75c1a31acb22 100644 --- a/corehq/apps/geospatial/routing_solvers/ortools.py +++ b/corehq/apps/geospatial/routing_solvers/pulp.py @@ -1,82 +1,76 @@ import haversine import requests +import pulp -from ortools.linear_solver import pywraplp from django.conf import settings from .mapbox_optimize import validate_routing_request +from corehq.apps.geospatial.routing_solvers.base import DisbursementAlgorithmSolverInterface -class ORToolsRadialDistanceSolver: +class RadialDistanceSolver(DisbursementAlgorithmSolverInterface): """ Solves user-case location assignment based on radial distance """ - def __init__(self, request_json, max_route_distance): + def __init__(self, request_json): + super().__init__(request_json) + validate_routing_request(request_json) self.user_locations = request_json['users'] self.case_locations = request_json['cases'] def calculate_distance_matrix(self): - users = [ - (float(user['lat']), float(user['lon'])) - for user in self.user_locations - ] - cases = [ - (float(case['lat']), float(case['lon'])) - for case in self.case_locations - ] - return haversine.haversine_vector(cases, users, comb=True) + distance_matrix = [] + for user in self.user_locations: + user_point = (float(user['lat']), float(user['lon'])) + user_distances = [] + for case in self.case_locations: + case_point = (float(case['lat']), float(case['lon'])) + user_distances.append(haversine.haversine(case_point, user_point)) + + distance_matrix.append(user_distances) + + return distance_matrix def solve(self, print_solution=False): - # Modelled after https://developers.google.com/optimization/assignment/assignment_teams costs = self.calculate_distance_matrix() user_count = len(costs) case_count = len(costs[0]) - # Solver - # Create the mip solver with the SCIP backend. - solver = pywraplp.Solver.CreateSolver("SCIP") + # Create a linear programming problem + problem = pulp.LpProblem("assign_user_cases", pulp.LpMinimize) - if not solver: - return - - # Variables - # x[i, j] is an array of 0-1 variables, which will be 1 - # if user i is assigned to case j. + # Define decision variables x = {} for i in range(user_count): for j in range(case_count): - x[i, j] = solver.IntVar(0, 1, "") + x[i, j] = pulp.LpVariable(f"x_{i}_{j}", 0, 1, pulp.LpBinary) - # Constraints - # Each user is assigned to at most case_count/user_count + # Add constraints for i in range(user_count): - solver.Add(solver.Sum([x[i, j] for j in range(case_count)]) <= int(case_count / user_count) + 1) + problem += pulp.lpSum([x[i, j] for j in range(case_count)]) <= int(case_count / user_count) + 1 - # Each case is assigned to exactly one user. for j in range(case_count): - solver.Add(solver.Sum([x[i, j] for i in range(user_count)]) == 1) + problem += pulp.lpSum([x[i, j] for i in range(user_count)]) == 1 - # Objective - objective_terms = [] - for i in range(user_count): - for j in range(case_count): - objective_terms.append(costs[i][j] * x[i, j]) - solver.Minimize(solver.Sum(objective_terms)) + # Define the objective function + objective_terms = [costs[i][j] * x[i, j] for i in range(user_count) for j in range(case_count)] + problem += pulp.lpSum(objective_terms) + + # Solve the problem + problem.solve() - # Solve - status = solver.Solve() solution = {loc['id']: [] for loc in self.user_locations} - # Print solution. - if status == pywraplp.Solver.OPTIMAL or status == pywraplp.Solver.FEASIBLE: + + # Process the solution + if pulp.LpStatus[problem.status] == "Optimal": if print_solution: - print(f"Total cost = {solver.Objective().Value()}\n") + print(f"Total cost = {pulp.value(problem.objective)}\n") for i in range(user_count): for j in range(case_count): - # Test if x[i,j] is 1 (with tolerance for floating point arithmetic). - if x[i, j].solution_value() > 0.5: + if pulp.value(x[i, j]) > 0.5: solution[self.user_locations[i]['id']].append(self.case_locations[j]['id']) if print_solution: print(f"Case {self.case_locations[j]['id']} assigned to " @@ -85,10 +79,10 @@ def solve(self, print_solution=False): else: if print_solution: print("No solution found.") - return solution + return None, solution -class ORToolsRoadNetworkSolver(ORToolsRadialDistanceSolver): +class RoadNetworkSolver(RadialDistanceSolver): """ Solves user-case location assignment based on driving distance """ @@ -102,10 +96,16 @@ def calculate_distance_matrix(self): f'{loc["lon"]},{loc["lat"]}' for loc in self.user_locations + self.case_locations] ) - sources = ";".join(map(str, list(range(len(self.user_locations))))) + sources_count = len(self.user_locations) + destinations_count = len(self.case_locations) + + sources = ";".join(map(str, list(range(sources_count)))) + destinations = ";".join(map(str, list(range(sources_count, sources_count + destinations_count)))) - url = f'https://api.mapbox.com/directions-matrix/v1/mapbox/driving/{coordinates}&{sources}' + url = f'https://api.mapbox.com/directions-matrix/v1/mapbox/driving/{coordinates}' params = { + 'sources': sources, + 'destinations': destinations, 'annotations': 'distance', 'access_token': settings.MAPBOX_ACCESS_TOKEN } diff --git a/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js b/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js index 4425c31298ce5..eacd28941a425 100644 --- a/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +++ b/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js @@ -3,28 +3,41 @@ hqDefine("geospatial/js/case_grouping_map",[ "knockout", 'underscore', 'hqwebapp/js/initial_page_data', + 'hqwebapp/js/bootstrap3/alert_user', + 'geospatial/js/models', + 'geospatial/js/utils' ], function ( $, ko, _, - initialPageData + initialPageData, + alertUser, + models, + utils ) { - const MAP_CONTAINER_ID = 'case-grouping-map'; - let map; - const clusterStatsInstance = new clusterStatsModel(); - function caseModel(caseId, coordiantes, caseLink) { - 'use strict'; - var self = {}; - self.caseId = caseId; - self.coordinates = coordiantes; - self.caseLink = caseLink; + const MAPBOX_LAYER_VISIBILITY = { + None: 'none', + Visible: 'visible', + }; + const DEFAULT_MARKER_OPACITY = 1.0; + const OBSCURING_OPACITY = 0.2; + const DEFAULT_GROUP_ID = "unassigned-group-id"; + const DEFAULT_GROUP = { + groupId: DEFAULT_GROUP_ID, + name: gettext("No group"), + color: `rgba(128,128,128,${OBSCURING_OPACITY})`, + } - // TODO: Group ID needs to be set - self.groupId = null; + const MAP_CONTAINER_ID = 'case-grouping-map'; + const clusterStatsInstance = new clusterStatsModel(); + let exportModelInstance; + let groupLockModelInstance = new groupLockModel(); + let caseGroupsInstance = new caseGroupSelectModel(); + let mapMarkers = []; - return self; - } + let mapModel; + let polygonFilterInstance; function clusterStatsModel() { 'use strict'; @@ -44,14 +57,18 @@ hqDefine("geospatial/js/case_grouping_map",[ if (!self.casesToExport().length) { return; } + // Only cases with belonging to groups should be exported + let exportableCases = self.casesToExport().filter(function(caseItem) { + return caseItem.groupId !== DEFAULT_GROUP_ID; + }); - const casesToExport = _.map(self.casesToExport(), function (caseItem) { - const coordinates = (caseItem.coordinates) ? `${caseItem.coordinates.lng} ${caseItem.coordinates.lat}` : ""; - return { - 'groupId': caseItem.groupId, - 'caseId': caseItem.caseId, - 'coordinates': coordinates, - }; + if (!exportableCases.length) { + // If no case belongs to a group, we export all cases + exportableCases = self.casesToExport(); + } + + const casesToExport = _.map(exportableCases, function (caseItem) { + return caseItem.toJson(); }); let csvStr = ""; @@ -69,102 +86,52 @@ hqDefine("geospatial/js/case_grouping_map",[ const hiddenElement = document.createElement('a'); hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csvStr); hiddenElement.target = '_blank'; - hiddenElement.download = `Grouped Cases (${getTodayDate()}).csv`; + hiddenElement.download = `Grouped Cases (${utils.getTodayDate()}).csv`; hiddenElement.click(); hiddenElement.remove(); }; - return self; - } - - function getTodayDate() { - const todayDate = new Date(); - return todayDate.toLocaleDateString(); - } - - function initMap() { - 'use strict'; + self.addGroupDataToCases = function(caseGroups, groupsData, assignDefaultGroup) { + const defaultGroup = groupsData.find((group) => {return group.groupId === DEFAULT_GROUP_ID}); + self.casesToExport().forEach(caseItem => { + const groupId = caseGroups[caseItem.itemId]; + if (groupId !== undefined) { + const group = groupsData.find((group) => {return group.groupId === groupId}); + self.setItemGroup(caseItem, groupId, group.coordinates); + } else if (assignDefaultGroup) { + self.setItemGroup(caseItem, defaultGroup.groupId, {}); + } + }); + } - mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef - const centerCoordinates = [2.43333330, 9.750]; + self.setItemGroup = function(item, groupId, groupCoordinates) { + item.groupId = groupId; + item.groupCoordinates = groupCoordinates; + } - const mapboxInstance = new mapboxgl.Map({ // eslint-disable-line no-undef - container: MAP_CONTAINER_ID, // container ID - style: 'mapbox://styles/mapbox/streets-v12', // style URL - center: centerCoordinates, // starting position [lng, lat] - zoom: 6, - attribution: '© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> ©' + - ' <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', - }); + self.updateCaseGroup = function(itemId, groupData) { + var item = self.casesToExport().find((caseItem) => {return caseItem.itemId == itemId}); + self.setItemGroup(item, groupData.groupId, groupData.coordinates); + } - mapboxInstance.on('load', () => { - map.addSource('caseWithGPS', { - type: 'geojson', - data: { - "type": "FeatureCollection", - "features": [], - }, - cluster: true, - clusterMaxZoom: 14, // Max zoom to cluster points on - clusterRadius: 50, // Radius of each cluster when clustering points (defaults to 50) - }); - map.addLayer({ - id: 'clusters', - type: 'circle', - source: 'caseWithGPS', - filter: ['has', 'point_count'], - paint: { - 'circle-color': [ - 'step', - ['get', 'point_count'], - '#51bbd6', - 100, - '#f1f075', - 750, - '#f28cb1', - ], - 'circle-radius': [ - 'step', - ['get', 'point_count'], - 20, - 100, - 30, - 750, - 40, - ], - }, + self.clearCaseGroups = function() { + self.casesToExport().forEach(caseItem => { + if (caseItem.groupId) { + caseItem.groupId = null; + caseItem.groupCoordinates = null; + } }); - map.addLayer({ - id: 'cluster-count', - type: 'symbol', - source: 'caseWithGPS', - filter: ['has', 'point_count'], - layout: { - 'text-field': ['get', 'point_count_abbreviated'], - 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], - 'text-size': 12, - }, - }); - map.addLayer({ - id: 'unclustered-point', - type: 'circle', - source: 'caseWithGPS', - filter: ['!', ['has', 'point_count']], - paint: { - 'circle-color': 'red', - 'circle-radius': 10, - 'circle-stroke-width': 1, - 'circle-stroke-color': '#fff', - }, - }); - }); - mapboxInstance.on('moveend', updateClusterStats); + } - return mapboxInstance; + self.groupsReady = function() { + return groupLockModelInstance.groupsLocked(); + } + + return self; } function updateClusterStats() { - const sourceFeatures = map.querySourceFeatures('caseWithGPS', { + const sourceFeatures = mapModel.mapInstance.querySourceFeatures('caseWithGPS', { sourceLayer: 'clusters', filter: ['==', 'cluster', true], }); @@ -207,13 +174,13 @@ hqDefine("geospatial/js/case_grouping_map",[ }; _.each(caseList, function (caseWithGPS) { - const coordinates = caseWithGPS.coordinates; + const coordinates = caseWithGPS.itemData.coordinates; if (coordinates && coordinates.lat && coordinates.lng) { caseLocationsGeoJson["features"].push( { "type": "feature", "properties": { - "id": caseWithGPS.caseId, + "id": caseWithGPS.itemId, }, "geometry": { "type": "Point", @@ -224,18 +191,320 @@ hqDefine("geospatial/js/case_grouping_map",[ } }); - if (map.getSource('caseWithGPS')) { - map.getSource('caseWithGPS').setData(caseLocationsGeoJson); + if (mapModel.mapInstance.getSource('caseWithGPS')) { + mapModel.mapInstance.getSource('caseWithGPS').setData(caseLocationsGeoJson); } else { - map.on('load', () => { - map.getSource('caseWithGPS').setData(caseLocationsGeoJson); + mapModel.mapInstance.on('load', () => { + mapModel.mapInstance.getSource('caseWithGPS').setData(caseLocationsGeoJson); + }); + } + } + + function getClusterLeavesAsync(clusterSource, clusterId, pointCount) { + return new Promise((resolve, reject) => { + clusterSource.getClusterLeaves(clusterId, pointCount, 0, (error, casePoints) => { + if (error) { + reject(error); + } else { + resolve(casePoints); + } + }); + }); + } + + function setMapLayersVisibility(visibility) { + mapModel.mapInstance.setLayoutProperty('clusters', 'visibility', visibility); + mapModel.mapInstance.setLayoutProperty('cluster-count', 'visibility', visibility); + mapModel.mapInstance.setLayoutProperty('unclustered-point', 'visibility', visibility); + } + + function mapMarkerModel(itemId, itemData, marker, markerColors) { + 'use strict'; + var self = {}; + self.title = gettext("Select group"); + self.itemId = itemId; + self.itemData = itemData; + self.marker = marker; + self.selectCssId = "select" + itemId; + self.isSelected = ko.observable(false); + self.markerColors = markerColors; + + self.groupsOptions = ko.observable(caseGroupsInstance.generatedGroups); + self.selectedGroup = ko.observable(itemData.groupId); + + self.updateGroup = ko.computed(function () { + if (!self.itemId) { + return; + } + caseGroupsInstance.updateCaseGroup(self.itemId, self.selectedGroup()); + const newGroup = caseGroupsInstance.getGroupByID(self.selectedGroup()); + if (newGroup) { + changeMarkerColor(self, newGroup.color); + exportModelInstance.updateCaseGroup(self.itemId, newGroup); + } + }); + + function changeMarkerColor(selectedCase, newColor) { + let marker = selectedCase.marker; + let element = marker.getElement(); + let svg = element.getElementsByTagName("svg")[0]; + let path = svg.getElementsByTagName("path")[0]; + path.setAttribute("fill", newColor); + } + + return self; + } + + function revealGroupsOnMap() { + setMapLayersVisibility(MAPBOX_LAYER_VISIBILITY.None); + mapMarkers.forEach((marker) => marker.remove()); + mapMarkers = []; + exportModelInstance.casesToExport().forEach(function (caseItem) { + const coordinates = caseItem.itemData.coordinates; + if (!coordinates) { + return; + } + const caseGroupID = caseItem.groupId; + if (caseGroupsInstance.groupIDInVisibleGroupIds(caseGroupID)) { + let caseGroup = caseGroupsInstance.getGroupByID(caseGroupID); + color = caseGroup.color; + const marker = new mapboxgl.Marker({ color: color, draggable: false }); // eslint-disable-line no-undef + marker.setLngLat([coordinates.lng, coordinates.lat]); + + // Add the marker to the map + marker.addTo(mapModel.mapInstance); + mapMarkers.push(marker); + + let popupDiv = document.createElement("div"); + popupDiv.setAttribute("data-bind", "template: 'select-case'"); + + let popup = new mapboxgl.Popup({ offset: 25, anchor: "bottom" }) // eslint-disable-line no-undef + .setLngLat(coordinates) + .setDOMContent(popupDiv); + + marker.setPopup(popup); + + const markerDiv = marker.getElement(); + // Show popup on hover + markerDiv.addEventListener('mouseenter', marker.togglePopup); + + // Hide popup if mouse leaves marker and popup + var addLeaveEvent = function (fromDiv, toDiv) { + fromDiv.addEventListener('mouseleave', function () { + setTimeout(function () { + if (!$(toDiv).is(':hover')) { + // mouse left toDiv as well + marker.togglePopup(); + } + }, 100); + }); + }; + addLeaveEvent(markerDiv, popupDiv); + addLeaveEvent(popupDiv, markerDiv); + const colors = {default: color, selected: color}; + + const mapMarkerInstance = new mapMarkerModel(caseItem.itemId, caseItem, marker, colors); + $(popupDiv).koApplyBindings(mapMarkerInstance); + } + }); + } + + function caseGroupSelectModel() { + 'use strict'; + var self = {}; + + self.groupsByCase; + // generatedGroups and caseGroupsForTable contains the same data, but there's weird knockoutjs behaviour + // if we're making generatedGroups an observable. caseGroupsForTable is populated by setCaseGroupsForTable + self.generatedGroups = []; + self.caseGroupsForTable = ko.observableArray([]); + self.visibleGroupIDs = ko.observableArray([]); + self.casePerGroup = {}; + + self.groupIDInVisibleGroupIds = function(groupID) { + return self.visibleGroupIDs().indexOf(groupID) !== -1; + }; + + self.getGroupByID = function(groupID) { + return self.generatedGroups.find((group) => group.groupId === groupID); + }; + + self.updateCaseGroup = function(itemId, newGroupId) { + self.groupsByCase[itemId] = newGroupId; + }; + + self.loadCaseGroups = function(caseGroups, groups) { + self.groupsByCase = caseGroups; + self.generatedGroups = groups; + + self.showAllGroups(); + }; + + self.clear = function() { + self.generatedGroups = []; + self.caseGroupsForTable([]); + self.visibleGroupIDs([]); + }; + + self.restoreMarkerOpacity = function() { + mapMarkers.forEach(function(marker) { + setMarkerOpacity(marker, DEFAULT_MARKER_OPACITY); + }); + }; + + self.highlightGroup = function(group) { + exportModelInstance.casesToExport().forEach(caseItem => { + let caseIsInGroup = caseItem.groupId === group.groupId; + let opacity = DEFAULT_MARKER_OPACITY + if (!caseIsInGroup) { + opacity = OBSCURING_OPACITY; + } + let marker = mapMarkers.find((marker) => { + let markerCoordinates = marker.getLngLat(); + let caseCoordinates = caseItem.itemData.coordinates; + let latEqual = markerCoordinates.lat === caseCoordinates.lat; + let lonEqual = markerCoordinates.lng === caseCoordinates.lng; + return latEqual && lonEqual; + }); + if (marker) { + setMarkerOpacity(marker, opacity); + } + }); + }; + + function setMarkerOpacity(marker, opacity) { + let element = marker.getElement(); + element.style.opacity = opacity; + }; + + self.showSelectedGroups = function() { + if (!self.groupsByCase) { + return; + } + + let filteredCaseGroups = {}; + for (const caseID in self.groupsByCase) { + if (self.groupIDInVisibleGroupIds(self.groupsByCase[caseID])) { + filteredCaseGroups[caseID] = self.groupsByCase[caseID]; + } + } + exportModelInstance.addGroupDataToCases(filteredCaseGroups, self.generatedGroups); + revealGroupsOnMap(); + }; + + self.showAllGroups = function() { + if (!self.groupsByCase) { + return; + } + self.visibleGroupIDs(_.map(self.generatedGroups, function(group) {return group.groupId})); + revealGroupsOnMap(); + self.setCaseGroupsForTable(); + }; + + self.setCaseGroupsForTable = function() { + self.caseGroupsForTable(self.generatedGroups); + } + + self.groupsReady = function() { + return groupLockModelInstance.groupsLocked() && self.caseGroupsForTable().length; + }; + + return self; + } + + async function setCaseGroups() { + const sourceFeatures = mapModel.mapInstance.querySourceFeatures('caseWithGPS', { + sourceLayer: 'clusters', + filter: ['==', 'cluster', true], + }); + const clusterSource = mapModel.mapInstance.getSource('caseWithGPS'); + let caseGroups = {}; + let failedClustersCount = 0; + processedCluster = {}; + + var groupCount = 1; + var groups = [DEFAULT_GROUP]; + + for (const cluster of sourceFeatures) { + const clusterId = cluster.properties.cluster_id; + if (processedCluster[clusterId] === undefined) { + processedCluster[clusterId] = true; + } + else { + continue; + } + + const pointCount = cluster.properties.point_count; + + try { + const casePoints = await getClusterLeavesAsync(clusterSource, clusterId, pointCount); + const groupUUID = utils.uuidv4(); + + if (casePoints.length) { + groupName = _.template(gettext("Group <%- groupCount %>"))({ + groupCount: groupCount, + }); + groupCount += 1; + + groups.push({ + name: groupName, + groupId: groupUUID, + color: utils.getRandomRGBColor(), + coordinates: { + lng: cluster.geometry.coordinates[0], + lat: cluster.geometry.coordinates[1], + } + }); + for (const casePoint of casePoints) { + const caseId = casePoint.properties.id; + caseGroups[caseId] = groupUUID; + } + } + } catch (error) { + failedClustersCount += 1; + } + } + if (failedClustersCount > 0) { + const message = _.template(gettext("Something went wrong processing <%- failedClusters %> groups. These groups will not be exported."))({ + failedClusters: failedClustersCount, }); + alertUser.alert_user(message, 'danger'); } + exportModelInstance.addGroupDataToCases(caseGroups, groups, true); + caseGroupsInstance.loadCaseGroups(caseGroups, groups); + } + + function clearCaseGroups() { + setMapLayersVisibility(MAPBOX_LAYER_VISIBILITY.Visible); + mapMarkers.forEach((marker) => marker.remove()); + mapMarkers = []; + exportModelInstance.clearCaseGroups(); + caseGroupsInstance.clear(); + } + + function groupLockModel() { + 'use strict'; + var self = {}; + + self.groupsLocked = ko.observable(false); + + self.toggleGroupLock = function () { + // reset the warning banner + self.groupsLocked(!self.groupsLocked()); + if (self.groupsLocked()) { + mapModel.mapInstance.scrollZoom.disable(); + setCaseGroups(); + } else { + mapModel.mapInstance.scrollZoom.enable(); + clearCaseGroups(); + } + }; + return self; } $(function () { let caseModels = []; - const exportModelInstance = new exportModel(); + exportModelInstance = new exportModel(); // Parses a case row (which is an array of column values) to an object, using caseRowOrder as the order of the columns function parseCaseItem(caseItem, caseRowOrder) { @@ -252,18 +521,43 @@ hqDefine("geospatial/js/case_grouping_map",[ const caseRowOrder = initialPageData.get('case_row_order'); for (const caseItem of rawCaseData) { const caseObj = parseCaseItem(caseItem, caseRowOrder); - const caseModelInstance = new caseModel(caseObj.case_id, caseObj.gps_point, caseObj.link); + const caseModelInstance = new models.GroupedCaseMapItem(caseObj.case_id, {coordinates: caseObj.gps_point}, caseObj.link); caseModels.push(caseModelInstance); } + mapModel.caseMapItems(caseModels); exportModelInstance.casesToExport(caseModels); + + mapModel.fitMapBounds(caseModels); + } + + function initMap() { + mapModel = new models.Map(true); + mapModel.initMap(MAP_CONTAINER_ID); + + mapModel.mapInstance.on('moveend', updateClusterStats); + mapModel.mapInstance.on("draw.update", (e) => { + polygonFilterInstance.addPolygonsToFilterList(e.features); + }); + mapModel.mapInstance.on('draw.delete', function (e) { + polygonFilterInstance.removePolygonsFromFilterList(e.features); + }); + mapModel.mapInstance.on('draw.create', function (e) { + polygonFilterInstance.addPolygonsToFilterList(e.features); + }); } $(document).ajaxComplete(function (event, xhr, settings) { const isAfterReportLoad = settings.url.includes('geospatial/async/case_grouping_map/'); if (isAfterReportLoad) { $("#export-controls").koApplyBindings(exportModelInstance); - map = initMap(); + $("#lock-groups-controls").koApplyBindings(groupLockModelInstance); + initMap(); $("#clusterStats").koApplyBindings(clusterStatsInstance); + polygonFilterInstance = new models.PolygonFilter(mapModel, true, false); + polygonFilterInstance.loadPolygons(initialPageData.get('saved_polygons')); + $("#polygon-filters").koApplyBindings(polygonFilterInstance); + + $("#caseGroupSelect").koApplyBindings(caseGroupsInstance); return; } @@ -282,4 +576,4 @@ hqDefine("geospatial/js/case_grouping_map",[ } }); }); -}); \ No newline at end of file +}); diff --git a/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js b/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js index efab0ea8a15f6..7d918e85568e0 100644 --- a/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +++ b/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js @@ -2,11 +2,13 @@ hqDefine("geospatial/js/geospatial_map", [ "jquery", "hqwebapp/js/initial_page_data", "knockout", + 'geospatial/js/models', 'select2/dist/js/select2.full.min', ], function ( $, initialPageData, - ko + ko, + models ) { const caseMarkerColors = { 'default': "#808080", // Gray @@ -16,33 +18,17 @@ hqDefine("geospatial/js/geospatial_map", [ 'default': "#0e00ff", // Blue 'selected': "#0b940d", // Dark Green }; + const DEFAULT_POLL_TIME_MS = 1500; + + const MAP_CONTAINER_ID = 'geospatial-map'; var saveGeoJSONUrl = initialPageData.reverse('geo_polygon'); + var runDisbursementUrl = initialPageData.reverse('case_disbursement'); + var disbursementRunner; - function mapItemModel(itemId, itemData, marker, markerColors) { - 'use strict'; - var self = {}; - self.itemId = itemId; - self.itemData = itemData; - self.marker = marker; - self.selectCssId = "select" + itemId; - self.isSelected = ko.observable(false); - self.markerColors = markerColors; - - function changeMarkerColor(selectedCase, newColor) { - let marker = selectedCase.marker; - let element = marker.getElement(); - let svg = element.getElementsByTagName("svg")[0]; - let path = svg.getElementsByTagName("path")[0]; - path.setAttribute("fill", newColor); - } - - self.isSelected.subscribe(function () { - var color = self.isSelected() ? self.markerColors.selected : self.markerColors.default; - changeMarkerColor(self, color); - }); - return self; - } + var mapModel; + var polygonFilterModel; + var missingGPSModelInstance; function showMapControls(state) { $("#geospatial-map").toggle(state); @@ -51,522 +37,440 @@ hqDefine("geospatial/js/geospatial_map", [ $("#user-filters-panel").toggle(state); } - $(function () { - // Global var - var map; - - var caseModels = ko.observableArray([]); - var userModels = ko.observableArray([]); - var selectedCases = ko.computed(function () { - return caseModels().filter(function (currCase) { - return currCase.isSelected(); - }); - }); - var selectedUsers = ko.computed(function () { - return userModels().filter(function (currUser) { - return currUser.isSelected(); - }); - }); - - function filterMapItemsInPolygon(polygonFeature) { - _.values(caseModels()).filter(function (currCase) { - if (currCase.itemData.coordinates) { - currCase.isSelected(isMapItemInPolygon(polygonFeature, currCase.itemData.coordinates)); - } - }); - _.values(userModels()).filter(function (currUser) { - if (currUser.itemData.coordinates) { - currUser.isSelected(isMapItemInPolygon(polygonFeature, currUser.itemData.coordinates)); - } + var saveGeoJson = function () { + const data = mapModel.drawControls.getAll(); + if (data.features.length) { + let name = window.prompt(gettext("Name of the Area")); + data['name'] = name; + + $.ajax({ + type: 'post', + url: saveGeoJSONUrl, + dataType: 'json', + data: JSON.stringify({'geo_json': data}), + contentType: "application/json; charset=utf-8", + success: function (ret) { + delete data.name; + // delete drawn area + mapModel.drawControls.deleteAll(); + console.log('newPoly', name); + polygonFilterModel.savedPolygons.push( + new models.SavedPolygon({ + name: name, + id: ret.id, + geo_json: data, + }) + ); + // redraw using mapControlsModelInstance + polygonFilterModel.selectedSavedPolygonId(ret.id); + }, }); } + }; - function isMapItemInPolygon(polygonFeature, coordinates) { - // Will be 0 if a user deletes a point from a three-point polygon, - // since mapbox will delete the entire polygon. turf.booleanPointInPolygon() - // does not expect this, and will raise a 'TypeError' exception. - if (!polygonFeature.geometry.coordinates.length) { - return false; - } - const coordinatesArr = [coordinates.lng, coordinates.lat]; - const point = turf.point(coordinatesArr); // eslint-disable-line no-undef - return turf.booleanPointInPolygon(point, polygonFeature.geometry); // eslint-disable-line no-undef - } + var disbursementRunnerModel = function () { + var self = {}; - var loadMapBox = function (centerCoordinates) { - 'use strict'; + self.pollUrl = ko.observable(''); + self.isBusy = ko.observable(false); - var self = {}; - let clickedMarker; - mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef + self.hasMissingData = ko.observable(false); // True if the user attemps disbursement with polygon filtering that includes no cases/users. - if (!centerCoordinates) { - centerCoordinates = [-91.874, 42.76]; // should be domain specific + self.setBusy = function (isBusy) { + self.isBusy(isBusy); + $("#hq-content *").prop("disabled", isBusy); + if (isBusy) { + $("#btnRunDisbursement").addClass('disabled'); + } else { + $("#btnRunDisbursement").removeClass('disabled'); } + }; - const map = new mapboxgl.Map({ // eslint-disable-line no-undef - container: 'geospatial-map', // container ID - style: 'mapbox://styles/mapbox/streets-v12', // style URL - center: centerCoordinates, // starting position [lng, lat] - zoom: 12, - attribution: '© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> ©' + - ' <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', - }); - - const draw = new MapboxDraw({ // eslint-disable-line no-undef - // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md - displayControlsDefault: false, - boxSelect: true, // enables box selection - controls: { - polygon: true, - trash: true, - }, - }); - - map.addControl(draw); - - map.on("draw.update", function (e) { - var selectedFeatures = e.features; - - // Check if any features are selected - if (!selectedFeatures.length) { - return; - } - var selectedFeature = selectedFeatures[0]; - - if (selectedFeature.geometry.type === 'Polygon') { - filterMapItemsInPolygon(selectedFeature); - } - }); - - map.on('draw.selectionchange', function (e) { - // See https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md#drawselectionchange - var selectedFeatures = e.features; - if (!selectedFeatures.length) { - return; - } - - // Check if any features are selected - var selectedFeature = selectedFeatures[0]; - // Update this logic if we need to support case filtering by selecting multiple polygons - - if (selectedFeature.geometry.type === 'Polygon') { - // Now that we know we selected a polygon, we need to check which markers are inside - filterMapItemsInPolygon(selectedFeature); - } + self.handleDisbursementResults = function (result) { + // Clean stale disbursement results + mapModel.removeDisbursementLayers(); + + var groupId = 0; + Object.keys(result).forEach((userId) => { + let user = mapModel.userMapItems().find((userModel) => {return userModel.itemId === userId;}); + const userCoordString = user.itemData.coordinates['lng'] + " " + user.itemData.coordinates['lat']; + mapModel.caseGroupsIndex[userCoordString] = {groupId: groupId, item: user}; + + let cases = []; + mapModel.caseMapItems().forEach((caseModel) => { + if (result[userId].includes(caseModel.itemId)) { + cases.push(caseModel); + const coordString = caseModel.itemData.coordinates['lng'] + " " + caseModel.itemData.coordinates['lat']; + mapModel.caseGroupsIndex[coordString] = {groupId: groupId, item: caseModel}; + } + }); + connectUserWithCasesOnMap(user, cases); + groupId += 1; }); + self.setBusy(false); + }; - function getCoordinates(event) { - return event.lngLat; - } + self.runCaseDisbursementAlgorithm = function (cases, users) { + self.setBusy(true); + let mapInstance = mapModel.mapInstance; - // We should consider refactoring and splitting the below out to a new JS file - function moveMarkerToClickedCoordinate(coordinates) { // eslint-disable-line no-unused-vars - if (clickedMarker !== null) { - clickedMarker.remove(); + let caseData = []; + cases.forEach(function (c) { + const layerId = mapModel.getLineFeatureId(c.itemId); + if (mapInstance.getLayer(layerId)) { + mapInstance.removeLayer(layerId); } - if (draw.getMode() === 'draw_polygon') { - // It's weird moving the marker around with the ploygon - return; + if (mapInstance.getSource(layerId)) { + mapInstance.removeSource(layerId); } - clickedMarker = new mapboxgl.Marker({color: "FF0000", draggable: true}); // eslint-disable-line no-undef - clickedMarker.setLngLat(coordinates); - clickedMarker.addTo(map); - } - self.getMapboxDrawInstance = function () { - return draw; - }; - - self.getMapboxInstance = function () { - return map; - }; - - self.removeMarkersFromMap = function (itemArr) { - _.each(itemArr, function (currItem) { - currItem.marker.remove(); - }); - }; - - self.addMarkersToMap = function (itemArr, markerColours) { - let outArr = []; - _.forEach(itemArr, function (item, itemId) { - const coordinates = item.coordinates; - if (coordinates && coordinates.lat && coordinates.lng) { - const mapItem = self.addMarker(itemId, item, markerColours); - outArr.push(mapItem); - } + caseData.push({ + id: c.itemId, + lon: c.itemData.coordinates.lng, + lat: c.itemData.coordinates.lat, }); - return outArr; - }; + }); - self.addMarker = function (itemId, itemData, colors) { - const coordinates = itemData.coordinates; - // Create the marker - const marker = new mapboxgl.Marker({ color: colors.default, draggable: false }); // eslint-disable-line no-undef - marker.setLngLat(coordinates); - - // Add the marker to the map - marker.addTo(map); - - let popupDiv = document.createElement("div"); - popupDiv.setAttribute("data-bind", "template: 'select-case'"); - - let popup = new mapboxgl.Popup({ offset: 25, anchor: "bottom" }) // eslint-disable-line no-undef - .setLngLat(coordinates) - .setDOMContent(popupDiv); - - marker.setPopup(popup); - - const markerDiv = marker.getElement(); - // Show popup on hover - markerDiv.addEventListener('mouseenter', () => marker.togglePopup()); - - // Hide popup if mouse leaves marker and popup - var addLeaveEvent = function (fromDiv, toDiv) { - fromDiv.addEventListener('mouseleave', function () { - setTimeout(function () { - if (!$(toDiv).is(':hover')) { - // mouse left toDiv as well - marker.togglePopup(); - } - }, 100); - }); + let userData = users.map(function (c) { + return { + id: c.itemId, + lon: c.itemData.coordinates.lng, + lat: c.itemData.coordinates.lat, }; - addLeaveEvent(markerDiv, popupDiv); - addLeaveEvent(popupDiv, markerDiv); - - const mapItemInstance = new mapItemModel(itemId, itemData, marker, colors); - $(popupDiv).koApplyBindings(mapItemInstance); - - return mapItemInstance; - }; + }); - ko.applyBindings({'userModels': userModels, 'selectedUsers': selectedUsers}, $("#user-modals")[0]); - ko.applyBindings({'caseModels': caseModels, 'selectedCases': selectedCases}, $("#case-modals")[0]); - // Handle click events here - map.on('click', (event) => { - let coordinates = getCoordinates(event); // eslint-disable-line no-unused-vars + $.ajax({ + type: 'post', + url: runDisbursementUrl, + dataType: 'json', + data: JSON.stringify({'users': userData, "cases": caseData}), + contentType: "application/json; charset=utf-8", + success: function (ret) { + if (ret['poll_url'] !== undefined) { + self.startPoll(ret['poll_url']); + } else { + self.handleDisbursementResults(ret['result']); + } + }, }); - return self; }; - var saveGeoJson = function (drawInstance, mapControlsModelInstance) { - var data = drawInstance.getAll(); - - if (data.features.length) { - let name = window.prompt(gettext("Name of the Area")); - data['name'] = name; + self.startPoll = function (pollUrl) { + if (!self.isBusy()) { + self.setBusy(true); + } + self.pollUrl(pollUrl); + self.doPoll(); + }; + self.doPoll = function () { + var tick = function () { $.ajax({ - type: 'post', - url: saveGeoJSONUrl, - dataType: 'json', - data: JSON.stringify({'geo_json': data}), - contentType: "application/json; charset=utf-8", - success: function (ret) { - delete data.name; - // delete drawn area - drawInstance.deleteAll(); - mapControlsModelInstance.savedPolygons.push( - savedPolygon({ - name: name, - id: ret.id, - geo_json: data, - }) - ); - // redraw using mapControlsModelInstance - mapControlsModelInstance.selectedPolygon(ret.id); + method: 'GET', + url: self.pollUrl(), + success: function (data) { + const result = data.result; + if (!data) { + setTimeout(tick, DEFAULT_POLL_TIME_MS); + } else { + self.handleDisbursementResults(result); + } }, }); - } + }; + tick(); }; - function savedPolygon(polygon) { - var self = {}; - self.text = polygon.name; - self.id = polygon.id; - self.geoJson = polygon.geo_json; - return self; + function connectUserWithCasesOnMap(user, cases) { + cases.forEach((caseModel) => { + const lineCoordinates = [ + [user.itemData.coordinates.lng, user.itemData.coordinates.lat], + [caseModel.itemData.coordinates.lng, caseModel.itemData.coordinates.lat], + ]; + let mapInstance = mapModel.mapInstance; + mapInstance.addLayer({ + id: mapModel.getLineFeatureId(caseModel.itemId), + type: 'line', + source: { + type: 'geojson', + data: { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: lineCoordinates, + }, + }, + }, + layout: { + 'line-join': 'round', + 'line-cap': 'round', + }, + paint: { + 'line-color': '#808080', + 'line-width': 1, + }, + }); + }); } - var mapControlsModel = function () { - 'use strict'; - var self = {}; - var mapboxinstance = map.getMapboxInstance(); - self.btnSaveDisabled = ko.observable(true); - self.btnExportDisabled = ko.observable(true); - - // initial saved polygons - self.savedPolygons = ko.observableArray(); - _.each(initialPageData.get('saved_polygons'), function (polygon) { - self.savedPolygons.push(savedPolygon(polygon)); + return self; + }; + + function initMap() { + mapModel = new models.Map(); + mapModel.initMap(MAP_CONTAINER_ID); + + let selectedCases = ko.computed(function () { + return mapModel.caseMapItems().filter(function (currCase) { + return currCase.isSelected(); }); - // Keep track of the Polygon selected by the user - self.selectedPolygon = ko.observable(); - // Keep track of the Polygon displayed - self.activePolygon = ko.observable(); - - // On selection, add the polygon to the map - self.selectedPolygon.subscribe(function (value) { - var polygonObj = self.savedPolygons().find( - function (o) { return o.id === self.selectedPolygon(); } - ); - // Clear existing polygon - if (self.activePolygon()) { - mapboxinstance.removeLayer(self.activePolygon()); - mapboxinstance.removeSource(self.activePolygon()); - } - if (value !== undefined) { - // Add selected polygon - mapboxinstance.addSource( - String(polygonObj.id), - {'type': 'geojson', 'data': polygonObj.geoJson} - ); - mapboxinstance.addLayer({ - 'id': String(polygonObj.id), - 'type': 'fill', - 'source': String(polygonObj.id), - 'layout': {}, - 'paint': { - 'fill-color': '#0080ff', - 'fill-opacity': 0.5, - }, - }); - polygonObj.geoJson.features.forEach( - filterMapItemsInPolygon - ); - self.btnExportDisabled(false); - self.btnSaveDisabled(true); - } - // Mark as active polygon - self.activePolygon(self.selectedPolygon()); + }); + let selectedUsers = ko.computed(function () { + return mapModel.userMapItems().filter(function (currUser) { + return currUser.isSelected(); }); + }); - var mapHasPolygons = function () { - var drawnFeatures = map.getMapboxDrawInstance().getAll().features; - if (!drawnFeatures.length) { - return false; - } - return drawnFeatures.some(function (feature) { - return feature.geometry.type === "Polygon"; - }); - }; + ko.applyBindings({'userModels': mapModel.userMapItems, 'selectedUsers': selectedUsers}, $("#user-modals")[0]); + ko.applyBindings({'caseModels': mapModel.caseMapItems, 'selectedCases': selectedCases}, $("#case-modals")[0]); - mapboxinstance.on('draw.delete', function () { - self.btnSaveDisabled(!mapHasPolygons()); - }); + mapModel.mapInstance.on("draw.update", selectMapItemsInPolygons); + mapModel.mapInstance.on('draw.selectionchange', selectMapItemsInPolygons); + mapModel.mapInstance.on('draw.delete', function () { + polygonFilterModel.btnSaveDisabled(!mapModel.mapHasPolygons()); + selectMapItemsInPolygons(); + }); + mapModel.mapInstance.on('draw.create', function () { + polygonFilterModel.btnSaveDisabled(!mapModel.mapHasPolygons()); + }); + } - mapboxinstance.on('draw.create', function () { - self.btnSaveDisabled(!mapHasPolygons()); - }); + function selectMapItemsInPolygons() { + let features = mapModel.drawControls.getAll().features; + if (polygonFilterModel.activeSavedPolygon) { + features = features.concat(polygonFilterModel.activeSavedPolygon.geoJson.features); + } + mapModel.selectAllMapItems(features); + } - self.exportGeoJson = function () { - var exportButton = $("#btnExportDrawnArea"); - var selectedPolygon = self.savedPolygons().find( - function (o) { return o.id === self.selectedPolygon(); } - ); - if (selectedPolygon) { - var convertedData = 'text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(selectedPolygon.geoJson)); - exportButton.attr('href', 'data:' + convertedData); - exportButton.attr('download','data.geojson'); - } - }; + function initPolygonFilters() { + // Assumes `map` var is initialized + const $mapControlDiv = $("#mapControls"); + polygonFilterModel = new models.PolygonFilter(mapModel, false, true); + polygonFilterModel.loadPolygons(initialPageData.get('saved_polygons')); + if ($mapControlDiv.length) { + ko.cleanNode($mapControlDiv[0]); + $mapControlDiv.koApplyBindings(polygonFilterModel); + } - return self; - }; + const $saveDrawnArea = $("#btnSaveDrawnArea"); + $saveDrawnArea.click(function () { + if (mapModel && mapModel.mapInstance) { + saveGeoJson(); + } + }); - function initMapControls() { - // Assumes `map` var is initialized - var $mapControlDiv = $("#mapControls"); - var mapControlsModelInstance = mapControlsModel(); - if ($mapControlDiv.length) { - ko.cleanNode($mapControlDiv[0]); - $mapControlDiv.koApplyBindings(mapControlsModelInstance); + var $exportDrawnArea = $("#btnExportDrawnArea"); + $exportDrawnArea.click(function () { + if (mapModel && mapModel.mapInstance) { + polygonFilterModel.exportGeoJson("btnExportDrawnArea"); } + }); - var $saveDrawnArea = $("#btnSaveDrawnArea"); - $saveDrawnArea.click(function () { - if (map) { - saveGeoJson(map.getMapboxDrawInstance(), mapControlsModelInstance); + var $runDisbursement = $("#btnRunDisbursement"); + $runDisbursement.click(function () { + if (mapModel && mapModel.mapInstance && !polygonFilterModel.btnRunDisbursementDisabled()) { + let selectedCases = mapModel.caseMapItems(); + let selectedUsers = mapModel.userMapItems(); + if (mapModel.mapHasPolygons() || polygonFilterModel.activeSavedPolygon) { + selectedCases = mapModel.caseMapItems().filter(function (caseItem) { + return caseItem.isSelected(); + }); + selectedUsers = mapModel.userMapItems().filter((userItem) => { + return userItem.isSelected(); + }); } - }); - var $exportDrawnArea = $("#btnExportDrawnArea"); - $exportDrawnArea.click(function () { - if (map) { - mapControlsModelInstance.exportGeoJson(); + // User might do polygon filtering on an area with no cases/users. We should not do + // disbursement if this is the case + const hasValidData = selectedCases.length && selectedUsers.length; + disbursementRunner.hasMissingData(!hasValidData); + if (hasValidData) { + disbursementRunner.runCaseDisbursementAlgorithm(selectedCases, selectedUsers); } - }); - } + } + }); + } - var missingGPSModel = function () { - this.casesWithoutGPS = ko.observable([]); - this.usersWithoutGPS = ko.observable([]); - }; - var missingGPSModelInstance = new missingGPSModel(); - - var userFiltersModel = function () { - var self = {}; - - self.shouldShowUsers = ko.observable(false); - self.hasFiltersChanged = ko.observable(false); // Used to disable "Apply" button - self.showFilterMenu = ko.observable(true); - self.hasErrors = ko.observable(false); - self.selectedLocation = null; - - self.loadUsers = function () { - map.removeMarkersFromMap(userModels()); - userModels([]); - self.hasErrors(false); - if (!self.shouldShowUsers()) { - self.hasFiltersChanged(false); - missingGPSModelInstance.usersWithoutGPS([]); - return; - } + var userFiltersModel = function () { + var self = {}; - $.ajax({ - method: 'GET', - data: {'location_id': self.selectedLocation}, - url: initialPageData.reverse('get_users_with_gps'), - success: function (data) { - self.hasFiltersChanged(false); + self.shouldShowUsers = ko.observable(false); + self.hasFiltersChanged = ko.observable(false); // Used to disable "Apply" button + self.showFilterMenu = ko.observable(true); + self.hasErrors = ko.observable(false); + self.selectedLocation = null; + + self.loadUsers = function () { + mapModel.removeMarkersFromMap(mapModel.userMapItems()); + mapModel.userMapItems([]); + self.hasErrors(false); + if (!self.shouldShowUsers()) { + self.hasFiltersChanged(false); + missingGPSModelInstance.usersWithoutGPS([]); + return; + } - // TODO: There is a lot of indexing happening here. This should be replaced with a mapping to make reading it more explicit - const usersWithoutGPS = data.user_data.filter(function (item) { - return item.gps_point === null || !item.gps_point.length; - }); - missingGPSModelInstance.usersWithoutGPS(usersWithoutGPS); + $.ajax({ + method: 'GET', + data: {'location_id': self.selectedLocation}, + url: initialPageData.reverse('get_users_with_gps'), + success: function (data) { + self.hasFiltersChanged(false); - const usersWithGPS = data.user_data.filter(function (item) { - return item.gps_point !== null && item.gps_point.length; - }); + // TODO: There is a lot of indexing happening here. This should be replaced with a mapping to make reading it more explicit + const usersWithoutGPS = data.user_data.filter(function (item) { + return item.gps_point === null || !item.gps_point.length; + }); + missingGPSModelInstance.usersWithoutGPS(usersWithoutGPS); - const userData = _.object(_.map(usersWithGPS, function (userData) { - const gpsData = (userData.gps_point) ? userData.gps_point.split(' ') : []; - const lat = parseFloat(gpsData[0]); - const lng = parseFloat(gpsData[1]); + const usersWithGPS = data.user_data.filter(function (item) { + return item.gps_point !== null && item.gps_point.length; + }); - const editUrl = initialPageData.reverse('edit_commcare_user', userData.id); - const link = `<a class="ajax_dialog" href="${editUrl}" target="_blank">${userData.username}</a>`; + const userData = _.object(_.map(usersWithGPS, function (userData) { + const gpsData = (userData.gps_point) ? userData.gps_point.split(' ') : []; + const lat = parseFloat(gpsData[0]); + const lng = parseFloat(gpsData[1]); - return [userData.id, {'coordinates': {'lat': lat, 'lng': lng}, 'link': link}]; - })); + const editUrl = initialPageData.reverse('edit_commcare_user', userData.id); + const link = `<a class="ajax_dialog" href="${editUrl}" target="_blank">${userData.username}</a>`; - const userMapItems = map.addMarkersToMap(userData, userMarkerColors); - userModels(userMapItems); - }, - error: function () { - self.hasErrors(true); - }, - }); - }; + return [userData.id, {'coordinates': {'lat': lat, 'lng': lng}, 'link': link, 'type': 'user'}]; + })); - self.onLocationFilterChange = function (_, e) { - self.selectedLocation = $(e.currentTarget).select2('val'); - self.onFiltersChange(); - }; + const userMapItems = mapModel.addMarkersToMap(userData, userMarkerColors); + mapModel.userMapItems(userMapItems); + }, + error: function () { + self.hasErrors(true); + }, + }); + }; - self.onFiltersChange = function () { - self.hasFiltersChanged(true); - }; + self.onLocationFilterChange = function (_, e) { + self.selectedLocation = $(e.currentTarget).select2('val'); + self.onFiltersChange(); + }; - self.toggleFilterMenu = function () { - self.showFilterMenu(!self.showFilterMenu()); - const shouldShow = self.showFilterMenu() ? 'show' : 'hide'; - $("#user-filters-panel .panel-body").collapse(shouldShow); - }; + self.onFiltersChange = function () { + self.hasFiltersChanged(true); + }; - return self; + self.toggleFilterMenu = function () { + self.showFilterMenu(!self.showFilterMenu()); + const shouldShow = self.showFilterMenu() ? 'show' : 'hide'; + $("#user-filters-panel .panel-body").collapse(shouldShow); }; - function initUserFilters() { - const $userFiltersDiv = $("#user-filters-panel"); - if ($userFiltersDiv.length) { - const userFiltersInstance = userFiltersModel(); - $userFiltersDiv.koApplyBindings(userFiltersInstance); - $("#location-filter-select").select2({ - placeholder: gettext('All locations'), - allowClear: true, - cache: true, - ajax: { - url: initialPageData.reverse('location_search'), - dataType: 'json', - processResults: function (data) { - return { - results: $.map(data.results, function (item) { - return { - text: item.text, - id: item.id, - }; - }), - }; - }, - }, - }); - } - } + return self; + }; - function loadCases(caseData) { - map.removeMarkersFromMap(caseModels()); - caseModels([]); - var casesWithGPS = caseData.filter(function (item) { - return item[1] !== null; + function initUserFilters() { + const $userFiltersDiv = $("#user-filters-panel"); + if ($userFiltersDiv.length) { + const userFiltersInstance = userFiltersModel(); + $userFiltersDiv.koApplyBindings(userFiltersInstance); + $("#location-filter-select").select2({ + placeholder: gettext('All locations'), + allowClear: true, + cache: true, + ajax: { + url: initialPageData.reverse('location_search'), + dataType: 'json', + processResults: function (data) { + return { + results: $.map(data.results, function (item) { + return { + text: item.text, + id: item.id, + }; + }), + }; + }, + }, }); - // Index by case_id - var casesById = _.object(_.map(casesWithGPS, function (item) { - if (item[1]) { - return [item[0], {'coordinates': item[1], 'link': item[2]}]; - } - })); - const caseMapItems = map.addMarkersToMap(casesById, caseMarkerColors); - caseModels(caseMapItems); + } + } - var $missingCasesDiv = $("#missing-gps-cases"); - var casesWithoutGPS = caseData.filter(function (item) { - return item[1] === null; - }); - casesWithoutGPS = _.map(casesWithoutGPS, function (item) {return {"link": item[2]};}); - // Don't re-apply if this is the next page of the pagination - if (ko.dataFor($missingCasesDiv[0]) === undefined) { - $missingCasesDiv.koApplyBindings(missingGPSModelInstance); - missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS); + function loadCases(caseData) { + mapModel.removeMarkersFromMap(mapModel.caseMapItems()); + mapModel.caseMapItems([]); + var casesWithGPS = caseData.filter(function (item) { + return item[1] !== null; + }); + // Index by case_id + var casesById = _.object(_.map(casesWithGPS, function (item) { + if (item[1]) { + return [item[0], {'coordinates': item[1], 'link': item[2], 'type': 'case'}]; } + })); + const caseMapItems = mapModel.addMarkersToMap(casesById, caseMarkerColors); + mapModel.caseMapItems(caseMapItems); + + var $missingCasesDiv = $("#missing-gps-cases"); + var casesWithoutGPS = caseData.filter(function (item) { + return item[1] === null; + }); + casesWithoutGPS = _.map(casesWithoutGPS, function (item) {return {"link": item[2]};}); + // Don't re-apply if this is the next page of the pagination + if (ko.dataFor($missingCasesDiv[0]) === undefined) { + $missingCasesDiv.koApplyBindings(missingGPSModelInstance); missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS); } + missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS); - $(document).ajaxComplete(function (event, xhr, settings) { - // When mobile workers are loaded from the user filtering menu, ajaxComplete will be called again. - // We don't want to reload the map or cases when this happens, so simply return. - const isAfterUserLoad = settings.url.includes('geospatial/get_users_with_gps/'); - if (isAfterUserLoad) { - return; - } + mapModel.fitMapBounds(caseMapItems); + } - const isAfterReportLoad = settings.url.includes('geospatial/async/case_management_map/'); - // This indicates clicking Apply button or initial page load - if (isAfterReportLoad) { - map = loadMapBox(); - initMapControls(); - initUserFilters(); - // Hide controls until data is displayed - showMapControls(false); - return; - } + $(document).ajaxComplete(function (event, xhr, settings) { + // When mobile workers are loaded from the user filtering menu, ajaxComplete will be called again. + // We don't want to reload the map or cases when this happens, so simply return. + const isAfterUserLoad = settings.url.includes('geospatial/get_users_with_gps/'); + if (isAfterUserLoad) { + return; + } - // This indicates that report data is fetched either after apply or after pagination - const isAfterDataLoad = settings.url.includes('geospatial/json/case_management_map/'); - if (!isAfterDataLoad) { - return; - } + const isAfterReportLoad = settings.url.includes('geospatial/async/case_management_map/'); + // This indicates clicking Apply button or initial page load + if (isAfterReportLoad) { + initMap(); + initPolygonFilters(); + initUserFilters(); + // Hide controls until data is displayed + showMapControls(false); + missingGPSModelInstance = new models.MissingGPSModel(); + + disbursementRunner = new disbursementRunnerModel(); + $("#disbursement-spinner").koApplyBindings(disbursementRunner); + $("#disbursement-error").koApplyBindings(disbursementRunner); + + return; + } - showMapControls(true); - // Hide the datatable rows but not the pagination bar - $('.dataTables_scroll').hide(); + // This indicates that report data is fetched either after apply or after pagination + const isAfterDataLoad = settings.url.includes('geospatial/json/case_management_map/'); + if (!isAfterDataLoad) { + return; + } - if (xhr.responseJSON.aaData.length && map) { - loadCases(xhr.responseJSON.aaData); - } - }); + showMapControls(true); + // Hide the datatable rows but not the pagination bar + $('.dataTables_scroll').hide(); + + if (xhr.responseJSON.aaData.length && mapModel.mapInstance) { + loadCases(xhr.responseJSON.aaData); + } }); }); diff --git a/corehq/apps/geospatial/static/geospatial/js/gps_capture.js b/corehq/apps/geospatial/static/geospatial/js/gps_capture.js index 35c8ff289e8fb..dcac9d58c9569 100644 --- a/corehq/apps/geospatial/static/geospatial/js/gps_capture.js +++ b/corehq/apps/geospatial/static/geospatial/js/gps_capture.js @@ -4,6 +4,7 @@ hqDefine("geospatial/js/gps_capture",[ 'underscore', 'hqwebapp/js/initial_page_data', "hqwebapp/js/bootstrap3/components.ko", // for pagination + 'select2/dist/js/select2.full.min', ], function ( $, ko, @@ -12,6 +13,7 @@ hqDefine("geospatial/js/gps_capture",[ ) { 'use strict'; const MAP_CONTAINER_ID = "geospatial-map"; + const USERS_PER_PAGE = 10; var map; var selectedDataListObject; @@ -130,6 +132,13 @@ hqDefine("geospatial/js/gps_capture",[ return !self.showLoadingSpinner() && !self.hasError(); }); + self.isCreatingCase = ko.observable(false); + self.hasCreateCaseError = ko.observable(false); + self.availableCaseTypes = ko.observableArray([]); + self.selectedCaseType = ko.observable(''); + self.hasCaseTypeError = ko.observable(false); + self.selectedOwnerId = ko.observable(null); + self.captureLocationForItem = function (item) { self.itemLocationBeingCapturedOnMap(item); selectedDataListObject = self; @@ -144,7 +153,7 @@ hqDefine("geospatial/js/gps_capture",[ self.hasError(false); self.showPaginationSpinner(true); self.showLoadingSpinner(true); - let url = initialPageData.reverse('get_paginated_cases_or_users_without_gps'); + let url = initialPageData.reverse('get_paginated_cases_or_users'); if (self.dataType === 'case') { url += window.location.search; } @@ -179,15 +188,26 @@ hqDefine("geospatial/js/gps_capture",[ self.goToPage(1); }; + self.onOwnerIdChange = function (_, e) { + self.selectedOwnerId($(e.currentTarget).select2('val')); + }; + self.saveDataRow = function (dataItem) { self.isSubmissionSuccess(false); self.hasSubmissionError(false); + let dataItemJson = ko.mapping.toJS(dataItem); + if (self.isCreatingCase()) { + dataItemJson['case_type'] = self.selectedCaseType(); + dataItemJson['owner_id'] = self.selectedOwnerId(); + } + $.ajax({ method: 'POST', url: initialPageData.reverse('gps_capture'), data: JSON.stringify({ 'data_type': self.dataType, - 'data_item': ko.mapping.toJS(dataItem), + 'data_item': dataItemJson, + 'create_case': self.isCreatingCase(), }), dataType: "json", contentType: "application/json; charset=utf-8", @@ -195,6 +215,8 @@ hqDefine("geospatial/js/gps_capture",[ dataItem.hasUnsavedChanges(false); self.isSubmissionSuccess(true); resetMap(); + self.resetCaseCreate(); + $(window).scrollTop(0); }, error: function () { self.hasSubmissionError(true); @@ -202,6 +224,72 @@ hqDefine("geospatial/js/gps_capture",[ }); }; + self.startCreateCase = function () { + self.isCreatingCase(true); + const caseToCreate = new dataItemModel(null, self.dataType); + self.captureLocationForItem(caseToCreate); + + const placeholderStr = `${initialPageData.get('couch_user_username')} (${gettext("current user")})`; + $("#owner-select").select2({ + placeholder: placeholderStr, + cache: true, + allowClear: true, + delay: 250, + ajax: { + url: initialPageData.reverse('paginate_mobile_workers'), + dataType: 'json', + data: function (params) { + return { + query: params.term, + page_limit: USERS_PER_PAGE, + page: params.page, + }; + }, + processResults: function (data, params) { + params.page = params.page || 1; + + const hasMore = (params.page * USERS_PER_PAGE) < data.total; + const dataResults = $.map(data.users, function (user) { + return { + text: user.username, + id: user.user_id, + }; + }); + + return { + results: dataResults, + pagination: { + more: hasMore, + }, + }; + }, + }, + }); + }; + + self.finishCreateCase = function () { + const hasValidName = self.itemLocationBeingCapturedOnMap().name().length > 0; + self.hasCreateCaseError(!hasValidName); + const hasValidCaseType = self.selectedCaseType() && self.selectedCaseType().length > 0; + self.hasCaseTypeError(!hasValidCaseType); + if (hasValidName && hasValidCaseType) { + self.saveDataRow(self.itemLocationBeingCapturedOnMap()); + } + }; + + self.cancelCreateCase = function () { + self.resetCaseCreate(); + resetMap(); + }; + + self.resetCaseCreate = function () { + self.isCreatingCase(false); + self.hasCreateCaseError(false); + self.hasCaseTypeError(false); + self.selectedCaseType(''); + self.selectedOwnerId(null); + }; + return self; }; @@ -265,8 +353,11 @@ hqDefine("geospatial/js/gps_capture",[ } $(function () { + const caseDataItemListInstance = dataItemListModel('case'); + caseDataItemListInstance.availableCaseTypes(initialPageData.get('case_types_with_gps')); + $("#tabs-list").koApplyBindings(TabListViewModel()); - $("#no-gps-list-case").koApplyBindings(dataItemListModel('case')); + $("#no-gps-list-case").koApplyBindings(caseDataItemListInstance); $("#no-gps-list-user").koApplyBindings(dataItemListModel('user')); initMap(); diff --git a/corehq/apps/geospatial/static/geospatial/js/models.js b/corehq/apps/geospatial/static/geospatial/js/models.js new file mode 100644 index 0000000000000..f1a8a11853e3f --- /dev/null +++ b/corehq/apps/geospatial/static/geospatial/js/models.js @@ -0,0 +1,556 @@ +hqDefine('geospatial/js/models', [ + 'jquery', + 'knockout', + 'hqwebapp/js/initial_page_data', + 'geospatial/js/utils', +], function ( + $, + ko, + initialPageData, + utils +) { + const HOVER_DELAY = 400; + const DOWNPLAY_OPACITY = 0.2; + const FEATURE_QUERY_PARAM = 'features'; + const DEFAULT_CENTER_COORD = [-20.0, -0.0]; + const DISBURSEMENT_LAYER_PREFIX = 'route-'; + + var MissingGPSModel = function () { + this.casesWithoutGPS = ko.observable([]); + this.usersWithoutGPS = ko.observable([]); + }; + + var SavedPolygon = function (polygon) { + var self = this; + self.text = polygon.name; + self.id = polygon.id; + self.geoJson = polygon.geo_json; + }; + + var MapItem = function (itemId, itemData, marker, markerColors) { + 'use strict'; + var self = this; + self.itemId = itemId; + self.itemData = itemData; + self.marker = marker; + self.selectCssId = "select" + itemId; + self.isSelected = ko.observable(false); + self.markerColors = markerColors; + + self.groupId = null; + self.groupCoordinates = null; + + self.setMarkerOpacity = function (opacity) { + let element = self.marker.getElement(); + element.style.opacity = opacity; + }; + + function changeMarkerColor(selectedCase, newColor) { + let marker = selectedCase.marker; + let element = marker.getElement(); + let svg = element.getElementsByTagName("svg")[0]; + let path = svg.getElementsByTagName("path")[0]; + path.setAttribute("fill", newColor); + } + + self.getItemType = function () { + if (self.itemData.type === "user") { + return gettext("Mobile Worker"); + } + return gettext("Case"); + }; + + self.isSelected.subscribe(function () { + var color = self.isSelected() ? self.markerColors.selected : self.markerColors.default; + changeMarkerColor(self, color); + }); + }; + + var GroupedCaseMapItem = function (itemId, itemData, link) { + let self = this; + self.itemId = itemId; + self.itemData = itemData; + self.link = link; + self.groupId = null; + self.groupCoordinates = null; + + self.toJson = function () { + const coordinates = (self.itemData.coordinates) ? `${self.itemData.coordinates.lng} ${self.itemData.coordinates.lat}` : ""; + const groupCoordinates = (self.groupCoordinates) ? `${self.groupCoordinates.lng} ${self.groupCoordinates.lat}` : ""; + return { + 'groupId': self.groupId, + 'groupCenterCoordinates': groupCoordinates, + 'caseId': self.itemId, + 'coordinates': coordinates, + }; + }; + }; + + var Map = function (usesClusters) { + var self = this; + + self.usesClusters = usesClusters; + + self.mapInstance; + self.drawControls; + + self.caseMapItems = ko.observableArray([]); + self.userMapItems = ko.observableArray([]); + + self.caseGroupsIndex = {}; + + self.initMap = function (mapDivId, centerCoordinates) { + mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef + if (!centerCoordinates) { + centerCoordinates = [-91.874, 42.76]; // should be domain specific + } + + self.mapInstance = new mapboxgl.Map({ // eslint-disable-line no-undef + container: mapDivId, // container ID + style: 'mapbox://styles/mapbox/streets-v12', // style URL + center: centerCoordinates, // starting position [lng, lat] + zoom: 12, + attribution: '© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> ©' + + ' <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', + }); + + self.drawControls = new MapboxDraw({ // eslint-disable-line no-undef + // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md + displayControlsDefault: false, + boxSelect: true, // enables box selection + controls: { + polygon: true, + trash: true, + }, + }); + self.mapInstance.addControl(self.drawControls); + if (self.usesClusters) { + createClusterLayers(); + } + }; + + function createClusterLayers() { + // const mapInstance = self.mapInstance; + self.mapInstance.on('load', () => { + self.mapInstance.addSource('caseWithGPS', { + type: 'geojson', + data: { + "type": "FeatureCollection", + "features": [], + }, + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points on + clusterRadius: 50, // Radius of each cluster when clustering points (defaults to 50) + }); + self.mapInstance.addLayer({ + id: 'clusters', + type: 'circle', + source: 'caseWithGPS', + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#51bbd6', + 100, + '#f1f075', + 750, + '#f28cb1', + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 20, + 100, + 30, + 750, + 40, + ], + }, + }); + self.mapInstance.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'caseWithGPS', + filter: ['has', 'point_count'], + layout: { + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12, + }, + }); + self.mapInstance.addLayer({ + id: 'unclustered-point', + type: 'circle', + source: 'caseWithGPS', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': 'red', + 'circle-radius': 10, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff', + }, + }); + }); + } + + self.removeMarkersFromMap = function (itemArr) { + _.each(itemArr, function (currItem) { + currItem.marker.remove(); + }); + }; + + self.addMarkersToMap = function (itemArr, markerColours) { + let outArr = []; + _.forEach(itemArr, function (item, itemId) { + const coordinates = item.coordinates; + if (coordinates && coordinates.lat && coordinates.lng) { + const mapItem = addMarker(itemId, item, markerColours); + outArr.push(mapItem); + } + }); + return outArr; + }; + + function addMarker(itemId, itemData, colors) { + const coordinates = itemData.coordinates; + // Create the marker + const marker = new mapboxgl.Marker({ color: colors.default, draggable: false }); // eslint-disable-line no-undef + marker.setLngLat(coordinates); + + // Add the marker to the map + marker.addTo(self.mapInstance); + + let popupDiv = document.createElement("div"); + popupDiv.setAttribute("data-bind", "template: 'select-case'"); + + let popup = new mapboxgl.Popup({ offset: 25, anchor: "bottom" }) // eslint-disable-line no-undef + .setLngLat(coordinates) + .setDOMContent(popupDiv); + + marker.setPopup(popup); + + const markerDiv = marker.getElement(); + // Show popup on hover + markerDiv.addEventListener('mouseenter', () => marker.togglePopup()); + markerDiv.addEventListener('mouseenter', () => highlightMarkerGroup(marker)); + markerDiv.addEventListener('mouseleave', () => resetMarkersOpacity()); + + // Hide popup if mouse leaves marker and popup + var addLeaveEvent = function (fromDiv, toDiv) { + fromDiv.addEventListener('mouseleave', function () { + setTimeout(function () { + if (!$(toDiv).is(':hover')) { + // mouse left toDiv as well + marker.togglePopup(); + } + }, 100); + }); + }; + addLeaveEvent(markerDiv, popupDiv); + addLeaveEvent(popupDiv, markerDiv); + + const mapItemInstance = new MapItem(itemId, itemData, marker, colors); + $(popupDiv).koApplyBindings(mapItemInstance); + + return mapItemInstance; + } + + function resetMarkersOpacity() { + let markers = []; + Object.keys(self.caseGroupsIndex).forEach(itemCoordinates => { + const mapMarkerItem = self.caseGroupsIndex[itemCoordinates]; + markers.push(mapMarkerItem.item); + + const lineId = self.getLineFeatureId(mapMarkerItem.item.itemId); + if (self.mapInstance.getLayer(lineId)) { + self.mapInstance.setPaintProperty(lineId, 'line-opacity', 1); + } + }); + changeMarkersOpacity(markers, 1); + } + + function highlightMarkerGroup(marker) { + const markerCoords = marker.getLngLat(); + const currentMarkerPosition = markerCoords.lng + " " + markerCoords.lat; + const markerItem = self.caseGroupsIndex[currentMarkerPosition]; + + if (markerItem) { + const groupId = markerItem.groupId; + + let markersToHide = []; + Object.keys(self.caseGroupsIndex).forEach(itemCoordinates => { + const mapMarkerItem = self.caseGroupsIndex[itemCoordinates]; + + if (mapMarkerItem.groupId !== groupId) { + markersToHide.push(mapMarkerItem.item); + const lineId = self.getLineFeatureId(mapMarkerItem.item.itemId); + if (self.mapInstance.getLayer(lineId)) { + self.mapInstance.setPaintProperty(lineId, 'line-opacity', DOWNPLAY_OPACITY); + } + } + }); + changeMarkersOpacity(markersToHide, DOWNPLAY_OPACITY); + } + } + + function changeMarkersOpacity(markers, opacity) { + // It's necessary to delay obscuring the markers since mapbox does not play nice + // if we try to do it all at once. + setTimeout(function () { + markers.forEach(marker => { + marker.setMarkerOpacity(opacity); + }); + }, HOVER_DELAY); + } + + self.getLineFeatureId = function (itemId) { + return DISBURSEMENT_LAYER_PREFIX + itemId; + }; + + self.selectAllMapItems = function (featuresArr) { + // See https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md#drawselectionchange + for (const caseItem of self.caseMapItems()) { + self.selectMapItemInPolygons(featuresArr, caseItem); + } + for (const userItem of self.userMapItems()) { + self.selectMapItemInPolygons(featuresArr, userItem); + } + }; + + self.selectMapItemInPolygons = function (polygonArr, mapItem) { + let isSelected = false; + for (const polygon of polygonArr) { + if (polygon.geometry.type !== 'Polygon') { + continue; + } + if (isMapItemInPolygon(polygon, mapItem.itemData.coordinates)) { + isSelected = true; + break; + } + } + mapItem.isSelected(isSelected); + }; + + function isMapItemInPolygon(polygonFeature, coordinates) { + // Will be 0 if a user deletes a point from a three-point polygon, + // since mapbox will delete the entire polygon. turf.booleanPointInPolygon() + // does not expect this, and will raise a 'TypeError' exception. + if (!polygonFeature.geometry.coordinates.length) { + return false; + } + const coordinatesArr = [coordinates.lng, coordinates.lat]; + const point = turf.point(coordinatesArr); // eslint-disable-line no-undef + return turf.booleanPointInPolygon(point, polygonFeature.geometry); // eslint-disable-line no-undef + } + + self.mapHasPolygons = function () { + const drawnFeatures = self.drawControls.getAll().features; + if (!drawnFeatures.length) { + return false; + } + return drawnFeatures.some(function (feature) { + return feature.geometry.type === "Polygon"; + }); + }; + + // @param mapItems - Should be an array of mapItemModel type objects + self.fitMapBounds = function (mapItems) { + if (!mapItems.length) { + self.mapInstance.flyTo({ + zoom: 0, + center: DEFAULT_CENTER_COORD, + duration: 500, + }); + return; + } + + // See https://stackoverflow.com/questions/62939325/scale-mapbox-gl-map-to-fit-set-of-markers + const firstCoord = mapItems[0].itemData.coordinates; + const bounds = mapItems.reduce(function (bounds, mapItem) { + const coord = mapItem.itemData.coordinates; + if (coord) { + return bounds.extend(coord); + } + }, new mapboxgl.LngLatBounds(firstCoord, firstCoord)); // eslint-disable-line no-undef + + self.mapInstance.fitBounds(bounds, { + padding: 50, // in pixels + duration: 500, // in ms + maxZoom: 10, // 0-23 + }); + }; + + self.removeDisbursementLayers = function () { + const mapLayers = self.mapInstance.getStyle().layers; + mapLayers.forEach(function (layer) { + if (layer.id.includes(DISBURSEMENT_LAYER_PREFIX)) { + self.mapInstance.removeLayer(layer.id); + } + }); + }; + }; + + var PolygonFilter = function (mapObj, shouldUpdateQueryParam, shouldSelectAfterFilter) { + var self = this; + + self.mapObj = mapObj; + + // TODO: This can be moved to geospatial JS (specific functionality) + self.btnRunDisbursementDisabled = ko.computed(function () { + return !self.mapObj.caseMapItems().length || !self.mapObj.userMapItems().length; + }); + + self.shouldUpdateQuryParam = shouldUpdateQueryParam; + self.shouldSelectAfterFilter = shouldSelectAfterFilter; + self.btnSaveDisabled = ko.observable(true); + self.btnExportDisabled = ko.observable(true); + + self.polygons = {}; + self.shouldRefreshPage = ko.observable(false); + + self.savedPolygons = ko.observableArray([]); + self.selectedSavedPolygonId = ko.observable(''); + self.activeSavedPolygon; + + self.addPolygonsToFilterList = function (featureList) { + for (const feature of featureList) { + self.polygons[feature.id] = feature; + } + if (self.shouldUpdateQuryParam) { + updatePolygonQueryParam(); + } + }; + + self.removePolygonsFromFilterList = function (featureList) { + for (const feature of featureList) { + if (self.polygons[feature.id]) { + delete self.polygons[feature.id]; + } + } + if (self.shouldUpdateQuryParam) { + updatePolygonQueryParam(); + } + }; + + function updatePolygonQueryParam() { + const url = new URL(window.location.href); + if (Object.keys(self.polygons).length) { + url.searchParams.set(FEATURE_QUERY_PARAM, JSON.stringify(self.polygons)); + } else { + url.searchParams.delete(FEATURE_QUERY_PARAM); + } + window.history.replaceState({ path: url.href }, '', url.href); + self.shouldRefreshPage(true); + } + + self.loadPolygonFromQueryParam = function () { + const url = new URL(window.location.href); + const featureParam = url.searchParams.get(FEATURE_QUERY_PARAM); + if (featureParam) { + const features = JSON.parse(featureParam); + for (const featureId in features) { + const feature = features[featureId]; + self.mapObj.drawControls.add(feature); + self.polygons[featureId] = feature; + } + } + }; + + function removeActivePolygonLayer() { + if (self.activeSavedPolygon) { + self.mapObj.mapInstance.removeLayer(self.activeSavedPolygon.id); + self.mapObj.mapInstance.removeSource(self.activeSavedPolygon.id); + } + } + + function createActivePolygonLayer(polygonObj) { + self.mapObj.mapInstance.addSource( + String(polygonObj.id), + {'type': 'geojson', 'data': polygonObj.geoJson} + ); + self.mapObj.mapInstance.addLayer({ + 'id': String(polygonObj.id), + 'type': 'fill', + 'source': String(polygonObj.id), + 'layout': {}, + 'paint': { + 'fill-color': '#0080ff', + 'fill-opacity': 0.5, + }, + }); + } + + self.clearActivePolygon = function () { + if (self.activeSavedPolygon) { + // self.selectedSavedPolygonId(''); + self.removePolygonsFromFilterList(self.activeSavedPolygon.geoJson.features); + removeActivePolygonLayer(); + self.activeSavedPolygon = null; + self.btnSaveDisabled(false); + self.btnExportDisabled(true); + } + }; + + self.selectedSavedPolygonId.subscribe(() => { + const selectedId = parseInt(self.selectedSavedPolygonId()); + const polygonObj = self.savedPolygons().find( + function (o) { return o.id === selectedId; } + ); + if (!polygonObj) { + return; + } + + self.clearActivePolygon(); + + removeActivePolygonLayer(); + createActivePolygonLayer(polygonObj); + + self.activeSavedPolygon = polygonObj; + self.addPolygonsToFilterList(polygonObj.geoJson.features); + self.btnExportDisabled(false); + self.btnSaveDisabled(true); + if (self.shouldSelectAfterFilter) { + const features = polygonObj.geoJson.features.concat(mapObj.drawControls.getAll().features); + self.mapObj.selectAllMapItems(features); + } + }); + + self.loadPolygons = function (polygonArr) { + if (self.shouldUpdateQuryParam) { + self.loadPolygonFromQueryParam(); + } + self.savedPolygons([]); + + _.each(polygonArr, (polygon) => { + // Saved features don't have IDs, so we need to give them to uniquely identify them for polygon filtering + for (const feature of polygon.geo_json.features) { + feature.id = utils.uuidv4(); + } + self.savedPolygons.push(new SavedPolygon(polygon)); + }); + }; + + self.exportGeoJson = function (exportButtonId) { + const exportButton = $(`#${exportButtonId}`); + const selectedId = parseInt(self.selectedSavedPolygonId()); + const selectedPolygon = self.savedPolygons().find( + function (o) { return o.id === selectedId; } + ); + if (selectedPolygon) { + const convertedData = 'text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(selectedPolygon.geoJson)); + exportButton.attr('href', 'data:' + convertedData); + exportButton.attr('download','data.geojson'); + } + }; + }; + + return { + MissingGPSModel: MissingGPSModel, + SavedPolygon: SavedPolygon, + MapItem: MapItem, + GroupedCaseMapItem: GroupedCaseMapItem, + Map: Map, + PolygonFilter: PolygonFilter, + }; +}); \ No newline at end of file diff --git a/corehq/apps/geospatial/static/geospatial/js/utils.js b/corehq/apps/geospatial/static/geospatial/js/utils.js new file mode 100644 index 0000000000000..2bd2675ff098a --- /dev/null +++ b/corehq/apps/geospatial/static/geospatial/js/utils.js @@ -0,0 +1,30 @@ +hqDefine('geospatial/js/utils', [], function () { + + const DEFAULT_MARKER_OPACITY = 1.0; + + var getRandomRGBColor = function () { // TODO: Ensure generated colors looks different! + var r = Math.floor(Math.random() * 256); // Random value between 0 and 255 for red + var g = Math.floor(Math.random() * 256); // Random value between 0 and 255 for green + var b = Math.floor(Math.random() * 256); // Random value between 0 and 255 for blue + + return `rgba(${r},${g},${b},${DEFAULT_MARKER_OPACITY})`; + }; + + var uuidv4 = function () { + // https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid/2117523#2117523 + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); + }; + + var getTodayDate = function () { + const todayDate = new Date(); + return todayDate.toLocaleDateString(); + }; + + return { + getRandomRGBColor: getRandomRGBColor, + uuidv4: uuidv4, + getTodayDate: getTodayDate, + }; +}); \ No newline at end of file diff --git a/corehq/apps/geospatial/templates/base_template.html b/corehq/apps/geospatial/templates/base_template.html index d1b34a1ab732c..2c5e99c10c08e 100644 --- a/corehq/apps/geospatial/templates/base_template.html +++ b/corehq/apps/geospatial/templates/base_template.html @@ -6,6 +6,8 @@ <!-- Scripts for mapbox --> <script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script> <script src='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.0/mapbox-gl-draw.js'></script> + <script src="{% static 'geospatial/js/utils.js' %}"></script> + <script src="{% static 'geospatial/js/models.js' %}"></script> {% endblock %} {% block stylesheets %} diff --git a/corehq/apps/geospatial/templates/case_grouping_map.html b/corehq/apps/geospatial/templates/case_grouping_map.html index 434e865072864..e2c285b2b1fd7 100644 --- a/corehq/apps/geospatial/templates/case_grouping_map.html +++ b/corehq/apps/geospatial/templates/case_grouping_map.html @@ -4,14 +4,56 @@ {% load hq_shared_tags %} {% block reportcontent %} -<div class="row panel" id="export-controls"> - <div class="controls col-sm-2 col-md-2 col-lg-2"> - <button class="btn-default form-control" data-bind="click: downloadCSV"> - {% trans "Export Groups" %} - </button> +<div class="row panel"> + <div class="col col-md-2"> + <span id="lock-groups-controls"> + <div class="controls"> + <button data-bind="visible: !groupsLocked(), click: toggleGroupLock" class="btn-default form-control"> + <i class="fa fa-lock"></i> + {% trans "Lock Case Grouping for Me" %} + </button> + <button data-bind="visible: groupsLocked(), click: toggleGroupLock" class="btn-primary form-control"> + <i class="fa fa-unlock"></i> + {% trans "Unlock Case Grouping for Me" %} + </button> + </div> + </span> + </div> + <div class="col col-md-2"> + <span id="export-controls"> + <div class="controls"> + <button class="btn-default form-control" data-bind="click: downloadCSV, disable: !groupsReady()"> + {% trans "Export Groups" %} + </button> + </div> + </span> </div> </div> +<div class="panel" id="polygon-filters"> + <div class="row"> + <label for="saved-polygons" class="control-label col-sm-2 col-md-2 col-lg-2"> + {% trans "Filter by Saved Area" %}</label> + <div class="controls col-sm-2 col-md-2 col-lg-2"> + <select id="saved-polygons" + class="form-control" + data-bind="select2: savedPolygons, + value: selectedSavedPolygonId, + "> + </select> + </div> + <button class="btn btn-default" data-bind="click: clearActivePolygon, enable: selectedSavedPolygonId"> + {% trans 'Clear' %} + </button> + </div> + <div class="alert alert-info" data-bind="visible: shouldRefreshPage"> + {% blocktrans %} + Please + <a href="">refresh the page</a> + to apply the polygon filtering changes. + {% endblocktrans %} + </div> +</div> <div id="case-grouping-map" style="height: 500px"></div> <div class="panel-body-datatable"> @@ -24,28 +66,75 @@ {% endif %} {% endblock reporttable %} </div> - -<div class="row panel" id="clusterStats"> - <div class="col-sm-6 col-md-6 col-lg-6"> - <table class="table table-striped table-bordered"> - <thead> - <th colspan="2">{% trans "Summary of Case Grouping" %}</th> - </thead> - <tbody> - <tr> - <td>{% trans "Total number of clusters" %}</td> - <td data-bind="text: totalClusters"></td> - </tr> - <tr> - <td>{% trans "Maximum cases per cluster" %}</td> - <td data-bind="text: clusterMaxCount"></td> - </tr> - <tr> - <td>{% trans "Minimum cases per cluster" %}</td> - <td data-bind="text: clusterMinCount"></td> - </tr> - </tbody> - </table> +<div class="row"> + <div class="col-sm-6" id="clusterStats"> + <table class="table table-striped table-bordered"> + <thead> + <th colspan="2">{% trans "Summary of Case Grouping" %}</th> + </thead> + <tbody> + <tr> + <td>{% trans "Total number of clusters" %}</td> + <td data-bind="text: totalClusters"></td> + </tr> + <tr> + <td>{% trans "Maximum cases per cluster" %}</td> + <td data-bind="text: clusterMaxCount"></td> + </tr> + <tr> + <td>{% trans "Minimum cases per cluster" %}</td> + <td data-bind="text: clusterMinCount"></td> + </tr> + </tbody> + </table> + </div> + <div class="col-sm-6 row" id="caseGroupSelect"> + <div> + <div style="max-height: 200px; overflow-y: auto;"> + <table class="table table-striped table-bordered"> + <thead> + <th colspan="2">{% trans "Select Case Groups to View" %}</th> + </thead> + <tbody data-bind="foreach: caseGroupsForTable"> + <tr> + <td data-bind="event: {mouseover: $parent.highlightGroup, mouseout: $parent.restoreMarkerOpacity}"> + <div class="checkbox"> + <label> + <input type="checkbox" data-bind="checked: $parent.visibleGroupIDs, checkedValue: groupId" /> + <span data-bind="text: name"></span> + <span data-bind="style: {color: color}">■</span> + </label> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <div class="row"> + <div class="col col-md-6"> + <button class="btn-default form-control" data-bind="click: showSelectedGroups(), disable: !groupsReady()"> + {% trans "Show Only Selected Groups on Map" %} + </button> + </div> + <div class="col col-md-6"> + <button class="btn-default form-control" data-bind="click: showAllGroups(), disable: !groupsReady()"> + {% trans "Show All Groups" %} + </button> + </div> + </div> + </div> + </div> </div> </div> -{% endblock %} \ No newline at end of file + +<script type="text/html" id="select-case"> + <div class="d-flex flex-row"> + <label data-bind="attr: {for: selectCssId}, text: title"></label> + <select class="form-control" data-bind="attr: {id: selectCssId}, + options: groupsOptions, optionsText: 'name', optionsValue: 'groupId', value: selectedGroup"> + </select> + </div> + <div data-bind="html: $data.itemData.caseLink"></div> +</script> + +{% endblock %} diff --git a/corehq/apps/geospatial/templates/geospatial/case_grouping_map_base.html b/corehq/apps/geospatial/templates/geospatial/case_grouping_map_base.html index 77209960a6b33..949aa838330e0 100644 --- a/corehq/apps/geospatial/templates/geospatial/case_grouping_map_base.html +++ b/corehq/apps/geospatial/templates/geospatial/case_grouping_map_base.html @@ -15,4 +15,5 @@ {% block additional_initial_page_data %}{{ block.super }} {% initial_page_data 'case_row_order' case_row_order %} + {% initial_page_data "saved_polygons" saved_polygons %} {% endblock %} diff --git a/corehq/apps/geospatial/templates/geospatial/map_visualization_base.html b/corehq/apps/geospatial/templates/geospatial/map_visualization_base.html index 7aed2a2d901ff..14a2f0eab1ef8 100644 --- a/corehq/apps/geospatial/templates/geospatial/map_visualization_base.html +++ b/corehq/apps/geospatial/templates/geospatial/map_visualization_base.html @@ -16,6 +16,7 @@ {% block additional_initial_page_data %}{{ block.super }} {% initial_page_data "saved_polygons" saved_polygons %} {% registerurl 'geo_polygon' domain %} + {% registerurl 'case_disbursement' domain %} {% registerurl 'get_users_with_gps' domain %} {% registerurl 'edit_commcare_user' domain '---' %} {% registerurl 'location_search' domain %} diff --git a/corehq/apps/geospatial/templates/gps_capture.html b/corehq/apps/geospatial/templates/gps_capture.html index 7585552d5b026..360f6dc32886d 100644 --- a/corehq/apps/geospatial/templates/gps_capture.html +++ b/corehq/apps/geospatial/templates/gps_capture.html @@ -89,7 +89,7 @@ <h3 class="panel-title"> </div> </td> <td> - <button type="button" class="btn btn-default" data-bind="event: {click: $root.captureLocationForItem.bind($data)}"> + <button type="button" class="btn btn-default" data-bind="event: {click: $root.captureLocationForItem.bind($data)}, disable: $root.isCreatingCase"> {% trans "Capture on Map" %} </button> <button type="button" class="btn btn-primary" data-bind="enable: canSaveRow, event: {click: $root.saveDataRow.bind($data)}"> @@ -135,10 +135,63 @@ <h3 class="panel-title"> showSpinner: showPaginationSpinner"></pagination> </div> </div> - <h3 data-bind="visible: itemLocationBeingCapturedOnMap"> - <div data-bind="with: itemLocationBeingCapturedOnMap"> - {% trans "Capturing location for:" %} - <span data-bind="text: name"></span> - </div> - </h3> + {% if data_type == 'case' %} + <button class="btn btn-primary" data-bind="click: startCreateCase, hidden: isCreatingCase, disable: itemLocationBeingCapturedOnMap"> + {% trans 'Create New Case' %} + </button> + <button class="btn btn-primary" data-bind="click: finishCreateCase, visible: isCreatingCase"> + {% trans 'Save Case' %} + </button> + <button class="btn btn-default" data-bind="click: cancelCreateCase, visible: isCreatingCase"> + {% trans 'Cancel' %} + </button> + {% endif %} + <div class="panel" data-bind="visible: itemLocationBeingCapturedOnMap"> + <div data-bind="with: itemLocationBeingCapturedOnMap" class="row"> + <h3 class="col"> + {% trans "Capturing location for:" %} + <span data-bind="text: name"></span> + </h3> + <div data-bind="visible: $root.isCreatingCase" class="form-row"> + <div class="col" data-bind="css: { 'has-error': $root.hasCreateCaseError }"> + <label class="control-label col-sm-1 col-md-1 col-lg-1"> + {% trans 'Case Name' %} + </label> + <div class="col-sm-2 col-md-2 col-lg-2" > + <input data-bind="value: name, visible: $root.isCreatingCase" type="text" class="form-control" placeholder="{% trans 'Enter new case name...' %}" /> + <span class="help-block" data-bind="visible: $root.hasCreateCaseError"> + {% trans 'A case name is required' %} + </span> + </div> + </div> + <div class="col" data-bind="css: { 'has-error': $root.hasCaseTypeError }"> + <label class="control-label col-sm-1 col-md-1 col-lg-1"> + {% trans 'Case Type' %} + </label> + <div class="col-sm-2 col-md-2 col-lg-2"> + <select class="form-control" data-bind="select2: $root.availableCaseTypes, value: $root.selectedCaseType"> + </select> + <span class="help-block" data-bind="visible: $root.hasCaseTypeError"> + {% trans 'A case type is required' %} + </span> + </div> + </div> + <div class="col"> + <label class="control-label col-sm-1 col-md-1 col-lg-1"> + {% trans 'Owner' %} + </label> + <div class="col-sm-3 col-md-3 col-lg-3"> + <select class="form-control" + type="text" + id="owner-select" + data-bind="select2: {}, + optionsText: 'text', + optionsValue: 'id', + event: {change: $root.onOwnerIdChange}"> + </select> + </div> + </div> + </div> + </div> + </div> </div> diff --git a/corehq/apps/geospatial/templates/gps_capture_view.html b/corehq/apps/geospatial/templates/gps_capture_view.html index 41cfac4ed222d..dfdcf350da875 100644 --- a/corehq/apps/geospatial/templates/gps_capture_view.html +++ b/corehq/apps/geospatial/templates/gps_capture_view.html @@ -17,10 +17,13 @@ {% block page_content %} {% initial_page_data 'data_type' data_type %} -{% registerurl 'get_paginated_cases_or_users_without_gps' domain %} +{% registerurl 'get_paginated_cases_or_users' domain %} {% registerurl 'case_data' domain '---' %} {% registerurl 'edit_commcare_user' domain '---' %} {% registerurl 'gps_capture' domain %} +{% registerurl 'paginate_mobile_workers' domain %} +{% initial_page_data 'case_types_with_gps' case_types_with_gps %} +{% initial_page_data 'couch_user_username' couch_user_username %} <ul id="tabs-list" class="nav nav-tabs"> <li data-bind="click: onclickAction" class="active"><a data-toggle="tab" href="#tabs-cases">{% trans 'Update Case Data' %}</a></li> diff --git a/corehq/apps/geospatial/templates/map_visualization.html b/corehq/apps/geospatial/templates/map_visualization.html index ac8773d664ef3..d1d3daa08c95a 100644 --- a/corehq/apps/geospatial/templates/map_visualization.html +++ b/corehq/apps/geospatial/templates/map_visualization.html @@ -74,7 +74,7 @@ <select id="saved-polygons" class="form-control" data-bind="select2: savedPolygons, - value: selectedPolygon, + value: selectedSavedPolygonId, "> </select> </div> @@ -84,6 +84,22 @@ <a id="btnSaveDrawnArea" class="col-sm-2 btn btn-default" style="float:right; margin-right:1em" data-bind="attr: { disabled: btnSaveDisabled }"> {% trans 'Save Area' %} </a> + <a id="btnRunDisbursement" class="col-sm-2 btn btn-primary" style="float:right; margin-right:1em" data-bind="attr: { disabled: btnRunDisbursementDisabled }"> + {% trans 'Run Disbursement' %} + </a> +</div> +<div id="disbursement-spinner"> + <h4 id="loading" class="hide" + data-bind="visible: isBusy(), css: {hide: false}"> + <i class="fa fa-spin fa-spinner"></i> + {% trans "Running disbursement algorithm..." %} + </h4> +</div> +<div id="disbursement-error" class="alert alert-danger" data-bind="visible: hasMissingData()"> + {% blocktrans %} + Please ensure that the filtered area includes both cases and mobile workers before + attempting to run disbursement. + {% endblocktrans %} </div> <div id="geospatial-map" style="height: 500px"></div> @@ -108,7 +124,7 @@ </a> <a class="btn btn-default" target="_blank" href="{% url 'gps_capture' domain %}?data_type=user" data-bind="visible: usersWithoutGPS().length"> <span data-bind="text: usersWithoutGPS().length"></span> -  {% trans "Users Missing GPS Data" %} +  {% trans "Mobile Workers Missing GPS Data" %} </a> </div> @@ -212,6 +228,7 @@ <h4 class="modal-title">{% trans "All Cases on Map" %}</h4> </div> <script type="text/html" id="select-case"> + <small data-bind="html: getItemType()"></small> <div class="form-check"> <input type="checkbox" class="form-check-input" data-bind="checked: isSelected, attr: {id: selectCssId}"> <label class="form-check-label" data-bind="html: $data.itemData.link, attr: {for: selectCssId}"></label> diff --git a/corehq/apps/geospatial/tests/test_es.py b/corehq/apps/geospatial/tests/test_es.py index d7b20e57dd45d..106d9740888ee 100644 --- a/corehq/apps/geospatial/tests/test_es.py +++ b/corehq/apps/geospatial/tests/test_es.py @@ -36,14 +36,19 @@ def test_find_precision(): @es_test(requires=[case_search_adapter], setup_class=True) class TestGetMaxDocCount(TestCase): - # See https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html#_simple_low_precision_request - # and https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html#_high_precision_requests - # for more context about this test + """ + Verify ``get_max_doc_count()`` using an example pulled from + Elasticsearch docs. + + For more context, see + https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html#_simple_low_precision_request + and https://www.elastic.co/guide/en/elasticsearch/reference/5.6/search-aggregations-bucket-geohashgrid-aggregation.html#_high_precision_requests + """ # noqa: E501 @classmethod def setUpClass(cls): super().setUpClass() - with flag_enabled('USH_CASE_CLAIM_UPDATES'): + with flag_enabled('GEOSPATIAL'): case_search_es_setup(DOMAIN, cls._get_case_blocks()) @staticmethod diff --git a/corehq/apps/geospatial/tests/test_ortools.py b/corehq/apps/geospatial/tests/test_pulp.py similarity index 74% rename from corehq/apps/geospatial/tests/test_ortools.py rename to corehq/apps/geospatial/tests/test_pulp.py index aaed8c778eb21..5ae46a2e45766 100644 --- a/corehq/apps/geospatial/tests/test_ortools.py +++ b/corehq/apps/geospatial/tests/test_pulp.py @@ -1,16 +1,16 @@ from django.test import SimpleTestCase -from corehq.apps.geospatial.routing_solvers.ortools import ( - ORToolsRadialDistanceSolver +from corehq.apps.geospatial.routing_solvers.pulp import ( + RadialDistanceSolver ) -class TestORToolsRadialDistanceSolver(SimpleTestCase): +class TestRadialDistanceSolver(SimpleTestCase): # Tests the correctness of the code, not the optimumness of the solution def test(self): self.assertEqual( - ORToolsRadialDistanceSolver( + RadialDistanceSolver( { "users": [ {"id": "New York", "lon": -73.9750671, "lat": 40.7638143}, @@ -27,10 +27,11 @@ def test(self): {"id": "Jackson", "lat": 40.55517003526139, "lon": -106.34189549259928}, ], }, - 1000000000, - ).solve(), - { - 'New York': ['New Hampshire', 'Newark', 'NY2'], - 'Los Angeles': ['Phoenix', 'LA2', 'LA3', 'Dallas', 'Jackson'] - } + ).solve(), ( + None, + { + 'New York': ['New Hampshire', 'Newark', 'NY2'], + 'Los Angeles': ['Phoenix', 'LA2', 'LA3', 'Dallas', 'Jackson'] + } + ) ) diff --git a/corehq/apps/geospatial/tests/test_reports.py b/corehq/apps/geospatial/tests/test_reports.py index 87bcbbe05985b..dea6856edceae 100644 --- a/corehq/apps/geospatial/tests/test_reports.py +++ b/corehq/apps/geospatial/tests/test_reports.py @@ -1,14 +1,22 @@ -from nose.tools import assert_equal +import doctest +import json +from contextlib import contextmanager -from django.test import TestCase -from django.test.client import RequestFactory - -from corehq.apps.users.models import WebUser +from nose.tools import assert_equal, assert_raises +from corehq.apps.es import case_search_adapter +from corehq.apps.es.tests.utils import es_test from corehq.apps.geospatial.reports import ( - geojson_to_es_geoshape, CaseGroupingReport, + geojson_to_es_geoshape, ) +from corehq.apps.geospatial.utils import ( + get_geo_case_property, + validate_geometry, +) +from corehq.apps.hqcase.case_helper import CaseHelper +from corehq.apps.reports.tests.test_sql_reports import DOMAIN, BaseReportTest +from corehq.util.test_utils import flag_enabled def test_geojson_to_es_geoshape(): @@ -29,30 +37,38 @@ def test_geojson_to_es_geoshape(): }) -class TestCaseGroupingReport(TestCase): - domain = 'test-domain' +def test_validate_geometry_type(): + geojson_geometry = { + "type": "Point", + "coordinates": [125.6, 10.1] + } + with assert_raises(ValueError): + validate_geometry(geojson_geometry) + + +def test_validate_geometry_schema(): + geojson_geometry = { + "type": "Polygon", + "coordinates": [125.6, 10.1] + } + with assert_raises(ValueError): + validate_geometry(geojson_geometry) - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.user = WebUser(username='test@cchq.com', domain=cls.domain) - cls.user.save() - cls.request_factory = RequestFactory() - @classmethod - def tearDownClass(cls): - cls.user.delete(cls.domain, deleted_by=None) - super().tearDownClass() +@flag_enabled('GEOSPATIAL') +@es_test(requires=[case_search_adapter], setup_class=True) +class TestCaseGroupingReport(BaseReportTest): - def _create_dummy_request(self): - request = self.request_factory.get('/some/url') - request.couch_user = self.user - request.domain = self.domain + def _get_request(self, **kwargs): + request = self.factory.get('/some/url', **kwargs) + request.couch_user = self.couch_user + request.domain = DOMAIN + request.can_access_all_locations = True return request def test_case_row_order(self): - request = self._create_dummy_request() - report_obj = CaseGroupingReport(request, domain=self.domain) + request = self._get_request() + report_obj = CaseGroupingReport(request, domain=DOMAIN) report_obj.rendered_as = 'view' context_data = report_obj.template_context expected_columns = ['case_id', 'gps_point', 'link'] @@ -60,3 +76,480 @@ def test_case_row_order(self): list(context_data['case_row_order'].keys()), expected_columns ) + + def test_bucket_cases(self): + with self.get_cases() as (porto_novo, bohicon, lagos): + request = self._get_request() + report = CaseGroupingReport( + request, + domain=DOMAIN, + in_testing=True, + ) + json_data = report.json_dict['aaData'] + case_ids = [row[0] for row in json_data] + + self.assertEqual(len(json_data), 3) + self.assertIn(porto_novo.case_id, case_ids) + self.assertIn(bohicon.case_id, case_ids) + self.assertIn(lagos.case_id, case_ids) + + def test_bucket_and_polygon_with_hole(self): + with self.get_cases() as (porto_novo, bohicon, lagos): + request = self._get_request(data={ + 'features': json.dumps(polygon_with_hole) + }) + report = CaseGroupingReport( + request, + domain=DOMAIN, + in_testing=True, + ) + json_data = report.json_dict['aaData'] + case_ids = [row[0] for row in json_data] + + self.assertEqual(len(json_data), 1) + self.assertIn(porto_novo.case_id, case_ids) + + @contextmanager + def get_cases(self): + + def create_case(name, coordinates): + helper = CaseHelper(domain=DOMAIN) + helper.create_case({ + 'case_type': 'ville', + 'case_name': name, + 'properties': { + geo_property: f'{coordinates} 0 0', + } + }) + return helper.case + + geo_property = get_geo_case_property(DOMAIN) + porto_novo = create_case('Porto-Novo', '6.497222 2.605') + bohicon = create_case('Bohicon', '7.2 2.066667') + lagos = create_case('Lagos', '6.455027 3.384082') + case_search_adapter.bulk_index([ + porto_novo, + bohicon, + lagos + ], refresh=True) + + try: + yield porto_novo, bohicon, lagos + + finally: + case_search_adapter.bulk_delete([ + porto_novo.case_id, + bohicon.case_id, + lagos.case_id + ], refresh=True) + porto_novo.delete() + bohicon.delete() + lagos.delete() + + +def test_doctests(): + import corehq.apps.geospatial.reports as reports + + results = doctest.testmod(reports) + assert results.failed == 0 + + +def test_filter_for_two_polygons(): + two_polygons = { + "2a5ea4f248e76d593d1860fd30ff4d7a": { + "id": "2a5ea4f248e76d593d1860fd30ff4d7a", + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 3.8611289319507023, + 10.678207690092108 + ], + [ + 2.224654018272389, + 10.527070203861257 + ], + [ + 2.2356370713843887, + 10.062403156135844 + ], + [ + 1.3460097693168223, + 9.975878699953796 + ], + [ + 1.3789589286527928, + 9.293710611914307 + ], + [ + 1.6645183095633342, + 8.957545872249298 + ], + [ + 1.6205860971153925, + 6.988931314042162 + ], + [ + 1.8072980000180792, + 6.18155446649547 + ], + [ + 2.729874461421531, + 6.279818038095172 + ], + [ + 2.8067558332044484, + 9.011787334706497 + ], + [ + 3.1032982672269895, + 9.098556718238612 + ], + [ + 3.8611289319507023, + 10.678207690092108 + ] + ] + ] + } + }, + "f4d8cc04529c256645dd6515f21f5c73": { + "id": "f4d8cc04529c256645dd6515f21f5c73", + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 2.554145611629849, + 7.023269537230718 + ], + [ + 2.4333320273990466, + 7.469976682832254 + ], + [ + 2.2136709651603894, + 7.622407688138196 + ], + [ + 1.7084505220112192, + 7.644179142046397 + ], + [ + 1.9940099029217606, + 6.892443542434833 + ], + [ + 2.554145611629849, + 7.023269537230718 + ] + ] + ] + } + } + } + expected_filter = { + 'bool': { + 'should': ( + { + 'geo_polygon': { + 'case_properties.geopoint_value': { + 'points': [ + [ + 3.8611289319507023, + 10.678207690092108 + ], + [ + 2.224654018272389, + 10.527070203861257 + ], + [ + 2.2356370713843887, + 10.062403156135844 + ], + [ + 1.3460097693168223, + 9.975878699953796 + ], + [ + 1.3789589286527928, + 9.293710611914307 + ], + [ + 1.6645183095633342, + 8.957545872249298 + ], + [ + 1.6205860971153925, + 6.988931314042162 + ], + [ + 1.8072980000180792, + 6.18155446649547 + ], + [ + 2.729874461421531, + 6.279818038095172 + ], + [ + 2.8067558332044484, + 9.011787334706497 + ], + [ + 3.1032982672269895, + 9.098556718238612 + ], + [ + 3.8611289319507023, + 10.678207690092108 + ] + ] + } + } + }, + { + 'geo_polygon': { + 'case_properties.geopoint_value': { + 'points': [ + [ + 2.554145611629849, + 7.023269537230718 + ], + [ + 2.4333320273990466, + 7.469976682832254 + ], + [ + 2.2136709651603894, + 7.622407688138196 + ], + [ + 1.7084505220112192, + 7.644179142046397 + ], + [ + 1.9940099029217606, + 6.892443542434833 + ], + [ + 2.554145611629849, + 7.023269537230718 + ] + ] + } + } + } + ) + } + } + features_json = json.dumps(two_polygons) + actual_filter = CaseGroupingReport._get_filter_for_features(features_json) + assert_equal(actual_filter, expected_filter) + + +def test_one_polygon_with_hole(): + expected_filter = { + 'bool': { + 'should': ( + { + 'bool': { + 'filter': ( + { + 'geo_polygon': { + 'case_properties.geopoint_value': { + 'points': [ + [ + 3.8611289319507023, + 10.678207690092108 + ], + [ + 2.224654018272389, + 10.527070203861257 + ], + [ + 2.2356370713843887, + 10.062403156135844 + ], + [ + 1.3460097693168223, + 9.975878699953796 + ], + [ + 1.3789589286527928, + 9.293710611914307 + ], + [ + 1.6645183095633342, + 8.957545872249298 + ], + [ + 1.6205860971153925, + 6.988931314042162 + ], + [ + 1.8072980000180792, + 6.18155446649547 + ], + [ + 2.729874461421531, + 6.279818038095172 + ], + [ + 2.8067558332044484, + 9.011787334706497 + ], + [ + 3.1032982672269895, + 9.098556718238612 + ], + [ + 3.8611289319507023, + 10.678207690092108 + ] + ] + } + } + }, + { + 'bool': { + 'must_not': { + 'geo_polygon': { + 'case_properties.geopoint_value': { + 'points': [ + [ + 2.554145611629849, + 7.023269537230718 + ], + [ + 1.9940099029217606, + 6.892443542434833 + ], + [ + 1.7084505220112192, + 7.644179142046397 + ], + [ + 2.2136709651603894, + 7.622407688138196 + ], + [ + 2.4333320273990466, + 7.469976682832254 + ], + [ + 2.554145611629849, + 7.023269537230718 + ] + ] + } + } + } + } + } + ) + } + }, # <-- Note the comma: The value of "should" is a tuple. + ) + } + } + features_json = json.dumps(polygon_with_hole) + actual_filter = CaseGroupingReport._get_filter_for_features(features_json) + assert_equal(actual_filter, expected_filter) + + +polygon_with_hole = { + "2a5ea4f248e76d593d1860fd30ff4d7a": { + "id": "2a5ea4f248e76d593d1860fd30ff4d7a", + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + # External ring. Points listed counterclockwise. + [ + 3.8611289319507023, + 10.678207690092108 + ], + [ + 2.224654018272389, + 10.527070203861257 + ], + [ + 2.2356370713843887, + 10.062403156135844 + ], + [ + 1.3460097693168223, + 9.975878699953796 + ], + [ + 1.3789589286527928, + 9.293710611914307 + ], + [ + 1.6645183095633342, + 8.957545872249298 + ], + [ + 1.6205860971153925, + 6.988931314042162 + ], + [ + 1.8072980000180792, + 6.18155446649547 + ], + [ + 2.729874461421531, + 6.279818038095172 + ], + [ + 2.8067558332044484, + 9.011787334706497 + ], + [ + 3.1032982672269895, + 9.098556718238612 + ], + [ + 3.8611289319507023, + 10.678207690092108 + ] + ], + + # Hole. Points listed clockwise. + [ + [ + 2.554145611629849, + 7.023269537230718 + ], + [ + 1.9940099029217606, + 6.892443542434833 + ], + [ + 1.7084505220112192, + 7.644179142046397 + ], + [ + 2.2136709651603894, + 7.622407688138196 + ], + [ + 2.4333320273990466, + 7.469976682832254 + ], + [ + 2.554145611629849, + 7.023269537230718 + ] + ] + ] + } + } +} diff --git a/corehq/apps/geospatial/tests/test_utils.py b/corehq/apps/geospatial/tests/test_utils.py index c450bb6102e58..fbec0f277c65a 100644 --- a/corehq/apps/geospatial/tests/test_utils.py +++ b/corehq/apps/geospatial/tests/test_utils.py @@ -14,6 +14,7 @@ get_geo_user_property, set_case_gps_property, set_user_gps_property, + create_case_with_gps_property, ) from corehq.apps.geospatial.const import GPS_POINT_CASE_PROPERTY @@ -90,6 +91,21 @@ def test_set_case_gps_property(self): case_obj = CommCareCase.objects.get_case(self.case_obj.case_id, self.DOMAIN) self.assertEqual(case_obj.case_json[GPS_POINT_CASE_PROPERTY], '1.23 4.56 0.0 0.0') + def test_create_case_with_gps_property(self): + case_type = 'gps-case' + submit_data = { + 'name': 'CaseB', + 'lat': '1.23', + 'lon': '4.56', + 'case_type': case_type, + 'owner_id': self.user.user_id, + } + create_case_with_gps_property(self.DOMAIN, submit_data) + case_list = CommCareCase.objects.get_case_ids_in_domain(self.DOMAIN, case_type) + self.assertEqual(len(case_list), 1) + case_obj = CommCareCase.objects.get_case(case_list[0], self.DOMAIN) + self.assertEqual(case_obj.case_json[GPS_POINT_CASE_PROPERTY], '1.23 4.56 0.0 0.0') + def test_set_user_gps_property(self): submit_data = { 'id': self.user.user_id, @@ -99,4 +115,4 @@ def test_set_user_gps_property(self): } set_user_gps_property(self.DOMAIN, submit_data) user = CommCareUser.get_by_user_id(self.user.user_id, self.DOMAIN) - self.assertEqual(user.metadata[GPS_POINT_CASE_PROPERTY], '1.23 4.56 0.0 0.0') + self.assertEqual(user.get_user_data(self.DOMAIN)[GPS_POINT_CASE_PROPERTY], '1.23 4.56 0.0 0.0') diff --git a/corehq/apps/geospatial/tests/test_views.py b/corehq/apps/geospatial/tests/test_views.py index bc35478c27b48..1a00239420826 100644 --- a/corehq/apps/geospatial/tests/test_views.py +++ b/corehq/apps/geospatial/tests/test_views.py @@ -188,7 +188,7 @@ def test_success(self): @es_test(requires=[case_search_adapter, user_adapter], setup_class=True) class TestGetPaginatedCasesOrUsers(BaseGeospatialViewClass): - urlname = 'get_paginated_cases_or_users_without_gps' + urlname = 'get_paginated_cases_or_users' @classmethod def setUpClass(cls): @@ -219,7 +219,7 @@ def setUpClass(cls): password='1234', created_by=None, created_via=None, - metadata={GPS_POINT_CASE_PROPERTY: '12.34 45.67'} + user_data={GPS_POINT_CASE_PROPERTY: '12.34 45.67'} ) cls.user_b = CommCareUser.create( cls.domain, @@ -303,7 +303,7 @@ def setUpClass(cls): password='1234', created_by=None, created_via=None, - metadata={GPS_POINT_CASE_PROPERTY: '12.34 45.67'}, + user_data={GPS_POINT_CASE_PROPERTY: '12.34 45.67'}, location=cls.country_a, ) cls.user_b = CommCareUser.create( @@ -320,7 +320,7 @@ def setUpClass(cls): password='1234', created_by=None, created_via=None, - metadata={GPS_POINT_CASE_PROPERTY: '45.67 12.34'}, + user_data={GPS_POINT_CASE_PROPERTY: '45.67 12.34'}, ) user_adapter.bulk_index([cls.user_a, cls.user_b, cls.user_c], refresh=True) diff --git a/corehq/apps/geospatial/urls.py b/corehq/apps/geospatial/urls.py index d28111a0b4195..b06fcb7755ffc 100644 --- a/corehq/apps/geospatial/urls.py +++ b/corehq/apps/geospatial/urls.py @@ -6,10 +6,12 @@ GeospatialConfigPage, GPSCaptureView, MapboxOptimizationV2, + CaseDisbursementAlgorithm, geospatial_default, - get_paginated_cases_or_users_without_gps, + get_paginated_cases_or_users, get_users_with_gps, mapbox_routing_status, + routing_status_view, ) urlpatterns = [ @@ -19,13 +21,19 @@ url(r'^mapbox_routing/$', MapboxOptimizationV2.as_view(), name=MapboxOptimizationV2.urlname), + url(r'^run_disbursement/$', + CaseDisbursementAlgorithm.as_view(), + name=CaseDisbursementAlgorithm.urlname), url(r'^mapbox_routing_status/(?P<poll_id>[\w-]+)/', mapbox_routing_status, name="mapbox_routing_status"), + url(r'^routing_status/(?P<poll_id>[\w-]+)/', + routing_status_view, + name="routing_status"), url(r'^settings/$', GeospatialConfigPage.as_view(), name=GeospatialConfigPage.urlname), - url(r'^gps_capture/json/$', get_paginated_cases_or_users_without_gps, - name='get_paginated_cases_or_users_without_gps'), + url(r'^gps_capture/json/$', get_paginated_cases_or_users, + name='get_paginated_cases_or_users'), url(r'^gps_capture/$', GPSCaptureView.as_view(), name=GPSCaptureView.urlname), url(r'^users/json/$', get_users_with_gps, name='get_users_with_gps'), diff --git a/corehq/apps/geospatial/utils.py b/corehq/apps/geospatial/utils.py index 8fb5705d13459..6939a8997ce70 100644 --- a/corehq/apps/geospatial/utils.py +++ b/corehq/apps/geospatial/utils.py @@ -1,3 +1,4 @@ +import jsonschema from jsonobject.exceptions import BadValueError from couchforms.geopoint import GeoPoint @@ -31,24 +32,35 @@ def _format_coordinates(lat, lon): return f"{lat} {lon} 0.0 0.0" -def set_case_gps_property(domain, case_data): +def create_case_with_gps_property(domain, case_data): location_prop_name = get_geo_case_property(domain) - helper = CaseHelper(domain=domain, case_id=case_data['id']) - case_data = { + data = { 'properties': { location_prop_name: _format_coordinates(case_data['lat'], case_data['lon']) - } + }, + 'case_name': case_data['name'], + 'case_type': case_data['case_type'], + 'owner_id': case_data['owner_id'], } + helper = CaseHelper(domain=domain) + helper.create_case(data, user_id=case_data['owner_id']) + - helper.update(case_data) +def set_case_gps_property(domain, case_data, create_case=False): + location_prop_name = get_geo_case_property(domain) + data = { + 'properties': { + location_prop_name: _format_coordinates(case_data['lat'], case_data['lon']) + } + } + helper = CaseHelper(domain=domain, case_id=case_data['id']) + helper.update(data) def set_user_gps_property(domain, user_data): location_prop_name = get_geo_user_property(domain) user = CommCareUser.get_by_user_id(user_data['id']) - metadata = user.metadata - metadata[location_prop_name] = _format_coordinates(user_data['lat'], user_data['lon']) - user.update_metadata(metadata) + user.get_user_data(domain)[location_prop_name] = _format_coordinates(user_data['lat'], user_data['lon']) user.save() @@ -60,3 +72,96 @@ def get_lat_lon_from_dict(data, key): except (KeyError, BadValueError): lat, lon = ('', '') return lat, lon + + +def validate_geometry(geojson_geometry): + """ + Validates the GeoJSON geometry, and checks that its type is + supported. + """ + # Case properties that are set as the "GPS" data type in the Data + # Dictionary are given the ``geo_point`` data type in Elasticsearch. + # + # In Elasticsearch 8+, the flexible ``geo_shape`` query supports the + # ``geo_point`` type, but Elasticsearch 5.6 does not. Instead, we + # have to filter GPS case properties using the ``geo_polygon`` + # query, which is **deprecated in Elasticsearch 7.12**. + # + # TODO: After Elasticsearch is upgraded, switch from + # filters.geo_polygon to filters.geo_shape, and update this + # list of supported types. See filters.geo_shape for more + # details. (Norman, 2023-11-01) + supported_types = ( + 'Polygon', # Supported by Elasticsearch 5.6 + + # Available for the `geo_point` data type using the `geo_shape` + # filter from Elasticsearch 8+: + # + # 'Point', + # 'LineString', + # 'MultiPoint', + # 'MultiLineString', + # 'MultiPolygon', + + # 'GeometryCollection', YAGNI + ) + if geojson_geometry['type'] not in supported_types: + raise ValueError( + f"{geojson_geometry['type']} is not a supported geometry type" + ) + + # The complete schema is at https://geojson.org/schema/GeoJSON.json + schema = { + "type": "object", + "required": ["type", "coordinates"], + "properties": { + "type": { + "type": "string" + }, + "coordinates": { + "type": "array", + # Polygon-specific properties: + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + # Unused but valid + "bbox": { + "type": "array", + "minItems": 4, + "items": { + "type": "number" + } + } + } + } + try: + jsonschema.validate(geojson_geometry, schema) + except jsonschema.ValidationError as err: + raise ValueError( + f'{geojson_geometry!r} is not a valid GeoJSON geometry' + ) from err + + +def geojson_to_es_geoshape(geojson): + """ + Given a GeoJSON dict, returns a GeoJSON Geometry dict, with "type" + given as an Elasticsearch type (i.e. in lowercase). + + More info: + + * `The GeoJSON specification (RFC 7946) <https://datatracker.ietf.org/doc/html/rfc7946>`_ + * `Elasticsearch types <https://www.elastic.co/guide/en/elasticsearch/reference/5.6/geo-shape.html#input-structure>`_ + + """ # noqa: E501 + es_geoshape = geojson['geometry'].copy() + es_geoshape['type'] = es_geoshape['type'].lower() + return es_geoshape diff --git a/corehq/apps/geospatial/views.py b/corehq/apps/geospatial/views.py index a41261553509d..07793b71c5895 100644 --- a/corehq/apps/geospatial/views.py +++ b/corehq/apps/geospatial/views.py @@ -33,6 +33,7 @@ from corehq.apps.hqwebapp.decorators import use_datatables, use_jquery_ui from corehq.apps.reports.generic import get_filter_classes from corehq.apps.reports.standard.cases.basic import CaseListMixin +from corehq.apps.reports.standard.cases.filters import CaseSearchFilter from corehq.apps.users.dbaccessors import get_mobile_users_by_filters from corehq.apps.users.models import CommCareUser from corehq.form_processor.models import CommCareCase @@ -51,6 +52,7 @@ get_lat_lon_from_dict, set_case_gps_property, set_user_gps_property, + create_case_with_gps_property, ) @@ -88,6 +90,29 @@ def mapbox_routing_status(request, domain, poll_id): return routing_status(poll_id) +def routing_status_view(request, domain, poll_id): + # Todo; handle HTTPErrors + return json_response({ + 'result': routing_status(poll_id) + }) + + +class CaseDisbursementAlgorithm(BaseDomainView): + urlname = "case_disbursement" + + def post(self, request, domain, *args, **kwargs): + solver_class = GeoConfig.objects.get(domain=domain).disbursement_solver + request_json = json.loads(request.body.decode('utf-8')) + poll_id, result = solver_class(request_json).solve() + if poll_id is None: + return json_response( + {'result': result} + ) + return json_response({ + "poll_url": reverse("routing_status", args=[self.domain, poll_id]) + }) + + class GeoPolygonView(BaseDomainView): urlname = 'geo_polygon' @@ -139,7 +164,7 @@ def post(self, request, *args, **kwargs): class BaseConfigView(BaseDomainView): - section_name = _("Geospatial") + section_name = _("Data") @method_decorator(toggles.GEOSPATIAL.required_decorator()) def dispatch(self, request, *args, **kwargs): @@ -222,7 +247,7 @@ class GPSCaptureView(BaseDomainView): template_name = 'gps_capture_view.html' page_name = _("Manage GPS Data") - section_name = _("Geospatial") + section_name = _("Data") fields = [ 'corehq.apps.reports.filters.case_list.CaseListFilter', @@ -248,8 +273,15 @@ def page_url(self): @property def page_context(self): + case_types = CaseProperty.objects.filter( + case_type__domain=self.domain, + data_type=CaseProperty.DataType.GPS, + ).values_list('case_type__name', flat=True).distinct() + page_context = { 'mapbox_access_token': settings.MAPBOX_ACCESS_TOKEN, + 'case_types_with_gps': list(case_types), + 'couch_user_username': self.request.couch_user.raw_username, } page_context.update(self._case_filters_context()) return page_context @@ -279,9 +311,14 @@ def post(self, request, *args, **kwargs): json_data = json.loads(request.body) data_type = json_data.get('data_type', None) data_item = json_data.get('data_item', None) + create_case = json_data.get('create_case', False) if data_type == 'case': - set_case_gps_property(request.domain, data_item) + if create_case: + data_item['owner_id'] = data_item['owner_id'] or request.couch_user.user_id + create_case_with_gps_property(request.domain, data_item) + else: + set_case_gps_property(request.domain, data_item) elif data_type == 'user': set_user_gps_property(request.domain, data_item) @@ -292,7 +329,7 @@ def post(self, request, *args, **kwargs): @require_GET @login_and_domain_required -def get_paginated_cases_or_users_without_gps(request, domain): +def get_paginated_cases_or_users(request, domain): page = int(request.GET.get('page', 1)) limit = int(request.GET.get('limit', 5)) query = request.GET.get('query', '') @@ -301,7 +338,7 @@ def get_paginated_cases_or_users_without_gps(request, domain): if case_or_user == 'user': data = _get_paginated_users_without_gps(domain, page, limit, query) else: - data = GetPaginatedCases(request, domain).get_paginated_cases_without_gps(domain, page, limit, query) + data = GetPaginatedCases(request, domain).get_paginated_cases_without_gps(domain, page, limit) return JsonResponse(data) @@ -322,7 +359,7 @@ def _base_query(self): .domain(self.domain) ) - def get_paginated_cases_without_gps(self, domain, page, limit, query): + def get_paginated_cases_without_gps(self, domain, page, limit): show_cases_with_missing_gps_data_only = True if GPSDataFilter(self.request, self.domain).show_all: @@ -332,11 +369,12 @@ def get_paginated_cases_without_gps(self, domain, page, limit, query): location_prop_name = get_geo_case_property(domain) if show_cases_with_missing_gps_data_only: cases_query = cases_query.case_property_missing(location_prop_name) - cases_query = ( - cases_query - .search_string_query(query, ['name']) - .sort('server_modified_on', desc=True) - ) + + search_string = CaseSearchFilter.get_value(self.request, self.domain) + if search_string: + cases_query = cases_query.set_query({"query_string": {"query": search_string}}) + + cases_query = cases_query.sort('server_modified_on', desc=True) case_ids = cases_query.get_ids() paginator = Paginator(case_ids, limit) @@ -365,7 +403,7 @@ def _get_paginated_users_without_gps(domain, page, limit, query): UserES() .domain(domain) .mobile_users() - .missing_or_empty_metadata_property(location_prop_name) + .missing_or_empty_user_data_property(location_prop_name) .search_string_query(query, ['username']) .sort('created_on', desc=True) ) @@ -408,7 +446,7 @@ def get_users_with_gps(request, domain): { 'id': user.user_id, 'username': user.raw_username, - 'gps_point': user.metadata.get(location_prop_name, ''), + 'gps_point': user.get_user_data(domain).get(location_prop_name, ''), } for user in users ] diff --git a/corehq/apps/hqadmin/reports.py b/corehq/apps/hqadmin/reports.py index 24ec652a5920b..35fc509b7599a 100644 --- a/corehq/apps/hqadmin/reports.py +++ b/corehq/apps/hqadmin/reports.py @@ -12,7 +12,6 @@ from phonelog.reports import BaseDeviceLogReport from corehq.apps.auditcare.utils.export import navigation_events_by_user -from corehq.apps.es import users as user_es from corehq.apps.reports.datatables import DataTablesColumn, DataTablesHeader from corehq.apps.reports.dispatcher import AdminReportDispatcher from corehq.apps.reports.generic import GenericTabularReport, GetParamsMixin @@ -21,6 +20,7 @@ from corehq.apps.sms.filters import RequiredPhoneNumberFilter from corehq.apps.sms.mixin import apply_leniency from corehq.apps.sms.models import PhoneNumber +from corehq.apps.users.dbaccessors import get_all_user_search_query from corehq.const import SERVER_DATETIME_FORMAT from corehq.apps.hqadmin.models import HqDeploy @@ -240,15 +240,8 @@ def rows(self): ] def _users_query(self): - query = (user_es.UserES() - .remove_default_filters() - .OR(user_es.web_users(), user_es.mobile_users())) - if 'search_string' in self.request.GET: - search_string = self.request.GET['search_string'] - fields = ['username', 'first_name', 'last_name', 'phone_numbers', - 'domain_membership.domain', 'domain_memberships.domain'] - query = query.search_string_query(search_string, fields) - return query + search_string = self.request.GET.get('search_string', None) + return get_all_user_search_query(search_string) def _get_page(self, query): return (query diff --git a/corehq/apps/hqmedia/tasks.py b/corehq/apps/hqmedia/tasks.py index 3b61145118da5..5b98d1eea492b 100644 --- a/corehq/apps/hqmedia/tasks.py +++ b/corehq/apps/hqmedia/tasks.py @@ -155,6 +155,7 @@ def _get_file_path(app, include_multimedia_files, include_index_files, build_pro fpath += '-targeted' else: dummy, fpath = tempfile.mkstemp() + os.close(dummy) return fpath diff --git a/corehq/apps/hqwebapp/_fonts/commcarehq_font/CommCareHQFont.glyphs b/corehq/apps/hqwebapp/_fonts/commcarehq_font/CommCareHQFont.glyphs index 466d409d5c87b..acdabd0472b4d 100644 --- a/corehq/apps/hqwebapp/_fonts/commcarehq_font/CommCareHQFont.glyphs +++ b/corehq/apps/hqwebapp/_fonts/commcarehq_font/CommCareHQFont.glyphs @@ -1,63 +1,59 @@ { -.appVersion = "118"; -DisplayStrings = ( -"/commcare_flower" -); copyright = "2013 Dimagi, Inc."; customParameters = ( { name = glyphOrder; value = ( -.notdef, -uni0000, -uni000D, -space, -uni25FC, -commcare_flower, -qtype_text, -qtype_numeric, -qtype_hidden_value, -qtype_calculation, -qtype_variable, -qtype_single_select, -qtype_multi_select, -qtype_decimal, -qtype_long, -qtyle_datetime, -qtype_audio_capture, -qtype_android_intent, -qtype_signature, -dashboard_apps, -commtrack, -qtype_multi_select_box, -qtype_single_select_circle, -dashboard_reports, -dashboard_data, -dashboard_users, -dashboard_settings, -dashboard_help, -dashboard_exchange, -dashboard_messaging, -qtype_balance, -dashboard_chart, -dashboard_forms, -dashboard_userreport, -dashboard_datatable, -dashboard_pie, -dashboard_survey, -dashboard_casemgt, -dashboard_blankform, -qtype_external_case, -qtype_external_case_data, -fd_expand, -fd_collapse, -fd_case_property, -fd_edit_form, -project_globe, -appbuilder_createform, -appbuilder_updateform, -appbuilder_completeform, -appbuilder_biometrics + ".notdef", + uni0000, + uni000D, + space, + uni25FC, + "commcare_flower", + "qtype_text", + "qtype_numeric", + "qtype_hidden_value", + "qtype_calculation", + "qtype_variable", + "qtype_single_select", + "qtype_multi_select", + "qtype_decimal", + "qtype_long", + "qtyle_datetime", + "qtype_audio_capture", + "qtype_android_intent", + "qtype_signature", + "dashboard_apps", + commtrack, + "qtype_multi_select_box", + "qtype_single_select_circle", + "dashboard_reports", + "dashboard_data", + "dashboard_users", + "dashboard_settings", + "dashboard_help", + "dashboard_exchange", + "dashboard_messaging", + "qtype_balance", + "dashboard_chart", + "dashboard_forms", + "dashboard_userreport", + "dashboard_datatable", + "dashboard_pie", + "dashboard_survey", + "dashboard_casemgt", + "dashboard_blankform", + "qtype_external_case", + "qtype_external_case_data", + "fd_expand", + "fd_collapse", + "fd_case_property", + "fd_edit_form", + "project_globe", + "appbuilder_createform", + "appbuilder_updateform", + "appbuilder_completeform", + "appbuilder_biometrics" ); } ); @@ -91,7 +87,7 @@ name = underlineThickness; value = 57; } ); -descender = -293; +descender = "-293"; id = "04DF3276-53F3-45FA-B502-39A9AACD583E"; weightValue = 400; widthValue = 5; @@ -104,6 +100,7 @@ glyphname = .notdef; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; width = 685; } ); @@ -113,6 +110,7 @@ glyphname = uni0000; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; width = 0; } ); @@ -123,6 +121,7 @@ glyphname = uni000D; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; width = 682; } ); @@ -133,6 +132,7 @@ glyphname = space; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; width = 685; } ); @@ -143,6 +143,7 @@ glyphname = uni25FC; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; width = 500; } ); @@ -150,231 +151,482 @@ unicode = 25FC; }, { glyphname = commcare_flower; -lastChange = "2023-10-13 13:43:49 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; nodes = ( -"1965 1125 OFFCURVE", -"1876 1239 OFFCURVE", -"1751 1310 CURVE", -"1687 1344 OFFCURVE", -"1619 1365 OFFCURVE", -"1549 1372 CURVE", -"1545 1402 OFFCURVE", -"1540 1432 OFFCURVE", -"1532 1461 CURVE", -"1492 1598 OFFCURVE", -"1402 1712 OFFCURVE", -"1277 1781 CURVE", -"1154 1852 OFFCURVE", -"1011 1868 OFFCURVE", -"873 1829 CURVE", -"738 1790 OFFCURVE", -"624 1700 OFFCURVE", -"553 1576 CURVE", -"519 1513 OFFCURVE", -"498 1442 OFFCURVE", -"491 1372 CURVE", -"461 1369 OFFCURVE", -"431 1363 OFFCURVE", -"402 1356 CURVE", -"265 1318 OFFCURVE", -"151 1228 OFFCURVE", -"82 1103 CURVE", -"11 980 OFFCURVE", -"-6 834 OFFCURVE", -"34 700 CURVE", -"73 562 OFFCURVE", -"163 448 OFFCURVE", -"287 379 CURVE", -"350 343 OFFCURVE", -"421 321 OFFCURVE", -"491 313 CURVE", -"494 285 OFFCURVE", -"500 255 OFFCURVE", -"507 225 CURVE", -"545 88 OFFCURVE", -"635 -24 OFFCURVE", -"760 -94 CURVE", -"883 -164 OFFCURVE", -"1028 -180 OFFCURVE", -"1163 -142 CURVE", -"1301 -102 OFFCURVE", -"1415 -13 OFFCURVE", -"1483 112 CURVE", -"1519 176 OFFCURVE", -"1542 244 OFFCURVE", -"1549 314 CURVE", -"1578 317 OFFCURVE", -"1608 322 OFFCURVE", -"1637 331 CURVE", -"1775 371 OFFCURVE", -"1887 461 OFFCURVE", -"1956 585 CURVE", -"2027 709 OFFCURVE", -"2042 851 OFFCURVE", -"2005 990 CURVE" -); -}, -{ -closed = 1; -nodes = ( -"761 1567 OFFCURVE", -"836 1626 OFFCURVE", -"925 1650 CURVE", -"1014 1676 OFFCURVE", -"1107 1665 OFFCURVE", -"1189 1620 CURVE", -"1268 1575 OFFCURVE", -"1328 1502 OFFCURVE", -"1353 1411 CURVE", -"1358 1394 OFFCURVE", -"1361 1376 OFFCURVE", -"1364 1359 CURVE", -"1370 1300 OFFCURVE", -"1363 1237 OFFCURVE", -"1339 1181 CURVE", -"1334 1170 OFFCURVE", -"1329 1159 OFFCURVE", -"1323 1148 CURVE", -"1309 1123 OFFCURVE", -"1283 1081 OFFCURVE", -"1254 1032 CURVE", -"1131 1109 OFFCURVE", -"886 1264 OFFCURVE", -"806 1310 CURVE", -"764 1333 OFFCURVE", -"720 1349 OFFCURVE", -"675 1359 CURVE", -"679 1403 OFFCURVE", -"693 1445 OFFCURVE", -"715 1486 CURVE" -); -}, -{ -closed = 1; -nodes = ( -"1154 768 OFFCURVE", -"1095 708 OFFCURVE", -"1020 708 CURVE", -"944 708 OFFCURVE", -"882 768 OFFCURVE", -"882 843 CURVE", -"882 919 OFFCURVE", -"944 981 OFFCURVE", -"1020 981 CURVE", -"1095 981 OFFCURVE", -"1154 919 OFFCURVE", -"1154 843 CURVE" -); -}, -{ -closed = 1; -nodes = ( -"530 589 OFFCURVE", -"514 544 OFFCURVE", -"504 499 CURVE", -"460 504 OFFCURVE", -"418 518 OFFCURVE", -"377 540 CURVE", -"296 587 OFFCURVE", -"237 659 OFFCURVE", -"213 749 CURVE", -"187 837 OFFCURVE", -"198 932 OFFCURVE", -"242 1013 CURVE", -"288 1094 OFFCURVE", -"361 1153 OFFCURVE", -"452 1179 CURVE", -"469 1184 OFFCURVE", -"486 1187 OFFCURVE", -"504 1188 CURVE", -"563 1196 OFFCURVE", -"626 1187 OFFCURVE", -"682 1164 CURVE", -"693 1159 OFFCURVE", -"704 1154 OFFCURVE", -"715 1148 CURVE", -"740 1133 OFFCURVE", -"782 1109 OFFCURVE", -"831 1078 CURVE", -"753 957 OFFCURVE", -"599 711 OFFCURVE", -"553 629 CURVE" -); -}, -{ -closed = 1; -nodes = ( -"1276 121 OFFCURVE", -"1204 62 OFFCURVE", -"1114 36 CURVE", -"1025 12 OFFCURVE", -"931 22 OFFCURVE", -"850 67 CURVE", -"768 113 OFFCURVE", -"710 186 OFFCURVE", -"684 276 CURVE", -"679 292 OFFCURVE", -"676 309 OFFCURVE", -"675 327 CURVE", -"667 389 OFFCURVE", -"676 450 OFFCURVE", -"699 507 CURVE", -"704 518 OFFCURVE", -"709 529 OFFCURVE", -"715 540 CURVE", -"730 565 OFFCURVE", -"754 606 OFFCURVE", -"784 655 CURVE", -"906 579 OFFCURVE", -"1151 423 OFFCURVE", -"1233 379 CURVE", -"1273 356 OFFCURVE", -"1318 338 OFFCURVE", -"1364 327 CURVE", -"1358 284 OFFCURVE", -"1344 241 OFFCURVE", -"1322 201 CURVE" -); -}, -{ -closed = 1; -nodes = ( -"1750 594 OFFCURVE", -"1676 535 OFFCURVE", -"1587 509 CURVE SMOOTH", -"1570 504 OFFCURVE", -"1553 501 OFFCURVE", -"1536 499 CURVE SMOOTH", -"1473 492 OFFCURVE", -"1413 500 OFFCURVE", -"1356 524 CURVE SMOOTH", -"1344 529 OFFCURVE", -"1333 534 OFFCURVE", -"1323 540 CURVE SMOOTH", -"1298 554 OFFCURVE", -"1257 580 OFFCURVE", -"1207 609 CURVE", -"1283 732 OFFCURVE", -"1439 977 OFFCURVE", -"1483 1058 CURVE SMOOTH", -"1507 1099 OFFCURVE", -"1525 1143 OFFCURVE", -"1536 1188 CURVE", -"1579 1184 OFFCURVE", -"1622 1170 OFFCURVE", -"1661 1148 CURVE SMOOTH", -"1742 1102 OFFCURVE", -"1801 1027 OFFCURVE", -"1826 938 CURVE SMOOTH", -"1850 848 OFFCURVE", -"1840 755 OFFCURVE", -"1795 674 CURVE SMOOTH" +"235 1454 OFFCURVE", +"206 1451 OFFCURVE", +"175 1454 CURVE", +"150 1455 OFFCURVE", +"124 1466 OFFCURVE", +"98 1488 CURVE", +"71 1501 OFFCURVE", +"52 1519 OFFCURVE", +"39 1543 CURVE", +"0 1599 OFFCURVE", +"-5 1688 OFFCURVE", +"40 1768 CURVE", +"57 1791 OFFCURVE", +"76 1809 OFFCURVE", +"96 1822 CURVE", +"131 1849 OFFCURVE", +"172 1863 OFFCURVE", +"219 1863 CURVE", +"226 1864 LINE SMOOTH", +"233 1865 OFFCURVE", +"239 1866 OFFCURVE", +"245 1866 CURVE SMOOTH", +"252 1866 OFFCURVE", +"257 1865 OFFCURVE", +"262 1863 CURVE", +"294 1857 OFFCURVE", +"322 1843 OFFCURVE", +"345 1822 CURVE", +"366 1809 OFFCURVE", +"383 1789 OFFCURVE", +"397 1762 CURVE", +"415 1732 OFFCURVE", +"424 1696 OFFCURVE", +"424 1653 CURVE", +"421 1636 OFFCURVE", +"410 1608 OFFCURVE", +"391 1568 CURVE", +"372 1540 OFFCURVE", +"352 1517 OFFCURVE", +"331 1499 CURVE", +"326 1496 OFFCURVE", +"318 1492 OFFCURVE", +"307 1485 CURVE SMOOTH", +"288 1472 OFFCURVE", +"273 1466 OFFCURVE", +"262 1465 CURVE" +); +}, +{ +closed = 1; +nodes = ( +"1622 1667 OFFCURVE", +"1630 1720 OFFCURVE", +"1665 1766 CURVE", +"1674 1791 OFFCURVE", +"1693 1809 OFFCURVE", +"1722 1822 CURVE", +"1741 1840 OFFCURVE", +"1773 1852 OFFCURVE", +"1818 1858 CURVE", +"1868 1861 OFFCURVE", +"1910 1848 OFFCURVE", +"1944 1819 CURVE", +"2009 1778 OFFCURVE", +"2041 1720 OFFCURVE", +"2041 1645 CURVE SMOOTH", +"2041 1607 LINE", +"2031 1570 OFFCURVE", +"2018 1541 OFFCURVE", +"2002 1518 CURVE", +"1989 1505 OFFCURVE", +"1968 1487 OFFCURVE", +"1939 1462 CURVE", +"1921 1453 OFFCURVE", +"1902 1448 OFFCURVE", +"1883 1446 CURVE", +"1868 1438 OFFCURVE", +"1851 1438 OFFCURVE", +"1832 1446 CURVE", +"1797 1446 OFFCURVE", +"1768 1453 OFFCURVE", +"1746 1467 CURVE", +"1740 1473 OFFCURVE", +"1734 1478 OFFCURVE", +"1728 1483 CURVE SMOOTH", +"1719 1490 OFFCURVE", +"1712 1495 OFFCURVE", +"1708 1500 CURVE", +"1694 1511 OFFCURVE", +"1683 1524 OFFCURVE", +"1675 1539 CURVE", +"1661 1553 OFFCURVE", +"1650 1576 OFFCURVE", +"1641 1607 CURVE SMOOTH" +); +}, +{ +closed = 1; +nodes = ( +"262 1093 LINE", +"262 1095 OFFCURVE", +"259 1096 OFFCURVE", +"253 1097 CURVE", +"196 1179 OFFCURVE", +"147 1282 OFFCURVE", +"104 1407 CURVE", +"119 1407 OFFCURVE", +"143 1406 OFFCURVE", +"177 1404 CURVE", +"207 1401 OFFCURVE", +"232 1400 OFFCURVE", +"253 1400 CURVE SMOOTH", +"262 1400 LINE SMOOTH", +"267 1400 OFFCURVE", +"270 1401 OFFCURVE", +"270 1402 CURVE", +"315 1402 OFFCURVE", +"352 1417 OFFCURVE", +"383 1446 CURVE", +"454 1493 OFFCURVE", +"482 1577 OFFCURVE", +"489 1650 CURVE SMOOTH", +"492 1682 OFFCURVE", +"493 1721 OFFCURVE", +"493 1768 CURVE", +"756 1700 OFFCURVE", +"968.999 1461 OFFCURVE", +"1021 1186 CURVE SMOOTH", +"1028 1151 OFFCURVE", +"1032 1130 OFFCURVE", +"1034 1123 CURVE", +"1039 1090 OFFCURVE", +"1043 1070 OFFCURVE", +"1044 1064 CURVE SMOOTH", +"1051 1019 OFFCURVE", +"1055 977.999 OFFCURVE", +"1055 939 CURVE SMOOTH", +"1055 914 OFFCURVE", +"1054 892 OFFCURVE", +"1051 874 CURVE", +"1042 889 OFFCURVE", +"1018 937 OFFCURVE", +"977.999 1016 CURVE SMOOTH", +"940 1093 OFFCURVE", +"904 1149 OFFCURVE", +"871 1185 CURVE SMOOTH", +"842 1216 OFFCURVE", +"816 1242 OFFCURVE", +"793 1263 CURVE SMOOTH", +"732 1321 OFFCURVE", +"673 1360 OFFCURVE", +"615 1381 CURVE SMOOTH", +"564 1400 OFFCURVE", +"532 1407 OFFCURVE", +"495 1396 CURVE SMOOTH", +"476 1390 OFFCURVE", +"461 1378 OFFCURVE", +"448 1360 CURVE", +"415 1291 OFFCURVE", +"415 1227 OFFCURVE", +"450 1167 CURVE", +"456 1164 OFFCURVE", +"466 1159 OFFCURVE", +"479 1154 CURVE SMOOTH", +"503 1145 OFFCURVE", +"551 1132 OFFCURVE", +"615 1119 CURVE", +"634 1046 OFFCURVE", +"590 973.999 OFFCURVE", +"518 944 CURVE SMOOTH", +"482 929 OFFCURVE", +"447 927 OFFCURVE", +"413 939 CURVE", +"354 983.999 OFFCURVE", +"306 1031 OFFCURVE", +"270 1082 CURVE SMOOTH" +); +}, +{ +closed = 1; +nodes = ( +"1269 1607 LINE", +"1275 1611 LINE SMOOTH", +"1346 1657 OFFCURVE", +"1449 1707 OFFCURVE", +"1585 1762 CURVE", +"1585 1742 OFFCURVE", +"1584 1718 OFFCURVE", +"1581 1689 CURVE SMOOTH", +"1578 1660 OFFCURVE", +"1577 1635 OFFCURVE", +"1577 1616 CURVE SMOOTH", +"1577 1597 LINE", +"1580 1546 OFFCURVE", +"1593 1508 OFFCURVE", +"1618 1483 CURVE", +"1687 1380 OFFCURVE", +"1817 1368 OFFCURVE", +"1944 1367 CURVE", +"1909 1238 OFFCURVE", +"1838 1125 OFFCURVE", +"1730 1029 CURVE SMOOTH", +"1647 957 OFFCURVE", +"1539 898 OFFCURVE", +"1442 867 CURVE SMOOTH", +"1345 835 OFFCURVE", +"1238 812 OFFCURVE", +"1159 812 CURVE SMOOTH", +"1120 812 OFFCURVE", +"1084 813 OFFCURVE", +"1051 816 CURVE", +"1072 831 OFFCURVE", +"1121 857 OFFCURVE", +"1198 895 CURVE SMOOTH", +"1273 931 OFFCURVE", +"1327 965 OFFCURVE", +"1360 996.999 CURVE", +"1481 1106 OFFCURVE", +"1574 1212 OFFCURVE", +"1574 1355 CURVE", +"1581 1382 OFFCURVE", +"1568 1402 OFFCURVE", +"1535 1414 CURVE", +"1508 1431 OFFCURVE", +"1477 1440 OFFCURVE", +"1442 1440 CURVE SMOOTH", +"1407 1440 OFFCURVE", +"1374 1431 OFFCURVE", +"1345 1414 CURVE", +"1319 1369 OFFCURVE", +"1304 1315 OFFCURVE", +"1301 1254 CURVE", +"1222 1230 OFFCURVE", +"1148 1274 OFFCURVE", +"1120 1346 CURVE SMOOTH", +"1106 1382 OFFCURVE", +"1106 1418 OFFCURVE", +"1119 1454 CURVE", +"1150 1505 OFFCURVE", +"1196 1553 OFFCURVE", +"1256 1597 CURVE SMOOTH" +); +}, +{ +closed = 1; +nodes = ( +"778 87 LINE", +"777 86 OFFCURVE", +"770 82 OFFCURVE", +"767 82 CURVE", +"684 23 OFFCURVE", +"581 -29 OFFCURVE", +"459 -73 CURVE", +"459 -56 OFFCURVE", +"460 -31 OFFCURVE", +"462 2 CURVE SMOOTH", +"464 75 LINE", +"464 94 LINE", +"460 174 OFFCURVE", +"398 276 OFFCURVE", +"273 301 CURVE SMOOTH", +"222 310 OFFCURVE", +"163 315 OFFCURVE", +"96 315 CURVE", +"175 584 OFFCURVE", +"403 790 OFFCURVE", +"678 843 CURVE", +"730 855 OFFCURVE", +"782 865 OFFCURVE", +"834 874 CURVE SMOOTH", +"861 878 OFFCURVE", +"879 880 OFFCURVE", +"900 880 CURVE SMOOTH", +"921 880 OFFCURVE", +"928 880 OFFCURVE", +"958 877 CURVE SMOOTH", +"992.999 874 LINE", +"971.999 860 OFFCURVE", +"924 834 OFFCURVE", +"849 797 CURVE SMOOTH", +"698 721 OFFCURVE", +"591 621 OFFCURVE", +"523 510 CURVE SMOOTH", +"488 455 OFFCURVE", +"471 395 OFFCURVE", +"471 331 CURVE", +"458 294 OFFCURVE", +"526 246 OFFCURVE", +"599 247 CURVE SMOOTH", +"635 248 OFFCURVE", +"667 258 OFFCURVE", +"694 278 CURVE", +"702 287 OFFCURVE", +"708 296 OFFCURVE", +"713 304 CURVE", +"718 313 OFFCURVE", +"725 335 OFFCURVE", +"733 368 CURVE", +"738 394 OFFCURVE", +"741 417 OFFCURVE", +"743 437 CURVE", +"820 458 OFFCURVE", +"896 414 OFFCURVE", +"924 342 CURVE SMOOTH", +"938 305 OFFCURVE", +"938 269 OFFCURVE", +"925 233 CURVE", +"886 182 OFFCURVE", +"839 135 OFFCURVE", +"782 94 CURVE" +); +}, +{ +closed = 1; +nodes = ( +"1779 597 LINE", +"1779 594 OFFCURVE", +"1782 590 OFFCURVE", +"1787 587 CURVE", +"1834 522 OFFCURVE", +"1885 420 OFFCURVE", +"1939 279 CURVE", +"1926 279 OFFCURVE", +"1901 281 OFFCURVE", +"1864 284 CURVE SMOOTH", +"1826 287 OFFCURVE", +"1800 289 OFFCURVE", +"1787 289 CURVE SMOOTH", +"1774 289 LINE", +"1727 286 OFFCURVE", +"1687 271 OFFCURVE", +"1655 245 CURVE", +"1570 181 OFFCURVE", +"1554 112 OFFCURVE", +"1543 -79 CURVE", +"1283 -7 OFFCURVE", +"1078 223 OFFCURVE", +"1021 499 CURVE SMOOTH", +"1014 537 LINE", +"1007 578 LINE", +"1005 592 LINE", +"1003 601 OFFCURVE", +"1001 609 OFFCURVE", +"999.999 616 CURVE SMOOTH", +"993.999 658 LINE SMOOTH", +"990.999 678 OFFCURVE", +"987.999 717 OFFCURVE", +"987.999 738 CURVE SMOOTH", +"987.999 769 OFFCURVE", +"989.999 795 OFFCURVE", +"992.999 816 CURVE", +"1009 789 OFFCURVE", +"1027 755 OFFCURVE", +"1046 714 CURVE SMOOTH", +"1095 612 OFFCURVE", +"1138 542 OFFCURVE", +"1175 504 CURVE SMOOTH", +"1282 388 OFFCURVE", +"1393 290 OFFCURVE", +"1529 290 CURVE", +"1552 287 OFFCURVE", +"1580 304 OFFCURVE", +"1589 321 CURVE SMOOTH", +"1622 382 OFFCURVE", +"1622 462 OFFCURVE", +"1587 518 CURVE", +"1574 527 OFFCURVE", +"1557 536 OFFCURVE", +"1534 544 CURVE SMOOTH", +"1508 553 OFFCURVE", +"1472 559 OFFCURVE", +"1427 562 CURVE", +"1406 639 OFFCURVE", +"1449 717 OFFCURVE", +"1521 746 CURVE SMOOTH", +"1557 761 OFFCURVE", +"1593 762 OFFCURVE", +"1630 749 CURVE", +"1687 712 OFFCURVE", +"1735 664 OFFCURVE", +"1774 605 CURVE SMOOTH" +); +}, +{ +closed = 1; +nodes = ( +"408 48 OFFCURVE", +"410 17 OFFCURVE", +"405 -6 CURVE", +"404 -19 OFFCURVE", +"396 -43 OFFCURVE", +"379 -77 CURVE", +"361 -102 OFFCURVE", +"341 -124 OFFCURVE", +"320 -143 CURVE", +"279 -158 OFFCURVE", +"249 -166 OFFCURVE", +"231 -167 CURVE", +"136 -176 OFFCURVE", +"66 -126 OFFCURVE", +"39 -77 CURVE", +"13 -38 OFFCURVE", +"0 2 OFFCURVE", +"0 42 CURVE", +"5 87 LINE SMOOTH", +"12 144 OFFCURVE", +"37 174 OFFCURVE", +"104 219 CURVE", +"128 232 OFFCURVE", +"147 238 OFFCURVE", +"160 239 CURVE", +"171 243 OFFCURVE", +"189 245 OFFCURVE", +"214 245 CURVE", +"245 239 OFFCURVE", +"270 227 OFFCURVE", +"291 210 CURVE", +"350 187 OFFCURVE", +"388 124 OFFCURVE", +"397 87 CURVE" +); +}, +{ +closed = 1; +nodes = ( +"1815 230 OFFCURVE", +"1845 234 OFFCURVE", +"1870 229 CURVE", +"1897 226 OFFCURVE", +"1921 217 OFFCURVE", +"1942 200 CURVE", +"1987 180 OFFCURVE", +"2028 118 OFFCURVE", +"2032 50 CURVE SMOOTH", +"2036 -13 OFFCURVE", +"2012 -68 OFFCURVE", +"1991 -94 CURVE SMOOTH", +"1980 -107 OFFCURVE", +"1965 -124 OFFCURVE", +"1944 -143 CURVE", +"1901 -166 OFFCURVE", +"1862 -178 OFFCURVE", +"1827 -178 CURVE SMOOTH", +"1818 -178 LINE SMOOTH", +"1801 -178 OFFCURVE", +"1788 -176 OFFCURVE", +"1779 -171 CURVE", +"1752 -167 OFFCURVE", +"1724 -155 OFFCURVE", +"1693 -135 CURVE", +"1668 -110 OFFCURVE", +"1651 -90 OFFCURVE", +"1641 -73 CURVE", +"1623 -36 OFFCURVE", +"1617 -1 OFFCURVE", +"1622 34 CURVE", +"1622 91 OFFCURVE", +"1637 112 OFFCURVE", +"1710 189 CURVE SMOOTH", +"1711 190 OFFCURVE", +"1716 192 OFFCURVE", +"1725 196 CURVE", +"1734 199 OFFCURVE", +"1741 202 OFFCURVE", +"1747 205 CURVE", +"1752 208 OFFCURVE", +"1763 212 OFFCURVE", +"1779 219 CURVE" ); } ); @@ -388,6 +640,7 @@ glyphname = qtype_text; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -481,6 +734,7 @@ glyphname = qtype_numeric; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -517,6 +771,7 @@ glyphname = qtype_hidden_value; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -604,6 +859,7 @@ glyphname = qtype_calculation; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -761,6 +1017,7 @@ glyphname = qtype_variable; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -914,6 +1171,7 @@ glyphname = qtype_single_select; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1025,6 +1283,7 @@ glyphname = qtype_multi_select; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1139,6 +1398,7 @@ glyphname = qtype_decimal; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1277,6 +1537,7 @@ glyphname = qtype_long; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1439,6 +1700,7 @@ glyphname = qtyle_datetime; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1756,6 +2018,7 @@ glyphname = qtype_audio_capture; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1847,6 +2110,7 @@ glyphname = qtype_android_intent; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -1930,6 +2194,7 @@ glyphname = qtype_signature; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2193,6 +2458,7 @@ glyphname = dashboard_apps; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2553,6 +2819,7 @@ glyphname = commtrack; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2634,6 +2901,7 @@ glyphname = qtype_multi_select_box; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2688,6 +2956,7 @@ glyphname = qtype_single_select_circle; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2758,6 +3027,7 @@ glyphname = dashboard_reports; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2797,6 +3067,7 @@ glyphname = dashboard_data; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -2847,6 +3118,7 @@ glyphname = dashboard_users; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3263,6 +3535,7 @@ glyphname = dashboard_settings; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3391,6 +3664,7 @@ glyphname = dashboard_help; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3538,6 +3812,7 @@ glyphname = dashboard_exchange; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3687,6 +3962,7 @@ glyphname = dashboard_messaging; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3758,6 +4034,7 @@ glyphname = qtype_balance; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -3864,6 +4141,7 @@ glyphname = dashboard_chart; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -4188,6 +4466,7 @@ glyphname = dashboard_forms; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -4284,6 +4563,7 @@ glyphname = dashboard_userreport; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -4432,6 +4712,7 @@ glyphname = dashboard_datatable; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -4660,6 +4941,7 @@ glyphname = dashboard_pie; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -4742,6 +5024,7 @@ glyphname = dashboard_survey; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5009,6 +5292,7 @@ glyphname = dashboard_casemgt; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5103,6 +5387,7 @@ glyphname = dashboard_blankform; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5136,6 +5421,7 @@ glyphname = qtype_external_case; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5228,6 +5514,7 @@ lastChange = "2015-12-30 23:03:00 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5261,6 +5548,7 @@ glyphname = fd_expand; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5308,6 +5596,7 @@ lastChange = "2015-12-30 23:03:39 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5355,6 +5644,7 @@ lastChange = "2015-12-30 23:18:11 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5394,6 +5684,7 @@ glyphname = fd_edit_form; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -5517,6 +5808,7 @@ lastChange = "2016-08-16 00:00:52 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -7519,6 +7811,7 @@ lastChange = "2016-08-16 00:01:46 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -7626,6 +7919,7 @@ lastChange = "2016-08-16 00:03:35 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -7749,6 +8043,7 @@ lastChange = "2019-02-25 23:04:29 +0000"; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -7857,6 +8152,7 @@ glyphname = appbuilder_biometrics; layers = ( { layerId = "04DF3276-53F3-45FA-B502-39A9AACD583E"; +name = Regular; paths = ( { closed = 1; @@ -7952,6 +8248,7 @@ width = 1536; unicode = F038; } ); +disablesNiceNames = 1; instances = ( { name = Regular; diff --git a/corehq/apps/hqwebapp/crispy.py b/corehq/apps/hqwebapp/crispy.py index 8cc954cd25d33..6236a42af9412 100644 --- a/corehq/apps/hqwebapp/crispy.py +++ b/corehq/apps/hqwebapp/crispy.py @@ -13,7 +13,9 @@ from crispy_forms.utils import flatatt, get_template_pack, render_field CSS_LABEL_CLASS = 'col-xs-12 col-sm-4 col-md-4 col-lg-2' +CSS_LABEL_CLASS_BOOTSTRAP5 = 'col-xs-12 col-sm-4 col-md-4 col-lg-3' CSS_FIELD_CLASS = 'col-xs-12 col-sm-8 col-md-8 col-lg-6' +CSS_FIELD_CLASS_BOOTSTRAP5 = 'col-xs-12 col-sm-8 col-md-8 col-lg-9' CSS_ACTION_CLASS = CSS_FIELD_CLASS + ' col-sm-offset-4 col-md-offset-4 col-lg-offset-2' @@ -24,6 +26,13 @@ class HQFormHelper(FormHelper): def __init__(self, *args, **kwargs): super(HQFormHelper, self).__init__(*args, **kwargs) + from corehq.apps.hqwebapp.utils.bootstrap import get_bootstrap_version, BOOTSTRAP_5 + bootstrap_version = get_bootstrap_version() + use_bootstrap5 = bootstrap_version == BOOTSTRAP_5 + if use_bootstrap5: + self.label_class = CSS_LABEL_CLASS_BOOTSTRAP5 + self.field_class = CSS_FIELD_CLASS_BOOTSTRAP5 + if 'autocomplete' not in self.attrs: self.attrs.update({ 'autocomplete': 'off', @@ -63,9 +72,16 @@ class ErrorsOnlyField(Field): template = 'hqwebapp/crispy/field/errors_only_field.html' +def get_form_action_class(): + """This is only valid for bootstrap 5""" + return CSS_LABEL_CLASS_BOOTSTRAP5.replace('col', 'offset') + ' ' + CSS_FIELD_CLASS_BOOTSTRAP5 + + def _get_offsets(context): label_class = context.get('label_class', '') - return re.sub(r'(xs|sm|md|lg)-', r'\g<1>-offset-', label_class) + use_bootstrap5 = context.get('use_bootstrap5') + return (label_class.replace('col', 'offset') if use_bootstrap5 + else re.sub(r'(xs|sm|md|lg)-', r'\g<1>-offset-', label_class)) class FormActions(OriginalFormActions): @@ -84,12 +100,13 @@ def render(self, form, context, template_pack=None, **kwargs): ) fields_html = mark_safe(fields_html) # nosec: just concatenated safe fields offsets = _get_offsets(context) - return render_to_string(self.template, { + context.update({ 'formactions': self, 'fields_output': fields_html, 'offsets': offsets, 'field_class': context.get('field_class', '') }) + return render_to_string(self.template, context.flatten()) class StaticField(LayoutObject): @@ -123,6 +140,11 @@ def render(self, form, context, template_pack=None, **kwargs): class ValidationMessage(LayoutObject): + """ + IMPORTANT: DO NOT USE IN BOOTSTRAP 5 VIEWS. + See bootstrap5/validators.ko and revisit styleguide in + Organisms > Forms for additional help. + """ template = 'hqwebapp/crispy/validation_message.html' def __init__(self, ko_observable): diff --git a/corehq/apps/hqwebapp/management/commands/build_requirejs.py b/corehq/apps/hqwebapp/management/commands/build_requirejs.py index 162a8e803502d..a1bcc1177e24b 100644 --- a/corehq/apps/hqwebapp/management/commands/build_requirejs.py +++ b/corehq/apps/hqwebapp/management/commands/build_requirejs.py @@ -5,7 +5,6 @@ import subprocess from collections import defaultdict from pathlib import Path -from uuid import uuid4 from shutil import copyfile from subprocess import call @@ -122,7 +121,8 @@ def _confirm_or_exit(): if confirm[0].lower() != 'y': exit() confirm = input("You are running locally. Have you already run " - "`./manage.py collectstatic --noinput && ./manage.py compilejsi18n` (y/n)? ") + "`./manage.py resource_static && ./manage.py collectstatic " + "--noinput && ./manage.py compilejsi18n` (y/n)? ") if confirm[0].lower() != 'y': exit() diff --git a/corehq/apps/hqwebapp/migrations/0011_add_new_columns_and_rename_model.py b/corehq/apps/hqwebapp/migrations/0011_add_new_columns_and_rename_model.py new file mode 100644 index 0000000000000..b669b1b1e47c8 --- /dev/null +++ b/corehq/apps/hqwebapp/migrations/0011_add_new_columns_and_rename_model.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.20 on 2023-10-10 09:50 + +import corehq.sql_db.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('hqwebapp', '0010_maintenancealert_scheduling'), + ] + + operations = [ + migrations.AddField( + model_name='maintenancealert', + name='created_by_domain', + field=corehq.sql_db.fields.CharIdField(db_index=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='maintenancealert', + name='created_by_user', + field=corehq.sql_db.fields.CharIdField(max_length=128, null=True), + ), + migrations.AlterModelTable( + name='maintenancealert', + table='hqwebapp_maintenancealert', + ), + migrations.RenameModel( + old_name='MaintenanceAlert', + new_name='Alert', + ), + ] diff --git a/corehq/apps/hqwebapp/models.py b/corehq/apps/hqwebapp/models.py index 7085114d2cc9c..95a38cd471d26 100644 --- a/corehq/apps/hqwebapp/models.py +++ b/corehq/apps/hqwebapp/models.py @@ -8,6 +8,7 @@ import architect from oauth2_provider.settings import APPLICATION_MODEL +from corehq.sql_db.fields import CharIdField from corehq.util.markup import mark_up_urls from corehq.util.models import ForeignValue, foreign_init from corehq.util.quickcache import quickcache @@ -24,7 +25,7 @@ def __new__(cls, category, action, label=None): return super(GaTracker, cls).__new__(cls, category, action, label) -class MaintenanceAlert(models.Model): +class Alert(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) active = models.BooleanField(default=False) @@ -35,25 +36,26 @@ class MaintenanceAlert(models.Model): text = models.TextField() domains = ArrayField(models.CharField(max_length=126), null=True) + created_by_domain = CharIdField(max_length=255, null=True, db_index=True) + created_by_user = CharIdField(max_length=128, null=True) class Meta(object): app_label = 'hqwebapp' + db_table = 'hqwebapp_maintenancealert' @property def html(self): return mark_up_urls(self.text) - def __repr__(self): - return "MaintenanceAlert(text='{}', active='{}', domains='{}')".format( - self.text, self.active, ", ".join(self.domains) if self.domains else "All Domains") - def save(self, *args, **kwargs): - MaintenanceAlert.get_active_alerts.clear(MaintenanceAlert) - super(MaintenanceAlert, self).save(*args, **kwargs) + cls = type(self) + cls.get_active_alerts.clear(cls) + super().save(*args, **kwargs) @classmethod @quickcache([], timeout=1 * 60) def get_active_alerts(cls): + # return active HQ alerts now = datetime.utcnow() active_alerts = cls.objects.filter( Q(active=True), diff --git a/corehq/apps/hqwebapp/static/app_manager/less/font-workflow/core.less b/corehq/apps/hqwebapp/static/app_manager/less/font-workflow/core.less index fb739d839990e..edb1454e83c49 100644 --- a/corehq/apps/hqwebapp/static/app_manager/less/font-workflow/core.less +++ b/corehq/apps/hqwebapp/static/app_manager/less/font-workflow/core.less @@ -3,7 +3,7 @@ font-family: @font-family; font-style: normal; font-weight: normal; - line-height: 1.06; + line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/corehq/apps/hqwebapp/static/app_manager/less/img/loading_flower.png b/corehq/apps/hqwebapp/static/app_manager/less/img/loading_flower.png index 1815e9c7f6e5d..652ab308a3242 100644 Binary files a/corehq/apps/hqwebapp/static/app_manager/less/img/loading_flower.png and b/corehq/apps/hqwebapp/static/app_manager/less/img/loading_flower.png differ diff --git a/corehq/apps/hqwebapp/static/cloudcare/css/webforms.css b/corehq/apps/hqwebapp/static/cloudcare/css/webforms.css index 4b4be52b86215..c4b0103a23f83 100644 --- a/corehq/apps/hqwebapp/static/cloudcare/css/webforms.css +++ b/corehq/apps/hqwebapp/static/cloudcare/css/webforms.css @@ -81,6 +81,10 @@ input:invalid { isolation: isolate; } +.widget button p { + margin-bottom: 0; +} + .coordinate { font-weight: bold; width: 8em; diff --git a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/content.less b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/content.less index ec8c84b36e62a..45893a0f07416 100644 --- a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/content.less +++ b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/content.less @@ -34,6 +34,7 @@ body { color: @cc-neutral-mid; padding-left: 1.5rem; font-weight: bold; + margin-top: 0px; } } diff --git a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/form.less b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/form.less index fa920c7d35ccc..ca35b3aff5afb 100644 --- a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/form.less +++ b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/form.less @@ -5,7 +5,7 @@ .box-shadow(0 0 10px 2px rgba(0,0,0,.1)); margin-bottom: 2rem; margin-top: 20px; - font-size: 16px; + font-size: @font-size-base; // Don't overshadow inputs .page-header { padding-bottom: 30px; @@ -15,9 +15,6 @@ } } - .loading { - right: 20px; - } .controls { padding-right: 25px; padding-top: 3px; @@ -36,28 +33,26 @@ } } + .gr.panel { + border-radius: 0px; + } + + .rep.panel { + border-radius: 0px; + } + .panel-body { - padding-left: @form-text-indent + 5px; - padding-right: @form-text-indent + 5px; + padding-left: 0px; + padding-right: 0px; // Bootstrap introduces -10px left/right margin for row classes. This causes element to overflow parent. .row { margin-left: 0px; margin-right: 0px; } - @media (max-width: @screen-xs-max) { - padding-left: 0px; - padding-right: 0px; - } } .info { - .panel-body { - overflow-x: auto; - @media (max-width: @screen-xs-max) { - padding-left: @form-text-indent + 5px; - padding-right: @form-text-indent + 5px; - } - } + overflow-x: auto; } .gr-header { @@ -71,6 +66,11 @@ } } + .panel-heading { + border-top-left-radius: 0px; + border-top-right-radius: 0px; + } + } .form-container .form-actions .btn { @@ -135,19 +135,6 @@ } - -.question-container .gr .children { - .info.panel-default { - border: none; - background-color: transparent; - .box-shadow(none); - - .panel-body { - padding-top: 6px; - } - } -} - @media print { .form-container.print-container { margin: 0px; diff --git a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/menu.less b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/menu.less index f7e6f8cdccdf1..89ffba28e4e9c 100644 --- a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/menu.less +++ b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/menu.less @@ -129,3 +129,7 @@ } } } + +#case-list-menu-header div button { + margin-left: 12px; +} diff --git a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/query.less b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/query.less index 560685b61cff0..0870550234e22 100644 --- a/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/query.less +++ b/corehq/apps/hqwebapp/static/cloudcare/less/formplayer-webapp/query.less @@ -8,10 +8,6 @@ } } -#search-more { - margin-top: 12px; -} - #sidebar-region { background: transparent; @media (min-width: @screen-md-min) { diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower-lg.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower-lg.png index 6abdb577b10ea..6394f19e215b4 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower-lg.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower-lg.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower.png index 19a272e6309ad..733b534ebcd3c 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-flower.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-120x120.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-120x120.png index 9a82babca18dc..80be0cac6ecd3 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-120x120.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-120x120.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-152x152.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-152x152.png index 69feef0bcc548..c6b79c109e2cf 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-152x152.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-152x152.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-167x167.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-167x167.png index 2390943d69298..0b523b353065e 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-167x167.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-167x167.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-180x180.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-180x180.png index 4f6d7835fd4ec..2925a971f6a95 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-180x180.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-180x180.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-57x57.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-57x57.png index 88b2beccc8b3b..a3252e87add05 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-57x57.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-57x57.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-76x76.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-76x76.png index e3a140559e72e..6f47526f2a43a 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-76x76.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-icon-76x76.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-mobile.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-mobile.png index fb4cc2bf4d7b6..39b24dedd58c7 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-mobile.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-mobile.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-small.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-small.png index 794c6a1d949e3..2922a91952379 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-small.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcare-logo-small.png differ diff --git a/corehq/apps/hqwebapp/static/hqstyle/images/commcarehq-icon-large.png b/corehq/apps/hqwebapp/static/hqstyle/images/commcarehq-icon-large.png index 3a24e1dd3ff59..b5ee7e242e7cb 100644 Binary files a/corehq/apps/hqwebapp/static/hqstyle/images/commcarehq-icon-large.png and b/corehq/apps/hqwebapp/static/hqstyle/images/commcarehq-icon-large.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/font/CommCareHQFont-Regular.otf b/corehq/apps/hqwebapp/static/hqwebapp/font/CommCareHQFont-Regular.otf index 85d55aed520d3..c21894e137717 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/font/CommCareHQFont-Regular.otf and b/corehq/apps/hqwebapp/static/hqwebapp/font/CommCareHQFont-Regular.otf differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.eot b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.eot old mode 100644 new mode 100755 index 537017726628f..d31f368fd567e Binary files a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.eot and b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.eot differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.svg b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.svg old mode 100644 new mode 100755 index c9f96b46e0097..e1eb8e9745bc0 --- a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.svg +++ b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.svg @@ -13,50 +13,50 @@ <glyph unicode=" " horiz-adv-x="685" /> <glyph unicode=" " horiz-adv-x="685" /> <glyph unicode="◼" horiz-adv-x="500" d="M0 0z" /> -<glyph unicode="" horiz-adv-x="2042" d="M2005 990q58 -218 -49 -405q-52 -93 -134 -158.5t-185 -95.5q-38 -12 -88 -17q-10 -103 -66 -202q-51 -93 -133.5 -158.5t-186.5 -95.5q-101 -29 -206 -16.5t-197 64.5q-94 53 -159.5 135t-93.5 184q-12 52 -16 88q-111 13 -204 66q-93 52 -158.5 135t-94.5 186 q-30 100 -17.5 205.5t65.5 197.5q52 94 134.5 159t185.5 94q50 12 89 16q11 109 62 204q53 93 136 158.5t184 94.5q215 61 404 -48q93 -52 159 -134.5t96 -185.5q10 -36 17 -89q105 -11 202 -62q191 -109 254 -320zM715 1486q-34 -64 -40 -127q70 -16 131 -49 q48 -28 448 -278q11 19 26.5 44.5t26 43t16.5 28.5t16 33q35 81 25 178q-6 37 -11 52q-38 137 -164 209q-127 70 -264 30q-138 -37 -210 -164zM1154 843q0 57 -39 97.5t-95 40.5q-57 0 -97.5 -40.5t-40.5 -97.5q0 -56 40.5 -95.5t97.5 -39.5q56 0 95 39.5t39 95.5zM553 629 q77 137 278 449q-12 8 -116 70q-11 6 -33 16q-88 36 -178 24q-26 -1 -52 -9q-138 -39 -210 -166q-69 -128 -29 -264q36 -135 164 -209q63 -34 127 -41q16 73 49 130zM1322 201q33 59 42 126q-69 17 -131 52q-43 23 -449 276l-69 -115q-6 -11 -16 -33q-36 -89 -24 -180 q1 -27 9 -51q40 -138 166 -209q124 -69 264 -31q136 39 208 165zM1795 674q69 123 31 264q-39 138 -165 210q-60 34 -125 40q-17 -68 -53 -130q-20 -36 -68.5 -115t-118.5 -191t-89 -143q26 -15 65 -38.5t51 -30.5t33 -16q85 -36 180 -25q28 3 51 10q138 40 208 165z" /> -<glyph unicode="" d="M696 1368q0 39 -6 189h-157q-35 0 -63.5 -11.5t-41.5 -27.5q-5 -5 -10 -12q-12 -14 -15 -17q-38 -44 -137 -252l-23 -69h-243v763h2047v-763h-225l-25 69q-77 174 -147 266q-39 49 -111 54h-176q-12 -129 -12 -189v-901q0 -95 24 -139q18 -32 31.5 -47t42.5 -33 q35 -22 127 -64l142 -41v-260h-1379v260l133 41q70 33 110 55q14 7 24 15q8 5 17 12q7 4 12 12l4 4l2 3q4 3 14 16q6 5 10 13q15 24 26 83q5 38 5 70v901z" /> +<glyph unicode="" horiz-adv-x="2042" d="M262 1465q18 2 45 20q7 4 15 9l9 5q31 27 60 69q29 60 33 85q0 64 -27 109q-21 41 -52 60q-35 32 -83 41q-8 3 -17 3q-5 0 -19 -2l-7 -1q-70 0 -123 -41q-31 -20 -56 -54q-33 -61 -32 -121.5t31 -103.5q19 -36 59 -55q38 -32 77 -34q48 -5 87 11zM1641 1607 q14 -48 34 -68q12 -23 33 -39q3 -4 20 -17q12 -10 18 -16q33 -21 86 -21q28 -12 51 0q30 3 56 16q51 44 63 56q24 35 39 89v38q0 113 -97 174q-51 43 -126 39q-68 -9 -96 -36q-44 -20 -57 -56q-53 -69 -24 -159zM262 1093l8 -11q52 -74 143 -143q50 -18 105 5t83 72t14 103 q-93 19 -136 35q-13 5 -29 13q-52 89 -2 193q20 27 47 36q44 13 120 -15q87 -32 178 -118q35 -31 78 -78q51 -56 107 -169q66 -130 73 -142q4 24 4 65q0 54 -11 125q0 1 -1.5 9.5t-4 21.5t-4.5 28q-1 5 -13 63q-39 207 -185.5 369t-342.5 213q0 -75 -4 -118 q-14 -143 -106 -204q-47 -44 -113 -44q0 -2 -8 -2h-9q-36 0 -76 4q-51 3 -73 3q65 -189 149 -310q9 -1 9 -4zM1269 1607l-13 -10q-90 -66 -137 -143q-20 -55 1 -108q22 -55 72.5 -82.5t108.5 -9.5q5 92 44 160q44 26 97 26q52 0 93 -26q50 -18 39 -59q0 -99 -54.5 -181.5 t-159.5 -176.5q-50 -48 -162 -102q-118 -58 -147 -79q44 -4 108 -4q116 0 283 55q160 51 288 162q161 143 214 338q-56 0 -96.5 4t-86.5 15.5t-82 35.5t-61 61q-36 36 -41 114v19q0 34 4 73t4 73q-205 -83 -310 -151zM778 87l4 7q82 59 143 139q20 54 -1 109t-72.5 83 t-108.5 12q-4 -35 -10 -69q-12 -50 -20 -64q-10 -16 -19 -26q-40 -29 -95 -31q-54 -1 -96 27.5t-32 56.5q0 97 52 179q109 178 326 287q110 54 144 77l-35 3q-30 3 -58 3q-26 0 -66 -6q-87 -15 -156 -31q-206 -40 -364.5 -183t-217.5 -345q98 0 177 -14q87 -17 137.5 -78.5 t53.5 -128.5v-19l-2 -73q-3 -50 -3 -75q183 66 308 155q6 0 11 5zM1779 597l-5 8q-58 89 -144 144q-55 19 -109 -3q-55 -22 -82.5 -74.5t-11.5 -109.5q68 -5 107 -18q31 -11 53 -26q26 -42 26.5 -96.5t-24.5 -100.5q-7 -13 -25 -23t-35 -8q-93 0 -176.5 56t-177.5 158 q-55 57 -129 210q-27 58 -53 102q-5 -35 -5 -78q0 -40 6 -80l6 -42q1 -4 5 -24l2 -14l7 -41l7 -38q43 -208 185.5 -366t336.5 -212q9 149 29.5 213.5t82.5 110.5q49 40 119 44h13q14 0 77 -5q62 -5 75 -5q-80 208 -152 308q-8 5 -8 10zM397 87q-8 33 -37 70t-69 53 q-33 26 -77 35q-38 0 -54 -6q-22 -2 -56 -20q-51 -34 -72.5 -62t-26.5 -70l-5 -45q0 -60 39 -119q23 -41 74.5 -68.5t117.5 -21.5q28 2 89 24q33 30 59 66q24 49 26 71q8 37 -8 93zM1779 219l-32 -14q-10 -5 -22 -9q-14 -6 -15 -7q-57 -60 -72.5 -86.5t-15.5 -68.5 q-8 -53 19 -107q14 -24 52 -62q47 -30 86 -36q13 -7 39 -7h9q52 0 117 35q24 21 47 49q46 58 41 144q-3 50 -29 92.5t-61 57.5q-30 24 -72 29q-35 7 -91 -10z" /> +<glyph unicode="" d="M696 467v901q0 39 -6 189h-157q-35 0 -63.5 -11.5t-41.5 -27.5q-5 -5 -10 -12q-12 -14 -15 -17q-38 -44 -137 -252l-23 -69h-243v763h2047v-763h-225l-25 69q-77 174 -147 266q-39 49 -111 54h-176q-12 -129 -12 -189v-901q0 -95 24 -139q18 -32 31.5 -47t42.5 -33 q35 -22 127 -64l142 -41v-260h-1379v260l133 41q70 33 110 55q14 7 24 15q8 5 17 12q7 4 12 12l4 4l2 3q4 3 14 16q6 5 10 13q15 24 26 83q5 38 5 70z" /> <glyph unicode="" horiz-adv-x="1696" d="M662 342v1025q-49 -31 -115 -68l-100 -57q-50 -27 -90.5 -35t-127.5 -8v416q285 116 497 316h412v-1589h329v-459h-1238v459h433z" /> <glyph unicode="" horiz-adv-x="2752" d="M1182 -34v539q0 37 -30 72q-10 37 -69 66l-194 146q-33 23 -102 43q-37 9 -58 17q-49 15 -57 17q6 1 115 32q47 11 102 40l194 123q59 29 69 60q13 17 21.5 48t8.5 56v545l-1182 -794v-223zM1577 1770v-545q0 -69 23 -104q28 -37 70 -60l196 -123q55 -29 102 -40 q60 -20 115 -32q-6 -1 -57 -17q-20 -7 -58 -17q-69 -20 -102 -43l-196 -146q-41 -22 -67 -60t-26 -78v-539l1175 787v223z" /> -<glyph unicode="" horiz-adv-x="2232" d="M1290 1281l49 -18q41 -16 57.5 -33t23.5 -51q7 -29 7 -109v-630q0 -112 -9 -146q-11 -35 -31 -49q-12 -8 -48 -23l-49 -21v-10h326v136h3q66 -159 250 -159q160 0 261.5 113.5t101.5 286.5q0 192 -95.5 301t-263.5 109q-82 0 -152 -44t-97 -113h-5v470h-329v-10zM243 608 q-243 -42 -243 -224q0 -101 67 -158.5t179 -57.5q75 0 143.5 37t108.5 99q9 -62 44.5 -99t90.5 -37q136 0 186 131l-12 5q-29 -41 -64 -41q-60 0 -60 99v390q0 187 -221 220q-40 6 -87 6q-113 0 -185 -25q-45 -16 -74 -42q-66 -61 -66 -174h196q-13 29 -13 61q0 40 31 76.5 t98 36.5q59 0 93.5 -36.5t34.5 -100.5v-121zM984 751h-209v-121h209v-231h121v231h208v121h-208v219h-121v-219zM1813 272q-98 0 -148 75t-50 219q0 146 52.5 227t145.5 81q96 0 146 -77.5t50 -230.5q0 -136 -51.5 -215t-144.5 -79zM490 441q0 -75 -47 -122t-112 -47 q-57 0 -91.5 36.5t-34.5 96.5q0 61 34.5 102t99.5 52l151 26v-144z" /> +<glyph unicode="" horiz-adv-x="2232" d="M1290 1281l49 -18q41 -16 57.5 -33t23.5 -51q7 -29 7 -109v-630q0 -112 -9 -146q-11 -35 -31 -49q-12 -8 -48 -23l-49 -21v-10h326v136h3q66 -159 250 -159q160 0 261.5 113.5t101.5 286.5q0 192 -95.5 301t-263.5 109q-82 0 -152 -44t-97 -113h-5v470h-329v-10zM490 653 l-247 -45q-243 -42 -243 -224q0 -101 67 -158.5t179 -57.5q75 0 143.5 37t108.5 99q9 -62 44.5 -99t90.5 -37q136 0 186 131l-12 5q-29 -41 -64 -41q-60 0 -60 99v390q0 187 -221 220q-40 6 -87 6q-113 0 -185 -25q-45 -16 -74 -42q-66 -61 -66 -174h196q-13 29 -13 61 q0 40 31 76.5t98 36.5q59 0 93.5 -36.5t34.5 -100.5v-121zM775 751v-121h209v-231h121v231h208v121h-208v219h-121v-219h-209zM1813 272q-98 0 -148 75t-50 219q0 146 52.5 227t145.5 81q96 0 146 -77.5t50 -230.5q0 -136 -51.5 -215t-144.5 -79zM490 585v-144 q0 -75 -47 -122t-112 -47q-57 0 -91.5 36.5t-34.5 96.5q0 61 34.5 102t99.5 52z" /> <glyph unicode="" horiz-adv-x="2084" d="M0 1360q0 -72 93 -72h3q8 0 50 5q31 16 43 72q17 70 72 113q54 45 147 45q187 0 285 -125q3 -3 24 -43l186 -368q-21 -23 -86 -120l-626 -849q-21 -53 -21 -76q0 -43 134 -43h159q66 0 107 53q232 326 295 416q126 182 162 225l21 30q5 7 33 47q96 -245 133 -326 q8 -14 45 -111q40 -104 59.5 -142.5t60.5 -91.5q92 -116 242 -116q229 0 344 183q120 182 120 391q0 70 -96 70q-33 0 -54 -10q-26 -12 -38 -68q-16 -67 -80 -111q-63 -48 -166 -48q-202 0 -310 126l-2 6l-21 38q-8 14 -30 60l-40 76l-101 193q19 11 98 126l627 883 q43 49 43 74q0 43 -137 43h-165q-77 0 -122 -53q-213 -300 -343 -502q-44 -68 -94 -148q-44 -70 -51 -81q-127 257 -171 348q-27 47 -55 108q-50 112 -60 127q-70 157 -143 190q-48 27 -109 27q-154 0 -265 -99q-103 -88 -151.5 -212t-48.5 -260z" /> -<glyph unicode="" horiz-adv-x="2460" d="M406 1777q168 0 286.5 -119.5t118.5 -287.5q0 -169 -118.5 -288t-286.5 -119t-287 119.5t-119 287.5t119.5 287.5t286.5 119.5zM2461 1325q0 -84 -60 -143.5t-144 -59.5h-967q-84 0 -143.5 59.5t-59.5 143.5v88q0 84 59.5 143.5t143.5 59.5h967q84 0 144 -59.5t60 -143.5 v-88zM545 1506q-59 56 -139 56q-82 0 -139.5 -56t-57.5 -136t57.5 -138t139.5 -58q81 0 138 57.5t57 138.5q0 80 -56 136zM406 651q168 0 286.5 -119t118.5 -287q0 -169 -118.5 -288t-286.5 -119q-111 0 -203 55q-94 54 -148.5 147.5t-54.5 204.5q0 168 119.5 287t286.5 119 zM2461 198q0 -84 -60 -142.5t-144 -58.5h-967q-84 0 -143.5 58.5t-59.5 142.5v91q0 83 59.5 142.5t143.5 59.5h967q84 0 144 -59.5t60 -142.5v-91z" /> -<glyph unicode="" horiz-adv-x="2459" d="M613 1774q84 0 143.5 -60t59.5 -144v-403q0 -84 -59 -143t-144 -59h-412q-83 0 -142 59t-59 143v403q0 84 59 144t142 60h412zM2459 1325q0 -84 -59.5 -143.5t-143.5 -59.5h-967q-84 0 -143.5 59.5t-59.5 143.5v88q0 84 59.5 143.5t143.5 59.5h967q84 0 143.5 -59.5 t59.5 -143.5v-88zM210 1561v-385h396v385h-396zM613 649q85 0 144 -59.5t59 -142.5v-407q0 -84 -59 -142.5t-144 -58.5h-412q-83 0 -142 58.5t-59 142.5v407q0 83 59 142.5t142 59.5h412zM2459 198q0 -84 -59.5 -142.5t-143.5 -58.5h-967q-84 0 -143.5 58.5t-59.5 142.5v91 q0 83 59.5 142.5t143.5 59.5h967q84 0 143.5 -59.5t59.5 -142.5v-91zM210 439v-390h396v390h-396z" /> +<glyph unicode="" horiz-adv-x="2460" d="M406 1777q168 0 286.5 -119.5t118.5 -287.5q0 -169 -118.5 -288t-286.5 -119t-287 119.5t-119 287.5t119.5 287.5t286.5 119.5zM2461 1413v-88q0 -84 -60 -143.5t-144 -59.5h-967q-84 0 -143.5 59.5t-59.5 143.5v88q0 84 59.5 143.5t143.5 59.5h967q84 0 144 -59.5 t60 -143.5zM545 1506q-59 56 -139 56q-82 0 -139.5 -56t-57.5 -136t57.5 -138t139.5 -58q81 0 138 57.5t57 138.5q0 80 -56 136zM406 651q168 0 286.5 -119t118.5 -287q0 -169 -118.5 -288t-286.5 -119q-111 0 -203 55q-94 54 -148.5 147.5t-54.5 204.5q0 168 119.5 287 t286.5 119zM2461 289v-91q0 -84 -60 -142.5t-144 -58.5h-967q-84 0 -143.5 58.5t-59.5 142.5v91q0 83 59.5 142.5t143.5 59.5h967q84 0 144 -59.5t60 -142.5z" /> +<glyph unicode="" horiz-adv-x="2459" d="M201 1774h412q84 0 143.5 -60t59.5 -144v-403q0 -84 -59 -143t-144 -59h-412q-83 0 -142 59t-59 143v403q0 84 59 144t142 60zM2459 1413v-88q0 -84 -59.5 -143.5t-143.5 -59.5h-967q-84 0 -143.5 59.5t-59.5 143.5v88q0 84 59.5 143.5t143.5 59.5h967q84 0 143.5 -59.5 t59.5 -143.5zM210 1176h396v385h-396v-385zM201 649h412q85 0 144 -59.5t59 -142.5v-407q0 -84 -59 -142.5t-144 -58.5h-412q-83 0 -142 58.5t-59 142.5v407q0 83 59 142.5t142 59.5zM2459 289v-91q0 -84 -59.5 -142.5t-143.5 -58.5h-967q-84 0 -143.5 58.5t-59.5 142.5v91 q0 83 59.5 142.5t143.5 59.5h967q84 0 143.5 -59.5t59.5 -142.5zM210 49h396v390h-396v-390z" /> <glyph unicode="" horiz-adv-x="2052" d="M651 854q0 -94 18 -233q2 -20 11 -107q12 -133 107 -313q80 -138 215 -232q140 -87 345 -87q200 0 341.5 83.5t221.5 235.5q85 143 109 313q34 177 34 340q0 156 -34 344q-26 171 -109 312q-73 142 -213 229q-133 90 -350 90q-376 0 -560 -319q-72 -128 -107 -312 q-29 -200 -29 -344zM1048 854q0 42 6 176q17 243 124 367q56 66 169 66q197 0 264 -228q42 -153 42 -381q0 -224 -42 -379q-67 -226 -264 -226q-190 0 -257 226q-28 106 -36 199q-6 142 -6 180zM0 142q0 -46 18 -96q26 -50 52 -79q32 -31 79.5 -50.5t95.5 -19.5q45 0 95 21 q98 41 129 128q20 43 20 96t-20 96q-16 45 -52 76q-71 71 -172 71q-61 0 -95 -18q-46 -21 -75.5 -49.5t-56.5 -79.5q-18 -51 -18 -96z" /> -<glyph unicode="" horiz-adv-x="3094" d="M417 631h265v1300h-258l-424 -357l157 -181l260 230v-992zM1826 519h-577l394 340q44 40 69 68q43 53 58 82q45 83 45 199q0 93 -32 171q-63 148 -224 195q-81 26 -165 26q-91 0 -177 -26q-163 -46 -241 -198q-37 -78 -41 -175l275 -20q6 72 49.5 118.5t119.5 46.5 q65 0 112 -39q48 -37 48 -102q0 -58 -36 -101q-32 -38 -80 -81l-505 -454v-292h908v242zM2426 337q95 -2 143 -62q22 -31 22 -84q0 -79 -44.5 -123t-131.5 -44q-90 0 -133 40q-46 39 -60 111l-291 -69q41 -176 167 -255q123 -81 300 -81q206 0 331 96q61 46 102 123 q35 78 35 171q0 112 -64 200q-67 85 -180 103v6q106 21 165 98q58 79 58 186q0 179 -133 273q-60 48 -139 67q-75 22 -167 22q-88 0 -157 -18q-155 -35 -240 -151q-45 -58 -66 -144l303 -61q13 53 56.5 87t104.5 34q68 0 115 -39t47 -97q0 -89 -66 -119q-59 -26 -143 -26 h-90v-239h64z" /> -<glyph unicode="" horiz-adv-x="2442" d="M765 252q34 -100 61 -147l17 -30h-704q-58 0 -98.5 41t-40.5 99v1392q0 58 40.5 98.5t98.5 40.5h139v104q0 72 51.5 123.5t123.5 51.5h69q71 0 122.5 -51.5t51.5 -123.5v-104h418v104q0 72 51 123.5t123 51.5h70q72 0 122.5 -51.5t50.5 -123.5v-104h140q58 0 98.5 -40.5 t40.5 -98.5v-175l-33 8q-28 7 -95 12t-122 3q-75 -3 -167.5 -25t-170.5 -58q-25 -11 -80 -44h-203v-169l19 18q-38 -39 -88 -104v255h-348v-313h309q-24 -40 -39 -70h-270v-347h184l5 39q-3 -63 2 -143l-7 34h-184v-313h251zM417 1537q0 -13 11 -23.5t25 -10.5h69 q14 0 24.5 10.5t10.5 23.5v313q0 14 -10.5 25t-24.5 11h-69q-14 0 -25 -11t-11 -25v-313zM1253 1537q0 -13 10.5 -23.5t24.5 -10.5h70q14 0 24 10.5t10 23.5v313q0 14 -10.5 25t-23.5 11h-70q-14 0 -24.5 -11t-10.5 -25v-313zM2377 877q65 -156 65 -323 q0 -225 -111.5 -418.5t-304.5 -305.5q-190 -112 -419 -112q-226 0 -419 111.5t-305 305.5q-112 190 -112 419q0 167 64.5 321.5t179.5 269.5t270.5 180t321.5 65t322 -65q315 -134 448 -448zM139 1328v-313h314v313h-314zM1042 1255q38 33 100 73h3zM1042 1255l-17 -14l10 9 zM958 1177q33 34 67 64zM2120 340q43 102 43 214q0 150 -74 278.5t-203 203.5t-279 75t-279 -74.5t-204 -203.5t-75 -279q0 -149 75 -278t204 -204q128 -75 279 -75q110 0 215 44q211 90 298 299zM831 1015q14 24 39 58l-37 -58h-2zM1920 554q13 0 23.5 -10.5t10.5 -24.5 v-70q0 -13 -10 -23.5t-24 -10.5h-418q-14 0 -24.5 10.5t-10.5 23.5v488q0 13 10.5 23.5t24.5 10.5h69q14 0 25 -10.5t11 -23.5v-383h313zM139 945v-347h314v347h-314zM729 771q19 83 63 174h3zM713 667q3 55 16 102l-18 -132zM139 528v-313h314v313h-314zM765 252 q-11 32 -21 66q-24 78 -31 176z" /> -<glyph unicode="" horiz-adv-x="2161" d="M2091 1215q70 121 70 264q0 144 -70 265q-70 122 -191.5 192t-265.5 70q-105 0 -201.5 -40.5t-169.5 -113.5t-113.5 -171t-40.5 -202t40.5 -201.5t113.5 -170.5t170 -113.5t201 -40.5q141 0 263 70.5t194 191.5zM1578 805q-10 -1 -30 0q-27 0 -83 10q-65 11 -118.5 30.5 t-117.5 60.5q-144 91 -212 235q-34 71 -45.5 167t4.5 161l6 29q-33 -56 -62 -100q-62 -98 -176 -259t-200 -266q-223 -275 -489 -523q-36 -33 -46 -48q-10 -13 -8.5 -35.5t14.5 -35.5q22 -25 94 -102q72 -76 99 -107q39 -46 101 -11l88 65q552 398 1083 677z" /> -<glyph unicode="" horiz-adv-x="2425" d="M565 937q-263 -153 -414 -402.5t-151 -536.5h2425q0 291 -153 543t-417 402l226 359q25 32 16.5 72.5t-44.5 64.5q-36 20 -77 11t-65 -43l-242 -377q-212 79 -458 79q-250 0 -466 -84l-243 382q-23 34 -63 43t-77 -11q-36 -24 -44.5 -65t15.5 -72zM871 439 q-43 -42 -102 -42t-101 41.5t-42 100.5q0 61 42 102.5t101 41.5q60 0 102 -42t42 -102q0 -58 -42 -100zM1656 397q-59 0 -101.5 42t-42.5 100q0 60 42.5 102t101.5 42q61 0 102.5 -41.5t41.5 -102.5q0 -59 -42 -100.5t-102 -41.5z" /> -<glyph unicode="" horiz-adv-x="2508" d="M2450 1814q31 -7 47.5 -36t8.5 -63l-410 -1488l-10 -36l-4 -14q-22 -35 -123 -162q-49 -62 -113 -151l-28 -33l-4 44q-13 114 -25 183q-8 84 -19 206v11l420 1527q7 30 36.5 48t62.5 12l13 -7l135 -36zM1792 -93q7 -18 14 -37l8 -39l-16 38q-25 55 -64 85q-20 18 -67 31 q-67 12 -205 -24q-88 -21 -104 -24q-195 -42 -323 -14q-44 9 -118 34l-37 18l-40 23l-2 2q-2 1 -3 2t-3 2l-2 2q-13 10 -23 12l-15 13l-19 16q-181 147 -95 371q16 41 45 88q37 62 102 144q4 5 50 64q142 162 184 249q13 22 22 51l-1 34h-2l2 1l-10 13l-2 6l-4 4q-3 3 -6 3 l-2 3q-2 0 -4 2q-1 1 -4 1q-38 9 -81 1q-55 -8 -117 -33q-95 -37 -249 -128.5t-289 -194.5q-15 -11 -34 -26l-20 -16t-21 -16q-7 -7 -11 -9l-31 -24l-195 252l38 25q39 29 97 66q229 146 363 213q263 126 443 143q132 6 217 -40l29 -17q9 -9 27 -23q18 -10 44 -48l5 -8 l6 -11h2v-2l4 -10l11 -17l7 -21q1 -3 2.5 -9.5t2.5 -9.5l6 -19l2 -18q16 -86 -35 -183.5t-133 -184.5q-45 -48 -120 -123q-88 -89 -140 -155q-94 -120 -76 -192q8 -49 60 -99q7 -10 7 -11l14 -10q3 -1 11 -9q14 -14 21 -15l26 -19l27 -15q54 -34 123 -47q100 -23 253 -8 q25 1 76 9q100 12 156 12q66 0 98 -13q42 -14 75 -45q27 -27 43 -66zM1064 1075l2 -3v3h-2zM2063 241q-15 16 -37 26l-13 2q-48 4 -70 -37l-4 -15l-26 7q4 4 4 15q1 22 -10 40t-30 26q-8 5 -12 5q-22 7 -49 -3l-10 -38q0 -25 23 -220l92 -26q117 151 133 179z" /> -<glyph unicode="" horiz-adv-x="1698" d="M1352 -71q35 50 54 499.5t18 933.5l-1 441q0 48 -38.5 95.5t-80.5 47.5l-62 16q-64 14 -180 27.5t-207 13.5q-103 0 -211.5 -13t-167.5 -30l-49 -14q-51 0 -102 -48.5t-51 -94.5q0 -44 1 -61q0 -65 8 -399q14 -743 39 -1130q6 -100 16 -187q8 -77 25 -109.5t63 -62.5 q66 -45 190 -60q47 -5 73 -5h272q80 0 184 22q65 12 120 43t87 75zM1282 785q0 -17 -13.5 -26.5t-35.5 -9.5h-769q-37 0 -37 36v958q0 35 37 35h769q22 0 35.5 -9t13.5 -26v-958zM843 678q59 0 108 -17q46 -18 46 -42q0 -28 -46.5 -49t-107.5 -21q-62 0 -108 21t-46 49 q0 26 46 42q48 17 108 17zM451 440q7 3 18 6q63 17 90 7q92 -20 105 -37q0 -18 -31.5 -32.5t-73.5 -14.5q-52 0 -93 32q-32 23 -15 39zM1139 453q56 7 101 -7l17 -6q10 -18 -33 -44.5t-85 -26.5q-57 0 -86 25q-14 11 -8 22q0 12 80 33l1 1h2l2 1zM855 429q40 0 67.5 -14.5 t27.5 -33.5t-27.5 -33t-67.5 -14q-42 0 -75 14.5t-33 32.5t33 33t75 15zM451 310q7 3 18 6q59 16 90 6q91 -20 105 -36q0 -18 -31.5 -32t-73.5 -14q-53 0 -94 32q-27 22 -14 38zM1139 322q56 8 101 -6l17 -6q10 -18 -33 -44t-85 -26q-39 0 -71 15q-32 14 -23 31q0 11 77 32 q4 0 17 4zM855 286q95 0 95 -36q0 -18 -27 -31.5t-68 -13.5q-44 0 -76 13.5t-32 31.5q0 17 31 27q30 9 77 9zM469 189q50 22 90 16q78 -17 105 -50q0 -18 -31.5 -32t-73.5 -14q-53 0 -94 32q-29 23 -14 38zM1139 205q23 3 52.5 -2.5t48.5 -14.5l17 -9q10 -18 -33 -44 t-85 -26q-39 0 -71 15q-32 14 -23 31q0 10 29 25q28 15 65 25zM855 155q41 0 68 -13.5t27 -32.5q0 -36 -95 -36q-108 0 -108 36q0 18 32 32t76 14zM469 67q57 17 90 6q77 -13 105 -48q0 -35 -105 -35q-51 0 -88 24t-20 48zM1139 73q56 8 101 -6l17 -5q9 -26 -30.5 -49 t-87.5 -23q-42 0 -72 9q-31 9 -22 26q0 10 29 25q27 15 65 23zM855 25q41 0 68 -13.5t27 -32.5q0 -36 -95 -36q-108 0 -108 36q0 17 33 31.5t75 14.5z" /> -<glyph unicode="" horiz-adv-x="2746" d="M1469 1726l-824 75l-645 -571l758 -160zM1993 22q273 230 458 461q140 175 214.5 333.5t79.5 300.5t-72 253q-50 74 -132.5 132.5t-172.5 95.5t-204 64t-211.5 41t-210.5 23l147 -365q416 -14 559.5 -182.5t20.5 -469.5q-121 -294 -476 -687zM1271 -16l675 711l-375 977 l-712 -666zM1862 50q122 106 242 245.5t211 292.5q83 143 98 287q13 140 -101 229t-366 106l103 -213q119 -20 161 -110.5t-1 -223.5q-79 -271 -347 -613zM1169 -90l-798 232l-371 956l758 -149z" /> -<glyph unicode="" horiz-adv-x="2097" d="M1575 1949q141 0 262 -70t190.5 -191.5t69.5 -261.5v-1037q0 -141 -69 -262q-70 -122 -190 -191.5t-263 -69.5h-1060q-140 0 -258 70q-119 70 -188 191t-69 262v1037q0 140 69 261q69 122 188 192t258 70h1060zM541 1401v-991h1016v991h-1016z" /> +<glyph unicode="" horiz-adv-x="3094" d="M682 631v1300h-258l-424 -357l157 -181l260 230v-992h265zM1249 519l394 340q44 40 69 68q43 53 58 82q45 83 45 199q0 93 -32 171q-63 148 -224 195q-81 26 -165 26q-91 0 -177 -26q-163 -46 -241 -198q-37 -78 -41 -175l275 -20q6 72 49.5 118.5t119.5 46.5 q65 0 112 -39q48 -37 48 -102q0 -58 -36 -101q-32 -38 -80 -81l-505 -454v-292h908v242h-577zM2334 342l92 -5q95 -2 143 -62q22 -31 22 -84q0 -79 -44.5 -123t-131.5 -44q-90 0 -133 40q-46 39 -60 111l-291 -69q41 -176 167 -255q123 -81 300 -81q206 0 331 96 q61 46 102 123q35 78 35 171q0 112 -64 200q-67 85 -180 103v6q106 21 165 98q58 79 58 186q0 179 -133 273q-60 48 -139 67q-75 22 -167 22q-88 0 -157 -18q-155 -35 -240 -151q-45 -58 -66 -144l303 -61q13 53 56.5 87t104.5 34q68 0 115 -39t47 -97q0 -89 -66 -119 q-59 -26 -143 -26h-90v-239h64z" /> +<glyph unicode="" horiz-adv-x="2442" d="M773 215l-8 37q34 -100 61 -147l17 -30h-704q-58 0 -98.5 41t-40.5 99v1392q0 58 40.5 98.5t98.5 40.5h139v104q0 72 51.5 123.5t123.5 51.5h69q71 0 122.5 -51.5t51.5 -123.5v-104h418v104q0 72 51 123.5t123 51.5h70q72 0 122.5 -51.5t50.5 -123.5v-104h140 q58 0 98.5 -40.5t40.5 -98.5v-175l-33 8q-28 7 -95 12t-122 3q-75 -3 -167.5 -25t-170.5 -58q-25 -11 -80 -44h-203v-169l19 18q-38 -39 -88 -104v255h-348v-313h309q-24 -40 -39 -70h-270v-347h184l5 39q-3 -63 2 -143l-7 34h-184v-313h251zM417 1850v-313q0 -13 11 -23.5 t25 -10.5h69q14 0 24.5 10.5t10.5 23.5v313q0 14 -10.5 25t-24.5 11h-69q-14 0 -25 -11t-11 -25zM1253 1850v-313q0 -13 10.5 -23.5t24.5 -10.5h70q14 0 24 10.5t10 23.5v313q0 14 -10.5 25t-23.5 11h-70q-14 0 -24.5 -11t-10.5 -25zM2377 877q65 -156 65 -323 q0 -225 -111.5 -418.5t-304.5 -305.5q-190 -112 -419 -112q-226 0 -419 111.5t-305 305.5q-112 190 -112 419q0 167 64.5 321.5t179.5 269.5t270.5 180t321.5 65t322 -65q315 -134 448 -448zM139 1015h314v313h-314v-313zM1145 1328l-103 -73q38 33 100 73h3zM1025 1241 l10 9l7 5zM1025 1241l-67 -64q33 34 67 64zM2120 340q43 102 43 214q0 150 -74 278.5t-203 203.5t-279 75t-279 -74.5t-204 -203.5t-75 -279q0 -149 75 -278t204 -204q128 -75 279 -75q110 0 215 44q211 90 298 299zM833 1015h-2q14 24 39 58zM1607 554h313q13 0 23.5 -10.5 t10.5 -24.5v-70q0 -13 -10 -23.5t-24 -10.5h-418q-14 0 -24.5 10.5t-10.5 23.5v488q0 13 10.5 23.5t24.5 10.5h69q14 0 25 -10.5t11 -23.5v-383zM139 598h314v347h-314v-347zM795 945l-66 -174q19 83 63 174h3zM711 637l2 30q3 55 16 102zM139 215h314v313h-314v-313z M713 494l52 -242q-11 32 -21 66q-24 78 -31 176z" /> +<glyph unicode="" horiz-adv-x="2161" d="M2091 1215q70 121 70 264q0 144 -70 265q-70 122 -191.5 192t-265.5 70q-105 0 -201.5 -40.5t-169.5 -113.5t-113.5 -171t-40.5 -202t40.5 -201.5t113.5 -170.5t170 -113.5t201 -40.5q141 0 263 70.5t194 191.5zM1480 753l98 52q-10 -1 -30 0q-27 0 -83 10 q-65 11 -118.5 30.5t-117.5 60.5q-144 91 -212 235q-34 71 -45.5 167t4.5 161l6 29q-33 -56 -62 -100q-62 -98 -176 -259t-200 -266q-223 -275 -489 -523q-36 -33 -46 -48q-10 -13 -8.5 -35.5t14.5 -35.5q22 -25 94 -102q72 -76 99 -107q39 -46 101 -11l88 65 q552 398 1083 677z" /> +<glyph unicode="" horiz-adv-x="2425" d="M333 1302l232 -365q-263 -153 -414 -402.5t-151 -536.5h2425q0 291 -153 543t-417 402l226 359q25 32 16.5 72.5t-44.5 64.5q-36 20 -77 11t-65 -43l-242 -377q-212 79 -458 79q-250 0 -466 -84l-243 382q-23 34 -63 43t-77 -11q-36 -24 -44.5 -65t15.5 -72zM871 439 q-43 -42 -102 -42t-101 41.5t-42 100.5q0 61 42 102.5t101 41.5q60 0 102 -42t42 -102q0 -58 -42 -100zM1656 397q-59 0 -101.5 42t-42.5 100q0 60 42.5 102t101.5 42q61 0 102.5 -41.5t41.5 -102.5q0 -59 -42 -100.5t-102 -41.5z" /> +<glyph unicode="" horiz-adv-x="2508" d="M2437 1819l13 -5q31 -7 47.5 -36t8.5 -63l-410 -1488l-10 -36l-4 -14q-22 -35 -123 -162q-49 -62 -113 -151l-28 -33l-4 44q-13 114 -25 183q-8 84 -19 206v11l420 1527q7 30 36.5 48t62.5 12l13 -7zM1792 -93q7 -18 14 -37l8 -39l-16 38q-25 55 -64 85q-20 18 -67 31 q-67 12 -205 -24q-88 -21 -104 -24q-195 -42 -323 -14q-44 9 -118 34l-37 18l-40 23l-2 2q-2 1 -3 2t-3 2l-2 2q-13 10 -23 12l-15 13l-19 16q-181 147 -95 371q16 41 45 88q37 62 102 144q4 5 50 64q142 162 184 249q13 22 22 51l-1 34h-2l2 1l-10 13l-2 6l-4 4q-3 3 -6 3 l-2 3q-2 0 -4 2q-1 1 -4 1q-38 9 -81 1q-55 -8 -117 -33q-95 -37 -249 -128.5t-289 -194.5q-15 -11 -34 -26l-20 -16t-21 -16q-7 -7 -11 -9l-31 -24l-195 252l38 25q39 29 97 66q229 146 363 213q263 126 443 143q132 6 217 -40l29 -17q9 -9 27 -23q18 -10 44 -48l5 -8 l6 -11h2v-2l4 -10l11 -17l7 -21q1 -3 2.5 -9.5t2.5 -9.5l6 -19l2 -18q16 -86 -35 -183.5t-133 -184.5q-45 -48 -120 -123q-88 -89 -140 -155q-94 -120 -76 -192q8 -49 60 -99q7 -10 7 -11l14 -10q3 -1 11 -9q14 -14 21 -15l26 -19l27 -15q54 -34 123 -47q100 -23 253 -8 q25 1 76 9q100 12 156 12q66 0 98 -13q42 -14 75 -45q27 -27 43 -66zM1066 1072v3h-2zM2054 202l9 39q-15 16 -37 26l-13 2q-48 4 -70 -37l-4 -15l-26 7q4 4 4 15q1 22 -10 40t-30 26q-8 5 -12 5q-22 7 -49 -3l-10 -38q0 -25 23 -220l92 -26q117 151 133 179z" /> +<glyph unicode="" horiz-adv-x="1698" d="M1352 -71q35 50 54 499.5t18 933.5l-1 441q0 48 -38.5 95.5t-80.5 47.5l-62 16q-64 14 -180 27.5t-207 13.5q-103 0 -211.5 -13t-167.5 -30l-49 -14q-51 0 -102 -48.5t-51 -94.5q0 -44 1 -61q0 -65 8 -399q14 -743 39 -1130q6 -100 16 -187q8 -77 25 -109.5t63 -62.5 q66 -45 190 -60q47 -5 73 -5h272q80 0 184 22q65 12 120 43t87 75zM1282 1743v-958q0 -17 -13.5 -26.5t-35.5 -9.5h-769q-37 0 -37 36v958q0 35 37 35h769q22 0 35.5 -9t13.5 -26zM843 678q59 0 108 -17q46 -18 46 -42q0 -28 -46.5 -49t-107.5 -21q-62 0 -108 21t-46 49 q0 26 46 42q48 17 108 17zM451 440q7 3 18 6q63 17 90 7q92 -20 105 -37q0 -18 -31.5 -32.5t-73.5 -14.5q-52 0 -93 32q-32 23 -15 39zM1130 451l9 2q56 7 101 -7l17 -6q10 -18 -33 -44.5t-85 -26.5q-57 0 -86 25q-14 11 -8 22q0 12 80 33l1 1h2zM855 429q40 0 67.5 -14.5 t27.5 -33.5t-27.5 -33t-67.5 -14q-42 0 -75 14.5t-33 32.5t33 33t75 15zM451 310q7 3 18 6q59 16 90 6q91 -20 105 -36q0 -18 -31.5 -32t-73.5 -14q-53 0 -94 32q-27 22 -14 38zM1139 322q56 8 101 -6l17 -6q10 -18 -33 -44t-85 -26q-39 0 -71 15q-32 14 -23 31q0 11 77 32 q4 0 17 4zM855 286q95 0 95 -36q0 -18 -27 -31.5t-68 -13.5q-44 0 -76 13.5t-32 31.5q0 17 31 27q30 9 77 9zM451 179l18 10q50 22 90 16q78 -17 105 -50q0 -18 -31.5 -32t-73.5 -14q-53 0 -94 32q-29 23 -14 38zM1139 205q23 3 52.5 -2.5t48.5 -14.5l17 -9q10 -18 -33 -44 t-85 -26q-39 0 -71 15q-32 14 -23 31q0 10 29 25q28 15 65 25zM855 155q41 0 68 -13.5t27 -32.5q0 -36 -95 -36q-108 0 -108 36q0 18 32 32t76 14zM451 62l18 5q57 17 90 6q77 -13 105 -48q0 -35 -105 -35q-51 0 -88 24t-20 48zM1139 73q56 8 101 -6l17 -5q9 -26 -30.5 -49 t-87.5 -23q-42 0 -72 9q-31 9 -22 26q0 10 29 25q27 15 65 23zM855 25q41 0 68 -13.5t27 -32.5q0 -36 -95 -36q-108 0 -108 36q0 17 33 31.5t75 14.5z" /> +<glyph unicode="" horiz-adv-x="2746" d="M645 1801l-645 -571l758 -160l711 656zM1993 22q273 230 458 461q140 175 214.5 333.5t79.5 300.5t-72 253q-50 74 -132.5 132.5t-172.5 95.5t-204 64t-211.5 41t-210.5 23l147 -365q416 -14 559.5 -182.5t20.5 -469.5q-121 -294 -476 -687zM1946 695l-375 977l-712 -666 l412 -1022zM1862 50q122 106 242 245.5t211 292.5q83 143 98 287q13 140 -101 229t-366 106l103 -213q119 -20 161 -110.5t-1 -223.5q-79 -271 -347 -613zM371 142l-371 956l758 -149l411 -1039z" /> +<glyph unicode="" horiz-adv-x="2097" d="M515 1949h1060q141 0 262 -70t190.5 -191.5t69.5 -261.5v-1037q0 -141 -69 -262q-70 -122 -190 -191.5t-263 -69.5h-1060q-140 0 -258 70q-119 70 -188 191t-69 262v1037q0 140 69 261q69 122 188 192t258 70zM541 410h1016v991h-1016v-991z" /> <glyph unicode="" horiz-adv-x="2098" d="M1049 1959q289 0 530 -144q240 -143 379.5 -383t139.5 -525q0 -170 -53 -330.5t-150 -291.5q-205 -273 -515 -376q-164 -53 -331 -53q-208 0 -402.5 81.5t-338.5 225.5q-145 145 -226.5 340t-81.5 404q0 208 81.5 403.5t226.5 340.5t339.5 226.5t401.5 81.5zM1049 1403 q-138 0 -254 -65q-118 -66 -186 -181t-68 -250q0 -137 68 -253q68 -118 185.5 -186.5t254.5 -68.5q102 0 196 40t163 110t107.5 164t38.5 194q0 208 -146.5 352t-358.5 144z" /> -<glyph unicode="" horiz-adv-x="1976" d="M1976 104h-1757v1826h-219v-2045h1976v219zM1907 1503v-474l-162 128l-462 -556l-381 301l-439 -509l-140 127l556 637l380 -301l348 405l-174 126z" /> -<glyph unicode="" horiz-adv-x="1970" d="M1970 106h-1749v1824h-221v-2045h1970v221zM1423 234h414v1595h-414v-1595zM352 234h414v880h-414v-880zM893 234h425v523h-425v-523z" /> -<glyph unicode="" horiz-adv-x="2466" d="M1001 993q-40 43 -77.5 111t-54.5 131q-30 113 -29 266q0 82 22 158.5t63 130.5q100 134 307 139l5 1l8 -1q209 -5 306 -139q52 -71 72 -178.5t13 -201t-29 -175.5q-15 -61 -52.5 -128t-80.5 -114q-36 -38 -37.5 -69t26.5 -97q26 -69 64 -91q49 -28 295 -119 q62 -23 88 -32.5t66 -25.5t53 -22.5t36 -21t29.5 -23.5t18.5 -26.5t18 -33t15 -40.5q17 -49 35 -330l2 -48q-14 -13 -34 -22q-22 -12 -91 -32q-248 -75 -820 -75q-420 0 -666 39q-94 15 -162 36q-65 19 -87 31q-15 7 -29 20q16 325 36 381q32 93 100 135q26 18 225 90 q242 90 294 119q40 22 71.5 94.5t17.5 113.5q-6 14 -37 49zM798 1655l-12 -21l-23 1h-8q-10 -1 -34 -1q-49 -4 -65 -26l-29 -39q-37 -49 -50 -129q-17 -76 -4.5 -181t58.5 -187q25 -47 55 -80l7 -7q2 -3 5 -5l3 -3v-1l3 -3l1 -2l1 -1q16 -22 20.5 -40.5t0.5 -54.5 q-4 -45 -30.5 -89t-63.5 -65q-29 -18 -240 -98q-64 -24 -71 -27q-105 -40 -128 -63q-16 -15 -33 -63q-13 -40 -24 -270v-16q20 -7 32 -8q6 -1 45 -11l25 -8l1 -27l2 -61l4 -56l-53 19q-125 45 -154 59q-17 6 -28 14q-11 6 -9 9q0 2 1 3t1 3q2 7 1 8l-3 6l-2 8v12l1 5 q11 287 34 356q33 95 82 131t229 103q194 73 221 89q7 4 16.5 21t10.5 29v6l-7 7q-90 99 -126 230q-4 13 -11 49q-21 97 -15 214q3 66 25.5 136t59.5 119q41 54 74 76.5t93 37.5v6l36 1h109v-49l-3 -8zM1779 1763q34 -7 79.5 -38.5t86.5 -75.5q48 -65 69 -153.5t19 -164.5 t-17 -151l-11 -49q-31 -121 -127 -230l-2 -3l-4 -4v-6q1 -12 10 -29t16 -21q22 -13 221 -89q184 -69 231.5 -104t80.5 -130q23 -69 34 -356v-10l1 -7l-3 -8l-1 -6q-1 -1 1 -9t-6 -13l-53 -24q-31 -14 -131 -50l-52 -19l2 56l2 61l1 27l25 8q38 10 46 11q12 1 32 8v8 q-1 2 -1 8q-10 232 -24 270q-20 57 -62 85q-19 10 -170 68q-217 85 -240 98q-37 20 -63 63.5t-31 90.5q-4 67 22 95l1 1v2l4 2v1l3 3l3 4l6 6q2 2 2 3q151 176 111 442q-13 81 -52 135q-36 53 -44 55l-12 4q-9 5 -40 6q-23 0 -32 1h-7l-24 -1l-10 21l-31 58l-5 8v49h109 l37 -1v-6zM882 743q37 16 49 24q11 4 19 15l21 25l-29 15l-11 4q-11 6 -21.5 25.5t-11.5 35.5v10q6 8 11 13q17 18 45 58l9 15l-10 15q-20 24 -36 50l-22 33l-21 -33q-25 -44 -47 -65q-27 -27 -34 -47t-3 -59q2 -33 20.5 -70t45.5 -61l11 -9zM1611 751q27 23 44.5 58 t19.5 68q4 34 -3 54.5t-33 46.5q-21 24 -48 65l-15 26l-16 -24q-21 -33 -37 -52l-8 -10l7 -12q27 -39 46 -58q7 -7 11 -15v-12q-1 -18 -12.5 -38t-23.5 -27q-3 -3 -10 -6l-23 -12l16 -18q11 -11 17 -14q16 -9 49 -24l10 -4z" /> -<glyph unicode="" horiz-adv-x="1940" d="M1922 499q13 -8 16.5 -24t-4.5 -26l-133 -211q-8 -15 -25 -19t-30 5l-244 82l-3 3q-135 -123 -314 -174l-50 -215q0 -16 -11 -27.5t-28 -11.5h-251q-16 0 -27.5 11t-11.5 28l-49 213q-175 49 -320 178l-211 -73q-13 -8 -30 -5t-25 16l-133 212q-8 13 -5 29.5t16 24.5 l157 163q-32 111 -32 219q0 101 29 212l-2 2l-179 183q-15 8 -19 25t5 30l131 212q8 14 24.5 18t30.5 -4l245 -82l2 -3q142 123 311 172l54 232q0 16 11 27.5t28 11.5h251q17 0 29 -11.5t12 -27.5l55 -232q165 -47 301 -165l226 78q13 8 30 4.5t25 -16.5l135 -212 q8 -13 4.5 -30t-16.5 -25l-162 -167q34 -103 34 -232q0 -97 -30 -212l3 -3zM484 607q75 -131 205.5 -206t283.5 -75q112 0 216.5 43.5t182.5 121.5t121.5 182.5t43.5 216.5q0 152 -75 283q-76 131 -206.5 206.5t-282.5 75.5q-151 0 -282 -75q-131 -76 -207 -207t-76 -283 q0 -153 76 -283z" /> -<glyph unicode="" horiz-adv-x="2045" d="M626 1849q189 81 397 81q203 0 392.5 -79t331.5 -219q140 -139 219.5 -330.5t79.5 -394.5t-79.5 -393t-219.5 -330q-142 -140 -332 -219.5t-392 -79.5t-392 79.5t-332 219.5q-140 140 -219.5 330t-79.5 393q0 204 80 396q161 383 546 546zM1335 1648q-146 64 -312 64 q-159 0 -308.5 -62.5t-260.5 -173.5t-173.5 -260.5t-62.5 -308.5t62.5 -309t173.5 -259q111 -111 260.5 -173.5t308.5 -62.5q217 0 402 108q186 108 294 293t108 403q0 160 -63 310q-126 299 -429 431zM1144 725q0 59 64 113q113 87 157.5 157.5t44.5 164.5q0 82 -29 138 q-54 119 -197 164q-68 20 -142 20q-175 0 -282 -92.5t-127 -262.5l253 -16q4 65 46.5 105t115.5 40q50 0 84 -32t34 -82q0 -28 -20 -59t-37.5 -47.5t-68.5 -62.5q-22 -18 -66 -57q-35 -35 -40 -42q-16 -23 -22 -42q-14 -39 -14 -83v-72h246v48zM882 494q-11 -28 -11 -59 q0 -64 43 -107t108 -43q63 0 106 43t43 107t-43 107t-106 43q-65 0 -108 -43q-19 -19 -32 -48z" /> -<glyph unicode="" horiz-adv-x="2003" d="M278 1930q-95 0 -154.5 -42.5t-91.5 -145t-32 -271.5l8 -3q7 -4 35 -7q21 -4 67 -4q45 0 71.5 28.5t34.5 62.5q0 5 2 11q0 68 21.5 110.5t59.5 42.5h1349q54 0 95.5 -34.5t41.5 -85.5v-595q0 -54 -34 -95t-86 -41l-262 1l9 43q25 128 25 158q0 19 -14.5 33t-29.5 14h-19 q-12 0 -21 -9l-328 -328q-8 -8 -8 -21t8 -21l328 -329q9 -9 21 -9h19q15 0 29.5 14t14.5 33q0 33 -45 201h317q140 0 217 87t77 235v672q0 141 -86.5 218t-235.5 77h-1403zM1816 323q-20 -23 -28 -55l-3 -11q0 -69 -22 -111t-60 -42h-1349q-54 0 -95 34t-41 86v595 q0 54 34 95t86 41l262 -1l-8 -43q-25 -128 -25 -158q0 -20 13.5 -33.5t29.5 -13.5h20q11 0 20 9l328 328q8 8 8 21t-8 21l-328 329q-9 9 -20 9h-20q-16 0 -29.5 -13.5t-13.5 -33.5q0 -31 44 -202h-316q-141 0 -218 -86t-77 -235v-674q0 -140 87 -217t235 -77h1404 q140 0 208.5 114.5t68.5 355.5l-109 3q-46 0 -78 -35z" /> -<glyph unicode="" horiz-adv-x="2115" d="M881 120q4 -15 4 -27q0 -130 -149 -270l-19 -15l21 -39l46 18q116 48 224 112q70 41 132.5 91t94.5 95l24 35h534q149 0 235.5 77.5t86.5 218.5v1106q0 141 -86.5 218t-235.5 77h-1471q-148 0 -235 -77.5t-87 -217.5v-1106q0 -141 86.5 -218.5t235.5 -77.5h559zM1897 459 q0 -51 -41.5 -85.5t-95.5 -34.5h-1405q-54 0 -95 34t-41 86v1019q0 52 41 86t95 34h1405q54 0 95.5 -34.5t41.5 -85.5v-1019z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1758 665q68 0 116.5 -49t48.5 -117t-48.5 -117t-116.5 -49h-325l-70 -334q-11 -58 -57 -95.5t-105 -37.5q-6 0 -34 5q-67 14 -104.5 71.5t-23.5 123.5l55 267h-368l-71 -332q-11 -57 -56.5 -94t-104.5 -37q-18 0 -35 3q-67 14 -104.5 71.5t-24.5 124.5l55 264h-219 q-68 0 -117 49t-49 117t49 117t117 49h288l111 533h-275q-68 0 -116.5 49.5t-48.5 117.5t48.5 116.5t116.5 48.5h345l58 280q12 57 58 94.5t104 37.5q12 0 34 -5q68 -13 105 -69.5t24 -125.5l-44 -212h369l58 278q12 56 58 93t104 37q19 0 35 -3q66 -12 104 -70.5t25 -125.5 l-44 -209h199q68 0 117 -48.5t49 -116.5t-49 -117.5t-117 -49.5h-269l-111 -533h256zM905 1198l-111 -533h369l111 533h-369z" /> -<glyph unicode="" horiz-adv-x="2045" d="M0 -115h2044v180h-1864v1865h-180v-2045zM1586 1293q7 14 7 36q0 37 -26.5 63.5t-63.5 26.5t-63.5 -26.5t-26.5 -63.5v-9l-156 -88q-25 18 -55 18q-24 0 -44 -11l-169 128q3 11 3 22q0 37 -26.5 63.5t-63.5 26.5t-64 -26.5t-27 -63.5q0 -3 2 -17l-164 -112q-23 14 -48 14 q-42 0 -69 -32l-221 67l-21 -69l221 -67q5 -33 31 -56t59 -23q37 0 63.5 26t26.5 64q0 13 -1 17l164 111q25 -14 48 -14q22 0 44 12l169 -129q-3 -9 -3 -21q0 -37 26.5 -63.5t63.5 -26.5q38 0 64.5 26.5t26.5 63.5v5q-1 2 -1 4l157 88q23 -19 54 -19q16 0 31 6l114 -122 l-133 -185q-3 0 -6 1h-6q-37 0 -63 -26l-149 47q-5 32 -30 53.5t-59 21.5q-37 0 -63.5 -26.5t-26.5 -63.5q0 -12 3 -21l-181 -160q-15 6 -32 6q-28 0 -54 -18l-157 76q-2 36 -28 61.5t-62 25.5q-37 0 -63.5 -26.5t-26.5 -64.5q0 -28 16 -51l-264 -507l75 -39l263 507 q29 0 54 18l157 -76q2 -36 28 -61t62 -25q38 0 64 26.5t26 63.5q0 11 -3 21l181 159q20 -6 32 -6q35 0 64 26l148 -47q6 -32 31 -53.5t58 -21.5q37 0 63.5 26.5t26.5 63.5q0 23 -9 41l123 172l71 -75l52 50l-80 85l88 124l-68 49l-79 -111zM902 1359q-13 0 -21.5 8.5 t-8.5 21.5q0 12 8.5 21t21.5 9q12 0 21 -9t9 -21q0 -13 -9 -21.5t-21 -8.5zM1503 1298q-12 0 -21 9t-9 22q0 12 9 21t21 9q13 0 21.5 -9t8.5 -21q0 -13 -8.5 -22t-21.5 -9zM580 1163q-9 9 -9 21q0 13 8.5 21.5t21.5 8.5t21.5 -8.5t8.5 -21.5q0 -12 -8.5 -21t-21.5 -9t-21 9z M1202 1130q-12 0 -21 9t-9 21q0 13 9 21.5t21 8.5q13 0 22 -8.5t9 -21.5q0 -12 -9 -21t-22 -9zM1202 914q-12 0 -21 8.5t-9 21.5q0 12 9 21t21 9q13 0 22 -9t9 -21q0 -13 -9 -21.5t-22 -8.5zM1503 817q-12 0 -21 9t-9 21q0 13 9 21.5t21 8.5q13 0 21.5 -8.5t8.5 -21.5 q0 -12 -8.5 -21t-21.5 -9zM580 802q-9 9 -9 21q0 13 8.5 21.5t21.5 8.5t21.5 -8.5t8.5 -21.5q0 -12 -8.5 -21t-21.5 -9t-21 9zM902 649q-13 0 -21.5 8.5t-8.5 21.5t8.5 21.5t21.5 8.5q12 0 21 -8.5t9 -21.5t-9 -21.5t-21 -8.5z" /> -<glyph unicode="" d="M0 629v1203h666l326 -332v-871h-992zM592 1452v238h-449v-918h706v680h-257zM241 1292v-55h504v55h-504zM2045 1207v-241h-962v241h962zM241 1134v-55h504v55h-504zM241 976v-56h504v56h-504zM2045 874v-241h-962v241h962zM2045 540v-240h-962v240h962zM2045 207v-240 h-962v240h962z" /> +<glyph unicode="" horiz-adv-x="1976" d="M219 104v1826h-219v-2045h1976v219h-1757zM1907 1029l-162 128l-462 -556l-381 301l-439 -509l-140 127l556 637l380 -301l348 405l-174 126l474 116v-474z" /> +<glyph unicode="" horiz-adv-x="1970" d="M221 106v1824h-221v-2045h1970v221h-1749zM1837 234v1595h-414v-1595h414zM766 234v880h-414v-880h414zM1318 234v523h-425v-523h425z" /> +<glyph unicode="" horiz-adv-x="2466" d="M1001 993q-40 43 -77.5 111t-54.5 131q-30 113 -29 266q0 82 22 158.5t63 130.5q100 134 307 139l5 1l8 -1q209 -5 306 -139q52 -71 72 -178.5t13 -201t-29 -175.5q-15 -61 -52.5 -128t-80.5 -114q-36 -38 -37.5 -69t26.5 -97q26 -69 64 -91q49 -28 295 -119 q62 -23 88 -32.5t66 -25.5t53 -22.5t36 -21t29.5 -23.5t18.5 -26.5t18 -33t15 -40.5q17 -49 35 -330l2 -48q-14 -13 -34 -22q-22 -12 -91 -32q-248 -75 -820 -75q-420 0 -666 39q-94 15 -162 36q-65 19 -87 31q-15 7 -29 20q16 325 36 381q32 93 100 135q26 18 225 90 q242 90 294 119q40 22 71.5 94.5t17.5 113.5q-6 14 -37 49zM786 1634l-23 1h-8q-10 -1 -34 -1q-49 -4 -65 -26l-29 -39q-37 -49 -50 -129q-17 -76 -4.5 -181t58.5 -187q25 -47 55 -80l7 -7q2 -3 5 -5l3 -3v-1l3 -3l1 -2l1 -1q16 -22 20.5 -40.5t0.5 -54.5q-4 -45 -30.5 -89 t-63.5 -65q-29 -18 -240 -98q-64 -24 -71 -27q-105 -40 -128 -63q-16 -15 -33 -63q-13 -40 -24 -270v-16q20 -7 32 -8q6 -1 45 -11l25 -8l1 -27l2 -61l4 -56l-53 19q-125 45 -154 59q-17 6 -28 14q-11 6 -9 9q0 2 1 3t1 3q2 7 1 8l-3 6l-2 8v12l1 5q11 287 34 356 q33 95 82 131t229 103q194 73 221 89q7 4 16.5 21t10.5 29v6l-7 7q-90 99 -126 230q-4 13 -11 49q-21 97 -15 214q3 66 25.5 136t59.5 119q41 54 74 76.5t93 37.5v6l36 1h109v-49l-3 -8l-31 -58zM1779 1769v-6q34 -7 79.5 -38.5t86.5 -75.5q48 -65 69 -153.5t19 -164.5 t-17 -151l-11 -49q-31 -121 -127 -230l-2 -3l-4 -4v-6q1 -12 10 -29t16 -21q22 -13 221 -89q184 -69 231.5 -104t80.5 -130q23 -69 34 -356v-10l1 -7l-3 -8l-1 -6q-1 -1 1 -9t-6 -13l-53 -24q-31 -14 -131 -50l-52 -19l2 56l2 61l1 27l25 8q38 10 46 11q12 1 32 8v8 q-1 2 -1 8q-10 232 -24 270q-20 57 -62 85q-19 10 -170 68q-217 85 -240 98q-37 20 -63 63.5t-31 90.5q-4 67 22 95l1 1v2l4 2v1l3 3l3 4l6 6q2 2 2 3q151 176 111 442q-13 81 -52 135q-36 53 -44 55l-12 4q-9 5 -40 6q-23 0 -32 1h-7l-24 -1l-10 21l-31 58l-5 8v49h109z M867 737l15 6q37 16 49 24q11 4 19 15l21 25l-29 15l-11 4q-11 6 -21.5 25.5t-11.5 35.5v10q6 8 11 13q17 18 45 58l9 15l-10 15q-20 24 -36 50l-22 33l-21 -33q-25 -44 -47 -65q-27 -27 -34 -47t-3 -59q2 -33 20.5 -70t45.5 -61zM1602 743l9 8q27 23 44.5 58t19.5 68 q4 34 -3 54.5t-33 46.5q-21 24 -48 65l-15 26l-16 -24q-21 -33 -37 -52l-8 -10l7 -12q27 -39 46 -58q7 -7 11 -15v-12q-1 -18 -12.5 -38t-23.5 -27q-3 -3 -10 -6l-23 -12l16 -18q11 -11 17 -14q16 -9 49 -24z" /> +<glyph unicode="" horiz-adv-x="1940" d="M1742 682l180 -183q13 -8 16.5 -24t-4.5 -26l-133 -211q-8 -15 -25 -19t-30 5l-244 82l-3 3q-135 -123 -314 -174l-50 -215q0 -16 -11 -27.5t-28 -11.5h-251q-16 0 -27.5 11t-11.5 28l-49 213q-175 49 -320 178l-211 -73q-13 -8 -30 -5t-25 16l-133 212q-8 13 -5 29.5 t16 24.5l157 163q-32 111 -32 219q0 101 29 212l-2 2l-179 183q-15 8 -19 25t5 30l131 212q8 14 24.5 18t30.5 -4l245 -82l2 -3q142 123 311 172l54 232q0 16 11 27.5t28 11.5h251q17 0 29 -11.5t12 -27.5l55 -232q165 -47 301 -165l226 78q13 8 30 4.5t25 -16.5l135 -212 q8 -13 4.5 -30t-16.5 -25l-162 -167q34 -103 34 -232q0 -97 -30 -212zM484 607q75 -131 205.5 -206t283.5 -75q112 0 216.5 43.5t182.5 121.5t121.5 182.5t43.5 216.5q0 152 -75 283q-76 131 -206.5 206.5t-282.5 75.5q-151 0 -282 -75q-131 -76 -207 -207t-76 -283 q0 -153 76 -283z" /> +<glyph unicode="" horiz-adv-x="2045" d="M626 1849q189 81 397 81q203 0 392.5 -79t331.5 -219q140 -139 219.5 -330.5t79.5 -394.5t-79.5 -393t-219.5 -330q-142 -140 -332 -219.5t-392 -79.5t-392 79.5t-332 219.5q-140 140 -219.5 330t-79.5 393q0 204 80 396q161 383 546 546zM1335 1648q-146 64 -312 64 q-159 0 -308.5 -62.5t-260.5 -173.5t-173.5 -260.5t-62.5 -308.5t62.5 -309t173.5 -259q111 -111 260.5 -173.5t308.5 -62.5q217 0 402 108q186 108 294 293t108 403q0 160 -63 310q-126 299 -429 431zM1144 677v48q0 59 64 113q113 87 157.5 157.5t44.5 164.5q0 82 -29 138 q-54 119 -197 164q-68 20 -142 20q-175 0 -282 -92.5t-127 -262.5l253 -16q4 65 46.5 105t115.5 40q50 0 84 -32t34 -82q0 -28 -20 -59t-37.5 -47.5t-68.5 -62.5q-22 -18 -66 -57q-35 -35 -40 -42q-16 -23 -22 -42q-14 -39 -14 -83v-72h246zM882 494q-11 -28 -11 -59 q0 -64 43 -107t108 -43q63 0 106 43t43 107t-43 107t-106 43q-65 0 -108 -43q-19 -19 -32 -48z" /> +<glyph unicode="" horiz-adv-x="2003" d="M1681 1930h-1403q-95 0 -154.5 -42.5t-91.5 -145t-32 -271.5l8 -3q7 -4 35 -7q21 -4 67 -4q45 0 71.5 28.5t34.5 62.5q0 5 2 11q0 68 21.5 110.5t59.5 42.5h1349q54 0 95.5 -34.5t41.5 -85.5v-595q0 -54 -34 -95t-86 -41l-262 1l9 43q25 128 25 158q0 19 -14.5 33 t-29.5 14h-19q-12 0 -21 -9l-328 -328q-8 -8 -8 -21t8 -21l328 -329q9 -9 21 -9h19q15 0 29.5 14t14.5 33q0 33 -45 201h317q140 0 217 87t77 235v672q0 141 -86.5 218t-235.5 77zM1816 323q-20 -23 -28 -55l-3 -11q0 -69 -22 -111t-60 -42h-1349q-54 0 -95 34t-41 86v595 q0 54 34 95t86 41l262 -1l-8 -43q-25 -128 -25 -158q0 -20 13.5 -33.5t29.5 -13.5h20q11 0 20 9l328 328q8 8 8 21t-8 21l-328 329q-9 9 -20 9h-20q-16 0 -29.5 -13.5t-13.5 -33.5q0 -31 44 -202h-316q-141 0 -218 -86t-77 -235v-674q0 -140 87 -217t235 -77h1404 q140 0 208.5 114.5t68.5 355.5l-109 3q-46 0 -78 -35z" /> +<glyph unicode="" horiz-adv-x="2115" d="M322 120h559q4 -15 4 -27q0 -130 -149 -270l-19 -15l21 -39l46 18q116 48 224 112q70 41 132.5 91t94.5 95l24 35h534q149 0 235.5 77.5t86.5 218.5v1106q0 141 -86.5 218t-235.5 77h-1471q-148 0 -235 -77.5t-87 -217.5v-1106q0 -141 86.5 -218.5t235.5 -77.5z M1897 1478v-1019q0 -51 -41.5 -85.5t-95.5 -34.5h-1405q-54 0 -95 34t-41 86v1019q0 52 41 86t95 34h1405q54 0 95.5 -34.5t41.5 -85.5z" /> +<glyph unicode="" horiz-adv-x="2048" d="M1502 665h256q68 0 116.5 -49t48.5 -117t-48.5 -117t-116.5 -49h-325l-70 -334q-11 -58 -57 -95.5t-105 -37.5q-6 0 -34 5q-67 14 -104.5 71.5t-23.5 123.5l55 267h-368l-71 -332q-11 -57 -56.5 -94t-104.5 -37q-18 0 -35 3q-67 14 -104.5 71.5t-24.5 124.5l55 264h-219 q-68 0 -117 49t-49 117t49 117t117 49h288l111 533h-275q-68 0 -116.5 49.5t-48.5 117.5t48.5 116.5t116.5 48.5h345l58 280q12 57 58 94.5t104 37.5q12 0 34 -5q68 -13 105 -69.5t24 -125.5l-44 -212h369l58 278q12 56 58 93t104 37q19 0 35 -3q66 -12 104 -70.5t25 -125.5 l-44 -209h199q68 0 117 -48.5t49 -116.5t-49 -117.5t-117 -49.5h-269zM794 665h369l111 533h-369z" /> +<glyph unicode="" horiz-adv-x="2045" d="M2044 -115v180h-1864v1865h-180v-2045h2044zM1691 1182l-105 111q7 14 7 36q0 37 -26.5 63.5t-63.5 26.5t-63.5 -26.5t-26.5 -63.5v-9l-156 -88q-25 18 -55 18q-24 0 -44 -11l-169 128q3 11 3 22q0 37 -26.5 63.5t-63.5 26.5t-64 -26.5t-27 -63.5q0 -3 2 -17l-164 -112 q-23 14 -48 14q-42 0 -69 -32l-221 67l-21 -69l221 -67q5 -33 31 -56t59 -23q37 0 63.5 26t26.5 64q0 13 -1 17l164 111q25 -14 48 -14q22 0 44 12l169 -129q-3 -9 -3 -21q0 -37 26.5 -63.5t63.5 -26.5q38 0 64.5 26.5t26.5 63.5v5q-1 2 -1 4l157 88q23 -19 54 -19 q16 0 31 6l114 -122l-133 -185q-3 0 -6 1h-6q-37 0 -63 -26l-149 47q-5 32 -30 53.5t-59 21.5q-37 0 -63.5 -26.5t-26.5 -63.5q0 -12 3 -21l-181 -160q-15 6 -32 6q-28 0 -54 -18l-157 76q-2 36 -28 61.5t-62 25.5q-37 0 -63.5 -26.5t-26.5 -64.5q0 -28 16 -51l-264 -507 l75 -39l263 507q29 0 54 18l157 -76q2 -36 28 -61t62 -25q38 0 64 26.5t26 63.5q0 11 -3 21l181 159q20 -6 32 -6q35 0 64 26l148 -47q6 -32 31 -53.5t58 -21.5q37 0 63.5 26.5t26.5 63.5q0 23 -9 41l123 172l71 -75l52 50l-80 85l88 124l-68 49zM902 1359q-13 0 -21.5 8.5 t-8.5 21.5q0 12 8.5 21t21.5 9q12 0 21 -9t9 -21q0 -13 -9 -21.5t-21 -8.5zM1503 1298q-12 0 -21 9t-9 22q0 12 9 21t21 9q13 0 21.5 -9t8.5 -21q0 -13 -8.5 -22t-21.5 -9zM580 1163q-9 9 -9 21q0 13 8.5 21.5t21.5 8.5t21.5 -8.5t8.5 -21.5q0 -12 -8.5 -21t-21.5 -9t-21 9z M1202 1130q-12 0 -21 9t-9 21q0 13 9 21.5t21 8.5q13 0 22 -8.5t9 -21.5q0 -12 -9 -21t-22 -9zM1202 914q-12 0 -21 8.5t-9 21.5q0 12 9 21t21 9q13 0 22 -9t9 -21q0 -13 -9 -21.5t-22 -8.5zM1503 817q-12 0 -21 9t-9 21q0 13 9 21.5t21 8.5q13 0 21.5 -8.5t8.5 -21.5 q0 -12 -8.5 -21t-21.5 -9zM580 802q-9 9 -9 21q0 13 8.5 21.5t21.5 8.5t21.5 -8.5t8.5 -21.5q0 -12 -8.5 -21t-21.5 -9t-21 9zM902 649q-13 0 -21.5 8.5t-8.5 21.5t8.5 21.5t21.5 8.5q12 0 21 -8.5t9 -21.5t-9 -21.5t-21 -8.5z" /> +<glyph unicode="" d="M0 1832h666l326 -332v-871h-992v1203zM592 1690h-449v-918h706v680h-257v238zM241 1237h504v55h-504v-55zM2045 966h-962v241h962v-241zM241 1079h504v55h-504v-55zM241 920h504v56h-504v-56zM2045 633h-962v241h962v-241zM2045 300h-962v240h962v-240zM2045 -33h-962 v240h962v-240z" /> <glyph unicode="" d="M2045 1018q0 160 -62 308t-171 257t-257 171t-308 62q-223 0 -411 -113.5t-291 -306.5q-58 -24 -96 -74q-37 -50 -52 -128t-8 -147q6 -77 26.5 -141t72.5 -133q16 -49 40 -100q-9 -31 -27 -61t-35 -39q-40 -22 -210 -85q-60 -23 -85 -32.5t-56 -24.5t-38.5 -20t-20.5 -21 t-16.5 -26t-13.5 -36q-7 -19 -11 -59q-6 -52 -12 -164l-3 -47q8 -8 21 -15q17 -9 62 -22q49 -16 115 -26q168 -28 473 -28q153 0 276 9q210 14 307 44q46 12 65 23q17 11 23 16q-2 42 -4.5 78.5t-4 60.5t-1.5 27q197 21 361 131q164 111 258 286.5t94 375.5zM1251 420l-2 2 q-2 2 -3 2q-10 7 -160 64q-172 63 -209 85q-27 15 -46 64q-19 47 -18.5 69t26.5 49q69 77 95 173q35 130 13 262q-28 162 -160 210q85 102 205 159.5t255 57.5q161 0 300 -80q139 -81 218.5 -219.5t79.5 -299.5q0 -162 -79 -298q-80 -139 -217 -219t-298 -81zM1146 1417 v-398q0 -43 35 -75l196 -195l140 141l-170 171v356h-201z" /> -<glyph unicode="" d="M687 1827v-222h-639v222h639zM1471 1827v-222h-673v222h673zM1993 1827v-222h-411v222h411zM687 1494v-222h-639v222h639zM1471 1494v-222h-673v222h673zM1993 1494v-222h-411v222h411zM715 1188v-277h-694v277h694zM1498 1188v-277h-727v277h727zM2021 1188v-277h-467 v277h467zM76 1133v-167h583v167h-583zM826 1133v-167h617v167h-617zM1609 1133v-167h356v167h-356zM687 827v-222h-639v222h639zM1471 827v-222h-673v222h673zM1993 827v-222h-411v222h411zM715 522v-278h-694v278h694zM1498 522v-278h-727v278h727zM2021 522v-278h-467v278 h467zM76 466v-166h583v166h-583zM826 466v-166h617v166h-617zM1609 466v-166h356v166h-356zM687 161v-223h-639v223h639zM1471 161v-223h-673v223h673zM1993 161v-223h-411v223h411z" /> -<glyph unicode="" d="M1985 628v1147h-252v-1147h252zM1368 628h-657v574q232 -15 397 -180v470h260v-864zM1680 628v695h-260v-695h260zM634 589q0 -9 5 -19l307 -530q-132 -65 -273 -65q-125 0 -239 48.5t-196.5 131t-131 196t-48.5 238.5q0 161 76.5 298.5t209 221t290.5 93.5v-613z M1197 705q-34 152 -145 263.5t-264 145.5v-409h409zM1285 551q-9 -146 -81.5 -270.5t-191.5 -202.5l-273 473h546z" /> -<glyph unicode="" d="M1132 1695q-3 39 -31.5 66t-67.5 27t-67.5 -27t-31.5 -66h-211q-31 0 -55 -20t-30 -50h-228q-27 0 -46 -19t-19 -46v-1554q0 -26 19 -45.5t46 -19.5h1247q26 0 45 19.5t19 45.5v1554q0 27 -19 46t-45 19h-229q-6 30 -30 50t-55 20h-211zM637 1423h792v47h164v-1374h-1120 v1374h164v-47zM646 1143q-15 15 -1 31q15 16 30 1l28 -27l104 132q14 16 31 3q7 -5 8 -14t-5 -16l-134 -169l-17 17zM561 1132q0 56 39.5 95t94.5 39q9 0 15.5 -6.5t6.5 -15.5t-6.5 -15t-15.5 -6q-37 0 -64 -26.5t-27 -64.5t27 -64.5t64 -26.5t64 26.5t27 64.5q0 9 6.5 15 t15.5 6q8 0 14.5 -6t6.5 -15q0 -56 -39.5 -95.5t-94.5 -39.5t-94.5 39.5t-39.5 95.5zM953 1098q-17 0 -30 13t-13 31t13 30.5t30 12.5h475q17 0 30 -12.5t13 -30.5t-13 -31t-30 -13h-475zM646 767q-16 15 -1 30q15 16 30 1l28 -26l104 131q13 16 31 4q16 -14 3 -31 l-119 -150l-15 -18l-17 16zM561 754q0 56 39.5 95t94.5 39q9 0 15.5 -6t6.5 -15t-6.5 -15.5t-15.5 -6.5q-37 0 -64 -26.5t-27 -64.5t27 -64.5t64 -26.5t64 26.5t27 64.5q0 9 6.5 15.5t15.5 6.5q8 0 14.5 -6.5t6.5 -15.5q0 -56 -39.5 -95t-94.5 -39t-94.5 39t-39.5 95z M953 721q-17 0 -30 13t-13 31t13 30.5t30 12.5h475q17 0 30 -12.5t13 -30.5t-13 -31t-30 -13h-475zM829 399q0 -55 -39.5 -94.5t-94.5 -39.5t-94.5 39.5t-39.5 94.5t39.5 94.5t94.5 39.5t94.5 -39.5t39.5 -94.5zM604 399q0 -37 27 -64t64 -27t64 27t27 64t-27 64t-64 27 t-64 -27t-27 -64zM953 368q-17 0 -30 13t-13 31q0 17 13 30t30 13h475q17 0 30 -13t13 -30q0 -18 -13 -31t-30 -13h-475z" /> -<glyph unicode="" d="M1930 1560v-990h-340v-287h-210v-277q0 -42 -44 -42h-1169q-43 0 -43 42v836q0 21 13.5 35.5t34.5 14.5h138v284h278v275h284v286h894l166 -177h-2zM1809 1430h-175v186h-641v-165h417l178 -178h2v-570h219v727zM1468 1152h-177v177h-582v-153h352l177 -177h-3v-275h101 q19 0 31.5 -16.5t12.5 -38.5v-241h88v724zM432 893l130 -1q18 0 28 -10l8 -10l72 -148h444v153h-174v177h-508v-161z" /> -<glyph unicode="" d="M1698 1472v-1517h-1324v1770h1070zM1529 124v1179h-254v253h-732v-1432h986z" /> -<glyph unicode="" d="M737 1754q83 0 143.5 -62t60.5 -146v-240h895q86 0 145 -61t59 -148v-927q0 -87 -59 -147t-145 -60h-1628q-84 0 -143.5 60.5t-59.5 146.5v1376q0 84 60 146t143 62h529zM1404 1642q93 0 148.5 -39.5t55.5 -122.5h-482v162h278zM302 1091q-35 0 -60.5 -26.5t-25.5 -64.5 v-761q0 -37 25.5 -62.5t60.5 -25.5h1441q38 0 64 25.5t26 62.5v761q0 38 -26 64.5t-64 26.5h-1441zM1621 893q13 0 20 -3.5t9.5 -12.5t2.5 -16.5t-1 -23t-1 -24.5v-378q0 -9 1 -25t1 -23.5t-2.5 -16.5t-9.5 -12.5t-20 -3.5h-1187q-12 0 -19.5 3.5t-10 12.5t-2.5 16.5t1 23.5 t1 25v378q0 9 -1 24.5t-1 23t2.5 16.5t10 12.5t19.5 3.5h1187z" /> -<glyph unicode="" d="M1967 -71h-1879v1880h1311l568 -569v-1311zM1595 301v567h-567v568h-568v-1135h1135z" /> -<glyph unicode="" d="M1977 1784h-1904v-228h1904v228zM1310 921l-286 492l-281 -492h567zM743 782l281 -492l286 492h-567zM73 -82h1904v229h-1904v-229z" /> -<glyph unicode="" d="M1977 1783h-1904v-228h1904v228zM1309 1413l-284 -491l-284 491h568zM741 288l284 492l284 -492h-568zM73 -81h1904v228h-1904v-228z" /> -<glyph unicode="" d="M1963 -71v1880h-1880v-1880h1880zM1626 1472v-1206h-1206v1206h1206zM1343 549v639h-639v-639h639z" /> -<glyph unicode="" d="M1926 1497q26 25 26 61t-26 61l-147 147q-24 25 -59.5 25t-60.5 -25l-127 -126q-12 -12 -12 -27.5t12 -27.5l213 -213q12 -12 28 -12t27 12zM1700 1282q12 4 12 18t-12 25l-215 214q-11 11 -26 11t-26 -11l-701 -701q-12 -10 -12 -26t12 -27l213 -215q11 -11 27.5 -9 t26.5 20zM880 482l6 1q7 3 8 7q8 8 8 20t-8 20l-203 204q-21 19 -40 0l-6 -9l-2 -5l-105 -305l-1 -6q-3 -14 7 -26q10 -10 27 -8l6 2zM1088 1471q39 0 67.5 28t28.5 68q0 39 -28.5 67.5t-67.5 28.5h-896q-41 0 -69.5 -28.5t-28.5 -67.5v-1537q0 -40 28.5 -68t69.5 -28h1536 q3 0 20.5 6.5t39.5 21t29 29.5q7 23 7 39v896q0 40 -28 68t-68 28t-68.5 -28t-28.5 -68v-800h-1344v1345h801z" /> -<glyph unicode="" horiz-adv-x="2098" d="M2098 908q0 171 -53 331t-150 289t-226 226t-289 150t-331 53t-331 -53t-289 -150t-226 -226t-150 -289t-53 -331q0 -214 83 -408t223.5 -334.5t335 -223.5t407.5 -83t407.5 83t335 223.5t223.5 334.5t83 408zM1920 830l-1 -1q2 16 2 23v6l-2 -10v-9v-8l-4 4v9l2 8l-2 4 l-2 -6h-1l-3 -11v3l3 14l1 6l2 8l2 -6l2 6v7l2 4v12v-4v-12q2 44 0 86v3v2q2 -40 2 -60q0 -40 -3 -78zM1909 748v-2v5l-1 -4h-4l-6 -5l-4 -6l-2 -12l2 -10l4 8l8 12l-2 -8h-2l-2 -10l-4 -8v-13l-2 -6v-3v-5l-1 -3l1 3l-4 -8v2l2 6l-2 6l2 14l-4 -6q-4 -22 -8 -35l-3 -4v2 l3 14l2 13l-2 -2v8l4 10q2 13 10 41l4 13l4 -2l4 2l3 4l2 3q0 -3 -2 -9v-3v-2zM477 1569q122 105 274 161v-1l-5 -4l-2 -2l-4 -4l8 6h5l8 -2v-4h6l4 -4q0 -6 4 -10v-11h-8l-8 4q-6 3 -8 5l-2 -2l6 -3v-6h8l-2 -4l-10 -2l10 -2q2 -1 8 -2t8 -2l6 -6l-4 -4h-20h-4l-9 4l5 -4 q-2 -1 -6 -2t-7.5 -2t-7.5 -2l-14 -5l-3 -6l-4 2h-8l-4 -8l-10 -6l-4 2l-5 -10l-8 4l-2 -6l-6 -2l-4 2l-4 2l-4 -7l-5 -4v6h-4v-4l-4 -8l-8 -8l-8 2l6 12l-8 -6l-9 -2l4 -4t5 -4q-6 -1 -16.5 -2t-16.5 -2l-9 4v2h-6l-14 -6l-10 -2l-2 2h-3l-4 2l-8 -4l-8 -4l-8 -3l-9 -4 l-8 -8q-3 -2 -10 -7t-10 -7v2zM1494 1660q190 -112 303 -300v1v-2l-4 4l4 -11l2 -4q1 -2 2 -5.5t2 -4.5l5 -8v-4q0 -1 1 -3.5t1 -3.5v-4l2 -4l4 -6v-2h2l4 -4l10 -12l4 -7l7 -14l2 -8v-5v-8l-2 -4l-3 6l-4 9l-8 12v4l-8 6h-2v-2v-2l-2 2h-2v2l-2 6v2l-2 1l-3 8v2h-2l-4 2 l-2 -8v-7l-2 -6l-2 8l-4 3l-2 10l-4 4v-8v-11l2 -10l-2 -2v-8l6 -8h4l4 -5l6 -2v11l5 -2l2 2l4 -6l-4 -7l2 -8v-10l2 -9l8 -4l8 -12q1 -2 6 -8.5t7 -9.5l4 -9v-6l4 -6l4 -2q1 -4 3 -11.5t3 -11.5l-2 -4q1 -6 2 -18.5t2 -18.5v-19l-4 -18l-4 -15l-6 -6l-2 -8l-4 4l-2 -10 l-7 -12l-6 -5v-16l-4 -2l-2 10v6h-10l-2 -4l-7 -16l-4 -15v-10q3 -5 8.5 -14.5t8.5 -14.5l8 -6l6 -10l6 -25v-25l-6 -12l-8 -15l-6 -16l-10 -17l-3 7l2 12l-6 6h-8l-4 6l-4 13l-8 4l-7 -2v12l-8 -2v-16q-1 -6 -3 -19.5t-3 -17.5v-9l6 2l4 -12l2 -12l6 -5h5l4 -6h2l4 -8l4 -8 v-10l-2 -7v-4v-8l2 -2l2 -12v-3l-6 -4l-8 7l-9 8v8l-4 8v11h-2l2 12l-2 4l-4 4l-2 8q-2 1 -3.5 3l-3 4t-3.5 4l-2 -9l-2 7l2 10l2 16l-2 9l2 12l-4 8v15l-4 6l-3 16l-4 17l-6 12l-6 -10l-10 -14h-6l-7 2l2 20l-4 13l-10 16v6h-6l-10 11l-3 6l-2 8l-4 6l-6 8l-10 -2l2 -6 l-2 -10l-6 2l-2 -4h-3h-4v-6l-8 -2l-14 -5l2 -10l-6 -10l-15 -13l-12 -20l-8 -12l-15 -7v-6l-6 -4l-12 -8l-5 -2l-4 -11l4 -20l2 -11l-6 -16v-25l-8 -2l-6 -12l4 -4l-12 -6l-2 -9l-5 -6l-14 12q-4 11 -12 35l-6 5l-7 14l-4 17l-2 10l-14 20q-9 35 -13 46l-2 18l-4 13l-20 -9 h-9l-20 15l6 6l-2 4l-17 12l-10 1l-6 10l-12 10l-25 -4l-23 -2l-20 -4q-6 1 -21 3t-21 3l-16 4l-6 17l-7 2l-12 -2l-14 -7l-17 3l-16 12l-13 4l-10 14l-10 19l-8 -4l-7 6l-6 -6h-8l4 -6l-2 -4q2 -4 5 -11.5t5 -11.5l7 -4l2 -6l10 -5v-6l-2 -4l2 -4l4 -4l2 -4l2 -4l-2 10l4 8 l4 2l4 -4v-8l-4 -8l2 -5h2v-4l11 2h22l29 29l4 6l2 -2l-2 -6l-2 -2l2 -12l6 -10l7 -5l10 -2l8 -2q8 -8 11 -14l6 -2v-4l-3 -6t-4 -7l-6 -6l-6 -10h-6l-2 -4l-2 -8l2 -9l-2 -2h-6l-9 -6l-2 -8l-4 -4h-10l-6 -4v-5l-8 -4l2 4l-9 -6h-8l-12 -4l-2 -6v-6l-15 -6l-24 -7l-13 -10 h-6h-4l-10 -6l-9 -2h-14h-4l-4 -4l-4 -2l-2 -4h-7l-6 -1l-10 1l-4 8v8l-2 4l-2 12l-3 5h3l-2 6l2 2v6l-2 6l-3 4l-2 5l-8 6l-8 18l-4 11l-10 10l-5 2l-10 14l-2 9l2 8l-8 16l-6 6l-7 4l-4 7l2 4l-4 8l-4 4l-4 11q-2 4 -14 22h-7l1 8l2 5l2 6v2l-4 -6l-3 -9l-4 -8h-6 q-2 2 -5 6t-5 6l-8 21l-2 -2l4 -16l8 -15l8 -23l4 -8l4 -8l11 -17l-4 -2v-10l16 -14l2 -4l4 -13l-4 -2l2 -16l4 -17q2 -2 6.5 -5t6.5 -5l8 -17l4 -14l8 -8l19 -15q4 -4 11 -12t11 -12l7 -3l4 -4v-6l-7 -2q1 -1 5 -4t6 -4l2 -6l6 -6h8l13 4l16 2l13 4h8l6 2h14l7 2l8 2l8 6h6 v-4v-8v-8l-4 -5l-4 -16q-14 -29 -17 -35l-14 -21q-9 -11 -31 -35l-16 -12l-21 -14l-14 -9l-17 -16l-4 -8l-4 -4l-10 -5l-4 -6h-5l-2 -10l-4 -6l-2 -9l-6 -4l-6 -16l2 -8l10 -6v-3l-4 -8v-4v-6l6 -8l6 -13l5 -4l2 -6v-12l2 -11l2 -20l2 -5l-4 -8l-9 -4l-8 -6q-6 -1 -16 -4 l-11 -4l-16 -11l-6 -2l-9 -8l-6 -2v-8l7 -8l4 -5v-4h2v-10v-6l4 -2l-2 -4l-6 -4q-2 -1 -7 -2t-9.5 -2.5t-8.5 -2.5l-6 -4l2 -4h4v-6l-2 -8v-8l-2 -3q-2 -1 -3.5 -2l-3 -2t-2.5 -2l-4 -4l-2 -4l-6 -6q-2 -1 -8.5 -6t-9.5 -7l-7 -4l-12 -2h-6v-2l-8 2l-5 -2l-14 4h-6l-4 2 l-9 -2h-10l-6 -2h-4l-6 4h-4l-4 3v-1l-1 1l-2 6l-6 6l2 2l-4 8q-12 10 -33 33l-12 10l-8 7l-5 10l-6 8l-10 17l-4 14l-4 6l-6 6l-9 9q-3 4 -8 11t-8 11l-13 13l-6 18v10l2 9v6l2 10l5 4l6 7l4 4v10l-2 8l-4 6q-2 3 -4.5 8t-4.5 7v2l3 4q-8 21 -9 23l-8 10v2q-2 4 -6 16 l-13 13l-14 16l-10 13l-8 16v4l2 4q4 10 4 19l-2 2l4 16l2 11l-6 12l-5 4l-2 8l-4 1v4l-14 -2l-4 2l-4 -2l-9 2l-8 10l-4 12l-8 11q-3 1 -9.5 2t-9.5 2h-10h-10l-17 -4l-6 -3l-10 -2l-9 7l-4 2l-8 4l-6 2l-12 2q-3 -1 -7.5 -2t-7.5 -2h-2h-2l-10 10l-8 13l-7 12l-6 12l-2 4 l-6 7l-4 10l-2 6v12l-4 9l-2 8l-1 2l-2 4v8v4l-2 3l-4 8l-2 2l-2 6v2l-2 4v15l2 6l-2 14l-2 8l4 2l2 3l2 6v8l4 6l4 13q1 8 1 22l2 6v7v10q1 2 2 6t2 6l4 2l4 6v5l6 8l6 8l4 2l3 8l2 7l6 8l6 2l10 12l6 4l7 -2l10 8l6 1l10 8v18l7 9l4 6l8 8l10 4l8 4q3 3 8.5 9.5t8.5 9.5 l6 -2l4 -7l10 -2l7 -6l4 -2l12 4h11l8 4l12 2q30 -5 37 -6l4 -4l11 4l12 -2l4 -4h8l13 4l8 -4l-2 -6l12 4v-2l-8 -6l-2 -6l4 -4l-6 -11l-10 -8v-8l6 -2l2 -7l4 -4l14 -8h6l9 -4l16 -10l4 -13l9 -4l16 -8l12 -10l7 2l8 6v12l6 6l10 5h9l18 -5l4 -6h4l4 -2l13 -4l2 -6l18 -2 q3 -1 14 -5.5t17 -7.5l11 3l6 4h12l9 -2l4 -7l4 4l10 -4l10 -2l7 4l4 3l4 6l4 10l2 4l6 12l8 9v10l4 6l-4 6l6 5h-8l-10 4l-10 -7h-19l-10 7l-13 2l-4 -6h-10l-12 8l-13 2l-6 14l-8 8l8 9l-6 8l16 12l19 -2l8 9l23 -3l16 7l15 2l22 -2l21 -11l17 -6l16 2l12 -2l15 6l2 6 l-11 17l-8 6l-8 2l-4 4l-15 11l-14 6l-8 8l10 2l12 10l-6 5l17 4v2l-11 -2h-10l-8 -2h-12l-11 -4v-7l6 -4h11l-4 -4l-13 -2l-18 -6l-4 2l4 6l-11 4l1 2l12 4l-4 4l-17 3v6h-12l-6 -6l-11 -9v-4l-6 -2l-4 2l-6 -16l-8 -4l-6 -9l2 -8v-6l10 -6l-2 -4h-15q-3 -2 -9 -4.5 t-9 -4.5l-2 5v2l-8 2l-5 2l-16 -2l6 -7l-6 -2h-6l-4 7l-2 -2v-7l4 -8l-6 -2l6 -8l6 -4l-2 -7l-11 5l1 -7h-7l2 -12h-8l-10 8l-2 11v10q-1 3 -4 8t-4 8v4v3l-5 6v6l3 10l2 4l-2 2l-1 4q-1 1 -6.5 4t-7.5 5l-6 6l-10 6l-5 12h2l-2 5l2 4l-6 4l-6 -6l-2 4l2 4h2l-8 4l-10 -4 l-2 -6l-2 -4l2 -7l8 -8l2 -12l10 -11l10 -2l1 -4l-3 -2q2 -1 5 -3.5t5.5 -4.5t4.5 -4l8 -8v-2l-4 -2l-4 8l-12 4l-7 -8l7 -6l-3 -5h-6l-10 -10h-6q0 2 2 4l4 8l4 2q-1 3 -2 7.5t-2 7.5h-2l-2 6l-6 4l-4 6l-7 2l-6 7l-8 10l-4 10l2 13l-6 2l-6 6h-5l-8 -4h-4l-12 -6l-17 8 h-16l-4 -8l-2 -7l-13 -6h-14l-2 -4l-13 -8l-8 -10v-7l-8 -4l-6 -8h-7l-12 -8q-8 2 -20 6l-7 -2l-6 -4h-4l-2 6v8l-8 4l-4 -2l-4 4h-3l5 9l2 8l-4 2v6l4 8l6 4l4 5l6 8l2 6v6l2 4l6 7v6l15 6l8 -6l12 -4l7 -5l8 -2l14 -4l8 6l13 23v17l-2 8l-10 8l4 10h12l12 -8l6 14l5 -8 l26 4l7 9q12 0 18 2l6 2l17 16l16 2l7 -2l4 2h8v-2l10 4v4l4 5v8l6 12l3 2l4 2h8l4 2l10 2l-4 -6l-8 -2v-4l4 -2l-4 -4l-2 2l-12 -8v-6l-1 -3l7 -4l-2 -4h10l8 2l8 -6l2 -4l7 2q3 1 14.5 3t16.5 3l10 -4v-4l10 -2l4 6l15 2l2 10l4 7l8 6l10 2l3 -10h8q1 3 4 8t4 8l-4 -2 l-4 4l2 6l14 2h11l10 -4h10l13 6l-7 11h-18l-17 -2h-16l-4 6l-7 4l5 10v9l8 6l12 6l25 10l6 2v4l-10 5l-15 -1l-12 -6l-2 -4q-20 -9 -39 -14l-13 -13l2 -8l5 -8l-13 -12l-10 -2l-12 -19l-9 -10l-12 2l-10 -7l-11 1l2 12v14l1 17l-1 8l-24 -10h-13l-8 8l4 12l13 25l12 6l25 4 l22 7q31 19 54 28q15 4 45 15l17 2l12 -2l15 6l14 -2h14l21 -10l-12 -2l6 -7l10 2l12 -8l21 -4l29 -14l6 -6v-7l-10 -6l-15 -2q-22 7 -37 10h-6l12 -8l-2 -16l11 -4l6 -3l2 5l-4 6l6 4l21 -8l8 2l-6 8l22 10h7l8 -4l6 8l-6 5l4 6l-2 12l23 -4l4 -6l-11 -2v-6l6 -3l13 2l2 5 q9 2 24.5 7.5t20.5 6.5h6l-8 -6h10l5 4h16l12 4l11 -6l8 8l-10 6l4 4l24 -2l11 -2l33 -10l4 6l-10 4l-2 2h-9v5l-8 8l-2 4l10 10v10l4 1h19l4 -5l-2 -8l6 -2l6 -6l5 -13l12 -6v-8l-10 -16h10l2 4l8 4v6l4 6l-8 6v7h-8h-2v10l-13 8l9 8l-5 7h3l6 -3l2 -10h8l-8 6l10 5l14 2 l15 -3l-12 7l-7 10l9 4l16 2l11 4l-7 2l2 7l5 2l6 6l12 4v2l12 4h5l4 8l8 2l-4 4v3l4 6h10l-2 -4l10 2l5 -4v4l10 4h12l6 -2l5 -4v-4l-1 -7v-4h5h8v4l10 -4l-2 2l2 4l10 4l5 -4l10 5l-10 6l6 2l2 2l8 -2l8 -4l4 -4l9 -3h6l-11 9h5l-2 6l4 2l2 4l2 2l-11 6l-6 4v1zM619 1171 l4 6l14 -4h21l-8 -10v-4l-4 -6l-7 6l-4 2zM1105 1546l-2 8l14 7l-8 2l15 8l-1 4q11 4 35 12l21 2l10 4l11 2l6 -4l-2 -4q-4 -1 -12.5 -3.5t-15 -4.5t-11.5 -4l-21 -6l-8 -9l-12 -10l4 -8l16 -8l-4 -2h-25l-2 4l-14 2l-2 6zM584 1449h10l8 -2l-8 -8l-14 -8l8 -4l-2 -15l4 -4 l-4 -14v-5l10 -4l-6 -8l-6 -2v-8l-11 -4l-12 4h-16l-3 2l-8 -4l-6 4l-8 -2l-2 4l20 8h7l-11 6v6l10 1v8l5 8l10 -4l6 6v8l-8 4v3l6 4v4l-8 -4l8 10v6l12 10l11 7l4 -2l10 -2zM551 1556h8l-2 9l8 -2l4 2l-4 6l10 4l5 -2l-2 -10l12 2l2 -5l12 1l6 -3l-8 -6l-2 -6l-14 -4h-25 l-4 4zM501 1400l11 10l6 12q12 3 24 9l5 -2l2 -9l-7 -6l-4 -8l-12 -8l-17 -4zM1097 396l-2 -6l-2 -4l-3 8l-4 -4l2 -8l-2 -6l-4 -2v-9q-23 -51 -27 -63l-8 -13l-12 -2l-12 -4q-5 2 -17 6l-4 6l-2 9l-4 8l-2 8l2 8l6 2v3l6 8l2 8l-2 6l-2 8l-2 9l6 6l2 8h8l7 2l6 2h6l8 6 l11 7l4 6l-2 6l6 -2l8 8v8l6 5l4 -5l4 -6l3 -8l2 -14zM1556 730l-2 -16l-6 -6l-12 -5l-5 11l-2 22l6 25l9 -8l6 -10zM1785 602l-4 9l-9 6l-2 8l-4 8l-2 9l-4 18l-8 10l-2 11l-4 8l-7 6l-4 12l-8 5l-10 14v8l6 2l16 2l7 -12l8 -8l4 -4l8 -11l9 2l6 -8l4 -10l6 -4l-4 -13l4 -4 h2v-10l2 -6l6 2l2 -8q-2 -7 -5 -20t-5 -19zM1847 603l-5 -9l-4 2l-6 -4l-6 -2h-6l-6 2l-4 -2l-3 -4h-12v6l-6 -2l8 15l10 4l3 -2h2v-4l12 4l2 6h10v-8l7 2zM1863 615l2 -6q-8 -8 -10 -9l-4 -2l4 7l2 2l2 6v-2zM1898 809l-2 -7l-4 -4v-6l-4 -8l-4 -15l2 -12l-2 -8l4 -9l-6 -6 v-8l-2 -12l-5 -13l-2 -14l-6 -23v4l-8 -10v8l-4 -2l-2 2l-10 -8v4l-3 -2l-6 -2l2 17l-2 2l-2 10v12l2 13l6 12l3 -2l6 6l2 14l4 3l8 8q1 3 5.5 14.5t6.5 16.5l5 12l4 13l2 14l2 2l2 -6v-6l4 -2zM1900 881l-4 -7l-2 -12l-6 -20l2 12l4 12l2 11l2 16zM1904 928l4 12v-6l-4 6 l-2 -6l-2 2v6v4l-2 1v-5l-4 5l-2 6l-2 14l2 -4q-1 6 -2 18.5t-2 18.5l2 2l4 -2v4v-4v-6l2 -10v-13l-2 -8v-12l2 -9h2l2 4l6 -4v-8l2 -2v-8zM1915 897l-2 -8v-10l-1 -5l-3 7q0 6 3 10v10l1 2v-10l2 18v-14zM1923 914h-6v-3l2 -8l-2 -6v10v7v6l-2 10l4 4l2 -4zM1148 1173l-4 4 l-2 -12h8l-4 -10l10 -4l-2 -13l2 -10l-2 -4l-22 -4l-19 2l-10 8l-13 2l-4 10v6l4 3l1 4l2 10l10 2l-4 4h-6l-5 10q-14 12 -22 23l2 8l-12 13l12 14l12 2l6 8l11 4l14 5l12 -4h11l2 -7l-2 -14l-11 2l-10 -2v-10l-12 2v-4l8 -3l6 -12l15 -4l2 -4l-2 -6v-4l4 -7v9l10 4l6 -9 l9 -8z" /> -<glyph unicode="" horiz-adv-x="1536" d="M771 348v177h177q16 0 28 11.5t12 28.5v82q0 17 -12 29t-28 12h-177v176q0 17 -11.5 29t-28.5 12h-82q-17 0 -29 -12t-12 -29v-176h-176q-17 0 -29 -12t-12 -29v-82q0 -17 12 -28.5t29 -11.5h176v-177q0 -16 12 -28.5t29 -12.5h82q16 0 28 12t12 29zM1408 1085h-416 q-40 0 -68 28t-28 68v416h-768v-1536h1280v1024zM1024 1213h376q-10 29 -22 41l-313 313q-12 12 -41 22v-376zM1468 1345q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48z" /> -<glyph unicode="" horiz-adv-x="1536" d="M735 1147q4 0 7 -3q7 -4 4 -13l-58 -148v-6h8h122q130 0 223 -92.5t93 -223.5v-41q0 -130 -93 -223t-223 -93h-446v145h446q70 0 120 50t50 121v41q0 71 -50 120.5t-120 49.5h-126l54 -138q4 -9 -4 -14q-4 -2 -7 -2t-7 2l-290 224q-5 3 -5 9t5 9l290 223q3 3 7 3z M1024 1213h376q-10 29 -22 41l-313 313q-12 12 -41 22v-376zM128 61h1280v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536zM992 1725q40 0 88 -20t76 -48l312 -312q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896z " /> -<glyph unicode="" horiz-adv-x="1536" d="M1024 1213h376q-10 29 -22 41l-313 313q-12 12 -41 22v-376zM128 61h1280v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536zM992 1725q40 0 88 -20t76 -48l312 -312q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28 h896zM896 768q14 0 27 -11l54 -54q11 -11 11 -27q0 -15 -11 -26l-287 -287l-54 -54q-11 -11 -27 -11t-27 11l-54 54l-143 143q-11 11 -11 27t11 27l54 54q11 11 27 11q14 0 27 -11l116 -117l260 260q11 11 27 11z" /> -<glyph unicode="" horiz-adv-x="1536" d="M1440 1725q40 0 68 -28t28 -68v-1600q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h465q10 0 16.5 -6.5t6.5 -16.5v-55q0 -10 7 -16.5t16 -6.5h322q9 0 16 6.5t7 16.5v55q0 10 6.5 16.5t16.5 6.5h465zM1408 61v1400h-1280v-1400h1280z M1029 811q57 -10 98 -49t62 -99t30 -123t9 -135q0 -79 -45 -134.5t-108 -55.5h-614q-63 0 -108 55.5t-45 134.5q0 72 9 135t30 123t62 99t98 49q104 -117 261 -117t261 117zM1044 1044q0 -115 -80.5 -196t-195.5 -81t-195.5 81t-80.5 196q0 114 80.5 195t195.5 81t195.5 -81 t80.5 -195z" /> +<glyph unicode="" d="M687 1605h-639v222h639v-222zM1471 1605h-673v222h673v-222zM1993 1605h-411v222h411v-222zM687 1272h-639v222h639v-222zM1471 1272h-673v222h673v-222zM1993 1272h-411v222h411v-222zM715 911h-694v277h694v-277zM1498 911h-727v277h727v-277zM2021 911h-467v277h467 v-277zM76 966h583v167h-583v-167zM826 966h617v167h-617v-167zM1609 966h356v167h-356v-167zM687 605h-639v222h639v-222zM1471 605h-673v222h673v-222zM1993 605h-411v222h411v-222zM715 244h-694v278h694v-278zM1498 244h-727v278h727v-278zM2021 244h-467v278h467v-278z M76 300h583v166h-583v-166zM826 300h617v166h-617v-166zM1609 300h356v166h-356v-166zM687 -62h-639v223h639v-223zM1471 -62h-673v223h673v-223zM1993 -62h-411v223h411v-223z" /> +<glyph unicode="" d="M1985 1775h-252v-1147h252v1147zM711 628v574q232 -15 397 -180v470h260v-864h-657zM1680 1323h-260v-695h260v695zM634 1202v-613q0 -9 5 -19l307 -530q-132 -65 -273 -65q-125 0 -239 48.5t-196.5 131t-131 196t-48.5 238.5q0 161 76.5 298.5t209 221t290.5 93.5z M788 705h409q-34 152 -145 263.5t-264 145.5v-409zM739 551h546q-9 -146 -81.5 -270.5t-191.5 -202.5z" /> +<glyph unicode="" d="M1343 1695h-211q-3 39 -31.5 66t-67.5 27t-67.5 -27t-31.5 -66h-211q-31 0 -55 -20t-30 -50h-228q-27 0 -46 -19t-19 -46v-1554q0 -26 19 -45.5t46 -19.5h1247q26 0 45 19.5t19 45.5v1554q0 27 -19 46t-45 19h-229q-6 30 -30 50t-55 20zM1429 1423v47h164v-1374h-1120 v1374h164v-47h792zM690 1101l-44 42q-15 15 -1 31q15 16 30 1l28 -27l104 132q14 16 31 3q7 -5 8 -14t-5 -16l-134 -169zM561 1132q0 56 39.5 95t94.5 39q9 0 15.5 -6.5t6.5 -15.5t-6.5 -15t-15.5 -6q-37 0 -64 -26.5t-27 -64.5t27 -64.5t64 -26.5t64 26.5t27 64.5 q0 9 6.5 15t15.5 6q8 0 14.5 -6t6.5 -15q0 -56 -39.5 -95.5t-94.5 -39.5t-94.5 39.5t-39.5 95.5zM1428 1098h-475q-17 0 -30 13t-13 31t13 30.5t30 12.5h475q17 0 30 -12.5t13 -30.5t-13 -31t-30 -13zM690 724l-44 43q-16 15 -1 30q15 16 30 1l28 -26l104 131q13 16 31 4 q16 -14 3 -31l-119 -150l-15 -18zM561 754q0 56 39.5 95t94.5 39q9 0 15.5 -6t6.5 -15t-6.5 -15.5t-15.5 -6.5q-37 0 -64 -26.5t-27 -64.5t27 -64.5t64 -26.5t64 26.5t27 64.5q0 9 6.5 15.5t15.5 6.5q8 0 14.5 -6.5t6.5 -15.5q0 -56 -39.5 -95t-94.5 -39t-94.5 39t-39.5 95z M1428 721h-475q-17 0 -30 13t-13 31t13 30.5t30 12.5h475q17 0 30 -12.5t13 -30.5t-13 -31t-30 -13zM829 399q0 -55 -39.5 -94.5t-94.5 -39.5t-94.5 39.5t-39.5 94.5t39.5 94.5t94.5 39.5t94.5 -39.5t39.5 -94.5zM604 399q0 -37 27 -64t64 -27t64 27t27 64t-27 64t-64 27 t-64 -27t-27 -64zM1428 368h-475q-17 0 -30 13t-13 31q0 17 13 30t30 13h475q17 0 30 -13t13 -30q0 -18 -13 -31t-30 -13z" /> +<glyph unicode="" d="M1930 570h-340v-287h-210v-277q0 -42 -44 -42h-1169q-43 0 -43 42v836q0 21 13.5 35.5t34.5 14.5h138v284h278v275h284v286h894l166 -177h-2v-990zM1634 1430v186h-641v-165h417l178 -178h2v-570h219v727h-175zM1291 1152v177h-582v-153h352l177 -177h-3v-275h101 q19 0 31.5 -16.5t12.5 -38.5v-241h88v724h-177zM432 893l130 -1q18 0 28 -10l8 -10l72 -148h444v153h-174v177h-508v-161z" /> +<glyph unicode="" d="M1698 -45h-1324v1770h1070l254 -253v-1517zM1529 1303h-254v253h-732v-1432h986v1179z" /> +<glyph unicode="" d="M208 1754h529q83 0 143.5 -62t60.5 -146v-240h895q86 0 145 -61t59 -148v-927q0 -87 -59 -147t-145 -60h-1628q-84 0 -143.5 60.5t-59.5 146.5v1376q0 84 60 146t143 62zM1126 1642h278q93 0 148.5 -39.5t55.5 -122.5h-482v162zM1743 1091h-1441q-35 0 -60.5 -26.5 t-25.5 -64.5v-761q0 -37 25.5 -62.5t60.5 -25.5h1441q38 0 64 25.5t26 62.5v761q0 38 -26 64.5t-64 26.5zM434 893h1187q13 0 20 -3.5t9.5 -12.5t2.5 -16.5t-1 -23t-1 -24.5v-378q0 -9 1 -25t1 -23.5t-2.5 -16.5t-9.5 -12.5t-20 -3.5h-1187q-12 0 -19.5 3.5t-10 12.5 t-2.5 16.5t1 23.5t1 25v378q0 9 -1 24.5t-1 23t2.5 16.5t10 12.5t19.5 3.5z" /> +<glyph unicode="" d="M88 -71v1880h1311l568 -569v-1311h-1879zM1595 868h-567v568h-568v-1135h1135v567z" /> +<glyph unicode="" d="M73 1784v-228h1904v228h-1904zM1024 1413l-281 -492h567zM1024 290l286 492h-567zM1977 -82v229h-1904v-229h1904z" /> +<glyph unicode="" d="M73 1783v-228h1904v228h-1904zM1025 922l-284 491h568zM1025 780l284 -492h-568zM1977 -81v228h-1904v-228h1904z" /> +<glyph unicode="" d="M1963 1809h-1880v-1880h1880v1880zM1626 266h-1206v1206h1206v-1206zM1343 1188h-639v-639h639v639z" /> +<glyph unicode="" d="M1800 1372l126 125q26 25 26 61t-26 61l-147 147q-24 25 -59.5 25t-60.5 -25l-127 -126q-12 -12 -12 -27.5t12 -27.5l213 -213q12 -12 28 -12t27 12zM287 1471h801q39 0 67.5 28t28.5 68q0 39 -28.5 67.5t-67.5 28.5h-896q-41 0 -69.5 -28.5t-28.5 -67.5v-1537 q0 -40 28.5 -68t69.5 -28h1536q3 0 20.5 6.5t39.5 21t29 29.5q7 23 7 39v896q0 40 -28 68t-68 28t-68.5 -28t-28.5 -68v-800h-1344v1345zM999 581l701 701q12 4 12 18t-12 25l-215 214q-11 11 -26 11t-26 -11l-701 -701q-12 -10 -12 -26t12 -27l213 -215q11 -11 27.5 -9 t26.5 20zM880 482l6 1q7 3 8 7q8 8 8 20t-8 20l-203 204q-21 19 -40 0l-6 -9l-2 -5l-105 -305l-1 -6q-3 -14 7 -26q10 -10 27 -8l6 2z" /> +<glyph unicode="" horiz-adv-x="2098" d="M1049 1957q171 0 331 -53t289 -150t226 -226t150 -289t53 -331q0 -214 -83 -408t-223.5 -334.5t-335 -223.5t-407.5 -83t-407.5 83t-335 223.5t-223.5 334.5t-83 408q0 171 53 331t150 289t226 226t289 150t331 53zM477 1567l20 14l8 8l9 4l8 3l16 8l4 -2h3l2 -2l10 2 l14 6h6v-2l9 -4q6 1 16.5 2t16.5 2q-2 1 -5 4l-4 4l9 2l8 6l-6 -12l8 -2l8 8l4 8v4h4v-6l5 4l4 7l8 -4l6 2l2 6l8 -4l5 10l4 -2l10 6l4 8h8l4 -2l3 6l14 5l21 6l-5 4l9 -4h24l4 4l-6 6q-2 1 -8 2t-8 2l-10 2l10 2l2 4h-8v6l-6 3l2 2q2 -2 8 -5l8 -4h8v11q-4 4 -4 10l-4 4h-6 v4l-8 2h-5l-8 -6l6 6l5 4v1q-152 -56 -274 -161v-2zM1713 879l10 14l6 10l6 -12l4 -17l3 -16l4 -6v-15l4 -8l-2 -12l2 -9l-2 -16l-2 -10l2 -7l2 9q2 -1 5 -5t5 -6l2 -8l4 -4l2 -4l-2 -12h2v-11l4 -8v-8l9 -8l8 -7l6 4v3l-2 12l-2 2v12l2 7v10l-8 16h-2l-4 6h-5l-6 5l-2 12 l-4 12l-6 -2v9l6 37v16l8 2v-12l7 2l8 -4l4 -13l4 -6h8l6 -6l-2 -12l3 -7l10 17l6 16l8 15l6 12v25l-6 25l-6 10l-8 6l-17 29v10l4 15l7 16l2 4h10v-6l2 -10l4 2v16l6 5l7 12l2 10l4 -4l2 8l6 6l4 15l4 18v19q-1 6 -2 18.5t-2 18.5l2 4l-6 23l-4 2l-4 6v6l-4 9q-2 3 -7 9.5 t-6 8.5l-8 12l-8 4l-2 9v10l-2 8l4 7l-4 6l-2 -2l-5 2v-11l-6 2l-4 5h-4l-6 8v8l2 2l-2 10v19l4 -4l2 -10l4 -3l2 -8l2 6v7l2 8l4 -2h2v-2l3 -8l2 -1v-2l2 -6v-2h2l2 -2v4h2l8 -6v-4l8 -12l4 -9l3 -6l2 4v13l-2 8l-7 14l-4 7l-10 12l-4 4h-2v2l-4 6l-2 4v4q0 1 -1 3.5 t-1 3.5v4l-5 8q-1 1 -2 4.5t-2 5.5l-2 4l-4 11l4 -4v1q-113 188 -303 300v-1l6 -4l11 -6l-2 -2l-2 -4l-4 -2l2 -6h-5l11 -9h-6l-9 3l-4 4l-8 4l-8 2l-2 -2l-6 -2l10 -6l-10 -5l-5 4l-10 -4l-2 -4l2 -2l-10 4v-4h-13v4l1 7v4l-5 4l-6 2h-12l-10 -4v-4l-5 4l-10 -2l2 4h-10 l-4 -6v-3l4 -4l-8 -2l-4 -8h-5l-12 -4v-2l-12 -4l-6 -6l-5 -2l-2 -7l7 -2l-11 -4l-16 -2l-9 -4l7 -10l12 -7l-15 3l-14 -2l-10 -5l8 -6h-8l-2 10l-6 3h-3l5 -7l-9 -8l13 -8v-10h10v-7l8 -6l-4 -6v-6l-8 -4l-2 -4h-10l10 16v8l-12 6l-5 13l-6 6l-6 2l2 8l-4 5h-19l-4 -1v-10 l-10 -10l2 -4l8 -8v-5h9l2 -2l10 -4l-4 -6l-33 10l-11 2l-24 2l-4 -4l10 -6l-8 -8l-11 6l-12 -4h-16l-5 -4h-10l8 6h-6q-5 -1 -20.5 -6.5t-24.5 -7.5l-2 -5l-13 -2l-6 3v6l11 2l-4 6l-23 4l2 -12l-4 -6l6 -5l-6 -8l-8 4h-7l-22 -10l6 -8l-8 -2l-21 8l-6 -4l4 -6l-2 -5l-6 3 l-11 4l2 16l-12 8h6q15 -3 37 -10l15 2l10 6v7l-6 6l-29 14l-21 4l-12 8l-10 -2l-6 7l12 2l-21 10h-14l-14 2l-15 -6l-12 2l-17 -2q-30 -11 -45 -15q-23 -9 -54 -28l-22 -7l-25 -4l-12 -6l-13 -25l-4 -12l8 -8h13l24 10l1 -8l-1 -17v-14l-2 -12l11 -1l10 7l12 -2l9 10l12 19 l10 2l13 12l-5 8l-2 8l13 13q19 5 39 14l2 4l12 6l15 1l10 -5v-4l-6 -2l-25 -10l-12 -6l-8 -6v-9l-5 -10l7 -4l4 -6h16l17 2h18l7 -11l-13 -6h-10l-10 4h-11l-14 -2l-2 -6l4 -4l4 2q-1 -3 -4 -8t-4 -8h-8l-3 10l-10 -2l-8 -6l-4 -7l-2 -10l-15 -2l-4 -6l-10 2v4l-10 4 l-31 -6l-7 -2l-2 4l-8 6l-8 -2h-10l2 4l-7 4l1 3v6l12 8l2 -2l4 4l-4 2v4l8 2l4 6l-10 -2l-4 -2h-8l-4 -2l-3 -2l-6 -12v-8l-4 -5v-4l-10 -4v2h-8l-4 -2l-7 2l-16 -2l-17 -16l-6 -2q-6 -2 -18 -2l-7 -9l-26 -4l-5 8l-6 -14l-12 8h-12l-4 -10l10 -8l2 -8v-17l-13 -23l-8 -6 l-14 4l-8 2l-7 5l-12 4l-8 6l-15 -6v-6l-6 -7l-2 -4v-6l-2 -6l-6 -8l-4 -5l-6 -4l-4 -8v-6l4 -2l-2 -8l-5 -9h3l4 -4l4 2l8 -4v-8l2 -6h4l6 4l7 2q12 -4 20 -6l12 8h7l6 8l8 4v7l8 10l13 8l2 4h14l13 6l2 7l4 8h16l17 -8l12 6h4l8 4h5l6 -6l6 -2l-2 -13l4 -10l8 -10l6 -7 l7 -2l4 -6l6 -4l2 -6h2q1 -3 2 -7.5t2 -7.5l-4 -2l-4 -8l-2 -2h-19l-14 4l-4 -6l16 -10l4 -2l7 -6l4 6v4l6 8h6l10 10h6l3 5l-7 6l7 8l12 -4l4 -8l4 2v2l-8 8q-2 2 -4.5 4t-5.5 4.5t-5 3.5l3 2l-1 4l-10 2l-10 11l-2 12l-8 8l-2 7l2 4l2 6l10 4l8 -4h-2l-2 -4l2 -4l6 6l6 -4 l-2 -4l2 -5h-2l5 -12l10 -6l6 -6q2 -2 7.5 -5t6.5 -4l1 -4l2 -2l-2 -4l-3 -10v-6l5 -6v-7q1 -3 4 -8t4 -8v-10l2 -11l10 -8h8l-2 12h7l-1 7l11 -5l2 7l-6 4l-6 8l6 2l-4 8v7l2 2l4 -7h6l6 2l-6 7l16 2l5 -2l8 -2v-2l2 -5q3 2 9 4.5t9 4.5h15l2 4l-10 6v6l-2 8l6 9l8 4l6 16 l4 -2l6 2v4l11 9l6 6h12v-6l17 -3l4 -4l-12 -4l-1 -2l11 -4l-4 -6l4 -2l18 6l13 2l4 4h-11l-6 4v7l11 4h12l8 2h10l11 2v-2l-17 -4l6 -5l-12 -10l-10 -2l8 -8l14 -6l15 -11l4 -4l8 -2l8 -6l11 -17l-2 -6l-15 -6l-12 2l-16 -2l-17 6l-21 11l-22 2l-15 -2l-16 -7l-23 3l-8 -9 l-19 2l-16 -12l6 -8l-8 -9l8 -8l6 -14l13 -2l12 -8h10l4 6l13 -2l10 -7h19l10 7l10 -4h8l-6 -5l4 -6l-4 -6v-10l-8 -9l-8 -16l-4 -10l-4 -6l-4 -3l-7 -4l-10 2l-10 4l-4 -4l-4 7l-9 2h-12l-6 -4l-11 -3q-6 3 -17 7.5t-14 5.5l-18 2l-2 6l-13 4l-4 2h-4l-4 6l-18 5h-9l-10 -5 l-6 -6v-12l-8 -6l-7 -2l-12 10l-16 8l-9 4l-4 13l-16 10l-9 4h-6l-14 8l-4 4l-2 7l-6 2v8l10 8l6 11l-4 4l2 6l8 6v2l-12 -4l2 6l-8 4l-13 -4h-8l-4 4l-12 2l-11 -4l-4 4l-37 6l-12 -2l-8 -4h-11l-12 -4l-4 2l-7 6l-10 2l-4 7l-6 2l-17 -19l-8 -4l-10 -4l-8 -8l-4 -6l-7 -9 v-18l-10 -8l-6 -1l-10 -8l-7 2l-6 -4l-10 -12l-6 -2l-6 -8l-2 -7l-3 -8l-4 -2l-12 -16v-5l-4 -6l-4 -2q-1 -2 -2 -6t-2 -6v-17l-2 -6q0 -14 -1 -22l-4 -13l-4 -6v-8l-2 -6l-2 -3l-4 -2l2 -8l2 -14l-2 -6v-15l2 -4v-2l2 -6l2 -2l4 -8l2 -3v-12l3 -6l2 -8l4 -9v-12l2 -6l4 -10 l6 -7l8 -16l7 -12l8 -13l10 -10h4q3 1 7.5 2t7.5 2l12 -2l6 -2l12 -6l9 -7l10 2l6 3l17 4h20q3 -1 9.5 -2t9.5 -2l8 -11l4 -12l8 -10l9 -2l4 2l4 -2l14 2v-4l4 -1l2 -8l5 -4l6 -12l-2 -11l-4 -16l2 -2q0 -9 -4 -19l-2 -4v-4l8 -16l10 -13l14 -16l13 -13q4 -12 6 -16v-2 l8 -10q1 -2 9 -23l-3 -4v-2q2 -2 4.5 -7t4.5 -8l4 -6l2 -8v-10l-4 -4l-6 -7l-5 -4l-2 -10v-6l-2 -9v-10l6 -18l13 -13l16 -22l15 -15l4 -6l4 -14l10 -17l6 -8l5 -10l8 -7l12 -10q21 -23 33 -33l4 -8l-2 -2l6 -6l2 -6l1 -1v1l4 -3h4l6 -4h4l6 2h10l9 2l4 -2h6l14 -4l5 2l8 -2 v2h6l12 2l7 4q3 2 9.5 7t8.5 6l6 6l2 4l4 4q1 1 2.5 2l3 2t3.5 2l2 3v8l2 8v6h-4l-2 4l6 4q4 1 8.5 2.5t9.5 2.5t7 2l6 4l2 4l-4 2v16h-2v4l-4 5l-7 8v8l6 2l9 8l6 2l16 11l11 4q10 3 16 4l8 6l9 4l4 8l-2 5l-2 20l-2 11v12l-2 6l-5 4l-6 13l-6 8v10l4 8v3l-10 6l-2 8l6 16 l6 4l2 9l4 6l2 10h5l4 6l10 5l4 4l4 8l17 16l14 9l21 14l16 12q22 24 31 35l14 21q3 6 17 35l4 16l4 5v20h-6l-8 -6l-8 -2l-7 -2h-14l-6 -2h-8l-13 -4l-16 -2l-13 -4h-8l-6 6l-2 6q-2 1 -6 4t-5 4l7 2v6l-4 4l-7 3q-4 4 -11 12t-11 12l-19 15l-8 8l-4 14l-8 17q-2 2 -6.5 5 t-6.5 5l-4 17l-2 16l4 2l-4 13l-2 4l-16 14v10l4 2l-11 17l-8 16l-8 23l-8 15l-4 16l2 2l8 -21q2 -2 5 -6t5 -6h6l4 8l3 9l4 6v-2l-2 -6l-2 -5l-1 -8h7q12 -18 14 -22l4 -11l4 -4l4 -8l-2 -4l4 -7l7 -4l6 -6l8 -16l-2 -8l2 -9l10 -14l5 -2l10 -10l4 -11l8 -18l8 -6l2 -5 l3 -4l2 -6v-6l-2 -2l2 -6h-3l3 -5l2 -12l2 -4v-8l4 -8l10 -1l6 1h7l2 4l4 2l4 4h18l9 2l10 6h10l13 10l24 7l15 6v6l2 6l12 4h8l9 6l-2 -4l8 4v5l6 4h10l4 4l2 8l9 6h6l2 2l-2 9l2 8l2 4h6l6 10l6 6l7 13v4l-6 2q-3 6 -11 14l-8 2l-10 2l-7 5l-6 10l-2 12l2 2l2 6l-2 2 l-4 -6l-29 -29h-22l-11 -2v4h-2l-2 5l4 8v8l-4 4l-4 -2l-4 -8l2 -10l-4 8l-4 4l-2 4l2 4v6l-10 5l-2 6l-7 4q-2 4 -5 11.5t-5 11.5l2 4l-4 6h8l6 6l7 -6l8 4l10 -19l10 -14l13 -4l16 -12l17 -3l14 7l12 2l7 -2l6 -17l16 -4l42 -6l20 4l23 2l25 4l12 -10l6 -10l10 -1l17 -12 l2 -4l-6 -6l20 -15h9l20 9l4 -13l2 -18q4 -11 13 -46l14 -20l2 -10l4 -17l7 -14l6 -5l12 -35l14 -12l5 6l2 9l12 6l-4 4l6 12l8 2v25l6 16l-2 11l-4 20l4 11l5 2l18 12v6l15 7l8 12l12 20l15 13l6 10l-2 10l14 5l8 2v6h7l2 4l6 -2l2 10l-2 6l10 2l6 -8l4 -6l2 -8l3 -6 l10 -11h6v-6l10 -16l4 -13l-2 -20l7 -2h6zM1105 1546l-8 -2l2 -6l14 -2l2 -4h25l4 2l-16 8l-4 8l12 10l8 9l21 6l39 12l2 4l-6 4l-11 -2l-10 -4l-21 -2l-35 -12l1 -4l-15 -8l8 -2l-14 -7zM602 1552l8 6l-6 3l-12 -1l-2 5l-12 -2l2 10l-5 2l-10 -4l4 -6l-4 -2l-8 2l2 -9h-8 l6 -10l4 -4h25l14 4zM580 1431l14 8l8 8l-8 2h-10l16 9l-10 2l-4 2l-11 -7l-12 -10v-6l-8 -10l8 4v-4l-6 -4v-3l8 -4v-8l-6 -6l-10 4l-5 -8v-8l-10 -1v-6l11 -6h-7l-20 -8l2 -4l8 2l6 -4l8 4l3 -2h16l12 -4l11 4v8l6 2l6 8l-10 4v5l4 14l-4 4l2 15zM526 1398l12 8l4 8l7 6 l-2 9l-5 2q-12 -6 -24 -9l-6 -12l-11 -10l8 -6zM1144 1177l-2 -12h8l-4 -10l10 -4l-2 -13l2 -10l-2 -4l-22 -4l-19 2l-10 8l-13 2l-4 10v6l4 3l1 4l2 10l10 2l-4 4h-6l-5 10q-14 12 -22 23l2 8l-12 13l12 14l12 2l6 8l25 9l12 -4h11l2 -7l-2 -14l-11 2l-10 -2v-10l-12 2v-4 l8 -3l6 -12l15 -4l2 -4l-2 -6v-4l4 -7v9l10 4l6 -9l9 -8l-11 -4zM656 1173h2l-2 -2v2zM1890 1002l-2 -2q1 -6 2 -18.5t2 -18.5l-2 4l2 -14l2 -6l1 -2l-1 6v12l2 8v13l-2 10v6zM1919 829l1 1q3 38 3 78v6q0 18 -2 54v-2v-3q0 -7 0.5 -20t0.5 -19l-1 6l-2 4l-4 -4l2 -10v-6h5 l-1 -37v4l-2 -4v-7l-2 -6l-2 6l-2 -8l-1 -6l-3 -14v-3l3 11h1l2 6l2 -4l-2 -8v-9l4 -4v17l2 10v-6q0 -7 -2 -23zM1900 946v-10l2 -2l2 6l2 -3v5zM1908 934v6l-1 -4zM1906 934l-2 -6l4 -4v8zM1923 914h-1v10zM1912 901v-10q-3 -4 -3 -10l3 -7l1 5v10l2 8v14l-2 -18v10z M1919 903l-2 8v-14zM1888 842l6 20l2 12l4 7l-2 12l-2 -16l-2 -11l-4 -12zM1875 701l5 13l2 12v8l6 6l-4 9l2 8l-2 12l4 15l4 8v6l4 4l2 7l-8 4v6l-2 6l-2 -2l-2 -14l-4 -13l-5 -12q-2 -5 -6.5 -16.5t-5.5 -14.5l-8 -8l-4 -3l-2 -14l-6 -6l-3 2l-6 -12l-2 -13v-12l2 -10 l2 -2l-2 -17l6 2l3 2v-4l10 8l2 -2l4 2v-8l8 10v-4l6 23zM1909 751v2q2 6 2 9l-2 -3l-3 -4l-4 -2l-4 2l-4 -13q-8 -28 -10 -41l-4 -10v-8l2 2l-2 -13l-3 -14v-2l3 4q4 13 8 35l4 6l-2 -14l2 -6l-2 -6v-2l4 8v8l2 6v13l4 8l2 10h2l2 8l-8 -12l-4 -8l-2 10l2 12l4 6l6 5h4z M1556 730l-6 13l-6 10l-9 8l-6 -25l2 -22l5 -11l12 5l6 6zM1785 602l8 3q2 6 5 19t5 20l-2 8l-6 -2l-2 6v10h-2l-4 4l4 13l-6 4l-4 10l-6 8l-9 -2l-8 11l-12 12l-7 12l-16 -2l-6 -2v-8l10 -14l8 -5l4 -12l7 -6l4 -8l2 -11l8 -10l6 -27l4 -8l2 -8l9 -6zM1851 598l4 2 q2 1 10 9l-2 6l-4 -4v2l-2 -6l-2 -2zM1847 603l-4 4l-7 -2v8h-10l-2 -6l-12 -4v4h-2l-3 2l-10 -4l-8 -15l6 2v-6h12l3 4l4 2l6 -2h6l6 2l6 4l4 -2zM1097 396l-4 5l-2 14l-3 8l-4 6l-4 5l-6 -5v-8l-8 -8l-6 2l2 -6l-4 -6l-11 -7l-8 -6h-6l-6 -2l-7 -2h-8l-2 -8l-6 -6l2 -9 l2 -8l2 -6l-2 -8l-6 -8v-3l-6 -2l-2 -8l2 -8l4 -8l2 -9l4 -6l17 -6l12 4l12 2l8 13q4 12 27 63v9l4 2l2 6l-2 8l4 4l3 -8l2 4z" /> +<glyph unicode="" horiz-adv-x="1536" d="M1156 1657l312 -312q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1408 1085h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280v1024zM1024 1213h376q-10 29 -22 41l-313 313 q-12 12 -41 22v-376zM771 525h177q16 0 28 11.5t12 28.5v82q0 17 -12 29t-28 12h-177v176q0 17 -11.5 29t-28.5 12h-82q-17 0 -29 -12t-12 -29v-176h-176q-17 0 -29 -12t-12 -29v-82q0 -17 12 -28.5t29 -11.5h176v-177q0 -16 12 -28.5t29 -12.5h82q16 0 28 12t12 29v177z " /> +<glyph unicode="" horiz-adv-x="1536" d="M96 1725h896q40 0 88 -20t76 -48l312 -312q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28zM1408 61v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280zM1024 1213h376q-10 29 -22 41l-313 313q-12 12 -41 22 v-376zM735 1147q4 0 7 -3q7 -4 4 -13l-58 -148v-6h8h122q130 0 223 -92.5t93 -223.5v-41q0 -130 -93 -223t-223 -93h-446v145h446q70 0 120 50t50 121v41q0 71 -50 120.5t-120 49.5h-126l54 -138q4 -9 -4 -14q-4 -2 -7 -2t-7 2l-290 224q-5 3 -5 9t5 9l290 223q3 3 7 3z" /> +<glyph unicode="" horiz-adv-x="1536" d="M96 1725h896q40 0 88 -20t76 -48l312 -312q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28zM1408 61v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280zM1024 1213h376q-10 29 -22 41l-313 313q-12 12 -41 22 v-376zM896 768q14 0 27 -11l54 -54q11 -11 11 -27q0 -15 -11 -26l-341 -341q-11 -11 -27 -11t-27 11l-197 197q-11 11 -11 27t11 27l54 54q11 11 27 11q14 0 27 -11l116 -117l260 260q11 11 27 11z" /> +<glyph unicode="" horiz-adv-x="1536" d="M975 1725h465q40 0 68 -28t28 -68v-1600q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h465q10 0 16.5 -6.5t6.5 -16.5v-55q0 -10 7 -16.5t16 -6.5h322q9 0 16 6.5t7 16.5v55q0 10 6.5 16.5t16.5 6.5zM1408 1461h-1280v-1400h1280v1400z M1044 1044q0 -115 -80.5 -196t-195.5 -81t-195.5 81t-80.5 196q0 114 80.5 195t195.5 81t195.5 -81t80.5 -195zM1029 811q57 -10 98 -49t62 -99t30 -123t9 -135q0 -79 -45 -134.5t-108 -55.5h-614q-63 0 -108 55.5t-45 134.5q0 72 9 135t30 123t62 99t98 49 q104 -117 261 -117t261 117z" /> </font> </defs></svg> \ No newline at end of file diff --git a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.ttf b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.ttf old mode 100644 new mode 100755 index ff329b6915f50..01efdde232d01 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.ttf and b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.ttf differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.woff b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.woff old mode 100644 new mode 100755 index 055bb41c421cc..de3f660cd0c9a Binary files a/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.woff and b/corehq/apps/hqwebapp/static/hqwebapp/font/commcarehqfont-regular.woff differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower-footer.png b/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower-footer.png index 4ddc5ed186b08..d908adc50646d 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower-footer.png and b/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower-footer.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower.png b/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower.png index ad4dcbabae3a7..733b534ebcd3c 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower.png and b/corehq/apps/hqwebapp/static/hqwebapp/images/commcare-flower.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/dimagi-footer.png b/corehq/apps/hqwebapp/static/hqwebapp/images/dimagi-footer.png index 137e12dae34c6..cabd10884831e 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/images/dimagi-footer.png and b/corehq/apps/hqwebapp/static/hqwebapp/images/dimagi-footer.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/favicon.png b/corehq/apps/hqwebapp/static/hqwebapp/images/favicon.png index 715e73a1d596c..dc89f30c486cb 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/images/favicon.png and b/corehq/apps/hqwebapp/static/hqwebapp/images/favicon.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/favicon2.png b/corehq/apps/hqwebapp/static/hqwebapp/images/favicon2.png index bf73d5b145806..b4fbae1bba820 100644 Binary files a/corehq/apps/hqwebapp/static/hqwebapp/images/favicon2.png and b/corehq/apps/hqwebapp/static/hqwebapp/images/favicon2.png differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/images/molly.jpg b/corehq/apps/hqwebapp/static/hqwebapp/images/molly.jpg new file mode 100644 index 0000000000000..4f7d5e71eb1d1 Binary files /dev/null and b/corehq/apps/hqwebapp/static/hqwebapp/images/molly.jpg differ diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/main.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/main.js index ba7fc2153ca03..e9efaaee791be 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/main.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/main.js @@ -409,15 +409,25 @@ hqDefine('hqwebapp/js/bootstrap3/main', [ var alertCookie = 'alerts_maintenance'; var closedAlerts = $.cookie(alertCookie) ? JSON.parse($.cookie(alertCookie)) : []; + var viewedDomainAlertsCookie = 'viewed_domain_alerts'; + var viewedDomainAlerts = $.cookie(viewedDomainAlertsCookie) ? JSON.parse($.cookie(viewedDomainAlertsCookie)) : []; + + var setUpAlert = function (alert, alertList, alertCookieName) { + var id = $(alert).data('id'); + if (!alertList.includes(id)) { + $(alert).removeClass('hide'); + $(alert).on('click', '.close', function () { + alertList.push(id); + $.cookie(alertCookieName, JSON.stringify(alertList), { expires: 7, path: '/', secure: initialPageData.get('secure_cookies') }); + }); + } + }; _.each($maintenance, function (alert) { - var id = $(alert).data('id'); - if (!closedAlerts.includes(id)) { - $(alert).removeClass('hide'); - $(alert).on('click', '.close', function () { - closedAlerts.push(id); - $.cookie(alertCookie, JSON.stringify(closedAlerts), { expires: 7, path: '/', secure: initialPageData.get('secure_cookies') }); - }); + if ($(alert).data('created-by-domain')) { + setUpAlert(alert, viewedDomainAlerts, viewedDomainAlertsCookie); + } else { + setUpAlert(alert, closedAlerts, alertCookie); } } ); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/requirejs_config.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/requirejs_config.js index d944f583a3345..6f4a0a038da56 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/requirejs_config.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/requirejs_config.js @@ -10,8 +10,6 @@ requirejs.config({ "datatables": "datatables.net/js/jquery.dataTables.min", "datatables.fixedColumns": "datatables-fixedcolumns/js/dataTables.fixedColumns", "datatables.bootstrap": "datatables-bootstrap3/BS3/assets/js/datatables", - "datatables.scroller": "datatables-scroller/js/dataTables.scroller", - "datatables.colReorder": "datatables-colreorder/js/dataTables.colReorder", }, shim: { "ace-builds/src-min-noconflict/ace": { exports: "ace" }, @@ -44,12 +42,6 @@ requirejs.config({ "datatables.fixedColumns": { "datatables.net": "datatables", }, - "datatables.scroller": { - "datatables.net": "datatables", - }, - "datatables.colReorder": { - "datatables.net": "datatables", - }, }, // This is really build config, but it's easier to define a js function here than in bootstrap3/requirejs.yml diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js similarity index 98% rename from corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js rename to corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js index 9fde8b13d881e..1f7ff498edfca 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js @@ -1,4 +1,4 @@ -hqDefine("hqwebapp/js/validators.ko", [ +hqDefine("hqwebapp/js/bootstrap3/validators.ko", [ 'jquery', 'knockout', 'knockout-validation/dist/knockout.validation.min', // needed for ko.validation diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/knockout_bindings.ko.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/knockout_bindings.ko.js index 1027241ded1e7..60e5c9f98a239 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/knockout_bindings.ko.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/knockout_bindings.ko.js @@ -2,11 +2,13 @@ hqDefine("hqwebapp/js/bootstrap5/knockout_bindings.ko", [ 'jquery', 'underscore', 'knockout', + "es6!hqwebapp/js/bootstrap5_loader", 'jquery-ui/ui/widgets/sortable', ], function ( $, _, - ko + ko, + bootstrap ) { // Need this due to https://github.com/knockout/knockout/pull/2324 // so that ko.bindingHandlers.foreach.update works properly @@ -344,19 +346,15 @@ hqDefine("hqwebapp/js/bootstrap5/knockout_bindings.ko", [ ko.bindingHandlers.modal = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { - $(element).addClass('modal fade').modal({ - show: false, - }); - // ko.bindingHandlers['if'].init(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); + viewModel.binding_modal = new bootstrap.Modal(element); }, update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { ko.bindingHandlers.visible.update(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); - var value = ko.utils.unwrapObservable(valueAccessor()); - // ko.bindingHandlers['if'].update(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); + let value = ko.utils.unwrapObservable(valueAccessor()); if (value) { - $(element).modal('show'); + viewModel.binding_modal.show(); } else { - $(element).modal('hide'); + viewModel.binding_modal.hide(); } }, }; @@ -383,19 +381,21 @@ hqDefine("hqwebapp/js/bootstrap5/knockout_bindings.ko", [ templateID = value.templateId; ifValue = _.has(value, 'if') ? value.if : true; } - var modal = $('<div></div>').addClass('modal fade').appendTo('body'), + let modalElement = $('<div></div>').addClass('modal fade').attr("tabindex", "-1").appendTo('body'), newValueAccessor = function () { - var clickAction = function () { + let clickAction = function () { if (!ifValue) { return; } - ko.bindingHandlers.template.init(modal.get(0), function () { + ko.bindingHandlers.template.init(modalElement.get(0), function () { return templateID; }, allBindingsAccessor, viewModel, bindingContext); - ko.bindingHandlers.template.update(modal.get(0), function () { + ko.bindingHandlers.template.update(modalElement.get(0), function () { return templateID; }, allBindingsAccessor, viewModel, bindingContext); - modal.modal('show'); + + let modal = new bootstrap.Modal(modalElement.get(0)); + modal.show(); }; return clickAction; }; @@ -405,11 +405,13 @@ hqDefine("hqwebapp/js/bootstrap5/knockout_bindings.ko", [ ko.bindingHandlers.openRemoteModal = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { - var modal = $('<div></div>').addClass('modal fade').appendTo('body'), + var modalElement = $('<div></div>').addClass('modal fade').attr("tabindex", "-1").appendTo('body'), newValueAccessor = function () { var clickAction = function () { - modal.load($(element).data('ajaxSource')); - modal.modal('show'); + modalElement.load($(element).data('ajaxSource'), function () { + let modal = new bootstrap.Modal(modalElement.get(0)); + modal.show(); + }); }; return clickAction; }; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/main.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/main.js index 7b4596c780dba..a5cc92c1504ee 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/main.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/main.js @@ -414,15 +414,25 @@ hqDefine('hqwebapp/js/bootstrap5/main', [ var alertCookie = 'alerts_maintenance'; var closedAlerts = $.cookie(alertCookie) ? JSON.parse($.cookie(alertCookie)) : []; + var viewedDomainAlertsCookie = 'viewed_domain_alerts'; + var viewedDomainAlerts = $.cookie(viewedDomainAlertsCookie) ? JSON.parse($.cookie(viewedDomainAlertsCookie)) : []; + + var setUpAlert = function (alert, alertList, alertCookieName) { + var id = $(alert).data('id'); + if (!alertList.includes(id)) { + $(alert).removeClass('hide'); + $(alert).on('click', '.close', function () { + alertList.push(id); + $.cookie(alertCookieName, JSON.stringify(alertList), { expires: 7, path: '/', secure: initialPageData.get('secure_cookies') }); + }); + } + }; _.each($maintenance, function (alert) { - var id = $(alert).data('id'); - if (!closedAlerts.includes(id)) { - $(alert).removeClass('hide'); - $(alert).on('click', '.close', function () { - closedAlerts.push(id); - $.cookie(alertCookie, JSON.stringify(closedAlerts), { expires: 7, path: '/', secure: initialPageData.get('secure_cookies') }); - }); + if ($(alert).data('created-by-domain')) { + setUpAlert(alert, viewedDomainAlerts, viewedDomainAlertsCookie); + } else { + setUpAlert(alert, closedAlerts, alertCookie); } } ); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/requirejs_config.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/requirejs_config.js index 8fdb7cf1ebd99..3cbcf3215a025 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/requirejs_config.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/requirejs_config.js @@ -12,16 +12,16 @@ requirejs.config({ "knockout": "knockout/build/output/knockout-latest.debug", "ko.mapping": "hqwebapp/js/lib/knockout_plugins/knockout_mapping.ko.min", "datatables": "datatables.net/js/jquery.dataTables.min", - "datatables.fixedColumns": "datatables-fixedcolumns/js/dataTables.fixedColumns", - "datatables.bootstrap": "datatables-bootstrap3/BS3/assets/js/datatables", - "datatables.scroller": "datatables-scroller/js/dataTables.scroller", - "datatables.colReorder": "datatables-colreorder/js/dataTables.colReorder", + "datatables.fixedColumns": "datatables.net-fixedcolumns/js/dataTables.fixedColumns.min", + "datatables.fixedColumns.bootstrap": "datatables.net-fixedcolumns/js/dataTables.fixedColumns.min", + "datatables.bootstrap": "datatables.net-bs5/js/dataTables.bootstrap5.min", }, shim: { "ace-builds/src-min-noconflict/ace": { exports: "ace" }, "ko.mapping": { deps: ['knockout'] }, "hqwebapp/js/bootstrap5/hq.helpers": { deps: ['jquery', 'knockout', 'underscore'] }, "datatables.bootstrap": { deps: ['datatables'] }, + "datatables.fixedColumns.bootstrap": { deps: ['datatables.fixedColumns'] }, "jquery.rmi/jquery.rmi": { deps: ['jquery', 'knockout', 'underscore'], exports: 'RMI', @@ -47,12 +47,6 @@ requirejs.config({ "datatables.fixedColumns": { "datatables.net": "datatables", }, - "datatables.scroller": { - "datatables.net": "datatables", - }, - "datatables.colReorder": { - "datatables.net": "datatables", - }, }, // This is really build config, but it's easier to define a js function here than in bootstrap5/requirejs.yml diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/sticky_tabs.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/sticky_tabs.js index 5f51aa17367e9..815d29c65dbcf 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/sticky_tabs.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/sticky_tabs.js @@ -16,15 +16,17 @@ hqDefine("hqwebapp/js/bootstrap5/sticky_tabs", [ } return ""; }; - $(function () { - var tabSelector = "a[data-toggle='tab']", + var tabSelector = "a[data-bs-toggle='tab']", navSelector = ".nav.sticky-tabs", hash = getHash(), $tabFromUrl = hash ? $("a[href='" + hash + "']") : undefined, $altTabSelector = $(navSelector + ' ' + tabSelector).first(), tabController; + // make sure we don't treat all anchor tags as a sticky tab + if ($tabFromUrl && $tabFromUrl.parents('.sticky-tabs').length === 0) return; + if ($tabFromUrl && $tabFromUrl.length) { tabController = new bootstrap.Tab($tabFromUrl); tabController.show(); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js new file mode 100644 index 0000000000000..4581636d9ce1b --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js @@ -0,0 +1,99 @@ +hqDefine("hqwebapp/js/bootstrap5/validators.ko", [ + 'jquery', + 'knockout', + 'knockout-validation/dist/knockout.validation.min', // needed for ko.validation +], function ( + $, + ko +) { + 'use strict'; + + ko.validation.init({ + errorMessageClass: 'invalid-feedback', + errorElementClass: 'is-invalid', + decorateElement: true, + decorateInputElement: true, + }, true); + + ko.validation.rules['emailRFC2822'] = { + validator: function (val) { + if (val === undefined || val.length === 0) return true; // do separate validation for required + var re = /(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/; + return re.test(val || '') && val.indexOf(' ') < 0; + }, + message: gettext("Not a valid email"), + }; + + ko.validation.registerExtenders(); + + /** + * Use this handler to show bootstrap validation states on a form input when + * your input's observable has been extended by Knockout Validation + * + * Pass in the following: + * { + * validator: primary observable with validation, + * delayedValidator: de-coupled rate limited observable with validation (optional), + * successMessage: text (optional), + * checkingMessage: text (optional), + * } + * + * delayedValidator is useful if you are doing async validation and want to decouple async validation from + * other validators (perhaps for rate limiting). See Organisms > Forms in styleguide for example. + * + */ + ko.bindingHandlers.koValidationStateFeedback = { + init: function (element, valueAccessor) { + let options = valueAccessor(), + successMessage = ko.unwrap(options.successMessage), + checkingMessage = ko.unwrap(options.checkingMessage); + $(element) + .after($('<span />').addClass('valid-feedback').text(successMessage)) + .after($('<span />').addClass('validating-feedback') + .append($('<i class="fa fa-spin fa-spinner"></i>')).append(" " + (checkingMessage || gettext("Checking...")))) + .after($('<span />').addClass('ko-delayed-feedback')); + }, + update: function (element, valueAccessor) { + let options = valueAccessor(), + validatorVal = ko.unwrap(options.validator), + isValid = false, + isValidating = false, + isDelayedValid; + + if (validatorVal === undefined) { + return; + } + + if (options.delayedValidator === undefined) { + isValid = options.validator.isValid(); + isValidating = options.validator.isValidating !== undefined && options.validator.isValidating(); + if (isValid !== undefined && !isValid) $(element).addClass('is-invalid'); + } else { + isValidating = options.validator.isValid() && options.delayedValidator.isValidating(); + + isDelayedValid = options.delayedValidator.isValid(); + if (!isDelayedValid && !isValidating) { + $(element).addClass('is-invalid').removeClass('is-valid is-validating'); + $(element).next('.ko-delayed-feedback') + .addClass('invalid-feedback').text(options.delayedValidator.error()); + } else { + $(element).next('.ko-delayed-feedback').removeClass('invalid-feedback').text(""); + } + + isValid = options.validator.isValid() && isDelayedValid; + } + + if (isValidating) { + $(element).removeClass('is-valid is-invalid').addClass('is-validating'); + } else { + $(element).removeClass('is-validating'); + } + + if (isValid && !isValidating) { + $(element).addClass('is-valid').removeClass('is-invalid is-validating'); + } else if (!isValid) { + $(element).removeClass('is-valid'); + } + }, + }; +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/components/bootstrap5/feedback.js b/corehq/apps/hqwebapp/static/hqwebapp/js/components/bootstrap5/feedback.js index 5e39558d8f9ab..26acf7571cc54 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/components/bootstrap5/feedback.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/components/bootstrap5/feedback.js @@ -21,9 +21,10 @@ hqDefine('hqwebapp/js/components/bootstrap5/feedback', [ $, initialPageData ) { + 'use strict'; return { viewModel: function (params) { - var self = {}; + let self = {}; if (!params.featureName) { throw new Error("Please specify a featureName in params."); @@ -61,11 +62,6 @@ hqDefine('hqwebapp/js/components/bootstrap5/feedback', [ if (data.success) { self.showSuccess(true); } - }) - .always(function () { - setTimeout(function () { - $('#modal-feedback-form-widget').modal('hide'); - }, 1000); }); }; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/multiselect_utils.js b/corehq/apps/hqwebapp/static/hqwebapp/js/multiselect_utils.js index e69805fe312b7..c986bf77978da 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/multiselect_utils.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/multiselect_utils.js @@ -40,9 +40,10 @@ hqDefine('hqwebapp/js/multiselect_utils', [ }; var _renderSearch = function (inputId, placeholder) { - var input = _.template( + var inputGroupTextClass = (window.USE_BOOTSTRAP5) ? "input-group-text" : "input-group-addon", + input = _.template( '<div class="input-group ms-input-group">' + - '<span class="input-group-addon">' + + '<span class="' + inputGroupTextClass + '">' + '<i class="fa fa-search"></i>' + '</span>' + '<input type="search" class="form-control search-input" id="<%- searchInputId %>" autocomplete="off" placeholder="<%- searchInputPlaceholder %>" />' + @@ -76,17 +77,18 @@ hqDefine('hqwebapp/js/multiselect_utils', [ selectAllId = baseId + '-select-all', removeAllId = baseId + '-remove-all', searchSelectableId = baseId + '-search-selectable', - searchSelectedId = baseId + '-search-selected'; + searchSelectedId = baseId + '-search-selected', + defaultBtnClass = (window.USE_BOOTSTRAP5) ? 'btn-outline-primary btn-sm' : 'btn-default'; $element.multiSelect({ selectableHeader: _renderHeader( selectableHeaderTitle, - _renderAction(selectAllId, 'btn-default', 'fa fa-plus', gettext("Add All"), disableModifyAllActions), + _renderAction(selectAllId, defaultBtnClass, 'fa fa-plus', gettext("Add All"), disableModifyAllActions), _renderSearch(searchSelectableId, searchItemTitle) ), selectionHeader: _renderHeader( selectedHeaderTitle, - _renderAction(removeAllId, 'btn-default', 'fa fa-remove', gettext("Remove All"), disableModifyAllActions), + _renderAction(removeAllId, defaultBtnClass, 'fa fa-remove', gettext("Remove All"), disableModifyAllActions), _renderSearch(searchSelectedId, searchItemTitle) ), afterInit: function () { diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/widgets.js b/corehq/apps/hqwebapp/static/hqwebapp/js/widgets.js index 9f75e28edbcdb..6fcabd0d42cd8 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/widgets.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/widgets.js @@ -15,6 +15,12 @@ hqDefine("hqwebapp/js/widgets",[ $(element).select2({ width: '100%', }); + if (window.USE_BOOTSTRAP5 && $(element).hasClass('is-invalid')) { + $(element).data('select2').$container.addClass('is-invalid'); + } + if (window.USE_BOOTSTRAP5 && $(element).hasClass('is-valid')) { + $(element).data('select2').$container.addClass('is-valid'); + } }); // .hqwebapp-autocomplete also allows for free text entry diff --git a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/backgrounds.less b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/backgrounds.less index 5fca9c2051b4d..a6af896ef1297 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/backgrounds.less +++ b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/backgrounds.less @@ -23,8 +23,8 @@ bottom: 0; left: 0; right: 0; - background: linear-gradient(#5D70D2, #323b43); - opacity: 0.3; + background-color: #45566E; + opacity: 0.75; } .bg-container { diff --git a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/includes/variables.less b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/includes/variables.less index f660630dfb0e4..e0506014610c8 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/includes/variables.less +++ b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/includes/variables.less @@ -57,9 +57,6 @@ @cc-brand-mid: #004ebc; @cc-brand-low: #002c5f; -// used in registration app only. will be merged into "brand" color Bootstrap 5 migration -@commcare-blue: #5D70D2; - // Accent colors used in few places (billing, web apps, one-offs). Minimize usage. @cc-dark-cool-accent-hi: #d6c5ea; @cc-dark-cool-accent-mid: #9060c8; @@ -88,7 +85,7 @@ @navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); @navbar-default-bg: @cc-bg; -@navbar-footer-height: 40px; +@navbar-footer-height: 38px; @navbar-footer-link-color: mix(darken(#ffffff, 10), @brand-primary, 90); @navbar-footer-link-color-hover: @gray-lighter; @navbar-footer-button-color: #474747; @@ -120,4 +117,5 @@ @input-border-radius-large: 5px; +@input-color: #000; @cursor-disabled: 'not-allowed'; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/navbar.less b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/navbar.less index 2cc9dce7bc56c..c2eb94b205d8b 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/navbar.less +++ b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/navbar.less @@ -67,11 +67,6 @@ &.btn { color: #ffffff; } } a.footer-link-img { - - img { - margin-top: -4px; - } - &:hover { text-decoration: none; } @@ -80,7 +75,7 @@ .dimagi-logo svg { height: 22px; width: 50px; - top: 7px; + top: 10px; position: relative; display: inline-block; } diff --git a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/select2s.less b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/select2s.less index 6bc1077c14987..6bdd8f63d4014 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/select2s.less +++ b/corehq/apps/hqwebapp/static/hqwebapp/less/_hq/select2s.less @@ -57,6 +57,14 @@ .select2-container { width: 100% !important; + .select2-selection.select2-selection--single { + height: @input-height-base; + } + + .select2-selection.select2-selection--multiple { + min-height: @input-height-base; + } + .select2-search-field { width: 100% !important; } @@ -64,6 +72,10 @@ .select2-input { width: 100% !important; } + + textarea.select2-search__field { // Placeholder for multi-select + line-height: 21px; + } } .select2-container.select2-container-active > .select2-choice { diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_datatables.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_datatables.scss index 919e00a409f5c..ec40fd38f4701 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_datatables.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_datatables.scss @@ -1,102 +1,54 @@ -.dataTables_info, -.dataTables_length { - display: inline-block; -} -.dataTables_info { - padding-top: 24px; - padding-right: 5px; +div.dataTables_wrapper div.dataTables_info { + padding-top: 9px !important; } -.datatable thead th, -.dataTable thead th { - background-color: desaturate($cc-brand-low, 50%); - color: #ffffff; - &:nth-child(odd) { - background-color: lighten(desaturate($cc-brand-low, 50%), 10%); - } +div.dataTables_wrapper div.dataTables_length label { + padding-top: 6px !important; } -.datatable tfoot td, -.datatable tfoot th, -.dataTable tfoot td, -.dataTable tfoot th{ - background-color: lighten(desaturate($cc-brand-low, 60%), 10%); - color: #ffffff; - padding: 8px; +table.dataTable.table-hq-report { + margin-top: 0 !important; } -.datatable .header, -.dataTable .header { - .dt-sort-icon:before{ - font-family: "Glyphicons Halflings"; - vertical-align: bottom; - } - &.headerSort { - .dt-sort-icon:before { - content: "\e150"; - opacity: 0.2; - } - } - &.headerSortDesc { - .dt-sort-icon:before { - content: "\e156"; - } - } - &.headerSortAsc { - .dt-sort-icon:before { - content: "\e155"; +.table-hq-report { + thead th { + background-color: $blue-800; + color: $white; + white-space: nowrap; + + &:nth-child(odd) { + background-color: $blue-700; } - } - &.headerSortDesc, - &.headerSortAsc { - background-color: $cc-brand-mid; - } -} -.datatable .sorting_1, -.dataTable .sorting_1 { - background-color: $cc-bg; -} + &.dtfc-fixed-left, + &.dtfc-fixed-right { + background-color: $blue !important; + } -.panel-body-datatable { - padding: 0; - .dataTables_control { - padding: 10px 15px; - .dataTables_info { - padding-top: 0; + &.sorting_asc::before, + &.sorting_desc::after { + opacity: 1.0 !important; + color: $white !important; } - .dataTables_paginate { - .pagination { - margin: 0; - } + + &::after, + &::before { + opacity: 0.3 !important; } } -} - -.dataTable td.text-xs { - font-size: .8em; -} - -.dataTable td.text-sm { - font-size: .9em; -} - -.dataTable td.text-lg { - font-size: 1.1em; -} -.dataTable td.text-xl { - font-size: 1.2em; -} - -.dataTable td.text-bold { - font-weight: bold; -} + tbody tr { + &.odd td.dtfc-fixed-left, + &.odd td.dtfc-fixed-right { + background-color: lighten($gray-200, 5%); + } -.dataTable td.text-red { - color: $cc-att-neg-mid; + &.even td.dtfc-fixed-left { + background-color: $gray-100; + } + } } -.dataTable td.text-green { - color: $cc-att-pos-mid; +.dtfc-right-top-blocker:last-child { + display: none !important; } diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_feedback.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_feedback.scss index 271b5eac160cd..d24cfd34c99ac 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_feedback.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_feedback.scss @@ -24,28 +24,28 @@ feedback { } .rate-bad { - color: $cc-att-neg-mid; + color: $danger; &.selected { - background-color: $cc-att-neg-mid; + background-color: $danger; color: white; } } .rate-ok { - color: $cc-dark-warm-accent-mid; + color: $warning; &.selected { - background-color: $cc-dark-warm-accent-mid; + background-color: $warning; color: white; } } .rate-good { - color: $cc-att-pos-mid; + color: $success; &.selected { - background-color: $cc-att-pos-mid; + background-color: $success; color: white; } } diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_forms.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_forms.scss index a63a1f883dd3c..bd1aeb577d82c 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_forms.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_forms.scss @@ -1,3 +1,9 @@ +.row > div > .form-check:first-child, +.row > div > .input-group > .form-check:first-child { + padding-top: add($input-padding-y, $input-border-width); + margin-bottom: 0; +} + .form-hide-actions .form-actions { display: none; } @@ -17,4 +23,27 @@ p.help-block { margin-top: 5px; margin-bottom: 10px; color: lighten($cc-text, 25%); -} \ No newline at end of file +} + +legend { + border-bottom: 1px solid $border-color; + padding-bottom: $spacer * .25; + margin-bottom: $spacer * 1.25; +} + +.form-actions { + border-top: 1px solid $border-color; + background-color: $light; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + padding: $spacer 0; + margin: 0 0 $spacer 0; + + div { + padding-left: 6px; + } +} + +.ms-header .btn { + margin-top: -3px; +} diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_inline_edit.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_inline_edit.scss index 7c8b7e344baf1..1440ce0871806 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_inline_edit.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_inline_edit.scss @@ -19,12 +19,12 @@ &:hover { cursor: pointer; - background-color: $cc-bg; + background-color: $light; border-radius: 4px; } i { - color: $cc-neutral-hi; + color: $secondary; display: inline-block; margin-left: 5px; margin-top: 2px; @@ -34,20 +34,11 @@ white-space: pre-wrap; } } - .read-write { - .form-group { - margin-left: 0; - margin-right: 0; - } - .langcode-container.has-lang { - input, textarea { - padding-right: 45px; - } - } + min-width: 350px; } } table .ko-inline-edit .read-only:hover { - background-color: $cc-neutral-hi; + background-color: $light; } diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_navbar.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_navbar.scss index a10bf8f673cd4..66f0da7d72976 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_navbar.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_navbar.scss @@ -128,9 +128,6 @@ &.btn { color: #ffffff; } } a.footer-link-img { - img { - margin-top: -2px; - } &:hover { text-decoration: none; } @@ -139,7 +136,7 @@ .dimagi-logo svg { height: 22px; width: 50px; - top: 0; + top: 2px; position: relative; display: inline-block; } diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_pagination.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_pagination.scss index 4f7bac691f4e8..e69de29bb2d1d 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_pagination.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_pagination.scss @@ -1,12 +0,0 @@ -.pagination .form-control { - display: inline; - width: auto; -} - -.pagination-text { - margin: 17px 0; -} - -.pagination > .active > a { - @include button-variant($white, $cc-brand-low, $cc-brand-low); -} diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_select2.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_select2.scss index 73173563041bb..e9cd0039e847f 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_select2.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_select2.scss @@ -73,3 +73,86 @@ .select2-selection__placeholder { color: $gray-base !important; } + +.select2-container--default .select2-selection--single, +.select2-container--default .select2-selection--multiple { + border-color: $border-color !important; + height: 32px !important; +} + +.select2-container--default .select2-selection--single { + --#{$prefix}form-select2-bg-img: #{escape-svg($form-select-indicator)}; + + .select2-selection__rendered { + padding-left: $input-padding-x !important; + padding-right: 36px !important; + padding-top: 1px !important; + } + .select2-selection__arrow b { + border: none !important; + background-image: var(--#{$prefix}form-select2-bg-img), var(--#{$prefix}form-select-bg-icon, none); + background-repeat: no-repeat; + margin-top: 0 !important; + width: 12px !important; + height: 12px !important; + top: 9px !important; + right: 14px !important; + left: auto !important; + } +} + +.select2-container--default.is-invalid, +.select2-container--default.is-valid { + + .select2-selection__rendered { + padding-right: 66px !important; + } + .select2-selection--single .select2-selection__arrow { + background-repeat: no-repeat; + background-size: 24%; + background-position: 15px 7px; + width: 66px !important; + } + + &.select2-container--focus { + .select2-selection--single, + .select2-selection--multiple { + outline: 0; + } + } +} + +.select2-container--default.is-invalid { + .select2-selection--single, + .select2-selection--multiple { + border-color: $form-feedback-icon-invalid-color !important; + } + .select2-selection--single .select2-selection__arrow { + background-image: escape-svg($form-feedback-icon-invalid); + } +} + +.select2-container--default.is-valid { + .select2-selection--single, + .select2-selection--multiple { + border-color: $form-feedback-icon-valid-color !important; + } + .select2-selection--single .select2-selection__arrow { + background-image: escape-svg($form-feedback-icon-valid); + } +} + +.select2-container--default.is-invalid.select2-container--focus { + .select2-selection--single, + .select2-selection--multiple { + box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity); + } +} + +.select2-container--default.is-valid.select2-container--focus { + .select2-selection--single, + .select2-selection--multiple { + box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity); + outline: 0; + } +} diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss index 7117179c8112d..073a30a41f3a9 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss @@ -1,3 +1,5 @@ +$prefix: bs; + // Grid Containers // we will want to adapt the defaults in a full redesign $container-max-widths: ( @@ -43,9 +45,6 @@ $dropdown-font-size: 12px; // Badges $badge-border-radius: .25em; - -// Custom colors we created (in Bootstrap 3 stylesheets) - // Main background & text $cc-bg: #f2f2f1; $cc-text: #1c2126; @@ -106,16 +105,90 @@ $cc-dark-warm-accent-hi: #ffe3c2; $cc-dark-warm-accent-mid: #ff8400; $cc-dark-warm-accent-low: #994f00; +// Grays from default stylesheet — needed for reference +$gray-100: #f8f9fa; +$gray-200: #e9ecef; +$gray-600: #6c757d; +$gray-800: #343a40; + +// Base color overrides +$blue: #5D70D2; +$green: #3FA12A; +$red: #E73C27; +$teal: #01A2A9; +$yellow: #EEAE00; + +// for determining when to show black or white text on top of color +$min-contrast-ratio: 3; + +// Theme Colors +$primary: $blue; +$secondary: $gray-600; +$success: $green; +$danger: $red; +$info: $teal; +$warning: $yellow; +$light: $gray-100; +$dark: $gray-800; + +$theme-colors: ( + "primary": $primary, + "success": $success, + "info": $info, + "warning": $warning, + "danger": $danger, + "dark": $dark, + "secondary": $secondary, + "light": $light, +); -////// Bootstrap 5 Color Overrides -$primary: #5c6ac5; -$success: $cc-att-pos-mid; -$info: $cc-light-cool-accent-mid; -$danger: $cc-att-neg-mid; - -$body-color: $cc-text; - -$link-color: $cc-brand-mid; -$link-hover-color: $cc-brand-low; - -$link-hover-color: $cc-brand-low; \ No newline at end of file +$body-color: $dark; +$link-color: darken($primary, 10); +$link-hover-color: darken($primary, 50); +$border-color: $gray-200; + + +// Form Sates + +$form-feedback-valid-color: $success; +$form-feedback-invalid-color: $danger; +$form-feedback-icon-valid-color: $form-feedback-valid-color; +$form-feedback-icon-valid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'><path fill='#{$form-feedback-icon-valid-color}' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>"); +$form-feedback-icon-invalid-color: $form-feedback-invalid-color; +$form-feedback-icon-invalid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='#{$form-feedback-icon-invalid-color}'><circle cx='6' cy='6' r='4.5'/><path stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/><circle cx='6' cy='8.2' r='.6' fill='#{$form-feedback-icon-invalid-color}' stroke='none'/></svg>"); + +$focus-ring-blur: 0; +$focus-ring-width: .25rem; +$focus-ring-opacity: .25; + +$input-btn-focus-blur: $focus-ring-blur; +$input-btn-focus-width: $focus-ring-width; +$input-focus-width: $input-btn-focus-width; +$input-btn-focus-color-opacity: $focus-ring-opacity; + +$form-validation-states: ( + "valid": ( + "color": var(--#{$prefix}form-valid-color), + "icon": $form-feedback-icon-valid, + "tooltip-color": #fff, + "tooltip-bg-color": var(--#{$prefix}success), + "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity), + "border-color": var(--#{$prefix}form-valid-border-color), + ), + "invalid": ( + "color": var(--#{$prefix}form-invalid-color), + "icon": $form-feedback-icon-invalid, + "tooltip-color": #fff, + "tooltip-bg-color": var(--#{$prefix}danger), + "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity), + "border-color": var(--#{$prefix}form-invalid-border-color), + ), + "validating": ( + "color": $secondary, + "icon": "", + "tooltip-color": #fff, + "tooltip-bg-color": var(--#{$prefix}dark), + "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}dark-rgb), $input-btn-focus-color-opacity), + "border-color": $dark, + ) +); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables_bootstrap3.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables_bootstrap3.scss index 025b18308b23c..5d8bb97d1bfcf 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables_bootstrap3.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables_bootstrap3.scss @@ -32,7 +32,7 @@ $navbar-height: 60px; $navbar-padding-vertical: calc(($navbar-height - 20px) / 2); $navbar-default-bg: $cc-bg; -$navbar-footer-height: 40px; +$navbar-footer-height: 38px; $navbar-footer-link-color: mix(darken(#ffffff, 10), $brand-primary, 90%); $navbar-footer-link-color-hover: $gray-lighter; $navbar-footer-button-color: #474747; diff --git a/corehq/apps/hqwebapp/static/registration/less/registration-main.less b/corehq/apps/hqwebapp/static/registration/less/registration-main.less index 4faf0c702008a..fd4e713b1cb49 100644 --- a/corehq/apps/hqwebapp/static/registration/less/registration-main.less +++ b/corehq/apps/hqwebapp/static/registration/less/registration-main.less @@ -25,20 +25,11 @@ margin-top: 0; } } -.reg-plan-container { - width: 100%; - max-width: @reg-form-width*1.25; - margin: 0 auto; - margin-top: 10vh; -} @media (min-width: @screen-md-min) { .reg-form-container { width: @reg-form-width; } - .reg-plan-container { - width: @reg-form-width * 1.25; - } } ul.form-step-progress li { @@ -265,10 +256,8 @@ p.sso-message-block { } .plan-container { - background-color: white; - border-radius: 10px; - border-top-left-radius: 15px; - border-top-right-radius: 15px; + background-color: @well-bg; + border-radius: 5px; padding-bottom: 35px; p { @@ -291,16 +280,9 @@ p.sso-message-block { ul.check-list { margin: 10px 20px 0; - .fa { - font-size: 1.5em; - vertical-align: middle; - margin-left: -10px; - margin-right: 7px; - } + li { padding-bottom: 5px; - padding-left: 10px; - line-height: 1.5em; &.not-available { color: darken(@well-bg, 30); @@ -309,19 +291,6 @@ p.sso-message-block { } } -.plan-body { - padding: 0 10px; -} - -.plan-icon { - float: left; - padding-top: 25px; - text-align: center; - vertical-align: middle; - width: 85px; - font-size: 3.25em; -} - .plan-price { font-weight: bold; } @@ -334,13 +303,11 @@ p.sso-message-block { .plan-header { margin: 0; + text-align: center; font-weight: 800; - border-top-left-radius: 10px; - border-top-right-radius: 10px; h2 { - margin-bottom: 0; - margin-left: 85px; + margin: 0; padding: 30px 0 25px; } @@ -353,25 +320,22 @@ p.sso-message-block { } .plan-container-professional { + border: 2px solid @cc-brand-mid; .plan-header { color: white; - background-color: @commcare-blue; - border-bottom: 2px solid @commcare-blue; + background-color: @cc-brand-mid; + border-bottom: 2px solid @cc-brand-mid; small { color: white; } } - .check-list .fa { - color: @commcare-blue; - } - a, a:link, a:visited { - color: darken(@commcare-blue, 10); + color: @cc-brand-mid; &.btn-primary { color: white; @@ -379,7 +343,7 @@ p.sso-message-block { } a:hover { - color: darken(@commcare-blue, 30); + color: darken(@cc-brand-mid, 10); } a.btn-primary { @@ -387,37 +351,29 @@ p.sso-message-block { } p.lead { - color: darken(@commcare-blue, 10); + color: @cc-brand-mid; } } .plan-container-community { + border: 2px solid @cc-dark-cool-accent-mid; padding-bottom: 63px; .plan-header { - color: white; - background-color: @cc-dark-cool-accent-mid; + color: @cc-dark-cool-accent-low; border-bottom: 2px solid @cc-dark-cool-accent-mid; small { - color: white; + color: @cc-dark-cool-accent-low; } } - .plan-icon { - padding-top: 22px; - } - .check-list { - height: 243px; - - .fa { - color: @cc-dark-cool-accent-mid; - } + height: 225px; } p.lead { - color: @cc-dark-cool-accent-mid; + color: @cc-dark-cool-accent-low; } a, diff --git a/corehq/apps/hqwebapp/tasks.py b/corehq/apps/hqwebapp/tasks.py index 356846b11d849..b219466d795a5 100644 --- a/corehq/apps/hqwebapp/tasks.py +++ b/corehq/apps/hqwebapp/tasks.py @@ -20,8 +20,7 @@ from corehq.util.bounced_email_manager import BouncedEmailManager from corehq.util.email_event_utils import get_bounced_system_emails from corehq.util.log import send_HTML_email -from corehq.util.metrics import metrics_gauge_task, metrics_track_errors -from corehq.util.metrics.const import MPM_MAX +from corehq.util.metrics import metrics_track_errors from corehq.util.models import TransientBounceEmail @@ -45,7 +44,8 @@ def mark_subevent_gateway_error(messaging_event_id, error, retrying=False): @task(serializer='pickle', queue="email_queue", bind=True, default_retry_delay=15 * 60, max_retries=10, acks_late=True) def send_mail_async(self, subject, message, recipient_list, from_email=settings.DEFAULT_FROM_EMAIL, - messaging_event_id=None, domain: str = None, use_domain_gateway=False): + messaging_event_id=None, filename: str = None, content=None, domain: str = None, + use_domain_gateway=False): """ Call with send_mail_async.delay(*args, **kwargs) - sends emails in the main celery queue - if sending fails, retry in 15 min @@ -80,8 +80,8 @@ def send_mail_async(self, subject, message, recipient_list, from_email=settings. headers = {} - if settings.RETURN_PATH_EMAIL: - headers['Return-Path'] = settings.RETURN_PATH_EMAIL + if configuration.return_path_email: + headers['Return-Path'] = configuration.return_path_email if messaging_event_id is not None: headers[COMMCARE_MESSAGE_ID_HEADER] = messaging_event_id @@ -97,6 +97,8 @@ def send_mail_async(self, subject, message, recipient_list, from_email=settings. headers=headers, connection=configuration.connection ) + if filename and content: + message.attach(filename=filename, content=content) return message.send() except SMTPDataError as e: # If the SES configuration has not been properly set up, resend the message @@ -137,7 +139,8 @@ def send_html_email_async(self, subject, recipient, html_content, file_attachments=None, bcc=None, smtp_exception_skip_list=None, messaging_event_id=None, - domain=None): + domain=None, + use_domain_gateway=False): """ Call with send_HTML_email_async.delay(*args, **kwargs) - sends emails in the main celery queue - if sending fails, retry in 15 min @@ -155,7 +158,8 @@ def send_html_email_async(self, subject, recipient, html_content, bcc=bcc, smtp_exception_skip_list=smtp_exception_skip_list, messaging_event_id=messaging_event_id, - domain=domain + domain=domain, + use_domain_gateway=use_domain_gateway ) except Exception as e: recipient = list(recipient) if not isinstance(recipient, str) else [recipient] @@ -245,15 +249,6 @@ def clean_expired_transient_emails(): ) -def get_maintenance_alert_active(): - from corehq.apps.hqwebapp.models import MaintenanceAlert - return 1 if MaintenanceAlert.get_active_alerts() else 0 - - -metrics_gauge_task('commcare.maintenance_alerts.active', get_maintenance_alert_active, - run_every=crontab(minute=1), multiprocess_mode=MPM_MAX) - - @periodic_task(run_every=crontab(minute=0, hour=4)) def clear_expired_oauth_tokens(): # https://django-oauth-toolkit.readthedocs.io/en/latest/management_commands.html#cleartokens diff --git a/corehq/apps/hqwebapp/templates/500.html b/corehq/apps/hqwebapp/templates/500.html index 4d5c6a9c8551c..5e8b8bf888184 100644 --- a/corehq/apps/hqwebapp/templates/500.html +++ b/corehq/apps/hqwebapp/templates/500.html @@ -27,7 +27,7 @@ <h4 class="card-title pb-1 mb-3 border-bottom"> {% endblocktrans %} </p> <p class="mb-0"> - <button id="refresh" type="button" class="btn btn-outline-secondary">{% trans "Refresh Page" %}</button> + <button id="refresh" type="button" class="btn btn-outline-primary">{% trans "Refresh Page" %}</button> </p> </div> </div> @@ -44,7 +44,7 @@ <h4 class="card-title pb-1 mb-3 border-bottom"> {% endblocktrans %} </p> <p class="mb-0"> - <a href="https://status.commcarehq.org/" class="btn btn-outline-secondary">{% trans "View Status Page" %}</a> + <a href="https://status.commcarehq.org/" class="btn btn-outline-primary">{% trans "View Status Page" %}</a> </p> </div> </div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/base.html b/corehq/apps/hqwebapp/templates/hqwebapp/base.html index 712e00e46895e..db4abdd1d00c8 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/base.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/base.html @@ -136,6 +136,10 @@ {% endcompress %} {% endif %} + <script> + window.USE_BOOTSTRAP5 = {{ use_bootstrap5|BOOL }}; + </script> + {% if not requirejs_main %} {% javascript_libraries use_bootstrap5=use_bootstrap5 underscore=True jquery_ui=request.use_jquery_ui ko=True hq=True analytics=True %} {% endif %} @@ -409,7 +413,11 @@ {% if request.use_ko_validation and not requirejs_main %} <script src="{% static 'knockout-validation/dist/knockout.validation.min.js' %}"></script> - <script src="{% static 'hqwebapp/js/validators.ko.js' %}"></script> + {% if use_bootstrap5 %} + <script src="{% static 'hqwebapp/js/bootstrap5/validators.ko.js' %}"></script> + {% else %} + <script src="{% static 'hqwebapp/js/bootstrap3/validators.ko.js' %}"></script> + {% endif %} {% endif %} {% if is_demo_visible %} diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/checkbox_widget.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/checkbox_widget.html new file mode 100644 index 0000000000000..703f644be9ca7 --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/checkbox_widget.html @@ -0,0 +1 @@ +<label class="checkbox"><input {{ attrs }} /> {{ inline_label }}</label> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/form_actions.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/form_actions.html new file mode 100644 index 0000000000000..00f51a85d9330 --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap3/form_actions.html @@ -0,0 +1,5 @@ +<div {% if formactions.attrs %} {{ formactions.flat_attrs|safe }}{% endif %} class="form-actions"> + <div class="{{ offsets }} controls {{ field_class }}"> + {{ fields_output }} + </div> +</div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/checkbox_widget.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/checkbox_widget.html new file mode 100644 index 0000000000000..a2a7797116663 --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/checkbox_widget.html @@ -0,0 +1,4 @@ +<div class="form-check"> + <input {{ attrs }} /> + <label class="form-check-label" for="{{ input_id }}">{{ inline_label }}</label> +</div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/form_actions.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/form_actions.html new file mode 100644 index 0000000000000..fe52406f6134d --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/bootstrap5/form_actions.html @@ -0,0 +1,5 @@ +<div class="form-actions{% if 'form-horizontal' in form_class %} row{% endif %}" {% if formactions.attrs %} {{ formactions.flat_attrs|safe }}{% endif %}> + <div class="{{ offsets }} {{ field_class }}"> + {{ fields_output }} + </div> +</div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/checkbox_widget.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/checkbox_widget.html new file mode 100644 index 0000000000000..a89f8b9cec96c --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/checkbox_widget.html @@ -0,0 +1 @@ +{% if use_bootstrap5 %}{% include 'hqwebapp/crispy/bootstrap5/checkbox_widget.html' %}{% else %}{% include 'hqwebapp/crispy/bootstrap3/checkbox_widget.html' %}{% endif %} diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/form_actions.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/form_actions.html index 00f51a85d9330..4c6d55482c448 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/form_actions.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/form_actions.html @@ -1,5 +1 @@ -<div {% if formactions.attrs %} {{ formactions.flat_attrs|safe }}{% endif %} class="form-actions"> - <div class="{{ offsets }} controls {{ field_class }}"> - {{ fields_output }} - </div> -</div> +{% if use_bootstrap5 %}{% include 'hqwebapp/crispy/bootstrap5/form_actions.html' %}{% else %}{% include 'hqwebapp/crispy/bootstrap3/form_actions.html' %}{% endif %} diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/crispy/switch_widget.html b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/switch_widget.html new file mode 100644 index 0000000000000..cb2ff733594ae --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/crispy/switch_widget.html @@ -0,0 +1,4 @@ +<div class="form-check form-switch"> + <input {{ attrs }} /> + <label class="form-check-label" for="{{ input_id }}">{{ inline_label }}</label> +</div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/iframe_domain_login.html b/corehq/apps/hqwebapp/templates/hqwebapp/iframe_domain_login.html index b1198f55ccdea..b2ac7083f09d6 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/iframe_domain_login.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/iframe_domain_login.html @@ -9,7 +9,8 @@ {% block background_content %} <div class="bg-container"> - <div class="bg-full-cover-fixed bg-registration"></div> + <div class="bg-full-cover-fixed bg-registration b-lazy" + data-src="{% static 'hqwebapp/images/molly.jpg' %}"></div> <div class="bg-overlay"></div> </div> {% endblock %} diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html b/corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html index d9bfe7cb4aecc..10fe061244f8d 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html @@ -103,6 +103,9 @@ <h3 class="card-title pb-1 mb-3 border-bottom"> <th> {% trans "Created" %} </th> + <th> + {% trans "Added By" %} + </th> <th class="col-sm-3"> {% trans "Message" %} </th> @@ -124,6 +127,7 @@ <h3 class="card-title pb-1 mb-3 border-bottom"> data-bind="foreach: alerts"> <tr> <td data-bind="text: created"></td> + <td data-bind="text: created_by_user"></td> <td> <div class="alert alert-warning" data-bind="html: html"></div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html index 76ecf2a2637f6..6de6447dc1ac0 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html @@ -21,7 +21,7 @@ <li> <a href="#" data-bind="click: previousPage"> - <span>«</span> + <span>{% trans 'Previous' %}</span> </a> </li> <!-- ko foreach: pagesShown --> @@ -37,7 +37,7 @@ <!-- /ko --> <li> <a href="#" data-bind="click: nextPage"> - <span>»</span> + <span>{% trans 'Next' %}</span> </a> </li> </ul> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/maintenance_alerts.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/maintenance_alerts.html index ec2432c837f52..50a6237ef77d0 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/maintenance_alerts.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/maintenance_alerts.html @@ -1,8 +1,8 @@ {% load hq_shared_tags %} -{% maintenance_alerts request as alerts %} +{% commcarehq_alerts request as alerts %} {% for alert in alerts %} - <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}"> + <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}" data-created-by-domain="{{ alert.created_by_domain|BOOL }}"> <button class="close" data-dismiss="alert" aria-label="close">×</button> {{ alert.html }} </div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_feedback.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_feedback.html index d25b7a7b94421..04c254fa37d29 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_feedback.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_feedback.html @@ -9,33 +9,30 @@ <i class="fa fa-thumbs-up"></i> </a> - <div class="modal fade" + <div class="modal fade modal-lg" id="modal-feedback-form-widget" - role="dialog"> + tabindex="-1" + aria-labelledby="feedbackFormModalTitle" + aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> - <button type="button" class="btn-close" data-bs-dismiss="modal"> - <span aria-hidden="true">×</span> - <span class="sr-only"> - {% trans "Close" %} - </span> - </button> - <h4 class="modal-title"> + <h4 class="modal-title" id="feedbackFormModalTitle"> {% blocktrans %} We would love to get your feedback {% endblocktrans %} </h4> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> </div> - <div class="modal-body form" + <div class="modal-body" data-bind="visible: !showSuccess()"> - <div class="form-group"> - <label class="control-label"> + <div class="mb-3"> + <label class="form-label"> {% blocktrans %} How would you rate this feature? {% endblocktrans %} </label> - <div class="row feedback-smiles"> + <div class="row mb-3 feedback-smiles"> <div class="col-sm-4"> <button type="button" data-bind="click: rateBad, css: { selected: rating() === 1 }" @@ -62,8 +59,8 @@ <h4 class="modal-title"> </div> </div> </div> - <div class="form-group"> - <label class="control-label"> + <div class="mb-3"> + <label class="form-label"> {% trans "Any other comments?" %} </label> <textarea data-bind="value: additionalFeedback" diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_inline_edit.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_inline_edit.html index 3173cbfb73ec7..6a164a1cae769 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_inline_edit.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_inline_edit.html @@ -1,45 +1,49 @@ <script type="text/html" id="ko-inline-edit-template"> - <div class="ko-inline-edit inline" data-bind="css: {'has-error': hasError()}"> - <div class="read-only" data-bind="visible: !isEditing(), click: edit, attr: readOnlyAttrs, style: {cursor: disallow_edit ? 'default' : 'pointer'}"> - <span data-bind="visible: isSaving()" class="float-end"> - <i class="fa fa-spin fa-spinner"></i> - </span> + <div class="ko-inline-edit inline" + data-bind="css: {'has-error': hasError()}"> + <div class="read-only" + data-bind="visible: !isEditing(), click: edit, attr: readOnlyAttrs, style: {cursor: disallow_edit ? 'default' : 'pointer'}"> + <span data-bind="visible: isSaving()" class="float-end"> + <i class="fa fa-spin fa-spinner"></i> + </span> <!-- ko if: iconClass --> - <span class="prefixed-icon" data-bind="css: containerClass"> - <i data-bind="css: iconClass"></i> - </span> + <span class="prefixed-icon" data-bind="css: containerClass"> + <i data-bind="css: iconClass"></i> + </span> <!-- /ko --> <!-- ko if: lang --> - <span class="btn btn-sm btn-info btn-langcode-preprocessed" data-bind="text: lang, visible: !value()"></span> + <span class="btn btn-sm btn-info btn-langcode-preprocessed" data-bind="text: lang, visible: !value()"></span> <!-- /ko --> <span data-bind="text: value, visible: value, attr: {'class': containerClass + ' ' + readOnlyClass + ' text'}"></span> - <span class="placeholder text-body-secondary" data-bind="text: placeholder, css: containerClass, visible: !value()"></span> - <span class="inline-edit-icon" data-bind="css: containerClass, visible: !disallow_edit"><i class="fa fa-pencil"></i></span> + <span class="text-secondary" + data-bind="text: placeholder, css: containerClass, visible: !value()"></span> + <span class="inline-edit-icon" + data-bind="css: containerClass, visible: !disallow_edit"> + <i class="fa fa-pencil"></i> + </span> </div> - <div class="read-write form-inline" data-bind="visible: isEditing(), css: containerClass"> - <div class="form-group langcode-container" data-bind="css: {'has-lang': lang}"> - <!-- ko if: nodeName === "textarea" --> - <textarea class="form-control vertical-resize" data-bind=" - attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, - value: value, - hasFocus: isEditing(), - "></textarea> - <!-- /ko --> - <!-- ko if: nodeName === "input" --> - <input type="text" class="form-control" data-bind=" - attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, - value: value, - hasFocus: isEditing(), - " /> - <!-- /ko --> + <div class="read-write" + data-bind="visible: isEditing(), css: containerClass"> + <div class="input-group langcode-container" + data-bind="css: {'has-lang': lang}"> <!-- ko if: lang --> - <span class="btn btn-sm btn-info btn-langcode-preprocessed langcode-input float-end" + <span class="btn-langcode-preprocessed langcode-input input-group-text text-bg-info" data-bind="text: lang, visible: !value()" ></span> <!-- /ko --> - </div> - <div class="help-block" data-bind="text: errorMessage, visible: hasError()"></div> - <div class="form-group"> + <!-- ko if: nodeName === "textarea" --> + <textarea class="form-control vertical-resize" + data-bind="attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, + value: value, + hasFocus: isEditing(),"></textarea> + <!-- /ko --> + <!-- ko if: nodeName === "input" --> + <input type="text" + class="form-control" + data-bind="attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, + value: value, + hasFocus: isEditing()," /> + <!-- /ko --> <button class="btn btn-primary" data-bind="click: save, hasFocus: saveHasFocus, visible: !isSaving()"> <i class="fa fa-check"></i> </button> @@ -47,6 +51,7 @@ <i class="fa fa-remove"></i> </button> </div> + <div class="help-block" data-bind="text: errorMessage, visible: hasError()"></div> </div> </div><!-- ko runOnInit: afterRenderFunc --><!-- /ko --> </script> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html index e6cf54fd20206..981125e9a7b0b 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html @@ -1,46 +1,53 @@ {% load i18n %} <!-- used by corehq/apps/hqwebapp/static/hqwebapp/js/components/pagination.js --> <script type="text/html" id="ko-pagination-template"> - <div data-bind="css: { row: !inlinePageListOnly }"> - <div class="col-md-5" - data-bind="visible: !inlinePageListOnly, + <div class="py-3" data-bind="css: { 'd-flex justify-content-between': !inlinePageListOnly }"> + <div data-bind="visible: !inlinePageListOnly, if: !inlinePageListOnly"> - <div class="form-inline pagination-text"> - <span data-bind="text: itemsText"></span> - <span> - <select class="form-control" - data-bind="value: perPage, - options: [5, 25, 50, 100], - optionsText: perPageOptionsText"> - </select> - </span> + <div class="input-group"> + <div class="input-group-text"><!-- ko text: itemsText --><!-- /ko --></div> + <select class="form-select" + data-bind="value: perPage, + options: [5, 25, 50, 100], + optionsText: perPageOptionsText"> + </select> </div> </div> - <div data-bind="css: { 'col-md-7 text-end': !inlinePageListOnly }"> - <ul class="pagination"> - <li> - <a href="#" - data-bind="click: previousPage"> - <span>«</span> - </a> - </li> - <!-- ko foreach: pagesShown --> - <li class="text-center" - data-bind="css: { active: $data == $parent.currentPage() }"> - <a href="#" - data-bind="click: $parent.goToPage"> - <i class="fa fa-spin fa-spinner" - data-bind="visible: $parent.showSpinner() && $data == $parent.currentPage()"></i> - <span data-bind="text: $data, visible: !$parent.showSpinner() || $data != $parent.currentPage()"></span> - </a> - </li> - <!-- /ko --> - <li> - <a href="#" data-bind="click: nextPage"> - <span>»</span> - </a> - </li> - </ul> + + <div> + <nav aria-label="Page navigation example"> + <ul class="pagination"> + <li class="page-item"> + <a href="#" + class="page-link" + aria-label="Previous" + data-bind="click: previousPage"> + <span aria-hidden="true">{% trans 'Previous' %}</span> + </a> + </li> + <!-- ko foreach: pagesShown --> + <li class="page-item" + data-bind="css: { active: $data == $parent.currentPage() }, + attr: { 'aria-current': ($data == $parent.currentPage()) ? 'page': undefined } "> + <a href="#" + class="page-link" + data-bind="click: $parent.goToPage"> + <i class="fa fa-spin fa-spinner" + data-bind="visible: $parent.showSpinner() && $data == $parent.currentPage()"></i> + <span data-bind="text: $data, visible: !$parent.showSpinner() || $data != $parent.currentPage()"></span> + </a> + </li> + <!-- /ko --> + <li class="page-item"> + <a href="#" + class="page-link" + aria-label="Next" + data-bind="click: nextPage"> + <span aria-hidden="true">{% trans 'Next' %}</span> + </a> + </li> + </ul> + </nav> </div> </div> </script> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_search_box.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_search_box.html index c4d6590c5d60f..437509db737e2 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_search_box.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_search_box.html @@ -3,14 +3,12 @@ <form class="input-group" data-bind="submit: clickAction"> <input type="text" class="form-control" data-bind="value: value, valueUpdate: 'afterkeydown', attr: {placeholder: placeholder}, event: {keypress: keypressAction}" /> - <span class="input-group-btn"> - <button type="button" class="btn btn-outline-primary" data-bind="click: clickAction, visible: !immediate"> - <i class="fa fa-search"></i> - </button> - <button class="btn btn-outline-primary" type="button" data-bind="click: clearQuery"> - <i class="fa fa-times"></i> - </button> - </span> + <button type="button" class="btn btn-outline-primary" data-bind="click: clickAction, visible: !immediate"> + <i class="fa fa-search"></i> + </button> + <button class="btn btn-outline-primary" type="button" data-bind="click: clearQuery"> + <i class="fa fa-times"></i> + </button> </form> </div> </script> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_select_toggle.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_select_toggle.html index b8e09aa907abe..70d4bf6dbed16 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_select_toggle.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_select_toggle.html @@ -1,6 +1,6 @@ <script type="text/html" id="ko-select-toggle"> <div class="ko-select-toggle"> - <select class="hide" data-bind="foreach: options(), attr: htmlAttrs, value: value"> + <select class="d-none" data-bind="foreach: options(), attr: htmlAttrs, value: value"> <option data-bind="value: $data.id, text: $data.text, attr: {selected: $data.selected}"></option> @@ -16,4 +16,5 @@ <!-- ko text: $data.text --><!-- /ko --> </button> </div> + </div> </script> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/maintenance_alerts.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/maintenance_alerts.html index 8401ae89b0153..3f00b12569dfc 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/maintenance_alerts.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/maintenance_alerts.html @@ -1,8 +1,8 @@ {% load hq_shared_tags %} -{% maintenance_alerts request as alerts %} +{% commcarehq_alerts request as alerts %} {% for alert in alerts %} - <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}"> + <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}" data-created-by-domain="{{ alert.created_by_domain|BOOL }}"> <button class="btn-close" data-bs-dismiss="alert" aria-label="btn-close">×</button> {{ alert.html }} </div> diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/partials/requirejs.html b/corehq/apps/hqwebapp/templates/hqwebapp/partials/requirejs.html index 8d38904925734..a8e30ef6bf74e 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/partials/requirejs.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/partials/requirejs.html @@ -5,7 +5,6 @@ // hqModules.js uses `typeof define` and `define.amd` to determine whether or not to use RequireJS, but // this fails for form designer, which currently uses RequireJS for vellum but not for the surrounding page. window.USE_REQUIREJS = {{ requirejs_main|BOOL }}; - window.USE_BOOTSTRAP5 = {{ use_bootstrap5|BOOL }}; </script> {% if requirejs_main %} diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/svg/dimagi_logo.html b/corehq/apps/hqwebapp/templates/hqwebapp/svg/dimagi_logo.html index 6f6757591005f..4ab812db23b95 100644 --- a/corehq/apps/hqwebapp/templates/hqwebapp/svg/dimagi_logo.html +++ b/corehq/apps/hqwebapp/templates/hqwebapp/svg/dimagi_logo.html @@ -1,3 +1,51 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 227.97 72.8"> - <path stroke="transparent" fill="#ffffff" d="M38.13,0h-2.9c-.7,0-1.26,.55-1.32,1.26V20.04c0,.55-.32,.85-.79,.85-.15,0-.38-.09-.62-.32-3.44-2.96-7.92-4.69-12.76-4.69C8.86,15.88,0,24.73,0,35.52s8.86,19.72,19.74,19.72c4.93,0,9.39-1.79,12.93-4.86,.15-.09,.32-.15,.47-.15,.47,0,.79,.32,.79,.85v2.35c0,.7,.62,1.32,1.32,1.32h2.9c.7,0,1.26-.62,1.26-1.32V1.32c-.02-.7-.58-1.32-1.28-1.32ZM19.74,49.76c-7.83,0-14.25-6.42-14.25-14.24s6.42-14.16,14.25-14.16,14.17,6.33,14.17,14.16-6.34,14.24-14.17,14.24ZM54.2,17.67V53.36c0,.7-.62,1.32-1.32,1.32h-2.9c-.7,0-1.32-.62-1.32-1.32V17.67c0-.79,.62-1.32,1.32-1.32h2.9c.68,0,1.32,.53,1.32,1.32Zm112.68-1.32h-2.82c-.7,0-1.32,.62-1.32,1.32v2.26c0,.53-.32,.85-.7,.85-.23,0-.47-.09-.7-.23-3.44-2.96-7.92-4.78-12.76-4.78-10.88,0-19.74,8.85-19.74,19.72s8.85,19.72,19.74,19.72c4.86,0,10.03-2.03,12.76-4.78,.23-.23,.47-.32,.62-.32,.47,0,.79,.47,.79,.94v2.26c0,.7,.62,1.32,1.32,1.32h2.9c.7,0,1.26-.47,1.26-1.17V17.55c-.02-.66-.64-1.19-1.34-1.19Zm-18.33,33.32c-7.83,0-14.25-6.33-14.25-14.16s6.42-14.16,14.25-14.16,14.17,6.33,14.17,14.16-6.34,14.16-14.17,14.16Zm63.67-33.32h-2.82c-.7,0-1.32,.55-1.32,1.32v2.26c0,.55-.38,.85-.79,.85-.23,0-.47-.09-.62-.32-3.43-2.9-7.92-4.69-12.85-4.69-10.8,0-19.65,8.85-19.65,19.72s8.86,19.63,19.65,19.63c4.93,0,9.39-1.73,12.85-4.69,.15-.23,.38-.32,.62-.32,.38,0,.79,.32,.79,.85v7.91c0,6.65-3.76,8.21-9.71,8.38-.7,0-1.32,.62-1.32,1.41v2.81c0,.7,.62,1.32,1.32,1.32,9.09-.32,15.19-4.07,15.19-13.92V17.48c-.09-.66-.64-1.13-1.34-1.13Zm-18.39,33.32c-7.83,0-14.17-6.33-14.17-14.16s6.34-14.24,14.17-14.24,14.25,6.42,14.25,14.24-6.42,14.16-14.25,14.16ZM227.97,2.41v2.79c0,.79-.62,1.32-1.32,1.32h-2.9c-.7,0-1.32-.55-1.32-1.32V2.41c0-.7,.62-1.32,1.32-1.32h2.9c.68-.02,1.32,.62,1.32,1.32Zm0,15.26V53.36c0,.7-.62,1.32-1.32,1.32h-2.9c-.7,0-1.32-.62-1.32-1.32V17.67c0-.79,.62-1.32,1.32-1.32h2.9c.68,0,1.32,.53,1.32,1.32Zm-122.65-1.51c-3.24,0-8.43,1.49-13.08,4.84-4.22-3.09-8.88-4.84-12.31-4.84-9.35,0-16.94,7.93-16.94,17.67v19.38c0,.81,.66,1.45,1.45,1.45h2.88c.81,0,1.45-.66,1.45-1.45v-19.38c0-5.73,4.48-11.87,11.16-11.87,1.92,0,4.95,1.07,7.94,3.01-2.94,3.37-5.46,8.17-5.46,14.73,0,4.31,.96,7.99,2.8,10.66,1.86,2.71,4.57,4.26,7.43,4.26,4.93,0,10.24-4.67,10.24-14.92,0-5.76-2.56-10.79-6.15-14.71,3.24-2.07,6.61-3.05,8.62-3.05,6.68,0,11.16,6.14,11.16,11.87v19.38c0,.81,.66,1.45,1.45,1.45h2.88c.81,0,1.45-.66,1.45-1.45v-19.38c-.02-9.72-7.62-17.65-16.96-17.65Zm-8.26,23.54c0,5.93-2.28,9.12-4.44,9.12s-4.44-3.2-4.44-9.12c0-4.58,1.73-8.23,4.16-10.98,2.71,2.9,4.72,6.63,4.72,10.98ZM48.59,5.2V2.41c0-.7,.62-1.32,1.32-1.32h2.9c.7,0,1.32,.62,1.32,1.32v2.79c0,.79-.62,1.32-1.32,1.32h-2.9c-.7,0-1.32-.55-1.32-1.32Z"></path> +<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"> + <g> + <path stroke="transparent" fill="#ffffff" d="M33.522,53.736h-6.058v-4.579h-0.134c-2.356,3.433-6.8,5.386-11.109,5.386C6.328,54.544,0,47.205,0,37.78 + c0-9.561,6.396-16.764,16.222-16.764c4.581,0,8.957,2.088,11.109,5.386h0.134V2.839h6.058V53.736z M27.465,37.78 + c0-6.463-4.039-11.11-10.502-11.11S6.461,31.317,6.461,37.78c0,6.464,4.039,11.108,10.502,11.108S27.465,44.244,27.465,37.78z"></path> + </g> + <g> + <path stroke="transparent" fill="#ffffff" d="M67.813,21.824h5.655v4.982h0.134c0.607-1.884,4.509-5.79,10.233-5.79c4.711,0,7.941,2.021,9.963,5.925 + c2.088-3.904,6.26-5.925,10.099-5.925c9.828,0,12.118,7.002,12.118,14.138v18.582h-6.059V35.962c0-4.847-1.012-9.292-6.87-9.292 + c-5.854,0-8.145,3.973-8.145,9.629v17.437h-6.059V37.039c0-6.059-0.874-10.369-6.733-10.369c-4.376,0-8.277,3.3-8.277,10.504 + v16.562h-6.06V21.824z"></path> + </g> + <g> + <path stroke="transparent" fill="#ffffff" d="M122.792,25.73c3.365-3.165,8.212-4.714,12.655-4.714c9.426,0,13.332,5.117,13.332,10.637v16.293 + c0,2.221,0.066,4.107,0.268,5.79h-5.385c-0.136-1.618-0.202-3.231-0.202-4.848h-0.135c-2.694,4.106-6.33,5.655-11.176,5.655 + c-5.925,0-11.039-3.367-11.039-9.56c0-8.213,7.874-11.041,17.571-11.041h4.443v-1.348c0-3.298-2.425-6.731-7.608-6.731 + c-4.646,0-6.869,1.951-9.089,3.635L122.792,25.73z M139.892,38.386c-5.723,0-12.723,1.01-12.723,6.127 + c0,3.635,2.693,5.182,6.866,5.182c6.733,0,9.09-4.98,9.09-9.29v-2.02H139.892z"></path> + </g> + <g> + <path stroke="transparent" fill="#ffffff" d="M187.388,53.465c0,9.627-7,16.427-17.437,16.427c-6.061,0-11.107-1.547-15.617-5.722l4.105-5.117 + c3.165,3.368,6.732,5.184,11.376,5.184c9.022,0,11.513-5.654,11.513-11.041v-4.711h-0.204c-2.286,3.835-6.661,5.654-11.037,5.654 + c-9.359,0-16.294-7.07-16.224-16.359c0-9.425,6.325-16.764,16.224-16.764c4.309,0,8.751,1.953,11.107,5.386h0.134v-4.578h6.06 + V53.465z M160.324,37.78c0,6.462,4.041,10.705,10.503,10.705s10.501-4.243,10.501-10.705c0-6.463-4.039-11.11-10.501-11.11 + S160.324,31.317,160.324,37.78z"></path> + </g> + <g> + <path stroke="transparent" fill="#ffffff" d="M195.558,5.935c2.423,0,4.442,2.02,4.442,4.445c0,2.423-2.02,4.442-4.442,4.442s-4.442-2.02-4.442-4.442 + C191.115,7.955,193.135,5.935,195.558,5.935z M192.53,21.824h6.059v31.913h-6.059V21.824z"></path> + </g> + <g> + <g> + <path stroke="transparent" fill="#ffffff" d="M50.548,17.7c-4.171,0-7.564-3.395-7.564-7.565c0-4.172,3.394-7.565,7.564-7.565c4.172,0,7.565,3.395,7.565,7.565 + S54.72,17.7,50.548,17.7z"></path> + </g> + </g> + <path stroke="transparent" fill="#ffffff" d="M137.048,60.53c-2.605,0.359-16.955,0.997-16.955,0.997L87.846,62.52l-2.99,7.192l-3.323,8.222l-3.657,9.098l-3.331,9.095 + c-0.189,0.516-0.569,0.904-1.031,1.119c-0.463,0.213-1.004,0.252-1.521,0.061c-0.295-0.107-0.546-0.277-0.75-0.488 + c-0.205-0.213-0.356-0.467-0.449-0.745l-0.011-0.034l-0.014-0.037l-5.815-17.599L59.136,60.8L53.259,43.22l-2.472-7.383 + l-0.826,2.751l-1.297,4.322l-1.338,4.311l-1.336,4.312l-1.377,4.146l-1.374,4.145l-0.024,0.08l-0.024,0.078 + c-0.207,0.643-0.612,1.323-1.13,1.68c-0.516,0.36-1.419,0.53-1.782,0.528l-2.431-0.005c0,0-8.778-0.415-10.401-0.511 + c-1.619-0.097-6.788-0.51-8.104-0.597c-1.62-0.107-6.244-0.561-7.86-0.769c-1.741-0.224-8.403-1.269-10.093-1.521 + c-0.089-0.011-0.167-0.059-0.219-0.126c-0.053-0.066-0.078-0.153-0.065-0.243c0.01-0.076,0.044-0.142,0.094-0.192 + c0.053-0.05,0.12-0.083,0.19-0.093c1.621-0.211,14.832-0.772,18.82-0.982c1.621-0.105,6.788-0.343,8.449-0.387l6.663-0.223h1.662 + l0.906-0.146l0,0l0.621-2.299l1.199-4.428l1.234-4.343l1.234-4.341l1.273-4.33l1.277-4.33l1.316-4.317c0,0,1.024-3.592,1.343-4.404 + c0.321-0.811,0.867-1.562,1.615-1.962c0.47-0.25,1-0.387,1.545-0.391c0.326-0.001,0.657,0.046,0.986,0.146 + c0.552,0.168,0.923,0.369,1.3,0.761c0.378,0.391,0.767,1.051,0.945,1.488c0.365,0.913,5.255,17.801,5.255,17.801l5.139,17.801 + l5.173,17.783l3.562,12.092l0.062,0.216l0.031-0.073l1.439-3.561l3.433-8.982l6.751-18.396c0,0,0.207-0.611,0.396-0.809 + c0.19-0.198,1.068-0.645,1.731-0.645c0.667,0,34.824,0.098,35.491,0.098c8.312,0,27.436,0.567,28.1,1.292 + c0.073,0.08,0.073,0.373-0.912,0.565C146.104,59.476,137.811,60.424,137.048,60.53z"></path> </svg> diff --git a/corehq/apps/hqwebapp/templatetags/hq_shared_tags.py b/corehq/apps/hqwebapp/templatetags/hq_shared_tags.py index c9c9237388721..3ce0aee6f92e7 100644 --- a/corehq/apps/hqwebapp/templatetags/hq_shared_tags.py +++ b/corehq/apps/hqwebapp/templatetags/hq_shared_tags.py @@ -24,7 +24,7 @@ from corehq import privileges from corehq.apps.hqwebapp.exceptions import AlreadyRenderedException, TemplateTagJSONException -from corehq.apps.hqwebapp.models import MaintenanceAlert +from corehq.apps.hqwebapp.models import Alert from corehq.motech.utils import pformat_json register = template.Library() @@ -237,19 +237,28 @@ def can_use_restore_as(request): @register.simple_tag def css_label_class(): - from corehq.apps.hqwebapp.crispy import CSS_LABEL_CLASS + from corehq.apps.hqwebapp.crispy import CSS_LABEL_CLASS, CSS_LABEL_CLASS_BOOTSTRAP5 + from corehq.apps.hqwebapp.utils.bootstrap import get_bootstrap_version, BOOTSTRAP_5 + if get_bootstrap_version() == BOOTSTRAP_5: + return CSS_LABEL_CLASS_BOOTSTRAP5 return CSS_LABEL_CLASS @register.simple_tag def css_field_class(): - from corehq.apps.hqwebapp.crispy import CSS_FIELD_CLASS + from corehq.apps.hqwebapp.crispy import CSS_FIELD_CLASS, CSS_FIELD_CLASS_BOOTSTRAP5 + from corehq.apps.hqwebapp.utils.bootstrap import get_bootstrap_version, BOOTSTRAP_5 + if get_bootstrap_version() == BOOTSTRAP_5: + return CSS_FIELD_CLASS_BOOTSTRAP5 return CSS_FIELD_CLASS @register.simple_tag def css_action_class(): - from corehq.apps.hqwebapp.crispy import CSS_ACTION_CLASS + from corehq.apps.hqwebapp.crispy import CSS_ACTION_CLASS, get_form_action_class + from corehq.apps.hqwebapp.utils.bootstrap import get_bootstrap_version, BOOTSTRAP_5 + if get_bootstrap_version() == BOOTSTRAP_5: + return get_form_action_class() return CSS_ACTION_CLASS @@ -389,13 +398,19 @@ def chevron(value): @register.simple_tag -def maintenance_alerts(request): - active_alerts = MaintenanceAlert.get_active_alerts() - domain = getattr(request, 'domain', None) +def commcarehq_alerts(request): + from corehq.apps.domain.auth import user_can_access_domain_specific_pages + + active_alerts = Alert.get_active_alerts() + load_alerts_for_domain = None + + if hasattr(request, 'domain') and user_can_access_domain_specific_pages(request): + load_alerts_for_domain = request.domain + return [ alert for alert in active_alerts if (not alert.domains - or domain in alert.domains) + or load_alerts_for_domain in alert.domains) ] diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/checkbox_widget.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/checkbox_widget.html.diff.txt new file mode 100644 index 0000000000000..c7fb1fcbcc8a7 --- /dev/null +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/checkbox_widget.html.diff.txt @@ -0,0 +1,8 @@ +--- ++++ +@@ -1 +1,4 @@ +-<label class="checkbox"><input {{ attrs }} /> {{ inline_label }}</label> ++<div class="form-check"> ++ <input {{ attrs }} /> ++ <label class="form-check-label" for="{{ input_id }}">{{ inline_label }}</label> ++</div> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/form_actions.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/form_actions.html.diff.txt new file mode 100644 index 0000000000000..ffe8bfd29c8e1 --- /dev/null +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/crispy/form_actions.html.diff.txt @@ -0,0 +1,12 @@ +--- ++++ +@@ -1,5 +1,5 @@ +-<div {% if formactions.attrs %} {{ formactions.flat_attrs|safe }}{% endif %} class="form-actions"> +- <div class="{{ offsets }} controls {{ field_class }}"> +- {{ fields_output }} +- </div> ++<div class="form-actions{% if 'form-horizontal' in form_class %} row{% endif %}" {% if formactions.attrs %} {{ formactions.flat_attrs|safe }}{% endif %}> ++ <div class="{{ offsets }} {{ field_class }}"> ++ {{ fields_output }} ++ </div> + </div> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_feedback.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_feedback.html.diff.txt index 3adfd4e672e0f..6abee2f5a19d1 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_feedback.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_feedback.html.diff.txt @@ -1,6 +1,6 @@ --- +++ -@@ -1,10 +1,10 @@ +@@ -1,42 +1,39 @@ {% load i18n %} {% load hq_shared_tags %} -<!-- used by corehq/apps/hqwebapp/static/hqwebapp/js/components/bootstrap3/feedback.js --> @@ -13,25 +13,50 @@ {% trans "Give Feedback" %} <i class="fa fa-thumbs-up"></i> </a> -@@ -15,7 +15,7 @@ + +- <div class="modal fade" ++ <div class="modal fade modal-lg" + id="modal-feedback-form-widget" +- role="dialog"> ++ tabindex="-1" ++ aria-labelledby="feedbackFormModalTitle" ++ aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal"> -+ <button type="button" class="btn-close" data-bs-dismiss="modal"> - <span aria-hidden="true">×</span> - <span class="sr-only"> - {% trans "Close" %} -@@ -36,7 +36,7 @@ +- <span aria-hidden="true">×</span> +- <span class="sr-only"> +- {% trans "Close" %} +- </span> +- </button> +- <h4 class="modal-title"> ++ <h4 class="modal-title" id="feedbackFormModalTitle"> + {% blocktrans %} + We would love to get your feedback + {% endblocktrans %} + </h4> ++ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> +- <div class="modal-body form" ++ <div class="modal-body" + data-bind="visible: !showSuccess()"> +- <div class="form-group"> +- <label class="control-label"> ++ <div class="mb-3"> ++ <label class="form-label"> + {% blocktrans %} + How would you rate this feature? {% endblocktrans %} </label> - <div class="row feedback-smiles"> +- <div class="row feedback-smiles"> - <div class="col-xs-4"> ++ <div class="row mb-3 feedback-smiles"> + <div class="col-sm-4"> <button type="button" data-bind="click: rateBad, css: { selected: rating() === 1 }" class="btn btn-link rate-bad"> -@@ -44,7 +44,7 @@ +@@ -44,7 +41,7 @@ {% trans "Don't Like It / Not Useful" %} </button> </div> @@ -40,7 +65,7 @@ <button type="button" data-bind="click: rateOk, css: { selected: rating() === 2 }" class="btn btn-link rate-ok"> -@@ -52,7 +52,7 @@ +@@ -52,7 +49,7 @@ {% trans "Not Sure / Confusing" %} </button> </div> @@ -49,7 +74,18 @@ <button type="button" data-bind="click: rateGood, css: { selected: rating() === 3 }" class="btn btn-link rate-good"> -@@ -80,8 +80,8 @@ +@@ -62,8 +59,8 @@ + </div> + </div> + </div> +- <div class="form-group"> +- <label class="control-label"> ++ <div class="mb-3"> ++ <label class="form-label"> + {% trans "Any other comments?" %} + </label> + <textarea data-bind="value: additionalFeedback" +@@ -80,8 +77,8 @@ </div> <div class="modal-footer"> <button type="button" diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_inline_edit.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_inline_edit.html.diff.txt index 9dbf1f26ca192..a62b25d2b298d 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_inline_edit.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_inline_edit.html.diff.txt @@ -1,37 +1,83 @@ --- +++ -@@ -1,7 +1,7 @@ +@@ -1,52 +1,57 @@ <script type="text/html" id="ko-inline-edit-template"> - <div class="ko-inline-edit inline" data-bind="css: {'has-error': hasError()}"> - <div class="read-only" data-bind="visible: !isEditing(), click: edit, attr: readOnlyAttrs, style: {cursor: disallow_edit ? 'default' : 'pointer'}"> +- <div class="ko-inline-edit inline" data-bind="css: {'has-error': hasError()}"> +- <div class="read-only" data-bind="visible: !isEditing(), click: edit, attr: readOnlyAttrs, style: {cursor: disallow_edit ? 'default' : 'pointer'}"> - <span data-bind="visible: isSaving()" class="pull-right"> -+ <span data-bind="visible: isSaving()" class="float-end"> - <i class="fa fa-spin fa-spinner"></i> - </span> +- <i class="fa fa-spin fa-spinner"></i> +- </span> ++ <div class="ko-inline-edit inline" ++ data-bind="css: {'has-error': hasError()}"> ++ <div class="read-only" ++ data-bind="visible: !isEditing(), click: edit, attr: readOnlyAttrs, style: {cursor: disallow_edit ? 'default' : 'pointer'}"> ++ <span data-bind="visible: isSaving()" class="float-end"> ++ <i class="fa fa-spin fa-spinner"></i> ++ </span> <!-- ko if: iconClass --> -@@ -10,10 +10,10 @@ - </span> +- <span class="prefixed-icon" data-bind="css: containerClass"> +- <i data-bind="css: iconClass"></i> +- </span> ++ <span class="prefixed-icon" data-bind="css: containerClass"> ++ <i data-bind="css: iconClass"></i> ++ </span> <!-- /ko --> <!-- ko if: lang --> - <span class="btn btn-xs btn-info btn-langcode-preprocessed" data-bind="text: lang, visible: !value()"></span> -+ <span class="btn btn-sm btn-info btn-langcode-preprocessed" data-bind="text: lang, visible: !value()"></span> ++ <span class="btn btn-sm btn-info btn-langcode-preprocessed" data-bind="text: lang, visible: !value()"></span> <!-- /ko --> <span data-bind="text: value, visible: value, attr: {'class': containerClass + ' ' + readOnlyClass + ' text'}"></span> - <span class="placeholder text-muted" data-bind="text: placeholder, css: containerClass, visible: !value()"></span> -+ <span class="placeholder text-body-secondary" data-bind="text: placeholder, css: containerClass, visible: !value()"></span> - <span class="inline-edit-icon" data-bind="css: containerClass, visible: !disallow_edit"><i class="fa fa-pencil"></i></span> +- <span class="inline-edit-icon" data-bind="css: containerClass, visible: !disallow_edit"><i class="fa fa-pencil"></i></span> ++ <span class="text-secondary" ++ data-bind="text: placeholder, css: containerClass, visible: !value()"></span> ++ <span class="inline-edit-icon" ++ data-bind="css: containerClass, visible: !disallow_edit"> ++ <i class="fa fa-pencil"></i> ++ </span> </div> - <div class="read-write form-inline" data-bind="visible: isEditing(), css: containerClass"> -@@ -33,7 +33,7 @@ - " /> - <!-- /ko --> +- <div class="read-write form-inline" data-bind="visible: isEditing(), css: containerClass"> +- <div class="form-group langcode-container" data-bind="css: {'has-lang': lang}"> +- <!-- ko if: nodeName === "textarea" --> +- <textarea class="form-control vertical-resize" data-bind=" +- attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, +- value: value, +- hasFocus: isEditing(), +- "></textarea> +- <!-- /ko --> +- <!-- ko if: nodeName === "input" --> +- <input type="text" class="form-control" data-bind=" +- attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, +- value: value, +- hasFocus: isEditing(), +- " /> +- <!-- /ko --> ++ <div class="read-write" ++ data-bind="visible: isEditing(), css: containerClass"> ++ <div class="input-group langcode-container" ++ data-bind="css: {'has-lang': lang}"> <!-- ko if: lang --> - <span class="btn btn-xs btn-info btn-langcode-preprocessed langcode-input pull-right" -+ <span class="btn btn-sm btn-info btn-langcode-preprocessed langcode-input float-end" ++ <span class="btn-langcode-preprocessed langcode-input input-group-text text-bg-info" data-bind="text: lang, visible: !value()" ></span> <!-- /ko --> -@@ -43,7 +43,7 @@ +- </div> +- <div class="help-block" data-bind="text: errorMessage, visible: hasError()"></div> +- <div class="form-group"> ++ <!-- ko if: nodeName === "textarea" --> ++ <textarea class="form-control vertical-resize" ++ data-bind="attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, ++ value: value, ++ hasFocus: isEditing(),"></textarea> ++ <!-- /ko --> ++ <!-- ko if: nodeName === "input" --> ++ <input type="text" ++ class="form-control" ++ data-bind="attr: {name: name, id: id, placeholder: placeholder, rows: rows, cols: cols}, ++ value: value, ++ hasFocus: isEditing()," /> ++ <!-- /ko --> <button class="btn btn-primary" data-bind="click: save, hasFocus: saveHasFocus, visible: !isSaving()"> <i class="fa fa-check"></i> </button> @@ -40,3 +86,7 @@ <i class="fa fa-remove"></i> </button> </div> ++ <div class="help-block" data-bind="text: errorMessage, visible: hasError()"></div> + </div> + </div><!-- ko runOnInit: afterRenderFunc --><!-- /ko --> + </script> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_pagination.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_pagination.html.diff.txt index d103f772317c8..3e2c803989d1f 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_pagination.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_pagination.html.diff.txt @@ -1,20 +1,93 @@ --- +++ -@@ -2,7 +2,7 @@ +@@ -1,46 +1,53 @@ + {% load i18n %} <!-- used by corehq/apps/hqwebapp/static/hqwebapp/js/components/pagination.js --> <script type="text/html" id="ko-pagination-template"> - <div data-bind="css: { row: !inlinePageListOnly }"> +- <div data-bind="css: { row: !inlinePageListOnly }"> - <div class="col-sm-5" -+ <div class="col-md-5" - data-bind="visible: !inlinePageListOnly, +- data-bind="visible: !inlinePageListOnly, ++ <div class="py-3" data-bind="css: { 'd-flex justify-content-between': !inlinePageListOnly }"> ++ <div data-bind="visible: !inlinePageListOnly, if: !inlinePageListOnly"> - <div class="form-inline pagination-text"> -@@ -16,7 +16,7 @@ - </span> +- <div class="form-inline pagination-text"> +- <span data-bind="text: itemsText"></span> +- <span> +- <select class="form-control" +- data-bind="value: perPage, +- options: [5, 25, 50, 100], +- optionsText: perPageOptionsText"> +- </select> +- </span> ++ <div class="input-group"> ++ <div class="input-group-text"><!-- ko text: itemsText --><!-- /ko --></div> ++ <select class="form-select" ++ data-bind="value: perPage, ++ options: [5, 25, 50, 100], ++ optionsText: perPageOptionsText"> ++ </select> </div> </div> - <div data-bind="css: { 'col-sm-7 text-right': !inlinePageListOnly }"> -+ <div data-bind="css: { 'col-md-7 text-end': !inlinePageListOnly }"> - <ul class="pagination"> - <li> - <a href="#" +- <ul class="pagination"> +- <li> +- <a href="#" +- data-bind="click: previousPage"> +- <span>{% trans 'Previous' %}</span> +- </a> +- </li> +- <!-- ko foreach: pagesShown --> +- <li class="text-center" +- data-bind="css: { active: $data == $parent.currentPage() }"> +- <a href="#" +- data-bind="click: $parent.goToPage"> +- <i class="fa fa-spin fa-spinner" +- data-bind="visible: $parent.showSpinner() && $data == $parent.currentPage()"></i> +- <span data-bind="text: $data, visible: !$parent.showSpinner() || $data != $parent.currentPage()"></span> +- </a> +- </li> +- <!-- /ko --> +- <li> +- <a href="#" data-bind="click: nextPage"> +- <span>{% trans 'Next' %}</span> +- </a> +- </li> +- </ul> ++ ++ <div> ++ <nav aria-label="Page navigation example"> ++ <ul class="pagination"> ++ <li class="page-item"> ++ <a href="#" ++ class="page-link" ++ aria-label="Previous" ++ data-bind="click: previousPage"> ++ <span aria-hidden="true">{% trans 'Previous' %}</span> ++ </a> ++ </li> ++ <!-- ko foreach: pagesShown --> ++ <li class="page-item" ++ data-bind="css: { active: $data == $parent.currentPage() }, ++ attr: { 'aria-current': ($data == $parent.currentPage()) ? 'page': undefined } "> ++ <a href="#" ++ class="page-link" ++ data-bind="click: $parent.goToPage"> ++ <i class="fa fa-spin fa-spinner" ++ data-bind="visible: $parent.showSpinner() && $data == $parent.currentPage()"></i> ++ <span data-bind="text: $data, visible: !$parent.showSpinner() || $data != $parent.currentPage()"></span> ++ </a> ++ </li> ++ <!-- /ko --> ++ <li class="page-item"> ++ <a href="#" ++ class="page-link" ++ aria-label="Next" ++ data-bind="click: nextPage"> ++ <span aria-hidden="true">{% trans 'Next' %}</span> ++ </a> ++ </li> ++ </ul> ++ </nav> + </div> + </div> + </script> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_search_box.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_search_box.html.diff.txt index 60574f9d8522c..0aa1a50b6bbe6 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_search_box.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_search_box.html.diff.txt @@ -1,15 +1,23 @@ --- +++ -@@ -4,10 +4,10 @@ +@@ -3,14 +3,12 @@ + <form class="input-group" data-bind="submit: clickAction"> <input type="text" class="form-control" data-bind="value: value, valueUpdate: 'afterkeydown', attr: {placeholder: placeholder}, event: {keypress: keypressAction}" /> - <span class="input-group-btn"> +- <span class="input-group-btn"> - <button type="button" class="btn btn-default" data-bind="click: clickAction, visible: !immediate"> -+ <button type="button" class="btn btn-outline-primary" data-bind="click: clickAction, visible: !immediate"> - <i class="fa fa-search"></i> - </button> +- <i class="fa fa-search"></i> +- </button> - <button class="btn btn-default" type="button" data-bind="click: clearQuery"> -+ <button class="btn btn-outline-primary" type="button" data-bind="click: clearQuery"> - <i class="fa fa-times"></i> - </button> - </span> +- <i class="fa fa-times"></i> +- </button> +- </span> ++ <button type="button" class="btn btn-outline-primary" data-bind="click: clickAction, visible: !immediate"> ++ <i class="fa fa-search"></i> ++ </button> ++ <button class="btn btn-outline-primary" type="button" data-bind="click: clearQuery"> ++ <i class="fa fa-times"></i> ++ </button> + </form> + </div> + </script> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_select_toggle.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_select_toggle.html.diff.txt index 84b0f1e0e3c2f..1b6be926dc215 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_select_toggle.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/ko_select_toggle.html.diff.txt @@ -1,6 +1,12 @@ --- +++ -@@ -6,7 +6,7 @@ +@@ -1,12 +1,12 @@ + <script type="text/html" id="ko-select-toggle"> + <div class="ko-select-toggle"> +- <select class="hide" data-bind="foreach: options(), attr: htmlAttrs, value: value"> ++ <select class="d-none" data-bind="foreach: options(), attr: htmlAttrs, value: value"> + <option data-bind="value: $data.id, + text: $data.text, attr: {selected: $data.selected}"></option> </select> <div class="btn-group-separated" data-bind="foreach: options()"> @@ -9,3 +15,9 @@ data-bind="css: { active: $data.selected, disabled: $parent.disabled, +@@ -16,4 +16,5 @@ + <!-- ko text: $data.text --><!-- /ko --> + </button> + </div> ++ </div> + </script> diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/maintenance_alerts.html.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/maintenance_alerts.html.diff.txt index 92814ab2ef9ee..26448f6a3eb24 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/maintenance_alerts.html.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/hqwebapp/partials/maintenance_alerts.html.diff.txt @@ -1,9 +1,9 @@ --- +++ @@ -3,7 +3,7 @@ - {% maintenance_alerts request as alerts %} + {% commcarehq_alerts request as alerts %} {% for alert in alerts %} - <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}"> + <div class="alert alert-warning alert-maintenance hide" data-id="{{ alert.id }}" data-created-by-domain="{{ alert.created_by_domain|BOOL }}"> - <button class="close" data-dismiss="alert" aria-label="close">×</button> + <button class="btn-close" data-bs-dismiss="alert" aria-label="btn-close">×</button> {{ alert.html }} diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/components/feedback.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/components/feedback.js.diff.txt index c90f5bed16c39..ef4dfd7c7730c 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/components/feedback.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/components/feedback.js.diff.txt @@ -9,3 +9,27 @@ 'knockout', 'jquery', 'hqwebapp/js/initial_page_data', +@@ -21,9 +21,10 @@ + $, + initialPageData + ) { ++ 'use strict'; + return { + viewModel: function (params) { +- var self = {}; ++ let self = {}; + + if (!params.featureName) { + throw new Error("Please specify a featureName in params."); +@@ -61,11 +62,6 @@ + if (data.success) { + self.showSuccess(true); + } +- }) +- .always(function () { +- setTimeout(function () { +- $('#modal-feedback-form-widget').modal('hide'); +- }, 1000); + }); + }; + diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/knockout_bindings.ko.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/knockout_bindings.ko.js.diff.txt index 22b1d21d8cd22..7cbadea05c756 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/knockout_bindings.ko.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/knockout_bindings.ko.js.diff.txt @@ -1,8 +1,87 @@ --- +++ -@@ -1,4 +1,4 @@ +@@ -1,12 +1,14 @@ -hqDefine("hqwebapp/js/bootstrap3/knockout_bindings.ko", [ +hqDefine("hqwebapp/js/bootstrap5/knockout_bindings.ko", [ 'jquery', 'underscore', 'knockout', ++ "es6!hqwebapp/js/bootstrap5_loader", + 'jquery-ui/ui/widgets/sortable', + ], function ( + $, + _, +- ko ++ ko, ++ bootstrap + ) { + // Need this due to https://github.com/knockout/knockout/pull/2324 + // so that ko.bindingHandlers.foreach.update works properly +@@ -344,19 +346,15 @@ + + ko.bindingHandlers.modal = { + init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { +- $(element).addClass('modal fade').modal({ +- show: false, +- }); +- // ko.bindingHandlers['if'].init(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); ++ viewModel.binding_modal = new bootstrap.Modal(element); + }, + update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { + ko.bindingHandlers.visible.update(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); +- var value = ko.utils.unwrapObservable(valueAccessor()); +- // ko.bindingHandlers['if'].update(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext); ++ let value = ko.utils.unwrapObservable(valueAccessor()); + if (value) { +- $(element).modal('show'); ++ viewModel.binding_modal.show(); + } else { +- $(element).modal('hide'); ++ viewModel.binding_modal.hide(); + } + }, + }; +@@ -383,19 +381,21 @@ + templateID = value.templateId; + ifValue = _.has(value, 'if') ? value.if : true; + } +- var modal = $('<div></div>').addClass('modal fade').appendTo('body'), ++ let modalElement = $('<div></div>').addClass('modal fade').attr("tabindex", "-1").appendTo('body'), + newValueAccessor = function () { +- var clickAction = function () { ++ let clickAction = function () { + if (!ifValue) { + return; + } +- ko.bindingHandlers.template.init(modal.get(0), function () { ++ ko.bindingHandlers.template.init(modalElement.get(0), function () { + return templateID; + }, allBindingsAccessor, viewModel, bindingContext); +- ko.bindingHandlers.template.update(modal.get(0), function () { ++ ko.bindingHandlers.template.update(modalElement.get(0), function () { + return templateID; + }, allBindingsAccessor, viewModel, bindingContext); +- modal.modal('show'); ++ ++ let modal = new bootstrap.Modal(modalElement.get(0)); ++ modal.show(); + }; + return clickAction; + }; +@@ -405,11 +405,13 @@ + + ko.bindingHandlers.openRemoteModal = { + init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { +- var modal = $('<div></div>').addClass('modal fade').appendTo('body'), ++ var modalElement = $('<div></div>').addClass('modal fade').attr("tabindex", "-1").appendTo('body'), + newValueAccessor = function () { + var clickAction = function () { +- modal.load($(element).data('ajaxSource')); +- modal.modal('show'); ++ modalElement.load($(element).data('ajaxSource'), function () { ++ let modal = new bootstrap.Modal(modalElement.get(0)); ++ modal.show(); ++ }); + }; + return clickAction; + }; diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/main.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/main.js.diff.txt index 2ee1129a21d07..f183b6f3d14ae 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/main.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/main.js.diff.txt @@ -48,7 +48,7 @@ $(document).on('click', '.track-usage-link', function (e) { var $link = $(e.currentTarget), -@@ -449,8 +454,13 @@ +@@ -459,8 +464,13 @@ // EULA modal var eulaCookie = "gdpr_rollout"; if (!$.cookie(eulaCookie)) { @@ -64,7 +64,7 @@ $("body").addClass("has-eula"); $("#eula-agree").click(function () { $(this).disableButton(); -@@ -458,7 +468,7 @@ +@@ -468,7 +478,7 @@ url: initialPageData.reverse("agree_to_eula"), method: "POST", success: function () { @@ -73,7 +73,7 @@ $("body").removeClass("has-eula"); }, error: function (xhr) { -@@ -474,21 +484,22 @@ +@@ -484,21 +494,22 @@ }, }); }); diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/requirejs_config.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/requirejs_config.js.diff.txt index 24e33843bbbe7..7372fcb0a9e72 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/requirejs_config.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/requirejs_config.js.diff.txt @@ -1,6 +1,6 @@ --- +++ -@@ -2,9 +2,13 @@ +@@ -2,21 +2,26 @@ requirejs.config({ baseUrl: '/static/', paths: { @@ -14,7 +14,11 @@ "knockout": "knockout/build/output/knockout-latest.debug", "ko.mapping": "hqwebapp/js/lib/knockout_plugins/knockout_mapping.ko.min", "datatables": "datatables.net/js/jquery.dataTables.min", -@@ -15,9 +19,8 @@ +- "datatables.fixedColumns": "datatables-fixedcolumns/js/dataTables.fixedColumns", +- "datatables.bootstrap": "datatables-bootstrap3/BS3/assets/js/datatables", ++ "datatables.fixedColumns": "datatables.net-fixedcolumns/js/dataTables.fixedColumns.min", ++ "datatables.fixedColumns.bootstrap": "datatables.net-fixedcolumns/js/dataTables.fixedColumns.min", ++ "datatables.bootstrap": "datatables.net-bs5/js/dataTables.bootstrap5.min", }, shim: { "ace-builds/src-min-noconflict/ace": { exports: "ace" }, @@ -23,9 +27,11 @@ - "hqwebapp/js/bootstrap3/hq.helpers": { deps: ['jquery', 'bootstrap', 'knockout', 'underscore'] }, + "hqwebapp/js/bootstrap5/hq.helpers": { deps: ['jquery', 'knockout', 'underscore'] }, "datatables.bootstrap": { deps: ['datatables'] }, ++ "datatables.fixedColumns.bootstrap": { deps: ['datatables.fixedColumns'] }, "jquery.rmi/jquery.rmi": { deps: ['jquery', 'knockout', 'underscore'], -@@ -52,7 +55,7 @@ + exports: 'RMI', +@@ -44,7 +49,7 @@ }, }, diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/sticky_tabs.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/sticky_tabs.js.diff.txt index 622970466996f..ac0ebb20ea8a0 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/sticky_tabs.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/sticky_tabs.js.diff.txt @@ -15,14 +15,23 @@ ) { var getHash = function () { if (window.location.hash) { -@@ -21,16 +21,21 @@ - var tabSelector = "a[data-toggle='tab']", +@@ -16,21 +16,28 @@ + } + return ""; + }; +- + $(function () { +- var tabSelector = "a[data-toggle='tab']", ++ var tabSelector = "a[data-bs-toggle='tab']", navSelector = ".nav.sticky-tabs", hash = getHash(), - $tabFromUrl = hash ? $("a[href='" + hash + "']") : undefined; + $tabFromUrl = hash ? $("a[href='" + hash + "']") : undefined, + $altTabSelector = $(navSelector + ' ' + tabSelector).first(), + tabController; ++ ++ // make sure we don't treat all anchor tags as a sticky tab ++ if ($tabFromUrl && $tabFromUrl.parents('.sticky-tabs').length === 0) return; if ($tabFromUrl && $tabFromUrl.length) { - $tabFromUrl.tab('show'); @@ -42,7 +51,7 @@ if (!$link.closest(navSelector).length) { return true; } -@@ -42,13 +47,18 @@ +@@ -42,13 +49,18 @@ window.location.hash = tabName; } diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/validators.ko.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/validators.ko.js.diff.txt new file mode 100644 index 0000000000000..1549c760b89e4 --- /dev/null +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/hqwebapp/validators.ko.js.diff.txt @@ -0,0 +1,127 @@ +--- ++++ +@@ -1,4 +1,4 @@ +-hqDefine("hqwebapp/js/bootstrap3/validators.ko", [ ++hqDefine("hqwebapp/js/bootstrap5/validators.ko", [ + 'jquery', + 'knockout', + 'knockout-validation/dist/knockout.validation.min', // needed for ko.validation +@@ -6,6 +6,15 @@ + $, + ko + ) { ++ 'use strict'; ++ ++ ko.validation.init({ ++ errorMessageClass: 'invalid-feedback', ++ errorElementClass: 'is-invalid', ++ decorateElement: true, ++ decorateInputElement: true, ++ }, true); ++ + ko.validation.rules['emailRFC2822'] = { + validator: function (val) { + if (val === undefined || val.length === 0) return true; // do separate validation for required +@@ -19,52 +28,71 @@ + + /** + * Use this handler to show bootstrap validation states on a form input when +- * your input's observable has been extended by KnockoutValidation. ++ * your input's observable has been extended by Knockout Validation + * + * Pass in the following: + * { +- * validator: observableWithValidation, +- * delayedValidator: rateLimitedObservableWithValidation, ++ * validator: primary observable with validation, ++ * delayedValidator: de-coupled rate limited observable with validation (optional), ++ * successMessage: text (optional), ++ * checkingMessage: text (optional), + * } + * +- * delayedValidator is optional. Useful if you are doing async validation. ++ * delayedValidator is useful if you are doing async validation and want to decouple async validation from ++ * other validators (perhaps for rate limiting). See Organisms > Forms in styleguide for example. + * +- * You can see initial usage of this in registration/js/new_user.ko.js + */ + ko.bindingHandlers.koValidationStateFeedback = { +- init: function (element) { +- $(element).after($('<span />').addClass('fa form-control-feedback')); ++ init: function (element, valueAccessor) { ++ let options = valueAccessor(), ++ successMessage = ko.unwrap(options.successMessage), ++ checkingMessage = ko.unwrap(options.checkingMessage); ++ $(element) ++ .after($('<span />').addClass('valid-feedback').text(successMessage)) ++ .after($('<span />').addClass('validating-feedback') ++ .append($('<i class="fa fa-spin fa-spinner"></i>')).append(" " + (checkingMessage || gettext("Checking...")))) ++ .after($('<span />').addClass('ko-delayed-feedback')); + }, + update: function (element, valueAccessor) { +- var options = valueAccessor(), +- $feedback = $(element).next('.form-control-feedback'), +- $formGroup = $(element).parent('.form-group'); +- +- var validatorVal = ko.unwrap(options.validator); +- +- // reset formGroup +- $formGroup +- .addClass('has-feedback') +- .removeClass('has-success has-error has-warning'); +- +- // reset feedback +- $feedback +- .removeClass('fa-check fa-remove fa-spin fa-spinner'); ++ let options = valueAccessor(), ++ validatorVal = ko.unwrap(options.validator), ++ isValid = false, ++ isValidating = false, ++ isDelayedValid; + + if (validatorVal === undefined) { + return; + } +- var isValid = ( +- (options.validator.isValid() && options.delayedValidator === undefined) || +- (options.validator.isValid() && options.delayedValidator !== undefined && options.delayedValidator.isValid()) +- ); + +- if (isValid) { +- $feedback.addClass("fa-check"); +- $formGroup.addClass("has-success"); +- } else if (validatorVal !== undefined) { +- $feedback.addClass("fa-remove"); +- $formGroup.addClass("has-error"); ++ if (options.delayedValidator === undefined) { ++ isValid = options.validator.isValid(); ++ isValidating = options.validator.isValidating !== undefined && options.validator.isValidating(); ++ if (isValid !== undefined && !isValid) $(element).addClass('is-invalid'); ++ } else { ++ isValidating = options.validator.isValid() && options.delayedValidator.isValidating(); ++ ++ isDelayedValid = options.delayedValidator.isValid(); ++ if (!isDelayedValid && !isValidating) { ++ $(element).addClass('is-invalid').removeClass('is-valid is-validating'); ++ $(element).next('.ko-delayed-feedback') ++ .addClass('invalid-feedback').text(options.delayedValidator.error()); ++ } else { ++ $(element).next('.ko-delayed-feedback').removeClass('invalid-feedback').text(""); ++ } ++ ++ isValid = options.validator.isValid() && isDelayedValid; ++ } ++ ++ if (isValidating) { ++ $(element).removeClass('is-valid is-invalid').addClass('is-validating'); ++ } else { ++ $(element).removeClass('is-validating'); ++ } ++ ++ if (isValid && !isValidating) { ++ $(element).addClass('is-valid').removeClass('is-invalid is-validating'); ++ } else if (!isValid) { ++ $(element).removeClass('is-valid'); + } + }, + }; diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/backgrounds._backgrounds.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/backgrounds._backgrounds.style.diff.txt index 9e7bda53b6b45..5bd7914232eaf 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/backgrounds._backgrounds.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/backgrounds._backgrounds.style.diff.txt @@ -15,16 +15,7 @@ } .bg-full-cover-fixed.b-loaded { -@@ -23,13 +23,13 @@ - bottom: 0; - left: 0; - right: 0; -- background: linear-gradient(#5D70D2, #323b43); -- opacity: 0.3; -+ background-color: #45566E; -+ opacity: 0.75; - } - +@@ -30,6 +30,6 @@ .bg-container { height: 100%; width: 100%; diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/datatables._datatables.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/datatables._datatables.style.diff.txt index 7e7ee48e0ae9b..d5cae78d0025d 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/datatables._datatables.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/datatables._datatables.style.diff.txt @@ -1,52 +1,144 @@ --- +++ -@@ -9,10 +9,10 @@ +@@ -1,102 +1,54 @@ +-.dataTables_info, +-.dataTables_length { +- display: inline-block; +-} +-.dataTables_info { +- padding-top: 24px; +- padding-right: 5px; ++div.dataTables_wrapper div.dataTables_info { ++ padding-top: 9px !important; + } - .datatable thead th, - .dataTable thead th { +-.datatable thead th, +-.dataTable thead th { - background-color: desaturate(@cc-brand-low, 50%); -+ background-color: desaturate($cc-brand-low, 50%); - color: #ffffff; - &:nth-child(odd) { +- color: #ffffff; +- &:nth-child(odd) { - background-color: lighten(desaturate(@cc-brand-low, 50%), 10%); -+ background-color: lighten(desaturate($cc-brand-low, 50%), 10%); - } +- } ++div.dataTables_wrapper div.dataTables_length label { ++ padding-top: 6px !important; } -@@ -20,7 +20,7 @@ - .datatable tfoot th, - .dataTable tfoot td, - .dataTable tfoot th{ +-.datatable tfoot td, +-.datatable tfoot th, +-.dataTable tfoot td, +-.dataTable tfoot th{ - background-color: lighten(desaturate(@cc-brand-low, 60%), 10%); -+ background-color: lighten(desaturate($cc-brand-low, 60%), 10%); - color: #ffffff; - padding: 8px; +- color: #ffffff; +- padding: 8px; ++table.dataTable.table-hq-report { ++ margin-top: 0 !important; } -@@ -49,13 +49,13 @@ + +-.datatable .header, +-.dataTable .header { +- .dt-sort-icon:before{ +- font-family: "Glyphicons Halflings"; +- vertical-align: bottom; +- } +- &.headerSort { +- .dt-sort-icon:before { +- content: "\e150"; +- opacity: 0.2; ++.table-hq-report { ++ thead th { ++ background-color: $blue-800; ++ color: $white; ++ white-space: nowrap; ++ ++ &:nth-child(odd) { ++ background-color: $blue-700; ++ } ++ ++ &.dtfc-fixed-left, ++ &.dtfc-fixed-right { ++ background-color: $blue !important; ++ } ++ ++ &.sorting_asc::before, ++ &.sorting_desc::after { ++ opacity: 1.0 !important; ++ color: $white !important; ++ } ++ ++ &::after, ++ &::before { ++ opacity: 0.3 !important; + } } - &.headerSortDesc, - &.headerSortAsc { +- &.headerSortDesc { +- .dt-sort-icon:before { +- content: "\e156"; ++ ++ tbody tr { ++ &.odd td.dtfc-fixed-left, ++ &.odd td.dtfc-fixed-right { ++ background-color: lighten($gray-200, 5%); + } +- } +- &.headerSortAsc { +- .dt-sort-icon:before { +- content: "\e155"; +- } +- } +- &.headerSortDesc, +- &.headerSortAsc { - background-color: @cc-brand-mid; -+ background-color: $cc-brand-mid; - } - } +- } +-} - .datatable .sorting_1, - .dataTable .sorting_1 { +-.datatable .sorting_1, +-.dataTable .sorting_1 { - background-color: @cc-bg; -+ background-color: $cc-bg; +-} +- +-.panel-body-datatable { +- padding: 0; +- .dataTables_control { +- padding: 10px 15px; +- .dataTables_info { +- padding-top: 0; +- } +- .dataTables_paginate { +- .pagination { +- margin: 0; +- } ++ &.even td.dtfc-fixed-left { ++ background-color: $gray-100; + } + } } - .panel-body-datatable { -@@ -94,9 +94,9 @@ +-.dataTable td.text-xs { +- font-size: .8em; ++.dtfc-right-top-blocker:last-child { ++ display: none !important; } - - .dataTable td.text-red { +- +-.dataTable td.text-sm { +- font-size: .9em; +-} +- +-.dataTable td.text-lg { +- font-size: 1.1em; +-} +- +-.dataTable td.text-xl { +- font-size: 1.2em; +-} +- +-.dataTable td.text-bold { +- font-weight: bold; +-} +- +-.dataTable td.text-red { - color: @cc-att-neg-mid; -+ color: $cc-att-neg-mid; - } - - .dataTable td.text-green { +-} +- +-.dataTable td.text-green { - color: @cc-att-pos-mid; -+ color: $cc-att-pos-mid; - } +-} diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/feedback._feedback.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/feedback._feedback.style.diff.txt index cebce48a6a06c..53f4ef0db5789 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/feedback._feedback.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/feedback._feedback.style.diff.txt @@ -5,33 +5,33 @@ .rate-bad { - color: @cc-att-neg-mid; -+ color: $cc-att-neg-mid; ++ color: $danger; &.selected { - background-color: @cc-att-neg-mid; -+ background-color: $cc-att-neg-mid; ++ background-color: $danger; color: white; } } .rate-ok { - color: @cc-dark-warm-accent-mid; -+ color: $cc-dark-warm-accent-mid; ++ color: $warning; &.selected { - background-color: @cc-dark-warm-accent-mid; -+ background-color: $cc-dark-warm-accent-mid; ++ background-color: $warning; color: white; } } .rate-good { - color: @cc-att-pos-mid; -+ color: $cc-att-pos-mid; ++ color: $success; &.selected { - background-color: @cc-att-pos-mid; -+ background-color: $cc-att-pos-mid; ++ background-color: $success; color: white; } } diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/forms._forms.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/forms._forms.style.diff.txt index 6e25301d8786f..f962810451315 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/forms._forms.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/forms._forms.style.diff.txt @@ -1,6 +1,6 @@ --- +++ -@@ -1,352 +1,3 @@ +@@ -1,350 +1,7 @@ -// FORM ACTIONS from TWBS 2 -// ------------ - @@ -11,7 +11,10 @@ -.form-actions { - padding: (@line-height-base * @font-size-base - 1px) 0px @line-height-base * @font-size-base; - margin-top: @line-height-base * 1em; -- margin-bottom: 0; ++.row > div > .form-check:first-child, ++.row > div > .input-group > .form-check:first-child { ++ padding-top: add($input-padding-y, $input-border-width); + margin-bottom: 0; - background-color: @navbar-default-bg; - border-top: 1px solid @legend-border-color; - .border-bottom-radius(@border-radius-base); @@ -348,12 +351,10 @@ - -.controls-text { - padding-top: 7px; --} -- - .form-hide-actions .form-actions { - display: none; } -@@ -355,15 +6,15 @@ + + .form-hide-actions .form-actions { +@@ -355,15 +12,38 @@ .validationMessage { display: block; padding-top: 8px; @@ -369,10 +370,33 @@ - float: none; - position: initial; - width: 13px; -+.help-block, +-}+.help-block, +p.help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: lighten($cc-text, 25%); - } \ No newline at end of file ++} ++ ++legend { ++ border-bottom: 1px solid $border-color; ++ padding-bottom: $spacer * .25; ++ margin-bottom: $spacer * 1.25; ++} ++ ++.form-actions { ++ border-top: 1px solid $border-color; ++ background-color: $light; ++ border-bottom-left-radius: $border-radius; ++ border-bottom-right-radius: $border-radius; ++ padding: $spacer 0; ++ margin: 0 0 $spacer 0; ++ ++ div { ++ padding-left: 6px; ++ } ++} ++ ++.ms-header .btn { ++ margin-top: -3px; ++} 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 edac23c75f8aa..c1f222789497e 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 @@ -1,7 +1,11 @@ --- +++ -@@ -1,123 +1,121 @@ +@@ -1,121 +1,194 @@ -@import "@{b3-import-variables}"; ++$prefix: bs; + +-// Nunito Sans is used on dimagi.com and embedded in hqwebapp/base.html +-@font-family-sans-serif: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +// Grid Containers +// we will want to adapt the defaults in a full redesign +$container-max-widths: ( @@ -12,11 +16,9 @@ + xxl: 1160px +); --// Nunito Sans is used on dimagi.com and embedded in hqwebapp/base.html --@font-family-sans-serif: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; - -@font-size-base: 12px; -@icon-font-path: "../../bootstrap/fonts/"; ++ +// Typography Overrides +$font-family-sans-serif: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-size-base: .75rem; // approx 12px @@ -50,9 +52,6 @@ + +// Badges +$badge-border-radius: .25em; -+ -+ -+// Custom colors we created (in Bootstrap 3 stylesheets) // Main background & text -@cc-bg: #f2f2f1; @@ -134,9 +133,6 @@ -@cc-brand-hi: #bcdeff; -@cc-brand-mid: #004ebc; -@cc-brand-low: #002c5f; -- --// used in registration app only. will be merged into "brand" color Bootstrap 5 migration --@commcare-blue: #5D70D2; +$cc-brand-hi: #bcdeff; +$cc-brand-mid: #004ebc; +$cc-brand-low: #002c5f; @@ -152,27 +148,62 @@ -@cc-dark-warm-accent-hi: #ffe3c2; -@cc-dark-warm-accent-mid: #ff8400; -@cc-dark-warm-accent-low: #994f00; -- ++$cc-dark-warm-accent-hi: #ffe3c2; ++$cc-dark-warm-accent-mid: #ff8400; ++$cc-dark-warm-accent-low: #994f00; + -// Main TWBS Colors -@brand-primary: @cc-brand-mid; -@brand-success: @cc-att-pos-mid; -@brand-info: @cc-light-cool-accent-mid; -@brand-warning: @cc-dark-warm-accent-mid; -@brand-danger: @cc-att-neg-mid; -- ++// Grays from default stylesheet — needed for reference ++$gray-100: #f8f9fa; ++$gray-200: #e9ecef; ++$gray-600: #6c757d; ++$gray-800: #343a40; + -@text-color: @cc-text; -- ++// Base color overrides ++$blue: #5D70D2; ++$green: #3FA12A; ++$red: #E73C27; ++$teal: #01A2A9; ++$yellow: #EEAE00; + -@link-hover-color: @cc-brand-low; -- ++// for determining when to show black or white text on top of color ++$min-contrast-ratio: 3; + -@btn-default-color: @cc-text; -@btn-default-bg: @cc-bg; -@btn-default-border: @cc-neutral-hi; -- ++// Theme Colors ++$primary: $blue; ++$secondary: $gray-600; ++$success: $green; ++$danger: $red; ++$info: $teal; ++$warning: $yellow; ++$light: $gray-100; ++$dark: $gray-800; + -@navbar-height: 60px; -@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); -@navbar-default-bg: @cc-bg; -- --@navbar-footer-height: 40px; ++$theme-colors: ( ++ "primary": $primary, ++ "success": $success, ++ "info": $info, ++ "warning": $warning, ++ "danger": $danger, ++ "dark": $dark, ++ "secondary": $secondary, ++ "light": $light, ++); + +-@navbar-footer-height: 38px; -@navbar-footer-link-color: mix(darken(#ffffff, 10), @brand-primary, 90); -@navbar-footer-link-color-hover: @gray-lighter; -@navbar-footer-button-color: #474747; @@ -201,22 +232,56 @@ -@zindex-formplayer-progress: 990; -@zindex-cloudcare-debugger: 1005; -@zindex-formplayer-scroll-to-bottom: 5; -+$cc-dark-warm-accent-hi: #ffe3c2; -+$cc-dark-warm-accent-mid: #ff8400; -+$cc-dark-warm-accent-low: #994f00; ++$body-color: $dark; ++$link-color: darken($primary, 10); ++$link-hover-color: darken($primary, 50); ++$border-color: $gray-200; -@input-border-radius-large: 5px; +-@input-color: #000; -@cursor-disabled: 'not-allowed'; -+////// Bootstrap 5 Color Overrides -+$primary: #5c6ac5; -+$success: $cc-att-pos-mid; -+$info: $cc-light-cool-accent-mid; -+$danger: $cc-att-neg-mid; ++// Form Sates ++ ++$form-feedback-valid-color: $success; ++$form-feedback-invalid-color: $danger; ++$form-feedback-icon-valid-color: $form-feedback-valid-color; ++$form-feedback-icon-valid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'><path fill='#{$form-feedback-icon-valid-color}' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>"); ++$form-feedback-icon-invalid-color: $form-feedback-invalid-color; ++$form-feedback-icon-invalid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='#{$form-feedback-icon-invalid-color}'><circle cx='6' cy='6' r='4.5'/><path stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/><circle cx='6' cy='8.2' r='.6' fill='#{$form-feedback-icon-invalid-color}' stroke='none'/></svg>"); + -+$body-color: $cc-text; ++$focus-ring-blur: 0; ++$focus-ring-width: .25rem; ++$focus-ring-opacity: .25; + -+$link-color: $cc-brand-mid; -+$link-hover-color: $cc-brand-low; ++$input-btn-focus-blur: $focus-ring-blur; ++$input-btn-focus-width: $focus-ring-width; ++$input-focus-width: $input-btn-focus-width; ++$input-btn-focus-color-opacity: $focus-ring-opacity; + -+$link-hover-color: $cc-brand-low; \ No newline at end of file ++$form-validation-states: ( ++ "valid": ( ++ "color": var(--#{$prefix}form-valid-color), ++ "icon": $form-feedback-icon-valid, ++ "tooltip-color": #fff, ++ "tooltip-bg-color": var(--#{$prefix}success), ++ "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity), ++ "border-color": var(--#{$prefix}form-valid-border-color), ++ ), ++ "invalid": ( ++ "color": var(--#{$prefix}form-invalid-color), ++ "icon": $form-feedback-icon-invalid, ++ "tooltip-color": #fff, ++ "tooltip-bg-color": var(--#{$prefix}danger), ++ "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity), ++ "border-color": var(--#{$prefix}form-invalid-border-color), ++ ), ++ "validating": ( ++ "color": $secondary, ++ "icon": "", ++ "tooltip-color": #fff, ++ "tooltip-bg-color": var(--#{$prefix}dark), ++ "focus-box-shadow": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}dark-rgb), $input-btn-focus-color-opacity), ++ "border-color": $dark, ++ ) ++); diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables_bootstrap3.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables_bootstrap3.style.diff.txt index af944a22ba52f..eec4766ebc400 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables_bootstrap3.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/includes_variables._variables_bootstrap3.style.diff.txt @@ -1,6 +1,6 @@ --- +++ -@@ -1,123 +1,67 @@ +@@ -1,121 +1,67 @@ -@import "@{b3-import-variables}"; +/* +These are Boostrap 3 variables that were carried over in the @@ -67,7 +67,7 @@ -@call-to-action-mid: #5c6ac5; -@call-to-action-low: #212f78; -@call-to-action-extra-low: #000639; -+$navbar-footer-height: 40px; ++$navbar-footer-height: 38px; +$navbar-footer-link-color: mix(darken(#ffffff, 10), $brand-primary, 90%); +$navbar-footer-link-color-hover: $gray-lighter; +$navbar-footer-button-color: #474747; @@ -97,9 +97,6 @@ -@cc-brand-mid: #004ebc; -@cc-brand-low: #002c5f; - --// used in registration app only. will be merged into "brand" color Bootstrap 5 migration --@commcare-blue: #5D70D2; -- -// Accent colors used in few places (billing, web apps, one-offs). Minimize usage. -@cc-dark-cool-accent-hi: #d6c5ea; -@cc-dark-cool-accent-mid: #9060c8; @@ -128,7 +125,7 @@ -@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); -@navbar-default-bg: @cc-bg; - --@navbar-footer-height: 40px; +-@navbar-footer-height: 38px; -@navbar-footer-link-color: mix(darken(#ffffff, 10), @brand-primary, 90); -@navbar-footer-link-color-hover: @gray-lighter; -@navbar-footer-button-color: #474747; @@ -170,5 +167,6 @@ - -@input-border-radius-large: 5px; +-@input-color: #000; -@cursor-disabled: 'not-allowed'; +$cursor-disabled: 'not-allowed'; diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/inline_edit._inline_edit.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/inline_edit._inline_edit.style.diff.txt index 0d8eb1d66dbf7..8bdbb6cb4474f 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/inline_edit._inline_edit.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/inline_edit._inline_edit.style.diff.txt @@ -10,20 +10,36 @@ &:hover { cursor: pointer; - background-color: @cc-bg; -+ background-color: $cc-bg; ++ background-color: $light; border-radius: 4px; } i { - color: @cc-neutral-hi; -+ color: $cc-neutral-hi; ++ color: $secondary; display: inline-block; margin-left: 5px; margin-top: 2px; -@@ -49,5 +49,5 @@ +@@ -34,20 +34,11 @@ + white-space: pre-wrap; + } + } +- + .read-write { +- .form-group { +- margin-left: 0; +- margin-right: 0; +- } +- .langcode-container.has-lang { +- input, textarea { +- padding-right: 45px; +- } +- } ++ min-width: 350px; + } } table .ko-inline-edit .read-only:hover { - background-color: @cc-neutral-hi; -+ background-color: $cc-neutral-hi; ++ background-color: $light; } diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/pagination._pagination.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/pagination._pagination.style.diff.txt index 2cbf0095a4cd4..c7724a8ef6a68 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/pagination._pagination.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/pagination._pagination.style.diff.txt @@ -1,9 +1,15 @@ --- +++ -@@ -8,5 +8,5 @@ - } - - .pagination > .active > a { +@@ -1,12 +0,0 @@ +-.pagination .form-control { +- display: inline; +- width: auto; +-} +- +-.pagination-text { +- margin: 17px 0; +-} +- +-.pagination > .active > a { - .button-variant(#ffffff; @cc-brand-low; @cc-brand-low); -+ @include button-variant($white, $cc-brand-low, $cc-brand-low); - } +-} diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/select2s._select2.style.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/select2s._select2.style.diff.txt index 9a97deb37fd9d..ae7fe159a73ba 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/select2s._select2.style.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/stylesheets/imports/select2s._select2.style.diff.txt @@ -52,7 +52,29 @@ } } } -@@ -67,9 +67,9 @@ +@@ -57,14 +57,6 @@ + .select2-container { + width: 100% !important; + +- .select2-selection.select2-selection--single { +- height: @input-height-base; +- } +- +- .select2-selection.select2-selection--multiple { +- min-height: @input-height-base; +- } +- + .select2-search-field { + width: 100% !important; + } +@@ -72,16 +64,95 @@ + .select2-input { + width: 100% !important; + } +- +- textarea.select2-search__field { // Placeholder for multi-select +- line-height: 21px; +- } } .select2-container.select2-container-active > .select2-choice { @@ -64,3 +86,86 @@ - color: @gray-base !important; + color: $gray-base !important; } ++ ++.select2-container--default .select2-selection--single, ++.select2-container--default .select2-selection--multiple { ++ border-color: $border-color !important; ++ height: 32px !important; ++} ++ ++.select2-container--default .select2-selection--single { ++ --#{$prefix}form-select2-bg-img: #{escape-svg($form-select-indicator)}; ++ ++ .select2-selection__rendered { ++ padding-left: $input-padding-x !important; ++ padding-right: 36px !important; ++ padding-top: 1px !important; ++ } ++ .select2-selection__arrow b { ++ border: none !important; ++ background-image: var(--#{$prefix}form-select2-bg-img), var(--#{$prefix}form-select-bg-icon, none); ++ background-repeat: no-repeat; ++ margin-top: 0 !important; ++ width: 12px !important; ++ height: 12px !important; ++ top: 9px !important; ++ right: 14px !important; ++ left: auto !important; ++ } ++} ++ ++.select2-container--default.is-invalid, ++.select2-container--default.is-valid { ++ ++ .select2-selection__rendered { ++ padding-right: 66px !important; ++ } ++ .select2-selection--single .select2-selection__arrow { ++ background-repeat: no-repeat; ++ background-size: 24%; ++ background-position: 15px 7px; ++ width: 66px !important; ++ } ++ ++ &.select2-container--focus { ++ .select2-selection--single, ++ .select2-selection--multiple { ++ outline: 0; ++ } ++ } ++} ++ ++.select2-container--default.is-invalid { ++ .select2-selection--single, ++ .select2-selection--multiple { ++ border-color: $form-feedback-icon-invalid-color !important; ++ } ++ .select2-selection--single .select2-selection__arrow { ++ background-image: escape-svg($form-feedback-icon-invalid); ++ } ++} ++ ++.select2-container--default.is-valid { ++ .select2-selection--single, ++ .select2-selection--multiple { ++ border-color: $form-feedback-icon-valid-color !important; ++ } ++ .select2-selection--single .select2-selection__arrow { ++ background-image: escape-svg($form-feedback-icon-valid); ++ } ++} ++ ++.select2-container--default.is-invalid.select2-container--focus { ++ .select2-selection--single, ++ .select2-selection--multiple { ++ box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity); ++ } ++} ++ ++.select2-container--default.is-valid.select2-container--focus { ++ .select2-selection--single, ++ .select2-selection--multiple { ++ box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity); ++ outline: 0; ++ } ++} diff --git a/corehq/apps/hqwebapp/tests/test_models.py b/corehq/apps/hqwebapp/tests/test_models.py index d438fbdef7215..93c46aa28c0db 100644 --- a/corehq/apps/hqwebapp/tests/test_models.py +++ b/corehq/apps/hqwebapp/tests/test_models.py @@ -1,13 +1,13 @@ from datetime import datetime, timedelta from django.test import TestCase -from ..models import MaintenanceAlert, UserAccessLog, UserAgent +from ..models import Alert, UserAccessLog, UserAgent -class TestMaintenanceAlerts(TestCase): +class TestAlerts(TestCase): def test_creates_alert(self): kwargs = {'text': "Maintenance alert"} - alert = MaintenanceAlert.objects.create(**kwargs) + alert = Alert.objects.create(**kwargs) self.assertFalse(alert.active, False) self.assertEqual(alert.text, "Maintenance alert") @@ -15,14 +15,10 @@ def test_creates_alert(self): self.assertEqual(alert.end_time, None) self.assertEqual(alert.domains, None) self.assertEqual(alert.timezone, 'UTC') - self.assertEqual( - repr(alert), - "MaintenanceAlert(text='Maintenance alert', active='False', domains='All Domains')" - ) def test_wraps_alert_links(self): kwargs = {'text': "Link to www.commcare.org"} - alert = MaintenanceAlert.objects.create(**kwargs) + alert = Alert.objects.create(**kwargs) self.assertEqual(alert.text, "Link to www.commcare.org") self.assertEqual(alert.html, 'Link to <a href="www.commcare.org">www.commcare.org</a>') @@ -36,19 +32,19 @@ def test_shows_alerts_on_schedule(self): 'start_time': future_time, 'active': True } - alert = MaintenanceAlert.objects.create(**kwargs) + alert = Alert.objects.create(**kwargs) - active_alerts = MaintenanceAlert.get_active_alerts() + active_alerts = Alert.get_active_alerts() self.assertQuerysetEqual(active_alerts, []) alert.start_time = past_time alert.save() - active_alerts = MaintenanceAlert.get_active_alerts() + active_alerts = Alert.get_active_alerts() self.assertQuerysetEqual(active_alerts, [alert]) alert.end_time = past_time alert.save() - active_alerts = MaintenanceAlert.get_active_alerts() + active_alerts = Alert.get_active_alerts() self.assertQuerysetEqual(active_alerts, []) def test_shows_alerts_without_schedule(self): @@ -56,9 +52,9 @@ def test_shows_alerts_without_schedule(self): 'text': "Maintenance alert", 'active': True } - alert = MaintenanceAlert.objects.create(**kwargs) + alert = Alert.objects.create(**kwargs) - active_alerts = MaintenanceAlert.get_active_alerts() + active_alerts = Alert.get_active_alerts() self.assertQuerysetEqual(active_alerts, [alert]) diff --git a/corehq/apps/hqwebapp/tests/test_views.py b/corehq/apps/hqwebapp/tests/test_views.py index a80ee3a00dadd..7028e07cc5caf 100644 --- a/corehq/apps/hqwebapp/tests/test_views.py +++ b/corehq/apps/hqwebapp/tests/test_views.py @@ -4,7 +4,7 @@ from corehq.apps.domain.models import Domain from corehq.apps.domain.shortcuts import create_domain from corehq.apps.domain.tests.test_views import BaseAutocompleteTest -from corehq.apps.hqwebapp.models import MaintenanceAlert +from corehq.apps.hqwebapp.models import Alert from corehq.apps.users.dbaccessors import delete_all_users from corehq.apps.users.models import CommCareUser, WebUser @@ -129,7 +129,9 @@ class TestMaintenanceAlertsView(TestCase): @classmethod def setUpClass(cls): super(TestMaintenanceAlertsView, cls).setUpClass() - create_domain(cls.domain) + cls.project = create_domain(cls.domain) + cls.addClassCleanup(cls.project.delete) + cls.user = WebUser.create( cls.domain, 'maintenance-user', @@ -139,6 +141,7 @@ def setUpClass(cls): ) cls.user.is_superuser = True cls.user.save() + cls.addClassCleanup(cls.user.delete, cls.domain, deleted_by=None) def _alert_with_timezone(self): self.client.login(username=self.user.username, password='***') @@ -149,17 +152,17 @@ def _alert_with_timezone(self): 'timezone': 'US/Eastern' } self.client.post(reverse('create_alert'), params) - return MaintenanceAlert.objects.latest('created') + return Alert.objects.latest('created') def test_create_alert(self): self.client.login(username=self.user.username, password='***') + self.assertEqual(Alert.objects.count(), 0) + self.client.post(reverse('create_alert'), {'alert_text': "Maintenance alert"}) - alert = MaintenanceAlert.objects.latest('created') + alert = Alert.objects.first() - self.assertEqual( - repr(alert), - "MaintenanceAlert(text='Maintenance alert', active='False', domains='All Domains')" - ) + self.assertEqual(alert.text, 'Maintenance alert') + self.assertIsNone(alert.domains) def test_create_converts_to_utc(self): alert = self._alert_with_timezone() @@ -180,13 +183,49 @@ def test_view_converts_from_utc(self): def test_post_commands(self): self.client.login(username=self.user.username, password='***') self.client.post(reverse('create_alert'), {'alert_text': "Maintenance alert"}) - alert = MaintenanceAlert.objects.latest('created') + alert = Alert.objects.latest('created') self.assertFalse(alert.active) self.client.post(reverse('alerts'), {'command': 'activate', 'alert_id': alert.id}) - alert = MaintenanceAlert.objects.get(id=alert.id) + alert = Alert.objects.get(id=alert.id) self.assertTrue(alert.active) self.client.post(reverse('alerts'), {'command': 'deactivate', 'alert_id': alert.id}) - alert = MaintenanceAlert.objects.get(id=alert.id) + alert = Alert.objects.get(id=alert.id) self.assertFalse(alert.active) + + def test_view_access_to_global_alerts_only(self): + global_alert = Alert.objects.create(text='Test!', domains=['test1', 'test2']) + self.addCleanup(global_alert.delete) + + domain_alert = Alert.objects.create(created_by_domain='dummy_domain') + self.addCleanup(domain_alert.delete) + assert domain_alert.pk + + self.client.login(username=self.user.username, password='***') + response = self.client.get(reverse('alerts')) + + self.assertListEqual( + response.context['alerts'], + [{ + 'active': False, + 'created': str(global_alert.created), + 'created_by_user': None, + 'domains': 'test1, test2', + 'end_time': None, + 'expired': None, + 'html': 'Test!', + 'id': global_alert.id, + 'start_time': None + + }] + ) + + def test_update_restricted_to_global_alerts(self): + domain_alert = Alert.objects.create(created_by_domain='dummy_domain') + self.addCleanup(domain_alert.delete) + + self.client.login(username=self.user.username, password='***') + with self.assertRaisesMessage(Alert.DoesNotExist, + 'Alert matching query does not exist'): + self.client.post(reverse('alerts'), {'command': 'activate', 'alert_id': domain_alert.id}) diff --git a/corehq/apps/hqwebapp/two_factor_gateways.py b/corehq/apps/hqwebapp/two_factor_gateways.py index 46b421b1cae12..2891f444dc00f 100644 --- a/corehq/apps/hqwebapp/two_factor_gateways.py +++ b/corehq/apps/hqwebapp/two_factor_gateways.py @@ -246,12 +246,12 @@ def _report_usage(ip_address, number, username): def _report_current_global_two_factor_setup_rate_limiter(): for scope, limits in global_two_factor_setup_rate_limiter.iter_rates(): - for window, value, threshold in limits: + for rate_counter, current_rate, threshold in limits: metrics_gauge('commcare.two_factor.global_two_factor_setup_threshold', threshold, tags={ - 'window': window, + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode=MPM_MAX) - metrics_gauge('commcare.two_factor.global_two_factor_setup_usage', value, tags={ - 'window': window, + metrics_gauge('commcare.two_factor.global_two_factor_setup_usage', current_rate, tags={ + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode=MPM_MAX) diff --git a/corehq/apps/hqwebapp/views.py b/corehq/apps/hqwebapp/views.py index 7eb9a9f2b0684..fc0464ee9ede7 100644 --- a/corehq/apps/hqwebapp/views.py +++ b/corehq/apps/hqwebapp/views.py @@ -423,6 +423,8 @@ def _login(req, domain_name, custom_login_page, extra_context=None): couch_user = CouchUser.get_by_username(req.POST['auth-username'].lower()) if couch_user: response.set_cookie(settings.LANGUAGE_COOKIE_NAME, couch_user.language) + # reset cookie to an empty list on login to show domain alerts again + response.set_cookie('viewed_domain_alerts', []) activate(couch_user.language) return response @@ -646,8 +648,10 @@ def debug_notify(request): try: 0 // 0 except ZeroDivisionError: - notify_exception(request, - "If you want to achieve a 500-style email-out but don't want the user to see a 500, use notify_exception(request[, message])") + notify_exception( + request, + "If you want to achieve a 500-style email-out but don't want the user to see a 500, " + "use notify_exception(request[, message])") return HttpResponse("Email should have been sent") @@ -1223,6 +1227,8 @@ def quick_find(request): is_member = result.domain and request.couch_user.is_member_of(result.domain, allow_enterprise=True) if is_member or request.couch_user.is_superuser: doc_info = get_doc_info(result.doc) + if (doc_info.type == 'CommCareCase' or doc_info.type == 'XFormInstance') and doc_info.is_deleted: + raise Http404() else: raise Http404() if redirect and doc_info.link: @@ -1255,8 +1261,8 @@ def dispatch(self, request, *args, **kwargs): @method_decorator(require_superuser) def post(self, request): - from corehq.apps.hqwebapp.models import MaintenanceAlert - ma = MaintenanceAlert.objects.get(id=request.POST.get('alert_id')) + from corehq.apps.hqwebapp.models import Alert + ma = Alert.objects.get(id=request.POST.get('alert_id'), created_by_domain=None) command = request.POST.get('command') if command == 'activate': ma.active = True @@ -1267,8 +1273,11 @@ def post(self, request): @property def page_context(self): - from corehq.apps.hqwebapp.models import MaintenanceAlert + from corehq.apps.hqwebapp.models import Alert now = datetime.utcnow() + alerts = Alert.objects.filter( + created_by_domain__isnull=True + ).order_by('-active', '-created')[:20] return { 'timezones': pytz.common_timezones, 'alerts': [{ @@ -1282,7 +1291,8 @@ def page_context(self): 'expired': alert.end_time and alert.end_time < now, 'id': alert.id, 'domains': ", ".join(alert.domains) if alert.domains else "All domains", - } for alert in MaintenanceAlert.objects.order_by('-active', '-created')[:20]] + 'created_by_user': alert.created_by_user, + } for alert in alerts] } @property @@ -1293,7 +1303,7 @@ def page_url(self): @require_POST @require_superuser def create_alert(request): - from corehq.apps.hqwebapp.models import MaintenanceAlert + from corehq.apps.hqwebapp.models import Alert alert_text = request.POST.get('alert_text') domains = request.POST.get('domains') domains = domains.split() if domains else None @@ -1311,8 +1321,9 @@ def create_alert(request): tzinfo=pytz.timezone(timezone) ).server_time().done() if end_time else None - MaintenanceAlert(active=False, text=alert_text, domains=domains, - start_time=start_time, end_time=end_time, timezone=timezone).save() + Alert(active=False, text=alert_text, domains=domains, + start_time=start_time, end_time=end_time, timezone=timezone, + created_by_user=request.couch_user.username).save() return HttpResponseRedirect(reverse('alerts')) diff --git a/corehq/apps/hqwebapp/widgets.py b/corehq/apps/hqwebapp/widgets.py index 3a285d2471d89..25f396ba42d7a 100644 --- a/corehq/apps/hqwebapp/widgets.py +++ b/corehq/apps/hqwebapp/widgets.py @@ -17,12 +17,14 @@ class BootstrapCheckboxInput(CheckboxInput): + template_name = "hqwebapp/crispy/checkbox_widget.html" def __init__(self, attrs=None, check_test=bool, inline_label=""): - super(BootstrapCheckboxInput, self).__init__(attrs, check_test) + super().__init__(attrs, check_test) self.inline_label = inline_label - def render(self, name, value, attrs=None, renderer=None): + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) extra_attrs = {'type': 'checkbox', 'name': conditional_escape(name)} extra_attrs.update(self.attrs) final_attrs = self.build_attrs(attrs, extra_attrs=extra_attrs) @@ -35,11 +37,21 @@ def render(self, name, value, attrs=None, renderer=None): if value not in ('', True, False, None): # Only add the 'value' attribute if a value is non-empty. final_attrs['value'] = force_str(value) - final_attrs['class'] = 'bootstrapcheckboxinput' - return format_html( - '<label class="checkbox"><input{} /> {}</label>', - mark_safe(flatatt(final_attrs)), # nosec: trusting the user to sanitize attributes - self.inline_label) + from corehq.apps.hqwebapp.utils.bootstrap import get_bootstrap_version, BOOTSTRAP_5 + use_bootstrap5 = get_bootstrap_version() == BOOTSTRAP_5 + final_attrs['class'] = 'form-check-input' if use_bootstrap5 else 'bootstrapcheckboxinput' + context.update({ + 'use_bootstrap5': use_bootstrap5, + 'input_id': final_attrs.get('id'), + 'inline_label': self.inline_label, + 'attrs': mark_safe(flatatt(final_attrs)), # nosec: trusting the user to sanitize attributes + }) + return context + + +class BootstrapSwitchInput(BootstrapCheckboxInput): + """Only valid for forms using Bootstrap5""" + template_name = "hqwebapp/crispy/switch_widget.html" class _Select2AjaxMixin(): diff --git a/corehq/apps/linked_domain/local_accessors.py b/corehq/apps/linked_domain/local_accessors.py index 4532befe551be..a5e18dbe08b50 100644 --- a/corehq/apps/linked_domain/local_accessors.py +++ b/corehq/apps/linked_domain/local_accessors.py @@ -109,16 +109,16 @@ def get_data_dictionary(domain): } case_properties = (CaseProperty.objects .filter(case_type=case_type_obj) - .prefetch_related("group_obj") - .order_by("group_obj__name")) + .prefetch_related("group") + .order_by("group__name")) for property in case_properties: group = case_type["groups"].get(property.group_name) if not group: group = {"properties": {}} - if property.group_obj: - group["description"] = property.group_obj.description - group["index"] = property.group_obj.index + if property.group: + group["description"] = property.group.description + group["index"] = property.group.index case_type["groups"][property.group_name] = group group["properties"][property.name] = { diff --git a/corehq/apps/linked_domain/tests/test_update_data_dictionary.py b/corehq/apps/linked_domain/tests/test_update_data_dictionary.py index 6a6db0038784c..f0aab9481934f 100644 --- a/corehq/apps/linked_domain/tests/test_update_data_dictionary.py +++ b/corehq/apps/linked_domain/tests/test_update_data_dictionary.py @@ -21,16 +21,14 @@ def setUp(self): description='Name of patient', deprecated=False, data_type='plain', - group='Suspected group', - group_obj=self.suspected_group) + group=self.suspected_group) self.suspected_name.save() self.suspected_date = CaseProperty(case_type=self.suspected, name='Date opened', description='Date the case was opened', deprecated=False, data_type='date', - group='Suspected group', - group_obj=self.suspected_group) + group=self.suspected_group) self.suspected_date.save() self.confirmed = CaseType(domain=self.domain, @@ -47,24 +45,21 @@ def setUp(self): description='Name of patient', deprecated=False, data_type='plain', - group='Confirmed group', - group_obj=self.confirmed_group) + group=self.confirmed_group) self.confirmed_name.save() self.confirmed_date = CaseProperty(case_type=self.confirmed, name='Date opened', description='Date the case was opened', deprecated=False, data_type='date', - group='Confirmed group', - group_obj=self.confirmed_group) + group=self.confirmed_group) self.confirmed_date.save() self.confirmed_test = CaseProperty(case_type=self.confirmed, name='Test', description='Type of test performed', deprecated=False, data_type='plain', - group='Confirmed group', - group_obj=self.confirmed_group) + group=self.confirmed_group) self.confirmed_test.save() def tearDown(self): @@ -160,16 +155,14 @@ def expected_case_type(domain, description, groups): description='Name of patient', deprecated=False, data_type='plain', - group='Archived group', - group_obj=self.archived_group) + group=self.archived_group) self.archived_name.save() self.archived_reason = CaseProperty(case_type=self.archived, name='Reason', description='Reason for archiving', deprecated=False, data_type='plain', - group='Archived group', - group_obj=self.archived_group) + group=self.archived_group) self.archived_reason.save() update_data_dictionary(self.domain_link) diff --git a/corehq/apps/linked_domain/updates.py b/corehq/apps/linked_domain/updates.py index 22e1eeb446578..60f341be93f43 100644 --- a/corehq/apps/linked_domain/updates.py +++ b/corehq/apps/linked_domain/updates.py @@ -587,8 +587,7 @@ def update_data_dictionary(domain_link, is_pull=False, overwrite=False): case_property_obj.deprecated = case_property_desc['deprecated'] case_property_obj.data_type = case_property_desc['data_type'] if group_name: - case_property_obj.group = group_name - case_property_obj.group_obj = group_obj + case_property_obj.group = group_obj case_property_obj.save() diff --git a/corehq/apps/linked_domain/util.py b/corehq/apps/linked_domain/util.py index 900fdce39d4aa..8267cab106f68 100644 --- a/corehq/apps/linked_domain/util.py +++ b/corehq/apps/linked_domain/util.py @@ -88,11 +88,20 @@ def pull_missing_multimedia_for_app_and_notify(domain, app_id, email, force=Fals send_html_email_async.delay(subject, email, _( "Something went wrong while pulling multimedia for your linked app. " "Our team has been notified and will monitor the situation. " - "Please try again, and if the problem persists report it as an issue.")) + "Please try again, and if the problem persists report it as an issue."), + domain=domain, + use_domain_gateway=True + ) raise else: message = _("Multimedia was successfully updated for the linked app.") - send_html_email_async.delay(subject, email, message) + send_html_email_async.delay( + subject, + email, + message, + domain=domain, + use_domain_gateway=True, + ) def pull_missing_multimedia_for_app(app, old_multimedia_ids=None, force=False): diff --git a/corehq/apps/locations/dbaccessors.py b/corehq/apps/locations/dbaccessors.py index 63877137cdc96..b42458089857e 100644 --- a/corehq/apps/locations/dbaccessors.py +++ b/corehq/apps/locations/dbaccessors.py @@ -1,6 +1,5 @@ from itertools import chain -from dimagi.utils.chunked import chunked from dimagi.utils.couch.database import iter_docs from corehq.apps.es import UserES @@ -109,71 +108,6 @@ def user_ids_at_accessible_locations(domain_name, user): return mobile_user_ids_at_locations(accessible_location_ids) -def get_user_ids_from_assigned_location_ids(domain, location_ids): - """ - Returns {user_id: [location_id, location_id, ...], ...} - """ - result = ( - UserES() - .domain(domain) - .location(location_ids) - .non_null('assigned_location_ids') - .fields(['assigned_location_ids', '_id']) - .run().hits - ) - ret = {} - for r in result: - if 'assigned_location_ids' in r: - locs = r['assigned_location_ids'] - if not isinstance(locs, list): - locs = [r['assigned_location_ids']] - ret[r['_id']] = locs - return ret - - -def get_user_ids_from_primary_location_ids(domain, location_ids): - """ - Returns {user_id: primary_location_id, ...} - """ - result = ( - UserES() - .domain(domain) - .primary_location(location_ids) - .non_null('location_id') - .fields(['location_id', '_id']) - .run().hits - ) - ret = {} - for r in result: - if 'location_id' in r: - loc = r['location_id'] - ret[r['_id']] = loc - return ret - - -def generate_user_ids_from_primary_location_ids(domain, location_ids): - """ - Creates a generator for iterating through the user ids of the all the users in the - given domain whose primary location is given in the list of location_ids. - """ - for location_ids_chunk in chunked(location_ids, 50): - for user_id in get_user_ids_from_primary_location_ids(domain, location_ids_chunk).keys(): - yield user_id - - -def generate_user_ids_from_primary_location_ids_from_couch(domain, location_ids): - """ - Creates a generator for iterating through the user ids of the all the - mobile workers in the given domain whose primary location is given in - the list of location_ids. - - Retrieves the information from couch instead of elasticsearch. - """ - for location_id in location_ids: - for user_id in get_user_ids_by_location(domain, location_id): - yield user_id - - def get_location_ids_with_location_type(domain, location_type_code): """ Returns a QuerySet with the location_ids of all the unarchived SQLLocations in the diff --git a/corehq/apps/locations/tests/test_dbaccessors.py b/corehq/apps/locations/tests/test_dbaccessors.py index 33f8a2c33633f..b356b598477ea 100644 --- a/corehq/apps/locations/tests/test_dbaccessors.py +++ b/corehq/apps/locations/tests/test_dbaccessors.py @@ -14,7 +14,6 @@ from ..analytics import users_have_locations from ..dbaccessors import ( - generate_user_ids_from_primary_location_ids_from_couch, get_all_users_by_location, get_one_user_at_location, get_user_docs_by_location, @@ -22,9 +21,8 @@ get_users_assigned_to_locations, get_users_by_location_id, get_users_location_ids, + user_ids_at_locations, mobile_user_ids_at_locations, - get_user_ids_from_assigned_location_ids, - get_user_ids_from_primary_location_ids, ) from .util import make_loc, delete_all_locations from ..dbaccessors import get_filtered_locations_count @@ -111,32 +109,6 @@ def test_get_users_assigned_to_locations(self): ) other_user.delete(self.domain, deleted_by=None) - def test_generate_user_ids_from_primary_location_ids_from_couch(self): - self.assertItemsEqual( - list( - generate_user_ids_from_primary_location_ids_from_couch( - self.domain, [self.pentos.location_id, self.meereen.location_id] - ) - ), - [self.varys._id, self.tyrion._id, self.daenerys._id] - ) - - def test_generate_user_ids_from_primary_location_ids_es(self): - self.assertItemsEqual( - get_user_ids_from_primary_location_ids( - self.domain, [self.pentos.location_id, self.meereen.location_id] - ).keys(), - [self.varys._id, self.tyrion._id, self.daenerys._id] - ) - - def test_get_user_ids_from_assigned_location_ids(self): - self.assertItemsEqual( - get_user_ids_from_assigned_location_ids( - self.domain, [self.meereen.location_id] - ).keys(), - [self.tyrion._id, self.daenerys._id] - ) - def test_get_users_location_ids(self): self.assertItemsEqual( get_users_location_ids(self.domain, [self.varys._id, self.tyrion._id]), @@ -149,6 +121,12 @@ def test_user_ids_at_locations(self): [self.daenerys._id, self.tyrion._id] ) + def test_all_user_ids_at_locations(self): + self.assertItemsEqual( + user_ids_at_locations([self.meereen._id]), + [self.daenerys._id, self.tyrion._id, self.george._id] + ) + class TestFilteredLocationsCount(TestCase): diff --git a/corehq/apps/locations/util.py b/corehq/apps/locations/util.py index 01b1cfa7dfae1..7cfce92469033 100644 --- a/corehq/apps/locations/util.py +++ b/corehq/apps/locations/util.py @@ -1,4 +1,5 @@ import re +import os import tempfile from collections import OrderedDict @@ -297,6 +298,7 @@ def dump_locations(domain, download_id, include_consumption, headers_only, headers_only=headers_only, async_task=task, **kwargs) fd, path = tempfile.mkstemp() + os.close(fd) writer = Excel2007ExportWriter() writer.open(header_table=exporter.get_headers(), file=path) with writer: diff --git a/corehq/apps/ota/tests/test_utils.py b/corehq/apps/ota/tests/test_utils.py index 546ad512b5bda..ca7b5aefdf93b 100644 --- a/corehq/apps/ota/tests/test_utils.py +++ b/corehq/apps/ota/tests/test_utils.py @@ -246,9 +246,7 @@ def setUpClass(cls): password='***', created_by=None, created_via=None, - metadata={ - "login_as_user": cls.restore_user.username - }, + user_data={"login_as_user": cls.restore_user.username}, ) cls.commcare_user_login_as_multiple_upper_case = CommCareUser.create( username=format_username('cabernet', cls.domain), @@ -256,7 +254,7 @@ def setUpClass(cls): password='***', created_by=None, created_via=None, - metadata={ + user_data={ "login_as_user": f"{format_username('ruby', cls.domain)} {cls.restore_user.username.upper()}" }, ) @@ -266,7 +264,7 @@ def setUpClass(cls): password='***', created_by=None, created_via=None, - metadata={ + user_data={ "login_as_user": "someone@else deFAUlt" # intentionally mixed case to test case sensitivity }, ) diff --git a/corehq/apps/ota/utils.py b/corehq/apps/ota/utils.py index 34ae7834c1ba8..1a50bba355098 100644 --- a/corehq/apps/ota/utils.py +++ b/corehq/apps/ota/utils.py @@ -144,7 +144,7 @@ def _ensure_valid_restore_as_user(domain, couch_user, as_user_obj): if _limit_login_as(domain, couch_user): # Functionality should match the ES query. # See corehq.apps.cloudcare.esaccessors.login_as_user_query - login_as_username = as_user_obj.metadata.get('login_as_user') or '' + login_as_username = as_user_obj.get_user_data(domain).get('login_as_user') or '' candidates = login_as_username.lower().split() if couch_user.username.lower() not in candidates: is_default = 'default' in candidates diff --git a/corehq/apps/ota/views.py b/corehq/apps/ota/views.py index 38c31b3e0a323..498d6e79a9bab 100644 --- a/corehq/apps/ota/views.py +++ b/corehq/apps/ota/views.py @@ -76,6 +76,7 @@ handle_401_response, is_permitted_to_restore, ) +from corehq.util.metrics import metrics_histogram PROFILE_PROBABILITY = float(os.getenv('COMMCARE_PROFILE_RESTORE_PROBABILITY', 0)) PROFILE_LIMIT = os.getenv('COMMCARE_PROFILE_RESTORE_LIMIT') @@ -121,12 +122,20 @@ def app_aware_search(request, domain, app_id): Returns results as a fixture with the same structure as a casedb instance. """ + start_time = datetime.now() request_dict = request.GET if request.method == 'GET' else request.POST try: cases = get_case_search_results_from_request(domain, app_id, request.couch_user, request_dict) except CaseSearchUserError as e: return HttpResponse(str(e), status=400) fixtures = CaseDBFixture(cases).fixture + end_time = datetime.now() + metrics_histogram("commcare.app_aware_search.processing_time", + int((end_time - start_time).total_seconds() * 1000), + bucket_tag='duration_bucket', + buckets=(500, 1000, 5000), + bucket_unit='ms', + tags={'domain': domain}) return HttpResponse(fixtures, content_type="text/xml; charset=utf-8") @@ -370,7 +379,7 @@ def update_user_reporting_data(app_build_id, app_id, build_profile_id, couch_use def _safe_int(val): try: return int(val) - except: + except Exception: pass app_version = _safe_int(request.GET.get('app_version', '')) diff --git a/corehq/apps/receiverwrapper/rate_limiter.py b/corehq/apps/receiverwrapper/rate_limiter.py index d6908e62b9ade..00d442c98507b 100644 --- a/corehq/apps/receiverwrapper/rate_limiter.py +++ b/corehq/apps/receiverwrapper/rate_limiter.py @@ -210,13 +210,13 @@ def _delay_and_report_rate_limit_submission(domain, max_wait, delay_rather_than_ @quickcache([], timeout=60) # Only report up to once a minute def _report_current_global_submission_thresholds(): for scope, limits in global_submission_rate_limiter.iter_rates(): - for window, value, threshold in limits: + for rate_counter, value, threshold in limits: metrics_gauge('commcare.xform_submissions.global_threshold', threshold, tags={ - 'window': window, + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode='max') metrics_gauge('commcare.xform_submissions.global_usage', value, tags={ - 'window': window, + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode='max') @@ -224,12 +224,12 @@ def _report_current_global_submission_thresholds(): @quickcache([], timeout=60) # Only report up to once a minute def _report_current_global_case_update_thresholds(): for scope, limits in global_case_rate_limiter.iter_rates(): - for window, value, threshold in limits: + for rate_counter, value, threshold in limits: metrics_gauge('commcare.case_updates.global_threshold', threshold, tags={ - 'window': window, + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode='max') metrics_gauge('commcare.case_updates.global_usage', value, tags={ - 'window': window, + 'window': rate_counter.key, 'scope': scope }, multiprocess_mode='max') diff --git a/corehq/apps/registration/forms.py b/corehq/apps/registration/forms.py index ef5356cafddad..c5ed7c24b5be2 100644 --- a/corehq/apps/registration/forms.py +++ b/corehq/apps/registration/forms.py @@ -387,6 +387,8 @@ class BaseUserInvitationForm(NoAutocompleteMixin, forms.Form): def __init__(self, *args, **kwargs): self.is_sso = kwargs.pop('is_sso', False) + self.allow_invite_email_only = kwargs.pop('allow_invite_email_only', False) + self.invite_email = kwargs.pop('invite_email', False) super().__init__(*args, **kwargs) if settings.ENFORCE_SSO_LOGIN and self.is_sso: @@ -446,19 +448,26 @@ def __init__(self, *args, **kwargs): else: # web users login with their emails self.fields['email'].help_text = _('You will use this email to log in.') + if self.allow_invite_email_only: + self.fields['email'].widget.attrs['readonly'] = 'readonly' def clean_email(self): - data = super().clean_email() + email = super().clean_email() # web user login emails should be globally unique - duplicate = CouchUser.get_by_username(data) + if self.allow_invite_email_only and email != self.invite_email.lower(): + raise forms.ValidationError(_( + "You can only sign up with the email address your invitation was sent to." + )) + + duplicate = CouchUser.get_by_username(email) if duplicate: # sync django user duplicate.save() - if User.objects.filter(username__iexact=data).count() > 0 or duplicate: + if User.objects.filter(username__iexact=email).count() > 0 or duplicate: raise forms.ValidationError(_( 'Username already taken. Please try another or log in.' )) - return data + return email class MobileWorkerAccountConfirmationForm(BaseUserInvitationForm): diff --git a/corehq/apps/registration/static/registration/images/commcare.png b/corehq/apps/registration/static/registration/images/commcare.png new file mode 100644 index 0000000000000..742dac64d284b Binary files /dev/null and b/corehq/apps/registration/static/registration/images/commcare.png differ diff --git a/corehq/apps/registration/static/registration/images/commcare_by_dimagi.png b/corehq/apps/registration/static/registration/images/commcare_by_dimagi.png deleted file mode 100644 index cf88c79fd7d1e..0000000000000 Binary files a/corehq/apps/registration/static/registration/images/commcare_by_dimagi.png and /dev/null differ diff --git a/corehq/apps/registration/static/registration/images/dimagi.png b/corehq/apps/registration/static/registration/images/dimagi.png new file mode 100644 index 0000000000000..941ec92db21d7 Binary files /dev/null and b/corehq/apps/registration/static/registration/images/dimagi.png differ diff --git a/corehq/apps/registration/static/registration/images/hero-bg.jpg b/corehq/apps/registration/static/registration/images/hero-bg.jpg new file mode 100644 index 0000000000000..3c92a842c06b8 Binary files /dev/null and b/corehq/apps/registration/static/registration/images/hero-bg.jpg differ diff --git a/corehq/apps/registration/static/registration/images/hero-bg.png b/corehq/apps/registration/static/registration/images/hero-bg.png deleted file mode 100644 index 51a4ed5044d55..0000000000000 Binary files a/corehq/apps/registration/static/registration/images/hero-bg.png and /dev/null differ diff --git a/corehq/apps/registration/static/registration/js/login.js b/corehq/apps/registration/static/registration/js/login.js index 48bceb438faa4..8df7d96a24cd4 100644 --- a/corehq/apps/registration/static/registration/js/login.js +++ b/corehq/apps/registration/static/registration/js/login.js @@ -1,16 +1,24 @@ hqDefine('registration/js/login', [ 'jquery', + 'blazy/blazy', 'analytix/js/kissmetrix', 'registration/js/user_login_form', 'hqwebapp/js/initial_page_data', 'hqwebapp/js/captcha', // shows captcha ], function ( $, + blazy, kissmetrics, userLoginForm, initialPageData ) { $(function () { + // Blazy for loading images asynchronously + // Usage: specify the b-lazy class on an element and adding the path + // to the image in data-src="{% static 'path/to/image.jpg' %}" + new blazy({ + container: 'body', + }); // populate username field if set in the query string var urlParams = new URLSearchParams(window.location.search); diff --git a/corehq/apps/registration/templates/registration/email/_base.html b/corehq/apps/registration/templates/registration/email/_base.html index 8e37cab47627c..7e5b89aa5eab2 100644 --- a/corehq/apps/registration/templates/registration/email/_base.html +++ b/corehq/apps/registration/templates/registration/email/_base.html @@ -71,23 +71,34 @@ </div>{# /preview-text #} <div class="container" style="background-image: url('https://s3.amazonaws.com/dimagidotcom-staging-staticfiles/test/hero-bg.jpg'); line-height: 1.5em; font-size: 15px; font-weight: 400; color: #1c2126; width: 680px; background-color: #ffffff; margin: 0 auto; overflow: hidden; "> - <div class="hero" style="background-image: url('{{ url_prefix }}{% static 'registration/images/hero-bg.png' %}'); background-color: #5d70d2; height: 383px; width: 100%; background-position: center right; background-repeat: no-repeat; background-size: cover;" class="hero"> + <div class="hero" style="background-image: url('{{ url_prefix }}{% static 'registration/images/hero-bg.jpg' %}'); background-color: #333333; height: 330px; width: 100%; background-position: center; background-repeat: no-repeat; background-size: cover;" class="hero"> + {# START Dimagi Logo #} + <div style="text-align: center; padding: 20px 0;"> + <img src="{{ url_prefix }}{% static 'registration/images/dimagi.png' %}" width="53" height="25" /> + </div> + {# END Dimagi Logo #} {% block hero %} + {# START CommCare HQ Logo #} + <div style="text-align: center; padding-bottom: 10px;"> + <img src="{{ url_prefix }}{% static 'registration/images/commcare.png' %}" width="250" height="52" /> + </div> + {# END CommCare HQ Logo #} - {# START CommCare by Dimagi Logo #} - <div style="text-align: left; padding-top: 29px; padding-left: 43px;"> - <img src="{{ url_prefix }}{% static 'registration/images/commcare_by_dimagi.png' %}" width="228" height="51" - alt="CommCare by Dimagi" /> + {# START CTA Headline #} + <div style="text-align: center; font-weight: 800; color: #ffffff; font-size: 30px; line-height: 1.1em; padding-bottom: 15px;"> + {% block cta_headline %} + CTA HEADLINE -- Replace! + {% endblock %} </div> - {# END CommCare by Dimagi Logo #} + {# END CTA Headline #} {# START CTA Button #} - <div style="text-align: left; padding-top: 160px; padding-left: 43px;"> + <div style="text-align: center; margin-top: 0px;"> <a class="btn" href="{% block cta_url %}#{% endblock %}" - style="color: #5d70d2; text-decoration: none; padding: 13px 21px; background-color: #ffffff; border-radius: 5px; font-size: 14px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; -moz-border-radius: 5px; -webkit-border-radius: 5px; display: inline-block; cursor: pointer;"> + style="color: #ffffff; text-decoration: none; padding: 15px 20px; background-color: #5c6ac5; border-radius: 5px; font-size: 14px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; -moz-border-radius: 5px; -webkit-border-radius: 5px; display: inline-block; cursor: pointer;"> {% block cta_text %} CTA Text -- Replace! {% endblock %} @@ -101,14 +112,6 @@ <div class="content" style="padding: 20px 45px 35px; line-height: 1.2em; background-color: #f4f5fa; color: #1c2126;"> - {# START CTA Headline #} - <div style="text-align: center; font-weight: 800; font-size: 30px; line-height: 1.1em; padding: 10px 0;"> - {% block cta_headline %} - CTA HEADLINE -- Replace! - {% endblock %} - </div> - {# END CTA Headline #} - <div style="text-align: center; font-size: 1.6em; line-height: 1.2em; margin: 15px 0;"> {% block lead_text %}Lead Text -- Replace!{% endblock %} </div> diff --git a/corehq/apps/registration/templates/registration/email/password_reset_email_hq.html b/corehq/apps/registration/templates/registration/email/password_reset_email_hq.html index 30dc4f4604ab5..06b310c90b509 100644 --- a/corehq/apps/registration/templates/registration/email/password_reset_email_hq.html +++ b/corehq/apps/registration/templates/registration/email/password_reset_email_hq.html @@ -11,7 +11,7 @@ {% blocktrans %} Reset password for {% endblocktrans %} - <a style="color: #5d70d2 !important; text-decoration: none !important;">{{ user.get_username }}</a> + <a style="color: #ffffff !important; text-decoration: none !important;">{{ user.get_username }}</a> {% endblock %} {% block cta_url %}{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}{% endblock %} diff --git a/corehq/apps/registration/templates/registration/partials/choose_your_plan.html b/corehq/apps/registration/templates/registration/partials/choose_your_plan.html index 6d1cb71a482b3..9b32619ba475e 100644 --- a/corehq/apps/registration/templates/registration/partials/choose_your_plan.html +++ b/corehq/apps/registration/templates/registration/partials/choose_your_plan.html @@ -2,100 +2,90 @@ {% load hq_shared_tags %} <div id="registration-choose-plan-container" - class="reg-plan-container"> + class="reg-form-container reg-plan-container"> <div class="row"> <div class="col-xs-6"> - <div class="plan-container plan-container-community"> + <div class="plan-container plan-container-professional"> <div class="plan-header"> - <div class="plan-icon"> - <i class="fa fa-user"></i> - </div> <h2> <small>CommCare</small> - Community + Professional </h2> </div> - <div class="plan-body"> - <p class="lead"> + <p class="lead"> + {% blocktrans %} + For organizations exploring CommCare. + {% endblocktrans %} + </p> + {% include 'registration/partials/feature_list.html' with features=professional_features %} + <p class="plan-price"> + {% blocktrans %} + See <a href="https://dimagi.com/commcare/pricing/" target="_blank">pricing</a> options. + {% endblocktrans %} + </p> + <div class="plan-cta"> + <a href="#cta-form-start-trial" + id="js-get-trial" + data-toggle="modal" + class="btn btn-primary"> {% blocktrans %} - For individuals learning<br/> about data collection. + Request Custom Trial {% endblocktrans %} - </p> - {% include 'registration/partials/feature_list.html' with features=community_features %} - <p class="plan-price"> - {% blocktrans %} - Free for personal use. - {% endblocktrans %} - </p> - <div class="plan-cta"> - <a href="#" - id="js-create-account" - class="btn btn-default"> + </a> + <script src="//fast.wistia.com/embed/medias/jzm3ggrinr.jsonp" async></script> + <script src="//fast.wistia.com/assets/external/E-v1.js" async></script> + <span class="wistia_embed + wistia_async_jzm3ggrinr + popover=true + popoverPreventScroll=true + popoverBorderRadius=10 + popoverOverlayOpacity=.75 + playbar=true + smallPlayButton=true + volumeControl=false + settingsControl=false + fullscreenButton=true + controlsVisibleOnLoad=false + popoverContent=link"> + <a href="#"> + <i class="fa fa-play-circle"></i> {% blocktrans %} - Create Account + Watch Demo {% endblocktrans %} </a> - </div> + </span> </div> </div> </div> <div class="col-xs-6"> - <div class="plan-container plan-container-professional"> + <div class="plan-container plan-container-community"> <div class="plan-header"> - <div class="plan-icon"> - <i class="fa fa-briefcase"></i> - </div> <h2> <small>CommCare</small> - Professional + Community </h2> </div> - <div class="plan-body"> - <p class="lead"> - {% blocktrans %} - For organizations<br /> exploring CommCare. - {% endblocktrans %} - </p> - {% include 'registration/partials/feature_list.html' with features=professional_features %} - <p class="plan-price"> + <p class="lead"> + {% blocktrans %} + For individuals learning about data collection. + {% endblocktrans %} + </p> + {% include 'registration/partials/feature_list.html' with features=community_features %} + <p class="plan-price"> + {% blocktrans %} + Free for personal use. + {% endblocktrans %} + </p> + <div class="plan-cta"> + <a href="#" + id="js-create-account" + class="btn btn-default"> {% blocktrans %} - See <a href="https://dimagi.com/commcare/pricing/" target="_blank">pricing</a> options. + Create Account {% endblocktrans %} - </p> - <div class="plan-cta"> - <a href="#cta-form-start-trial" - id="js-get-trial" - data-toggle="modal" - class="btn btn-primary"> - {% blocktrans %} - Request Custom Trial - {% endblocktrans %} - </a> - <script src="//fast.wistia.com/embed/medias/jzm3ggrinr.jsonp" async></script> - <script src="//fast.wistia.com/assets/external/E-v1.js" async></script> - <span class="wistia_embed - wistia_async_jzm3ggrinr - popover=true - popoverPreventScroll=true - popoverBorderRadius=10 - popoverOverlayOpacity=.75 - playbar=true - smallPlayButton=true - volumeControl=false - settingsControl=false - fullscreenButton=true - controlsVisibleOnLoad=false - popoverContent=link"> - <a href="#"> - <i class="fa fa-play-circle"></i> - {% blocktrans %} - Watch Demo - {% endblocktrans %} - </a> - </span> - </div> + </a> </div> </div> </div> diff --git a/corehq/apps/registration/templates/registration/partials/feature_list.html b/corehq/apps/registration/templates/registration/partials/feature_list.html index 238d95d661b1e..2226372a6c15d 100644 --- a/corehq/apps/registration/templates/registration/partials/feature_list.html +++ b/corehq/apps/registration/templates/registration/partials/feature_list.html @@ -3,7 +3,7 @@ <ul class="check-list"> {% for feature in features %} <li> - <i class="fa fa-check-circle"></i> + <i class="fa fa-check-square"></i> {{ feature }} </li> {% endfor %} diff --git a/corehq/apps/registry/notifications.py b/corehq/apps/registry/notifications.py index 5dd6394965215..bd9ab42375fbf 100644 --- a/corehq/apps/registry/notifications.py +++ b/corehq/apps/registry/notifications.py @@ -57,4 +57,6 @@ def _send_registry_email(for_domain, subject, template, context): send_html_email_async.delay( subject, recipients, email_html, text_content=email_plaintext, + domain=for_domain, + use_domain_gateway=True, ) diff --git a/corehq/apps/reports/generic.py b/corehq/apps/reports/generic.py index d3ed12025f52e..86b06a2e6ac56 100644 --- a/corehq/apps/reports/generic.py +++ b/corehq/apps/reports/generic.py @@ -828,6 +828,7 @@ class GenericTabularReport(GenericReportView): # new class properties total_row = None statistics_rows = None + force_page_size = False # force page size to be as the default rows default_rows = 10 start_at_row = 0 show_all_rows = False @@ -1083,6 +1084,7 @@ def report_context(self): rows=rows, total_row=self.total_row, statistics_rows=self.statistics_rows, + force_page_size=self.force_page_size, default_rows=self.default_rows, start_at_row=self.start_at_row, show_all_rows=self.show_all_rows, @@ -1100,6 +1102,7 @@ def report_context(self): context.update({ 'report_table_js_options': { 'datatables': report_table['datatables'], + 'force_page_size': report_table['force_page_size'], 'default_rows': report_table['default_rows'] or 10, 'start_at_row': report_table['start_at_row'] or 0, 'show_all_rows': report_table['show_all_rows'], diff --git a/corehq/apps/reports/standard/cases/case_data.py b/corehq/apps/reports/standard/cases/case_data.py index d79d6013a3ffd..8d75187b0a96e 100644 --- a/corehq/apps/reports/standard/cases/case_data.py +++ b/corehq/apps/reports/standard/cases/case_data.py @@ -296,7 +296,7 @@ def _get_dd_props_by_group(domain, case_type): case_type__domain=domain, case_type__name=case_type, deprecated=False, - ).select_related('group_obj').order_by('group_obj__index', 'index'): + ).select_related('group').order_by('group__index', 'index'): ret[prop.group_name or None].append(prop) uncategorized = ret.pop(None, None) diff --git a/corehq/apps/reports/static/reports/js/config.dataTables.bootstrap.js b/corehq/apps/reports/static/reports/js/config.dataTables.bootstrap.js index bc8a431e93d5f..616ee95836557 100644 --- a/corehq/apps/reports/static/reports/js/config.dataTables.bootstrap.js +++ b/corehq/apps/reports/static/reports/js/config.dataTables.bootstrap.js @@ -12,6 +12,7 @@ hqDefine("reports/js/config.dataTables.bootstrap", [ var self = {}; self.dataTableElem = options.dataTableElem || '.datatable'; self.paginationType = options.paginationType || 'bs_normal'; + self.forcePageSize = options.forcePageSize || false; self.defaultRows = options.defaultRows || 10; self.startAtRowNum = options.startAtRowNum || 0; self.showAllRowsOption = options.showAllRowsOption || false; @@ -207,6 +208,10 @@ hqDefine("reports/js/config.dataTables.bootstrap", [ if (self.aoColumns) params.aoColumns = self.aoColumns; + if (self.forcePageSize) { + // limit the page size option to just the default size + params.lengthMenu = [self.defaultRows]; + } var datatable = $(this).dataTable(params); if (!self.datatable) self.datatable = datatable; diff --git a/corehq/apps/reports/static/reports/js/hq_report.js b/corehq/apps/reports/static/reports/js/hq_report.js index 2615c5cb3b00e..f6f713228b7df 100644 --- a/corehq/apps/reports/static/reports/js/hq_report.js +++ b/corehq/apps/reports/static/reports/js/hq_report.js @@ -100,8 +100,13 @@ hqDefine("reports/js/hq_report", [ self.handleTabularReportCookies = function (reportDatatable) { var defaultRowsCookieName = 'hqreport.tabularSetting.defaultRows', savedPath = window.location.pathname; - var defaultRowsCookie = '' + $.cookie(defaultRowsCookieName); - reportDatatable.defaultRows = parseInt(defaultRowsCookie) || reportDatatable.defaultRows; + + if (!reportDatatable.forcePageSize) { + // set the current pagination page size to be equal to page size + // used by the user last time for any report on HQ + var defaultRowsCookie = '' + $.cookie(defaultRowsCookieName); + reportDatatable.defaultRows = parseInt(defaultRowsCookie) || reportDatatable.defaultRows; + } $(reportDatatable.dataTableElem).on('hqreport.tabular.lengthChange', function (event, value) { $.cookie(defaultRowsCookieName, value, { diff --git a/corehq/apps/reports/static/reports/js/tabular.js b/corehq/apps/reports/static/reports/js/tabular.js index 33bcb5050ee1a..ebe772a93b07b 100644 --- a/corehq/apps/reports/static/reports/js/tabular.js +++ b/corehq/apps/reports/static/reports/js/tabular.js @@ -16,6 +16,7 @@ hqDefine("reports/js/tabular", [ var tableConfig = tableOptions, options = { dataTableElem: '#report_table_' + slug, + forcePageSize: tableConfig.force_page_size, defaultRows: tableConfig.default_rows, startAtRowNum: tableConfig.start_at_row, showAllRowsOption: tableConfig.show_all_rows, diff --git a/corehq/apps/reports/tasks.py b/corehq/apps/reports/tasks.py index 1197b3dd91e20..9542005d3fbd0 100644 --- a/corehq/apps/reports/tasks.py +++ b/corehq/apps/reports/tasks.py @@ -170,25 +170,22 @@ def export_all_rows_task(ReportClass, report_state, recipient_list=None, subject file = report.excel_response report_class = report.__class__.__module__ + '.' + report.__class__.__name__ - if report.domain is None: - # Some HQ-wide reports (e.g. accounting/smsbillables) will not have a domain associated with them - # This uses the user's first domain to store the file in the blobdb - report.domain = report.request.couch_user.get_domains()[0] + # Some HQ-wide reports (e.g. accounting/smsbillables) will not have a domain associated with them + # This uses the user's first domain to store the file in the blobdb + report_storage_domain = report.request.couch_user.get_domains()[0] if report.domain is None else report.domain - hash_id = _store_excel_in_blobdb(report_class, file, report.domain, report.slug) + hash_id = _store_excel_in_blobdb(report_class, file, report_storage_domain, report.slug) logger.info(f'Stored report {report.name} with parameters: {report_state["request_params"]} in hash {hash_id}') if not recipient_list: recipient_list = [report.request.couch_user.get_email()] for recipient in recipient_list: - _send_email(report.request.couch_user, report, hash_id, recipient=recipient, subject=subject) + link = absolute_reverse("export_report", args=[report_storage_domain, str(hash_id), report.export_format]) + _send_email(report, link, recipient=recipient, subject=subject) logger.info(f'Sent {report.name} with hash {hash_id} to {recipient}') -def _send_email(user, report, hash_id, recipient, subject=None): - link = absolute_reverse("export_report", args=[report.domain, str(hash_id), - report.export_format]) - - send_report_download_email(report.name, recipient, link, subject) +def _send_email(report, link, recipient, subject=None): + send_report_download_email(report.name, recipient, link, subject, domain=report.domain) def _store_excel_in_blobdb(report_class, file, domain, report_slug): diff --git a/corehq/apps/reports/tests/test_case_data.py b/corehq/apps/reports/tests/test_case_data.py index 1bb705f3706dc..ad7ce7e7720e9 100644 --- a/corehq/apps/reports/tests/test_case_data.py +++ b/corehq/apps/reports/tests/test_case_data.py @@ -123,7 +123,7 @@ def _create_case_property(self, prop_name, group=None): group_obj = None if group: group_obj, _ = CasePropertyGroup.objects.get_or_create(name=group, case_type=case_type_obj) - CaseProperty.objects.get_or_create(case_type=case_type_obj, name=prop_name, group_obj=group_obj) + CaseProperty.objects.get_or_create(case_type=case_type_obj, name=prop_name, group=group_obj) @unit_testing_only diff --git a/corehq/apps/reports/tests/test_esaccessors.py b/corehq/apps/reports/tests/test_esaccessors.py index 9aeb9201ec2f7..04312d19a7418 100644 --- a/corehq/apps/reports/tests/test_esaccessors.py +++ b/corehq/apps/reports/tests/test_esaccessors.py @@ -988,7 +988,7 @@ def setUpClass(cls): first_name='clark', last_name='kent', is_active=True, - metadata={PROFILE_SLUG: cls.profile.id, 'office': 'phone_booth'}, + user_data={PROFILE_SLUG: cls.profile.id, 'office': 'phone_booth'}, ) cls.user.save() diff --git a/corehq/apps/reports/util.py b/corehq/apps/reports/util.py index c07605f435aa0..3d7095001301a 100644 --- a/corehq/apps/reports/util.py +++ b/corehq/apps/reports/util.py @@ -6,7 +6,6 @@ from datetime import datetime import pytz -from django.conf import settings from django.core.cache import cache from django.db.transaction import atomic from django.http import Http404 @@ -383,7 +382,7 @@ def is_query_too_big(domain, mobile_user_and_group_slugs, request_user): return user_es_query.count() > USER_QUERY_LIMIT -def send_report_download_email(title, recipient, link, subject=None): +def send_report_download_email(title, recipient, link, subject=None, domain=None): if subject is None: subject = _("%s: Requested export excel data") % title body = "The export you requested for the '%s' report is ready.<br>" \ @@ -394,7 +393,8 @@ def send_report_download_email(title, recipient, link, subject=None): subject, recipient, _(body) % (title, "<a href='%s'>%s</a>" % (link, link)), - email_from=settings.DEFAULT_FROM_EMAIL + domain=domain, + use_domain_gateway=True, ) diff --git a/corehq/apps/saved_reports/models.py b/corehq/apps/saved_reports/models.py index 341a9b8ce25a2..67612b002d928 100644 --- a/corehq/apps/saved_reports/models.py +++ b/corehq/apps/saved_reports/models.py @@ -848,9 +848,10 @@ def _send_emails(self, title, report_text, emails, excel_files): def _send_email(self, title, email, body, excel_files): send_HTML_email( title, email, body, - email_from=settings.DEFAULT_FROM_EMAIL, file_attachments=excel_files, - smtp_exception_skip_list=LARGE_FILE_SIZE_ERROR_CODES) + smtp_exception_skip_list=LARGE_FILE_SIZE_ERROR_CODES, + domain=self.domain, + use_domain_gateway=True,) def _send_only_attachments(self, title, emails, excel_files): message = _("Unable to generate email report. Excel files are attached.") @@ -858,8 +859,9 @@ def _send_only_attachments(self, title, emails, excel_files): title, emails, message, - email_from=settings.DEFAULT_FROM_EMAIL, - file_attachments=excel_files + file_attachments=excel_files, + domain=self.domain, + use_domain_gateway=True, ) def _export_report(self, emails, title): diff --git a/corehq/apps/saved_reports/tasks.py b/corehq/apps/saved_reports/tasks.py index 2cf49c462b7a8..dc642d9df6da6 100644 --- a/corehq/apps/saved_reports/tasks.py +++ b/corehq/apps/saved_reports/tasks.py @@ -149,8 +149,8 @@ def send_email_report(self, recipient_emails, domain, report_slug, report_type, for recipient in recipient_emails: send_HTML_email(subject, recipient, - body, email_from=settings.DEFAULT_FROM_EMAIL, - smtp_exception_skip_list=LARGE_FILE_SIZE_ERROR_CODES) + body, smtp_exception_skip_list=LARGE_FILE_SIZE_ERROR_CODES, + domain=domain, use_domain_gateway=True,) except Exception as er: notify_exception( diff --git a/corehq/apps/saved_reports/tests/test_models.py b/corehq/apps/saved_reports/tests/test_models.py index 98c203e48ae3f..1a8cb3a269951 100644 --- a/corehq/apps/saved_reports/tests/test_models.py +++ b/corehq/apps/saved_reports/tests/test_models.py @@ -1,6 +1,5 @@ from datetime import datetime, timedelta from django.http.request import QueryDict -from django.conf import settings from django.test import SimpleTestCase, TestCase from smtplib import SMTPSenderRefused from dimagi.utils.dates import DateSpan @@ -227,8 +226,8 @@ def test_huge_reports_with_attachments_resend_only_attachments(self): self.mock_send_email.assert_called_with('Test Report', ['test1@dimagi.com', 'test2@dimagi.com'], 'Unable to generate email report. Excel files are attached.', - email_from=settings.DEFAULT_FROM_EMAIL, - file_attachments=['abba']) + file_attachments=['abba'], + domain='test-domain', use_domain_gateway=True) def test_failing_emails_are_logged(self): self.mock_send_email.side_effect = Exception('Email failed to send') diff --git a/corehq/apps/settings/tasks.py b/corehq/apps/settings/tasks.py index 3eb19a1c0ba38..3a55c769151ef 100644 --- a/corehq/apps/settings/tasks.py +++ b/corehq/apps/settings/tasks.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta -from django.conf import settings from django.template.loader import render_to_string import pytz @@ -53,4 +52,5 @@ def notify_about_to_expire_api_keys(): send_html_email_async.delay(subject, key.user.email, html_content, text_content=text_content, - email_from=settings.DEFAULT_FROM_EMAIL) + domain=key.domain, + use_domain_gateway=True) diff --git a/corehq/apps/sms/api.py b/corehq/apps/sms/api.py index f094eeff7b899..3bbae9c650087 100644 --- a/corehq/apps/sms/api.py +++ b/corehq/apps/sms/api.py @@ -390,8 +390,6 @@ def register_sms_user( username, cleaned_phone_number, domain, send_welcome_sms=False, admin_alert_emails=None ): try: - user_data = {} - username = process_username(username, domain) password = random_password() new_user = CommCareUser.create( @@ -400,7 +398,6 @@ def register_sms_user( password, created_by=None, created_via=USER_CHANGE_VIA_SMS, - metadata=user_data ) new_user.add_phone_number(cleaned_phone_number) new_user.save() @@ -457,7 +454,8 @@ def send_admin_registration_alert(domain, recipients, user): "domain": domain, "url": absolute_reverse(EditCommCareUserView.urlname, args=[domain, user.get_id]) }) - send_html_email_async.delay(subject, recipients, html_content, domain=domain) + send_html_email_async.delay(subject, recipients, html_content, + domain=domain, use_domain_gateway=True) def is_registration_text(text): @@ -487,9 +485,10 @@ def process_sms_registration(msg): 1) Select "Enable Mobile Worker Registration via SMS" in project settings. - 2) Text in "join <domain> worker <username>", where <domain> is the domain to join and <username> is the - requested username. If the username doesn't exist it will be created, otherwise the registration will error. - If the username argument is not specified, the username will be the mobile number + 2) Text in "join <domain> worker <username>", where <domain> is the domain to join + and <username> is the requested username. If the username doesn't exist it will be + created, otherwise the registration will error. If the username argument is not specified, + the username will be the mobile number The "join" and "worker" keywords can be any keyword in REGISTRATION_KEYWORDS and REGISTRATION_MOBILE_WORKER_KEYWORDS, respectively. This is meant to support multiple @@ -784,9 +783,9 @@ def _process_incoming(msg): # If the sms queue is enabled, then the billable gets created in remove_from_queue() if ( - not settings.SMS_QUEUE_ENABLED and - msg.domain and - domain_has_privilege(msg.domain, privileges.INBOUND_SMS) + not settings.SMS_QUEUE_ENABLED + and msg.domain + and domain_has_privilege(msg.domain, privileges.INBOUND_SMS) ): create_billable_for_sms(msg) diff --git a/corehq/apps/smsforms/tests/test_app.py b/corehq/apps/smsforms/tests/test_app.py index 856b37e922446..38f8ddc170858 100644 --- a/corehq/apps/smsforms/tests/test_app.py +++ b/corehq/apps/smsforms/tests/test_app.py @@ -22,10 +22,12 @@ ) from corehq.apps.smsforms.models import SQLXFormsSession from corehq.apps.users.models import WebUser +from corehq.apps.users.tests.util import patch_user_data_db_layer from corehq.form_processor.models import CommCareCase from corehq.messaging.scheduling.util import utcnow +@patch_user_data_db_layer @patch('corehq.apps.smsforms.app.tfsms.start_session') class TestStartSession(TestCase): domain = "test-domain" @@ -127,6 +129,7 @@ def test_start_session_as_user(self, xform_config_mock, tfsms_start_session_mock 'commcare_first_name': None, 'commcare_last_name': None, 'commcare_phone_number': None, + 'commcare_profile': '', 'commcare_project': self.domain, 'commcare_user_type': 'web', }, diff --git a/corehq/apps/translations/app_translations/upload_app.py b/corehq/apps/translations/app_translations/upload_app.py index 3b0ef76cdf4a3..556c68caffd2c 100644 --- a/corehq/apps/translations/app_translations/upload_app.py +++ b/corehq/apps/translations/app_translations/upload_app.py @@ -48,7 +48,7 @@ def validate_bulk_app_translation_upload(app, workbook, email, lang_to_compare, msgs = UploadedTranslationsValidator(app, workbook, lang_to_compare).compare() checker_messages, result_wb = run_translation_checker(file_obj) if msgs or checker_messages: - _email_app_translations_discrepancies(msgs, checker_messages, email, app.name, result_wb) + _email_app_translations_discrepancies(msgs, checker_messages, email, app.name, result_wb, app.domain) return [(messages.error, _("Issues found. You should receive an email shortly."))] else: return [(messages.success, _("No issues found."))] @@ -65,13 +65,14 @@ def run_translation_checker(file_obj): return translation_checker_messages, result_wb -def _email_app_translations_discrepancies(msgs, checker_messages, email, app_name, result_wb): +def _email_app_translations_discrepancies(msgs, checker_messages, email, app_name, result_wb, domain): """ :param msgs: messages for app translation discrepancies :param checker_messages: messages for issues found by translation checker :param email: email to :param app_name: name of the application :param result_wb: result wb of translation checker to attach with the email + :param domain: name of domain the application belongs to """ def form_email_content(msgs, checker_messages): if msgs: @@ -101,7 +102,8 @@ def attachment(title, content, mimetype='text/html'): attachments.append(attachment("{} TranslationChecker.xlsx".format(app_name), io.BytesIO(read_workbook_content_as_file(result_wb)), result_wb.mime_type)) - send_html_email_async.delay(subject, email, linebreaksbr(text_content), file_attachments=attachments) + send_html_email_async.delay(subject, email, linebreaksbr(text_content), file_attachments=attachments, + domain=domain, use_domain_gateway=True) def process_bulk_app_translation_upload(app, workbook, sheet_name_to_unique_id, lang=None): diff --git a/corehq/apps/translations/tasks.py b/corehq/apps/translations/tasks.py index 3a5ef130e5aab..464c49da39692 100644 --- a/corehq/apps/translations/tasks.py +++ b/corehq/apps/translations/tasks.py @@ -4,12 +4,12 @@ from django.conf import settings from django.core.files.temp import NamedTemporaryFile -from django.core.mail.message import EmailMessage from django.template.defaultfilters import linebreaksbr import six from corehq.apps.celery import task +from corehq.apps.hqwebapp.tasks import send_mail_async from corehq.apps.translations.generators import AppTranslationsGenerator from corehq.apps.translations.integrations.transifex.parser import ( TranslationsParser, @@ -33,15 +33,17 @@ def delete_resources_on_transifex(domain, data, email): result_note = "Hi,\nThe request to delete resources for app {app_id}(version {version}), " \ "was completed on project {transifex_project_slug} on transifex. " \ "The result is as follows:\n".format(**data) - email = EmailMessage( - subject='[{}] - Transifex removed translations'.format(settings.SERVER_ENVIRONMENT), - body=(result_note + - "\n".join([' '.join([sheet_name, result]) for sheet_name, result in delete_status.items()]) - ), - to=[email], - from_email=settings.DEFAULT_FROM_EMAIL + + subject = '[{}] - Transifex removed translations'.format(settings.SERVER_ENVIRONMENT) + body = (result_note + + "\n".join([' '.join([sheet_name, result])for sheet_name, result in delete_status.items()]) + ) + send_mail_async.delay( + subject, body, + recipient_list=[email], + domain=domain, + use_domain_gateway=True, ) - email.send() @task @@ -72,29 +74,34 @@ def push_translation_files_to_transifex(domain, data, email): "for language '{language}' " \ "was completed on project {transifex_project_slug} on transifex. " \ "The result is as follows:\n".format(**data) - email = EmailMessage( - subject='[{}] - Transifex pushed translations'.format(settings.SERVER_ENVIRONMENT), - body=(result_note + - "\n".join([' '.join([sheet_name, result]) for sheet_name, result in upload_status.items()]) - ), - to=[email], - from_email=settings.DEFAULT_FROM_EMAIL + + subject = '[{}] - Transifex pushed translations'.format(settings.SERVER_ENVIRONMENT) + body = (result_note + + "\n".join([' '.join([sheet_name, result]) for sheet_name, result in upload_status.items()]) + ) + send_mail_async.delay( + subject, body, + recipient_list=[email], + domain=domain, + use_domain_gateway=True, ) - email.send() @task def pull_translation_files_from_transifex(domain, data, user_email=None): def notify_error(error): - email = EmailMessage( - subject='[{}] - Transifex pulled translations'.format(settings.SERVER_ENVIRONMENT), - body="The request could not be completed. Something went wrong with the download. " - "Error raised : {}. " - "If you see this repeatedly and need support, please report an issue. ".format(error), - to=[user_email], - from_email=settings.DEFAULT_FROM_EMAIL + subject = '[{}] - Transifex pulled translations'.format(settings.SERVER_ENVIRONMENT) + body = ( + "The request could not be completed. Something went wrong with the download. " + "Error raised: {}. " + "If you see this repeatedly and need support, please report an issue.".format(error) + ) + send_mail_async.delay( + subject, body, + recipient_list=[user_email], + domain=domain, + use_domain_gateway=True, ) - email.send() version = data.get('version') transifex = Transifex(domain, data.get('app_id'), @@ -107,14 +114,15 @@ def notify_error(error): try: translation_file, filename = transifex.generate_excel_file() with open(translation_file.name, 'rb') as file_obj: - email = EmailMessage( + send_mail_async( subject='[{}] - Transifex pulled translations'.format(settings.SERVER_ENVIRONMENT), - body="PFA Translations pulled from transifex.", - to=[user_email], - from_email=settings.DEFAULT_FROM_EMAIL + message="PFA Translations pulled from transifex.", + recipient_list=[user_email], + filename=filename, + content=file_obj.read(), + domain=domain, + use_domain_gateway=True, ) - email.attach(filename=filename, content=file_obj.read()) - email.send() except Exception as e: notify_error(e) six.reraise(*sys.exc_info()) @@ -148,15 +156,15 @@ def backup_project_from_transifex(domain, data, email): zipfile.writestr(filename, file_obj.read()) os.remove(translation_file.name) tmp.seek(0) - email = EmailMessage( + send_mail_async( subject='[{}] - Transifex backup translations'.format(settings.SERVER_ENVIRONMENT), body="PFA Translations backup from transifex.", - to=[email], - from_email=settings.DEFAULT_FROM_EMAIL + recipient_list=[email], + filename="%s-TransifexBackup.zip" % project_details.get('name'), + content=tmp.read(), + domain=domain, + use_domain_gateway=True, ) - filename = "%s-TransifexBackup.zip" % project_details.get('name') - email.attach(filename=filename, content=tmp.read()) - email.send() @task @@ -173,14 +181,15 @@ def email_project_from_hq(domain, data, email): try: translation_file, __ = parser.generate_excel_file() with open(translation_file.name, 'rb') as file_obj: - email = EmailMessage( + send_mail_async( subject='[{}] - HQ translation download'.format(settings.SERVER_ENVIRONMENT), - body="Translations from HQ", - to=[email], - from_email=settings.DEFAULT_FROM_EMAIL) - filename = "{project}-{lang}-translations.xls".format(project=project_slug, lang=lang) - email.attach(filename=filename, content=file_obj.read()) - email.send() + message="Translations from HQ", + recipient_list=[email], + filename="{project}-{lang}-translations.xls".format(project=project_slug, lang=lang), + content=file_obj.read(), + domain=domain, + use_domain_gateway=True, + ) finally: try: os.remove(translation_file.name) @@ -218,9 +227,10 @@ def generate_email_body(): mappings ).migrate() - email = EmailMessage( + send_mail_async( subject='[{}] - Transifex Project Migration Status'.format(settings.SERVER_ENVIRONMENT), body=linebreaksbr(generate_email_body()), - to=[email], - from_email=settings.DEFAULT_FROM_EMAIL) - email.send() + recipient_list=[email], + domain=domain, + use_domain_gateway=True, + ) diff --git a/corehq/apps/user_importer/helpers.py b/corehq/apps/user_importer/helpers.py index e04c0909f7831..0404200083dd4 100644 --- a/corehq/apps/user_importer/helpers.py +++ b/corehq/apps/user_importer/helpers.py @@ -1,11 +1,12 @@ -from corehq.apps.users.models import DeactivateMobileWorkerTrigger +from django.utils.translation import gettext as _ + from dimagi.utils.parsing import string_to_boolean from corehq.apps.custom_data_fields.models import PROFILE_SLUG from corehq.apps.user_importer.exceptions import UserUploadError - from corehq.apps.users.audit.change_messages import UserChangeMessage from corehq.apps.users.model_log import UserModelAction +from corehq.apps.users.models import DeactivateMobileWorkerTrigger from corehq.apps.users.util import log_user_change @@ -40,8 +41,10 @@ def __init__(self, upload_domain, user_domain, user, is_new_user, changed_by_use if not is_new_user: self.original_user_doc = self.user.to_json() + self.original_user_data = self.user.get_user_data(user_domain).raw else: self.original_user_doc = None + self.original_user_data = None self.fields_changed = {} self.change_messages = {} @@ -75,7 +78,7 @@ def add_change_message(self, message): def _update_change_messages(self, change_messages): for slug in change_messages: if slug in self.change_messages: - raise UserUploadError(f"Double Entry for {slug}") + raise UserUploadError(_("Double Entry for {}").format(slug)) self.change_messages.update(change_messages) def add_info(self, change_message): @@ -160,9 +163,9 @@ def save_log(self): return self.logger.save() def _include_user_data_changes(self): - # ToDo: consider putting just the diff - if self.logger.original_user_doc and self.logger.original_user_doc['user_data'] != self.user.user_data: - self.logger.add_changes({'user_data': self.user.user_data}) + new_user_data = self.user.get_user_data(self.user_domain).raw + if self.logger.original_user_data != new_user_data: + self.logger.add_changes({'user_data': new_user_data}) class CommCareUserImporter(BaseUserImporter): @@ -188,35 +191,23 @@ def update_name(self, name): self.user.set_full_name(str(name)) self.logger.add_changes({'first_name': self.user.first_name, 'last_name': self.user.last_name}) - def update_user_data(self, data, uncategorized_data, profile, domain_info): - # Add in existing data. Don't use metadata - we don't want to add profile-controlled fields. - current_profile_id = self.user.user_data.get(PROFILE_SLUG) - - for key, value in self.user.user_data.items(): - if key not in data: - data[key] = value - if profile: - profile_obj = domain_info.profiles_by_name[profile] - data[PROFILE_SLUG] = profile_obj.id - for key in profile_obj.fields.keys(): - self.user.pop_metadata(key) + def update_user_data(self, data, uncategorized_data, profile_name, domain_info): + from corehq.apps.users.user_data import UserDataError + user_data = self.user.get_user_data(self.user_domain) + old_profile_id = user_data.profile_id + if PROFILE_SLUG in data: + raise UserUploadError(_("You cannot set {} directly").format(PROFILE_SLUG)) + if profile_name: + profile_id = domain_info.profiles_by_name[profile_name].pk + try: - self.user.update_metadata(data) - except ValueError as e: + user_data.update(data, profile_id=profile_id if profile_name else ...) + user_data.update(uncategorized_data) + except UserDataError as e: raise UserUploadError(str(e)) - if uncategorized_data: - self.user.update_metadata(uncategorized_data) - - # Clear blank user data so that it can be purged by remove_unused_custom_fields_from_users_task - for key in dict(data, **uncategorized_data): - value = self.user.metadata[key] - if value is None or value == '': - self.user.pop_metadata(key) - new_profile_id = self.user.user_data.get(PROFILE_SLUG) - if new_profile_id and new_profile_id != current_profile_id: - profile_name = domain_info.profile_name_by_id[new_profile_id] - self.logger.add_info(UserChangeMessage.profile_info(new_profile_id, profile_name)) + if user_data.profile_id and user_data.profile_id != old_profile_id: + self.logger.add_info(UserChangeMessage.profile_info(user_data.profile_id, profile_name)) def update_language(self, language): self.user.language = language @@ -234,7 +225,7 @@ def update_locations(self, location_codes, domain_info): from corehq.apps.user_importer.importer import ( check_modified_user_loc, find_location_id, - get_location_from_site_code + get_location_from_site_code, ) location_ids = find_location_id(location_codes, domain_info.location_cache) @@ -349,7 +340,7 @@ def update_locations(self, location_codes, membership, domain_info): from corehq.apps.user_importer.importer import ( check_modified_user_loc, find_location_id, - get_location_from_site_code + get_location_from_site_code, ) location_ids = find_location_id(location_codes, domain_info.location_cache) diff --git a/corehq/apps/user_importer/importer.py b/corehq/apps/user_importer/importer.py index e40c61c0c3f52..77ac10c59db42 100644 --- a/corehq/apps/user_importer/importer.py +++ b/corehq/apps/user_importer/importer.py @@ -315,7 +315,7 @@ def get_location_from_site_code(site_code, location_cache): DomainInfo = namedtuple('DomainInfo', [ 'validators', 'can_assign_locations', 'location_cache', - 'roles_by_name', 'profiles_by_name', 'profile_name_by_id', 'group_memoizer' + 'roles_by_name', 'profiles_by_name', 'group_memoizer' ]) @@ -383,7 +383,6 @@ def get_domain_info( allowed_group_names = [group.name for group in domain_group_memoizer.groups] profiles_by_name = {} - profile_name_by_id = {} domain_user_specs = [spec for spec in user_specs if spec.get('domain', upload_domain) == domain] if is_web_upload: roles_by_name = {role[1]: role[0] for role in get_editable_role_choices(domain, upload_user, @@ -404,10 +403,6 @@ def get_domain_info( profile.name: profile for profile in profiles } - profile_name_by_id = { - profile.pk: profile.name - for profile in profiles - } validators = get_user_import_validators( domain_obj, domain_user_specs, @@ -424,7 +419,6 @@ def get_domain_info( location_cache, roles_by_name, profiles_by_name, - profile_name_by_id, domain_group_memoizer ) domain_info_by_domain[domain] = domain_info @@ -537,7 +531,7 @@ def create_or_update_commcare_users_and_groups(upload_domain, user_specs, upload location_codes = row.get('location_code', []) if 'location_code' in row else None location_codes = format_location_codes(location_codes) role = row.get('role', None) - profile = row.get('user_profile', None) + profile_name = row.get('user_profile', None) web_user_username = row.get('web_user') phone_numbers = row.get('phone-number', []) if 'phone-number' in row else None @@ -581,7 +575,7 @@ def create_or_update_commcare_users_and_groups(upload_domain, user_specs, upload if name: commcare_user_importer.update_name(name) - commcare_user_importer.update_user_data(data, uncategorized_data, profile, domain_info) + commcare_user_importer.update_user_data(data, uncategorized_data, profile_name, domain_info) if update_deactivate_after_date: commcare_user_importer.update_deactivate_after(deactivate_after) @@ -607,7 +601,7 @@ def create_or_update_commcare_users_and_groups(upload_domain, user_specs, upload commcare_user_importer.update_role('none') if web_user_username: - user.update_metadata({'login_as_user': web_user_username}) + user.get_user_data(domain)['login_as_user'] = web_user_username user.save() log = commcare_user_importer.save_log() diff --git a/corehq/apps/user_importer/tests/test_importer.py b/corehq/apps/user_importer/tests/test_importer.py index 93f32b4949b05..90099c2911853 100644 --- a/corehq/apps/user_importer/tests/test_importer.py +++ b/corehq/apps/user_importer/tests/test_importer.py @@ -49,6 +49,7 @@ HqPermissions, ) from corehq.apps.users.model_log import UserModelAction +from corehq.apps.users.tests.util import patch_user_data_db_layer from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView from corehq.const import USER_CHANGE_VIA_BULK_IMPORTER from corehq.extensions.interface import disable_extensions @@ -164,7 +165,8 @@ def _get_spec(self, delete_keys=None, **kwargs): 'is_active': 'True', 'phone-number': ['23424123'], 'password': 123, - 'email': None + 'email': None, + 'user_profile': None, } if delete_keys: for key in delete_keys: @@ -172,6 +174,9 @@ def _get_spec(self, delete_keys=None, **kwargs): spec.update(kwargs) return spec + def assert_user_data_item(self, key, expected): + self.assertEqual(self.user.get_user_data(self.domain.name).get(key), expected) + def test_upload_with_missing_user_id(self): import_users_and_groups( self.domain.name, @@ -198,7 +203,7 @@ def test_location_not_list(self): False ) self.assertEqual(self.user.location_id, self.loc1._id) - self.assertEqual(self.user.location_id, self.user.metadata.get('commcare_location_id')) + self.assert_user_data_item('commcare_location_id', self.user.location_id) # multiple locations self.assertListEqual([self.loc1._id], self.user.assigned_location_ids) @@ -241,11 +246,11 @@ def test_location_add(self): ) # first location should be primary location self.assertEqual(self.user.location_id, self.loc1._id) - self.assertEqual(self.user.location_id, self.user.metadata.get('commcare_location_id')) + self.assert_user_data_item('commcare_location_id', self.user.location_id) # multiple locations self.assertListEqual([loc._id for loc in [self.loc1, self.loc2]], self.user.assigned_location_ids) # non-primary location - self.assertTrue(self.loc2._id in self.user.metadata.get('commcare_location_ids')) + self.assert_user_data_item('commcare_location_ids', " ".join([self.loc1._id, self.loc2._id])) user_history = UserHistory.objects.get(action=UserModelAction.CREATE.value, user_id=self.user.get_id, @@ -296,7 +301,7 @@ def test_location_remove(self): # user should have no locations self.assertEqual(self.user.location_id, None) - self.assertEqual(self.user.metadata.get('commcare_location_id'), None) + self.assert_user_data_item('commcare_location_id', None) self.assertListEqual(self.user.assigned_location_ids, []) user_history = UserHistory.objects.get(action=UserModelAction.UPDATE.value, @@ -323,8 +328,8 @@ def test_primary_location_replace(self): # user's primary location should be loc1 self.assertEqual(self.user.location_id, self.loc1._id) - self.assertEqual(self.user.metadata.get('commcare_location_id'), self.loc1._id) - self.assertEqual(self.user.metadata.get('commcare_location_ids'), " ".join([self.loc1._id, self.loc2._id])) + self.assert_user_data_item('commcare_location_id', self.loc1._id) + self.assert_user_data_item('commcare_location_ids', " ".join([self.loc1._id, self.loc2._id])) self.assertListEqual(self.user.assigned_location_ids, [self.loc1._id, self.loc2._id]) user_history = UserHistory.objects.get(action=UserModelAction.CREATE.value, @@ -349,8 +354,8 @@ def test_primary_location_replace(self): # user's location should now be loc2 self.assertEqual(self.user.location_id, self.loc2._id) - self.assertEqual(self.user.metadata.get('commcare_location_ids'), self.loc2._id) - self.assertEqual(self.user.metadata.get('commcare_location_id'), self.loc2._id) + self.assert_user_data_item('commcare_location_ids', self.loc2._id) + self.assert_user_data_item('commcare_location_id', self.loc2._id) self.assertListEqual(self.user.assigned_location_ids, [self.loc2._id]) user_history = UserHistory.objects.get(action=UserModelAction.UPDATE.value, @@ -398,7 +403,7 @@ def test_location_replace(self): # user's location should now be loc2 self.assertEqual(self.user.location_id, self.loc2._id) - self.assertEqual(self.user.metadata.get('commcare_location_id'), self.loc2._id) + self.assert_user_data_item('commcare_location_id', self.loc2._id) self.assertListEqual(self.user.assigned_location_ids, [self.loc2._id]) user_history = UserHistory.objects.get(action=UserModelAction.UPDATE.value, @@ -528,8 +533,11 @@ def test_empty_user_name(self): ) self.assertEqual(self.user.full_name, "") - def test_metadata(self): - # Set metadata + def assert_user_data_equals(self, expected): + self.assertEqual(self.user.get_user_data(self.domain.name).to_dict(), expected) + + def test_user_data(self): + # Set user_data import_users_and_groups( self.domain.name, [self._get_spec(data={'key': 'F#'})], @@ -538,9 +546,9 @@ def test_metadata(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'key': 'F#'}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'key': 'F#', 'commcare_profile': ''}) - # Update metadata + # Update user_data import_users_and_groups( self.domain.name, [self._get_spec(data={'key': 'Bb'}, user_id=self.user._id)], @@ -549,9 +557,9 @@ def test_metadata(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'key': 'Bb'}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'key': 'Bb', 'commcare_profile': ''}) - # Clear metadata + # set user data to blank import_users_and_groups( self.domain.name, [self._get_spec(data={'key': ''}, user_id=self.user._id)], @@ -560,18 +568,18 @@ def test_metadata(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain'}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'key': '', 'commcare_profile': ''}) # Allow falsy but non-blank values import_users_and_groups( self.domain.name, - [self._get_spec(data={'play_count': 0}, user_id=self.user._id)], + [self._get_spec(data={'key': 0}, user_id=self.user._id)], [], self.uploading_user.get_id, self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'play_count': 0}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'key': 0, 'commcare_profile': ''}) def test_uncategorized_data(self): # Set data @@ -583,7 +591,7 @@ def test_uncategorized_data(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'tempo': 'presto'}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'tempo': 'presto', 'commcare_profile': ''}) # Update data import_users_and_groups( @@ -594,42 +602,10 @@ def test_uncategorized_data(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'tempo': 'andante'}) - - # Clear metadata - import_users_and_groups( - self.domain.name, - [self._get_spec(uncategorized_data={'tempo': ''}, user_id=self.user._id)], - [], - self.uploading_user.get_id, - self.upload_record.pk, - False - ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain'}) - - def test_uncategorized_data_clear(self): - import_users_and_groups( - self.domain.name, - [self._get_spec(data={'tempo': 'andante'})], - [], - self.uploading_user.get_id, - self.upload_record.pk, - False - ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain', 'tempo': 'andante'}) - - import_users_and_groups( - self.domain.name, - [self._get_spec(data={'tempo': ''}, user_id=self.user._id)], - [], - self.uploading_user.get_id, - self.upload_record.pk, - False - ) - self.assertEqual(self.user.metadata, {'commcare_project': 'mydomain'}) + self.assert_user_data_equals({'commcare_project': 'mydomain', 'tempo': 'andante', 'commcare_profile': ''}) @patch('corehq.apps.user_importer.importer.domain_has_privilege', lambda x, y: True) - def test_metadata_ignore_system_fields(self): + def test_user_data_ignore_system_fields(self): self.setup_locations() import_users_and_groups( self.domain.name, @@ -639,8 +615,9 @@ def test_metadata_ignore_system_fields(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, { + self.assert_user_data_equals({ 'commcare_project': 'mydomain', + 'commcare_profile': '', 'commcare_location_id': self.loc1.location_id, 'commcare_location_ids': self.loc1.location_id, 'commcare_primary_case_sharing_id': self.loc1.location_id, @@ -655,24 +632,26 @@ def test_metadata_ignore_system_fields(self): self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, { + self.assert_user_data_equals({ 'commcare_project': 'mydomain', + 'commcare_profile': '', 'key': 'G#', 'commcare_location_id': self.loc1.location_id, 'commcare_location_ids': self.loc1.location_id, 'commcare_primary_case_sharing_id': self.loc1.location_id, }) - def test_metadata_profile(self): + def test_user_data_profile(self): import_users_and_groups( self.domain.name, - [self._get_spec(data={'key': 'F#', PROFILE_SLUG: self.profile.id})], + [self._get_spec(data={'key': 'F#'}, user_profile=self.profile.name)], [], self.uploading_user.get_id, self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, { + + self.assert_user_data_equals({ 'commcare_project': 'mydomain', 'key': 'F#', 'mode': 'minor', @@ -684,101 +663,94 @@ def test_metadata_profile(self): change_messages = UserChangeMessage.profile_info(self.profile.id, self.profile.name) self.assertDictEqual(user_history.change_messages, change_messages) + def test_user_data_profile_redundant(self): import_users_and_groups( self.domain.name, - [self._get_spec( - data={'key': 'F#', PROFILE_SLUG: ''}, - password="skyfall", - user_id=self.user.get_id)], + [self._get_spec(data={'mode': 'minor'}, user_profile=self.profile.name)], [], self.uploading_user.get_id, self.upload_record.pk, False ) + self.assert_user_data_equals({ + 'commcare_project': 'mydomain', + 'mode': 'minor', + PROFILE_SLUG: self.profile.id, + }) + # Profile fields shouldn't actually be added to user_data + self.assertEqual(self.user.get_user_data(self.domain.name).raw, {}) - user_history = UserHistory.objects.get( - user_id=self.user.get_id, changed_by=self.uploading_user.get_id, - action=UserModelAction.UPDATE.value) - - change_messages = UserChangeMessage.password_reset() - self.assertDictEqual(user_history.change_messages, change_messages) - + def test_user_data_profile_blank(self): import_users_and_groups( self.domain.name, - [self._get_spec( - data={'key': 'F#', PROFILE_SLUG: self.profile.id}, - password="******", - user_id=self.user.get_id)], + [self._get_spec(data={'mode': ''}, user_profile=self.profile.name)], [], self.uploading_user.get_id, self.upload_record.pk, False ) + self.assert_user_data_equals({ + 'commcare_project': 'mydomain', + 'mode': 'minor', + PROFILE_SLUG: self.profile.id, + }) - user_history = UserHistory.objects.filter( - user_id=self.user.get_id, changed_by=self.uploading_user.get_id, - action=UserModelAction.UPDATE.value - ).last() - change_messages = UserChangeMessage.profile_info(self.profile.id, self.profile.name) - self.assertDictEqual(user_history.change_messages, change_messages) - - def test_metadata_profile_redundant(self): - import_users_and_groups( + def test_user_data_profile_conflict(self): + rows = import_users_and_groups( self.domain.name, - [self._get_spec(data={PROFILE_SLUG: self.profile.id, 'mode': 'minor'})], + [self._get_spec(data={'mode': 'major'}, user_profile=self.profile.name)], [], self.uploading_user.get_id, self.upload_record.pk, False - ) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'mydomain', - 'mode': 'minor', - PROFILE_SLUG: self.profile.id, - }) - # Profile fields shouldn't actually be added to user_data - self.assertEqual(self.user.user_data, { - 'commcare_project': 'mydomain', - PROFILE_SLUG: self.profile.id, - }) + )['messages']['rows'] + self.assertEqual(rows[0]['flag'], "'mode' cannot be set directly") - def test_metadata_profile_blank(self): + def test_profile_cant_overwrite_existing_data(self): import_users_and_groups( self.domain.name, - [self._get_spec(data={PROFILE_SLUG: self.profile.id, 'mode': ''})], + [self._get_spec(data={'mode': 'major'})], [], self.uploading_user.get_id, self.upload_record.pk, False ) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'mydomain', - 'mode': 'minor', - PROFILE_SLUG: self.profile.id, - }) - - def test_metadata_profile_conflict(self): + # This fails because it would silently overwrite the existing "mode" rows = import_users_and_groups( self.domain.name, - [self._get_spec(data={PROFILE_SLUG: self.profile.id, 'mode': 'major'})], + [self._get_spec(user_id=self.user.get_id, user_profile=self.profile.name)], [], self.uploading_user.get_id, self.upload_record.pk, False )['messages']['rows'] - self.assertEqual(rows[0]['flag'], "metadata properties conflict with profile: mode") + self.assertEqual(rows[0]['flag'], "Profile conflicts with existing data") + + # This succeeds because it explicitly blanks out "mode" + import_users_and_groups( + self.domain.name, + [self._get_spec(user_id=self.user.get_id, user_profile=self.profile.name, data={'mode': ''})], + [], + self.uploading_user.get_id, + self.upload_record.pk, + False + ) + self.assert_user_data_equals({ + 'commcare_project': 'mydomain', + 'mode': 'minor', + PROFILE_SLUG: self.profile.id, + }) - def test_metadata_profile_unknown(self): - bad_id = self.profile.id + 100 + def test_user_data_profile_unknown(self): rows = import_users_and_groups( self.domain.name, - [self._get_spec(data={PROFILE_SLUG: bad_id})], + [self._get_spec(user_profile="not_a_real_profile")], [], self.uploading_user.get_id, self.upload_record.pk, False )['messages']['rows'] - self.assertEqual(rows[0]['flag'], "Could not find profile with id {}".format(bad_id)) + self.assertEqual(rows[0]['flag'], "Profile 'not_a_real_profile' does not exist") def test_upper_case_email(self): """ @@ -868,7 +840,7 @@ def test_tracking_update_to_existing_commcare_user(self): 'language': 'hin', 'email': 'hello@gmail.org', 'is_active': False, - 'user_data': {'commcare_project': 'mydomain', 'post': 'SE'} + 'user_data': {'post': 'SE'}, } ) self.assertEqual(user_history.changed_via, USER_CHANGE_VIA_BULK_IMPORTER) @@ -1165,12 +1137,7 @@ def test_ensure_user_history_on_only_userdata_update(self): ) user_history = UserHistory.objects.get(action=UserModelAction.UPDATE.value, changed_by=self.uploading_user.get_id) - self.assertDictEqual( - user_history.changes, - { - 'user_data': {'commcare_project': 'mydomain', 'key': 'F#'} - } - ) + self.assertDictEqual(user_history.changes, {'user_data': {'key': 'F#'}}) self.assertEqual(user_history.changed_via, USER_CHANGE_VIA_BULK_IMPORTER) self.assertEqual(user_history.change_messages, {}) @@ -2094,6 +2061,7 @@ def test_tableau_users(self, mock_request): local_tableau_users.get(username='george@eliot.com') +@patch_user_data_db_layer class TestUserChangeLogger(SimpleTestCase): @classmethod def setUpClass(cls): diff --git a/corehq/apps/userreports/tasks.py b/corehq/apps/userreports/tasks.py index 245ca8b51c5a0..14d76f125a2d6 100644 --- a/corehq/apps/userreports/tasks.py +++ b/corehq/apps/userreports/tasks.py @@ -80,7 +80,8 @@ def _build_indicators(config, document_store, relevant_ids): @serial_task('{indicator_config_id}', default_retry_delay=60 * 10, timeout=3 * 60 * 60, max_retries=20, queue=UCR_CELERY_QUEUE, ignore_result=True, serializer='pickle') -def rebuild_indicators(indicator_config_id, initiated_by=None, limit=-1, source=None, engine_id=None, diffs=None, trigger_time=None, domain=None): +def rebuild_indicators(indicator_config_id, initiated_by=None, limit=-1, source=None, + engine_id=None, diffs=None, trigger_time=None, domain=None): config = get_ucr_datasource_config_by_id(indicator_config_id) if trigger_time is not None and trigger_time < config.last_modified: return @@ -295,7 +296,7 @@ def build_async_indicators(indicator_doc_ids): # written to be used with _queue_indicators, indicator_doc_ids must # be a chunk of 100 memoizers = {'configs': {}, 'adapters': {}} - assert(len(indicator_doc_ids)) <= ASYNC_INDICATOR_CHUNK_SIZE + assert (len(indicator_doc_ids)) <= ASYNC_INDICATOR_CHUNK_SIZE def handle_exception(exception, config_id, doc, adapter): metric = None @@ -495,7 +496,8 @@ def async_indicators_metrics(): # Don't use ORM summing because it would attempt to get every value in DB unsuccessful_attempts = sum(AsyncIndicator.objects.values_list('unsuccessful_attempts', flat=True).all()[:100]) - metrics_gauge('commcare.async_indicator.unsuccessful_attempts', unsuccessful_attempts, multiprocess_mode='livesum') + metrics_gauge('commcare.async_indicator.unsuccessful_attempts', unsuccessful_attempts, + multiprocess_mode='livesum') oldest_unprocessed = AsyncIndicator.objects.filter(unsuccessful_attempts=0).first() if oldest_unprocessed: @@ -562,4 +564,4 @@ def export_ucr_async(report_export, download_id, user): expose_download(use_transfer, file_path, filename, download_id, 'xlsx', owner_ids=[user.get_id]) link = reverse("retrieve_download", args=[download_id], params={"get_file": '1'}, absolute=True) - send_report_download_email(report_export.title, user.get_email(), link) + send_report_download_email(report_export.title, user.get_email(), link, domain=report_export.domain) diff --git a/corehq/apps/userreports/tests/test_choice_provider.py b/corehq/apps/userreports/tests/test_choice_provider.py index d8155c977c9ea..b4de506c8318d 100644 --- a/corehq/apps/userreports/tests/test_choice_provider.py +++ b/corehq/apps/userreports/tests/test_choice_provider.py @@ -6,11 +6,11 @@ from django.utils.translation import gettext from corehq.apps.domain.shortcuts import create_domain, create_user +from corehq.apps.es.client import manager from corehq.apps.es.fake.groups_fake import GroupESFake from corehq.apps.es.fake.users_fake import UserESFake -from corehq.apps.es.client import manager -from corehq.apps.es.users import user_adapter from corehq.apps.es.tests.utils import es_test +from corehq.apps.es.users import user_adapter from corehq.apps.groups.models import Group from corehq.apps.locations.tests.util import LocationHierarchyTestCase from corehq.apps.registry.tests.utils import ( @@ -43,6 +43,7 @@ WebUser, ) from corehq.apps.users.models_role import UserRole +from corehq.apps.users.tests.util import patch_user_data_db_layer from corehq.apps.users.util import normalize_username from corehq.util.es.testing import sync_users_to_es from corehq.util.test_utils import flag_disabled, flag_enabled @@ -262,6 +263,7 @@ class UserChoiceProviderTest(SimpleTestCase, ChoiceProviderTestMixin): domain = 'user-choice-provider' @classmethod + @patch_user_data_db_layer def make_mobile_worker(cls, username, domain=None): domain = domain or cls.domain user = CommCareUser(username=normalize_username(username, domain), @@ -426,11 +428,11 @@ def test_get_choices_for_values(self): @es_test(requires=[user_adapter], setup_class=True) -class UserMetadataChoiceProviderTest(TestCase, ChoiceProviderTestMixin): +class UserUserDataChoiceProviderTest(TestCase, ChoiceProviderTestMixin): domain = 'user-meta-choice-provider' @classmethod - def make_web_user(cls, email, domain=None, metadata=None): + def make_web_user(cls, email, domain=None, user_data=None): domain = domain or cls.domain user = WebUser.create( domain=domain, @@ -438,13 +440,13 @@ def make_web_user(cls, email, domain=None, metadata=None): password="*****", created_by=None, created_via=None, - metadata=metadata, + user_data=user_data, ) return user @classmethod def setUpClass(cls): - super(UserMetadataChoiceProviderTest, cls).setUpClass() + super().setUpClass() report = ReportConfiguration(domain=cls.domain) cls.domain_obj = create_domain(cls.domain) @@ -452,12 +454,12 @@ def setUpClass(cls): cls.web_user = cls.make_web_user('ned@stark.com') cls.users = [ cls.make_mobile_worker('stark', - metadata={'sigil': 'direwolf', 'seat': 'Winterfell', 'login_as_user': 'arya@faceless.com'}), + user_data={'sigil': 'direwolf', 'seat': 'Winterfell', 'login_as_user': 'arya@faceless.com'}), cls.web_user, - cls.make_mobile_worker('lannister', metadata={'sigil': 'lion', 'seat': 'Casterly Rock'}), - cls.make_mobile_worker('targaryen', metadata={'sigil': 'dragon', 'false_sigil': 'direwolf'}), + cls.make_mobile_worker('lannister', user_data={'sigil': 'lion', 'seat': 'Casterly Rock'}), + cls.make_mobile_worker('targaryen', user_data={'sigil': 'dragon', 'false_sigil': 'direwolf'}), # test that docs in other domains are filtered out - cls.make_mobile_worker('Sauron', metadata={'sigil': 'eye', + cls.make_mobile_worker('Sauron', user_data={'sigil': 'eye', 'seat': 'Mordor'}, domain='some-other-domain-lotr'), ] manager.index_refresh(user_adapter.index_name) @@ -466,22 +468,24 @@ def setUpClass(cls): SearchableChoice( user.get_id, user.raw_username, searchable_text=[ - user.username, user.last_name, user.first_name, user.metadata.get('login_as_user')]) - for user in cls.users if user.is_member_of(cls.domain) + user.username, user.last_name, user.first_name, + user.get_user_data(cls.domain).get('login_as_user') + ] + ) for user in cls.users if user.is_member_of(cls.domain) ] choices.sort(key=lambda choice: choice.display) cls.choice_provider = UserChoiceProvider(report, None) cls.static_choice_provider = StaticChoiceProvider(choices) @classmethod - def make_mobile_worker(cls, username, domain=None, metadata=None): + def make_mobile_worker(cls, username, domain=None, user_data=None): user = CommCareUser.create( domain=domain or cls.domain, username=normalize_username(username), password="*****", created_by=None, created_via=None, - metadata=metadata, + user_data=user_data, ) return user @@ -603,7 +607,9 @@ def test_query_full_registry_access(self): Choice(value='B', display='B'), Choice(value='C', display='C')], self.choice_provider.query(ChoiceQueryContext(query='', offset=0, user=self.web_user))) - self.assertEqual([], self.choice_provider.query(ChoiceQueryContext(query='D', offset=0, user=self.web_user))) + self.assertEqual([], self.choice_provider.query( + ChoiceQueryContext(query='D', offset=0, user=self.web_user) + )) def test_query_no_registry_access(self): self.assertEqual([Choice(value='A', display='A')], diff --git a/corehq/apps/userreports/tests/test_report_builder.py b/corehq/apps/userreports/tests/test_report_builder.py index f05cbb31a1659..702bb4b1e0177 100644 --- a/corehq/apps/userreports/tests/test_report_builder.py +++ b/corehq/apps/userreports/tests/test_report_builder.py @@ -180,7 +180,7 @@ def test_builder_for_registry(self): case_type_for_registry = CaseType(domain=self.domain, name='registry_prop', fully_generated=True) case_type_for_registry.save() CaseProperty(case_type=case_type_for_registry, name='registry_property', - deprecated=False, data_type='plain', group='').save() + deprecated=False, data_type='plain', group=None).save() user = create_user("admin", "123") registry = create_registry_for_test(user, self.domain, invitations=[ Invitation('foo', accepted=True), Invitation('user-reports', accepted=True), diff --git a/corehq/apps/users/README.rst b/corehq/apps/users/README.rst index bc91e75490920..a77213fdf4c84 100644 --- a/corehq/apps/users/README.rst +++ b/corehq/apps/users/README.rst @@ -50,11 +50,11 @@ User Data Users may have arbitrary data associated with them, assigned by the project and then referenced in applications. This user data is implemented via the ``custom_data_fields`` app, the same way as location and product data. -User data is only relevant to mobile users. However, ``user_data`` is a property of ``CouchUser`` -and not ``CommCareUser`` because a legacy feature applied user data to web users. As a result of this, -some web users do have user data saved to their documents. +User data is being migrated to SQL to support web users, which will have a ``SQLUserData`` object for each domain +they are a member of. Data is accessed through the accessor ``user.get_user_data(domain)``, which returns an +instance of ``UserData`` - a class that acts like a dictionary, but factors in data controlled by user data +profiles and uneditable system fields. -User data should be accessed via the ``metadata`` property, which takes into account user data profiles. UserRole and Permissions ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/corehq/apps/users/account_confirmation.py b/corehq/apps/users/account_confirmation.py index 50cbf59f8b653..0e3fab380ca85 100644 --- a/corehq/apps/users/account_confirmation.py +++ b/corehq/apps/users/account_confirmation.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.template.loader import render_to_string from django.utils.translation import override, gettext_lazy as _ from corehq.apps.domain.models import SMSAccountConfirmationSettings @@ -63,7 +62,8 @@ def send_account_confirmation(commcare_user): subject = _(f'Confirm your CommCare account for {commcare_user.domain}') send_html_email_async.delay(subject, commcare_user.email, html_content, text_content=text_content, - email_from=settings.DEFAULT_FROM_EMAIL) + domain=commcare_user.domain, + use_domain_gateway=True) def send_account_confirmation_sms(commcare_user): diff --git a/corehq/apps/users/analytics.py b/corehq/apps/users/analytics.py index 7329bb6bebadb..7a10e1fb981a3 100644 --- a/corehq/apps/users/analytics.py +++ b/corehq/apps/users/analytics.py @@ -74,7 +74,7 @@ def get_search_users_in_domain_es_query(domain, search_string, limit, offset): user_es = UserES().domain(domain) if RESTRICT_LOGIN_AS.enabled(domain): - user_es = user_es.OR(users.metadata('login_as_user', search_string), + user_es = user_es.OR(users.user_data('login_as_user', search_string), queries.search_string_query(search_string, default_search_fields)) else: user_es = user_es.search_string_query(search_string, default_search_fields) diff --git a/corehq/apps/users/bulk_download.py b/corehq/apps/users/bulk_download.py index 3fbf2479de5f9..f2361835db4e8 100644 --- a/corehq/apps/users/bulk_download.py +++ b/corehq/apps/users/bulk_download.py @@ -3,39 +3,34 @@ from django.conf import settings from django.utils.translation import gettext -from corehq.apps.enterprise.models import EnterpriseMobileWorkerSettings from couchexport.writers import Excel2007ExportWriter from soil import DownloadBase from soil.util import expose_download, get_download_file_path from corehq import privileges from corehq.apps.accounting.utils import domain_has_privilege -from corehq.apps.custom_data_fields.models import ( - PROFILE_SLUG, - CustomDataFieldsDefinition, - CustomDataFieldsProfile, -) +from corehq.apps.custom_data_fields.models import CustomDataFieldsDefinition +from corehq.apps.enterprise.models import EnterpriseMobileWorkerSettings from corehq.apps.groups.models import Group from corehq.apps.locations.models import SQLLocation +from corehq.apps.reports.util import add_on_tableau_details from corehq.apps.user_importer.importer import BulkCacheBase, GroupMemoizer from corehq.apps.users.dbaccessors import ( count_invitations_by_filters, count_mobile_users_by_filters, count_web_users_by_filters, get_invitations_by_filters, - get_mobile_users_by_filters, get_mobile_usernames_by_filters, + get_mobile_users_by_filters, get_web_users_by_filters, ) -from corehq.apps.users.models import UserRole, DeactivateMobileWorkerTrigger -from corehq.apps.reports.util import add_on_tableau_details +from corehq.apps.users.models import DeactivateMobileWorkerTrigger, UserRole from corehq.toggles import TABLEAU_USER_SYNCING from corehq.util.workbook_json.excel import ( alphanumeric_sort_key, flatten_json, json_to_headers, ) -from couchdbkit import ResourceNotFound class LocationIdToSiteCodeCache(BulkCacheBase): @@ -96,16 +91,12 @@ def get_phone_numbers(user_data): def make_mobile_user_dict(user, group_names, location_cache, domain, fields_definition, deactivation_triggers): model_data = {} uncategorized_data = {} - model_data, uncategorized_data = ( - fields_definition.get_model_and_uncategorized(user.metadata) - ) + user_data = user.get_user_data(domain) + model_data, uncategorized_data = fields_definition.get_model_and_uncategorized(user_data.to_dict()) + profile_name = (user_data.profile.name + if user_data.profile and domain_has_privilege(domain, privileges.APP_USER_PROFILES) + else "") role = user.get_role(domain) - profile = None - if PROFILE_SLUG in user.metadata and domain_has_privilege(domain, privileges.APP_USER_PROFILES): - try: - profile = CustomDataFieldsProfile.objects.get(id=user.metadata[PROFILE_SLUG]) - except CustomDataFieldsProfile.DoesNotExist: - profile = None activity = user.reporting_metadata location_codes = get_location_codes(location_cache, user.location_id, user.assigned_location_ids) @@ -127,7 +118,7 @@ def _format_date(date): 'location_code': location_codes, 'role': role.name if role else '', 'domain': domain, - 'user_profile': profile.name if profile else '', + 'user_profile': profile_name, 'registered_on (read only)': _format_date(user.created_on), 'last_submission (read only)': _format_date(activity.last_submission_for_user.submission_date), 'last_sync (read only)': activity.last_sync_for_user.sync_date, @@ -200,7 +191,9 @@ def get_user_rows(user_dicts, user_headers): def parse_mobile_users(domain, user_filters, task=None, total_count=None): - from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView + from corehq.apps.users.views.mobile.custom_data_fields import ( + UserFieldsView, + ) fields_definition = CustomDataFieldsDefinition.get_or_create( domain, UserFieldsView.field_type diff --git a/corehq/apps/users/custom_data.py b/corehq/apps/users/custom_data.py deleted file mode 100644 index b24842e518f70..0000000000000 --- a/corehq/apps/users/custom_data.py +++ /dev/null @@ -1,23 +0,0 @@ -from corehq.apps.custom_data_fields.models import CustomDataFieldsDefinition -from corehq.apps.custom_data_fields.models import is_system_key -from corehq.apps.users.dbaccessors import get_all_commcare_users_by_domain - - -def remove_unused_custom_fields_from_users(domain): - """ - Removes all unused custom data fields from all users in the domain - """ - from corehq.apps.users.views.mobile.custom_data_fields import CUSTOM_USER_DATA_FIELD_TYPE - fields_definition = CustomDataFieldsDefinition.get(domain, CUSTOM_USER_DATA_FIELD_TYPE) - assert fields_definition, 'remove_unused_custom_fields_from_users called without a valid definition' - configured_field_keys = set([f.slug for f in fields_definition.get_fields()]) - for user in get_all_commcare_users_by_domain(domain): - keys_to_delete = _get_invalid_user_data_fields(user, configured_field_keys) - if keys_to_delete: - for key in keys_to_delete: - user.pop_metadata(key) - user.save() - - -def _get_invalid_user_data_fields(user, configured_field_keys): - return [key for key in user.user_data if not is_system_key(key) and not key in configured_field_keys] diff --git a/corehq/apps/users/dbaccessors.py b/corehq/apps/users/dbaccessors.py index b3392b7d9e6e8..8bf641462606d 100644 --- a/corehq/apps/users/dbaccessors.py +++ b/corehq/apps/users/dbaccessors.py @@ -3,6 +3,7 @@ from dimagi.utils.couch.database import iter_bulk_delete, iter_docs from corehq.apps.es import UserES +from corehq.apps.es.users import web_users, mobile_users from corehq.apps.locations.models import SQLLocation from corehq.apps.users.models import CommCareUser, CouchUser, Invitation, UserRole from corehq.pillows.utils import MOBILE_USER_TYPE, WEB_USER_TYPE @@ -41,7 +42,7 @@ def get_display_name_for_user_id(domain, user_id, default=None): def get_user_id_and_doc_type_by_domain(domain): key = ['active', domain] return [ - {"id": u['id'], "doc_type":u['key'][2]} + {"id": u['id'], "doc_type": u['key'][2]} for u in CouchUser.view( 'users/by_domain', reduce=False, @@ -391,3 +392,14 @@ def get_practice_mode_mobile_workers(domain): .fields(['_id', 'username']) .run().hits ) + + +def get_all_user_search_query(search_string): + query = (UserES() + .remove_default_filters() + .OR(web_users(), mobile_users())) + if search_string: + fields = ['username', 'first_name', 'last_name', 'phone_numbers', + 'domain_membership.domain', 'domain_memberships.domain'] + query = query.search_string_query(search_string, fields) + return query diff --git a/corehq/apps/users/forms.py b/corehq/apps/users/forms.py index e6c3ebf58b113..ef63590d2a5f7 100644 --- a/corehq/apps/users/forms.py +++ b/corehq/apps/users/forms.py @@ -30,10 +30,7 @@ from corehq.apps.analytics.tasks import set_analytics_opt_out from corehq.apps.app_manager.models import validate_lang from corehq.apps.custom_data_fields.edit_entity import CustomDataEditor -from corehq.apps.custom_data_fields.models import ( - PROFILE_SLUG, - CustomDataFieldsProfile, -) +from corehq.apps.custom_data_fields.models import CustomDataFieldsProfile, PROFILE_SLUG from corehq.apps.domain.extension_points import has_custom_clean_password from corehq.apps.domain.forms import EditBillingAccountInfoForm, clean_password from corehq.apps.domain.models import Domain @@ -62,13 +59,16 @@ from corehq.apps.user_importer.helpers import UserChangeLogger from corehq.const import LOADTEST_HARD_LIMIT, USER_CHANGE_VIA_WEB from corehq.pillows.utils import MOBILE_USER_TYPE, WEB_USER_TYPE -from corehq.toggles import TWO_STAGE_USER_PROVISIONING, TWO_STAGE_USER_PROVISIONING_BY_SMS +from corehq.toggles import ( + TWO_STAGE_USER_PROVISIONING, + TWO_STAGE_USER_PROVISIONING_BY_SMS, +) +from ..hqwebapp.signals import clear_login_attempts from .audit.change_messages import UserChangeMessage from .dbaccessors import user_exists -from .models import DeactivateMobileWorkerTrigger, UserRole, CouchUser +from .models import CouchUser, DeactivateMobileWorkerTrigger, UserRole from .util import cc_user_domain, format_username, log_user_change -from ..hqwebapp.signals import clear_login_attempts UNALLOWED_MOBILE_WORKER_NAMES = ('admin', 'demo_user') STRONG_PASSWORD_LEN = 12 @@ -265,11 +265,12 @@ def update_user(self, metadata_updated=False, profile_updated=False): if is_update_successful and (props_updated or role_updated or metadata_updated): change_messages = {} - profile_id = self.existing_user.user_data.get(PROFILE_SLUG) + user_data = self.existing_user.get_user_data(self.domain) + profile_id = user_data.profile_id if role_updated: change_messages.update(UserChangeMessage.role_change(user_new_role)) if metadata_updated: - props_updated['user_data'] = self.existing_user.user_data + props_updated['user_data'] = user_data.raw if profile_updated: profile_name = None if profile_id: @@ -1561,7 +1562,7 @@ def custom_data(self): return CustomDataEditor( domain=self.domain, field_view=UserFieldsView, - existing_custom_data=self.editable_user.metadata, + existing_custom_data=self.editable_user.get_user_data(self.domain).to_dict(), post_dict=self.data, ko_model="custom_fields", ) @@ -1571,9 +1572,15 @@ def is_valid(self): and all([self.user_form.is_valid(), self.custom_data.is_valid()])) def update_user(self): - metadata_updated, profile_updated = self.user_form.existing_user.update_metadata( - self.custom_data.get_data_to_save()) - return self.user_form.update_user(metadata_updated=metadata_updated, profile_updated=profile_updated) + user_data = self.user_form.existing_user.get_user_data(self.domain) + old_profile_id = user_data.profile_id + new_user_data = self.custom_data.get_data_to_save() + new_profile_id = new_user_data.pop(PROFILE_SLUG, ...) + changed = user_data.update(new_user_data, new_profile_id) + return self.user_form.update_user( + metadata_updated=changed, + profile_updated=old_profile_id != new_profile_id + ) class UserFilterForm(forms.Form): diff --git a/corehq/apps/users/management/commands/populate_sql_user_data.py b/corehq/apps/users/management/commands/populate_sql_user_data.py new file mode 100644 index 0000000000000..ec5d1f2518ddf --- /dev/null +++ b/corehq/apps/users/management/commands/populate_sql_user_data.py @@ -0,0 +1,27 @@ +# One-off migration, November 2023 + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from corehq.apps.users.models import CouchUser +from corehq.util.log import with_progress_bar +from corehq.util.queries import queryset_to_iterator + + +class Command(BaseCommand): + help = "Populate SQL user data from couch" + + def handle(self, **options): + queryset = get_users_without_user_data() + for user in with_progress_bar(queryset_to_iterator(queryset, User), queryset.count()): + populate_user_data(user) + + +def get_users_without_user_data(): + return User.objects.filter(sqluserdata__isnull=True) + + +def populate_user_data(django_user): + user = CouchUser.from_django_user(django_user, strict=True) + for domain in user.get_domains(): + user.get_user_data(domain).save() diff --git a/corehq/apps/users/migrations/0055_add_user_data.py b/corehq/apps/users/migrations/0055_add_user_data.py new file mode 100644 index 0000000000000..5ca5760340377 --- /dev/null +++ b/corehq/apps/users/migrations/0055_add_user_data.py @@ -0,0 +1,35 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_data_fields', '0008_custom_data_fields_upstream_ids'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0054_connectiduserlink'), + ] + + operations = [ + migrations.CreateModel( + name='SQLUserData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(max_length=128)), + ('user_id', models.CharField(max_length=36)), + ('modified_on', models.DateTimeField(auto_now=True)), + ('data', models.JSONField()), + ('django_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('profile', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='custom_data_fields.customdatafieldsprofile')), + ], + ), + migrations.AddIndex( + model_name='sqluserdata', + index=models.Index(fields=['user_id', 'domain'], name='users_sqlus_user_id_f129be_idx'), + ), + migrations.AlterUniqueTogether( + name='sqluserdata', + unique_together={('user_id', 'domain')}, + ), + ] diff --git a/corehq/apps/users/models.py b/corehq/apps/users/models.py index 3df069b5d8c97..7be1a12911708 100644 --- a/corehq/apps/users/models.py +++ b/corehq/apps/users/models.py @@ -101,6 +101,7 @@ StaticRole, UserRole, ) +from .user_data import SQLUserData # noqa from corehq import toggles, privileges from corehq.apps.accounting.utils import domain_has_privilege from corehq.apps.locations.models import ( @@ -937,7 +938,7 @@ class CouchUser(Document, DjangoUserMixin, IsMemberOfMixin, EulaMixin): language = StringProperty() subscribed_to_commcare_users = BooleanProperty(default=False) announcements_seen = ListProperty() - user_data = DictProperty() # use metadata property instead of accessing this directly + user_data = DictProperty() # use get_user_data object instead of accessing this directly # This should not be set directly but using set_location method only location_id = StringProperty() assigned_location_ids = StringListProperty() @@ -999,17 +1000,6 @@ def __repr__(self): ) for key in properties), ) - @property - def metadata(self): - return self.user_data - - def update_metadata(self, data): - self.user_data.update(data) - return True - - def pop_metadata(self, key, default=None): - return self.user_data.pop(key, default) - @property def two_factor_disabled(self): return ( @@ -1112,22 +1102,28 @@ def set_full_name(self, full_name): self.first_name = data.pop(0) self.last_name = ' '.join(data) + def get_user_data(self, domain): + from .user_data import UserData + if domain not in self._user_data_accessors: + self._user_data_accessors[domain] = UserData.lazy_init(self, domain) + return self._user_data_accessors[domain] + + def _save_user_data(self): + for user_data in self._user_data_accessors.values(): + user_data.save() + def get_user_session_data(self, domain): from corehq.apps.custom_data_fields.models import ( SYSTEM_PREFIX, COMMCARE_USER_TYPE_KEY, COMMCARE_USER_TYPE_DEMO, - COMMCARE_PROJECT ) - session_data = self.metadata + session_data = self.get_user_data(domain).to_dict() if self.is_commcare_user() and self.is_demo_user: session_data[COMMCARE_USER_TYPE_KEY] = COMMCARE_USER_TYPE_DEMO - if COMMCARE_PROJECT not in session_data: - session_data[COMMCARE_PROJECT] = domain - session_data.update({ f'{SYSTEM_PREFIX}_first_name': self.first_name, f'{SYSTEM_PREFIX}_last_name': self.last_name, @@ -1174,7 +1170,7 @@ def get_django_user(self, use_primary_db=False): queryset = User.objects if use_primary_db: queryset = queryset.using(router.db_for_write(User)) - return queryset.get(username__iexact=self.username) + return queryset.get(username=self.username) def add_phone_number(self, phone_number, default=False, **kwargs): """ Don't add phone numbers if they already exist """ @@ -1421,9 +1417,13 @@ def get_by_user_id(cls, userID, domain=None): def from_django_user(cls, django_user, strict=False): return cls.get_by_username(django_user.username, strict=strict) + def __init__(self, *args, **kwargs): + self._user_data_accessors = {} + super().__init__(*args, **kwargs) + @classmethod def create(cls, domain, username, password, created_by, created_via, email=None, uuid='', date='', - first_name='', last_name='', metadata=None, **kwargs): + user_data=None, first_name='', last_name='', **kwargs): try: django_user = User.objects.using(router.db_for_write(User)).get(username=username) except User.DoesNotExist: @@ -1435,21 +1435,20 @@ def create(cls, domain, username, password, created_by, created_via, email=None, if uuid: if not re.match(r'[\w-]+', uuid): raise cls.InvalidID('invalid id %r' % uuid) - couch_user = cls(_id=uuid) else: - couch_user = cls() + uuid = uuid4().hex + couch_user = cls(_id=uuid) if date: couch_user.created_on = force_to_datetime(date) else: couch_user.created_on = datetime.utcnow() - if 'user_data' in kwargs: - raise ValueError("Do not access user_data directly, pass metadata argument to create.") - metadata = metadata or {} - metadata.update({'commcare_project': domain}) - couch_user.update_metadata(metadata) couch_user.sync_from_django_user(django_user) + + if user_data: + couch_user.get_user_data(domain).update(user_data) + return couch_user def to_be_deleted(self): @@ -1466,7 +1465,7 @@ def save_docs(cls, docs, **kwargs): bulk_save = save_docs - def save(self, fire_signals=True, **params): + def save(self, fire_signals=True, update_django_user=True, **params): # HEADS UP! # When updating this method, please also ensure that your updates also # carry over to bulk_auto_deactivate_commcare_users. @@ -1478,10 +1477,12 @@ def save(self, fire_signals=True, **params): if by_username and by_username['id'] != self._id: raise self.Inconsistent("CouchUser with username %s already exists" % self.username) - if self._rev and not self.to_be_deleted(): + if update_django_user and self._rev and not self.to_be_deleted(): django_user = self.sync_to_django_user() django_user.save() + if not self.to_be_deleted(): + self._save_user_data() super(CouchUser, self).save(**params) if fire_signals: @@ -1664,59 +1665,8 @@ def wrap(cls, data): data['domain_membership'] = DomainMembership( domain=data.get('domain', ""), role_id=role_id ).to_json() - if not data.get('user_data', {}).get('commcare_project'): - data['user_data'] = dict(data['user_data'], **{'commcare_project': data['domain']}) - return super(CommCareUser, cls).wrap(data) - @property - def metadata(self): - from corehq.apps.custom_data_fields.models import PROFILE_SLUG - data = self.to_json().get('user_data', {}) - profile_id = data.get(PROFILE_SLUG) - profile = self.get_user_data_profile(profile_id) - if profile: - data.update(profile.fields) - return data - - def update_metadata(self, data): - from corehq.apps.custom_data_fields.models import PROFILE_SLUG - - new_data = {**self.user_data, **data} - old_profile_id = self.user_data.get(PROFILE_SLUG) - - profile = self.get_user_data_profile(new_data.get(PROFILE_SLUG)) - if profile: - overlap = {k for k, v in profile.fields.items() if new_data.get(k) and v != new_data[k]} - if overlap: - raise ValueError("metadata properties conflict with profile: {}".format(", ".join(overlap))) - for key in profile.fields.keys(): - new_data.pop(key, None) - - profile_updated = old_profile_id != new_data.get(PROFILE_SLUG) - metadata_updated = new_data != self.user_data - self.user_data = new_data - return metadata_updated, profile_updated - - def pop_metadata(self, key, default=None): - return self.user_data.pop(key, default) - - def get_user_data_profile(self, profile_id): - from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView - from corehq.apps.custom_data_fields.models import CustomDataFieldsProfile - if not profile_id: - return None - - try: - profile = CustomDataFieldsProfile.objects.get(id=profile_id) - except CustomDataFieldsProfile.DoesNotExist: - raise ValueError("Could not find profile with id {}".format(profile_id)) - if profile.definition.domain != self.domain: - raise ValueError("Could not find profile with id {}".format(profile_id)) - if profile.definition.field_type != UserFieldsView.field_type: - raise ValueError("Could not find profile with id {}".format(profile_id)) - return profile - def _is_demo_user_cached_value_is_stale(self): from corehq.apps.users.dbaccessors import get_practice_mode_mobile_workers cached_demo_users = get_practice_mode_mobile_workers.get_cached_value(self.domain) @@ -1777,12 +1727,11 @@ def create(cls, location=None, commit=True, is_account_confirmed=True, - metadata=None, + user_data=None, **kwargs): """ Main entry point into creating a CommCareUser (mobile worker). """ - uuid = uuid or uuid4().hex # if the account is not confirmed, also set is_active false so they can't login if 'is_active' not in kwargs: kwargs['is_active'] = is_account_confirmed @@ -1790,7 +1739,7 @@ def create(cls, assert not kwargs['is_active'], \ "it's illegal to create a user with is_active=True and is_account_confirmed=False" commcare_user = super(CommCareUser, cls).create(domain, username, password, created_by, created_via, - email, uuid, date, metadata=None, **kwargs) + email, uuid, date, user_data, **kwargs) if phone_number is not None: commcare_user.add_phone_number(phone_number) @@ -1801,10 +1750,6 @@ def create(cls, commcare_user.registering_device_id = device_id commcare_user.is_account_confirmed = is_account_confirmed commcare_user.domain_membership = DomainMembership(domain=domain, **kwargs) - # metadata can't be set until domain is present - if 'user_data' in kwargs: - raise ValueError("Do not access user_data directly, pass metadata argument to create.") - commcare_user.update_metadata(metadata or {}) if location: commcare_user.set_location(location, commit=False) @@ -1861,6 +1806,7 @@ def unretire(self, unretired_by_domain, unretired_by, unretired_via=None): - It will not restore Case Indexes that were removed - It will not restore the user's phone numbers - It will not restore reminders for cases + - It will not restore custom user data """ from corehq.apps.users.model_log import UserModelAction @@ -2068,7 +2014,8 @@ def add_to_assigned_locations(self, location, commit=True): return self.assigned_location_ids.append(location.location_id) self.get_domain_membership(self.domain).assigned_location_ids.append(location.location_id) - self.update_metadata({'commcare_location_ids': user_location_data(self.assigned_location_ids)}) + user_data = self.get_user_data(self.domain) + user_data['commcare_location_ids'] = user_location_data(self.assigned_location_ids) if commit: self.save() else: @@ -2090,31 +2037,25 @@ def set_location(self, location, commit=True): if not location.location_id: raise AssertionError("You can't set an unsaved location") - self.update_metadata({'commcare_location_id': location.location_id}) + user_data = self.get_user_data(self.domain) + user_data['commcare_location_id'] = location.location_id if not location.location_type_object.administrative: # just need to trigger a get or create to make sure # this exists, otherwise things blow up sp = SupplyInterface(self.domain).get_or_create_by_location(location) - - self.update_metadata({ - 'commtrack-supply-point': sp.case_id - }) + user_data['commtrack-supply-point'] = sp.case_id self.create_location_delegates([location]) - self.update_metadata({ - 'commcare_primary_case_sharing_id': - location.location_id - }) - + user_data['commcare_primary_case_sharing_id'] = location.location_id self.update_fixture_status(UserLookupTableType.LOCATION) self.location_id = location.location_id self.get_domain_membership(self.domain).location_id = location.location_id if self.location_id not in self.assigned_location_ids: self.assigned_location_ids.append(self.location_id) self.get_domain_membership(self.domain).assigned_location_ids.append(self.location_id) - self.update_metadata({'commcare_location_ids': user_location_data(self.assigned_location_ids)}) + user_data['commcare_location_ids'] = user_location_data(self.assigned_location_ids) self.get_sql_location.reset_cache(self) if commit: self.save() @@ -2134,18 +2075,19 @@ def unset_location(self, fall_back_to_next=False, commit=True): if old_primary_location_id: self._remove_location_from_user(old_primary_location_id) + user_data = self.get_user_data(self.domain) if self.assigned_location_ids: - self.update_metadata({'commcare_location_ids': user_location_data(self.assigned_location_ids)}) - elif self.metadata.get('commcare_location_ids'): - self.pop_metadata('commcare_location_ids') + user_data['commcare_location_ids'] = user_location_data(self.assigned_location_ids) + elif user_data.get('commcare_location_ids', None): + del user_data['commcare_location_ids'] if self.assigned_location_ids and fall_back_to_next: new_primary_location_id = self.assigned_location_ids[0] self.set_location(SQLLocation.objects.get(location_id=new_primary_location_id)) else: - self.pop_metadata('commcare_location_id', None) - self.pop_metadata('commtrack-supply-point', None) - self.pop_metadata('commcare_primary_case_sharing_id', None) + user_data.pop('commcare_location_id', None) + user_data.pop('commtrack-supply-point', None) + user_data.pop('commcare_primary_case_sharing_id', None) self.location_id = None self.clear_location_delegates() self.update_fixture_status(UserLookupTableType.LOCATION) @@ -2167,10 +2109,11 @@ def unset_location_by_id(self, location_id, fall_back_to_next=False): else: self._remove_location_from_user(location_id) + user_data = self.get_user_data(self.domain) if self.assigned_location_ids: - self.update_metadata({'commcare_location_ids': user_location_data(self.assigned_location_ids)}) + user_data['commcare_location_ids'] = user_location_data(self.assigned_location_ids) else: - self.pop_metadata('commcare_location_ids') + user_data.pop('commcare_location_ids', None) self.save() def _remove_location_from_user(self, location_id): @@ -2202,12 +2145,11 @@ def reset_locations(self, location_ids, commit=True): self.assigned_location_ids = location_ids self.get_domain_membership(self.domain).assigned_location_ids = location_ids + user_data = self.get_user_data(self.domain) if location_ids: - self.update_metadata({ - 'commcare_location_ids': user_location_data(location_ids) - }) + user_data['commcare_location_ids'] = user_location_data(location_ids) else: - self.pop_metadata('commcare_location_ids', None) + user_data.pop('commcare_location_ids', None) # try to set primary-location if not set already if not self.location_id and location_ids: @@ -2442,9 +2384,9 @@ def is_global_admin(self): @classmethod def create(cls, domain, username, password, created_by, created_via, email=None, uuid='', date='', - metadata=None, by_domain_required_for_log=True, **kwargs): + user_data=None, by_domain_required_for_log=True, **kwargs): web_user = super(WebUser, cls).create(domain, username, password, created_by, created_via, email, uuid, - date, metadata=metadata, **kwargs) + date, user_data, **kwargs) if domain: web_user.add_domain_membership(domain, **kwargs) web_user.save() @@ -2689,7 +2631,7 @@ def send_approval_email(self): html_content = render_to_string("users/email/new_domain_request.html", params) subject = _('Request to join %s approved') % domain_name send_html_email_async.delay(subject, self.email, html_content, text_content=text_content, - email_from=settings.DEFAULT_FROM_EMAIL) + domain=self.domain, use_domain_gateway=True) def send_request_email(self): domain_name = Domain.get_by_name(self.domain).display_name() @@ -2708,7 +2650,7 @@ def send_request_email(self): 'domain': domain_name, } send_html_email_async.delay(subject, recipients, html_content, text_content=text_content, - email_from=settings.DEFAULT_FROM_EMAIL) + domain=self.domain, use_domain_gateway=True) class InvitationStatus(object): @@ -2753,8 +2695,9 @@ def email_marked_as_bounced(self): def send_activation_email(self, remaining_days=30): inviter = CouchUser.get_by_user_id(self.invited_by) url = absolute_reverse("domain_accept_invitation", args=[self.domain, self.uuid]) + domain_obj = Domain.get_by_name(self.domain) params = { - "domain": self.domain, + "domain": domain_obj.display_name(), "url": url, "days": remaining_days, "inviter": inviter.formatted_name, @@ -2775,8 +2718,9 @@ def send_activation_email(self, remaining_days=30): send_html_email_async.delay(subject, self.email, html_content, text_content=text_content, cc=[inviter.get_email()], - email_from=settings.DEFAULT_FROM_EMAIL, - messaging_event_id=f"{self.EMAIL_ID_PREFIX}{self.uuid}") + messaging_event_id=f"{self.EMAIL_ID_PREFIX}{self.uuid}", + domain=self.domain, + use_domain_gateway=True) def get_role_name(self): if self.role: @@ -2811,7 +2755,9 @@ def _send_confirmation_email(self): subject, recipient, html_content, - text_content=text_content + text_content=text_content, + domain=self.domain, + use_domain_gateway=True ) def accept_invitation_and_join_domain(self, web_user): @@ -3025,7 +2971,14 @@ def process_record(self, user): fcm_token=self.fcm_token, fcm_token_timestamp=self.last_heartbeat, save_user=False ) if save: - user.save(fire_signals=False) + # update_django_user=False below is an optimization that allows us to update the CouchUser + # without propagating that change to SQL. + # This is an optimization we're able to do safely only because we happen to know that + # the present workflow only updates properties that are *not* stored on the django (SQL) user model. + # We have seen that these frequent updates to the SQL user table + # occasionally create deadlocks or pile-ups, + # which can be avoided by omitting that extraneous write entirely. + user.save(fire_signals=False, update_django_user=False) class Meta(object): unique_together = ('domain', 'user_id', 'app_id') @@ -3234,6 +3187,8 @@ def check_and_send_limit_email(domain, plan_limit, user_count, prev_count): 'user_count': user_count, 'plan_limit': plan_limit, }), + domain=domain, + use_domain_gateway=True, ) return diff --git a/corehq/apps/users/static/users/js/custom_data_fields.js b/corehq/apps/users/static/users/js/custom_data_fields.js index b5299aba700a9..39a64a31bff68 100644 --- a/corehq/apps/users/static/users/js/custom_data_fields.js +++ b/corehq/apps/users/static/users/js/custom_data_fields.js @@ -22,7 +22,7 @@ hqDefine("users/js/custom_data_fields", [ var customDataFieldsEditor = function (options) { assertProperties.assertRequired(options, ['profiles', 'slugs', 'profile_slug'], ['user_data']); - options.metadata = options.metadata || {}; + options.user_data = options.user_data || {}; var self = {}; self.profiles = _.indexBy(options.profiles, 'id'); @@ -32,8 +32,8 @@ hqDefine("users/js/custom_data_fields", [ var originalProfileFields = {}, originalProfileId, originalProfile; - if (options.metadata) { - originalProfileId = options.metadata[options.profile_slug]; + if (options.user_data) { + originalProfileId = options.user_data[options.profile_slug]; if (originalProfileId) { originalProfile = self.profiles[originalProfileId]; if (originalProfile) { @@ -43,7 +43,7 @@ hqDefine("users/js/custom_data_fields", [ } _.each(self.slugs, function (slug) { self[slug] = fieldModel({ - value: options.metadata[slug] || originalProfileFields[slug], + value: options.user_data[slug] || originalProfileFields[slug], disable: !!originalProfileFields[slug], }); }); diff --git a/corehq/apps/users/static/users/js/edit_commcare_user.js b/corehq/apps/users/static/users/js/edit_commcare_user.js index 99badcf3556eb..c740f120b947e 100644 --- a/corehq/apps/users/static/users/js/edit_commcare_user.js +++ b/corehq/apps/users/static/users/js/edit_commcare_user.js @@ -148,7 +148,7 @@ hqDefine('users/js/edit_commcare_user', [ $customDataFieldsForm.koApplyBindings(function () { return { custom_fields: customDataFields.customDataFieldsEditor({ - metadata: initialPageData.get('metadata'), + user_data: initialPageData.get('user_data'), profiles: initialPageData.get('custom_fields_profiles'), profile_slug: initialPageData.get('custom_fields_profile_slug'), slugs: initialPageData.get('custom_fields_slugs'), diff --git a/corehq/apps/users/static/users/js/invite_web_user.js b/corehq/apps/users/static/users/js/invite_web_user.js index b807c4f02e900..387db676f7eab 100644 --- a/corehq/apps/users/static/users/js/invite_web_user.js +++ b/corehq/apps/users/static/users/js/invite_web_user.js @@ -3,7 +3,7 @@ hqDefine('users/js/invite_web_user',[ 'knockout', 'hqwebapp/js/initial_page_data', 'hqwebapp/js/toggles', - 'hqwebapp/js/validators.ko', + 'hqwebapp/js/bootstrap3/validators.ko', 'locations/js/widgets', ], function ( $, diff --git a/corehq/apps/users/static/users/js/mobile_workers.js b/corehq/apps/users/static/users/js/mobile_workers.js index 6b7bbc0da0ea9..c5a65bfa8b5a3 100644 --- a/corehq/apps/users/static/users/js/mobile_workers.js +++ b/corehq/apps/users/static/users/js/mobile_workers.js @@ -27,7 +27,7 @@ hqDefine("users/js/mobile_workers",[ 'locations/js/widgets', 'users/js/custom_data_fields', 'hqwebapp/js/bootstrap3/components.ko', // for pagination - 'hqwebapp/js/validators.ko', // email address validation + 'hqwebapp/js/bootstrap3/validators.ko', // email address validation 'eonasdan-bootstrap-datetimepicker/build/js/bootstrap-datetimepicker.min', ], function ( $, diff --git a/corehq/apps/users/tasks.py b/corehq/apps/users/tasks.py index 954b7bc40b382..a0280d892565c 100644 --- a/corehq/apps/users/tasks.py +++ b/corehq/apps/users/tasks.py @@ -1,5 +1,5 @@ -from uuid import uuid4 from datetime import datetime +from uuid import uuid4 from django.conf import settings from django.db import transaction @@ -19,6 +19,7 @@ from dimagi.utils.couch import get_redis_lock from dimagi.utils.couch.bulk import BulkFetchException from dimagi.utils.logging import notify_exception +from dimagi.utils.retry import retry_on from soil import DownloadBase from corehq import toggles @@ -295,10 +296,20 @@ def reset_demo_user_restore_task(commcare_user_id, domain): @task(serializer='pickle') def remove_unused_custom_fields_from_users_task(domain): - from corehq.apps.users.custom_data import ( - remove_unused_custom_fields_from_users, + """Removes all unused custom data fields from all users in the domain""" + from corehq.apps.custom_data_fields.models import CustomDataFieldsDefinition + from corehq.apps.users.dbaccessors import get_all_commcare_users_by_domain + from corehq.apps.users.views.mobile.custom_data_fields import ( + CUSTOM_USER_DATA_FIELD_TYPE, ) - remove_unused_custom_fields_from_users(domain) + fields_definition = CustomDataFieldsDefinition.get(domain, CUSTOM_USER_DATA_FIELD_TYPE) + assert fields_definition, 'remove_unused_custom_fields_from_users_task called without a valid definition' + schema_fields = {f.slug for f in fields_definition.get_fields()} + for user in get_all_commcare_users_by_domain(domain): + user_data = user.get_user_data(domain) + changed = user_data.remove_unrecognized(schema_fields) + if changed: + user.save() @task() @@ -326,10 +337,8 @@ def update_domain_date(user_id, domain): queue='background_queue', ) def process_reporting_metadata_staging(): - from corehq.apps.users.models import ( - CouchUser, - UserReportingMetadataStaging, - ) + from corehq.apps.users.models import UserReportingMetadataStaging + lock_key = "PROCESS_REPORTING_METADATA_STAGING_TASK" process_reporting_metadata_lock = get_redis_lock( lock_key, @@ -342,21 +351,7 @@ def process_reporting_metadata_staging(): try: start = datetime.utcnow() - - for i in range(100): - with transaction.atomic(): - records = ( - UserReportingMetadataStaging.objects.select_for_update(skip_locked=True).order_by('pk') - )[:1] - for record in records: - user = CouchUser.get_by_user_id(record.user_id, record.domain) - try: - record.process_record(user) - except ResourceConflict: - # https://sentry.io/organizations/dimagi/issues/1479516073/ - user = CouchUser.get_by_user_id(record.user_id, record.domain) - record.process_record(user) - record.delete() + _process_reporting_metadata_staging() finally: process_reporting_metadata_lock.release() @@ -366,6 +361,28 @@ def process_reporting_metadata_staging(): process_reporting_metadata_staging.delay() +def _process_reporting_metadata_staging(): + from corehq.apps.users.models import UserReportingMetadataStaging + for i in range(100): + with transaction.atomic(): + records = (UserReportingMetadataStaging.objects.select_for_update(skip_locked=True).order_by('pk'))[:1] + for record in records: + _process_record_with_retry(record) + record.delete() + + +@retry_on(ResourceConflict, delays=[0, 0.5]) +def _process_record_with_retry(record): + """ + It is possible that an unrelated user update is saved to the db while we are processing the record + but before saving any user updates resulting from process_record. In this case, a ResourceConflict is + raised so we should try once more to see if it was just bad timing or a persistent error. + """ + from corehq.apps.users.models import CouchUser + user = CouchUser.get_by_user_id(record.user_id, record.domain) + record.process_record(user) + + @task(queue='background_queue', acks_late=True) def apply_correct_demo_mode_to_loadtest_user(commcare_user_id): """ diff --git a/corehq/apps/users/templates/users/edit_commcare_user.html b/corehq/apps/users/templates/users/edit_commcare_user.html index 991129d8a19bd..05395ce7217f3 100644 --- a/corehq/apps/users/templates/users/edit_commcare_user.html +++ b/corehq/apps/users/templates/users/edit_commcare_user.html @@ -15,7 +15,7 @@ {% initial_page_data "is_currently_logged_in_user" is_currently_logged_in_user %} {% initial_page_data "show_deactivate_after_date" show_deactivate_after_date %} {% initial_page_data "path" request.path %} - {% initial_page_data "metadata" couch_user.metadata %} + {% initial_page_data "user_data" user_data %} {% if couch_user.is_loadtest_user %} <p class="alert alert-warning"> {% blocktrans %} diff --git a/corehq/apps/users/tests/test_dbaccessors.py b/corehq/apps/users/tests/test_dbaccessors.py index cff5e29cb0e55..0b7fa215dc5a0 100644 --- a/corehq/apps/users/tests/test_dbaccessors.py +++ b/corehq/apps/users/tests/test_dbaccessors.py @@ -24,6 +24,7 @@ get_mobile_users_by_filters, get_web_users_by_filters, hard_delete_deleted_users, + get_all_user_search_query, ) from corehq.apps.users.models import ( CommCareUser, @@ -158,6 +159,20 @@ def usernames(users): self.assertEqual(count_web_users_by_filters(self.ccdomain.name, {}), 2) + # query_string search + self.assertItemsEqual( + get_all_user_search_query(self.ccdomain.name[0:2]).get_ids(), + [ + self.ccuser_1._id, self.ccuser_2._id, self.web_user._id, + self.location_restricted_web_user._id, self.ccuser_inactive._id + ] + ) + + self.assertItemsEqual( + get_all_user_search_query(self.ccuser_1.username).get_ids(), + [self.ccuser_1._id] + ) + # can search by username filters = {'search_string': 'user_1'} self.assertItemsEqual( @@ -220,7 +235,6 @@ def usernames(users): filters = {'web_user_assigned_location_ids': list(assigned_location_ids)} self.assertEqual(count_mobile_users_by_filters(self.ccdomain.name, filters), 2) - def test_get_invitations_by_filters(self): invitations = [ Invitation(domain=self.ccdomain.name, email='wolfgang@email.com', invited_by='friend@email.com', diff --git a/corehq/apps/users/tests/test_download.py b/corehq/apps/users/tests/test_download.py index eb175927cc121..630b1d9713418 100644 --- a/corehq/apps/users/tests/test_download.py +++ b/corehq/apps/users/tests/test_download.py @@ -65,7 +65,7 @@ def setUpClass(cls): first_name='Edith', last_name='Wharton', phone_number='27786541239', - metadata={'born': 1862} + user_data={'born': 1862} ) cls.user1.set_location(cls.location) cls.user2 = CommCareUser.create( @@ -76,7 +76,7 @@ def setUpClass(cls): None, first_name='George', last_name='Eliot', - metadata={'born': 1849, PROFILE_SLUG: cls.profile.id}, + user_data={'born': 1849, PROFILE_SLUG: cls.profile.id}, ) cls.user2.set_location(cls.location) cls.user3 = CommCareUser.create( diff --git a/corehq/apps/users/tests/test_download_with_profile.py b/corehq/apps/users/tests/test_download_with_profile.py index 72dee50a3ad48..f6a21dc86b92f 100644 --- a/corehq/apps/users/tests/test_download_with_profile.py +++ b/corehq/apps/users/tests/test_download_with_profile.py @@ -60,7 +60,7 @@ def setUpClass(cls): None, first_name='Edith', last_name='Wharton', - metadata={'born': 1862} + user_data={'born': 1862} ) cls.user2 = CommCareUser.create( cls.domain_obj.name, @@ -70,7 +70,7 @@ def setUpClass(cls): None, first_name='George', last_name='Eliot', - metadata={'born': 1849, PROFILE_SLUG: cls.profile.id}, + user_data={'born': 1849, PROFILE_SLUG: cls.profile.id}, ) @classmethod diff --git a/corehq/apps/users/tests/test_location_assignment.py b/corehq/apps/users/tests/test_location_assignment.py index 5909bd584b273..67d3fd7b4881d 100644 --- a/corehq/apps/users/tests/test_location_assignment.py +++ b/corehq/apps/users/tests/test_location_assignment.py @@ -129,20 +129,20 @@ def test_create_with_location(self): def assertPrimaryLocation(self, expected): self.assertEqual(self.user.location_id, expected) - self.assertEqual(self.user.user_data.get('commcare_location_id'), expected) + self.assertEqual(self.user.get_user_data(self.domain).get('commcare_location_id'), expected) self.assertTrue(expected in self.user.assigned_location_ids) def assertAssignedLocations(self, expected_location_ids): user = CommCareUser.get(self.user._id) self.assertListEqual(user.assigned_location_ids, expected_location_ids) - actual_ids = user.user_data.get('commcare_location_ids', '') + actual_ids = user.get_user_data(self.domain).get('commcare_location_ids', '') actual_ids = actual_ids.split(' ') if actual_ids else [] self.assertListEqual(actual_ids, expected_location_ids) def assertNonPrimaryLocation(self, expected): self.assertNotEqual(self.user.location_id, expected) self.assertTrue(expected in self.user.assigned_location_ids) - self.assertTrue(expected in self.user.user_data.get('commcare_location_ids')) + self.assertTrue(expected in self.user.get_user_data(self.domain).get('commcare_location_ids')) class WebUserLocationAssignmentTest(TestCase): diff --git a/corehq/apps/users/tests/test_log_user_change.py b/corehq/apps/users/tests/test_log_user_change.py index be2550e3265ed..4dcc58f634af7 100644 --- a/corehq/apps/users/tests/test_log_user_change.py +++ b/corehq/apps/users/tests/test_log_user_change.py @@ -186,7 +186,7 @@ def _get_expected_changes_json(user): 'status': None, 'subscribed_to_commcare_users': False, 'two_factor_auth_disabled_until': None, - 'user_data': {'commcare_project': 'test'}, + 'user_data': {}, 'user_location_id': None, 'username': user_json['username'] } diff --git a/corehq/apps/users/tests/test_remove_unused_custom_fields.py b/corehq/apps/users/tests/test_remove_unused_custom_fields.py deleted file mode 100644 index 5654b68495a57..0000000000000 --- a/corehq/apps/users/tests/test_remove_unused_custom_fields.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.test import SimpleTestCase - -from corehq.apps.users.custom_data import _get_invalid_user_data_fields -from corehq.apps.users.models import CommCareUser - - -class RemoveUnusedFieldsTestCase(SimpleTestCase): - - def test_empty(self): - user_with_no_fields = CommCareUser(user_data={}) - self.assertEqual([], _get_invalid_user_data_fields(user_with_no_fields, set())) - self.assertEqual([], _get_invalid_user_data_fields(user_with_no_fields, set(['a', 'b']))) - - def test_normal_behavior(self): - user_with_fields = CommCareUser(user_data={'a': 'foo', 'b': 'bar', 'c': 'baz'}) - self.assertEqual(set(['a', 'b', 'c']), set(_get_invalid_user_data_fields(user_with_fields, set()))) - self.assertEqual(set(['c']), set(_get_invalid_user_data_fields(user_with_fields, set(['a', 'b'])))) - self.assertEqual(set(['a', 'b', 'c']), - set(_get_invalid_user_data_fields(user_with_fields, set(['e', 'f'])))) - - def test_system_fields_not_removed(self): - user_with_system_fields = CommCareUser(user_data={'commcare_location_id': 'foo'}) - self.assertEqual([], _get_invalid_user_data_fields(user_with_system_fields, set())) - self.assertEqual([], _get_invalid_user_data_fields(user_with_system_fields, set(['a', 'b']))) diff --git a/corehq/apps/users/tests/test_signals.py b/corehq/apps/users/tests/test_signals.py index c98d31851d5d8..e6969014f78f1 100644 --- a/corehq/apps/users/tests/test_signals.py +++ b/corehq/apps/users/tests/test_signals.py @@ -9,6 +9,7 @@ from corehq.apps.es.tests.utils import es_test from corehq.apps.es.users import user_adapter from corehq.apps.reports.analytics.esaccessors import get_user_stubs +from corehq.apps.users.tests.util import patch_user_data_db_layer from corehq.util.es.testing import sync_users_to_es from corehq.util.test_utils import mock_out_couch @@ -57,6 +58,7 @@ def test_webuser_save(self, send_to_es, invalidate, sync_usercases, @mock_out_couch() +@patch_user_data_db_layer @patch('corehq.apps.users.models.CouchUser.sync_to_django_user', new=MagicMock) @patch('corehq.apps.analytics.signals.update_hubspot_properties') @patch('corehq.apps.callcenter.tasks.sync_usercases') diff --git a/corehq/apps/users/tests/test_tasks.py b/corehq/apps/users/tests/test_tasks.py index 8ecbebe640d40..570690cec7ee0 100644 --- a/corehq/apps/users/tests/test_tasks.py +++ b/corehq/apps/users/tests/test_tasks.py @@ -1,23 +1,32 @@ import uuid from contextlib import contextmanager from datetime import datetime, timedelta +from unittest.mock import patch from django.test import TestCase +from couchdbkit import ResourceConflict + from corehq.apps.domain.shortcuts import create_domain from corehq.apps.enterprise.tests.utils import create_enterprise_permissions +from corehq.apps.es import case_search_adapter +from corehq.apps.es.tests.utils import es_test +from corehq.apps.hqcase.case_helper import CaseCopier +from corehq.apps.reports.util import domain_copied_cases_by_owner from corehq.apps.users.dbaccessors import delete_all_users -from corehq.apps.users.models import CommCareUser, WebUser +from corehq.apps.users.models import ( + CommCareUser, + UserReportingMetadataStaging, + WebUser, +) from corehq.apps.users.tasks import ( + _process_reporting_metadata_staging, apply_correct_demo_mode_to_loadtest_user, + remove_users_test_cases, update_domain_date, ) -from corehq.apps.es.tests.utils import es_test -from corehq.apps.es import case_search_adapter from corehq.form_processor.models import CommCareCase -from corehq.apps.users.tasks import remove_users_test_cases -from corehq.apps.reports.util import domain_copied_cases_by_owner -from corehq.apps.hqcase.case_helper import CaseCopier +from corehq.util.test_utils import new_db_connection class TasksTest(TestCase): @@ -71,6 +80,7 @@ def test_update_domain_date_web_user_mirror(self): self.assertIsNone(self._last_accessed(self.web_user, self.mirror_domain.name)) +@patch('corehq.apps.users.models.CouchUser.get_user_session_data', new=lambda _, __: {}) class TestLoadtestUserIsDemoUser(TestCase): def test_set_loadtest_factor_on_demo_user(self): @@ -118,7 +128,6 @@ def _get_user(loadtest_factor, is_demo_user): 'username': f'testy@{domain_name}.commcarehq.org', 'loadtest_factor': loadtest_factor, 'is_demo_user': is_demo_user, - 'user_data': {}, 'date_joined': just_now, }) user.save() @@ -176,3 +185,105 @@ def _send_case_to_es( case_search_adapter.index(case, refresh=True) return case + + +@patch.object(UserReportingMetadataStaging, 'process_record') +class TestProcessReportingMetadataStaging(TestCase): + + def test_record_is_deleted_if_processed_successfully(self, mock_process_record): + record = UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + self.assertTrue(UserReportingMetadataStaging.objects.get(id=record.id)) + + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 1) + self.assertEqual(UserReportingMetadataStaging.objects.all().count(), 0) + + def test_record_is_not_deleted_if_not_processed_successfully(self, mock_process_record): + record = UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + mock_process_record.side_effect = Exception + + with self.assertRaises(Exception): + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 1) + self.assertTrue(UserReportingMetadataStaging.objects.get(id=record.id)) + + def test_process_record_is_retried_successfully_after_resource_conflict_raised(self, mock_process_record): + # Simulate the scenario where the first attempt to process a record raises ResourceConflict + # but the next attempt succeeds + mock_process_record.side_effect = [ResourceConflict, None] + UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 2) + self.assertEqual(UserReportingMetadataStaging.objects.all().count(), 0) + + def test_process_record_raises_resource_conflict_after_three_tries(self, mock_process_record): + # ResourceConflict will always be raised when calling mock_process_record + mock_process_record.side_effect = ResourceConflict + UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + + with self.assertRaises(ResourceConflict): + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 3) + self.assertEqual(UserReportingMetadataStaging.objects.all().count(), 1) + + def test_subsequent_records_are_not_processed_if_exception_raised(self, mock_process_record): + mock_process_record.side_effect = [Exception, None] + UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + UserReportingMetadataStaging.objects.create(user_id=self.user._id, domain='test-domain') + + with self.assertRaises(Exception): + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 1) + self.assertEqual(UserReportingMetadataStaging.objects.all().count(), 2) + + def setUp(self): + super().setUp() + self.user = CommCareUser.create('test-domain', 'test-username', 'qwer1234', None, None) + self.addCleanup(self.user.delete, 'test-domain', deleted_by=None) + + +@patch.object(UserReportingMetadataStaging, 'process_record') +class TestProcessReportingMetadataStagingTransaction(TestCase): + """ + This is testing the same method as TestProcessReportingMetadataStaging is above, but + this is specifically testing how the method behaves when a record is locked. + In order to reproduce this scenario without using a TransactionTestCase, which has a + heavy handed cleanup process that can disrupt other tests, we need to create the initial + records outside of the TestCase transaction, otherwise the records will not be available + from another db connection. No other test should be added to this class as ``select_for_update`` + will hold a lock for the duration of the outer transaction, and the general cleanup/teardown + here is kludgy. + """ + def test_subsequent_records_are_processed_if_record_is_locked(self, mock_process_record): + _ = UserReportingMetadataStaging.objects.select_for_update().get(pk=self.record.id) + with new_db_connection(): + _process_reporting_metadata_staging() + + self.assertEqual(mock_process_record.call_count, 1) + self.assertEqual(UserReportingMetadataStaging.objects.all().count(), 1) + + @classmethod + def setUpClass(cls): + cls.user = CommCareUser.create('test-domain', 'test-username', 'qwer1234', None, None) + # Create the records outside of the TestCase transaction to ensure they are committed/saved + # to the db by the time the method under tests attempts to read from the database + # Because this is outside of a transaction, we are responsible for cleaning these up + cls.record = UserReportingMetadataStaging.objects.create(user_id=cls.user._id, domain='test-domain') + cls.record_two = UserReportingMetadataStaging.objects.create(user_id=cls.user._id, domain='test-domain') + super().setUpClass() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + # Cleanup needs to be done outside of the TestCase transaction to ensure it is not rolled back + # Notably, the user is currently stored in couch and could be done within the transaction, but + # for consistency it is here + cls.user.delete('test-domain', deleted_by=None) + cls.record.delete() + cls.record_two.delete() diff --git a/corehq/apps/users/tests/test_user_data.py b/corehq/apps/users/tests/test_user_data.py new file mode 100644 index 0000000000000..24693bab325d5 --- /dev/null +++ b/corehq/apps/users/tests/test_user_data.py @@ -0,0 +1,277 @@ +import uuid +from unittest.mock import patch + +from django.test import SimpleTestCase, TestCase + +from corehq.apps.custom_data_fields.models import CustomDataFieldsProfile +from corehq.apps.users.dbaccessors import delete_all_users +from corehq.apps.users.management.commands.populate_sql_user_data import ( + get_users_without_user_data, + populate_user_data, +) +from corehq.apps.users.models import CommCareUser, WebUser +from corehq.apps.users.user_data import SQLUserData, UserData, UserDataError + + +class TestUserData(TestCase): + domain = 'test-user-data' + + @classmethod + def setUpTestData(cls): + delete_all_users() + + def make_commcare_user(self): + user = CommCareUser.create(self.domain, str(uuid.uuid4()), '***', None, None, timezone="UTC") + self.addCleanup(user.delete, self.domain, deleted_by=None) + return user + + def make_web_user(self): + user = WebUser.create(self.domain, str(uuid.uuid4()), '***', None, None, timezone="UTC") + self.addCleanup(user.delete, self.domain, deleted_by=None) + return user + + def test_user_data_accessor(self): + user = self.make_commcare_user() + user_data = user.get_user_data(self.domain) + self.assertEqual(user_data['commcare_project'], self.domain) + user_data.update({ + 'cruise': 'control', + 'this': 'road', + }) + # Normally you shouldn't use `user.user_data` directly - I'm demonstrating that it's not updated + self.assertEqual(user.user_data, {}) + + def test_web_users(self): + # This behavior is bad - data isn't fully scoped to domain + web_user = self.make_web_user() + user_data = web_user.get_user_data(self.domain) + self.assertEqual(user_data.to_dict(), { + 'commcare_project': self.domain, + 'commcare_profile': '', + }) + + user_data['start'] = 'sometimes' + self.assertEqual(web_user.get_user_data(self.domain).to_dict(), { + 'commcare_project': self.domain, + 'commcare_profile': '', + 'start': 'sometimes', + }) + # Only the original domain was modified + self.assertEqual(web_user.get_user_data('ANOTHER_DOMAIN').to_dict(), { + 'commcare_project': 'ANOTHER_DOMAIN', + 'commcare_profile': '', + }) + + def test_lazy_init_and_save(self): + # Mimic user created the old way, with data stored in couch + user = CommCareUser.create(self.domain, 'riggan', '***', None, None) + self.addCleanup(user.delete, self.domain, deleted_by=None) + user['user_data'] = {'favorite_color': 'purple', + 'start_date': '2023-01-01T00:00:00.000000Z'} + user.save() + with self.assertRaises(SQLUserData.DoesNotExist): + SQLUserData.objects.get(domain=self.domain, user_id=user.user_id) + + # Accessing data for the first time saves it to SQL + self.assertEqual(user.get_user_data(self.domain)['favorite_color'], 'purple') + sql_data = SQLUserData.objects.get(domain=self.domain, user_id=user.user_id) + self.assertEqual(sql_data.data['favorite_color'], 'purple') + self.assertEqual(sql_data.data['start_date'], '2023-01-01T00:00:00.000000Z') + + # Making a modification works immediately, but isn't persisted until user save + user.get_user_data(self.domain)['favorite_color'] = 'blue' + self.assertEqual(user.get_user_data(self.domain)['favorite_color'], 'blue') + sql_data.refresh_from_db() + self.assertEqual(sql_data.data['favorite_color'], 'purple') # unchanged + user.save() + sql_data.refresh_from_db() + self.assertEqual(sql_data.data['favorite_color'], 'blue') + + def test_get_users_without_user_data(self): + users_without_data = [ + self.make_commcare_user(), + self.make_commcare_user(), + self.make_web_user(), + self.make_web_user(), + ] + users_with_data = [self.make_commcare_user(), self.make_web_user()] + for user in users_with_data: + user.get_user_data(self.domain).save() + + users_to_migrate = get_users_without_user_data() + self.assertItemsEqual( + [u.username for u in users_without_data], + [u.username for u in users_to_migrate], + ) + + def test_migrate_commcare_user(self): + user = self.make_commcare_user() + user['user_data'] = {'favorite_color': 'purple'} + user.save() + populate_user_data(user) + sql_data = SQLUserData.objects.get(domain=self.domain, user_id=user.user_id) + self.assertEqual(sql_data.data['favorite_color'], 'purple') + + def test_migrate_web_user(self): + user = self.make_web_user() + # one user data dictionary, gets copied to all domains + user['user_data'] = {'favorite_color': 'purple'} + user.add_domain_membership('domain2', timezone='UTC') + user.save() + populate_user_data(user) + for domain in [self.domain, 'domain2']: + sql_data = SQLUserData.objects.get(domain=domain, user_id=user.user_id) + self.assertEqual(sql_data.data['favorite_color'], 'purple') + + def test_migrate_user_no_data(self): + user = self.make_commcare_user() + populate_user_data(user) + sql_data = SQLUserData.objects.get(domain=self.domain, user_id=user.user_id) + self.assertEqual(sql_data.data, {}) + + +def _get_profile(self, profile_id): + if profile_id == 'blues': + return CustomDataFieldsProfile( + id=profile_id, + name='blues', + fields={'favorite_color': 'blue'}, + ) + if profile_id == 'others': + return CustomDataFieldsProfile( + id=profile_id, + name='others', + fields={}, + ) + raise CustomDataFieldsProfile.DoesNotExist() + + +@patch('corehq.apps.users.user_data.UserData._get_profile', new=_get_profile) +class TestUserDataModel(SimpleTestCase): + domain = 'test-user-data-model' + + def init_user_data(self, raw_user_data=None, profile_id=None): + return UserData( + raw_user_data=raw_user_data or {}, + couch_user=None, # This is only used for saving to the db + domain=self.domain, + profile_id=profile_id, + ) + + def test_add_and_remove_profile(self): + # Custom user data profiles get their data added to metadata automatically for mobile users + user_data = self.init_user_data({'yearbook_quote': 'Not all who wander are lost.'}) + self.assertEqual(user_data.to_dict(), { + 'commcare_project': self.domain, + 'commcare_profile': '', + 'yearbook_quote': 'Not all who wander are lost.', + }) + + user_data.profile_id = 'blues' + self.assertEqual(user_data.to_dict(), { + 'commcare_project': self.domain, + 'commcare_profile': 'blues', + 'favorite_color': 'blue', # provided by the profile + 'yearbook_quote': 'Not all who wander are lost.', + }) + + # Remove profile should remove it and related fields + user_data.profile_id = None + self.assertEqual(user_data.to_dict(), { + 'commcare_project': self.domain, + 'commcare_profile': '', + 'yearbook_quote': 'Not all who wander are lost.', + }) + + def test_profile_conflicts_with_data(self): + user_data = self.init_user_data({'favorite_color': 'purple'}) + with self.assertRaisesMessage(UserDataError, "Profile conflicts with existing data"): + user_data.profile_id = 'blues' + + def test_profile_conflicts_with_blank_existing_data(self): + user_data = self.init_user_data({'favorite_color': ''}) + user_data.profile_id = 'blues' + self.assertEqual(user_data['favorite_color'], 'blue') + + def test_avoid_conflict_by_blanking_out(self): + user_data = self.init_user_data({'favorite_color': 'purple'}) + user_data.update({ + 'favorite_color': '', + }, profile_id='blues') + self.assertEqual(user_data['favorite_color'], 'blue') + + def test_data_conflicts_with_profile(self): + user_data = self.init_user_data({}, profile_id='blues') + with self.assertRaisesMessage(UserDataError, "'favorite_color' cannot be set directly"): + user_data['favorite_color'] = 'purple' + + def test_profile_and_data_conflict(self): + user_data = self.init_user_data({}) + with self.assertRaisesMessage(UserDataError, "'favorite_color' cannot be set directly"): + user_data.update({ + 'favorite_color': 'purple', + }, profile_id='blues') + + def test_update_shows_changed(self): + user_data = self.init_user_data({}) + changed = user_data.update({'favorite_color': 'purple'}) + self.assertTrue(changed) + changed = user_data.update({'favorite_color': 'purple'}) + self.assertFalse(changed) + + def test_update_order_irrelevant(self): + user_data = self.init_user_data({}, profile_id='blues') + user_data.update({ + 'favorite_color': 'purple', # this is compatible with the new profile, but not the old + }, profile_id='others') + + def test_ignore_noop_conflicts_with_profile(self): + user_data = self.init_user_data({}, profile_id='blues') + # this key is in the profile, but the values are the same + user_data['favorite_color'] = 'blue' + + def test_remove_profile(self): + user_data = self.init_user_data({}, profile_id='blues') + user_data.profile_id = None + self.assertEqual(user_data.profile_id, None) + self.assertEqual(user_data.profile, None) + + def test_remove_profile_and_clear(self): + user_data = self.init_user_data({}, profile_id='blues') + user_data.update({ + 'favorite_color': '', + }, profile_id=None) + + def test_delitem(self): + user_data = self.init_user_data({'yearbook_quote': 'something random'}) + del user_data['yearbook_quote'] + self.assertNotIn('yearbook_quote', user_data.to_dict()) + + def test_popitem(self): + user_data = self.init_user_data({'yearbook_quote': 'something random'}) + res = user_data.pop('yearbook_quote') + self.assertEqual(res, 'something random') + self.assertNotIn('yearbook_quote', user_data.to_dict()) + + self.assertEqual(user_data.pop('yearbook_quote', 'MISSING'), 'MISSING') + with self.assertRaises(KeyError): + user_data.pop('yearbook_quote') + + def test_remove_unrecognized(self): + user_data = self.init_user_data({ + 'in_schema': 'true', + 'not_in_schema': 'true', + 'commcare_location_id': '123', + }) + changed = user_data.remove_unrecognized({'in_schema', 'in_schema_not_doc'}) + self.assertTrue(changed) + self.assertEqual(user_data.raw, {'in_schema': 'true', 'commcare_location_id': '123'}) + + def test_remove_unrecognized_empty_field(self): + user_data = self.init_user_data({}) + changed = user_data.remove_unrecognized(set()) + self.assertFalse(changed) + self.assertEqual(user_data.raw, {}) + changed = user_data.remove_unrecognized({'a', 'b'}) + self.assertFalse(changed) + self.assertEqual(user_data.raw, {}) diff --git a/corehq/apps/users/tests/test_user_invitation.py b/corehq/apps/users/tests/test_user_invitation.py new file mode 100644 index 0000000000000..b0e65ef3db9ad --- /dev/null +++ b/corehq/apps/users/tests/test_user_invitation.py @@ -0,0 +1,98 @@ +import datetime + +from unittest.mock import Mock + +from django.test import TestCase + +from corehq.apps.users.models import Invitation +from corehq.apps.users.views.web import UserInvitationView, WebUserInvitationForm + +from django import forms + + +class StubbedWebUserInvitationForm(WebUserInvitationForm): + + def __init__(self, *args, **kwargs): + self.request_email = kwargs.pop('request_email', False) + super().__init__(*args, **kwargs) + + @property + def cleaned_data(self): + return {"email": self.request_email} + + +class TestUserInvitation(TestCase): + + def test_redirect_if_invite_does_not_exist(self): + request = Mock() + non_existing_uuid = "e1bd37f5-9ff8-4853-b953-fd75483a0ec7" + domain = "domain" + + response = UserInvitationView()(request, non_existing_uuid, domain=domain) + self.assertEqual(302, response.status_code) + self.assertEqual("/accounts/login/", response.url) + + def test_redirect_if_invite_is_already_accepted(self): + request = Mock() + invite_uuid = "e1bd37f5-9ff8-4853-b953-fd75483a0ec7" + domain = "domain" + + Invitation.objects.create( + uuid=invite_uuid, + domain=domain, + is_accepted=True, + invited_on=datetime.date(2023, 9, 1) + ) + + response = UserInvitationView()(request, invite_uuid, domain=domain) + self.assertEqual(302, response.status_code) + self.assertEqual("/accounts/login/", response.url) + + def test_redirect_if_invite_email_does_not_match(self): + form = StubbedWebUserInvitationForm( + { + "email": "other_test@dimagi.com", + "full_name": "asdf", + "password": "pass", + }, + is_sso=False, + allow_invite_email_only=True, + invite_email="test@dimagi.com", + request_email="other_test@dimagi.com", + ) + + with self.assertRaises(forms.ValidationError) as ve: + form.clean_email() + + self.assertEqual( + str(ve.exception), + "['You can only sign up with the email address your invitation was sent to.']") + + form = WebUserInvitationForm( + { + "email": "other_test@dimagi.com", + "full_name": "asdf", + "password": "pass12342&*LKJ", + "eula_confirmed": True + }, + is_sso=False, + allow_invite_email_only=False, + invite_email="test@dimagi.com", + ) + + print(form.errors) + self.assertTrue(form.is_valid()) + + form = WebUserInvitationForm( + { + "email": "test@dimagi.com", + "full_name": "asdf", + "password": "pass12342&*LKJ", + "eula_confirmed": True + }, + is_sso=False, + allow_invite_email_only=True, + invite_email="test@dimagi.com", + ) + + self.assertTrue(form.is_valid()) diff --git a/corehq/apps/users/tests/test_user_model.py b/corehq/apps/users/tests/test_user_model.py index bec50a536521e..1ae3957cc4bb5 100644 --- a/corehq/apps/users/tests/test_user_model.py +++ b/corehq/apps/users/tests/test_user_model.py @@ -2,17 +2,14 @@ from django.test import SimpleTestCase, TestCase -from corehq.apps.custom_data_fields.models import ( - CustomDataFieldsDefinition, - CustomDataFieldsProfile, - Field, - PROFILE_SLUG, +from corehq.apps.domain.shortcuts import create_domain +from corehq.apps.users.models import ( + CommCareUser, + CouchUser, + DeviceAppMeta, + WebUser, ) from corehq.apps.users.models_role import UserRole -from corehq.apps.users.role_utils import UserRolePresets -from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView -from corehq.apps.domain.shortcuts import create_domain -from corehq.apps.users.models import CommCareUser, DeviceAppMeta, WebUser, CouchUser from corehq.form_processor.tests.utils import FormProcessorTestUtils from corehq.form_processor.utils import ( TestFormMetadata, @@ -35,11 +32,11 @@ def setUp(self): created_via=None, ) - self.metadata = TestFormMetadata( + metadata = TestFormMetadata( domain=self.user.domain, user_id=self.user._id, ) - get_simple_wrapped_form('123', metadata=self.metadata) + get_simple_wrapped_form('123', metadata=metadata) def tearDown(self): CommCareUser.get_db().delete_doc(self.user._id) @@ -80,100 +77,6 @@ def test_last_modified_bulk(self): user = CommCareUser.get(self.user._id) self.assertGreater(user.last_modified, lm) - def test_user_data_not_allowed_in_create(self): - message = "Do not access user_data directly, pass metadata argument to create." - with self.assertRaisesMessage(ValueError, message): - CommCareUser.create(self.domain, 'martha', 'bmfa', None, None, user_data={'country': 'Canada'}) - - def test_metadata(self): - metadata = self.user.metadata - self.assertEqual(metadata, {'commcare_project': 'my-domain'}) - metadata.update({ - 'cruise': 'control', - 'this': 'road', - }) - self.user.update_metadata(metadata) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'my-domain', - 'cruise': 'control', - 'this': 'road', - }) - self.user.pop_metadata('cruise') - self.assertEqual(self.user.metadata, { - 'commcare_project': 'my-domain', - 'this': 'road', - }) - self.user.update_metadata({'this': 'field'}) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'my-domain', - 'this': 'field', - }) - - def test_metadata_with_profile(self): - definition = CustomDataFieldsDefinition(domain='my-domain', field_type=UserFieldsView.field_type) - definition.save() - definition.set_fields([Field(slug='start')]) - definition.save() - profile = CustomDataFieldsProfile( - name='low', - fields={'start': 'sometimes'}, - definition=definition, - ) - profile.save() - conflict_message = "metadata properties conflict with profile: start" - - # Custom user data profiles get their data added to metadata automatically for mobile users - self.user.update_metadata({PROFILE_SLUG: profile.id}) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'my-domain', - PROFILE_SLUG: profile.id, - 'start': 'sometimes', - }) - - # Remove profile should remove it and related fields - self.user.pop_metadata(PROFILE_SLUG) - self.assertEqual(self.user.metadata, { - 'commcare_project': 'my-domain', - }) - - # Can't add profile that conflicts with existing data - self.user.update_metadata({ - 'start': 'never', - 'end': 'yesterday', - }) - with self.assertRaisesMessage(ValueError, conflict_message): - self.user.update_metadata({ - PROFILE_SLUG: profile.id, - }) - - # Can't add data that conflicts with existing profile - self.user.pop_metadata('start') - self.user.update_metadata({PROFILE_SLUG: profile.id}) - with self.assertRaisesMessage(ValueError, conflict_message): - self.user.update_metadata({'start': 'never'}) - - # Can't add both a profile and conflicting data - self.user.pop_metadata(PROFILE_SLUG) - with self.assertRaisesMessage(ValueError, conflict_message): - self.user.update_metadata({ - PROFILE_SLUG: profile.id, - 'start': 'never', - }) - - # Custom user data profiles don't get populated for web users - web_user = WebUser.create(None, "imogen", "*****", None, None) - self.assertEqual(web_user.metadata, { - 'commcare_project': None, - }) - web_user.update_metadata({PROFILE_SLUG: profile.id}) - self.assertEqual(web_user.metadata, { - 'commcare_project': None, - PROFILE_SLUG: profile.id, - }) - - definition.delete() - web_user.delete(self.domain, deleted_by=None) - def test_commcare_user_lockout_limits(self): commcare_user = self.create_user('test_user', is_web_user=False) diff --git a/corehq/apps/users/tests/util.py b/corehq/apps/users/tests/util.py index 8e1467a8b6e1f..ea98243801195 100644 --- a/corehq/apps/users/tests/util.py +++ b/corehq/apps/users/tests/util.py @@ -1,5 +1,7 @@ -from casexml.apps.case.tests.util import create_case +from unittest.mock import patch + from corehq.apps.app_manager.const import USERCASE_TYPE +from corehq.apps.users.user_data import UserData def create_usercase(user): @@ -7,6 +9,8 @@ def create_usercase(user): Returns a context manager that yields the user case and deletes it on exit. """ + + from casexml.apps.case.tests.util import create_case return create_case( user.domain, USERCASE_TYPE, @@ -14,3 +18,7 @@ def create_usercase(user): external_id=user.get_id, update={'hq_user_id': user.get_id}, ) + + +patch_user_data_db_layer = patch('corehq.apps.users.user_data.UserData.lazy_init', + new=lambda u, d: UserData({}, None, d)) diff --git a/corehq/apps/users/user_data.py b/corehq/apps/users/user_data.py new file mode 100644 index 0000000000000..6a5dbe26d64c3 --- /dev/null +++ b/corehq/apps/users/user_data.py @@ -0,0 +1,190 @@ +from django.contrib.auth.models import User +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext as _ + +from corehq.apps.custom_data_fields.models import ( + COMMCARE_PROJECT, + PROFILE_SLUG, + CustomDataFieldsProfile, + is_system_key, +) + + +class UserDataError(Exception): + ... + + +class UserData: + def __init__(self, raw_user_data, couch_user, domain, profile_id=None): + self._local_to_user = raw_user_data + self._couch_user = couch_user + self.domain = domain + self._profile_id = profile_id or raw_user_data.get(PROFILE_SLUG, None) + + @classmethod + def lazy_init(cls, couch_user, domain): + # To be used during initial rollout - lazily create user_data objs from + # existing couch data + raw_user_data = couch_user.to_json().get('user_data', {}).copy() + raw_user_data.pop(COMMCARE_PROJECT, None) + profile_id = raw_user_data.pop(PROFILE_SLUG, None) + sql_data, _ = SQLUserData.objects.get_or_create( + user_id=couch_user.user_id, + domain=domain, + defaults={ + 'data': raw_user_data, + 'django_user': couch_user.get_django_user, + 'profile_id': profile_id, + } + ) + return cls(sql_data.data, couch_user, domain, profile_id=sql_data.profile_id) + + def save(self): + SQLUserData.objects.update_or_create( + user_id=self._couch_user.user_id, + domain=self.domain, + defaults={ + 'data': self._local_to_user, + 'django_user': self._couch_user.get_django_user, + 'profile_id': self.profile_id, + }, + ) + + @property + def _provided_by_system(self): + return { + **(self.profile.fields if self.profile else {}), + PROFILE_SLUG: self.profile_id or '', + COMMCARE_PROJECT: self.domain, + } + + def to_dict(self): + return { + **self._local_to_user, + **self._provided_by_system, + } + + @property + def raw(self): + """Data stored on the user object - used for auditing""" + return self._local_to_user.copy() + + def __repr__(self): + return f"UserData({self.to_dict()})" + + @property + def profile_id(self): + return self._profile_id + + @profile_id.setter + def profile_id(self, profile_id): + try: + del self.profile + except AttributeError: + pass + if profile_id: + new_profile = self._get_profile(profile_id) + non_empty_existing_fields = {k for k, v in self._local_to_user.items() if v} + if set(new_profile.fields).intersection(non_empty_existing_fields): + raise UserDataError(_("Profile conflicts with existing data")) + self._profile_id = profile_id + + @cached_property + def profile(self): + if self.profile_id: + return self._get_profile(self.profile_id) + + def _get_profile(self, profile_id): + from corehq.apps.users.views.mobile.custom_data_fields import ( + CUSTOM_USER_DATA_FIELD_TYPE, + ) + try: + return CustomDataFieldsProfile.objects.get( + id=profile_id, + definition__domain=self.domain, + definition__field_type=CUSTOM_USER_DATA_FIELD_TYPE, + ) + except CustomDataFieldsProfile.DoesNotExist as e: + raise UserDataError(_("User data profile not found")) from e + + def remove_unrecognized(self, schema_fields): + changed = False + for k in list(self._local_to_user): + if k not in schema_fields and not is_system_key(k): + del self[k] + changed = True + return changed + + def items(self): + return self.to_dict().items() + + def __iter__(self): + return iter(self.to_dict()) + + def __getitem__(self, key): + return self.to_dict()[key] + + def __contains__(self, item): + return item in self.to_dict() + + def get(self, key, default=None): + return self.to_dict().get(key, default) + + def __setitem__(self, key, value): + if key in self._provided_by_system: + if value == self._provided_by_system[key]: + return + raise UserDataError(_("'{}' cannot be set directly").format(key)) + self._local_to_user[key] = value + + def update(self, data, profile_id=...): + # There are order-specific conflicts that can arise when setting values + # individually - this method is a monolithic action whose final state + # should be consistent + original = self.to_dict() + original_profile = self.profile_id + for k in data: + self._local_to_user.pop(k, None) + if PROFILE_SLUG in data: + self.profile_id = data[PROFILE_SLUG] + if profile_id != ...: + self.profile_id = profile_id + for k, v in data.items(): + if k != PROFILE_SLUG: + if v or k not in self._provided_by_system: + self[k] = v + return original != self.to_dict() or original_profile != self.profile_id + + def __delitem__(self, key): + if key in self._provided_by_system: + raise UserDataError(_("{} cannot be deleted").format(key)) + del self._local_to_user[key] + + def pop(self, key, default=...): + try: + ret = self._local_to_user[key] + except KeyError as e: + if key in self._provided_by_system: + raise UserDataError(_("{} cannot be deleted").format(key)) from e + if default != ...: + return default + raise e + else: + del self._local_to_user[key] + return ret + + +class SQLUserData(models.Model): + domain = models.CharField(max_length=128) + user_id = models.CharField(max_length=36) + django_user = models.ForeignKey(User, on_delete=models.CASCADE) + modified_on = models.DateTimeField(auto_now=True) + + profile = models.ForeignKey("custom_data_fields.CustomDataFieldsProfile", + on_delete=models.PROTECT, null=True) + data = models.JSONField() + + class Meta: + unique_together = ("user_id", "domain") + indexes = [models.Index(fields=['user_id', 'domain'])] diff --git a/corehq/apps/users/views/__init__.py b/corehq/apps/users/views/__init__.py index 4d604ba5d8d77..670296d8a7f48 100644 --- a/corehq/apps/users/views/__init__.py +++ b/corehq/apps/users/views/__init__.py @@ -52,7 +52,6 @@ ) from corehq.apps.app_manager.dbaccessors import get_app_languages from corehq.apps.cloudcare.esaccessors import login_as_user_filter -from corehq.apps.custom_data_fields.models import PROFILE_SLUG from corehq.apps.domain.decorators import ( domain_admin_required, login_and_domain_required, @@ -352,7 +351,6 @@ def tableau_form(self): 'exception_type': type(e), }) - def post(self, request, *args, **kwargs): saved = False if self.request.POST['form_type'] == "commtrack": @@ -551,7 +549,6 @@ class ListWebUsersView(BaseRoleAccessView): page_title = gettext_lazy("Web Users") urlname = 'web_users' - @property @memoized def role_labels(self): @@ -791,7 +788,7 @@ def paginate_enterprise_users(request, domain): 'inactiveMobileCount': len(mobile_users[web_user.username]) - loginAsUserCount, }) for mobile_user in sorted(mobile_users[web_user.username], key=lambda x: x.username): - profile = mobile_user.get_user_data_profile(mobile_user.metadata.get(PROFILE_SLUG)) + profile = mobile_user.get_user_data(domain).profile users.append({ **_format_enterprise_user(mobile_user.domain, mobile_user), 'profile': profile.name if profile else None, diff --git a/corehq/apps/users/views/mobile/users.py b/corehq/apps/users/views/mobile/users.py index ac1824a113c48..9982ee2e9bb23 100644 --- a/corehq/apps/users/views/mobile/users.py +++ b/corehq/apps/users/views/mobile/users.py @@ -212,6 +212,7 @@ def main_context(self): 'custom_fields_slugs': [f.slug for f in self.form_user_update.custom_data.fields], 'custom_fields_profiles': sorted(profiles, key=lambda x: x['name'].lower()), 'custom_fields_profile_slug': PROFILE_SLUG, + 'user_data': self.editable_user.get_user_data(self.domain).to_dict(), 'edit_user_form_title': self.edit_user_form_title, 'strong_mobile_passwords': self.request.project.strong_mobile_passwords, 'has_any_sync_logs': self.has_any_sync_logs, @@ -838,7 +839,7 @@ def _build_commcare_user(self): device_id="Generated from HQ", first_name=first_name, last_name=last_name, - metadata=self.custom_data.get_data_to_save(), + user_data=self.custom_data.get_data_to_save(), is_account_confirmed=is_account_confirmed, location=SQLLocation.objects.get(domain=self.domain, location_id=location_id) if location_id else None, role_id=role_id @@ -1066,7 +1067,7 @@ def post(self, request, *args, **kwargs): created_via=USER_CHANGE_VIA_WEB, phone_number=phone_number, device_id="Generated from HQ", - metadata=self.custom_data.get_data_to_save(), + user_data=self.custom_data.get_data_to_save(), ) if 'location_id' in request.GET: @@ -1406,6 +1407,8 @@ def _clear_users_data(self, request, user_docs_by_id): subject=f"Mobile Worker Clearing Complete - {self.domain}", message=f"The mobile workers have been cleared successfully for the project '{self.domain}'.", recipient_list=[self.request.couch_user.get_email()], + domain=self.domain, + use_domain_gateway=True, ) diff --git a/corehq/apps/users/views/web.py b/corehq/apps/users/views/web.py index d0ba901c49549..551052800f1a8 100644 --- a/corehq/apps/users/views/web.py +++ b/corehq/apps/users/views/web.py @@ -66,10 +66,14 @@ def __call__(self, request, uuid, **kwargs): "request a project administrator to send you the invitation again.")) return HttpResponseRedirect(reverse("login")) + is_invited_user = (request.user.is_authenticated + and request.couch_user.username.lower() == invitation.email.lower()) + if invitation.is_accepted: - messages.error(request, _("Sorry, that invitation has already been used up. " - "If you feel this is a mistake please ask the inviter for " - "another invitation.")) + if request.user.is_authenticated and not is_invited_user: + messages.error(request, _("Sorry, that invitation has already been used up. " + "If you feel this is a mistake, please ask the inviter for " + "another invitation.")) return HttpResponseRedirect(reverse("login")) self.validate_invitation(invitation) @@ -98,7 +102,6 @@ def __call__(self, request, uuid, **kwargs): else: context['current_page'] = {'page_name': _('Project Invitation, Account Required')} if request.user.is_authenticated: - is_invited_user = request.couch_user.username.lower() == invitation.email.lower() if self.is_invited(invitation, request.couch_user) and not request.couch_user.is_superuser: if is_invited_user: # if this invite was actually for this user, just mark it accepted @@ -144,12 +147,19 @@ def __call__(self, request, uuid, **kwargs): }) return render(request, self.template, context) else: + domain_obj = Domain.get_by_name(invitation.domain) + allow_invite_email_only = domain_obj and domain_obj.allow_invite_email_only + idp = None if settings.ENFORCE_SSO_LOGIN: idp = IdentityProvider.get_required_identity_provider(invitation.email) if request.method == "POST": - form = WebUserInvitationForm(request.POST, is_sso=idp is not None) + form = WebUserInvitationForm( + request.POST, + is_sso=idp is not None, + allow_invite_email_only=allow_invite_email_only, + invite_email=invitation.email) if form.is_valid(): # create the new user invited_by_user = CouchUser.get_by_user_id(invitation.invited_by) @@ -158,6 +168,12 @@ def __call__(self, request, uuid, **kwargs): signup_request = AsyncSignupRequest.create_from_invitation(invitation) return HttpResponseRedirect(idp.get_login_url(signup_request.username)) + if allow_invite_email_only and \ + request.POST.get("email").lower() != invitation.email.lower(): + messages.error(request, _("You can only sign up with the email " + "address your invitation was sent to.")) + return HttpResponseRedirect(reverse("login")) + user = activate_new_user_via_reg_form( form, created_by=invited_by_user, @@ -199,6 +215,7 @@ def __call__(self, request, uuid, **kwargs): 'email': invitation.email, }, is_sso=idp is not None, + allow_invite_email_only=allow_invite_email_only ) context.update({ diff --git a/corehq/ex-submodules/casexml/apps/phone/tests/dummy.py b/corehq/ex-submodules/casexml/apps/phone/tests/dummy.py index c4e56753db674..c028e524f2dcf 100644 --- a/corehq/ex-submodules/casexml/apps/phone/tests/dummy.py +++ b/corehq/ex-submodules/casexml/apps/phone/tests/dummy.py @@ -26,6 +26,7 @@ def dummy_user_xml(user=None): <data key="commcare_first_name"/> <data key="commcare_last_name"/> <data key="commcare_phone_number"/> + <data key="commcare_profile"/> <data key="commcare_project">{}</data> <data key="commcare_user_type">{}</data> <data key="something">arbitrary</data> diff --git a/corehq/ex-submodules/casexml/apps/phone/tests/test_restore_user.py b/corehq/ex-submodules/casexml/apps/phone/tests/test_restore_user.py index 992e437eff9d8..45362bb8c7a5c 100644 --- a/corehq/ex-submodules/casexml/apps/phone/tests/test_restore_user.py +++ b/corehq/ex-submodules/casexml/apps/phone/tests/test_restore_user.py @@ -1,37 +1,30 @@ -from django.test import TestCase +from unittest.mock import Mock, patch from nose.tools import assert_equal -from corehq.apps.domain.models import Domain -from corehq.apps.users.dbaccessors import delete_all_users from corehq.apps.users.models import CommCareUser, DomainMembership, WebUser +from corehq.apps.users.tests.util import patch_user_data_db_layer DOMAIN = 'fixture-test' -class OtaRestoreUserTest(TestCase): +def _get_domain(name): + domain = Mock() + domain.name = name + domain.commtrack_enabled = True + return domain - @classmethod - def setUpClass(cls): - super(OtaRestoreUserTest, cls).setUpClass() - cls.domain = Domain.get_or_create_with_name(DOMAIN, is_active=True) - cls.domain.commtrack_enabled = True - cls.domain.save() - cls.user = CommCareUser(domain=DOMAIN, - domain_membership=DomainMembership(domain=DOMAIN, location_id='1', - assigned_location_ids=['1'])) - cls.restore_user = cls.user.to_ota_restore_user(DOMAIN) - @classmethod - def tearDownClass(cls): - delete_all_users() - cls.domain.delete() - super(OtaRestoreUserTest, cls).tearDownClass() - - def test_get_commtrack_location_id(self): - self.assertEqual(self.restore_user.get_commtrack_location_id(), '1') +@patch('casexml.apps.phone.models.Domain.get_by_name', _get_domain) +def test_get_commtrack_location_id(): + user = CommCareUser(domain=DOMAIN, domain_membership=DomainMembership( + domain=DOMAIN, location_id='1', assigned_location_ids=['1'] + )) + loc_id = user.to_ota_restore_user(DOMAIN).get_commtrack_location_id() + assert_equal(loc_id, '1') +@patch_user_data_db_layer def test_user_types(): for user, expected_type in [ (WebUser(), 'web'), diff --git a/corehq/ex-submodules/casexml/apps/phone/tests/utils.py b/corehq/ex-submodules/casexml/apps/phone/tests/utils.py index 659cc720a93e9..6384f280a632b 100644 --- a/corehq/ex-submodules/casexml/apps/phone/tests/utils.py +++ b/corehq/ex-submodules/casexml/apps/phone/tests/utils.py @@ -32,9 +32,7 @@ def create_restore_user( created_by=None, created_via=None, first_name=first_name, - metadata={ - 'something': 'arbitrary' - } + user_data={'something': 'arbitrary'}, ) ) if phone_number: @@ -66,7 +64,6 @@ def deprecated_generate_restore_payload(project, user, restore_id="", version=V1 ).get_payload().as_string() - def call_fixture_generator(gen, restore_user, project=None, last_sync=None, app=None, device_id=''): """ Convenience function for use in unit tests diff --git a/corehq/ex-submodules/dimagi/utils/django/email.py b/corehq/ex-submodules/dimagi/utils/django/email.py index 9901024ec9f18..dfe2c453fea5b 100644 --- a/corehq/ex-submodules/dimagi/utils/django/email.py +++ b/corehq/ex-submodules/dimagi/utils/django/email.py @@ -84,7 +84,7 @@ def send_HTML_email(subject, recipient, html_content, text_content=None, cc=None, email_from=settings.DEFAULT_FROM_EMAIL, file_attachments=None, bcc=None, smtp_exception_skip_list=None, messaging_event_id=None, - domain=None): + domain=None, use_domain_gateway=False): recipients = list(recipient) if not isinstance(recipient, str) else [recipient] filtered_recipients = get_valid_recipients(recipients, domain) bounced_addresses = list(set(recipients) - set(filtered_recipients)) @@ -104,21 +104,20 @@ def send_HTML_email(subject, recipient, html_content, text_content=None, NO_HTML_EMAIL_MESSAGE) elif not isinstance(text_content, str): text_content = text_content.decode('utf-8') + configuration = get_email_configuration(domain, use_domain_gateway, email_from) + headers = {'From': configuration.from_email} # From-header - headers = {'From': email_from} # From-header - - if settings.RETURN_PATH_EMAIL: - headers['Return-Path'] = settings.RETURN_PATH_EMAIL + if configuration.return_path_email: + headers['Return-Path'] = configuration.return_path_email if messaging_event_id is not None: headers[COMMCARE_MESSAGE_ID_HEADER] = messaging_event_id - if settings.SES_CONFIGURATION_SET is not None: - headers[SES_CONFIGURATION_SET_HEADER] = settings.SES_CONFIGURATION_SET + if configuration.SES_configuration_set is not None: + headers[SES_CONFIGURATION_SET_HEADER] = configuration.SES_configuration_set - connection = django_get_connection() - msg = EmailMultiAlternatives(subject, text_content, email_from, + msg = EmailMultiAlternatives(subject, text_content, configuration.from_email, filtered_recipients, headers=headers, - connection=connection, cc=cc, bcc=bcc) + connection=configuration.connection, cc=cc, bcc=bcc) for file in (file_attachments or []): if file: msg.attach(file["title"], file["file_obj"].getvalue(), @@ -161,10 +160,10 @@ def send_HTML_email(subject, recipient, html_content, text_content=None, error_msg = EmailMultiAlternatives( error_subject, error_text, - email_from, + configuration.from_email, filtered_recipients, headers=headers, - connection=connection, + connection=configuration.connection, cc=cc, bcc=bcc, ) @@ -200,6 +199,11 @@ def connection(self): def SES_configuration_set(self): pass + @property + @abstractmethod + def return_path_email(self): + pass + class DefaultEmailConfiguration(EmailConfigurationManager): def __init__(self, from_email: str): @@ -217,6 +221,10 @@ def connection(self): def SES_configuration_set(self): return settings.SES_CONFIGURATION_SET + @property + def return_path_email(self): + return settings.RETURN_PATH_EMAIL + class CustomEmailConfiguration(EmailConfigurationManager): def __init__(self, email_setting): @@ -242,3 +250,7 @@ def connection(self): @property def SES_configuration_set(self): return self._email_setting.ses_config_set_name if self._email_setting.use_tracking_headers else None + + @property + def return_path_email(self): + return self._email_setting.return_path_email diff --git a/corehq/ex-submodules/dimagi/utils/django/profiling_middleware.py b/corehq/ex-submodules/dimagi/utils/django/profiling_middleware.py index dd3c4c18ccd7a..3ceb70c2722bf 100644 --- a/corehq/ex-submodules/dimagi/utils/django/profiling_middleware.py +++ b/corehq/ex-submodules/dimagi/utils/django/profiling_middleware.py @@ -36,7 +36,8 @@ class ProfileMiddleware(MiddlewareMixin): """ def process_request(self, request): if (settings.DEBUG or request.user.is_superuser) and 'prof' in request.GET: - self.tmpfile = tempfile.mktemp() + fd, self.tmpfile = tempfile.mkstemp() + os.close(fd) self.prof = hotshot.Profile(self.tmpfile) def process_view(self, request, callback, callback_args, callback_kwargs): diff --git a/corehq/ex-submodules/soil/util.py b/corehq/ex-submodules/soil/util.py index 88ca85b3998e2..e4ce3dba6f347 100644 --- a/corehq/ex-submodules/soil/util.py +++ b/corehq/ex-submodules/soil/util.py @@ -131,7 +131,8 @@ def process_email_request(domain, download_id, email_address): '{}').format(dropbox_url) email_body = _('Your CommCare export for {} is ready! Click on the link below to download your requested data:' '<br/>{}{}').format(domain, download_url, dropbox_message) - send_HTML_email(_('CommCare Export Complete'), email_address, email_body) + send_HTML_email(_('CommCare Export Complete'), email_address, email_body, + domain=domain, use_domain_gateway=True,) def get_task(task_id): @@ -143,7 +144,8 @@ def get_download_file_path(use_transfer, filename): if use_transfer: fpath = os.path.join(settings.SHARED_DRIVE_CONF.transfer_dir, filename) else: - _, fpath = tempfile.mkstemp() + fd, fpath = tempfile.mkstemp() + os.close(fd) return fpath diff --git a/corehq/messaging/scheduling/models/content.py b/corehq/messaging/scheduling/models/content.py index b2572e35a0fdb..66998f404bf38 100644 --- a/corehq/messaging/scheduling/models/content.py +++ b/corehq/messaging/scheduling/models/content.py @@ -168,8 +168,10 @@ def send(self, recipient, logged_event, phone_entry=None): metrics_counter('commcare.messaging.email.sent', tags={'domain': logged_event.domain}) send_mail_async.delay(subject, message, - [email_address], logged_subevent.id, - domain=logged_event.domain, use_domain_gateway=True) + [email_address], + messaging_event_id=logged_subevent.id, + domain=logged_event.domain, + use_domain_gateway=True) email = Email( domain=logged_event.domain, diff --git a/corehq/messaging/scheduling/scheduling_partitioned/models.py b/corehq/messaging/scheduling/scheduling_partitioned/models.py index f5d5a29d93d1e..4c45f40ff8e8d 100644 --- a/corehq/messaging/scheduling/scheduling_partitioned/models.py +++ b/corehq/messaging/scheduling/scheduling_partitioned/models.py @@ -248,12 +248,13 @@ def passes_user_data_filter(self, contact): if not self.memoized_schedule.user_data_filter: return True + user_data = contact.get_user_data(self.domain) for key, value in self.memoized_schedule.user_data_filter.items(): - if key not in contact.metadata: + if key not in user_data: return False allowed_values_set = self.convert_to_set(value) - actual_values_set = self.convert_to_set(contact.metadata[key]) + actual_values_set = self.convert_to_set(user_data[key]) if actual_values_set.isdisjoint(allowed_values_set): return False diff --git a/corehq/messaging/scheduling/tests/test_recipients.py b/corehq/messaging/scheduling/tests/test_recipients.py index e414203a78512..b9f015e53404c 100644 --- a/corehq/messaging/scheduling/tests/test_recipients.py +++ b/corehq/messaging/scheduling/tests/test_recipients.py @@ -1,18 +1,16 @@ -import contextlib import uuid from datetime import time from django.test import TestCase, override_settings -from unittest.mock import patch - from casexml.apps.case.tests.util import create_case + from corehq.apps.casegroups.models import CommCareCaseGroup from corehq.apps.custom_data_fields.models import ( + PROFILE_SLUG, CustomDataFieldsDefinition, CustomDataFieldsProfile, Field, - PROFILE_SLUG, ) from corehq.apps.domain.shortcuts import create_domain from corehq.apps.groups.models import Group @@ -21,10 +19,10 @@ from corehq.apps.locations.tests.util import make_loc, setup_location_types from corehq.apps.sms.models import PhoneNumber from corehq.apps.users.models import CommCareUser, WebUser -from corehq.form_processor.models import CommCareCase -from corehq.form_processor.utils import is_commcarecase from corehq.apps.users.util import normalize_username from corehq.apps.users.views.mobile.custom_data_fields import UserFieldsView +from corehq.form_processor.models import CommCareCase +from corehq.form_processor.utils import is_commcarecase from corehq.messaging.pillow import get_case_messaging_sync_pillow from corehq.messaging.scheduling.models import ( Content, @@ -35,8 +33,9 @@ from corehq.messaging.scheduling.scheduling_partitioned.models import ( CaseScheduleInstanceMixin, CaseTimedScheduleInstance, - ScheduleInstance as AbstractScheduleInstance, ) +from corehq.messaging.scheduling.scheduling_partitioned.models import \ + ScheduleInstance as AbstractScheduleInstance from corehq.messaging.scheduling.tests.util import delete_timed_schedules from corehq.util.test_utils import ( create_test_case, @@ -66,17 +65,17 @@ def setUpClass(cls): cls.mobile_user2 = CommCareUser.create(cls.domain, 'mobile2', 'abc', None, None) cls.mobile_user2.set_location(cls.state_location) - cls.mobile_user3 = CommCareUser.create(cls.domain, 'mobile3', 'abc', None, None, metadata={ + cls.mobile_user3 = CommCareUser.create(cls.domain, 'mobile3', 'abc', None, None, user_data={ 'role': 'pharmacist', }) cls.mobile_user3.save() - cls.mobile_user4 = CommCareUser.create(cls.domain, 'mobile4', 'abc', None, None, metadata={ + cls.mobile_user4 = CommCareUser.create(cls.domain, 'mobile4', 'abc', None, None, user_data={ 'role': 'nurse', }) cls.mobile_user4.save() - cls.mobile_user5 = CommCareUser.create(cls.domain, 'mobile5', 'abc', None, None, metadata={ + cls.mobile_user5 = CommCareUser.create(cls.domain, 'mobile5', 'abc', None, None, user_data={ 'role': ['nurse', 'pharmacist'], }) cls.mobile_user5.save() @@ -99,14 +98,14 @@ def setUpClass(cls): definition=cls.definition, ) cls.profile.save() - cls.mobile_user6 = CommCareUser.create(cls.domain, 'mobile6', 'abc', None, None, metadata={ + cls.mobile_user6 = CommCareUser.create(cls.domain, 'mobile6', 'abc', None, None, user_data={ PROFILE_SLUG: cls.profile.id, }) cls.mobile_user5.save() cls.web_user = WebUser.create(cls.domain, 'web', 'abc', None, None) - cls.web_user2 = WebUser.create(cls.domain, 'web2', 'abc', None, None, metadata={ + cls.web_user2 = WebUser.create(cls.domain, 'web2', 'abc', None, None, user_data={ 'role': 'nurse', }) cls.web_user2.save() diff --git a/corehq/motech/openmrs/tests/test_tasks.py b/corehq/motech/openmrs/tests/test_tasks.py index a022dc78baec5..66990865c9a41 100644 --- a/corehq/motech/openmrs/tests/test_tasks.py +++ b/corehq/motech/openmrs/tests/test_tasks.py @@ -422,6 +422,7 @@ def test_bad_owner(self): 'address.', recipient_list=['admin@example.com'], + domain=TEST_DOMAIN, use_domain_gateway=True ) def test_bad_group(self): @@ -457,6 +458,7 @@ def test_bad_group(self): 'address.', recipient_list=['admin@example.com'], + domain=TEST_DOMAIN, use_domain_gateway=True ) diff --git a/corehq/motech/repeaters/apps.py b/corehq/motech/repeaters/apps.py index c28d7e8534dcd..b5e0b6837e6fd 100644 --- a/corehq/motech/repeaters/apps.py +++ b/corehq/motech/repeaters/apps.py @@ -9,6 +9,7 @@ class RepeaterAppConfig(AppConfig): name = 'corehq.motech.repeaters' + default_auto_field = 'django.db.models.BigAutoField' def ready(self): from . import signals # noqa: disable=unused-import,F401 diff --git a/corehq/motech/repeaters/migration_utils.py b/corehq/motech/repeaters/migration_utils.py new file mode 100644 index 0000000000000..a9d3b582c887f --- /dev/null +++ b/corehq/motech/repeaters/migration_utils.py @@ -0,0 +1,26 @@ +import logging + +from corehq.motech.repeaters.models import Repeater + +log = logging.getLogger(__name__) + + +def repair_repeaters_with_whitelist_bug(): + """ + Used in 0004_fix_whitelist_bug_repeaters.py + The whitelist bug resulted in the white_listed_form_xmlns key in a + repeater's option field storing '[]' as a form id it would whitelist. + :return: list of repeater ids that were fixed + """ + all_repeaters = Repeater.all_objects.all() + broken_repeaters = [r for r in all_repeaters if r.options.get("white_listed_form_xmlns") == ["[]"]] + + fixed_repeater_ids = [] + for repeater in broken_repeaters: + # reset back to an empty list + repeater.options["white_listed_form_xmlns"] = [] + repeater.save() + fixed_repeater_ids.append(repeater.repeater_id) + + log.info(f"[repair_repeaters] The following repeaters were fixed:\n{fixed_repeater_ids}") + return fixed_repeater_ids diff --git a/corehq/motech/repeaters/migrations/0003_id_fields.py b/corehq/motech/repeaters/migrations/0003_id_fields.py new file mode 100644 index 0000000000000..07bf16bb1458b --- /dev/null +++ b/corehq/motech/repeaters/migrations/0003_id_fields.py @@ -0,0 +1,123 @@ +# Generated by Django 3.2.20 on 2023-08-21 19:39 + +from django.db import migrations, models +from django.db.models.deletion import DO_NOTHING + +import corehq.sql_db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('repeaters', '0002_repeaters_db'), + ] + + operations = [ + migrations.RunSQL( + sql="DROP INDEX IF EXISTS repeaters_repeater_domain_b537389f_like", + reverse_sql=""" + CREATE INDEX IF NOT EXISTS repeaters_repeater_domain_b537389f_like + ON repeaters_repeater (domain varchar_pattern_ops) + """, + state_operations=[migrations.AlterField( + model_name='repeater', + name='domain', + field=corehq.sql_db.fields.CharIdField(db_index=True, max_length=126), + )], + ), + migrations.AlterField( + model_name='sqlrepeatrecord', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='sqlrepeatrecordattempt', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.RunSQL( + # Requires "repeaters_repeatrecord" to be empty + sql=""" + CREATE FUNCTION set_default_repeaters_repeater_id() + RETURNS trigger LANGUAGE plpgsql AS $BODY$ + BEGIN + IF NEW.id_ IS NULL THEN + NEW.id_ = NEW.repeater_id::uuid; + ELSIF NEW.repeater_id IS NULL THEN + NEW.repeater_id = REPLACE(NEW.id_::varchar, '-', ''); + END IF; + RETURN NEW; + END + $BODY$; + SET CONSTRAINTS "repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters" IMMEDIATE; + ALTER TABLE "repeaters_repeatrecord" + DROP CONSTRAINT "repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters", + ADD COLUMN "repeater_id_" uuid NOT NULL, + ALTER COLUMN repeater_id DROP NOT NULL; + ALTER TABLE "repeaters_repeater" ADD COLUMN "id_" uuid; + ALTER TABLE "repeaters_repeater" + ADD CONSTRAINT "repeaters_repeater_id_key" UNIQUE ("id"), + DROP CONSTRAINT "repeaters_repeater_pkey", + ALTER COLUMN "id_" TYPE uuid USING "repeater_id"::uuid, + ADD CONSTRAINT "repeaters_repeater_pkey" PRIMARY KEY ("id_"), + ADD CONSTRAINT id_eq CHECK ("id_" = "repeater_id"::uuid); + CREATE TRIGGER repeaters_repeater_default_id BEFORE INSERT ON repeaters_repeater + FOR EACH ROW EXECUTE FUNCTION set_default_repeaters_repeater_id(); + ALTER TABLE repeaters_repeatrecord + ADD CONSTRAINT repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters + FOREIGN KEY (repeater_id_) REFERENCES repeaters_repeater(id_) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + ALTER TABLE repeaters_repeatrecordattempt + DROP CONSTRAINT repeaters_repeatrecordattempt_repeat_record_id_cc88c323_fk, + ADD CONSTRAINT repeaters_repeatrecordattempt_repeat_record_id_cc88c323_fk + FOREIGN KEY (repeat_record_id) REFERENCES repeaters_repeatrecord(id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + """, + reverse_sql=""" + SET CONSTRAINTS "repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters" IMMEDIATE; + ALTER TABLE "repeaters_repeatrecord" + DROP CONSTRAINT repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters, + DROP COLUMN "repeater_id_", + ALTER COLUMN repeater_id SET NOT NULL; + DROP TRIGGER repeaters_repeater_default_id ON repeaters_repeater; + DROP FUNCTION set_default_repeaters_repeater_id(); + ALTER TABLE "repeaters_repeater" + DROP CONSTRAINT "repeaters_repeater_pkey", + DROP CONSTRAINT id_eq, + DROP COLUMN "id_", + ADD CONSTRAINT "repeaters_repeater_pkey" PRIMARY KEY ("id"), + DROP CONSTRAINT "repeaters_repeater_id_key"; + ALTER TABLE repeaters_repeatrecord + ADD CONSTRAINT repeaters_repeatreco_repeater_id_01b51f9d_fk_repeaters + FOREIGN KEY (repeater_id) REFERENCES repeaters_repeater(id) + DEFERRABLE INITIALLY DEFERRED; + ALTER TABLE repeaters_repeatrecordattempt + DROP CONSTRAINT repeaters_repeatrecordattempt_repeat_record_id_cc88c323_fk, + ADD CONSTRAINT repeaters_repeatrecordattempt_repeat_record_id_cc88c323_fk + FOREIGN KEY (repeat_record_id) REFERENCES repeaters_repeatrecord(id) + DEFERRABLE INITIALLY DEFERRED; + """, + state_operations=[ + migrations.AlterField( + model_name='repeater', + name='id', + field=models.UUIDField(db_column="id_", primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sqlrepeatrecord', + name='repeater', + field=models.ForeignKey( + db_column='repeater_id_', + on_delete=DO_NOTHING, + related_name='repeat_records', + to='repeaters.repeater', + ), + ), + migrations.AlterField( + model_name='sqlrepeatrecordattempt', + name='repeat_record', + field=models.ForeignKey(on_delete=DO_NOTHING, to='repeaters.sqlrepeatrecord'), + ), + ], + ), + ] diff --git a/corehq/motech/repeaters/migrations/0004_fix_whitelist_bug_repeaters.py b/corehq/motech/repeaters/migrations/0004_fix_whitelist_bug_repeaters.py new file mode 100644 index 0000000000000..28ffb57aa5ebb --- /dev/null +++ b/corehq/motech/repeaters/migrations/0004_fix_whitelist_bug_repeaters.py @@ -0,0 +1,20 @@ +from django.db import migrations + +from corehq.motech.repeaters.migration_utils import repair_repeaters_with_whitelist_bug +from corehq.util.django_migrations import skip_on_fresh_install + + +@skip_on_fresh_install +def _fix_broken_repeaters(apps, schema_editor): + repair_repeaters_with_whitelist_bug() + + +class Migration(migrations.Migration): + + dependencies = [ + ('repeaters', '0003_id_fields'), + ] + + operations = [ + migrations.RunPython(_fix_broken_repeaters, reverse_code=migrations.RunPython.noop) + ] diff --git a/corehq/motech/repeaters/models.py b/corehq/motech/repeaters/models.py index 84a3d32bb6d11..ddd6bc5c9223e 100644 --- a/corehq/motech/repeaters/models.py +++ b/corehq/motech/repeaters/models.py @@ -114,6 +114,7 @@ from corehq.motech.repeaters.optionvalue import OptionValue from corehq.motech.requests import simple_request from corehq.privileges import DATA_FORWARDING, ZAPIER_INTEGRATION +from corehq.sql_db.fields import CharIdField from corehq.util.metrics import metrics_counter from corehq.util.models import ForeignObject, foreign_init from corehq.util.urlvalidate.ip_resolver import CannotResolveHost @@ -188,6 +189,8 @@ def save(self, *args, **kwargs): # If repeater_id is not set then set one if not self.repeater_id: self.repeater_id = uuid.uuid4().hex + if self.id is None: + self.id = uuid.UUID(self.repeater_id) self.name = self.name or self.connection_settings.name return super().save(*args, **kwargs) @@ -251,7 +254,8 @@ def by_domain(self, domain): @foreign_init class Repeater(RepeaterSuperProxy): - domain = models.CharField(max_length=126, db_index=True) + id = models.UUIDField(primary_key=True, db_column="id_") + domain = CharIdField(max_length=126, db_index=True) repeater_id = models.CharField(max_length=36, unique=True) name = models.CharField(max_length=255, null=True) format = models.CharField(max_length=64, null=True) @@ -1167,12 +1171,19 @@ def requeue(self): self.next_check = datetime.utcnow() +# on_delete=DB_CASCADE denotes ON DELETE CASCADE in the database. The +# constraints are configured in a migration. Note that Django signals +# will not fire on records deleted via cascade. +DB_CASCADE = models.DO_NOTHING + + class SQLRepeatRecord(models.Model): domain = models.CharField(max_length=126) couch_id = models.CharField(max_length=36, null=True, blank=True) payload_id = models.CharField(max_length=36) repeater = models.ForeignKey(Repeater, - on_delete=models.CASCADE, + on_delete=DB_CASCADE, + db_column="repeater_id_", related_name='repeat_records') state = models.TextField(choices=RECORD_STATES, default=RECORD_PENDING_STATE) @@ -1304,8 +1315,7 @@ def last_message(self): class SQLRepeatRecordAttempt(models.Model): - repeat_record = models.ForeignKey(SQLRepeatRecord, - on_delete=models.CASCADE) + repeat_record = models.ForeignKey(SQLRepeatRecord, on_delete=DB_CASCADE) state = models.TextField(choices=RECORD_STATES) message = models.TextField(blank=True, default='') traceback = models.TextField(blank=True, default='') @@ -1342,7 +1352,7 @@ def attempt_forward_now(repeater: Repeater): return if not repeater.is_ready: return - process_repeater.delay(repeater.id) + process_repeater.delay(repeater.id.hex) def get_payload(repeater: Repeater, repeat_record: SQLRepeatRecord) -> str: diff --git a/corehq/motech/repeaters/tasks.py b/corehq/motech/repeaters/tasks.py index 0e7a8a62ad51c..8a8afe2e7c61b 100644 --- a/corehq/motech/repeaters/tasks.py +++ b/corehq/motech/repeaters/tasks.py @@ -204,7 +204,7 @@ def _process_repeat_record(repeat_record): @task(queue=settings.CELERY_REPEAT_RECORD_QUEUE) -def process_repeater(repeater_id: int): +def process_repeater(repeater_id): """ Worker task to send SQLRepeatRecords in chronological order. diff --git a/corehq/motech/repeaters/tests/test_migration_utils.py b/corehq/motech/repeaters/tests/test_migration_utils.py new file mode 100644 index 0000000000000..3f060af1be53f --- /dev/null +++ b/corehq/motech/repeaters/tests/test_migration_utils.py @@ -0,0 +1,67 @@ +from django.test import TestCase + +from corehq.motech.models import ConnectionSettings +from corehq.motech.repeaters.migration_utils import ( + repair_repeaters_with_whitelist_bug, +) +from corehq.motech.repeaters.models import FormRepeater + + +class TestRepairRepeatersWithWhitelistBug(TestCase): + """ + The whitelist bug resulted in the white_listed_form_xmlns key in a + repeater's option storing '[]' as a form id it would whitelist. + """ + def test_properly_configured_repeater_is_ignored(self): + repeater = FormRepeater.objects.create( + domain='test', + connection_settings=self.connection_settings, + name='properly-configured-repeater', + options={'white_listed_form_xmlns': ['abc123']} + ) + + fixed_ids = repair_repeaters_with_whitelist_bug() + + refetched_repeater = FormRepeater.objects.get(id=repeater.repeater_id) + self.assertEqual(refetched_repeater.options['white_listed_form_xmlns'], ['abc123']) + self.assertEqual(fixed_ids, []) + + def test_impacted_repeater_is_fixed(self): + repeater = FormRepeater.objects.create( + domain='test', + connection_settings=self.connection_settings, + name='repeater-with-bug', + options={'white_listed_form_xmlns': ['[]']} + ) + + fixed_ids = repair_repeaters_with_whitelist_bug() + + refetched_repeater = FormRepeater.objects.get(id=repeater.repeater_id) + self.assertEqual(refetched_repeater.options['white_listed_form_xmlns'], []) + self.assertEqual(fixed_ids, [repeater.repeater_id]) + + def test_impacted_but_deleted_repeater_is_fixed(self): + repeater = FormRepeater.objects.create( + domain='test', + connection_settings=self.connection_settings, + name='deletd-repeater-with-bug', + options={'white_listed_form_xmlns': ['[]']}, + is_deleted=True, + ) + + fixed_ids = repair_repeaters_with_whitelist_bug() + + refetched_repeater = FormRepeater.all_objects.get(id=repeater.repeater_id) + self.assertEqual(refetched_repeater.options['white_listed_form_xmlns'], []) + self.assertEqual(fixed_ids, [repeater.repeater_id]) + + @classmethod + def setUpClass(cls): + super().setUpClass() + conn = ConnectionSettings( + domain='test', + name="repeaters-bug-settings", + url="url", + ) + conn.save() + cls.connection_settings = conn diff --git a/corehq/motech/repeaters/tests/test_repeater.py b/corehq/motech/repeaters/tests/test_repeater.py index b2f8fe66fcd28..8cafdd9779ca6 100644 --- a/corehq/motech/repeaters/tests/test_repeater.py +++ b/corehq/motech/repeaters/tests/test_repeater.py @@ -951,7 +951,7 @@ def test_trigger(self): 'first_name': '', 'last_name': '', 'default_phone_number': None, - 'user_data': {'commcare_project': self.domain}, + 'user_data': {'commcare_project': self.domain, 'commcare_profile': ''}, 'groups': [], 'phone_numbers': [], 'email': '', diff --git a/corehq/motech/requests.py b/corehq/motech/requests.py index 81744c21e9482..a0ef0754ea789 100644 --- a/corehq/motech/requests.py +++ b/corehq/motech/requests.py @@ -213,6 +213,8 @@ def notify_error(self, message, details=None): _('MOTECH Error'), '\r\n'.join(message_lines), recipient_list=self.notify_addresses, + domain=self.domain_name, + use_domain_gateway=True, ) diff --git a/corehq/motech/tests/test_requests.py b/corehq/motech/tests/test_requests.py index c0200e4c7dcac..02fb08b6c3f3d 100644 --- a/corehq/motech/tests/test_requests.py +++ b/corehq/motech/tests/test_requests.py @@ -182,7 +182,8 @@ def test_notify_error_address_list(self): 'connections. If necessary, please provide an alternate ' 'address.' ), - recipient_list=['foo@example.com', 'bar@example.com'] + recipient_list=['foo@example.com', 'bar@example.com'], + domain='test-domain', use_domain_gateway=True ) diff --git a/corehq/pillows/case_search.py b/corehq/pillows/case_search.py index e648525b27359..5f9b7bd20ae78 100644 --- a/corehq/pillows/case_search.py +++ b/corehq/pillows/case_search.py @@ -20,6 +20,7 @@ from corehq.form_processor.backends.sql.dbaccessors import CaseReindexAccessor from corehq.pillows.base import is_couch_change_for_sql_domain from corehq.toggles import ( + GEOSPATIAL, USH_CASE_CLAIM_UPDATES, ) from corehq.util.doc_processor.sql import SqlDocumentProvider @@ -83,7 +84,7 @@ def _get_case_properties(doc_dict): dynamic_properties = [_format_property(key, value, case_id) for key, value in doc_dict['case_json'].items()] - if USH_CASE_CLAIM_UPDATES.enabled(domain): + if USH_CASE_CLAIM_UPDATES.enabled(domain) or GEOSPATIAL.enabled(domain): _add_smart_types(dynamic_properties, domain, doc_dict['type']) return base_case_properties + dynamic_properties @@ -92,9 +93,16 @@ def _get_case_properties(doc_dict): def _add_smart_types(dynamic_properties, domain, case_type): # Properties are stored in a dict like {"key": "dob", "value": "1900-01-01"} # `value` is a multi-field property that duck types numeric and date values - # We can't do that for geo_points in ES v2, as `ignore_malformed` is broken - gps_props = get_gps_properties(domain, case_type) - gps_props.add(get_geo_case_property(domain)) + # We can't do that for properties like geo_points in ES v2, as `ignore_malformed` is broken + if USH_CASE_CLAIM_UPDATES.enabled(domain): + gps_props = get_gps_properties(domain, case_type) + _add_gps_smart_types(dynamic_properties, gps_props) + if GEOSPATIAL.enabled(domain): + gps_props = [get_geo_case_property(domain)] + _add_gps_smart_types(dynamic_properties, gps_props) + + +def _add_gps_smart_types(dynamic_properties, gps_props): for prop in dynamic_properties: if prop['key'] in gps_props: try: diff --git a/corehq/project_limits/rate_counter/rate_counter.py b/corehq/project_limits/rate_counter/rate_counter.py index 8a3ff21613a83..c8540f6f00291 100644 --- a/corehq/project_limits/rate_counter/rate_counter.py +++ b/corehq/project_limits/rate_counter/rate_counter.py @@ -68,6 +68,13 @@ def get(self, scope, timestamp=None): contribution_from_earliest = earliest_grain_count * (1 - progress_in_current_grain) return sum(counts) + contribution_from_earliest + def retry_after(self): + """Calculates the time (in seconds) left in the current grain""" + timestamp = time.time() + progress_in_current_grain = (timestamp % self.grain_duration) / self.grain_duration + progress_left_in_grain = 1 - progress_in_current_grain + return progress_left_in_grain * self.grain_duration + def increment(self, scope, delta=1, timestamp=None): # this intentionally doesn't return because this is the active grain count, # not the total that would be returned by get diff --git a/corehq/project_limits/rate_limiter.py b/corehq/project_limits/rate_limiter.py index d38072309f3e1..aa1708b172d1f 100644 --- a/corehq/project_limits/rate_limiter.py +++ b/corehq/project_limits/rate_limiter.py @@ -39,19 +39,19 @@ def report_usage(self, scope='', delta=1): rate_counter.increment(self.feature_key + limit_scope, delta=delta) def get_window_of_first_exceeded_limit(self, scope=''): - for limit_scope, rates in self.iter_rates(scope): - for rate_counter_key, current_rate, limit in rates: + for _limit_scope, rates in self.iter_rates(scope): + for rate_counter, current_rate, limit in rates: if current_rate >= limit: - return rate_counter_key + return rate_counter.key return None def allow_usage(self, scope=''): allowed = False # allow usage if any scope has capacity - for limit_scope, rates in self.iter_rates(scope): + for _limit_scope, rates in self.iter_rates(scope): allow = all(current_rate < limit - for rate_counter_key, current_rate, limit in rates) + for _rate_counter, current_rate, limit in rates) # for each scope all counters must be below threshold if allow: allowed = True @@ -59,23 +59,38 @@ def allow_usage(self, scope=''): metrics_counter('commcare.rate_limit_exceeded', tags={'key': self.feature_key, 'scope': scope}) return allowed + def get_retry_after(self, scope): + """ + Returns the minimum amount of seconds until additional capacity becomes available again + """ + seconds_per_scope = {} + for scope, rates in self.iter_rates(scope): + seconds_per_scope[scope] = 0.0 + for rate_counter, current_rate, limit in rates: + if current_rate >= limit: + seconds_per_scope[scope] = max(seconds_per_scope[scope], rate_counter.retry_after()) + retry_after_values = seconds_per_scope.values() + return min(retry_after_values) + def iter_rates(self, scope=''): """ - Get generator of tuples for each set of limits returned by get_rate_limits, where the first item - of the tuple is the normalized scope, and the second is a generator of (key, current rate, rate limit) - for each limit in that scope + Get generator of tuples for each set of limits returned by `get_rate_limits`, where the first item + of the tuple is the normalized scope, and the second is a generator of + (rate_counter (obj), current rate, rate limit) for each limit in that scope e.g. ('test-domain', [ - ('week', 92359, 115000) - ('day', ...) + (rate_counter, 92359, 115000) + (rate_counter, ...) ... ]) + where `rate_counter.key` is the window of the counter i.e. 'week', 'day' etc """ + for limit_scope, limits in self.get_rate_limits(scope): yield ( limit_scope, - ((rate_counter.key, rate_counter.get(self.feature_key + limit_scope), limit) + ((rate_counter, rate_counter.get(self.feature_key + limit_scope), limit) for rate_counter, limit in limits) ) @@ -86,8 +101,8 @@ def wait(self, scope, timeout, windows_not_to_wait_on=('hour', 'day', 'week')): larger_windows_allow = all( current_rate < limit for limit_scope, limits in self.iter_rates(scope) - for rate_counter_key, current_rate, limit in limits - if rate_counter_key in windows_not_to_wait_on + for rate_counter, current_rate, limit in limits + if rate_counter.key in windows_not_to_wait_on ) if not larger_windows_allow: # There's no point in waiting 15 seconds for the hour/day/week values to change diff --git a/corehq/project_limits/tests/test_rate_limiter.py b/corehq/project_limits/tests/test_rate_limiter.py index 7e5309ea4a755..342910c8e2954 100644 --- a/corehq/project_limits/tests/test_rate_limiter.py +++ b/corehq/project_limits/tests/test_rate_limiter.py @@ -2,8 +2,15 @@ from testil import eq -from corehq.project_limits.rate_limiter import RateLimiter, RateDefinition, \ - PerUserRateDefinition +from corehq.project_limits.rate_counter.presets import ( + second_rate_counter, + week_rate_counter, +) +from corehq.project_limits.rate_limiter import ( + PerUserRateDefinition, + RateDefinition, + RateLimiter, +) @patch('corehq.project_limits.rate_limiter.get_n_users_in_domain', lambda domain: 10) @@ -30,7 +37,7 @@ def test_get_window_of_first_exceeded_limit(): min_rate_def = RateDefinition(per_second=100) per_user_rate_def = PerUserRateDefinition(per_user_rate_def, min_rate_def) rate_limiter = RateLimiter('my_feature', per_user_rate_def.get_rate_limits) - rate_limiter.iter_rates = Mock(return_value=[('', [('second', 11, 10)])]) + rate_limiter.iter_rates = Mock(return_value=[('', [(second_rate_counter, 11, 10)])]) expected_window = 'second' actual_window = rate_limiter.get_window_of_first_exceeded_limit('my_domain') eq(actual_window, expected_window) @@ -44,7 +51,7 @@ def test_get_window_of_first_exceeded_limit_none(): min_rate_def = RateDefinition(per_second=100) per_user_rate_def = PerUserRateDefinition(per_user_rate_def, min_rate_def) rate_limiter = RateLimiter('my_feature', per_user_rate_def.get_rate_limits) - rate_limiter.iter_rates = Mock(return_value=[('', [('second', 9, 10)])]) + rate_limiter.iter_rates = Mock(return_value=[('', [(second_rate_counter, 9, 10)])]) expected_window = None actual_window = rate_limiter.get_window_of_first_exceeded_limit('my_domain') eq(actual_window, expected_window) @@ -58,7 +65,7 @@ def test_get_window_of_first_exceeded_limit_priority(): min_rate_def = RateDefinition(per_second=100) per_user_rate_def = PerUserRateDefinition(per_user_rate_def, min_rate_def) rate_limiter = RateLimiter('my_feature', per_user_rate_def.get_rate_limits) - rate_limiter.iter_rates = Mock(return_value=[('', [('week', 11, 10), ('second', 11, 10)])]) + rate_limiter.iter_rates = Mock(return_value=[('', [(week_rate_counter, 11, 10), ('second', 11, 10)])]) expected_window = 'week' actual_window = rate_limiter.get_window_of_first_exceeded_limit('my_domain') eq(actual_window, expected_window) diff --git a/corehq/reports.py b/corehq/reports.py index fdd0d852addcd..ce8c40e508404 100644 --- a/corehq/reports.py +++ b/corehq/reports.py @@ -355,7 +355,7 @@ def EDIT_DATA_INTERFACES(domain_obj): ) GEOSPATIAL_MAP = ( - (_("Case Management"), ( + (_("Case Mapping"), ( CaseManagementMap, CaseGroupingReport, )), diff --git a/corehq/tabs/config.py b/corehq/tabs/config.py index 00dbd85693f66..15d6ba84c6da2 100644 --- a/corehq/tabs/config.py +++ b/corehq/tabs/config.py @@ -16,7 +16,6 @@ SMSAdminTab, TranslationsTab, AttendanceTrackingTab, - GeospatialTab, ) MENU_TABS = ( @@ -34,7 +33,6 @@ EnterpriseSettingsTab, MySettingsTab, TranslationsTab, - GeospatialTab, # Admin AdminTab, SMSAdminTab, diff --git a/corehq/tabs/tabclasses.py b/corehq/tabs/tabclasses.py index 9b5656aec2be5..fa41f7b9683ea 100644 --- a/corehq/tabs/tabclasses.py +++ b/corehq/tabs/tabclasses.py @@ -460,6 +460,7 @@ class ProjectDataTab(UITab): '/a/{domain}/data_dictionary/', '/a/{domain}/importer/', '/a/{domain}/case/', + '/a/{domain}/geospatial/', ) @property @@ -578,6 +579,10 @@ def can_view_ecd_preview(self): def can_deduplicate_cases(self): return toggles.CASE_DEDUPE.enabled_for_request(self._request) + @property + def _can_view_geospatial(self): + return toggles.GEOSPATIAL.enabled(self.domain) + @property def _is_viewable(self): return self.domain and ( @@ -637,6 +642,8 @@ def sidebar_items(self): ] ] ) + if self._can_view_geospatial: + items += self._get_geospatial_views() return items @cached_property @@ -974,6 +981,23 @@ def _get_explore_data_views(self): }) return explore_data_views + def _get_geospatial_views(self): + geospatial_items = CaseManagementMapDispatcher.navigation_sections( + request=self._request, domain=self.domain) + management_sections = [ + { + 'title': _("Manage GPS Data"), + 'url': reverse(GPSCaptureView.urlname, args=(self.domain,)), + }, + { + 'title': _("Configure Geospatial Settings"), + 'url': reverse(GeospatialConfigPage.urlname, args=(self.domain,)), + } + ] + for section in management_sections: + geospatial_items[0][1].append(section) + return geospatial_items + @property def dropdown_items(self): if ( @@ -1995,6 +2019,7 @@ def _get_administration_section(domain): from corehq.apps.domain.views.settings import ( FeaturePreviewsView, ManageDomainMobileWorkersView, + ManageDomainAlertsView, RecoveryMeasuresHistory, ) from corehq.apps.ota.models import MobileRecoveryMeasure @@ -2012,6 +2037,12 @@ def _get_administration_section(domain): 'url': reverse(FeaturePreviewsView.urlname, args=[domain]) }) + if toggles.CUSTOM_DOMAIN_BANNER_ALERTS.enabled(domain): + administration.append({ + 'title': _(ManageDomainAlertsView.page_title), + 'url': reverse(ManageDomainAlertsView.urlname, args=[domain]) + }) + if toggles.TRANSFER_DOMAIN.enabled(domain): administration.append({ 'title': _(TransferDomainView.page_title), @@ -2565,39 +2596,6 @@ def _is_viewable(self): return toggles.ATTENDANCE_TRACKING.enabled(self.domain) and self.couch_user.can_manage_events(self.domain) -class GeospatialTab(UITab): - title = gettext_noop("Geospatial") - view = 'geospatial_default' - - url_prefix_formats = ( - '/a/{domain}/geospatial', - ) - - @property - def sidebar_items(self): - items = [ - (_("Settings"), [ - { - 'title': _("Configure geospatial settings"), - 'url': reverse(GeospatialConfigPage.urlname, args=(self.domain,)), - }, - { - 'title': _("Manage GPS Data"), - 'url': reverse(GPSCaptureView.urlname, args=(self.domain,)), - }, - ]), - ] - items.extend( - CaseManagementMapDispatcher.navigation_sections(request=self._request, domain=self.domain) - ) - - return items - - @property - def _is_viewable(self): - return toggles.GEOSPATIAL.enabled(self.domain) - - def _get_repeat_record_report(domain): from corehq.motech.repeaters.models import are_repeat_records_migrated from corehq.motech.repeaters.views import ( diff --git a/corehq/toggles/__init__.py b/corehq/toggles/__init__.py index 96f98ca84ec6d..4879d4c908049 100644 --- a/corehq/toggles/__init__.py +++ b/corehq/toggles/__init__.py @@ -1419,7 +1419,7 @@ def _commtrackify(domain_name, toggle_is_enabled): EXPORT_DATA_SOURCE_DATA = StaticToggle( 'export_data_source_data', 'Add Export Data Source Data page', - TAG_CUSTOM, + TAG_SOLUTIONS_LIMITED, [NAMESPACE_USER, NAMESPACE_DOMAIN], description="Add the Export Data Source Data page to the Data tab", ) @@ -2452,7 +2452,22 @@ def _handle_attendance_tracking_role(domain, is_enabled): 'custom_email_gateway', 'Allows user to define custom email gateway that can be used to send emails from HQ', TAG_CUSTOM, - [NAMESPACE_DOMAIN] + [NAMESPACE_DOMAIN], + help_link=('https://confluence.dimagi.com/display/USH/' + 'Allow+user+to+define+custom+email+gateway+that+' + 'can+be+used+to+send+emails+from+HQ'), +) + +ALLOW_WEB_APPS_RESTRICTION = StaticToggle( + 'allow_web_apps_restriction', + 'Makes domain eligible to be restricted from using web apps/app preview.', + tag=TAG_SAAS_CONDITIONAL, + namespaces=[NAMESPACE_DOMAIN], + description=""" + When enabled, the domain is eligible to be restricted from using web apps/app preview. The intention is + to only enable this for domains in extreme cases where their formplayer restores are resource intensive + to the point where they can degrade web apps performance for the entire system. + """ ) @@ -2653,3 +2668,12 @@ def domain_has_privilege_from_toggle(privilege_slug, domain): description='Project level data dictionary of cases', help_link='https://confluence.dimagi.com/display/commcarepublic/Data+Dictionary' ) + + +CUSTOM_DOMAIN_BANNER_ALERTS = StaticToggle( + slug='custom_domain_banners', + label='Allow projects to add banners for their users on HQ', + tag=TAG_CUSTOM, + namespaces=[NAMESPACE_DOMAIN], + description='Allow projects to add banners visible to their users on HQ on every login', +) diff --git a/docker/README.rst b/docker/README.rst index 548090ee9a589..13cc2e2595f6f 100644 --- a/docker/README.rst +++ b/docker/README.rst @@ -224,3 +224,44 @@ DOCKER_HQ_OVERLAYFS_METACOPY=[ on | **off** ] (performance optimization, has security implications). (Default: "off") See ``.travis.yml`` for environment variable options used on Travis. + + +Run containers with Podman instead of Docker +============================================ + +Podman 4.3 or later can be used to run HQ containers. Unlike docker, podman is +daemonless and runs containers in rootless mode by default. Podman 4.x is +available on recent versions of Ubuntu. Older versions, such as Ubuntu 22.04, +require `a third-party package repository <https://podman.io/docs/installation#debian>`_. + + +Install Podman +-------------- + +.. code:: bash + + sudo apt install podman podman-docker + + echo 'export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock' >> ~/.bashrc + echo 'export DOCKER_SOCK=$XDG_RUNTIME_DIR/podman/podman.sock' >> ~/.bashrc + +Create a podman wrapper script named `docker` with the following content +somewhere on your ``PATH`` (``~/.local/bin/docker`` may be a good place if it +is on your ``PATH``). + +.. code:: bash + + #! /usr/bin/bash + if [[ "$1" == compose ]]; then + shift + /usr/bin/docker-compose "$@" # v1, installed by podman-docker + else + podman "$@" + fi + +Start containers +---------------- + +:: + + ./scripts/docker up -d diff --git a/docker/files/Dockerfile.couch b/docker/files/Dockerfile.couch index 962cb74cd959c..8517e0cf849d7 100644 --- a/docker/files/Dockerfile.couch +++ b/docker/files/Dockerfile.couch @@ -1,3 +1,3 @@ -FROM apache/couchdb:3.3.1 +FROM docker.io/apache/couchdb:3.3.1 RUN echo '-name node1@127.0.0.1' >> /opt/couchdb/etc/vm.args diff --git a/docker/files/Dockerfile.es.2 b/docker/files/Dockerfile.es.2 index f1852340e4c8d..9e88f3db4540d 100644 --- a/docker/files/Dockerfile.es.2 +++ b/docker/files/Dockerfile.es.2 @@ -1,3 +1,3 @@ -FROM elasticsearch:2.4.6 +FROM docker.io/elasticsearch:2.4.6 RUN bin/plugin install analysis-phonetic diff --git a/docker/files/Dockerfile.es.5 b/docker/files/Dockerfile.es.5 index 592c2545153a9..5d92f6dda41b7 100644 --- a/docker/files/Dockerfile.es.5 +++ b/docker/files/Dockerfile.es.5 @@ -1,3 +1,3 @@ -FROM elasticsearch:5.6.16 +FROM docker.io/elasticsearch:5.6.16 RUN bin/elasticsearch-plugin install analysis-phonetic diff --git a/docker/hq-compose-os-default.yml b/docker/hq-compose-os-default.yml index 55f46985bb98c..805d5edb8710b 100644 --- a/docker/hq-compose-os-default.yml +++ b/docker/hq-compose-os-default.yml @@ -4,4 +4,4 @@ version: '2.3' services: postgres: - image: dimagi/docker-postgresql:${DOCKER_HQ_POSTGRES_VERSION} + image: docker.io/dimagi/docker-postgresql:${DOCKER_HQ_POSTGRES_VERSION} diff --git a/docker/hq-compose-os-macos-m1-11.yml b/docker/hq-compose-os-macos-m1-11.yml index de147348bff77..1b5c5742746a3 100644 --- a/docker/hq-compose-os-macos-m1-11.yml +++ b/docker/hq-compose-os-macos-m1-11.yml @@ -5,4 +5,4 @@ version: '2.3' services: postgres: # TODO: enumerate differences with 'dimagi/docker-postgresql' image - image: arm64v8/postgres + image: docker.io/arm64v8/postgres diff --git a/docker/hq-compose-os-macos-m1-12.yml b/docker/hq-compose-os-macos-m1-12.yml index 4824608766508..3d013f496570a 100644 --- a/docker/hq-compose-os-macos-m1-12.yml +++ b/docker/hq-compose-os-macos-m1-12.yml @@ -4,7 +4,7 @@ version: '2.3' services: postgres: - image: dimagi/docker-postgresql:${DOCKER_HQ_POSTGRES_VERSION} + image: docker.io/dimagi/docker-postgresql:${DOCKER_HQ_POSTGRES_VERSION} platform: linux/amd64 couch: platform: linux/amd64 diff --git a/docker/hq-compose.yml b/docker/hq-compose.yml index 0b4d95573df3f..9f5f8e1b46ed4 100644 --- a/docker/hq-compose.yml +++ b/docker/hq-compose.yml @@ -18,6 +18,7 @@ services: JS_TEST_EXTENSIONS: "${JS_TEST_EXTENSIONS}" NOSE_DIVIDED_WE_RUN: "${NOSE_DIVIDED_WE_RUN}" REUSE_DB: "${REUSE_DB}" + STRIPE_PRIVATE_KEY: "${STRIPE_PRIVATE_KEY}" TRAVIS: "${TRAVIS}" TRAVIS_BRANCH: "${TRAVIS_BRANCH}" TRAVIS_BUILD_ID: "${TRAVIS_BUILD_ID}" @@ -51,7 +52,7 @@ services: - ${VOLUME_PREFIX}${BLANK_IF_TESTS-lib:}/mnt/lib formplayer: - image: dimagi/formplayer + image: docker.io/dimagi/formplayer environment: COMMCARE_HOST: "http://host.docker.internal:8000" COMMCARE_ALTERNATE_ORIGINS: "http://localhost:8000,http://127.0.0.1:8000" @@ -95,7 +96,7 @@ services: - ${VOLUME_PREFIX}${BLANK_IF_TESTS-couchdb2:}/opt/couchdb/data redis: - image: redis:7 + image: docker.io/redis:7 expose: - "6379" healthcheck: @@ -138,20 +139,21 @@ services: - ./files/elasticsearch_5.yml:/usr/share/elasticsearch/config/elasticsearch.yml:rw zookeeper: - image: zookeeper:3.7 + image: docker.io/zookeeper:3.7 + environment: + ZOO_4LW_COMMANDS_WHITELIST: "ruok" expose: - "2181" - # No healthcheck: - # * Polling the port causes "EndOfStreamException: Unable to read - # additional data from client, it probably closed the socket" - # * Using the "ruok" command returns "ruok is not executed because it is - # not in the whitelist." - # See https://zookeeper.apache.org/doc/r3.7.1/zookeeperAdmin.html#sc_4lw + healthcheck: + test: echo ruok | nc localhost 2181 + start_period: 15s + interval: 10s + retries: 10 volumes: - ${VOLUME_PREFIX}${BLANK_IF_TESTS-zookeeper:}/opt/zookeeper-3.7/data kafka: - image: confluentinc/cp-kafka:7.2.2 # kafka v3.2.2 + image: docker.io/confluentinc/cp-kafka:7.2.2 # kafka v3.2.2 expose: - "9092" environment: @@ -179,14 +181,16 @@ services: - ${VOLUME_PREFIX}${BLANK_IF_TESTS-kafka:}/kafka/kafka-logs-1 minio: - image: minio/minio + image: docker.io/minio/minio command: server --address :9980 --console-address :9981 /data expose: - "9980" - "9981" healthcheck: - # https://min.io/docs/minio/linux/operations/monitoring/healthcheck-probe.html#node-liveness - test: curl -I http://minio:9980/minio/health/live + # The docker image ships with the expectation that minio runs on port 9000. + # If we ran on port 9000, we wouldn't need to update the local alias, + # but we felt it was less disruptive to use this workaround + test: mc alias set local http://localhost:9980 "" "" >/dev/null && mc ready local start_period: 15s interval: 10s retries: 10 diff --git a/docs/nfs.rst b/docs/nfs.rst index 6510b4fcf8a8f..7ad86d60fda5f 100644 --- a/docs/nfs.rst +++ b/docs/nfs.rst @@ -25,7 +25,8 @@ Using apache / nginx to handle downloads if transfer_enabled: path = os.path.join(settings.SHARED_DRIVE_CONF.transfer_dir, uuid.uuid4().hex) else: - _, path = tempfile.mkstemp() + fd, path = tempfile.mkstemp() + os.close(fd) make_file(path) @@ -46,7 +47,8 @@ This also works for files that are generated asynchronously:: if use_transfer: path = os.path.join(settings.SHARED_DRIVE_CONF.transfer_dir, uuid.uuid4().hex) else: - _, path = tempfile.mkstemp() + fd, path = tempfile.mkstemp() + os.close(fd) generate_file(path) diff --git a/key_file.key b/key_file.key deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 516eefaf6fc51..5a9acb19c5083 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -191,7 +191,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -265,6 +265,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -456,6 +471,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -948,6 +964,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2452,6 +2469,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2745,6 +2763,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3436,7 +3455,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3796,6 +3814,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4395,6 +4417,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4412,6 +4438,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4645,6 +4677,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5124,6 +5157,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5321,20 +5358,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6176,10 +6199,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6708,6 +6729,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6763,6 +6785,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -6994,6 +7042,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7661,11 +7710,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7758,6 +7802,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7921,6 +7971,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7946,6 +7997,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8382,6 +8434,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8416,6 +8494,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9617,12 +9699,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10314,6 +10390,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11486,6 +11568,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11811,7 +11894,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11827,6 +11912,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12755,6 +12844,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13100,12 +13195,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13904,6 +14001,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14744,6 +14842,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15045,6 +15151,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15411,6 +15521,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16012,8 +16151,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16415,7 +16554,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17520,6 +17661,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17590,7 +17759,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17598,7 +17767,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17926,6 +18104,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21162,7 +21344,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21224,11 +21406,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21249,10 +21426,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21336,10 +21563,30 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Save Case" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21382,6 +21629,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21393,15 +21648,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21409,7 +21668,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23813,10 +24072,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23825,22 +24080,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23955,6 +24198,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24008,11 +24258,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26690,6 +26935,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34920,6 +35170,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38334,6 +38592,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38801,7 +39075,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42580,6 +42854,10 @@ msgstr "" msgid "User Management" msgstr "" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42843,10 +43121,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "Google Authenticator" diff --git a/locale/en/LC_MESSAGES/djangojs.po b/locale/en/LC_MESSAGES/djangojs.po index a2780d60f097b..126e64a7b2dc7 100644 --- a/locale/en/LC_MESSAGES/djangojs.po +++ b/locale/en/LC_MESSAGES/djangojs.po @@ -1278,7 +1278,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1352,7 +1351,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1643,7 +1641,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1987,6 +1984,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2230,6 +2228,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2629,14 +2628,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2645,10 +2636,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2657,22 +2644,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2683,22 +2658,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2836,10 +2795,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2848,6 +2803,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3431,10 +3394,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3530,6 +3523,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3643,10 +3645,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index ed2461b0427fd..374a6c558a51a 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -200,7 +200,7 @@ msgstr "Nombre del Plan" msgid "Edition" msgstr "Edición" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "Visibilidad" @@ -279,6 +279,21 @@ msgstr "" msgid "Billing Account" msgstr "Cuenta de Facturación" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "Versión" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -475,6 +490,7 @@ msgid "Company / Organization" msgstr "Empresa/Organización" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -1045,6 +1061,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2694,6 +2711,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2989,6 +3007,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3701,7 +3720,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "Manejo de Casos" @@ -4078,6 +4096,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4719,6 +4741,10 @@ msgstr "" "esto en el <a target=\"_blank\" href=\"https://help.commcarehq.org/display/" "commcarepublic/Grid+View+for+Form+and+Module+Screens\">Sitio de Ayuda </a>. " +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "Habilitar Configuración de Visualización de Menú por Módulo" @@ -4736,6 +4762,12 @@ msgstr "Habilitar" msgid "Enabled" msgstr "Habilitado" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4983,6 +5015,7 @@ msgid "Numeric Selection" msgstr "Selección Numérica" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "Numérico" @@ -5535,6 +5568,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5738,20 +5775,6 @@ msgstr "Etiquetas" msgid "Question IDs" msgstr "ID de Preguntas" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "Versión" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6610,10 +6633,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -7142,6 +7163,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -7197,6 +7219,32 @@ msgstr "Descargando su Archivo" msgid "Processing data. Please wait..." msgstr "Procesando los datos. Por favor, espere..." +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "Cambios en el Formulario" @@ -7432,6 +7480,7 @@ msgstr "Etiqueta del Caso" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -8117,11 +8166,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -8214,6 +8258,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "Gráfica" @@ -8395,6 +8445,7 @@ msgstr "" "por la primera prioridad, luego la segunda, etc." #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -8423,6 +8474,7 @@ msgstr "Clasificar Cálculos" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8880,6 +8932,32 @@ msgstr "" msgid "Add default search property" msgstr "Agregar propiedad de búsqueda predeterminada" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "Opciones de Búsqueda y Solicitud" @@ -8914,6 +8992,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -10141,12 +10223,6 @@ msgstr "" msgid "Revert" msgstr "Revertir" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10878,6 +10954,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "Hubo un problema mientras procesábamos su solicitud." @@ -12099,6 +12181,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -12428,7 +12511,9 @@ msgstr "SÍ" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "Borrar" @@ -12444,6 +12529,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -13383,6 +13472,12 @@ msgstr "" msgid "Case Property Group" msgstr "Grupo de Propiedad del Caso" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13732,12 +13827,14 @@ msgstr "Reasignar" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "Nombre del Caso" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -14544,6 +14641,7 @@ msgstr "Fecha propiedad de caso (avanzado)" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -15414,6 +15512,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15749,6 +15855,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "La solicitud de transferencia de dominio ya no está activa" @@ -16116,6 +16226,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "Configuración de Fixture de Ubicación" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16719,8 +16858,8 @@ msgstr "Hola," #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -17140,7 +17279,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -18287,6 +18428,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "Calculador de Tarifas SMS" @@ -18362,7 +18531,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -18370,16 +18539,21 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Send Email" -msgid "Sender's email" -msgstr "Enviar Correo Electrónico" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" +msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Gateway" msgid "Use Gateway?" -msgstr "Portal" +msgstr "" #: corehq/apps/email/forms.py msgid "Select this option to use this email gateway for sending emails" @@ -18396,10 +18570,8 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Client Secret" msgid "SNS Endpoint Secret" -msgstr "Secreto de Cliente" +msgstr "" #: corehq/apps/email/forms.py msgid "" @@ -18408,10 +18580,8 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Configuration" msgid "SES Configuration Set Name" -msgstr "Configuración" +msgstr "" #: corehq/apps/email/forms.py msgid "" @@ -18420,22 +18590,16 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Saved!" msgid "Saved" -msgstr "¡Guardado!" +msgstr "Guardado" #: corehq/apps/email/templates/email/email_settings.html -#, fuzzy -#| msgid "Update My Settings" msgid "Add Email Gateway Settings" -msgstr "Actualice Mis Configuraciones" +msgstr "" #: corehq/apps/email/views.py -#, fuzzy -#| msgid "Edit Settings" msgid "Email Settings" -msgstr "Editar Configuraciones" +msgstr "" #: corehq/apps/enterprise/enterprise.py msgid "Enterprise Report" @@ -18712,6 +18876,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -22006,7 +22174,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -22068,11 +22236,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -22082,10 +22245,8 @@ msgid "gps_point" msgstr "" #: corehq/apps/geospatial/reports.py -#, fuzzy -#| msgid "Add link" msgid "link" -msgstr "Agregar enlace" +msgstr "" #: corehq/apps/geospatial/reports.py msgid "Case Management Map" @@ -22096,10 +22257,58 @@ msgid "Case Grouping" msgstr "" #: corehq/apps/geospatial/templates/case_grouping_map.html -#, fuzzy -#| msgid "Reporting Groups" +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" -msgstr "Grupos de Reporte" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" #: corehq/apps/geospatial/templates/gps_capture.html msgid "" @@ -22184,10 +22393,32 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +#, fuzzy +#| msgid "Resave Case" +msgid "Save Case" +msgstr "Guardar de nuevo el Caso" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -22230,6 +22461,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -22241,15 +22480,19 @@ msgid "Show Filter Options" msgstr "Mostrar Opciones de Filtro" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -22257,7 +22500,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -24753,10 +24996,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -24765,22 +25004,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -24895,6 +25122,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "Anterior" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24948,11 +25182,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "Anterior" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -27748,6 +27977,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "Usted utilizará este correo electrónico para ingresar." +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -36216,6 +36450,14 @@ msgstr "¡Las Traducciones UI han sido actualizadas!" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -39751,6 +39993,22 @@ msgstr "¿Está seguro que desea eliminar esta invitación?" msgid "Copy and paste admin emails" msgstr "Copiar y pegar correos electrónicos administrativos" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -40245,9 +40503,13 @@ msgstr "" "administrador del proyecto que le envíe una invitación otra vez." #: corehq/apps/users/views/web.py +#, fuzzy +#| msgid "" +#| "Sorry, that invitation has already been used up. If you feel this is a " +#| "mistake please ask the inviter for another invitation." msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" "Disculpe, esa invitación ya ha sido utilizada. Si cree que esto es un error, " "por favor pídale al que lo invitó que le envíe una nueva invitación." @@ -44075,6 +44337,10 @@ msgstr "Estatus del Dominio" msgid "User Management" msgstr "Manejo de Casos" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "Ver Todos" @@ -44128,10 +44394,8 @@ msgid "Edit Gateway" msgstr "Editar Portal" #: corehq/tabs/tabclasses.py -#, fuzzy -#| msgid "SMS Connectivity" msgid "Email Connectivity" -msgstr "Conectividad SMS" +msgstr "" #: corehq/tabs/tabclasses.py msgid "Template Management" @@ -44340,10 +44604,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/es/LC_MESSAGES/djangojs.po b/locale/es/LC_MESSAGES/djangojs.po index 696048e051ca4..8be21f3e5cc8d 100644 --- a/locale/es/LC_MESSAGES/djangojs.po +++ b/locale/es/LC_MESSAGES/djangojs.po @@ -1307,7 +1307,6 @@ msgid "Phone Number or Numeric ID" msgstr "Número de teléfono o ID Numérico" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "Contraseña" @@ -1385,7 +1384,6 @@ msgid "Long" msgstr "Largo" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "Decimal" @@ -1706,7 +1704,6 @@ msgid "Form has validation errors." msgstr "El formulario tiene errores de validación." #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "Número" @@ -2094,6 +2091,7 @@ msgid "Lookup table was not found in the project" msgstr "No se encontró la Tabla de búsqueda en el proyecto." #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "Cargando..." @@ -2364,6 +2362,7 @@ msgid "A reference to an integer question in this form." msgstr "Una referencia a una pregunta de un entero en este formulario." #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "Caso" @@ -2768,14 +2767,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "Error al evaluar la expresión." -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "Código de barras" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "Respuesta libre" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2784,10 +2775,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2796,22 +2783,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "Opción no válida" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2822,22 +2797,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2978,10 +2937,6 @@ msgstr "" "formulario. Por favor reporte un problema si este mensaje continúa " "apareciendo." -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2990,6 +2945,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3578,10 +3541,42 @@ msgstr "ID Confidencial" msgid "Sensitive Date" msgstr "Fecha Confidencial" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +#, fuzzy +#| msgid "Current" +msgid "current user" +msgstr "Actual" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "Usuario Móvil" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3681,6 +3676,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "No pudimos apagar la nueva función. Por favor intente más tarde." +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "No es un correo electrónico válido" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3796,10 +3800,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "No es un correo electrónico válido" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" @@ -5143,6 +5143,3 @@ msgstr "¡Incorrecto! La respuesta es:" #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "CommCare HQ was unable to make the request: " msgstr "" - -#~ msgid "Description" -#~ msgstr "Descripción" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 51ecba103d857..2cf8959008c06 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -192,7 +192,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -266,6 +266,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -457,6 +472,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -949,6 +965,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2453,6 +2470,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2746,6 +2764,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3437,7 +3456,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3797,6 +3815,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4396,6 +4418,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4413,6 +4439,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4646,6 +4678,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5125,6 +5158,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5322,20 +5359,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6177,10 +6200,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6709,6 +6730,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6764,6 +6786,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -6995,6 +7043,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7662,11 +7711,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7759,6 +7803,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7922,6 +7972,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7947,6 +7998,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8383,6 +8435,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8417,6 +8495,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9618,12 +9700,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10315,6 +10391,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11487,6 +11569,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11812,7 +11895,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11828,6 +11913,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12756,6 +12845,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13101,12 +13196,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13905,6 +14002,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14745,6 +14843,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15046,6 +15152,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15412,6 +15522,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16013,8 +16152,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16416,7 +16555,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17521,6 +17662,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17591,7 +17760,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17599,7 +17768,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17927,6 +18105,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21163,7 +21345,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21225,11 +21407,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21250,10 +21427,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21337,10 +21564,30 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Save Case" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21383,6 +21630,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21394,15 +21649,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21410,7 +21669,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23814,10 +24073,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23826,22 +24081,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23956,6 +24199,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24009,11 +24259,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26691,6 +26936,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34919,6 +35169,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38333,6 +38591,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38800,7 +39074,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42579,6 +42853,10 @@ msgstr "" msgid "User Management" msgstr "" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42842,10 +43120,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index e9e3a388de757..c197b5d958d99 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -1279,7 +1279,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1353,7 +1352,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1644,7 +1642,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1988,6 +1985,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2231,6 +2229,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2630,14 +2629,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2646,10 +2637,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2658,22 +2645,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2684,22 +2659,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2837,10 +2796,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2849,6 +2804,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3432,10 +3395,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3531,6 +3524,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3644,10 +3646,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/locale/fra/LC_MESSAGES/django.po b/locale/fra/LC_MESSAGES/django.po index 97594c21a84a3..0b7ee17c0a82f 100644 --- a/locale/fra/LC_MESSAGES/django.po +++ b/locale/fra/LC_MESSAGES/django.po @@ -224,7 +224,7 @@ msgstr "Nom du Plan " msgid "Edition" msgstr "Edition" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "Visibilité" @@ -304,6 +304,21 @@ msgstr "" msgid "Billing Account" msgstr "Compte de facturation" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "Version" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -499,6 +514,7 @@ msgid "Company / Organization" msgstr "Compagnie / Organisation" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -1065,6 +1081,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2710,6 +2727,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -3005,6 +3023,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3708,7 +3727,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "Gestion des dossiers" @@ -4085,6 +4103,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4715,6 +4737,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4732,6 +4758,12 @@ msgstr "Permettre" msgid "Enabled" msgstr "Activé" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4965,6 +4997,7 @@ msgid "Numeric Selection" msgstr "Sélection numérique" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "Numérique" @@ -5444,6 +5477,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5651,20 +5688,6 @@ msgstr "Libellés" msgid "Question IDs" msgstr "Identifications de question" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "Version" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6530,10 +6553,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -7062,6 +7083,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -7117,6 +7139,32 @@ msgstr "Télécharger votre dossier" msgid "Processing data. Please wait..." msgstr "Traitement de données. Veuillez patienter..." +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "Modifications de formulaire" @@ -7350,6 +7398,7 @@ msgstr "Étiquette de dossier" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -8037,11 +8086,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -8134,6 +8178,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "Graphique" @@ -8316,6 +8366,7 @@ msgstr "" "selon les priorités." #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -8344,6 +8395,7 @@ msgstr "Calcul de tri" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8802,6 +8854,32 @@ msgstr "" msgid "Add default search property" msgstr "Ajouter une propriété de recherche par défaut" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "Options de recherche et de demande" @@ -8836,6 +8914,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -10067,12 +10149,6 @@ msgstr "" msgid "Revert" msgstr "Restaurer" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10787,6 +10863,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "Il y a eu un problème pour procéder à votre requête." @@ -12010,6 +12092,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -12341,7 +12424,9 @@ msgstr "OK" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "Effacer" @@ -12357,6 +12442,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -13295,6 +13384,12 @@ msgstr "" msgid "Case Property Group" msgstr "Groupe de propriété de dossier" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13644,12 +13739,14 @@ msgstr "Transférer" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "Nom de dossier" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -14457,6 +14554,7 @@ msgstr "Date de la propriété de dossier (avancé)" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -15329,6 +15427,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15663,6 +15769,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "Demande de transfert de domaine n'est plus active" @@ -16029,6 +16139,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "Paramètres d'éléments de Sites" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16636,8 +16775,8 @@ msgstr "Salut," #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -17059,7 +17198,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -18212,6 +18353,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "Calculateur de taux des SMS" @@ -18288,7 +18457,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -18296,16 +18465,21 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Send Email" -msgid "Sender's email" -msgstr "Envoyer un courrier électronique" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" +msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Gateway" msgid "Use Gateway?" -msgstr "Passerelle" +msgstr "" #: corehq/apps/email/forms.py msgid "Select this option to use this email gateway for sending emails" @@ -18322,10 +18496,8 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Client Secret" msgid "SNS Endpoint Secret" -msgstr "Secret client" +msgstr "" #: corehq/apps/email/forms.py msgid "" @@ -18334,10 +18506,8 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Configuration" msgid "SES Configuration Set Name" -msgstr "Configuration" +msgstr "" #: corehq/apps/email/forms.py msgid "" @@ -18346,22 +18516,16 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Saved!" msgid "Saved" -msgstr "Sauvegardé!" +msgstr "Enregistré" #: corehq/apps/email/templates/email/email_settings.html -#, fuzzy -#| msgid "Update My Settings" msgid "Add Email Gateway Settings" -msgstr "Mettre à jour mes paramètres" +msgstr "" #: corehq/apps/email/views.py -#, fuzzy -#| msgid "Edit Settings" msgid "Email Settings" -msgstr "Modifier les paramètres" +msgstr "" #: corehq/apps/enterprise/enterprise.py msgid "Enterprise Report" @@ -18638,6 +18802,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21925,7 +22093,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21987,11 +22155,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -22001,10 +22164,8 @@ msgid "gps_point" msgstr "" #: corehq/apps/geospatial/reports.py -#, fuzzy -#| msgid "Add link" msgid "link" -msgstr "Ajouter un lien" +msgstr "" #: corehq/apps/geospatial/reports.py msgid "Case Management Map" @@ -22015,10 +22176,58 @@ msgid "Case Grouping" msgstr "" #: corehq/apps/geospatial/templates/case_grouping_map.html -#, fuzzy -#| msgid "Reporting Groups" +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" -msgstr "Groupes de signalement" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "Afficher tous les groupes" #: corehq/apps/geospatial/templates/gps_capture.html msgid "" @@ -22103,10 +22312,32 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +#, fuzzy +#| msgid "Resave Case" +msgid "Save Case" +msgstr "Réenregistré le dossier" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -22149,6 +22380,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -22160,15 +22399,19 @@ msgid "Show Filter Options" msgstr "Options de filtre d'affichage" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -22176,7 +22419,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -24686,10 +24929,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -24698,22 +24937,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -24828,6 +25055,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "Précédent" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24881,11 +25115,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "Précédent" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -27684,6 +27913,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "Vous utiliserez cette adresse électronique pour vous connecter" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -36193,6 +36427,14 @@ msgstr "Les traductions des IU ont été mises à jour !" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -39715,6 +39957,22 @@ msgstr "Êtes vous certain de vouloir supprimer cette invitation?" msgid "Copy and paste admin emails" msgstr "Copier et coller des messages électroniques d'admin" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -40211,9 +40469,13 @@ msgstr "" "administrateur projet de vous envoyer une nouvelle invitation." #: corehq/apps/users/views/web.py +#, fuzzy +#| msgid "" +#| "Sorry, that invitation has already been used up. If you feel this is a " +#| "mistake please ask the inviter for another invitation." msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" "Désolé, cette invitation a déjà été utilisée. Si vous pensez qu'il s'agit " "d'une erreur, demandez à la personne qui a émis l'invitation de vous en " @@ -44041,6 +44303,10 @@ msgstr "Statistiques de domaine" msgid "User Management" msgstr "Manejo de Casos" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "Tout Afficher" @@ -44094,10 +44360,8 @@ msgid "Edit Gateway" msgstr "Modifier passerelle " #: corehq/tabs/tabclasses.py -#, fuzzy -#| msgid "SMS Connectivity" msgid "Email Connectivity" -msgstr "Connectivité SMS" +msgstr "" #: corehq/tabs/tabclasses.py msgid "Template Management" @@ -44306,10 +44570,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/fra/LC_MESSAGES/djangojs.po b/locale/fra/LC_MESSAGES/djangojs.po index 9bfdb780d0640..43e95eb62d525 100644 --- a/locale/fra/LC_MESSAGES/djangojs.po +++ b/locale/fra/LC_MESSAGES/djangojs.po @@ -13,10 +13,11 @@ # Rowena Luk <rluk@dimagi.com>, 2021 # Dimagi Dev <devops@dimagi.com>, 2021 # Ismaïla Diene <idiene@dimagi.com>, 2022 +# Tyler Wymer <twymer@dimagi.com>, 2023 # Aissetou Sawadogo <ais107@hotmail.com>, 2023 # Ayla Reith <areith@dimagi.com>, 2023 -# Tyler Wymer <twymer@dimagi.com>, 2023 # Carla Legros <clegros@dimagi.com>, 2023 +# patrick keating <pkeating@dimagi.com>, 2023 # #, fuzzy msgid "" @@ -24,7 +25,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "PO-Revision-Date: 2017-07-19 15:34+0000\n" -"Last-Translator: Carla Legros <clegros@dimagi.com>, 2023\n" +"Last-Translator: patrick keating <pkeating@dimagi.com>, 2023\n" "Language-Team: French (https://app.transifex.com/dimagi/teams/9388/fr/)\n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -1315,7 +1316,6 @@ msgid "Phone Number or Numeric ID" msgstr "Numéro de téléphone ou identifiant numérique" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "Mot de passe" @@ -1389,7 +1389,6 @@ msgid "Long" msgstr "Long" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "Décimal" @@ -1683,7 +1682,6 @@ msgid "Form has validation errors." msgstr "Le formulaire comporte des erreurs de validation." #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "Nombre" @@ -2027,6 +2025,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "Téléchargement en cours..." @@ -2270,6 +2269,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "Dossier" @@ -2669,14 +2669,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "Code-barres" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "Réponse gratuite" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2685,10 +2677,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2697,22 +2685,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2723,22 +2699,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2876,10 +2836,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2888,6 +2844,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3471,10 +3435,42 @@ msgstr "Identifiant sensible" msgid "Sensitive Date" msgstr "Date sensible" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +#, fuzzy +#| msgid "Current" +msgid "current user" +msgstr "Actuel" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "Utilisateur mobile" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3572,6 +3568,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3685,10 +3690,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" @@ -4996,6 +4997,3 @@ msgstr "" #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "CommCare HQ was unable to make the request: " msgstr "" - -#~ msgid "Description" -#~ msgstr "Description" diff --git a/locale/hi/LC_MESSAGES/django.po b/locale/hi/LC_MESSAGES/django.po index 1060f6a8b24f9..19f683cd68694 100644 --- a/locale/hi/LC_MESSAGES/django.po +++ b/locale/hi/LC_MESSAGES/django.po @@ -192,7 +192,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -266,6 +266,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -457,6 +472,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -949,6 +965,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2453,6 +2470,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2746,6 +2764,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3437,7 +3456,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3797,6 +3815,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4396,6 +4418,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4413,6 +4439,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4646,6 +4678,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5125,6 +5158,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5322,20 +5359,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6177,10 +6200,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6709,6 +6730,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6764,6 +6786,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -6995,6 +7043,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7662,11 +7711,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7759,6 +7803,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7922,6 +7972,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7947,6 +7998,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8383,6 +8435,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8417,6 +8495,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9618,12 +9700,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10315,6 +10391,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11487,6 +11569,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11812,7 +11895,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11828,6 +11913,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12756,6 +12845,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13101,12 +13196,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13905,6 +14002,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14745,6 +14843,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15046,6 +15152,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15412,6 +15522,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16013,8 +16152,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16416,7 +16555,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17521,6 +17662,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17591,7 +17760,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17599,7 +17768,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17927,6 +18105,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21163,7 +21345,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21225,11 +21407,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21250,10 +21427,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21337,10 +21564,30 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Save Case" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21383,6 +21630,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21394,15 +21649,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21410,7 +21669,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23814,10 +24073,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23826,22 +24081,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23956,6 +24199,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24009,11 +24259,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26691,6 +26936,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34919,6 +35169,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38333,6 +38591,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38800,7 +39074,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42579,6 +42853,10 @@ msgstr "" msgid "User Management" msgstr "" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42842,10 +43120,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/hi/LC_MESSAGES/djangojs.po b/locale/hi/LC_MESSAGES/djangojs.po index b8b3b2d2082ee..5845e408b7cb1 100644 --- a/locale/hi/LC_MESSAGES/djangojs.po +++ b/locale/hi/LC_MESSAGES/djangojs.po @@ -1279,7 +1279,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1353,7 +1352,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1644,7 +1642,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1988,6 +1985,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2231,6 +2229,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2630,14 +2629,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2646,10 +2637,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2658,22 +2645,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2684,22 +2659,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2837,10 +2796,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2849,6 +2804,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3432,10 +3395,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3531,6 +3524,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3644,10 +3646,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/locale/hin/LC_MESSAGES/django.po b/locale/hin/LC_MESSAGES/django.po index 2e11aa79366e9..90bc1b2411602 100644 --- a/locale/hin/LC_MESSAGES/django.po +++ b/locale/hin/LC_MESSAGES/django.po @@ -206,7 +206,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -280,6 +280,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -471,6 +486,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -963,6 +979,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2467,6 +2484,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2760,6 +2778,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3451,7 +3470,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3811,6 +3829,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4410,6 +4432,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4427,6 +4453,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4660,6 +4692,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5139,6 +5172,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5336,20 +5373,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6191,10 +6214,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6723,6 +6744,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6778,6 +6800,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -7009,6 +7057,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7676,11 +7725,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7773,6 +7817,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7936,6 +7986,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7961,6 +8012,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8397,6 +8449,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8431,6 +8509,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9632,12 +9714,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10329,6 +10405,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11501,6 +11583,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11826,7 +11909,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11842,6 +11927,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12770,6 +12859,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13115,12 +13210,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13919,6 +14016,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14759,6 +14857,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15060,6 +15166,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15426,6 +15536,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16027,8 +16166,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16430,7 +16569,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17535,6 +17676,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17605,7 +17774,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17613,7 +17782,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17941,6 +18119,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21177,7 +21359,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21239,11 +21421,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21264,10 +21441,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21351,10 +21578,32 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +#, fuzzy +#| msgid "Manage Case" +msgid "Save Case" +msgstr "Diagnóstico de Casos" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21397,6 +21646,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21408,15 +21665,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21424,7 +21685,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23828,10 +24089,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23840,22 +24097,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23970,6 +24215,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24023,11 +24275,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26705,6 +26952,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34933,6 +35185,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38347,6 +38607,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38814,7 +39090,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42593,6 +42869,10 @@ msgstr "" msgid "User Management" msgstr "Manejo de Casos" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42856,10 +43136,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/hin/LC_MESSAGES/djangojs.po b/locale/hin/LC_MESSAGES/djangojs.po index a24457d545a07..1e5a3b1b225f2 100644 --- a/locale/hin/LC_MESSAGES/djangojs.po +++ b/locale/hin/LC_MESSAGES/djangojs.po @@ -1282,7 +1282,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1356,7 +1355,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1647,7 +1645,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1991,6 +1988,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2234,6 +2232,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2633,14 +2632,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2649,10 +2640,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2661,22 +2648,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2687,22 +2662,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2840,10 +2799,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2852,6 +2807,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3435,10 +3398,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3534,6 +3527,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3647,10 +3649,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/locale/por/LC_MESSAGES/django.po b/locale/por/LC_MESSAGES/django.po index a2e6c938d3f4a..22b53a670a00a 100644 --- a/locale/por/LC_MESSAGES/django.po +++ b/locale/por/LC_MESSAGES/django.po @@ -223,7 +223,7 @@ msgstr "Nome do plano" msgid "Edition" msgstr "Edição" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "Visibilidade" @@ -301,6 +301,21 @@ msgstr "" msgid "Billing Account" msgstr "Conta de Faturamento" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -492,6 +507,7 @@ msgid "Company / Organization" msgstr "Companhia/ Organização" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -1009,6 +1025,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2513,6 +2530,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2806,6 +2824,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3497,7 +3516,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "Gestão de caso" @@ -3861,6 +3879,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4462,6 +4484,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4479,6 +4505,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4712,6 +4744,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5191,6 +5224,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5388,20 +5425,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6246,10 +6269,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6778,6 +6799,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6833,6 +6855,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -7064,6 +7112,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7731,11 +7780,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7828,6 +7872,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7991,6 +8041,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -8016,6 +8067,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8452,6 +8504,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8486,6 +8564,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9687,12 +9769,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10392,6 +10468,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11578,6 +11660,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11904,7 +11987,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11920,6 +12005,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12854,6 +12943,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13199,12 +13294,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "Nome do Caso" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -14003,6 +14100,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14843,6 +14941,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15144,6 +15250,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15510,6 +15620,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16111,8 +16250,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16514,7 +16653,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17620,6 +17761,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17690,7 +17859,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17698,7 +17867,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17740,22 +17918,16 @@ msgid "" msgstr "" #: corehq/apps/email/forms.py -#, fuzzy -#| msgid "Saved!" msgid "Saved" -msgstr "Salvo!" +msgstr "" #: corehq/apps/email/templates/email/email_settings.html -#, fuzzy -#| msgid "Email Gateway Error" msgid "Add Email Gateway Settings" -msgstr "Erro de Portal de E-mail" +msgstr "" #: corehq/apps/email/views.py -#, fuzzy -#| msgid "Settings" msgid "Email Settings" -msgstr "Definições" +msgstr "" #: corehq/apps/enterprise/enterprise.py msgid "Enterprise Report" @@ -18032,6 +18204,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21311,7 +21487,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21373,11 +21549,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21399,10 +21570,58 @@ msgid "Case Grouping" msgstr "" #: corehq/apps/geospatial/templates/case_grouping_map.html -#, fuzzy -#| msgid "Reporting Group" +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" -msgstr "Grupo de Relatório" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" #: corehq/apps/geospatial/templates/gps_capture.html msgid "" @@ -21487,10 +21706,32 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +#, fuzzy +#| msgid "Manage Case" +msgid "Save Case" +msgstr "Diagnóstico de Casos" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21533,6 +21774,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21544,15 +21793,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21560,7 +21813,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23980,10 +24233,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23992,22 +24241,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -24122,6 +24359,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "Anterior" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24175,11 +24419,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "Anterior" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26875,6 +27114,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -35140,6 +35384,14 @@ msgstr "Traduções UI atualizadas!" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38575,6 +38827,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -39043,7 +39311,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42832,6 +43100,10 @@ msgstr "Estatísticas do Web Domain" msgid "User Management" msgstr "Manejo de Casos" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "Ver tudo" @@ -42885,10 +43157,8 @@ msgid "Edit Gateway" msgstr "" #: corehq/tabs/tabclasses.py -#, fuzzy -#| msgid "Daily Form Activity" msgid "Email Connectivity" -msgstr "Atividade Diária do Formulário" +msgstr "" #: corehq/tabs/tabclasses.py msgid "Template Management" @@ -43097,10 +43367,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/por/LC_MESSAGES/djangojs.po b/locale/por/LC_MESSAGES/djangojs.po index cb653232c237a..34a4ffad871af 100644 --- a/locale/por/LC_MESSAGES/djangojs.po +++ b/locale/por/LC_MESSAGES/djangojs.po @@ -5,9 +5,9 @@ # # Translators: # Jensen Daniel <jdaniel@dimagi.com>, 2019 +# Claire O'Grady <cogrady@dimagi.com>, 2019 # Séfora Vazirna <seforan@gmail.com>, 2022 # Dimagi Dev <devops@dimagi.com>, 2023 -# Claire O'Grady <cogrady@dimagi.com>, 2023 # #, fuzzy msgid "" @@ -15,7 +15,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "PO-Revision-Date: 2017-07-19 15:34+0000\n" -"Last-Translator: Claire O'Grady <cogrady@dimagi.com>, 2023\n" +"Last-Translator: Dimagi Dev <devops@dimagi.com>, 2023\n" "Language-Team: Portuguese (https://app.transifex.com/dimagi/teams/9388/pt/)\n" "Language: pt\n" "MIME-Version: 1.0\n" @@ -1286,7 +1286,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "Palavra-passe" @@ -1360,7 +1359,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1652,7 +1650,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1996,6 +1993,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2239,6 +2237,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "Caso" @@ -2638,14 +2637,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2654,10 +2645,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2666,22 +2653,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2692,22 +2667,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2845,10 +2804,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2857,6 +2812,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3440,10 +3403,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3539,6 +3532,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3652,10 +3654,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" @@ -4950,6 +4948,3 @@ msgstr "" #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "CommCare HQ was unable to make the request: " msgstr "" - -#~ msgid "Description" -#~ msgstr "Descrição" diff --git a/locale/pt/LC_MESSAGES/django.po b/locale/pt/LC_MESSAGES/django.po index 1060f6a8b24f9..19f683cd68694 100644 --- a/locale/pt/LC_MESSAGES/django.po +++ b/locale/pt/LC_MESSAGES/django.po @@ -192,7 +192,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -266,6 +266,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -457,6 +472,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -949,6 +965,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2453,6 +2470,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2746,6 +2764,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3437,7 +3456,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3797,6 +3815,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4396,6 +4418,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4413,6 +4439,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4646,6 +4678,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5125,6 +5158,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5322,20 +5359,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6177,10 +6200,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6709,6 +6730,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6764,6 +6786,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -6995,6 +7043,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7662,11 +7711,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7759,6 +7803,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7922,6 +7972,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7947,6 +7998,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8383,6 +8435,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8417,6 +8495,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9618,12 +9700,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10315,6 +10391,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11487,6 +11569,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11812,7 +11895,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11828,6 +11913,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12756,6 +12845,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13101,12 +13196,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13905,6 +14002,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14745,6 +14843,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15046,6 +15152,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15412,6 +15522,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16013,8 +16152,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16416,7 +16555,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17521,6 +17662,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17591,7 +17760,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17599,7 +17768,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17927,6 +18105,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21163,7 +21345,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21225,11 +21407,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21250,10 +21427,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21337,10 +21564,30 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Save Case" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21383,6 +21630,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21394,15 +21649,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21410,7 +21669,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23814,10 +24073,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23826,22 +24081,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23956,6 +24199,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24009,11 +24259,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26691,6 +26936,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34919,6 +35169,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38333,6 +38591,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38800,7 +39074,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42579,6 +42853,10 @@ msgstr "" msgid "User Management" msgstr "" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42842,10 +43120,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/pt/LC_MESSAGES/djangojs.po b/locale/pt/LC_MESSAGES/djangojs.po index b8b3b2d2082ee..5845e408b7cb1 100644 --- a/locale/pt/LC_MESSAGES/djangojs.po +++ b/locale/pt/LC_MESSAGES/djangojs.po @@ -1279,7 +1279,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1353,7 +1352,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1644,7 +1642,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1988,6 +1985,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2231,6 +2229,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2630,14 +2629,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2646,10 +2637,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2658,22 +2645,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2684,22 +2659,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2837,10 +2796,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2849,6 +2804,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3432,10 +3395,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3531,6 +3524,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3644,10 +3646,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/locale/sw/LC_MESSAGES/django.po b/locale/sw/LC_MESSAGES/django.po index eda3987085615..e091f69281175 100644 --- a/locale/sw/LC_MESSAGES/django.po +++ b/locale/sw/LC_MESSAGES/django.po @@ -196,7 +196,7 @@ msgstr "" msgid "Edition" msgstr "" -#: corehq/apps/accounting/filters.py +#: corehq/apps/accounting/filters.py corehq/apps/accounting/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html msgid "Visibility" msgstr "" @@ -270,6 +270,21 @@ msgstr "" msgid "Billing Account" msgstr "" +#: corehq/apps/accounting/forms.py +#: corehq/apps/app_manager/templates/app_manager/case_summary.html +#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html +#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html +#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html +#: corehq/apps/builds/templates/builds/all.html +#: corehq/apps/builds/templates/builds/edit_menu.html +#: corehq/apps/domain/forms.py +#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html +#: corehq/apps/domain/templates/domain/manage_releases_by_location.html +#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html +msgid "Version" +msgstr "" + #: corehq/apps/accounting/forms.py #: corehq/apps/accounting/templates/accounting/email/invoice.html #: corehq/apps/accounting/templates/accounting/email/invoice_autopayment.html @@ -461,6 +476,7 @@ msgid "Company / Organization" msgstr "" #: corehq/apps/accounting/forms.py +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html #: corehq/apps/reminders/forms.py corehq/apps/reports/standard/deployments.py #: corehq/apps/reports/standard/sms.py corehq/ex-submodules/phonelog/reports.py @@ -953,6 +969,7 @@ msgstr "" #: corehq/apps/export/templates/export/partials/export_list_create_export_modal.html #: corehq/apps/export/templates/export/partials/feed_filter_modal.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/groups/templates/groups/group_members.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap3/modal_report_issue.html #: corehq/apps/hqwebapp/templates/hqwebapp/includes/bootstrap5/modal_report_issue.html @@ -2457,6 +2474,7 @@ msgstr "" #: corehq/apps/accounting/templates/accounting/invoice.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/data_dictionary/models.py corehq/apps/hqadmin/reports.py #: corehq/apps/registry/templates/registry/partials/audit_logs.html @@ -2750,6 +2768,7 @@ msgstr "" #: corehq/apps/custom_data_fields/templates/custom_data_fields/custom_data_fields.html #: corehq/apps/data_interfaces/templates/data_interfaces/case_rule.html #: corehq/apps/data_interfaces/templates/data_interfaces/edit_deduplication_rule.html +#: corehq/apps/domain/forms.py #: corehq/apps/fixtures/templates/fixtures/partials/edit_table_modal.html #: corehq/apps/geospatial/forms.py #: corehq/apps/geospatial/templates/gps_capture.html @@ -3441,7 +3460,6 @@ msgstr "" #: corehq/apps/app_manager/add_ons.py #: corehq/apps/app_manager/templates/app_manager/form_view.html -#: corehq/reports.py msgid "Case Management" msgstr "" @@ -3801,6 +3819,10 @@ msgid "" "Format \"{}\" can only be used once but is used by multiple properties: {}" msgstr "" +#: corehq/apps/app_manager/helpers/validators.py +msgid "Column/Field \"{}\": Clickable Icons require a form to be configured." +msgstr "" + #: corehq/apps/app_manager/helpers/validators.py msgid "Case tiles may only be used for the case list (not the case details)." msgstr "" @@ -4400,6 +4422,10 @@ msgid "" "Grid+View+for+Form+and+Module+Screens\">Help Site</a>." msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "Dynamic Search for Split Screen Case Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "Enable Menu Display Setting Per-Module" msgstr "" @@ -4417,6 +4443,12 @@ msgstr "" msgid "Enabled" msgstr "" +#: corehq/apps/app_manager/static_strings.py +msgid "" +"Enable searching as input values change after initial Split Screen Case " +"Search" +msgstr "" + #: corehq/apps/app_manager/static_strings.py msgid "" "For mobile map displays, chooses a base tileset for the underlying map layer" @@ -4650,6 +4682,7 @@ msgid "Numeric Selection" msgstr "" #: corehq/apps/app_manager/static_strings.py +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Numeric" msgstr "" @@ -5129,6 +5162,10 @@ msgstr "" msgid "2 x 3 grid of image and text" msgstr "" +#: corehq/apps/app_manager/suite_xml/features/case_tiles.py +msgid "BHA Referrals" +msgstr "" + #: corehq/apps/app_manager/suite_xml/features/scheduler.py #, python-brace-format msgid "There is no schedule for form {form_id}" @@ -5326,20 +5363,6 @@ msgstr "" msgid "Question IDs" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/case_summary.html -#: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html -#: corehq/apps/app_manager/templates/app_manager/partials/multimedia_sizes.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_deploy_modal.html -#: corehq/apps/app_manager/templates/app_manager/partials/releases/releases_table.html -#: corehq/apps/builds/templates/builds/all.html -#: corehq/apps/builds/templates/builds/edit_menu.html -#: corehq/apps/domain/forms.py -#: corehq/apps/domain/templates/domain/manage_releases_by_app_profile.html -#: corehq/apps/domain/templates/domain/manage_releases_by_location.html -#: corehq/apps/hqmedia/templates/hqmedia/translations_coverage.html -msgid "Version" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/case_summary.html #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html #: corehq/apps/cloudcare/templates/formplayer/pagination.html @@ -6181,10 +6204,8 @@ msgstr "" #, python-format msgid "" "\n" -" <a href=\"%(module_url)s\">%(module_name)s</a>\n" -" has a Parent Menu configured with \"make search input " -"available after search\",\n" -" This workflow is unsupported.\n" +" The case list in <a href=\"%(module_url)s\">%(module_name)s</" +"a> can not use the same \"search input instance name\" as its Parent Menu.\n" " " msgstr "" @@ -6713,6 +6734,7 @@ msgstr "" #: corehq/apps/data_interfaces/templates/data_interfaces/list_case_groups.html #: corehq/apps/data_interfaces/templates/data_interfaces/list_deduplication_rules.html #: corehq/apps/data_interfaces/templates/data_interfaces/partials/auto_update_rule_list.html +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html #: corehq/apps/domain/templates/domain/stripe_cards.html #: corehq/apps/export/templates/export/dialogs/delete_custom_export_dialog.html #: corehq/apps/export/templates/export/partials/table.html @@ -6768,6 +6790,32 @@ msgstr "" msgid "Processing data. Please wait..." msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "Session Endpoint ID" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow endpoint to access hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "Allow access to hidden forms" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/form_session_endpoint.html +msgid "" +"Turn this setting on to allow this endpoint to access\n" +" hidden forms." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/form_summary_header.html msgid "Form Changes" msgstr "" @@ -6999,6 +7047,7 @@ msgstr "" #: corehq/apps/data_interfaces/views.py #: corehq/apps/export/templates/export/customize_export_new.html #: corehq/apps/export/templates/export/partials/table.html +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/reports/filters/select.py #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/v2/filters/case_report.py @@ -7666,11 +7715,6 @@ msgstr "" msgid "Enable these function datums in session endpoints" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "Session Endpoint ID" -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/function_datum_endpoints.html msgid "Comma separated list of function datums in session endpoints" msgstr "" @@ -7763,6 +7807,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/module_session_endpoint.html +msgid "" +"A session endpoint ID allows Android apps to call in to\n" +" CommCare at this position." +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/add_property_button.html msgid "Graph" msgstr "" @@ -7926,6 +7976,7 @@ msgid "" msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/enterprise/interface.py corehq/apps/reports/standard/sms.py #: corehq/apps/smsbillables/filters.py msgid "Direction" @@ -7951,6 +8002,7 @@ msgstr "" #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_property.html #: corehq/apps/userreports/templates/userreports/partials/property_list_configuration.html msgid "Format" @@ -8387,6 +8439,32 @@ msgstr "" msgid "Add default search property" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Custom Sort Properties" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "" +"Sort search results by case property before filtering. These will affect the " +"priority in which results are returned and are hidden from the user." +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Exact" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Ascending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Descending" +msgstr "" + +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Add custom sort property" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Search and Claim Options" msgstr "" @@ -8421,6 +8499,10 @@ msgstr "" msgid "Make search input available after search" msgstr "" +#: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html +msgid "Search Input Instance Name" +msgstr "" + #: corehq/apps/app_manager/templates/app_manager/partials/modules/case_search_properties.html msgid "Label for Searching" msgstr "" @@ -9622,12 +9704,6 @@ msgstr "" msgid "Revert" msgstr "" -#: corehq/apps/app_manager/templates/app_manager/partials/session_endpoints.html -msgid "" -"A session endpoint ID allows Android apps to call in to\n" -" CommCare at this position." -msgstr "" - #: corehq/apps/app_manager/templates/app_manager/partials/settings/add_ons.html msgid "" "\n" @@ -10319,6 +10395,12 @@ msgid "" " " msgstr "" +#: corehq/apps/app_manager/views/modules.py +msgid "" +"'{}' is an invalid instance name. It can contain only letters, numbers, and " +"underscores." +msgstr "" + #: corehq/apps/app_manager/views/modules.py msgid "There was a problem processing your request." msgstr "" @@ -11491,6 +11573,7 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py #: corehq/apps/fixtures/templates/fixtures/fixtures_base.html +#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py #: corehq/apps/settings/views.py #: corehq/apps/userreports/reports/builder/forms.py #: corehq/apps/users/templates/users/roles_and_permissions.html @@ -11816,7 +11899,9 @@ msgstr "" #: corehq/apps/cloudcare/templates/formplayer/query_view.html #: corehq/apps/cloudcare/templates/formplayer/settings_view.html #: corehq/apps/data_dictionary/templates/data_dictionary/base.html -#: corehq/apps/domain/forms.py corehq/apps/sms/templates/sms/chat_contacts.html +#: corehq/apps/domain/forms.py +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/sms/templates/sms/chat_contacts.html msgid "Clear" msgstr "" @@ -11832,6 +11917,10 @@ msgstr "" msgid "Scroll to bottom" msgstr "" +#: corehq/apps/cloudcare/templates/formplayer/case_list.html +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/templates/formplayer/case_list.html msgid "Refine search" msgstr "" @@ -12760,6 +12849,12 @@ msgstr "" msgid "Case Property Group" msgstr "" +#: corehq/apps/data_dictionary/templates/data_dictionary/base.html +msgid "" +"This GPS case property is currently being used to store the geolocation for " +"cases, so the data type cannot be changed." +msgstr "" + #: corehq/apps/data_dictionary/templates/data_dictionary/base.html #: corehq/apps/data_dictionary/tests/test_util.py #: corehq/apps/data_dictionary/util.py corehq/apps/export/forms.py @@ -13105,12 +13200,14 @@ msgstr "" #: corehq/apps/data_interfaces/interfaces.py #: corehq/apps/data_interfaces/views.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/v2/reports/explore_case_data.py msgid "Case Name" msgstr "" #: corehq/apps/data_interfaces/interfaces.py +#: corehq/apps/geospatial/templates/gps_capture.html #: corehq/apps/registry/templates/registry/registry_list.html #: corehq/apps/reports/standard/cases/basic.py #: corehq/apps/reports/templates/reports/partials/scheduled_reports_table.html @@ -13909,6 +14006,7 @@ msgstr "" #: corehq/apps/domain/forms.py #: corehq/apps/domain/templates/domain/manage_releases_by_location.html #: corehq/apps/events/forms.py corehq/apps/events/views.py +#: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/hqwebapp/doc_info.py corehq/apps/locations/forms.py #: corehq/apps/locations/templates/locations/manage/location.html #: corehq/apps/locations/templates/locations/manage/locations.html @@ -14749,6 +14847,14 @@ msgid "" "number of failed attempts" msgstr "" +#: corehq/apps/domain/forms.py +msgid "During sign up, only allow the email address the invitation was sent to" +msgstr "" + +#: corehq/apps/domain/forms.py +msgid "Disables the email field on the sign up page" +msgstr "" + #: corehq/apps/domain/forms.py msgid "Edit Privacy Settings" msgstr "" @@ -15050,6 +15156,10 @@ msgstr "" msgid "Restriction for profile {profile} failed: {message}" msgstr "" +#: corehq/apps/domain/forms.py +msgid "Add New Alert" +msgstr "" + #: corehq/apps/domain/models.py msgid "Transfer domain request is no longer active" msgstr "" @@ -15416,6 +15526,35 @@ msgstr "" msgid "Location Fixture Settings" msgstr "" +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Available Alerts" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Added By" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate or De-activate" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "Activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html +msgid "De-activate Alert" +msgstr "" + +#: corehq/apps/domain/templates/domain/admin/manage_alerts.html +msgid "No alerts added yet for the project." +msgstr "" + #: corehq/apps/domain/templates/domain/admin/recovery_measures_history.html msgid "Measure" msgstr "" @@ -16017,8 +16156,8 @@ msgstr "" #, python-format msgid "" "\n" -" %(inviter)s has invited you to join the %(domain)s project space at " -"CommCare HQ.\n" +" %(inviter)s has invited you to join the %(domain)s project at CommCare " +"HQ.\n" " This invitation expires in %(days)s day(s).\n" msgstr "" @@ -16420,7 +16559,9 @@ msgstr "" #: corehq/apps/domain/templates/domain/renew_plan.html #: corehq/apps/domain/templates/domain/select_plan.html #: corehq/apps/domain/templates/login_and_password/two_factor/_wizard_actions.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html #: corehq/apps/registration/forms.py #: corehq/apps/reports/templates/reports/filters/drilldown_options.html @@ -17525,6 +17666,34 @@ msgstr "" msgid "Manage Mobile Workers" msgstr "" +#: corehq/apps/domain/views/settings.py +msgid "Manage Project Alerts" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert saved!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "There was an error saving your alert. Please try again!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert not found!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert was removed!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Alert updated!" +msgstr "" + +#: corehq/apps/domain/views/settings.py +msgid "Unexpected update received. Alert not updated!" +msgstr "" + #: corehq/apps/domain/views/sms.py msgid "SMS Rate Calculator" msgstr "" @@ -17595,7 +17764,7 @@ msgid "Server" msgstr "" #: corehq/apps/email/forms.py -msgid "e.g. \"https://smtp.example.com\"" +msgid "e.g. \"smtp.example.com\"" msgstr "" #: corehq/apps/email/forms.py @@ -17603,7 +17772,16 @@ msgid "Port" msgstr "" #: corehq/apps/email/forms.py -msgid "Sender's email" +msgid "Sender's Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "Return Path Email" +msgstr "" + +#: corehq/apps/email/forms.py +msgid "" +"The email address to which message bounces and complaints should be sent" msgstr "" #: corehq/apps/email/forms.py @@ -17931,6 +18109,10 @@ msgstr "" msgid "180 days" msgstr "" +#: corehq/apps/enterprise/forms.py +msgid "365 days" +msgstr "" + #: corehq/apps/enterprise/forms.py msgid "" "Mobile workers who have not submitted a form after these many days will be " @@ -21167,7 +21349,7 @@ msgstr "" msgid "Disbursement algorithm" msgstr "" -#: corehq/apps/geospatial/forms.py +#: corehq/apps/geospatial/forms.py corehq/tabs/tabclasses.py msgid "Configure Geospatial Settings" msgstr "" @@ -21229,11 +21411,6 @@ msgstr "" msgid "Target Size Grouping" msgstr "" -#: corehq/apps/geospatial/reports.py corehq/apps/geospatial/views.py -#: corehq/tabs/tabclasses.py -msgid "Geospatial" -msgstr "" - #: corehq/apps/geospatial/reports.py msgid "case_id" msgstr "" @@ -21254,10 +21431,60 @@ msgstr "" msgid "Case Grouping" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Lock Case Grouping for Me" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Unlock Case Grouping for Me" +msgstr "" + #: corehq/apps/geospatial/templates/case_grouping_map.html msgid "Export Groups" msgstr "" +#: corehq/apps/geospatial/templates/case_grouping_map.html +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Filter by Saved Area" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "" +"\n" +" Please\n" +" <a href=\"\">refresh the page</a>\n" +" to apply the polygon filtering changes.\n" +" " +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Summary of Case Grouping" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Total number of clusters" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Maximum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Minimum cases per cluster" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Select Case Groups to View" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show Only Selected Groups on Map" +msgstr "" + +#: corehq/apps/geospatial/templates/case_grouping_map.html +msgid "Show All Groups" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "" "\n" @@ -21341,10 +21568,32 @@ msgid "" " " msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Create New Case" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +#, fuzzy +#| msgid "Manage Case" +msgid "Save Case" +msgstr "Diagnóstico de Casos" + #: corehq/apps/geospatial/templates/gps_capture.html msgid "Capturing location for:" msgstr "" +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "Enter new case name..." +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case name is required" +msgstr "" + +#: corehq/apps/geospatial/templates/gps_capture.html +msgid "A case type is required" +msgstr "" + #: corehq/apps/geospatial/templates/gps_capture_view.html msgid "Update Case Data" msgstr "" @@ -21387,6 +21636,14 @@ msgstr "" msgid "Show mobile workers on the map" msgstr "" +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "" +"\n" +" Only users at this location will be shown on the " +"map.\n" +" " +msgstr "" + #: corehq/apps/geospatial/templates/map_visualization.html #: corehq/apps/reports/templates/reports/standard/partials/filter_panel.html msgid "Hide Filter Options" @@ -21398,15 +21655,19 @@ msgid "Show Filter Options" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Filter by Saved Area" +msgid "Export Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Export Area" +msgid "Save Area" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Save Area" +msgid "Run Disbursement" +msgstr "" + +#: corehq/apps/geospatial/templates/map_visualization.html +msgid "Running disbursement algorithm..." msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -21414,7 +21675,7 @@ msgid "Cases Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html -msgid "Users Missing GPS Data" +msgid "Mobile Workers Missing GPS Data" msgstr "" #: corehq/apps/geospatial/templates/map_visualization.html @@ -23818,10 +24079,6 @@ msgstr "" msgid "Preview Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Available Alerts" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Scheduled start" msgstr "" @@ -23830,22 +24087,10 @@ msgstr "" msgid "Scheduled end" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate or De-activate" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Schedule Alert" msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "Activate Alert" -msgstr "" - -#: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html -msgid "De-activate Alert" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/maintenance_alerts.html msgid "Alert Expired" msgstr "" @@ -23960,6 +24205,13 @@ msgstr "" msgid "Submit Feedback" msgstr "" +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/ko_pagination.html +#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html +msgid "Previous" +msgstr "" + #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/mobile_ux_warning.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/mobile_ux_warning.html msgid "CommCare HQ looks better on desktop!" @@ -24013,11 +24265,6 @@ msgid "" " " msgstr "" -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/pagination.html -#: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/pagination.html -msgid "Previous" -msgstr "" - #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap3/paused_plan_notice.html #: corehq/apps/hqwebapp/templates/hqwebapp/partials/bootstrap5/paused_plan_notice.html #, python-format @@ -26695,6 +26942,11 @@ msgstr "" msgid "You will use this email to log in." msgstr "" +#: corehq/apps/registration/forms.py corehq/apps/users/views/web.py +msgid "" +"You can only sign up with the email address your invitation was sent to." +msgstr "" + #: corehq/apps/registration/forms.py msgid "Username already taken. Please try another or log in." msgstr "" @@ -34923,6 +35175,14 @@ msgstr "" msgid "Please select language to validate." msgstr "" +#: corehq/apps/user_importer/helpers.py +msgid "Double Entry for {}" +msgstr "" + +#: corehq/apps/user_importer/helpers.py +msgid "You cannot set {} directly" +msgstr "" + #: corehq/apps/user_importer/importer.py #, python-brace-format msgid "" @@ -38337,6 +38597,22 @@ msgstr "" msgid "Copy and paste admin emails" msgstr "" +#: corehq/apps/users/user_data.py +msgid "Profile conflicts with existing data" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "User data profile not found" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "'{}' cannot be set directly" +msgstr "" + +#: corehq/apps/users/user_data.py +msgid "{} cannot be deleted" +msgstr "" + #: corehq/apps/users/validation.py msgid "Username is required." msgstr "" @@ -38804,7 +39080,7 @@ msgstr "" #: corehq/apps/users/views/web.py msgid "" "Sorry, that invitation has already been used up. If you feel this is a " -"mistake please ask the inviter for another invitation." +"mistake, please ask the inviter for another invitation." msgstr "" #: corehq/apps/users/views/web.py @@ -42583,6 +42859,10 @@ msgstr "" msgid "User Management" msgstr "Manejo de Casos" +#: corehq/reports.py +msgid "Case Mapping" +msgstr "" + #: corehq/tabs/tabclasses.py corehq/tabs/utils.py msgid "View All" msgstr "" @@ -42846,10 +43126,6 @@ msgstr "" msgid "Manage Attendance Tracking Events" msgstr "" -#: corehq/tabs/tabclasses.py -msgid "Configure geospatial settings" -msgstr "" - #: corehq/trans_override.py msgid "Token generator" msgstr "" diff --git a/locale/sw/LC_MESSAGES/djangojs.po b/locale/sw/LC_MESSAGES/djangojs.po index 8cc35fcd88090..cd1392e972d86 100644 --- a/locale/sw/LC_MESSAGES/djangojs.po +++ b/locale/sw/LC_MESSAGES/djangojs.po @@ -1282,7 +1282,6 @@ msgid "Phone Number or Numeric ID" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js #: corehq/motech/static/motech/js/connection_settings_detail.js msgid "Password" msgstr "" @@ -1356,7 +1355,6 @@ msgid "Long" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Decimal" msgstr "" @@ -1647,7 +1645,6 @@ msgid "Form has validation errors." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Number" msgstr "" @@ -1991,6 +1988,7 @@ msgid "Lookup table was not found in the project" msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js #: corehq/apps/reports/static/reports/js/project_health_dashboard.js msgid "Loading..." msgstr "" @@ -2234,6 +2232,7 @@ msgid "A reference to an integer question in this form." msgstr "" #: corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +#: corehq/apps/geospatial/static/geospatial/js/models.js #: corehq/apps/userreports/static/userreports/js/data_source_select_model.js msgid "Case" msgstr "" @@ -2633,14 +2632,6 @@ msgstr "" msgid "Error evaluating expression." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Barcode" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Free response" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid whole number" msgstr "" @@ -2649,10 +2640,6 @@ msgstr "" msgid "Number is too large" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Phone number or Numeric ID" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid number" msgstr "" @@ -2661,22 +2648,10 @@ msgstr "" msgid "Please choose an item" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Combobox" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Not a valid choice" msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "12-hour clock" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "24-hour clock" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Invalid file type chosen. Please select a valid multimedia file." msgstr "" @@ -2687,22 +2662,6 @@ msgid "" "that is smaller than 4MB." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload image" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload audio file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Upload video file" -msgstr "" - -#: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js -msgid "Draw signature" -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/form_entry/entries.js msgid "Map layer not configured." msgstr "" @@ -2840,10 +2799,6 @@ msgid "" "continue to see this message." msgstr "" -#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/api.js -msgid "Switching project spaces..." -msgstr "" - #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/utils.js msgid "Fetching your location..." msgstr "" @@ -2852,6 +2807,14 @@ msgstr "" msgid "Please perform a search." msgstr "" +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Show Map" +msgstr "" + +#: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +msgid "Hide Map" +msgstr "" + #: corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js msgid "" "You have selected more than the maximum selection limit of <%= value %> . " @@ -3435,10 +3398,40 @@ msgstr "" msgid "Sensitive Date" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "No group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Select group" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "Group <%- groupCount %>" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js +msgid "" +"Something went wrong processing <%- failedClusters %> groups. These groups " +"will not be exported." +msgstr "" + #: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js msgid "Name of the Area" msgstr "" +#: corehq/apps/geospatial/static/geospatial/js/geospatial_map.js +msgid "All locations" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/gps_capture.js +msgid "current user" +msgstr "" + +#: corehq/apps/geospatial/static/geospatial/js/models.js +msgid "Mobile Worker" +msgstr "" + #: corehq/apps/groups/static/groups/js/group_members.js msgid "Edit Group Information" msgstr "" @@ -3534,6 +3527,15 @@ msgstr "" msgid "We could not turn off the new feature. Please try again later." msgstr "" +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap3/validators.ko.js +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Not a valid email" +msgstr "" + +#: corehq/apps/hqwebapp/static/hqwebapp/js/bootstrap5/validators.ko.js +msgid "Checking..." +msgstr "" + #: corehq/apps/hqwebapp/static/hqwebapp/js/components/inline_edit.js msgid "Error saving, please try again." msgstr "" @@ -3647,10 +3649,6 @@ msgstr "" msgid "Edit mapping for \"<%- property %>\"" msgstr "" -#: corehq/apps/hqwebapp/static/hqwebapp/js/validators.ko.js -msgid "Not a valid email" -msgstr "" - #: corehq/apps/integration/static/integration/js/dialer/connect-streams-min.js msgid "MultiSessionHangUp" msgstr "" diff --git a/migrations.lock b/migrations.lock index b07c59ae32844..493b6e4486e37 100644 --- a/migrations.lock +++ b/migrations.lock @@ -301,6 +301,7 @@ data_dictionary 0013_auto_20230529_1614 0014_auto_20230705_2007 0015_casetype_is_deprecated + 0016_remove_case_property_group_and_rename_group_obj_caseproperty_group data_interfaces 0001_initial 0002_remove_exists_option @@ -388,6 +389,7 @@ dropbox 0001_initial email 0001_initial + 0002_emailsettings_return_path_email enterprise 0001_initial 0002_enterprisepermissions_account_unique @@ -593,6 +595,7 @@ hqwebapp 0008_hqoauthapplication 0009_truncate_authtoken_table 0010_maintenancealert_scheduling + 0011_add_new_columns_and_rename_model integration 0001_initial 0002_dialersettings @@ -777,6 +780,8 @@ reminders repeaters 0001_adjust_auth_field_format_squashed_0015_drop_connection_settings_fk 0002_repeaters_db + 0003_id_fields + 0004_fix_whitelist_bug_repeaters reports 0001_initial 0002_auto_20171121_1803 @@ -1175,6 +1180,7 @@ users 0052_hqapikey_last_used 0053_userreportingmetadatastaging_fcm_token 0054_connectiduserlink + 0055_add_user_data util 0001_initial 0002_complaintbouncemeta_permanentbouncemeta_transientbounceemail diff --git a/package.json b/package.json index 9613ae1767d4d..21600b309a4ca 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ "Caret.js": "INTELOGIE/Caret.js#0.3.1", "DOMPurify": "npm:dompurify#2.3.6", "ace-builds": "1.5.0", - "autotrack": "2.4.1", "babel-plugin-transform-modules-requirejs-babel": "^0.1.0", "backbone": "npm:backbone#~1.3.2", "backbone.marionette": "4.1.3", "backbone.radio": "^2.0.0", + "blazy": "1.8.2", "bootstrap": "3.4.1", "bootstrap-daterangepicker": "3.0.3", "bootstrap-switch": "3.3.2", @@ -25,18 +25,14 @@ "bootstrap5": "npm:bootstrap@5.3.1", "calendars": "kbwood/calendars#2.1.2", "clipboard": "1.5.15", - "crypto-js": "4.0.0", + "crypto-js": "4.2.0", "css-grid-polyfill-binaries": "FremyCompany/css-grid-polyfill-binaries#1.1.2", "d3": "3.5.17", - "datamaps": "0.5.9", "datatables-bootstrap3": "Jowin/Datatables-Bootstrap3", - "datatables-buttons": "DataTables/Buttons#^1.5.6", - "datatables-colreorder": "DataTables/ColReorder#^1.5.1", "datatables-fixedcolumns": "DataTables/FixedColumns#3.2.0", - "datatables-fixedheader": "DataTables/FixedHeader#^3.1.3", - "datatables-scroller": "DataTables/Scroller#^1.5.1", "datatables.net": "1.11.3", - "datatables.net-dt": "1.11.3", + "datatables.net-bs5": "1.13.8", + "datatables.net-fixedcolumns-bs5": "4.3.0", "detectrtc": "1.4.0", "eonasdan-bootstrap-datetimepicker": "4.17.49", "fast-levenshtein": "2.0.6", @@ -73,7 +69,6 @@ "nprogress": "0.2.0", "nvd3": "1.1.10", "nvd3-1.8.6": "npm:nvd3#1.8.6", - "perfect-scrollbar": "1.5.0", "quicksearch": "DeuxHuitHuit/quicksearch#2.2.1", "requirejs": "2.3.6", "requirejs-babel7": "^1.3.2", diff --git a/requirements/base-requirements.in b/requirements/base-requirements.in index c7cf877d40f78..e39005bc18a78 100644 --- a/requirements/base-requirements.in +++ b/requirements/base-requirements.in @@ -67,7 +67,7 @@ looseversion lxml markdown oic -ortools # Used in Geospatial features to solve routing problems - SolTech +pulp # Used in Geospatial features to solve routing problems - SolTech openpyxl packaging phonenumberslite diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index 8766468f93556..a9f8cc3a95c91 100644 --- a/requirements/dev-requirements.txt +++ b/requirements/dev-requirements.txt @@ -4,8 +4,6 @@ # # `make requirements` or `make upgrade-requirements` # -absl-py==1.4.0 - # via ortools alabaster==0.7.12 # via sphinx alembic==1.7.7 @@ -97,7 +95,7 @@ coverage==7.1.0 # via -r test-requirements.in crispy-bootstrap3to5 @ git+https://github.com/dimagi/crispy-bootstrap3to5.git@775b93b8cd8e5312cab4a367409a95b66ce18165 # via -r base-requirements.in -cryptography==41.0.4 +cryptography==41.0.6 # via # -r sso-requirements.in # jwcrypto @@ -133,7 +131,7 @@ diff-match-patch==20230430 # via -r base-requirements.in dimagi-memoized==1.1.3 # via -r base-requirements.in -django==3.2.20 +django==3.2.23 # via # -r base-requirements.in # crispy-bootstrap3to5 @@ -249,7 +247,7 @@ executing==2.0.0 # via stack-data fakecouch==0.0.15 # via -r test-requirements.in -faker==18.6.1 +faker==19.12.0 # via -r test-requirements.in firebase-admin==6.1.0 # via -r base-requirements.in @@ -349,7 +347,7 @@ importlib-metadata==6.0.0 # sphinx ipython==8.10.0 # via -r dev-requirements.in -iso8601==1.1.0 +iso8601==2.0.0 # via -r base-requirements.in isodate==0.6.1 # via python3-saml @@ -434,8 +432,6 @@ nose==1.3.7 # sniffer nose-exclude==0.5.0 # via -r test-requirements.in -numpy==1.24.3 - # via ortools oauthlib==3.1.0 # via # django-oauth-toolkit @@ -448,8 +444,6 @@ openpyxl==3.0.9 # commcaretranslationchecker opentelemetry-api==1.18.0 # via ddtrace -ortools==9.7.2996 - # via -r base-requirements.in packaging==23.0 # via # -r base-requirements.in @@ -499,7 +493,6 @@ protobuf==4.24.2 # google-cloud-firestore # googleapis-common-protos # grpcio-status - # ortools # proto-plus psutil==5.8.0 # via -r dev-requirements.in @@ -511,6 +504,8 @@ psycopg2==2.9.6 # sqlalchemy-postgres-copy ptyprocess==0.7.0 # via pexpect +pulp==2.7.0 + # via -r base-requirements.in pure-eval==0.2.2 # via stack-data py-kissmetrics==1.1.0 @@ -800,7 +795,7 @@ vine==5.0.0 # kombu wcwidth==0.2.5 # via prompt-toolkit -werkzeug==2.2.3 +werkzeug==3.0.1 # via -r base-requirements.in wheel==0.38.1 # via @@ -820,13 +815,13 @@ yapf==0.31.0 # via -r dev-requirements.in zipp==3.7.0 # via importlib-metadata -zope-event==4.5.0 +zope-event==5.0 # via gevent -zope-interface==5.4.0 +zope-interface==6.1 # via gevent # The following packages are considered to be unsafe in a requirements file: -pip==23.0.1 +pip==23.3 # via pip-tools setuptools==67.3.2 # via diff --git a/requirements/docs-requirements.txt b/requirements/docs-requirements.txt index 61824bd0c9966..f4385b992ed79 100644 --- a/requirements/docs-requirements.txt +++ b/requirements/docs-requirements.txt @@ -4,8 +4,6 @@ # # `make requirements` or `make upgrade-requirements` # -absl-py==1.4.0 - # via ortools alabaster==0.7.12 # via sphinx alembic==1.7.7 @@ -79,7 +77,7 @@ contextlib2==21.6.0 # via schema crispy-bootstrap3to5 @ git+https://github.com/dimagi/crispy-bootstrap3to5.git@775b93b8cd8e5312cab4a367409a95b66ce18165 # via -r base-requirements.in -cryptography==41.0.4 +cryptography==41.0.6 # via # jwcrypto # oic @@ -111,7 +109,7 @@ diff-match-patch==20230430 # via -r base-requirements.in dimagi-memoized==1.1.3 # via -r base-requirements.in -django==3.2.20 +django==3.2.23 # via # -r base-requirements.in # crispy-bootstrap3to5 @@ -302,7 +300,7 @@ importlib-metadata==6.0.0 # markdown # opentelemetry-api # sphinx -iso8601==1.1.0 +iso8601==2.0.0 # via -r base-requirements.in jinja2==3.1.2 # via @@ -364,8 +362,6 @@ msgpack==1.0.5 # via cachecontrol myst-parser==2.0.0 # via -r docs-requirements.in -numpy==1.24.3 - # via ortools oauthlib==3.1.0 # via # django-oauth-toolkit @@ -378,8 +374,6 @@ openpyxl==3.0.9 # commcaretranslationchecker opentelemetry-api==1.18.0 # via ddtrace -ortools==9.7.2996 - # via -r base-requirements.in packaging==23.0 # via # -r base-requirements.in @@ -416,12 +410,13 @@ protobuf==4.24.2 # google-cloud-firestore # googleapis-common-protos # grpcio-status - # ortools # proto-plus psycogreen==1.0.2 # via -r base-requirements.in psycopg2==2.9.6 # via -r base-requirements.in +pulp==2.7.0 + # via -r base-requirements.in py-kissmetrics==1.1.0 # via -r base-requirements.in pyasn1==0.4.8 @@ -656,7 +651,7 @@ vine==5.0.0 # kombu wcwidth==0.2.5 # via prompt-toolkit -werkzeug==2.2.3 +werkzeug==3.0.1 # via -r base-requirements.in wrapt==1.12.1 # via deprecated @@ -668,9 +663,9 @@ xmltodict==0.13.0 # via ddtrace zipp==3.7.0 # via importlib-metadata -zope-event==4.5.0 +zope-event==5.0 # via gevent -zope-interface==5.4.0 +zope-interface==6.1 # via gevent # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/prod-requirements.txt b/requirements/prod-requirements.txt index 9230e4c58003a..af1cb00606dbb 100644 --- a/requirements/prod-requirements.txt +++ b/requirements/prod-requirements.txt @@ -4,8 +4,6 @@ # # `make requirements` or `make upgrade-requirements` # -absl-py==1.4.0 - # via ortools alembic==1.7.7 # via -r base-requirements.in amqp==5.1.1 @@ -81,7 +79,7 @@ contextlib2==21.6.0 # via schema crispy-bootstrap3to5 @ git+https://github.com/dimagi/crispy-bootstrap3to5.git@775b93b8cd8e5312cab4a367409a95b66ce18165 # via -r base-requirements.in -cryptography==41.0.4 +cryptography==41.0.6 # via # -r prod-requirements.in # -r sso-requirements.in @@ -116,7 +114,7 @@ diff-match-patch==20230430 # via -r base-requirements.in dimagi-memoized==1.1.3 # via -r base-requirements.in -django==3.2.20 +django==3.2.23 # via # -r base-requirements.in # crispy-bootstrap3to5 @@ -305,7 +303,7 @@ importlib-metadata==6.0.0 # opentelemetry-api ipython==8.10.0 # via -r prod-requirements.in -iso8601==1.1.0 +iso8601==2.0.0 # via -r base-requirements.in isodate==0.6.1 # via python3-saml @@ -364,8 +362,6 @@ msgpack==1.0.5 # via cachecontrol ndg-httpsclient==0.5.1 # via -r prod-requirements.in -numpy==1.24.3 - # via ortools oauthlib==3.1.0 # via # django-oauth-toolkit @@ -378,8 +374,6 @@ openpyxl==3.0.9 # commcaretranslationchecker opentelemetry-api==1.18.0 # via ddtrace -ortools==9.7.2996 - # via -r base-requirements.in packaging==23.0 # via # -r base-requirements.in @@ -423,7 +417,6 @@ protobuf==4.24.2 # google-cloud-firestore # googleapis-common-protos # grpcio-status - # ortools # proto-plus psycogreen==1.0.2 # via -r base-requirements.in @@ -431,6 +424,8 @@ psycopg2==2.9.6 # via -r base-requirements.in ptyprocess==0.7.0 # via pexpect +pulp==2.7.0 + # via -r base-requirements.in pure-eval==0.2.2 # via stack-data py-kissmetrics==1.1.0 @@ -657,7 +652,7 @@ vine==5.0.0 # kombu wcwidth==0.2.5 # via prompt-toolkit -werkzeug==2.2.3 +werkzeug==3.0.1 # via -r base-requirements.in wrapt==1.12.1 # via deprecated @@ -671,13 +666,13 @@ xmltodict==0.13.0 # via ddtrace zipp==3.7.0 # via importlib-metadata -zope-event==4.5.0 +zope-event==5.0 # via gevent -zope-interface==5.4.0 +zope-interface==6.1 # via gevent # The following packages are considered to be unsafe in a requirements file: -pip==23.0.1 +pip==23.3 # via -r prod-requirements.in setuptools==67.3.2 # via diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 2cbf722d47c7f..fef42e6bf5a23 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -4,8 +4,6 @@ # # `make requirements` or `make upgrade-requirements` # -absl-py==1.4.0 - # via ortools alembic==1.7.7 # via -r base-requirements.in amqp==5.1.1 @@ -75,7 +73,7 @@ contextlib2==21.6.0 # via schema crispy-bootstrap3to5 @ git+https://github.com/dimagi/crispy-bootstrap3to5.git@775b93b8cd8e5312cab4a367409a95b66ce18165 # via -r base-requirements.in -cryptography==41.0.4 +cryptography==41.0.6 # via # -r sso-requirements.in # jwcrypto @@ -108,7 +106,7 @@ diff-match-patch==20230430 # via -r base-requirements.in dimagi-memoized==1.1.3 # via -r base-requirements.in -django==3.2.20 +django==3.2.23 # via # -r base-requirements.in # crispy-bootstrap3to5 @@ -287,7 +285,7 @@ importlib-metadata==6.0.0 # via # markdown # opentelemetry-api -iso8601==1.1.0 +iso8601==2.0.0 # via -r base-requirements.in isodate==0.6.1 # via python3-saml @@ -340,8 +338,6 @@ markupsafe==2.1.2 # werkzeug msgpack==1.0.5 # via cachecontrol -numpy==1.24.3 - # via ortools oauthlib==3.1.0 # via # django-oauth-toolkit @@ -354,8 +350,6 @@ openpyxl==3.0.9 # commcaretranslationchecker opentelemetry-api==1.18.0 # via ddtrace -ortools==9.7.2996 - # via -r base-requirements.in packaging==23.0 # via # -r base-requirements.in @@ -389,12 +383,13 @@ protobuf==4.24.2 # google-cloud-firestore # googleapis-common-protos # grpcio-status - # ortools # proto-plus psycogreen==1.0.2 # via -r base-requirements.in psycopg2==2.9.6 # via -r base-requirements.in +pulp==2.7.0 + # via -r base-requirements.in py-kissmetrics==1.1.0 # via -r base-requirements.in pyasn1==0.4.8 @@ -598,7 +593,7 @@ vine==5.0.0 # kombu wcwidth==0.2.5 # via prompt-toolkit -werkzeug==2.2.3 +werkzeug==3.0.1 # via -r base-requirements.in wrapt==1.12.1 # via deprecated @@ -612,9 +607,9 @@ xmltodict==0.13.0 # via ddtrace zipp==3.7.0 # via importlib-metadata -zope-event==4.5.0 +zope-event==5.0 # via gevent -zope-interface==5.4.0 +zope-interface==6.1 # via gevent # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index fd8295ee0fefe..3c342ae6f823b 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -4,8 +4,6 @@ # # `make requirements` or `make upgrade-requirements` # -absl-py==1.4.0 - # via ortools alembic==1.7.7 # via -r base-requirements.in amqp==5.1.1 @@ -84,7 +82,7 @@ coverage==7.1.0 # via -r test-requirements.in crispy-bootstrap3to5 @ git+https://github.com/dimagi/crispy-bootstrap3to5.git@775b93b8cd8e5312cab4a367409a95b66ce18165 # via -r base-requirements.in -cryptography==41.0.4 +cryptography==41.0.6 # via # -r sso-requirements.in # jwcrypto @@ -117,7 +115,7 @@ diff-match-patch==20230430 # via -r base-requirements.in dimagi-memoized==1.1.3 # via -r base-requirements.in -django==3.2.20 +django==3.2.23 # via # -r base-requirements.in # crispy-bootstrap3to5 @@ -220,7 +218,7 @@ exceptiongroup==1.1.1 # via cattrs fakecouch==0.0.15 # via -r test-requirements.in -faker==18.6.1 +faker==19.12.0 # via -r test-requirements.in firebase-admin==6.1.0 # via -r base-requirements.in @@ -306,7 +304,7 @@ importlib-metadata==6.0.0 # via # markdown # opentelemetry-api -iso8601==1.1.0 +iso8601==2.0.0 # via -r base-requirements.in isodate==0.6.1 # via python3-saml @@ -368,8 +366,6 @@ nose==1.3.7 # nose-exclude nose-exclude==0.5.0 # via -r test-requirements.in -numpy==1.24.3 - # via ortools oauthlib==3.1.0 # via # django-oauth-toolkit @@ -382,8 +378,6 @@ openpyxl==3.0.9 # commcaretranslationchecker opentelemetry-api==1.18.0 # via ddtrace -ortools==9.7.2996 - # via -r base-requirements.in packaging==23.0 # via # -r base-requirements.in @@ -422,7 +416,6 @@ protobuf==4.24.2 # google-cloud-firestore # googleapis-common-protos # grpcio-status - # ortools # proto-plus psycogreen==1.0.2 # via -r base-requirements.in @@ -430,6 +423,8 @@ psycopg2==2.9.6 # via # -r base-requirements.in # sqlalchemy-postgres-copy +pulp==2.7.0 + # via -r base-requirements.in py-kissmetrics==1.1.0 # via -r base-requirements.in pyasn1==0.4.8 @@ -658,7 +653,7 @@ vine==5.0.0 # kombu wcwidth==0.2.5 # via prompt-toolkit -werkzeug==2.2.3 +werkzeug==3.0.1 # via -r base-requirements.in wheel==0.38.1 # via pip-tools @@ -674,13 +669,13 @@ xmltodict==0.13.0 # via ddtrace zipp==3.7.0 # via importlib-metadata -zope-event==4.5.0 +zope-event==5.0 # via gevent -zope-interface==5.4.0 +zope-interface==6.1 # via gevent # The following packages are considered to be unsafe in a requirements file: -pip==23.0.1 +pip==23.3 # via pip-tools setuptools==67.3.2 # via diff --git a/scripts/docker b/scripts/docker index 4f77094c92952..728bc77895c1e 100755 --- a/scripts/docker +++ b/scripts/docker @@ -239,6 +239,7 @@ export JS_SETUP="${JS_SETUP:-no}" export JS_TEST_EXTENSIONS="$JS_TEST_EXTENSIONS" export NOSE_DIVIDED_WE_RUN="$NOSE_DIVIDED_WE_RUN" export REUSE_DB="$REUSE_DB" +export STRIPE_PRIVATE_KEY="$STRIPE_PRIVATE_KEY" export TRAVIS="$TRAVIS" export TRAVIS_BRANCH="$TRAVIS_BRANCH" export TRAVIS_BUILD_ID="$TRAVIS_BUILD_ID" diff --git a/settings.py b/settings.py index 6ee3cd5817d52..dc0a57794576f 100755 --- a/settings.py +++ b/settings.py @@ -860,14 +860,14 @@ # The variables should be used while reindexing an index. # When the variables are set to true the data will be written to both primary and secondary indexes. -ES_APPS_INDEX_MULTIPLEXED = False -ES_CASE_SEARCH_INDEX_MULTIPLEXED = False -ES_CASES_INDEX_MULTIPLEXED = False -ES_DOMAINS_INDEX_MULTIPLEXED = False -ES_FORMS_INDEX_MULTIPLEXED = False -ES_GROUPS_INDEX_MULTIPLEXED = False -ES_SMS_INDEX_MULTIPLEXED = False -ES_USERS_INDEX_MULTIPLEXED = False +ES_APPS_INDEX_MULTIPLEXED = True +ES_CASE_SEARCH_INDEX_MULTIPLEXED = True +ES_CASES_INDEX_MULTIPLEXED = True +ES_DOMAINS_INDEX_MULTIPLEXED = True +ES_FORMS_INDEX_MULTIPLEXED = True +ES_GROUPS_INDEX_MULTIPLEXED = True +ES_SMS_INDEX_MULTIPLEXED = True +ES_USERS_INDEX_MULTIPLEXED = True # Setting the variable to True would mean that the primary index would become secondary and vice-versa @@ -1042,6 +1042,8 @@ def _pkce_required(client_id): "default": "CommCare", } +ALLOW_MAKE_SUPERUSER_COMMAND = True + ENTERPRISE_MODE = False RESTRICT_DOMAIN_CREATION = False @@ -1151,6 +1153,18 @@ def _pkce_required(client_id): CONNECTID_USERINFO_URL = 'http://localhost:8080/o/userinfo' +MAX_MOBILE_UCR_LIMIT = 300 # used in corehq.apps.cloudcare.util.should_restrict_web_apps_usage + +# used by periodic tasks that delete soft deleted data older than PERMANENT_DELETION_WINDOW days +PERMANENT_DELETION_WINDOW = 90 # days + +# GSheets related work that was dropped, but should be picked up in the near future +GOOGLE_OATH_CONFIG = {} +GOOGLE_OAUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets'] +GOOGLE_SHEETS_API_NAME = "sheets" +GOOGLE_SHEETS_API_VERSION = "v4" +DAYS_KEEP_GSHEET_STATUS = 14 + try: # try to see if there's an environmental variable set for local_settings custom_settings = os.environ.get('CUSTOMSETTINGS', None) @@ -1230,8 +1244,6 @@ def _pkce_required(client_id): IS_SAAS_ENVIRONMENT = SERVER_ENVIRONMENT in ('production', 'staging') -ALLOW_MAKE_SUPERUSER_COMMAND = True - if 'KAFKA_URL' in globals(): import warnings warnings.warn(inspect.cleandoc("""KAFKA_URL is deprecated @@ -2079,15 +2091,5 @@ def _pkce_required(client_id): SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" -# Config settings for the google oauth handshake to get a user token -# Google Cloud Platform secret settings config file -GOOGLE_OATH_CONFIG = {} -# Scopes to give read/write access to the code that generates the spreadsheets -GOOGLE_OAUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets'] - -GOOGLE_SHEETS_API_NAME = "sheets" -GOOGLE_SHEETS_API_VERSION = "v4" - -DAYS_KEEP_GSHEET_STATUS = 14 - -PERMANENT_DELETION_WINDOW = 90 # days +# NOTE: if you are adding a new setting that you intend to have other environments override, +# make sure you add it before localsettings are imported (from localsettings import *) diff --git a/testapps/test_pillowtop/tests/test_grouptouser_pillow.py b/testapps/test_pillowtop/tests/test_grouptouser_pillow.py index d0386ed43049a..f428baaadbe15 100644 --- a/testapps/test_pillowtop/tests/test_grouptouser_pillow.py +++ b/testapps/test_pillowtop/tests/test_grouptouser_pillow.py @@ -16,6 +16,7 @@ reindex_and_clean, ) from corehq.apps.users.models import CommCareUser +from corehq.apps.users.tests.util import patch_user_data_db_layer from corehq.pillows.groups_to_user import ( get_group_pillow, remove_group_from_users, @@ -133,7 +134,8 @@ def _create_es_user(user_id, domain): last_name='Casual', is_active=True, ) - user_adapter.index(user, refresh=True) + with patch_user_data_db_layer: + user_adapter.index(user, refresh=True) return user diff --git a/testsettings.py b/testsettings.py index 1533c2e8a6e6d..0cdcfcf476ed6 100644 --- a/testsettings.py +++ b/testsettings.py @@ -18,6 +18,27 @@ ES_FOR_TEST_INDEX_SWAPPED = False +# Set multiplexed settings to false for test runs + +ES_APPS_INDEX_MULTIPLEXED = False +ES_CASE_SEARCH_INDEX_MULTIPLEXED = False +ES_CASES_INDEX_MULTIPLEXED = False +ES_DOMAINS_INDEX_MULTIPLEXED = False +ES_FORMS_INDEX_MULTIPLEXED = False +ES_GROUPS_INDEX_MULTIPLEXED = False +ES_SMS_INDEX_MULTIPLEXED = False +ES_USERS_INDEX_MULTIPLEXED = False + + +ES_APPS_INDEX_SWAPPED = False +ES_CASE_SEARCH_INDEX_SWAPPED = False +ES_CASES_INDEX_SWAPPED = False +ES_DOMAINS_INDEX_SWAPPED = False +ES_FORMS_INDEX_SWAPPED = False +ES_GROUPS_INDEX_SWAPPED = False +ES_SMS_INDEX_SWAPPED = False +ES_USERS_INDEX_SWAPPED = False + # note: the only reason these are prepended to INSTALLED_APPS is because of # a weird travis issue with kafka. if for any reason this order causes problems # it can be reverted whenever that's figured out. @@ -139,3 +160,6 @@ def _set_logging_levels(levels): # A workaround to test the messaging framework. See: https://stackoverflow.com/a/60218100 MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' + +if os.environ.get("STRIPE_PRIVATE_KEY"): + STRIPE_PRIVATE_KEY = os.environ.get("STRIPE_PRIVATE_KEY") diff --git a/yarn.lock b/yarn.lock index 4f1753db4d5fe..3849474ca2bee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -391,11 +391,6 @@ "@types/node" "*" "@types/responselike" "*" -"@types/d3@3.5.38": - version "3.5.38" - resolved "https://registry.yarnpkg.com/@types/d3/-/d3-3.5.38.tgz#76f8f2e9159ae562965b2fa0e6fbee1aa643a1bc" - integrity sha1-dvjy6RWa5WKWWy+g5vvuGqZDobw= - "@types/http-cache-semantics@*": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" @@ -477,11 +472,6 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" - integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== - acorn@^8.7.1: version "8.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" @@ -531,26 +521,16 @@ ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -amdefine@>=0.0.4: - version "1.0.1" - resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" - integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= - ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== -ansi-regex@^2.0.0, ansi-regex@^5.0.0, ansi-regex@^5.0.1: +ansi-regex@^5.0.0, ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= - ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -667,22 +647,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= -autotrack@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/autotrack/-/autotrack-2.4.1.tgz#ccbf010e3d95ef23c8dd6db4e8df025135c82ee6" - integrity sha512-79GgyClNc1U+iqbrKLaB/kk8lvGcvpmt8pJL7SfkJx/LF47x6TU/NquBhzXc1AtOFi4X14fa3Qxjlk6K6Om7dQ== - dependencies: - chalk "^1.1.3" - dom-utils "^0.9.0" - fs-extra "^3.0.1" - glob "^7.1.1" - google-closure-compiler-js "^20170423.0.0" - gzip-size "^3.0.0" - rollup "^0.41.4" - rollup-plugin-memory "^2.0.0" - rollup-plugin-node-resolve "^3.0.0" - source-map "^0.5.6" - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -777,6 +741,11 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" +blazy@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/blazy/-/blazy-1.8.2.tgz#50dfd638baaf9003efd6eb3a836aca54184ab6da" + integrity sha1-UN/WOLqvkAPv1us6g2rKVBhKtto= + body@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" @@ -836,16 +805,6 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -brfs@^1.3.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/brfs/-/brfs-1.6.1.tgz#b78ce2336d818e25eea04a0947cba6d4fb8849c3" - integrity sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ== - dependencies: - quote-stream "^1.0.1" - resolve "^1.1.5" - static-module "^2.2.0" - through2 "^2.0.0" - browser-stdout@1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" @@ -856,11 +815,6 @@ buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= -buffer-equal@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" - integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs= - buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -874,11 +828,6 @@ buffer@^5.2.1, buffer@^5.5.0: base64-js "^1.0.2" ieee754 "^1.1.4" -builtin-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-2.0.0.tgz#60b7ef5ae6546bd7deefa74b08b62a43a232648e" - integrity sha512-3U5kUA5VPsRUA3nofm/BXX7GVHKfxz0hOBAPxXrIvHzlDRkQVqEn6yi8QJegxl4LzOHLdvb7XF5dVawa/VVYBg== - builtins@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" @@ -1025,17 +974,6 @@ chalk@2.4.2, chalk@^2.0.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@~4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -1130,11 +1068,6 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" -clone-buffer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" - integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg= - clone-response@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" @@ -1142,30 +1075,16 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" -clone-stats@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" - integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= -clone@^2.1.1, clone@^2.1.2: +clone@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= -cloneable-readable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec" - integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ== - dependencies: - inherits "^2.0.1" - process-nextick-args "^2.0.0" - readable-stream "^2.3.5" - cmd-shim@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-5.0.0.tgz#8d0aaa1a6b0708630694c4dbde070ed94c707724" @@ -1255,7 +1174,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.6.2, concat-stream@~1.6.0: +concat-stream@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -1275,13 +1194,6 @@ continuable-cache@^0.3.1: resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" integrity sha1-vXJ6f67XfnH/OYWskzUakSczrQ8= -convert-source-map@^1.5.1: - version "1.7.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" - integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== - dependencies: - safe-buffer "~5.1.1" - core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -1303,10 +1215,10 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.0.0.tgz#2904ab2677a9d042856a2ea2ef80de92e4a36dcc" - integrity sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg== +crypto-js@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== css-grid-polyfill-binaries@FremyCompany/css-grid-polyfill-binaries#1.1.2: version "0.0.0" @@ -1317,24 +1229,7 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -d3-geo-projection@0.2: - version "0.2.16" - resolved "https://registry.yarnpkg.com/d3-geo-projection/-/d3-geo-projection-0.2.16.tgz#4994ecd1033ddb1533b6c4c5528a1c81dcc29427" - integrity sha1-SZTs0QM92xUztsTFUoocgdzClCc= - dependencies: - brfs "^1.3.0" - -d3-queue@1: - version "1.2.3" - resolved "https://registry.yarnpkg.com/d3-queue/-/d3-queue-1.2.3.tgz#143a701cfa65fe021292f321c10d14e98abd491b" - integrity sha1-FDpwHPpl/gISkvMhwQ0U6Yq9SRs= - -d3-queue@2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/d3-queue/-/d3-queue-2.0.3.tgz#07fbda3acae5358a9c5299aaf880adf0953ed2c2" - integrity sha1-B/vaOsrlNYqcUpmq+ICt8JU+0sI= - -d3@3, d3@3.5.17, d3@^3.5.6: +d3@3.5.17: version "3.5.17" resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" integrity sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g= @@ -1346,54 +1241,53 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -datamaps@0.5.9: - version "0.5.9" - resolved "https://registry.yarnpkg.com/datamaps/-/datamaps-0.5.9.tgz#2a775473aaab29b55025208b2245e840ecfd4fe1" - integrity sha512-GUXpO713URNzaExVUgBtqA5fr2UuxUG/fVitI04zEFHVL2FHSjd672alHq8E16oQqRNzF0m1bmx8WlTnDrGSqQ== - dependencies: - "@types/d3" "3.5.38" - d3 "^3.5.6" - topojson "^1.6.19" - datatables-bootstrap3@Jowin/Datatables-Bootstrap3: version "0.0.0" resolved "https://codeload.github.com/Jowin/Datatables-Bootstrap3/tar.gz/45802ec4d337bdba6750f03b6472ddbb1d34608c" -datatables-buttons@DataTables/Buttons#^1.5.6: - version "0.0.0" - resolved "https://codeload.github.com/DataTables/Buttons/tar.gz/5e51cec85d3b3ffc467c8d213592927a590a80ae" - -datatables-colreorder@DataTables/ColReorder#^1.5.1: - version "0.0.0" - resolved "https://codeload.github.com/DataTables/ColReorder/tar.gz/f0e558a1f38919fa95d82700aeea7ce3ca6fbfd5" - datatables-fixedcolumns@DataTables/FixedColumns#3.2.0: version "0.0.0" resolved "https://codeload.github.com/DataTables/FixedColumns/tar.gz/a87428accba62d881e14fa2710bcc035a6efbbb6" -datatables-fixedheader@DataTables/FixedHeader#^3.1.3: - version "0.0.0" - resolved "https://codeload.github.com/DataTables/FixedHeader/tar.gz/314b2ee32502c1ecc82c7a37e7bfa0f1eea92a23" +datatables.net-bs5@1.13.8, datatables.net-bs5@>=1.13.4: + version "1.13.8" + resolved "https://registry.yarnpkg.com/datatables.net-bs5/-/datatables.net-bs5-1.13.8.tgz#807dca4b95c139fe217ed87bd25f3502b1d873d3" + integrity sha512-3B6S8LiKGtUtOsA97SkMddwggrza6JDtubnw1qjFb/mjqDmWO0PC1+QWeUspkLPFQCCbLaSVfXLWMdo44IGEmQ== + dependencies: + datatables.net "1.13.8" + jquery ">=1.7" -datatables-scroller@DataTables/Scroller#^1.5.1: - version "0.0.0" - resolved "https://codeload.github.com/DataTables/Scroller/tar.gz/7dc5c7a56c7797677b224cff4be0a3a18604cc75" +datatables.net-fixedcolumns-bs5@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedcolumns-bs5/-/datatables.net-fixedcolumns-bs5-4.3.0.tgz#2a3299d06ec00dbfe1545709cc48cc16a7cbac51" + integrity sha512-DvBRTfFlvZAqUErXYgkQLcF70sL5zBSJNsaWLF3in++sYHZDfY3fdVqHu0NQMTE0QuwmYh61AgJUrtWLrp7zwQ== + dependencies: + datatables.net-bs5 ">=1.13.4" + datatables.net-fixedcolumns ">=4.2.2" + jquery ">=1.7" -datatables.net-dt@1.11.3: - version "1.11.3" - resolved "https://registry.yarnpkg.com/datatables.net-dt/-/datatables.net-dt-1.11.3.tgz#242556a490585b457b7d2b9f5fd8fb10761d621b" - integrity sha512-EX/thRwXpQRj8hZSb+ZMDNQ4uW1zLZa9BoAhhw1b5HIDH1nJ9WRTkERsoxE+3WISeX8bDiaEydf8TTQBSqxXVw== +datatables.net-fixedcolumns@>=4.2.2: + version "4.3.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedcolumns/-/datatables.net-fixedcolumns-4.3.0.tgz#1c5a0b13d56db46b0f53563f2150fabfcb7ebbd9" + integrity sha512-H2otCswJDHufI4A8k7HUDj25HCB3a44KFnBlYEwYFWdrJayLcYB3I79kBjS8rSCu4rFEp0I9nVLKvWgKlZZgCQ== dependencies: - datatables.net ">=1.10.25" + datatables.net ">=1.13.4" jquery ">=1.7" -datatables.net@1.11.3, datatables.net@>=1.10.25: +datatables.net@1.11.3: version "1.11.3" resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.11.3.tgz#80e691036efcd62467558ee64c07dd566cb761b4" integrity sha512-VMj5qEaTebpNurySkM6jy6sGpl+s6onPK8xJhYr296R/vUBnz1+id16NVqNf9z5aR076OGcpGHCuiTuy4E05oQ== dependencies: jquery ">=1.7" +datatables.net@1.13.8, datatables.net@>=1.13.4: + version "1.13.8" + resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.8.tgz#05a2fb5a036b0b65b66d1bb1eae0ba018aaea8a3" + integrity sha512-2pDamr+GUwPTby2OgriVB9dR9ftFKD2AQyiuCXzZIiG4d9KkKFQ7gqPfNmG7uj9Tc5kDf+rGj86do4LAb/V71g== + dependencies: + jquery ">=1.7" + dateformat@~3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" @@ -1448,11 +1342,6 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= - defaults@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" @@ -1530,11 +1419,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-utils@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/dom-utils/-/dom-utils-0.9.0.tgz#e615a5af15ac4505e55ef612c72b5b5d176121f3" - integrity sha1-5hWlrxWsRQXlXvYSxytbXRdhIfM= - dot-prop@^5.1.1: version "5.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" @@ -1542,18 +1426,6 @@ dot-prop@^5.1.1: dependencies: is-obj "^2.0.0" -duplexer2@~0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= - dependencies: - readable-stream "^2.0.2" - -duplexer@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= - ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -1637,7 +1509,7 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: +escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= @@ -1647,30 +1519,6 @@ escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -escodegen@^1.11.1: - version "1.14.3" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" - integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== - dependencies: - esprima "^4.0.1" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -escodegen@~1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.1.tgz#dbae17ef96c8e4bedb1356f4504fa4cc2f7cb7e2" - integrity sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q== - dependencies: - esprima "^3.1.3" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - eslint-scope@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" @@ -1746,12 +1594,7 @@ espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" -esprima@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" - integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= - -esprima@^4.0.0, esprima@^4.0.1: +esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -1770,11 +1613,6 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - estraverse@^5.1.0, estraverse@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" @@ -1848,16 +1686,6 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -falafel@^2.1.0: - version "2.2.4" - resolved "https://registry.yarnpkg.com/falafel/-/falafel-2.2.4.tgz#b5d86c060c2412a43166243cb1bce44d1abd2819" - integrity sha512-0HXjo8XASWRmsS0X1EkhwEMZaD3Qvp7FfURwjLKjG1ghfRm/MGZl2r4cWUTv41KdNghTw4OUMmVtdGQp3+H+uQ== - dependencies: - acorn "^7.1.1" - foreach "^2.0.5" - isarray "^2.0.1" - object-keys "^1.0.6" - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -1868,7 +1696,7 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-levenshtein@2.0.6, fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: +fast-levenshtein@2.0.6, fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= @@ -1995,11 +1823,6 @@ for-own@^1.0.0: dependencies: for-in "^1.0.1" -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= - forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -2044,15 +1867,6 @@ fs-extra@^1.0.0: jsonfile "^2.1.0" klaw "^1.0.0" -fs-extra@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-3.0.1.tgz#3794f378c58b342ea7dbbb23095109c4b3b62291" - integrity sha1-N5TzeMWLNC6n27sjCVEJxLO2IpE= - dependencies: - graceful-fs "^4.1.2" - jsonfile "^3.0.0" - universalify "^0.1.0" - fs-minipass@^2.0.0, fs-minipass@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -2175,7 +1989,7 @@ glob@7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.2.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: +glob@7.2.0, glob@^7.1.3, glob@^7.1.4: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -2264,15 +2078,6 @@ good-listener@^1.2.0: dependencies: delegate "^3.1.2" -google-closure-compiler-js@^20170423.0.0: - version "20170423.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20170423.0.0.tgz#e9e8b40dadfdf0e64044c9479b5d26d228778fbc" - integrity sha1-6ei0Da398OZARMlHm10m0ih3j7w= - dependencies: - minimist "^1.2.0" - vinyl "^2.0.1" - webpack-core "^0.6.8" - got@^11.8.5: version "11.8.5" resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" @@ -2419,13 +2224,6 @@ grunt@^1.2.0, grunt@~0.4.1: nopt "~3.0.6" rimraf "~3.0.2" -gzip-size@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-3.0.0.tgz#546188e9bdc337f673772f81660464b389dce520" - integrity sha1-VGGI6b3DN/Zzdy+BZgRks4nc5SA= - dependencies: - duplexer "^0.1.1" - har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -2444,13 +2242,6 @@ hard-rejection@^2.1.0: resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= - dependencies: - ansi-regex "^2.0.0" - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -2471,7 +2262,7 @@ has-unicode@^2.0.1: resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= -has@^1.0.1, has@^1.0.3: +has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -2584,11 +2375,6 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -iconv-lite@0.2: - version "0.2.11" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.2.11.tgz#1ce60a3a57864a292d1321ff4609ca4bb965adc8" - integrity sha1-HOYKOleGSiktEyH/RgnKS7llrcg= - iconv-lite@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" @@ -2656,7 +2442,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2774,11 +2560,6 @@ is-lambda@^1.0.1: resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= -is-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= - is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -2850,11 +2631,6 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= -isarray@^2.0.1: - version "2.0.5" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -3017,13 +2793,6 @@ jsonfile@^2.1.0: optionalDependencies: graceful-fs "^4.1.6" -jsonfile@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66" - integrity sha1-pezG9l9T9mLEQVx2daAzHQmS7GY= - optionalDependencies: - graceful-fs "^4.1.6" - jsonparse@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" @@ -3132,14 +2901,6 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - libnpmaccess@^6.0.4: version "6.0.4" resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-6.0.4.tgz#2dd158bd8a071817e2207d3b201d37cf1ad6ae6b" @@ -3355,13 +3116,6 @@ lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.10.1.tgz#db577f42a94c168f676b638d15da8fb073448cab" integrity sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A== -magic-string@^0.22.4: - version "0.22.5" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" - integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w== - dependencies: - vlq "^0.2.2" - make-fetch-happen@^10.0.3: version "10.1.7" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.7.tgz#b1402cb3c9fad92b380ff3a863cdae5414a42f76" @@ -3499,13 +3253,6 @@ meow@^6.1.1: type-fest "^0.13.1" yargs-parser "^18.1.3" -merge-source-map@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.0.4.tgz#a5de46538dae84d4114cc5ea02b4772a6346701f" - integrity sha1-pd5GU42uhNQRTMXqArR3KmNGcB8= - dependencies: - source-map "^0.5.6" - micromatch@^4.0.2: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" @@ -3567,7 +3314,7 @@ minimist-options@^4.0.2: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@0.0.8, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@0.0.8, minimist@^1.2.5, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -4116,16 +3863,6 @@ object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== -object-inspect@~1.4.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.4.1.tgz#37ffb10e71adaf3748d05f713b4c9452f402cbc4" - integrity sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw== - -object-keys@^1.0.6: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - object.defaults@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" @@ -4163,25 +3900,6 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== -optimist@0.3: - version "0.3.7" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.3.7.tgz#c90941ad59e4273328923074d2cf2e7cbc6ec0d9" - integrity sha1-yQlBrVnkJzMokjB00s8ufLxuwNk= - dependencies: - wordwrap "~0.0.2" - -optionator@^0.8.1: - version "0.8.3" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" - integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.6" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - word-wrap "~1.2.3" - optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -4388,11 +4106,6 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= -perfect-scrollbar@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/perfect-scrollbar/-/perfect-scrollbar-1.5.0.tgz#821d224ed8ff61990c23f26db63048cdc75b6b83" - integrity sha512-NrNHJn5mUGupSiheBTy6x+6SXCFbLlm8fVZh9moIzw/LgqElN5q4ncR4pbCBCYuCJ8Kcl9mYM0NgDxvW+b4LxA== - performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -4455,17 +4168,12 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - proc-log@^2.0.0, proc-log@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-2.0.1.tgz#8f3f69a1f608de27878f91f5c688b225391cb685" integrity sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw== -process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: +process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== @@ -4596,15 +4304,6 @@ quicksearch@DeuxHuitHuit/quicksearch#2.2.1: dependencies: jquery ">=1.8" -quote-stream@^1.0.1, quote-stream@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/quote-stream/-/quote-stream-1.0.2.tgz#84963f8c9c26b942e153feeb53aae74652b7e0b2" - integrity sha1-hJY/jJwmuULhU/7rU6rnRlK34LI= - dependencies: - buffer-equal "0.0.1" - minimist "^1.1.3" - through2 "^2.0.0" - randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -4669,7 +4368,7 @@ read@1, read@^1.0.7, read@~1.0.7: dependencies: mute-stream "~0.0.4" -readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@~2.3.3, readable-stream@~2.3.6: +readable-stream@^2.2.2: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4728,16 +4427,6 @@ regexpp@^3.2.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -replace-ext@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a" - integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw== - request-progress@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-2.0.1.tgz#5d36bb57961c673aa5b788dbc8141fdf23b44e08" @@ -4814,7 +4503,7 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.1.5, resolve@^1.1.6, resolve@^1.10.0: +resolve@^1.10.0: version "1.17.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== @@ -4856,32 +4545,6 @@ rimraf@^2.5.2: dependencies: glob "^7.1.3" -rollup-plugin-memory@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-memory/-/rollup-plugin-memory-2.0.0.tgz#0a8ac6b57fa0e714f89a15c3ac82bc93f89c47c5" - integrity sha1-CorGtX+g5xT4mhXDrIK8k/icR8U= - -rollup-plugin-node-resolve@^3.0.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.4.0.tgz#908585eda12e393caac7498715a01e08606abc89" - integrity sha512-PJcd85dxfSBWih84ozRtBkB731OjXk0KnzN0oGp7WOWcarAFkVa71cV5hTJg2qpVsV2U8EUwrzHP3tvy9vS3qg== - dependencies: - builtin-modules "^2.0.0" - is-module "^1.0.0" - resolve "^1.1.6" - -rollup@^0.41.4: - version "0.41.6" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-0.41.6.tgz#e0d05497877a398c104d816d2733a718a7a94e2a" - integrity sha1-4NBUl4d6OYwQTYFtJzOnGKepTio= - dependencies: - source-map-support "^0.4.0" - -rw@1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" - integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q= - rw@~0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/rw/-/rw-0.1.4.tgz#4903cbd80248ae0ede685bf58fd236a7a9b29a3e" @@ -4954,20 +4617,6 @@ set-value@^4.0.1: is-plain-object "^2.0.4" is-primitive "^3.0.1" -shallow-copy@~0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" - integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= - -shapefile@0.3: - version "0.3.1" - resolved "https://registry.yarnpkg.com/shapefile/-/shapefile-0.3.1.tgz#9bb9a429bd6086a0cfb03962d14cfdf420ffba12" - integrity sha1-m7mkKb1ghqDPsDli0Uz99CD/uhI= - dependencies: - d3-queue "1" - iconv-lite "0.2" - optimist "0.3" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -5035,31 +4684,7 @@ socks@^2.6.2: ip "^1.1.5" smart-buffer "^4.2.0" -source-list-map@~0.1.7: - version "0.1.8" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106" - integrity sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY= - -source-map-support@^0.4.0: - version "0.4.18" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" - integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== - dependencies: - source-map "^0.5.6" - -source-map@^0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -source-map@~0.4.1: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - integrity sha1-66T12pwNyZneaAMti092FzZSA2s= - dependencies: - amdefine ">=0.0.4" - -source-map@~0.6.0, source-map@~0.6.1: +source-map@~0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -5122,33 +4747,6 @@ ssri@^9.0.0, ssri@^9.0.1: dependencies: minipass "^3.1.1" -static-eval@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.1.0.tgz#a16dbe54522d7fa5ef1389129d813fd47b148014" - integrity sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw== - dependencies: - escodegen "^1.11.1" - -static-module@^2.2.0: - version "2.2.5" - resolved "https://registry.yarnpkg.com/static-module/-/static-module-2.2.5.tgz#bd40abceae33da6b7afb84a0e4329ff8852bfbbf" - integrity sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ== - dependencies: - concat-stream "~1.6.0" - convert-source-map "^1.5.1" - duplexer2 "~0.1.4" - escodegen "~1.9.0" - falafel "^2.1.0" - has "^1.0.1" - magic-string "^0.22.4" - merge-source-map "1.0.4" - object-inspect "~1.4.0" - quote-stream "~1.0.2" - readable-stream "~2.3.3" - shallow-copy "~0.0.1" - static-eval "^2.0.0" - through2 "~2.0.3" - string-template@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" @@ -5191,13 +4789,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - strip-ansi@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" @@ -5251,11 +4842,6 @@ supports-color@8.1.1: dependencies: has-flag "^4.0.0" -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= - supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -5342,14 +4928,6 @@ throttleit@^1.0.0: resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= -through2@^2.0.0, through2@~2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" - integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== - dependencies: - readable-stream "~2.3.6" - xtend "~4.0.1" - through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -5422,18 +5000,6 @@ topojson@3.0.2: topojson-server "3.0.0" topojson-simplify "3.0.2" -topojson@^1.6.19: - version "1.6.27" - resolved "https://registry.yarnpkg.com/topojson/-/topojson-1.6.27.tgz#adbe33a67e2f1673d338df12644ad20fc20b42ed" - integrity sha1-rb4zpn4vFnPTON8SZErSD8ILQu0= - dependencies: - d3 "3" - d3-geo-projection "0.2" - d3-queue "2" - optimist "0.3" - rw "1" - shapefile "0.3" - tough-cookie@^4.1.3, tough-cookie@~2.5.0: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" @@ -5483,13 +5049,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - type-detect@^4.0.0, type-detect@^4.0.5: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -5589,11 +5148,6 @@ unique-slug@^3.0.0: dependencies: imurmurhash "^0.1.4" -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -5665,23 +5219,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vinyl@^2.0.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" - integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg== - dependencies: - clone "^2.1.1" - clone-buffer "^1.0.0" - clone-stats "^1.0.0" - cloneable-readable "^1.0.0" - remove-trailing-separator "^1.0.1" - replace-ext "^1.0.0" - -vlq@^0.2.2: - version "0.2.3" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" - integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== - walk-up-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/walk-up-path/-/walk-up-path-1.0.0.tgz#d4745e893dd5fd0dbb58dd0a4c6a33d9c9fec53e" @@ -5699,14 +5236,6 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= -webpack-core@^0.6.8: - version "0.6.9" - resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.9.tgz#fc571588c8558da77be9efb6debdc5a3b172bdc2" - integrity sha1-/FcViMhVjad76e+23r3Fo7FyvcI= - dependencies: - source-list-map "~0.1.7" - source-map "~0.4.1" - websocket-driver@>=0.5.1: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" @@ -5760,16 +5289,11 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -word-wrap@^1.2.3, word-wrap@~1.2.3: +word-wrap@^1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.5.tgz#383aeebd5c176c320d6364fb869669559bbdbac9" integrity sha512-plhoNEfSVdHMKXQyAxvH0Zyv3/4NL8r6pwgMQdmHR2vBUXn2t74PN2pBRppqKUa6RMT0yldyvOHG5Dbjwy2mBQ== -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= - workerpool@6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" @@ -5818,7 +5342,7 @@ xpath@dimagi/js-xpath#v0.0.7: biginteger "^1.0.3" yarn "1.22.10" -xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: +xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==