diff --git a/CHANGELOG.md b/CHANGELOG.md index 7294ef2d065..3959b300005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,61 @@ +v8.1.34 (2023-02-16) +------------------------- + * Drop raw_urns field on Broadcast + * Pass group id instead of uuid to contact_search mailroom endpoint + * Remove unused expression_migrate from mailroom client + +v8.1.33 (2023-02-15) +------------------------- + * Fix routing of current workspace to settings + * Add Broadcast.urns which matches the JSON and FlowStart.urns + +v8.1.32 (2023-02-14) +------------------------- + * Drop Broadcast.urns and .send_all + +v8.1.30 (2023-02-13) +------------------------- + * Fix keyword triggers match type + +v8.1.29 (2023-02-13) +------------------------- + * Fix omnibox search for anon org to allow search by contact name + * Prepare to drop Broadcast.send_all and .urns + +v8.1.27 (2023-02-10) +------------------------- + * Move all form text from Trigger model to forms + * Add migration to convert URNs to contacts on scheduled broadcasts + +v8.1.26 (2023-02-10) +------------------------- + * Remove returning specific URNs from omniboxes and instead match contacts by URN + * Rework spa menu eliminate mapping + +v8.1.25 (2023-02-09) +------------------------- + * Remove support for unused v1 omnibox format + * Update broadcasts API endpoint to support attachments + +v8.1.24 (2023-02-08) +------------------------- + * Update to latest cryptography library + * Add task to interrupt flow sessions after 90 days + +v8.1.23 (2023-02-06) +------------------------- + * Fix flow results redirecting to it's own page + * Make sure WA numbers can only be claimed once + +v8.1.22 (2023-02-06) +------------------------- + * Update to latest django to get security fix + +v8.1.21 (2023-02-06) +------------------------- + * Fix export > import path on new ui + * Fix login redirects from pjax calls + v8.1.20 (2023-02-02) ------------------------- * Add servicing menu on org read diff --git a/package.json b/package.json index c5d635c0584..ab0a9548be9 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,13 @@ ] }, "dependencies": { - "@nyaruka/flow-editor": "1.19.1", - "@nyaruka/temba-components": "0.39.1", + "@nyaruka/flow-editor": "1.19.2", + "@nyaruka/temba-components": "0.41.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", "highcharts": "5.0.6", - "intercooler": "1.1.2", + "intercooler": "1.2.3", "is-core-module": "2.4.0", "jquery": "2.1.0", "jquery-migrate": "1.4.1", diff --git a/poetry.lock b/poetry.lock index c77674d5b08..c2514bf6c2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -304,7 +304,7 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "3.4.7" +version = "39.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -314,12 +314,14 @@ python-versions = ">=3.6" cffi = ">=1.12" [package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] -docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] -pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "ruff", "mypy", "types-pytz", "types-requests", "check-manifest"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] +test = ["pytest (>=6.2.0)", "pytest-shard (>=0.1.2)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] [[package]] name = "deprecated" @@ -345,7 +347,7 @@ python-versions = "*" [[package]] name = "django" -version = "4.0.8" +version = "4.0.9" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false @@ -740,7 +742,7 @@ python-versions = ">=3.7" [[package]] name = "phonenumbers" -version = "8.13.4" +version = "8.13.6" description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." category = "main" optional = false @@ -1309,7 +1311,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "b0b44044cb9e9b4e38984bce009a579d181adcc751c54874ada9e41587660b6d" +content-hash = "1d8a004cbff4e7959d32df6e1242580620e0d32b8c4114f923451024f9ce4503" [metadata.files] amqp = [] @@ -1352,22 +1354,7 @@ codecov = [ ] colorama = [] coverage = [] -cryptography = [ - {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, - {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, - {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, - {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b01fd6f2737816cb1e08ed4807ae194404790eac7ad030b34f2ce72b332f5586"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:bf40af59ca2465b24e54f671b2de2c59257ddc4f7e5706dbd6930e26823668d3"}, - {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, -] +cryptography = [] deprecated = [ {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, diff --git a/pyproject.toml b/pyproject.toml index 76e3df5f838..45f21ddfdce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "8.1.20" +version = "8.1.34" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] license = "AGPL-3" @@ -10,7 +10,7 @@ repository = "http://github.com/rapidpro/rapidpro" [tool.poetry.dependencies] python = "^3.10" -Django = "^4.0.7" +Django = "4.0.9" django-compressor = "^3.1" django-countries = "^7.0" django-hamlpy = "^1.4.4" @@ -26,7 +26,7 @@ redis = "^3.5.3" elasticsearch = "^7.11.0" elasticsearch-dsl = "^7.3.0" boto3 = "^1.17.21" -cryptography = "3.4.7" +cryptography = "^39.0.1" vonage = "2.5.2" pyfcm = "^1.5.1" pyotp = "2.4.1" @@ -46,7 +46,7 @@ gunicorn = "^20.0.4" iptools = "^0.7.0" iso-639 = "^0.4.5" iso8601 = "^0.1.14" -phonenumbers = "*" +phonenumbers = "^8.13.6" pycountry = "^20.7.3" python-dateutil = "^2.8.2" psycopg2-binary = "^2.9.1" diff --git a/static/js/urls.js b/static/js/urls.js deleted file mode 100644 index 130f054c9ed..00000000000 --- a/static/js/urls.js +++ /dev/null @@ -1,69 +0,0 @@ -// prettier-ignore -window.urls = [ - { old: /\/channels\/(.*)\/logs\/msg\/(.*)/, new: /\/settings\/channels\/(.*)\/history\/msg\/(.*)/ }, - { old: /\/msg\/filter\/(.*)/, new: /\/messages\/labels\/(.*)/ }, - { old: /\/msg\/flow\//, new: /\/messages\/flows/ }, - { old: /\/msg\/(.*)\//, new: /\/messages\/(.*)/ }, - { old: /\/contact\/read\/(.*)/, new: /\/contacts\/read\/(.*)/ }, - { old: /\/contact\/filter\/(.*)/, new: /\/contacts\/groups\/(.*)/ }, - { old: /\/contactfield\//, new: /\/contacts\/fields\// }, - { old: /\/contact\//, new: /\/contacts\// }, - { old: /\/flow\/editor\/(.*)/, new: /\/flows\/editor\/(.*)/ }, - { old: /\/flow\/results\/(.*)/, new: /\/flows\/results\/(.*)/ }, - { old: /\/flow\/filter\/(.*)\//, new: /\/flows\/labels\/(.*)/ }, - { old: /\/flowstart\//, new: /\/flows\/starts\// }, - { old: /\/flow\//, new: /\/flows\/active/ }, - { old: /\/flow\/(.*)\//, new: /\/flows\/(.*)\// }, - { old: /\/ticket\/(.*)\/(.*)\/(.*)/, new: /\/tickets\/(.*)\/(.*)\/(.*)/ }, - { old: /\/trigger\//, new: /\/triggers\/active\// }, - { old: /\/trigger\/(.*)\//, new: /\/trigger\/(.*)/ }, - { old: /\/campaignevent\/read\/(.*)\/(.*)/, new: /\/campaigns\/event\/(.*)\/(.*)/ }, - { old: /\/campaign\/read\/(.*)/, new: /\/campaigns\/campaign\/(.*)/ }, - { old: /\/campaign\//, new: /\/campaigns\/active/ }, - { old: /\/channels\/logs\/(.*)\//, new: /\/settings\/channels\/(.*)\/history\// }, - { old: /\/channels\/channel\/configuration\/(.*)\//, new: /\/settings\/channels\/(.*)\/config\// }, - { old: /\/channels\/channel\/read\/(.*)\//, new: /\/settings\/channels\/(.*)\// }, - { old: /\/channels\/channel\/claim\//, new: /\/settings\/workspace\/new-channel\// }, - { old: /\/channels\/types\/(.*)\/claim/, new: /\/settings\/workspace\/new-(.*)\// }, - { old: /\/classifier\/connect.*/, new: /\/settings\/workspace\/new-classifier\// }, - { old: /\/classifiers\/types\/(.*)/, new: /\/settings\/classifiers\/types\/(.*)/ }, - { old: /\/httplog\/classifier\/(.*)\//, new: /\/settings\/classifiers\/(.*)\/history\// }, - { old: /\/httplog\/read\/(.*)\//, new: /\/settings\/httplog\/(.*)\// }, - { old: /\/classifier\/read\/(.*)\//, new: /\/settings\/classifiers\/(.*)\// }, - { old: /\/org\/manage_accounts\/(.*)/, new: /\/settings\/users/ }, - { old: /\/user\/account\//, new: /\/settings\/account/ }, - { old: /\/user\/two_factor_disable\//, new: /\/settings\/authentication\/2fa-disable/ }, - { old: /\/user\/two_factor_enable\//, new: /\/settings\/authentication\// }, - { old: /\/user\/two_factor_token\//, new: /\/settings\/authentication\/2fa-token\// }, - { old: /\/users\/confirm_access\//, new: /\/settings\/authentication\/confirm\/(.*)/ }, - { old: /\/org\/export\//, new: /\/settings\/workspace\/export\// }, - { old: /\/org\/import\//, new: /\/settings\/workspace\/import\// }, - { old: /\/org\/read\/(.*)/, new: /\/staff\/workspace\/(.*)/ }, - { old: /\/user\/read\/(.*)/, new: /\/staff\/user\/(.*)/ }, - { old: /\/org\/update\/(.*)/, new: /\/staff\/workspaces\/(.*)\/update/ }, - { old: /\/org\/home\//, new: /\/settings\/workspace\// }, - { old: /\/dashboard\/home\//, new: /\/settings\/dashboard\// }, - { old: /\/org\/manage_accounts_sub_org\/\?org=(.*)/, new: /\/settings\/workspaces\/(.*)\/users\// }, - { old: /\/org\/sub_orgs\//, new: /\/settings\/workspaces\// }, - { old: /\/org\/manage\/(.*)/, new: /\/staff\/(.*)/ }, -]; - -window.excludeUrls = ["/user/login", "/org/service"]; - -window.mapUrl = function (path, reverse) { - var findDirection = reverse ? 'new' : 'old'; - var replaceDirection = reverse ? 'old' : 'new'; - - for (var mapping of urls) { - var match = path.match(mapping[findDirection]); - if (match) { - path = mapping[replaceDirection].source.replaceAll('\\/', '/'); - for (var i = 1; i < match.length; i++) { - path = path.replace('(.*)', match[i]); - } - path = path.replaceAll('(.*)', ''); - return path; - } - } - return path; -}; diff --git a/temba/api/v2/fields.py b/temba/api/v2/fields.py index a10b2a2dce3..641e1c75e79 100644 --- a/temba/api/v2/fields.py +++ b/temba/api/v2/fields.py @@ -9,6 +9,7 @@ from temba.flows.models import Flow from temba.msgs.models import Label, Msg from temba.tickets.models import Ticket, Ticketer, Topic +from temba.utils import languages from temba.utils.uuid import is_uuid # default maximum number of items in a posted list or dict @@ -16,24 +17,44 @@ DEFAULT_MAX_DICT_ITEMS = 100 -def validate_size(value, max_size): +def validate_size(value, max_size: int): if hasattr(value, "__len__") and len(value) > max_size: - raise serializers.ValidationError("This field can only contain up to %d items." % max_size) + raise serializers.ValidationError(f"This field can only contain up to {max_size} items.") -def validate_translations(value, base_language, max_length): - if len(value) == 0: +def validate_language(value): + if not isinstance(value, str) or len(value) != 3 or not languages.get_name(value): + raise serializers.ValidationError("Not an allowed ISO 639-3 language code.") + + +def validate_translations(value, *, max_length: int, lists: bool, max_items: int = 0): + if not isinstance(value, dict): + raise serializers.ValidationError("Must be a dictionary of languages to translated values.") + elif len(value) == 0: raise serializers.ValidationError("Must include at least one translation.") - if base_language not in value: - raise serializers.ValidationError("Must include translation for base language '%s'" % base_language) for lang, trans in value.items(): - if not isinstance(lang, str) or len(lang) != 3: - raise serializers.ValidationError("Language code %s is not valid." % str(lang)) - if not isinstance(trans, str): - raise serializers.ValidationError("Translations must be strings.") - if len(trans) > max_length: - raise serializers.ValidationError("Ensure translations have no more than %d characters." % max_length) + validate_language(lang) + + if lists: + if not isinstance(trans, list) or not all([isinstance(t, str) for t in trans]): + raise serializers.ValidationError("Translations must be lists of strings.") + + if len(trans) > max_items: + raise serializers.ValidationError(f"Translations can only contain up to {max_items} items.") + + as_list = trans + else: + if not isinstance(trans, str): + raise serializers.ValidationError("Translations must be strings.") + + as_list = [trans] + + for t in as_list: + if not t.strip(): + raise serializers.ValidationError("Translations cannot be empty or blank.") + if len(t) > max_length: + raise serializers.ValidationError("Translations must have no more than %d characters." % max_length) def validate_urn(value, strict=True, country_code=None): @@ -47,33 +68,52 @@ def validate_urn(value, strict=True, country_code=None): return normalized -class TranslatableField(serializers.Field): +class LanguageField(serializers.CharField): + max_length = 3 + + def to_internal_value(self, data): + validate_language(data) + + return super().to_internal_value(data) + + +class TranslationsField(serializers.Field): """ - A field which is either a simple string or a translations dict + A field which is either a string or a language -> string translations dict """ - def __init__(self, **kwargs): - self.max_length = kwargs.pop("max_length", None) + def __init__(self, max_length, **kwargs): + self.max_length = max_length + super().__init__(**kwargs) def to_internal_value(self, data): - org = self.context["org"] - base_language = org.flow_languages[0] - if isinstance(data, str): - if len(data) > self.max_length: - raise serializers.ValidationError( - "Ensure this field has no more than %d characters." % self.max_length - ) + data = {self.context["org"].flow_languages[0]: data} - data = {base_language: data} + validate_translations(data, max_length=self.max_length, lists=False) - elif isinstance(data, dict): - validate_translations(data, base_language, self.max_length) - else: - raise serializers.ValidationError("Value must be a string or dict of strings.") + return data + + +class TranslationsListField(serializers.Field): + """ + A field which is either a list of strings or a language -> list of strings translations dict + """ + + def __init__(self, max_items, max_length, **kwargs): + self.max_items = max_items + self.max_length = max_length + + super().__init__(**kwargs) + + def to_internal_value(self, data): + if isinstance(data, list): + data = {self.context["org"].flow_languages[0]: data} + + validate_translations(data, max_length=self.max_length, lists=True, max_items=self.max_items) - return data, base_language + return data class LimitedListField(serializers.ListField): diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 2474384508b..d96b7dcdecb 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -184,15 +184,20 @@ class BroadcastReadSerializer(ReadSerializer): Broadcast.STATUS_FAILED: "failed", } - text = serializers.SerializerMethodField() - status = serializers.SerializerMethodField() urns = serializers.SerializerMethodField() contacts = fields.ContactField(many=True) groups = fields.ContactGroupField(many=True) + text = serializers.SerializerMethodField() + attachments = serializers.SerializerMethodField() + base_language = fields.LanguageField() + status = serializers.SerializerMethodField() created_on = serializers.DateTimeField(default_timezone=pytz.UTC) def get_text(self, obj): - return {lang: trans["text"] for lang, trans in obj.translations.items()} + return {lang: trans.get("text") for lang, trans in obj.translations.items()} + + def get_attachments(self, obj): + return {lang: trans.get("attachments", []) for lang, trans in obj.translations.items()} def get_status(self, obj): return self.STATUSES.get(obj.status, "sent") @@ -201,23 +206,36 @@ def get_urns(self, obj): if self.context["org"].is_anon: return None else: - return obj.raw_urns or [] + return obj.urns or [] class Meta: model = Broadcast - fields = ("id", "urns", "contacts", "groups", "text", "status", "created_on") + fields = ("id", "urns", "contacts", "groups", "text", "attachments", "base_language", "status", "created_on") class BroadcastWriteSerializer(WriteSerializer): - text = fields.TranslatableField(required=True, max_length=Msg.MAX_TEXT_LEN) urns = fields.URNListField(required=False) contacts = fields.ContactField(many=True, required=False) groups = fields.ContactGroupField(many=True, required=False) + text = fields.TranslationsField(required=True, max_length=Msg.MAX_TEXT_LEN) + attachments = fields.TranslationsListField(required=False, max_items=10, max_length=2048) + base_language = fields.LanguageField(required=False) ticket = fields.TicketField(required=False) def validate(self, data): + text = data["text"] + attachments = data.get("attachments") + base_language = data.get("base_language") + if not (data.get("urns") or data.get("contacts") or data.get("groups")): - raise serializers.ValidationError("Must provide either urns, contacts or groups") + raise serializers.ValidationError("Must provide either urns, contacts or groups.") + + if base_language: + if base_language not in text: + raise serializers.ValidationError("No text translation provided in base language.") + + if attachments and base_language not in attachments: + raise serializers.ValidationError("No attachment translations provided in base language.") return data @@ -226,21 +244,18 @@ def save(self): Create a new broadcast to send out """ - text, base_language = self.validated_data["text"] - - # create the broadcast broadcast = Broadcast.create( self.context["org"], self.context["user"], - text=text, - base_language=base_language, + text=self.validated_data["text"], + attachments=self.validated_data.get("attachments", {}), + base_language=self.validated_data.get("base_language"), groups=self.validated_data.get("groups", []), contacts=self.validated_data.get("contacts", []), urns=self.validated_data.get("urns", []), ticket=self.validated_data.get("ticket"), ) - # send it on_transaction_commit(lambda: broadcast.send_async()) return broadcast @@ -351,7 +366,7 @@ class CampaignEventWriteSerializer(WriteSerializer): unit = serializers.ChoiceField(required=True, choices=list(UNITS.keys())) delivery_hour = serializers.IntegerField(required=True, min_value=-1, max_value=23) relative_to = fields.ContactFieldField(required=True) - message = fields.TranslatableField(required=False, max_length=Msg.MAX_TEXT_LEN) + message = fields.TranslationsField(required=False, max_length=Msg.MAX_TEXT_LEN) flow = fields.FlowField(required=False) def validate_unit(self, value): @@ -362,17 +377,18 @@ def validate_campaign(self, value): raise serializers.ValidationError("Cannot change campaign for existing events") return value + def validate_message(self, value): + if value and not value.get(self.context["org"].flow_languages[0]): + raise serializers.ValidationError("Message text in default flow language is required.") + + return value + def validate(self, data): message = data.get("message") flow = data.get("flow") - if message and not flow: - translations, base_language = message - if not translations[base_language]: - raise serializers.ValidationError("Message text is required") - if (message and flow) or (not message and not flow): - raise serializers.ValidationError("Flow UUID or a message text required.") + raise serializers.ValidationError("Flow or a message text required.") return data @@ -380,6 +396,11 @@ def save(self): """ Create or update our campaign event """ + + org = self.context["org"] + user = self.context["user"] + base_language = org.flow_languages[0] + campaign = self.validated_data.get("campaign") offset = self.validated_data.get("offset") unit = self.validated_data.get("unit") @@ -389,9 +410,7 @@ def save(self): flow = self.validated_data.get("flow") if self.instance: - - # we dont update, we only create - self.instance = self.instance.recreate() + self.instance = self.instance.recreate() # don't update but re-create to invalidate existing event fires # we are being set to a flow if flow: @@ -401,20 +420,17 @@ def save(self): # we are being set to a message else: - translations, base_language = message - self.instance.message = translations + self.instance.message = message # if we aren't currently a message event, we need to create our hidden message flow if self.instance.event_type != CampaignEvent.TYPE_MESSAGE: - self.instance.flow = Flow.create_single_message( - self.context["org"], self.context["user"], translations, base_language - ) + self.instance.flow = Flow.create_single_message(org, user, message, base_language) self.instance.event_type = CampaignEvent.TYPE_MESSAGE # otherwise, we can just update that flow else: # set our single message on our flow - self.instance.flow.update_single_message_flow(self.context["user"], translations, base_language) + self.instance.flow.update_single_message_flow(user, message, base_language) # update our other attributes self.instance.offset = offset @@ -427,21 +443,11 @@ def save(self): else: if flow: self.instance = CampaignEvent.create_flow_event( - self.context["org"], self.context["user"], campaign, relative_to, offset, unit, flow, delivery_hour + org, user, campaign, relative_to, offset, unit, flow, delivery_hour ) else: - translations, base_language = message - self.instance = CampaignEvent.create_message_event( - self.context["org"], - self.context["user"], - campaign, - relative_to, - offset, - unit, - translations, - delivery_hour, - base_language, + org, user, campaign, relative_to, offset, unit, message, delivery_hour, base_language ) self.instance.update_flow_name() diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index c54484b37d2..69471a7cbd1 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -184,6 +184,60 @@ def test_contact(self): }, ) + def test_language_and_translations(self): + self.assert_field( + fields.LanguageField(source="test"), + submissions={ + "eng": "eng", + "kin": "kin", + 123: serializers.ValidationError, + "base": serializers.ValidationError, + }, + representations={"eng": "eng"}, + ) + + field = fields.TranslationsField(source="test", max_length=10) + field._context = {"org": self.org} + + self.assertEqual(field.to_internal_value("Hello"), {"eng": "Hello"}) + self.assertEqual(field.to_internal_value({"eng": "Hello"}), {"eng": "Hello"}) + self.assertEqual(field.to_internal_value({"eng": "Hello", "spa": "Hola"}), {"eng": "Hello", "spa": "Hola"}) + self.assertRaises(serializers.ValidationError, field.to_internal_value, "") # empty + self.assertRaises(serializers.ValidationError, field.to_internal_value, " ") # blank + self.assertRaises(serializers.ValidationError, field.to_internal_value, 123) # not a string or dict + self.assertRaises(serializers.ValidationError, field.to_internal_value, {}) # no translations + self.assertRaises(serializers.ValidationError, field.to_internal_value, {123: "Hello"}) # lang not a str + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"base": "Hello"}) # lang not valid + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"kin": 123}) # translation not a str + self.assertRaises(serializers.ValidationError, field.to_internal_value, "HelloHello1") # translation too long + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"eng": "HelloHello1"}) + + field = fields.TranslationsListField(source="test", max_items=2, max_length=10) + field._context = {"org": self.org} + + self.assertEqual(field.to_internal_value(["Hello"]), {"eng": ["Hello"]}) + self.assertEqual(field.to_internal_value({"eng": ["Hello"]}), {"eng": ["Hello"]}) + self.assertEqual( + field.to_internal_value({"eng": ["Hello", "Bye"], "spa": ["Hola"]}), + {"eng": ["Hello", "Bye"], "spa": ["Hola"]}, + ) + self.assertRaises(serializers.ValidationError, field.to_internal_value, [""]) # empty + self.assertRaises(serializers.ValidationError, field.to_internal_value, [" "]) # blank + self.assertRaises(serializers.ValidationError, field.to_internal_value, 123) # not a string or dict + self.assertRaises(serializers.ValidationError, field.to_internal_value, {}) # no translations + self.assertRaises(serializers.ValidationError, field.to_internal_value, {123: ["Hello"]}) # lang not a str + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"base": ["Hello"]}) # lang not valid + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"kin": 123}) # translation not a list + self.assertRaises(serializers.ValidationError, field.to_internal_value, ["HelloHello1"]) # too long + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"eng": ["x", "HelloHello1"]}) + self.assertRaises(serializers.ValidationError, field.to_internal_value, {"eng": ["x", "y", "z"]}) # too many + + # check that default language is based on first flow language + self.org.flow_languages = ["spa", "kin"] + self.org.save(update_fields=("flow_languages",)) + + self.assertEqual(field.to_internal_value(["Hello"]), {"spa": ["Hello"]}) + def test_others(self): group = self.create_group("Customers") field_obj = self.create_field("registered", "Registered On", value_type=ContactField.TYPE_DATETIME) @@ -290,32 +344,6 @@ def test_others(self): representations={self.agent: {"email": "agent@nyaruka.com", "name": "Agnes"}}, ) - field = fields.TranslatableField(source="test", max_length=10) - field._context = {"org": self.org} - - self.assertEqual(field.to_internal_value("Hello"), ({"eng": "Hello"}, "eng")) - self.assertEqual(field.to_internal_value({"eng": "Hello"}), ({"eng": "Hello"}, "eng")) - - self.org.set_flow_languages(self.admin, ["kin"]) - self.org.save() - - self.assertEqual(field.to_internal_value("Hello"), ({"kin": "Hello"}, "kin")) - self.assertEqual( - field.to_internal_value({"eng": "Hello", "kin": "Muraho"}), ({"eng": "Hello", "kin": "Muraho"}, "kin") - ) - - self.assertRaises(serializers.ValidationError, field.to_internal_value, 123) # not a string or dict - self.assertRaises(serializers.ValidationError, field.to_internal_value, {"kin": 123}) - self.assertRaises(serializers.ValidationError, field.to_internal_value, {}) - self.assertRaises(serializers.ValidationError, field.to_internal_value, {123: "Hello", "kin": "Muraho"}) - self.assertRaises(serializers.ValidationError, field.to_internal_value, "HelloHello1") # too long - self.assertRaises( - serializers.ValidationError, field.to_internal_value, {"kin": "HelloHello1"} - ) # also too long - self.assertRaises( - serializers.ValidationError, field.to_internal_value, {"eng": "HelloHello1"} - ) # base lang not provided - class EndpointsTest(TembaTest): def setUp(self): @@ -867,7 +895,7 @@ def test_broadcasts(self, mock_queue_broadcast): bcast4.save(update_fields=("status",)) # no filtering - with self.assertNumQueries(NUM_BASE_REQUEST_QUERIES + 4): + with self.assertNumQueries(NUM_BASE_REQUEST_QUERIES + 3): response = self.fetchJSON(url, readonly_models={Broadcast}) resp_json = response.json() @@ -881,6 +909,8 @@ def test_broadcasts(self, mock_queue_broadcast): "contacts": [{"uuid": self.joe.uuid, "name": self.joe.name}], "groups": [], "text": {"eng": "Hello 2"}, + "attachments": {"eng": []}, + "base_language": "eng", "status": "queued", "created_on": format_datetime(bcast2.created_on), }, @@ -893,6 +923,8 @@ def test_broadcasts(self, mock_queue_broadcast): "contacts": [{"uuid": self.joe.uuid, "name": self.joe.name}], "groups": [{"uuid": reporters.uuid, "name": reporters.name}], "text": {"eng": "Hello 4"}, + "attachments": {"eng": []}, + "base_language": "eng", "status": "failed", "created_on": format_datetime(bcast4.created_on), }, @@ -922,14 +954,38 @@ def test_broadcasts(self, mock_queue_broadcast): # try to create new broadcast with no recipients response = self.postJSON(url, None, {"text": "Hello"}) - self.assertResponseError(response, "non_field_errors", "Must provide either urns, contacts or groups") + self.assertResponseError(response, "non_field_errors", "Must provide either urns, contacts or groups.") + + # try to create new broadcast with translations that don't include base language + response = self.postJSON( + url, None, {"text": {"kin": "Muraho"}, "base_language": "eng", "contacts": [self.joe.uuid]} + ) + self.assertResponseError(response, "non_field_errors", "No text translation provided in base language.") + + # try to create new broadcast with translations that don't include base language + response = self.postJSON( + url, + None, + { + "text": {"eng": "Hello"}, + "attachments": {"spa": ["http://text.mp3"]}, + "base_language": "eng", + "contacts": [self.joe.uuid], + }, + ) + self.assertResponseError(response, "non_field_errors", "No attachment translations provided in base language.") # create new broadcast with all fields response = self.postJSON( url, None, { - "text": "Hi @(format_urn(urns.tel))", + "text": {"eng": "Hello @contact.name", "spa": "Hola @contact.name"}, + "attachments": { + "eng": ["http://example.com/test.jpg", "http://example.com/test.mp3"], + "kin": ["http://example.com/muraho.mp3"], + }, + "base_language": "eng", "urns": ["twitter:franky"], "contacts": [self.joe.uuid, self.frank.uuid], "groups": [reporters.uuid], @@ -938,28 +994,44 @@ def test_broadcasts(self, mock_queue_broadcast): ) broadcast = Broadcast.objects.get(id=response.json()["id"]) - self.assertEqual({"eng": {"text": "Hi @(format_urn(urns.tel))"}}, broadcast.translations) - self.assertEqual(["twitter:franky"], broadcast.raw_urns) + self.assertEqual( + { + "eng": { + "text": "Hello @contact.name", + "attachments": ["http://example.com/test.jpg", "http://example.com/test.mp3"], + }, + "spa": {"text": "Hola @contact.name"}, + "kin": {"attachments": ["http://example.com/muraho.mp3"]}, + }, + broadcast.translations, + ) + self.assertEqual("eng", broadcast.base_language) + self.assertEqual(["twitter:franky"], broadcast.urns) self.assertEqual({self.joe, self.frank}, set(broadcast.contacts.all())) self.assertEqual({reporters}, set(broadcast.groups.all())) self.assertEqual(ticket, broadcast.ticket) mock_queue_broadcast.assert_called_once_with(broadcast) - # create new broadcast with translations + # create new broadcast without translations response = self.postJSON( - url, None, {"text": {"eng": "Hello", "fra": "Bonjour"}, "contacts": [self.joe.uuid, self.frank.uuid]} + url, + None, + { + "text": "Hello", + "attachments": ["http://example.com/test.jpg", "http://example.com/test.mp3"], + "contacts": [self.joe.uuid, self.frank.uuid], + }, ) broadcast = Broadcast.objects.get(id=response.json()["id"]) - self.assertEqual({"eng": {"text": "Hello"}, "fra": {"text": "Bonjour"}}, broadcast.translations) + self.assertEqual( + {"eng": {"text": "Hello", "attachments": ["http://example.com/test.jpg", "http://example.com/test.mp3"]}}, + broadcast.translations, + ) + self.assertEqual("eng", broadcast.base_language) self.assertEqual({self.joe, self.frank}, set(broadcast.contacts.all())) - # create new broadcast with an expression - response = self.postJSON(url, None, {"text": "You are @fields.age", "contacts": [self.joe.uuid]}) - broadcast = Broadcast.objects.get(id=response.json()["id"]) - self.assertEqual({"eng": {"text": "You are @fields.age"}}, broadcast.translations) - # try sending as a flagged org self.org.flag() response = self.postJSON(url, None, {"text": "Hello", "urns": ["twitter:franky"]}) @@ -1324,10 +1396,12 @@ def test_campaign_events(self, mr_mocks): "offset": 15, "unit": "epocs", "delivery_hour": 25, + "message": {"kin": "Muraho"}, }, ) self.assertResponseError(response, "unit", '"epocs" is not a valid choice.') self.assertResponseError(response, "delivery_hour", "Ensure this value is less than or equal to 23.") + self.assertResponseError(response, "message", "Message text in default flow language is required.") # provide valid values for those fields.. but not a message or flow response = self.postJSON( @@ -1341,7 +1415,7 @@ def test_campaign_events(self, mr_mocks): "delivery_hour": -1, }, ) - self.assertResponseError(response, "non_field_errors", "Flow UUID or a message text required.") + self.assertResponseError(response, "non_field_errors", "Flow or a message text required.") # create a message event response = self.postJSON( @@ -1382,7 +1456,7 @@ def test_campaign_events(self, mr_mocks): ) # we should have failed validation for sending an empty message - self.assertResponseError(response, "non_field_errors", "Message text is required") + self.assertResponseError(response, "message", "Translations cannot be empty or blank.") response = self.postJSON( url, diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index c3db291fd9f..272d3021963 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -539,7 +539,9 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): * **urns** - the URNs that received the broadcast (array of strings) * **contacts** - the contacts that received the broadcast (array of objects) * **groups** - the groups that received the broadcast (array of objects) - * **text** - the message text (string or translations object) + * **text** - the message text translations (dict of strings) + * **attachments** - the attachment translations (dict of lists of strings) + * **base_language** - the default translation language (string) * **status** - the status of the message (one of "queued", "sent", "failed"). * **created_on** - when this broadcast was either created (datetime) (filterable as `before` and `after`). @@ -558,7 +560,9 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): "urns": ["tel:+250788123123", "tel:+250788123124"], "contacts": [{"uuid": "09d23a05-47fe-11e4-bfe9-b8f6b119e9ab", "name": "Joe"}] "groups": [], - "text": "hello world", + "text": {"eng", "hello world"}, + "attachments": {"eng", []}, + "base_language": "eng", "created_on": "2013-03-02T17:28:12.123456Z" }, ... @@ -567,10 +571,12 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): A `POST` allows you to create and send new broadcasts, with the following JSON data: - * **text** - the text of the message to send (string, limited to 640 characters) * **urns** - the URNs of contacts to send to (array of up to 100 strings, optional) * **contacts** - the UUIDs of contacts to send to (array of up to 100 strings, optional) * **groups** - the UUIDs of contact groups to send to (array of up to 100 strings, optional) + * **text** - the message text translations (dict of strings) + * **attachments** - the attachment translations (dict of lists of strings) + * **base_language** - the default translation language (string, optional) Example: @@ -578,7 +584,8 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): { "urns": ["tel:+250788123123", "tel:+250788123124"], "contacts": ["09d23a05-47fe-11e4-bfe9-b8f6b119e9ab"], - "text": "hello @contact.name" + "text": {"eng": "Hello @contact.name!", "spa": "Hola @contact.name!"}, + "base_language": "eng" } You will receive a response containing the message broadcast created: @@ -588,7 +595,9 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): "urns": ["tel:+250788123123", "tel:+250788123124"], "contacts": [{"uuid": "09d23a05-47fe-11e4-bfe9-b8f6b119e9ab", "name": "Joe"}] "groups": [], - "text": "hello world", + "text": {"eng": "Hello @contact.name!", "spa": "Hola @contact.name!"}, + "attachments": {"eng", [], "spa": []}, + "base_language": "eng", "created_on": "2013-03-02T17:28:12.123456Z" } """ @@ -601,7 +610,6 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseAPIView): throttle_scope = "v2.broadcasts" def filter_queryset(self, queryset): - org = self.request.org queryset = queryset.filter(schedule=None, is_active=True) # filter by id (optional) @@ -614,11 +622,6 @@ def filter_queryset(self, queryset): Prefetch("groups", queryset=ContactGroup.objects.only("uuid", "name").order_by("id")), ) - if not org.is_anon: - queryset = queryset.prefetch_related( - Prefetch("urns", queryset=ContactURN.objects.only("scheme", "path", "display").order_by("id")) - ) - return self.filter_before_after(queryset, "created_on") @classmethod diff --git a/temba/archives/views.py b/temba/archives/views.py index 9f5baf4e9e2..bc2548292f9 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -55,6 +55,8 @@ def get_context_data(self, **kwargs): return context class Run(BaseList): + menu_path = "/settings/archives/run" + @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/$" % (path, Archive.TYPE_FLOWRUN) @@ -66,6 +68,8 @@ def get_archive_type(self): return Archive.TYPE_FLOWRUN class Message(BaseList): + menu_path = "/settings/archives/message" + @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/$" % (path, Archive.TYPE_MSG) diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 8665348fec7..552e376c8b1 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -60,7 +60,6 @@ def derive_menu(self): self.create_menu_item( menu_id="active", name=_("Active"), - verbose_name=_("Active Campaigns"), icon="icon.active", count=org.campaigns.filter(is_active=True, is_archived=False).count(), href="campaigns.campaign_list", @@ -71,7 +70,6 @@ def derive_menu(self): self.create_menu_item( menu_id="archived", name=_("Archived"), - verbose_name=_("Archived Campaigns"), icon="icon.archive", count=org.campaigns.filter(is_active=True, is_archived=True).count(), href="campaigns.campaign_archived", @@ -205,6 +203,7 @@ class List(BaseList): fields = ("name", "group") bulk_actions = ("archive",) search_fields = ("name__icontains", "group__name__icontains") + menu_path = "/campaign/active" def derive_title(self): return _("Active Campaigns") @@ -227,6 +226,7 @@ def build_content_menu(self, menu): class Archived(BaseList): fields = ("name",) bulk_actions = ("restore",) + menu_path = "/campaign/archived" def derive_title(self): return _("Archived Campaigns") diff --git a/temba/channels/tests.py b/temba/channels/tests.py index 58fe08b5823..4a923c88f83 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -28,6 +28,7 @@ from temba.triggers.models import Trigger from temba.utils import json from temba.utils.models import generate_uuid +from temba.utils.views import TEMBA_MENU_SELECTION from .models import Alert, Channel, ChannelCount, ChannelEvent, ChannelLog, SyncEvent from .tasks import ( @@ -467,6 +468,13 @@ def test_read(self): response = self.client.get(tel_channel_read_url) self.assertRedirect(response, reverse("orgs.org_choose")) + # new ui path + self.login(self.user) + self.make_beta(self.user) + self.new_ui() + response = self.client.get(tel_channel_read_url) + self.assertEqual(f"/settings/channels/{self.tel_channel.uuid}", response.headers[TEMBA_MENU_SELECTION]) + # org users can response = self.fetch_protected(tel_channel_read_url, self.user) @@ -1932,6 +1940,11 @@ def test_msg(self): msg1_url, allow_viewers=False, allow_editors=False, allow_org2=False, context_objects=[log1, log2] ) + self.login(self.admin) + self.new_ui() + response = self.client.get(msg1_url) + self.assertEqual(f"/settings/channels/{self.channel.uuid}", response.headers[TEMBA_MENU_SELECTION]) + def test_call(self): contact = self.create_contact("Fred", phone="+12067799191") flow = self.create_flow("IVR") @@ -2105,6 +2118,11 @@ def test_read_and_list(self): response = self.client.get(list_url) self.assertEqual([failed_log, success_log], list(response.context["object_list"])) + self.new_ui() + response = self.client.get(list_url) + self.assertEqual(f"/settings/channels/{self.channel.uuid}", response.headers[TEMBA_MENU_SELECTION]) + self.old_ui() + # check error logs only response = self.client.get(list_url + "?errors=1") self.assertEqual([failed_log], list(response.context["object_list"])) diff --git a/temba/channels/types/twilio/tests.py b/temba/channels/types/twilio/tests.py index 994bba5580f..2dd230aa1c0 100644 --- a/temba/channels/types/twilio/tests.py +++ b/temba/channels/types/twilio/tests.py @@ -135,9 +135,7 @@ def test_claim(self): # claim it response = self.client.post(claim_twilio, dict(country="US", phone_number="12062345678")) - self.assertFormError( - response, "form", "phone_number", "That number is already connected (+12062345678)" - ) + self.assertFormError(response, "form", "phone_number", "Number is already connected to this workspace") # make sure the schemes do not overlap, having a WA channel with the same number channel = Channel.objects.get(channel_type="T", org=self.org) diff --git a/temba/channels/types/whatsapp/tests.py b/temba/channels/types/whatsapp/tests.py index bc1b9228f8e..36e87b63e62 100644 --- a/temba/channels/types/whatsapp/tests.py +++ b/temba/channels/types/whatsapp/tests.py @@ -170,6 +170,78 @@ def test_claim(self, mock_health): # deactivate our channel channel.release(self.admin) + @patch("temba.channels.types.whatsapp.WhatsAppType.check_health") + def test_duplicate_number_channels(self, mock_health): + mock_health.return_value = MockResponse(200, '{"meta": {"api_status": "stable", "version": "v2.35.2"}}') + TemplateTranslation.objects.all().delete() + Channel.objects.all().delete() + + url = reverse("channels.types.whatsapp.claim") + self.login(self.admin) + + response = self.client.get(reverse("channels.channel_claim")) + self.assertNotContains(response, url) + + response = self.client.get(url) + self.assertEqual(200, response.status_code) + post_data = response.context["form"].initial + + post_data["number"] = "0788123123" + post_data["username"] = "temba" + post_data["password"] = "tembapasswd" + post_data["country"] = "RW" + post_data["base_url"] = "https://nyaruka.com/whatsapp" + post_data["facebook_namespace"] = "my-custom-app" + post_data["facebook_business_id"] = "1234" + post_data["facebook_access_token"] = "token123" + post_data["facebook_template_list_domain"] = "graph.facebook.com" + post_data["facebook_template_list_api_version"] = "" + + # will fail with invalid phone number + response = self.client.post(url, post_data) + + with patch("requests.post") as mock_post, patch("requests.get") as mock_get, patch( + "requests.patch" + ) as mock_patch: + mock_post.return_value = MockResponse(200, '{"users": [{"token": "abc123"}]}') + mock_get.return_value = MockResponse(200, '{"data": []}') + mock_patch.return_value = MockResponse(200, '{"data": []}') + + response = self.client.post(url, post_data) + self.assertEqual(302, response.status_code) + + channel = Channel.objects.get() + + with patch("requests.post") as mock_post, patch("requests.get") as mock_get, patch( + "requests.patch" + ) as mock_patch: + mock_post.return_value = MockResponse(200, '{"users": [{"token": "abc123"}]}') + mock_get.return_value = MockResponse(200, '{"data": []}') + mock_patch.return_value = MockResponse(200, '{"data": []}') + + response = self.client.post(url, post_data) + self.assertEqual(200, response.status_code) + self.assertFormError(response, "form", "__all__", "Number is already connected to this workspace") + + channel.org = self.org2 + channel.save() + + with patch("requests.post") as mock_post, patch("requests.get") as mock_get, patch( + "requests.patch" + ) as mock_patch: + mock_post.return_value = MockResponse(200, '{"users": [{"token": "abc123"}]}') + mock_get.return_value = MockResponse(200, '{"data": []}') + mock_patch.return_value = MockResponse(200, '{"data": []}') + + response = self.client.post(url, post_data) + self.assertEqual(200, response.status_code) + self.assertFormError( + response, + "form", + "__all__", + "Number is already connected to another workspace", + ) + def test_refresh_tokens(self): TemplateTranslation.objects.all().delete() Channel.objects.all().delete() diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 1833dd09e1a..c0d9ec3991b 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -75,6 +75,16 @@ def clean(self): _("Unable to check WhatsApp enterprise account, please check username and password") ) + # validate we don't add the same number twice + existing = Channel.objects.filter( + is_active=True, address=self.cleaned_data["number"], schemes__overlap=list(self.channel_type.schemes) + ).first() + if existing: # pragma: needs cover + if existing.org == self.request.org: + raise forms.ValidationError(_("Number is already connected to this workspace")) + + raise forms.ValidationError(_("Number is already connected to another workspace")) + # check we can access their facebook templates from .type import TEMPLATE_LIST_URL diff --git a/temba/channels/types/whatsapp_cloud/tests.py b/temba/channels/types/whatsapp_cloud/tests.py index bfdcc789f05..531adf3dc1f 100644 --- a/temba/channels/types/whatsapp_cloud/tests.py +++ b/temba/channels/types/whatsapp_cloud/tests.py @@ -547,7 +547,7 @@ def test_claim(self, mock_randint): self.assertEqual(200, response.status_code) self.assertEqual( response.context["form"].errors["__all__"][0], - "That number is already connected (1234)", + "Number is already connected to this workspace", ) def test_clear_session_token(self): diff --git a/temba/channels/types/whatsapp_cloud/views.py b/temba/channels/types/whatsapp_cloud/views.py index d73ece5f60f..5f0178623dc 100644 --- a/temba/channels/types/whatsapp_cloud/views.py +++ b/temba/channels/types/whatsapp_cloud/views.py @@ -164,23 +164,15 @@ def form_valid(self, form): } # don't add the same number twice to the same account - existing = org.channels.filter( - is_active=True, address=phone_number_id, schemes__overlap=list(self.channel_type.schemes) - ).first() - if existing: # pragma: needs cover - form._errors["__all__"] = form.error_class([_("That number is already connected (%s)") % number]) - return self.form_invalid(form) - existing = Channel.objects.filter( is_active=True, address=phone_number_id, schemes__overlap=list(self.channel_type.schemes) ).first() if existing: # pragma: needs cover - form._errors["__all__"] = form.error_class( - [ - _("That number is already connected to another account - %(org)s (%(user)s)") - % dict(org=existing.org, user=existing.created_by.username) - ] - ) + if existing.org == self.request.org: + form._errors["__all__"] = form.error_class([_("Number is already connected to this workspace")]) + return self.form_invalid(form) + + form._errors["__all__"] = form.error_class([_("Number is already connected to another workspace")]) return self.form_invalid(form) # assign system user to WABA diff --git a/temba/channels/views.py b/temba/channels/views.py index cd509c9b803..7ac45492e14 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -571,27 +571,15 @@ def form_valid(self, form, *args, **kwargs): return self.form_invalid(form) # don't add the same number twice to the same account - existing = org.channels.filter( - is_active=True, address=data["phone_number"], schemes__overlap=list(self.channel_type.schemes) - ).first() - if existing: # pragma: needs cover - form._errors["phone_number"] = form.error_class( - [_("That number is already connected (%s)" % data["phone_number"])] - ) - return self.form_invalid(form) - existing = Channel.objects.filter( is_active=True, address=data["phone_number"], schemes__overlap=list(self.channel_type.schemes) ).first() if existing: # pragma: needs cover - form._errors["phone_number"] = form.error_class( - [ - _( - "That number is already connected to another account - %(org)s (%(user)s)" - % dict(org=existing.org, user=existing.created_by.username) - ) - ] - ) + if existing.org == self.request.org: + form._errors["phone_number"] = form.error_class([_("Number is already connected to this workspace")]) + return self.form_invalid(form) + + form._errors["phone_number"] = form.error_class([_("Number is already connected to another workspace")]) return self.form_invalid(form) error_message = None @@ -744,6 +732,9 @@ class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): slug_url_kwarg = "uuid" exclude = ("id", "is_active", "created_by", "modified_by", "modified_on") + def derive_menu_path(self): + return f"/settings/channels/{self.get_object().uuid}" + def get_queryset(self): return Channel.objects.filter(is_active=True) @@ -1383,6 +1374,9 @@ def folder(self) -> str: else: return self.FOLDER_MESSAGES + def derive_menu_path(self): + return f"/settings/channels/{self.channel.uuid}" + def build_content_menu(self, menu): list_url = reverse("channels.channellog_list", args=[self.channel.uuid]) @@ -1479,6 +1473,9 @@ def derive_url_pattern(cls, path, action): def msg(self): return get_object_or_404(Msg, id=self.kwargs["msg_id"]) + def derive_menu_path(self): + return f"/settings/channels/{self.msg.channel.uuid}" + def build_content_menu(self, menu): if not self.is_spa(): menu.add_link(_("More Logs"), reverse("channels.channellog_list", args=[self.msg.channel.uuid])) diff --git a/temba/classifiers/tests.py b/temba/classifiers/tests.py index 841e9bfb5b2..05d1d765732 100644 --- a/temba/classifiers/tests.py +++ b/temba/classifiers/tests.py @@ -5,6 +5,7 @@ from temba.request_logs.models import HTTPLog from temba.tests import CRUDLTestMixin, MockResponse, TembaTest +from temba.utils.views import TEMBA_MENU_SELECTION from .models import Classifier from .types.luis import LuisType @@ -125,11 +126,13 @@ def test_views(self): response = self.client.get(reverse("orgs.org_home")) self.assertContentMenuContains(reverse("orgs.org_home"), self.admin, "Add Classifier") + self.new_ui() read_url = reverse("classifiers.classifier_read", args=[self.c1.uuid]) self.assertContains(response, read_url) # read page response = self.client.get(read_url) + self.assertEqual(f"/settings/classifiers/{self.c1.uuid}", response.headers[TEMBA_MENU_SELECTION]) # contains intents self.assertContains(response, "book_flight") diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 55c8b8e876a..240f9235c79 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -77,6 +77,9 @@ class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): slug_url_kwarg = "uuid" exclude = ("id", "is_active", "created_by", "modified_by", "modified_on") + def derive_menu_path(self): + return f"/settings/classifiers/{self.get_object().uuid}" + def build_content_menu(self, menu): obj = self.get_object() diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 3cf89ae43a0..28f55aa0651 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -692,7 +692,7 @@ def get_scheduled_broadcasts(self): return ( SystemLabel.get_queryset(self.org, SystemLabel.TYPE_SCHEDULED) .filter(schedule__next_fire__gte=timezone.now()) - .filter(Q(contacts__in=[self]) | Q(urns__in=self.get_urns()) | Q(groups__in=self.groups.all())) + .filter(Q(contacts__in=[self]) | Q(groups__in=self.groups.all())) .select_related("org", "schedule") ) diff --git a/temba/contacts/search/mailroom.py b/temba/contacts/search/mailroom.py index b94fba9c395..dc578d77905 100644 --- a/temba/contacts/search/mailroom.py +++ b/temba/contacts/search/mailroom.py @@ -57,10 +57,10 @@ def search_contacts( org, query: str, *, group=None, sort: str = None, offset: int = None, exclude_ids=() ) -> mailroom.SearchResults: try: - group_uuid = group.uuid if group else None + group_id = group.id if group else None return mailroom.get_client().contact_search( - org.id, group_uuid=str(group_uuid), query=query, sort=sort, offset=offset, exclude_ids=exclude_ids + org.id, group_id=group_id, query=query, sort=sort, offset=offset, exclude_ids=exclude_ids ) except mailroom.MailroomException as e: raise SearchException.from_mailroom_exception(e) diff --git a/temba/contacts/search/omnibox.py b/temba/contacts/search/omnibox.py index fe5bb5f304a..8bda65d3bb9 100644 --- a/temba/contacts/search/omnibox.py +++ b/temba/contacts/search/omnibox.py @@ -5,8 +5,7 @@ from django.db.models import Q from django.db.models.functions import Upper -from temba.channels.models import Channel -from temba.contacts.models import Contact, ContactGroup, ContactGroupCount, ContactURN +from temba.contacts.models import Contact, ContactGroup, ContactGroupCount from temba.utils.models.es import IDSliceQuerySet from . import SearchException, search_contacts @@ -14,7 +13,6 @@ SEARCH_ALL_GROUPS = "g" SEARCH_STATIC_GROUPS = "s" SEARCH_CONTACTS = "c" -SEARCH_URNS = "u" def omnibox_query(org, **kwargs): @@ -24,25 +22,16 @@ def omnibox_query(org, **kwargs): # determine what type of group/contact/URN lookup is being requested contact_uuids = kwargs.get("c", None) # contacts with ids group_uuids = kwargs.get("g", None) # groups with ids - urn_ids = kwargs.get("u", None) # URNs with ids search = kwargs.get("search", None) # search of groups, contacts and URNs - types = list(kwargs.get("types", "")) # limit search to types (g | s | c | u) + types = list(kwargs.get("types", "")) # limit search to types (g | s | c) if contact_uuids: - return ( - Contact.objects.filter( - org=org, status=Contact.STATUS_ACTIVE, is_active=True, uuid__in=contact_uuids.split(",") - ) - .distinct() - .order_by("name") - ) - + return Contact.objects.filter( + org=org, status=Contact.STATUS_ACTIVE, is_active=True, uuid__in=contact_uuids.split(",") + ).order_by("name") elif group_uuids: return ContactGroup.get_groups(org).filter(uuid__in=group_uuids.split(",")).order_by("name") - elif urn_ids: - return ContactURN.objects.filter(org=org, id__in=urn_ids.split(",")).select_related("contact").order_by("path") - # searching returns something which acts enough like a queryset to be paged return omnibox_mixed_search(org, search, types) @@ -60,10 +49,10 @@ def term_search(queryset, fields, terms): def omnibox_mixed_search(org, query, types): """ - Performs a mixed group, contact and URN search, returning the first N matches of each type. + Performs a mixed group and contact search, returning the first N matches of each type. """ query_terms = query.split(" ") if query else None - search_types = types or (SEARCH_ALL_GROUPS, SEARCH_CONTACTS, SEARCH_URNS) + search_types = types or (SEARCH_ALL_GROUPS, SEARCH_CONTACTS) per_type_limit = 25 results = [] @@ -81,8 +70,11 @@ def omnibox_mixed_search(org, query, types): if SEARCH_CONTACTS in search_types: try: - # query elastic search for contact ids, then fetch contacts from db - search_results = search_contacts(org, query, group=org.active_contacts_group, sort="name") + if org.is_anon: + search_query = f"name ~ {json.dumps(query)}" + else: + search_query = f"name ~ {json.dumps(query)} OR urn ~ {json.dumps(query)}" + search_results = search_contacts(org, search_query, group=org.active_contacts_group, sort="name") contacts = IDSliceQuerySet( Contact, search_results.contact_ids, @@ -97,30 +89,14 @@ def omnibox_mixed_search(org, query, types): except SearchException: pass - if SEARCH_URNS in search_types: - if not org.is_anon and query and len(query) >= 3: - try: - # build an OR'ed query of all sendable schemes - sendable_schemes = org.get_schemes(Channel.ROLE_SEND) - scheme_query = " OR ".join(f"{s} ~ {json.dumps(query)}" for s in sendable_schemes) - search_results = search_contacts(org, scheme_query, group=org.active_contacts_group, sort="name") - urns = ContactURN.objects.filter( - contact_id__in=search_results.contact_ids, scheme__in=sendable_schemes - ) - results += list(urns.prefetch_related("contact").order_by(Upper("path"))[:per_type_limit]) - except SearchException: - pass - return results -def omnibox_serialize(org, groups, contacts, *, urns=(), raw_urns=(), json_encode=False): +def omnibox_serialize(org, groups, contacts, *, json_encode=False): """ Shortcut for proper way to serialize a queryset of groups and contacts for omnibox component """ - serialized = omnibox_results_to_dict(org, list(groups) + list(contacts) + list(urns), version="2") - - serialized += [{"type": "urn", "id": u} for u in raw_urns] + serialized = omnibox_results_to_dict(org, list(groups) + list(contacts)) if json_encode: return [json.dumps(_) for _ in serialized] @@ -131,16 +107,14 @@ def omnibox_serialize(org, groups, contacts, *, urns=(), raw_urns=(), json_encod def omnibox_deserialize(org, omnibox): group_ids = [item["id"] for item in omnibox if item["type"] == "group"] contact_ids = [item["id"] for item in omnibox if item["type"] == "contact"] - urns = [item["id"] for item in omnibox if item["type"] == "urn"] if not org.is_anon else [] return { "groups": org.groups.filter(uuid__in=group_ids, is_active=True), "contacts": Contact.objects.filter(uuid__in=contact_ids, org=org, is_active=True), - "urns": urns, } -def omnibox_results_to_dict(org, results, version: str = "1"): +def omnibox_results_to_dict(org, results): """ Converts the result of a omnibox query (queryset of contacts, groups or URNs, or a list) into a dict {id, text} """ @@ -151,42 +125,16 @@ def omnibox_results_to_dict(org, results, version: str = "1"): for obj in results: if isinstance(obj, ContactGroup): - if version == "1": - result = {"id": "g-%s" % obj.uuid, "text": obj.name, "extra": group_counts[obj]} - else: - result = {"id": obj.uuid, "name": obj.name, "type": "group", "count": group_counts[obj]} + result = {"id": str(obj.uuid), "name": obj.name, "type": "group", "count": group_counts[obj]} elif isinstance(obj, Contact): - if version == "1": - if org.is_anon: - result = {"id": "c-%s" % obj.uuid, "text": obj.get_display(org)} - else: - result = {"id": "c-%s" % obj.uuid, "text": obj.get_display(org), "extra": obj.get_urn_display()} - else: - if org.is_anon: - result = {"id": obj.uuid, "name": obj.get_display(org), "type": "contact"} - else: - result = { - "id": obj.uuid, - "name": obj.get_display(org), - "type": "contact", - "urn": obj.get_urn_display(), - } - - elif isinstance(obj, ContactURN): - if version == "1": - result = { - "id": "u-%d" % obj.id, - "text": obj.get_display(org), - "scheme": obj.scheme, - "extra": obj.contact.name or None, - } + if org.is_anon: + result = {"id": str(obj.uuid), "name": obj.get_display(org), "type": "contact"} else: result = { - "id": obj.identity, + "id": str(obj.uuid), "name": obj.get_display(org), - "contact": obj.contact.name or None, - "scheme": obj.scheme, - "type": "urn", + "type": "contact", + "urn": obj.get_urn_display(), } formatted.append(result) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index fa38d40dafe..30654b0b9fd 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -23,7 +23,7 @@ from temba.airtime.models import AirtimeTransfer from temba.campaigns.models import Campaign, CampaignEvent, EventFire -from temba.channels.models import Channel, ChannelEvent, ChannelLog +from temba.channels.models import ChannelEvent, ChannelLog from temba.contacts.search import SearchException, search_contacts from temba.contacts.views import ContactListView from temba.flows.models import Flow, FlowSession, FlowStart @@ -50,6 +50,7 @@ from temba.utils import json from temba.utils.dates import datetime_to_str, datetime_to_timestamp from temba.utils.templatetags.temba import datetime as datetime_tag, duration +from temba.utils.views import TEMBA_MENU_SELECTION from .models import ( URN, @@ -262,9 +263,7 @@ def test_list(self, mr_mocks): # we re-run the search for the response, but exclude Joe self.assertEqual( - call( - self.org.id, group_uuid=str(active_contacts.uuid), query="Joe", sort="", offset=0, exclude_ids=[joe.id] - ), + call(self.org.id, group_id=active_contacts.id, query="Joe", sort="", offset=0, exclude_ids=[joe.id]), mr_mocks.calls["contact_search"][-1], ) @@ -512,6 +511,12 @@ def test_read(self, mr_mocks): # login as admin self.login(self.admin) + self.new_ui() + + response = self.client.get(read_url) + self.assertContains(response, "Joe") + self.assertEqual("/contact/active", response.headers[TEMBA_MENU_SELECTION]) + self.old_ui() # block the contact joe.block(self.admin) @@ -519,6 +524,12 @@ def test_read(self, mr_mocks): self.assertContentMenu(read_url, self.admin, ["Edit", "Custom Fields"]) + self.new_ui() + response = self.client.get(read_url) + self.assertContains(response, "Joe") + self.assertEqual("/contact/blocked", response.headers[TEMBA_MENU_SELECTION]) + self.old_ui() + # can't access a deleted contact joe.release(self.admin) @@ -1952,11 +1963,6 @@ def test_omnibox(self, mr_mocks, mock_search_contacts): unready.status = ContactGroup.STATUS_EVALUATING unready.save(update_fields=("status",)) - joe_tel = self.joe.get_urn(URN.TEL_SCHEME) - joe_twitter = self.joe.get_urn(URN.TWITTER_SCHEME) - frank_tel = self.frank.get_urn(URN.TEL_SCHEME) - voldemort_tel = self.voldemort.get_urn(URN.TEL_SCHEME) - # Postgres will defer to strcoll for ordering which even for en_US.UTF-8 will return different results on OSX # and Ubuntu. To keep ordering consistent for this test, we don't let URNs start with + # (see http://postgresql.nabble.com/a-strange-order-by-behavior-td4513038.html) @@ -1966,16 +1972,13 @@ def test_omnibox(self, mr_mocks, mock_search_contacts): self.login(self.admin) - def omnibox_request(query, version="1"): + def omnibox_request(query): path = reverse("contacts.contact_omnibox") - response = self.client.get(f"{path}?{query}&v={version}") + response = self.client.get(f"{path}?{query}") return response.json()["results"] - # omnibox view will try to search it as a contact then as a URN so 2 calls to mailroom search endpoint + # configure mocking so we call actual search method but mailroom returns an error mr_mocks.error("ooh that doesn't look right") - mr_mocks.error("ooh that doesn't look right again") - - # for this one test we want to call the actual search method.. mock_search_contacts.side_effect = search_contacts # error is swallowed and we show no results @@ -1995,70 +1998,48 @@ def omnibox_request(query, version="1"): self.assertEqual( [ # all 4 groups A-Z - {"id": joe_and_frank.uuid, "name": "Joe and Frank", "type": "group", "count": 2}, - {"id": men.uuid, "name": "Men", "type": "group", "count": 0}, - {"id": nobody.uuid, "name": "Nobody", "type": "group", "count": 0}, - {"id": open_tickets.uuid, "name": "Open Tickets", "type": "group", "count": 0}, + {"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}, + {"id": str(men.uuid), "name": "Men", "type": "group", "count": 0}, + {"id": str(nobody.uuid), "name": "Nobody", "type": "group", "count": 0}, + {"id": str(open_tickets.uuid), "name": "Open Tickets", "type": "group", "count": 0}, # all 4 contacts A-Z - {"id": self.billy.uuid, "name": "Billy Nophone", "type": "contact", "urn": ""}, - {"id": self.frank.uuid, "name": "Frank Smith", "type": "contact", "urn": "250782222222"}, - {"id": self.joe.uuid, "name": "Joe Blow", "type": "contact", "urn": "blow80"}, - {"id": self.voldemort.uuid, "name": "250768383383", "type": "contact", "urn": "250768383383"}, + {"id": str(self.billy.uuid), "name": "Billy Nophone", "type": "contact", "urn": ""}, + {"id": str(self.frank.uuid), "name": "Frank Smith", "type": "contact", "urn": "250782222222"}, + {"id": str(self.joe.uuid), "name": "Joe Blow", "type": "contact", "urn": "blow80"}, + {"id": str(self.voldemort.uuid), "name": "250768383383", "type": "contact", "urn": "250768383383"}, ], - omnibox_request(query="", version="2"), + omnibox_request(query=""), ) - with self.assertNumQueries(17): + with self.assertNumQueries(14): mock_search_contacts.side_effect = [ SearchResults(query="", total=2, contact_ids=[self.billy.id, self.frank.id], metadata=QueryMetadata()), - SearchResults( - query="", - total=2, - contact_ids=[self.voldemort.id, self.frank.id], - metadata=QueryMetadata(), - ), ] self.assertEqual( [ - # 2 contacts - {"id": self.billy.uuid, "name": "Billy Nophone", "type": "contact", "urn": ""}, - {"id": self.frank.uuid, "name": "Frank Smith", "type": "contact", "urn": "250782222222"}, - # 2 sendable URNs with contact names - { - "id": "tel:250768383383", - "name": "250768383383", - "contact": None, - "scheme": "tel", - "type": "urn", - }, - { - "id": "tel:250782222222", - "name": "250782222222", - "type": "urn", - "contact": "Frank Smith", - "scheme": "tel", - }, + # 2 contacts A-Z + {"id": str(self.billy.uuid), "name": "Billy Nophone", "type": "contact", "urn": ""}, + {"id": str(self.frank.uuid), "name": "Frank Smith", "type": "contact", "urn": "250782222222"}, ], - omnibox_request(query="search=250", version="2"), + omnibox_request(query="search=250"), ) with self.assertNumQueries(15): mock_search_contacts.side_effect = [ SearchResults(query="", total=2, contact_ids=[self.billy.id, self.frank.id], metadata=QueryMetadata()), - SearchResults(query="", total=0, contact_ids=[], metadata=QueryMetadata()), ] self.assertEqual( [ # all 4 groups A-Z - {"id": f"g-{joe_and_frank.uuid}", "text": "Joe and Frank", "extra": 2}, - {"id": f"g-{men.uuid}", "text": "Men", "extra": 0}, - {"id": f"g-{nobody.uuid}", "text": "Nobody", "extra": 0}, - {"id": f"g-{open_tickets.uuid}", "text": "Open Tickets", "extra": 0}, + {"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}, + {"id": str(men.uuid), "name": "Men", "type": "group", "count": 0}, + {"id": str(nobody.uuid), "name": "Nobody", "type": "group", "count": 0}, + {"id": str(open_tickets.uuid), "name": "Open Tickets", "type": "group", "count": 0}, # 2 contacts A-Z - {"id": f"c-{self.billy.uuid}", "text": "Billy Nophone", "extra": ""}, - {"id": f"c-{self.frank.uuid}", "text": "Frank Smith", "extra": "250782222222"}, + {"id": str(self.billy.uuid), "name": "Billy Nophone", "type": "contact", "urn": ""}, + {"id": str(self.frank.uuid), "name": "Frank Smith", "type": "contact", "urn": "250782222222"}, ], omnibox_request(query=""), ) @@ -2068,10 +2049,10 @@ def omnibox_request(query, version="1"): # g = just the 4 groups self.assertEqual( [ - {"id": f"g-{joe_and_frank.uuid}", "text": "Joe and Frank", "extra": 2}, - {"id": f"g-{men.uuid}", "text": "Men", "extra": 0}, - {"id": f"g-{nobody.uuid}", "text": "Nobody", "extra": 0}, - {"id": f"g-{open_tickets.uuid}", "text": "Open Tickets", "extra": 0}, + {"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}, + {"id": str(men.uuid), "name": "Men", "type": "group", "count": 0}, + {"id": str(nobody.uuid), "name": "Nobody", "type": "group", "count": 0}, + {"id": str(open_tickets.uuid), "name": "Open Tickets", "type": "group", "count": 0}, ], omnibox_request("types=g"), ) @@ -2079,118 +2060,42 @@ def omnibox_request(query, version="1"): # s = just the 2 non-query manual groups self.assertEqual( [ - {"id": f"g-{joe_and_frank.uuid}", "text": "Joe and Frank", "extra": 2}, - {"id": f"g-{nobody.uuid}", "text": "Nobody", "extra": 0}, + {"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}, + {"id": str(nobody.uuid), "name": "Nobody", "type": "group", "count": 0}, ], omnibox_request("types=s"), ) - mock_search_contacts.side_effect = [ - SearchResults( - query="", - total=4, - contact_ids=[self.billy.id, self.frank.id, self.joe.id, self.voldemort.id], - metadata=QueryMetadata(), - ), - SearchResults( - query="", - total=3, - contact_ids=[self.voldemort.id, self.joe.id, self.frank.id], - metadata=QueryMetadata(), - ), - ] - self.assertEqual( - [ - {"id": f"c-{self.billy.uuid}", "text": "Billy Nophone", "extra": ""}, - {"id": f"c-{self.frank.uuid}", "text": "Frank Smith", "extra": "250782222222"}, - {"id": f"c-{self.joe.uuid}", "text": "Joe Blow", "extra": "blow80"}, - {"id": f"c-{self.voldemort.uuid}", "text": "250768383383", "extra": "250768383383"}, - {"id": f"u-{voldemort_tel.id}", "text": "250768383383", "extra": None, "scheme": "tel"}, - {"id": f"u-{joe_tel.id}", "text": "250781111111", "extra": "Joe Blow", "scheme": "tel"}, - {"id": f"u-{frank_tel.id}", "text": "250782222222", "extra": "Frank Smith", "scheme": "tel"}, - ], - omnibox_request("search=250&types=c,u"), - ) - - # search for Frank by phone - mock_search_contacts.side_effect = [ - SearchResults(query="name ~ 222", total=0, contact_ids=[], metadata=QueryMetadata()), - SearchResults(query="urn ~ 222", total=1, contact_ids=[self.frank.id], metadata=QueryMetadata()), - ] - self.assertEqual( - [{"id": f"u-{frank_tel.id}", "text": "250782222222", "extra": "Frank Smith", "scheme": "tel"}], - omnibox_request("search=222"), - ) - - # create twitter channel - self.create_channel("TT", "Twitter", "nyaruka") - - # add add an external channel so numbers get normalized - Channel.create(self.org, self.user, "RW", "EX", schemes=[URN.TEL_SCHEME]) - - # search for Joe - match on last name and twitter handle - mock_search_contacts.side_effect = [ - SearchResults(query="name ~ blow", total=1, contact_ids=[self.joe.id], metadata=QueryMetadata()), - SearchResults(query="urn ~ blow", total=1, contact_ids=[self.joe.id], metadata=QueryMetadata()), - ] - self.assertEqual( - [ - dict(id="c-%s" % self.joe.uuid, text="Joe Blow", extra="blow80"), - dict(id="u-%d" % joe_tel.pk, text="0781 111 111", extra="Joe Blow", scheme="tel"), - dict(id="u-%d" % joe_twitter.pk, text="blow80", extra="Joe Blow", scheme="twitter"), - ], - omnibox_request("search=BLOW"), - ) - # lookup by group id self.assertEqual( - [dict(id="g-%s" % joe_and_frank.uuid, text="Joe and Frank", extra=2)], + [{"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}], omnibox_request(f"g={joe_and_frank.uuid}"), ) - # lookup by URN ids - urn_query = "u=%d,%d" % (self.joe.get_urn(URN.TWITTER_SCHEME).id, self.frank.get_urn(URN.TEL_SCHEME).id) - self.assertEqual( - [ - dict(id="u-%d" % frank_tel.pk, text="0782 222 222", extra="Frank Smith", scheme="tel"), - dict(id="u-%d" % joe_twitter.pk, text="blow80", extra="Joe Blow", scheme="twitter"), - ], - omnibox_request(urn_query), - ) - with AnonymousOrg(self.org): mock_search_contacts.side_effect = [ - SearchResults(query="", total=1, contact_ids=[self.billy.id], metadata=QueryMetadata()) + SearchResults(query="", total=1, contact_ids=[self.billy.id], metadata=QueryMetadata()), + SearchResults(query="", total=1, contact_ids=[self.billy.id], metadata=QueryMetadata()), ] self.assertEqual( [ - # all 4 groups... - {"id": f"g-{joe_and_frank.uuid}", "text": "Joe and Frank", "extra": 2}, - {"id": f"g-{men.uuid}", "text": "Men", "extra": 0}, - {"id": f"g-{nobody.uuid}", "text": "Nobody", "extra": 0}, - {"id": f"g-{open_tickets.uuid}", "text": "Open Tickets", "extra": 0}, + # all 4 groups A-Z + {"id": str(joe_and_frank.uuid), "name": "Joe and Frank", "type": "group", "count": 2}, + {"id": str(men.uuid), "name": "Men", "type": "group", "count": 0}, + {"id": str(nobody.uuid), "name": "Nobody", "type": "group", "count": 0}, + {"id": str(open_tickets.uuid), "name": "Open Tickets", "type": "group", "count": 0}, # 1 contact - {"id": f"c-{self.billy.uuid}", "text": "Billy Nophone"}, - # no urns + {"id": str(self.billy.uuid), "name": "Billy Nophone", "type": "contact"}, ], omnibox_request(""), ) - # same search but with v2 format - mock_search_contacts.side_effect = [ - SearchResults(query="", total=1, contact_ids=[self.billy.id], metadata=QueryMetadata()) - ] self.assertEqual( [ - # all 4 groups A-Z - {"id": joe_and_frank.uuid, "name": "Joe and Frank", "type": "group", "count": 2}, - {"id": men.uuid, "name": "Men", "type": "group", "count": 0}, - {"id": nobody.uuid, "name": "Nobody", "type": "group", "count": 0}, - {"id": open_tickets.uuid, "name": "Open Tickets", "type": "group", "count": 0}, # 1 contact - {"id": self.billy.uuid, "name": "Billy Nophone", "type": "contact"}, + {"id": str(self.billy.uuid), "name": "Billy Nophone", "type": "contact"}, ], - omnibox_request("", version="2"), + omnibox_request("search=Billy"), ) # exclude blocked and stopped contacts @@ -2200,16 +2105,6 @@ def omnibox_request(query, version="1"): # lookup by contact uuids self.assertEqual(omnibox_request("c=%s,%s" % (self.joe.uuid, self.frank.uuid)), []) - # but still lookup by URN ids - urn_query = "u=%d,%d" % (self.joe.get_urn(URN.TWITTER_SCHEME).pk, self.frank.get_urn(URN.TEL_SCHEME).pk) - self.assertEqual( - [ - {"id": f"u-{frank_tel.id}", "text": "0782 222 222", "extra": "Frank Smith", "scheme": "tel"}, - {"id": f"u-{joe_twitter.id}", "text": "blow80", "extra": "Joe Blow", "scheme": "twitter"}, - ], - omnibox_request(urn_query), - ) - def test_history(self): url = reverse("contacts.contact_history", args=[self.joe.uuid]) @@ -2674,41 +2569,37 @@ def test_date_tags(self): ) def test_get_scheduled_messages(self): - self.just_joe = self.create_group("Just Joe", [self.joe]) + just_joe = self.create_group("Just Joe", [self.joe]) - self.assertFalse(self.joe.get_scheduled_broadcasts()) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) broadcast = Broadcast.create(self.org, self.admin, {"eng": "Hello"}, contacts=[self.frank]) - self.assertFalse(self.joe.get_scheduled_broadcasts()) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) broadcast.contacts.add(self.joe) - self.assertFalse(self.joe.get_scheduled_broadcasts()) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) schedule_time = timezone.now() + timedelta(days=2) broadcast.schedule = Schedule.create_schedule(self.org, self.admin, schedule_time, Schedule.REPEAT_NEVER) - broadcast.save() + broadcast.save(update_fields=("schedule",)) self.assertEqual(self.joe.get_scheduled_broadcasts().count(), 1) - self.assertTrue(broadcast in self.joe.get_scheduled_broadcasts()) + self.assertIn(broadcast, self.joe.get_scheduled_broadcasts()) broadcast.contacts.remove(self.joe) - self.assertFalse(self.joe.get_scheduled_broadcasts()) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) - broadcast.groups.add(self.just_joe) + broadcast.groups.add(just_joe) self.assertEqual(self.joe.get_scheduled_broadcasts().count(), 1) - self.assertTrue(broadcast in self.joe.get_scheduled_broadcasts()) + self.assertIn(broadcast, self.joe.get_scheduled_broadcasts()) - broadcast.groups.remove(self.just_joe) - self.assertFalse(self.joe.get_scheduled_broadcasts()) - - broadcast.urns.add(self.joe.get_urn()) - self.assertEqual(self.joe.get_scheduled_broadcasts().count(), 1) - self.assertTrue(broadcast in self.joe.get_scheduled_broadcasts()) + broadcast.groups.remove(just_joe) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) broadcast.schedule.next_fire = None broadcast.schedule.save(update_fields=["next_fire"]) - self.assertFalse(self.joe.get_scheduled_broadcasts()) + self.assertEqual(0, self.joe.get_scheduled_broadcasts().count()) def test_update_urns_field(self): update_url = reverse("contacts.contact_update", args=[self.joe.pk]) @@ -3850,6 +3741,15 @@ def test_get_for_api(self): with AnonymousOrg(self.org): self.assertEqual(urn.get_for_api(), "tel:********") + def test_ensure_normalization(self): + contact1 = self.create_contact("Bob", urns=["tel:+250788111111"]) + contact2 = self.create_contact("Jim", urns=["tel:+0788222222"]) + + self.org.normalize_contact_tels() + + self.assertEqual("+250788111111", contact1.urns.get().path) + self.assertEqual("+250788222222", contact2.urns.get().path) + class ContactFieldTest(TembaTest): def setUp(self): diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 8d3ca8a5c2f..e26c3b7ac94 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -577,7 +577,6 @@ def render_to_response(self, context, **response_kwargs): "id": "active", "count": counts[Contact.STATUS_ACTIVE], "name": _("Active"), - "verbose_name": _("Active Contacts"), "href": reverse("contacts.contact_list"), "icon": "icon.active", }, @@ -586,14 +585,12 @@ def render_to_response(self, context, **response_kwargs): "icon": "icon.archive", "count": counts[Contact.STATUS_ARCHIVED], "name": _("Archived"), - "verbose_name": _("Archived Contacts"), "href": reverse("contacts.contact_archived"), }, { "id": "blocked", "count": counts[Contact.STATUS_BLOCKED], "name": _("Blocked"), - "verbose_name": _("Blocked Contacts"), "href": reverse("contacts.contact_blocked"), "icon": "icon.contact_blocked", }, @@ -601,7 +598,6 @@ def render_to_response(self, context, **response_kwargs): "id": "stopped", "count": counts[Contact.STATUS_STOPPED], "name": _("Stopped"), - "verbose_name": _("Stopped Contacts"), "href": reverse("contacts.contact_stopped"), "icon": "icon.contact_stopped", }, @@ -650,7 +646,7 @@ def render_to_response(self, context, **response_kwargs): if group_items: menu.append( - {"id": "groups", "icon": "users", "name": _("Groups"), "items": group_items, "inline": True} + {"id": "filter", "icon": "users", "name": _("Groups"), "items": group_items, "inline": True} ) return JsonResponse({"results": menu}) @@ -658,7 +654,7 @@ def render_to_response(self, context, **response_kwargs): class Export(ModalMixin, OrgPermsMixin, SmartFormView): form_class = ExportForm - submit_button_name = "Export" + submit_button_name = _("Export") success_url = "@contacts.contact_list" def derive_params(self): @@ -755,7 +751,7 @@ def render_to_response(self, context, **response_kwargs): page = context["page_obj"] object_list = context["object_list"] - results = omnibox_results_to_dict(org, object_list, self.request.GET.get("v", "1")) + results = omnibox_results_to_dict(org, object_list) json_result = {"results": results, "more": page.has_next(), "total": len(results), "err": "nil"} @@ -766,6 +762,9 @@ class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): fields = ("name",) select_related = ("current_flow",) + def derive_menu_path(self): + return f"/contact/{self.object.get_status_display().lower()}" + def derive_title(self): return self.object.get_display() @@ -1039,12 +1038,12 @@ def get(self, request, *args, **kwargs): class List(ContentMenuMixin, ContactListView): title = _("Active Contacts") system_group = ContactGroup.TYPE_DB_ACTIVE + menu_path = "/contact/active" def get_bulk_actions(self): return ("block", "archive", "send", "start-flow") if self.has_org_perm("contacts.contact_update") else () def build_content_menu(self, menu): - is_spa = "HTTP_TEMBA_SPA" in self.request.META search = self.request.GET.get("search") # define save search conditions @@ -1075,7 +1074,7 @@ def build_content_menu(self, menu): _("New Group"), "new-group", reverse("contacts.contactgroup_create"), title=_("New Group") ) - if self.has_org_perm("contacts.contactfield_list") and not is_spa: + if self.has_org_perm("contacts.contactfield_list") and not self.is_spa(): menu.add_link(_("Manage Fields"), reverse("contacts.contactfield_list")) if self.has_org_perm("contacts.contact_export"): @@ -1144,9 +1143,8 @@ class Filter(OrgObjPermsMixin, ContentMenuMixin, ContactListView): template_name = "contacts/contact_filter.haml" def build_content_menu(self, menu): - is_spa = "HTTP_TEMBA_SPA" in self.request.META - if self.has_org_perm("contacts.contactfield_list") and not is_spa: + if self.has_org_perm("contacts.contactfield_list") and not self.is_spa(): menu.add_link(_("Manage Fields"), reverse("contacts.contactfield_list")) if not self.group.is_system and self.has_org_perm("contacts.contactgroup_update"): @@ -1229,7 +1227,7 @@ class Update(SpaMixin, ComponentFormMixin, NonAtomicMixin, ModalMixin, OrgObjPer submit_button_name = _("Save Changes") def get_success_url(self): - if "HTTP_TEMBA_SPA" in self.request.META: + if self.is_spa(): return "hide" return super().get_success_url() @@ -1812,6 +1810,8 @@ def post(self, request, *args, **kwargs): return HttpResponse(json.dumps(payload), status=400, content_type="application/json") class List(ContentMenuMixin, ContactFieldListView): + menu_path = "/contact/fields" + def build_content_menu(self, menu): menu.add_modax( _("New Field"), @@ -1892,6 +1892,7 @@ class Meta: form_class = Form success_message = "" success_url = "id@contacts.contactimport_preview" + menu_path = "/contact/import" def get_form_kwargs(self): kwargs = super().get_form_kwargs() diff --git a/temba/dashboard/views.py b/temba/dashboard/views.py index c083f77e2f7..9abcb8264a4 100644 --- a/temba/dashboard/views.py +++ b/temba/dashboard/views.py @@ -22,6 +22,7 @@ class Home(SpaMixin, OrgPermsMixin, SmartTemplateView): title = _("Dashboard") permission = "orgs.org_dashboard" template_name = "dashboard/home.haml" + menu_path = "/settings/dashboard" class MessageHistory(OrgPermsMixin, SmartTemplateView): diff --git a/temba/flows/models.py b/temba/flows/models.py index d29a63dfcee..aec3589742b 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1283,7 +1283,7 @@ def delete(self, interrupt: bool = True): self.save(update_fields=("delete_from_results",)) if interrupt and self.session and self.session.status == FlowSession.STATUS_WAITING: - mailroom.queue_interrupt(self.org, session=self.session) + mailroom.queue_interrupt(self.org, sessions=[self.session]) super().delete() diff --git a/temba/flows/tasks.py b/temba/flows/tasks.py index 411bee12c10..2af51e63eab 100644 --- a/temba/flows/tasks.py +++ b/temba/flows/tasks.py @@ -1,4 +1,5 @@ import logging +from collections import defaultdict from datetime import datetime, timedelta import pytz @@ -10,6 +11,7 @@ from django.utils import timezone from django.utils.timesince import timesince +from temba import mailroom from temba.contacts.models import ContactField, ContactGroup from temba.utils import chunk_list from temba.utils.crons import cron_task @@ -27,7 +29,6 @@ FlowStartCount, ) -FLOW_TIMEOUT_KEY = "flow_timeouts_%y_%m_%d" logger = logging.getLogger(__name__) @@ -84,10 +85,38 @@ def trim_flow_revisions(): logger.info(f"Trimmed {count} flow revisions since {last_trim} in {elapsed}") +@cron_task() +def interrupt_flow_sessions(): + """ + Interrupt old flow sessions which have exceeded the absolute time limit + """ + + before = timezone.now() - timedelta(days=90) + num_interrupted = 0 + + # get old sessions and organize into lists by org + by_org = defaultdict(list) + sessions = ( + FlowSession.objects.filter(created_on__lte=before, status=FlowSession.STATUS_WAITING) + .only("id", "org") + .select_related("org") + .order_by("id") + ) + for session in sessions: + by_org[session.org].append(session) + + for org, sessions in by_org.items(): + for batch in chunk_list(sessions, 100): + mailroom.queue_interrupt(org, sessions=batch) + num_interrupted += len(sessions) + + return {"interrupted": num_interrupted} + + @cron_task() def trim_flow_sessions(): """ - Cleanup old flow sessions + Cleanup ended flow sessions """ trim_before = timezone.now() - settings.RETENTION_PERIODS["flowsession"] diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 787e2a3bc6b..70f65f27938 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -53,7 +53,13 @@ FlowVersionConflictException, get_flow_user, ) -from .tasks import squash_flow_counts, trim_flow_revisions, trim_flow_sessions, update_session_wait_expires +from .tasks import ( + interrupt_flow_sessions, + squash_flow_counts, + trim_flow_revisions, + trim_flow_sessions, + update_session_wait_expires, +) from .views import FlowCRUDL @@ -253,6 +259,7 @@ def test_editor(self): flow = self.get_flow("color") self.login(self.admin) + self.new_ui() flow_editor_url = reverse("flows.flow_editor", args=[flow.uuid]) @@ -1944,7 +1951,12 @@ def test_views(self): # add a trigger on this flow Trigger.objects.create( - org=self.org, keyword="unique", flow=flow1, created_by=self.admin, modified_by=self.admin + org=self.org, + keyword="unique", + match_type=Trigger.MATCH_FIRST_WORD, + flow=flow1, + created_by=self.admin, + modified_by=self.admin, ) # create a new surveyor flow @@ -2007,7 +2019,12 @@ def test_views(self): # create another trigger so there are two in the way trigger = Trigger.objects.create( - org=self.org, keyword="this", flow=flow1, created_by=self.admin, modified_by=self.admin + org=self.org, + keyword="this", + match_type=Trigger.MATCH_FIRST_WORD, + flow=flow1, + created_by=self.admin, + modified_by=self.admin, ) response = self.client.post( @@ -2052,6 +2069,7 @@ def test_views(self): self.assertEqual(flow3.triggers.count(), 5) self.assertEqual(flow3.triggers.filter(is_archived=True).count(), 2) self.assertEqual(flow3.triggers.filter(is_archived=False).count(), 3) + self.assertEqual(flow3.triggers.filter(is_archived=False, match_type=Trigger.MATCH_FIRST_WORD).count(), 3) self.assertEqual(flow3.triggers.filter(is_archived=False).exclude(groups=None).count(), 0) # update flow with unformatted keyword @@ -3987,6 +4005,48 @@ def test_big_ids(self): class FlowSessionTest(TembaTest): + @mock_mailroom + def test_interrupt(self, mr_mocks): + contact = self.create_contact("Ben Haggerty", phone="+250788123123") + + def create_session(org, created_on: datetime): + return FlowSession.objects.create( + uuid=uuid4(), + org=org, + contact=contact, + created_on=created_on, + output_url="http://sessions.com/123.json", + status=FlowSession.STATUS_WAITING, + wait_started_on=timezone.now(), + wait_expires_on=timezone.now() + timedelta(days=7), + wait_resume_on_expire=False, + ) + + create_session(self.org, timezone.now() - timedelta(days=89)) + session2 = create_session(self.org, timezone.now() - timedelta(days=91)) + session3 = create_session(self.org, timezone.now() - timedelta(days=92)) + session4 = create_session(self.org2, timezone.now() - timedelta(days=92)) + + interrupt_flow_sessions() + + self.assertEqual( + [ + { + "type": "interrupt_sessions", + "org_id": self.org.id, + "queued_on": matchers.Datetime(), + "task": {"session_ids": [session2.id, session3.id]}, + }, + { + "type": "interrupt_sessions", + "org_id": self.org2.id, + "queued_on": matchers.Datetime(), + "task": {"session_ids": [session4.id]}, + }, + ], + mr_mocks.queued_batch_tasks, + ) + def test_trim(self): contact = self.create_contact("Ben Haggerty", phone="+250788123123") flow = self.get_flow("color") diff --git a/temba/flows/views.py b/temba/flows/views.py index ea74491ba69..98e63e4c5de 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -228,14 +228,11 @@ def derive_menu(self): menu = [] menu.append( - self.create_menu_item( - name=_("Active"), verbose_name=_("Active Flows"), icon="icon.active", href="flows.flow_list" - ) + self.create_menu_item(menu_id="", name=_("Active"), icon="icon.active", href="flows.flow_list") ) menu.append( self.create_menu_item( name=_("Archived"), - verbose_name=_("Archived Flows"), icon="icon.archive", href="flows.flow_archived", ) @@ -464,7 +461,9 @@ def post_save(self, obj): if self.form.cleaned_data["keyword_triggers"]: keywords = self.form.cleaned_data["keyword_triggers"].split(",") for keyword in keywords: - Trigger.create(org, user, Trigger.TYPE_KEYWORD, flow=obj, keyword=keyword) + Trigger.create( + org, user, Trigger.TYPE_KEYWORD, flow=obj, keyword=keyword, match_type=Trigger.MATCH_FIRST_WORD + ) return obj @@ -676,6 +675,7 @@ def post_save(self, obj): org=org, keyword=keyword, trigger_type=Trigger.TYPE_KEYWORD, + match_type=Trigger.MATCH_FIRST_WORD, flow=obj, created_by=user, modified_by=user, @@ -812,6 +812,7 @@ def derive_queryset(self, *args, **kwargs): class List(BaseList): title = _("Active Flows") bulk_actions = ("archive", "label", "download-results") + menu_path = "/flow/active" def derive_queryset(self, *args, **kwargs): queryset = super().derive_queryset(*args, **kwargs) @@ -903,6 +904,11 @@ def get_queryset(self, **kwargs): class Editor(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): slug_url_kwarg = "uuid" + def derive_menu_path(self): + if self.object.is_archived: + return "/flow/archived" + return "/flow/active" + def derive_title(self): return self.object.name @@ -941,9 +947,9 @@ def get_context_data(self, *args, **kwargs): if key.endswith(".js") and filename.endswith(".js"): scripts.append(filename) - context["scripts"] = scripts - context["styles"] = styles - context["dev_mode"] = dev_mode + context["scripts"] = scripts + context["styles"] = styles + context["dev_mode"] = dev_mode flow = self.object @@ -1755,14 +1761,13 @@ class Form(forms.ModelForm): recipients = OmniboxField( label=_("Recipients"), required=False, - help_text=_("The contacts to send the message to"), + help_text=_("The contacts to send the message to."), widget=OmniboxChoice( attrs={ - "placeholder": _("Recipients, enter contacts or groups"), + "placeholder": _("Search for contacts or groups"), "widget_only": True, "groups": True, "contacts": True, - "urns": True, } ), ) diff --git a/temba/mailroom/client.py b/temba/mailroom/client.py index c3cb55ea2b4..476a29b704b 100644 --- a/temba/mailroom/client.py +++ b/temba/mailroom/client.py @@ -121,20 +121,6 @@ def __init__(self, base_url, auth_token): def version(self): return self._request("", post=False).get("version") - def expression_migrate(self, expression): - """ - Migrates a legacy expression to latest engine version - """ - if not expression: - return "" - - try: - resp = self._request("expression/migrate", {"expression": expression}) - return resp["migrated"] - except FlowValidationException: - # if the expression is invalid.. just return original - return expression - def flow_migrate(self, definition, to_version=None): """ Migrates a flow definition to the specified spec version @@ -239,10 +225,12 @@ def contact_interrupt(self, org_id: int, user_id: int, contact_id: int): return self._request("contact/interrupt", payload) - def contact_search(self, org_id, group_uuid, query, sort, offset=0, exclude_ids=()) -> SearchResults: + def contact_search( + self, org_id: int, group_id: int, query: str, sort: str, offset=0, exclude_ids=() + ) -> SearchResults: payload = { "org_id": org_id, - "group_uuid": group_uuid, + "group_id": group_id, "exclude_ids": exclude_ids, "query": query, "sort": sort, diff --git a/temba/mailroom/queue.py b/temba/mailroom/queue.py index 7a49e367959..2ab860db990 100644 --- a/temba/mailroom/queue.py +++ b/temba/mailroom/queue.py @@ -89,7 +89,7 @@ def queue_broadcast(broadcast): "translations": broadcast.translations, "template_state": "unevaluated", "base_language": broadcast.base_language, - "urns": broadcast.raw_urns or [], + "urns": broadcast.urns or [], "contact_ids": list(broadcast.contacts.values_list("id", flat=True)), "group_ids": list(broadcast.groups.values_list("id", flat=True)), "broadcast_id": broadcast.id, @@ -167,20 +167,20 @@ def queue_interrupt_channel(org, channel): _queue_batch_task(org.id, BatchTask.INTERRUPT_CHANNEL, task, HIGH_PRIORITY) -def queue_interrupt(org, *, contacts=None, flow=None, session=None): +def queue_interrupt(org, *, contacts=None, flow=None, sessions=None): """ Queues an interrupt task for handling by mailroom """ - assert contacts or flow or session, "must specify either a set of contacts or a flow or a session" + assert contacts or flow or sessions, "must specify either a set of contacts or a flow or sessions" task = {} if contacts: task["contact_ids"] = [c.id for c in contacts] if flow: task["flow_ids"] = [flow.id] - if session: - task["session_ids"] = [session.id] + if sessions: + task["session_ids"] = [s.id for s in sessions] _queue_batch_task(org.id, BatchTask.INTERRUPT_SESSIONS, task, HIGH_PRIORITY) diff --git a/temba/mailroom/tests.py b/temba/mailroom/tests.py index 3d41afeea76..7c13324f9a9 100644 --- a/temba/mailroom/tests.py +++ b/temba/mailroom/tests.py @@ -30,25 +30,6 @@ def test_version(self): self.assertEqual("5.3.4", version) - def test_expression_migrate(self): - with patch("requests.post") as mock_post: - mock_post.return_value = MockResponse(200, '{"migrated": "@fields.age"}') - migrated = get_client().expression_migrate("@contact.age") - - self.assertEqual("@fields.age", migrated) - - mock_post.assert_called_once_with( - "http://localhost:8090/mr/expression/migrate", - headers={"User-Agent": "Temba"}, - json={"expression": "@contact.age"}, - ) - - # in case of error just return original - mock_post.return_value = MockResponse(422, '{"error": "bad isn\'t a thing"}') - migrated = get_client().expression_migrate("@(bad)") - - self.assertEqual("@(bad)", migrated) - def test_flow_migrate(self): flow_def = {"nodes": [{"val": Decimal("1.23")}]} @@ -373,7 +354,7 @@ def test_contact_search(self, mock_post): } """, ) - response = get_client().contact_search(1, "2752dbbc-723f-4007-8bc5-b3720835d3a9", "frank", "-created_on") + response = get_client().contact_search(1, 2, "frank", "-created_on") self.assertEqual('name ~ "frank"', response.query) self.assertEqual(["name"], response.metadata.attributes) @@ -383,7 +364,7 @@ def test_contact_search(self, mock_post): json={ "query": "frank", "org_id": 1, - "group_uuid": "2752dbbc-723f-4007-8bc5-b3720835d3a9", + "group_id": 2, "exclude_ids": (), "offset": 0, "sort": "-created_on", @@ -393,7 +374,7 @@ def test_contact_search(self, mock_post): mock_post.return_value = MockResponse(400, '{"error":"no such field age"}') with self.assertRaises(MailroomException): - get_client().contact_search(1, "2752dbbc-723f-4007-8bc5-b3720835d3a9", "age > 10", "-created_on") + get_client().contact_search(1, 2, "age > 10", "-created_on") def test_ticket_assign(self): with patch("requests.post") as mock_post: @@ -469,10 +450,6 @@ def test_request_failure(self): {"endpoint": "flow/migrate", "request": matchers.Dict(), "response": {"errors": ["Bad request", "Doh!"]}}, ) - def test_empty_expression(self): - # empty is as empty does - self.assertEqual("", get_client().expression_migrate("")) - class MailroomQueueTest(TembaTest): def setUp(self): diff --git a/temba/msgs/migrations/0215_scheduled_bcasts_urns_to_contacts.py b/temba/msgs/migrations/0215_scheduled_bcasts_urns_to_contacts.py new file mode 100644 index 00000000000..f5185417a85 --- /dev/null +++ b/temba/msgs/migrations/0215_scheduled_bcasts_urns_to_contacts.py @@ -0,0 +1,44 @@ +# Generated by Django 4.0.9 on 2023-02-10 14:27 + +from django.db import migrations + +from temba.utils import chunk_list + + +def convert_urns_to_contacts(apps, schema_editor): # pragma: no cover + Broadcast = apps.get_model("msgs", "Broadcast") + + # find non-deleted scheduled broadcasts with urns + broadcasts = list( + Broadcast.objects.filter(is_active=True) + .exclude(schedule=None) + .exclude(urns=None) + .only("id") + .prefetch_related("urns") + ) + + num_updated = 0 + + for batch in chunk_list(broadcasts, 100): + for bcast in batch: + for urn in bcast.urns.all(): + if urn.contact_id: + bcast.contacts.add(urn.contact_id) + + bcast.urns.clear() + num_updated += 1 + + print(f"Updated {num_updated} of {len(broadcasts)} scheduled broadcasts with URNs") + + +def reverse(apps, schema_editor): # pragma: no cover + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0214_broadcast_query_msg_quick_replies"), + ] + + operations = [migrations.RunPython(convert_urns_to_contacts, reverse)] diff --git a/temba/msgs/migrations/0216_alter_broadcast_send_all.py b/temba/msgs/migrations/0216_alter_broadcast_send_all.py new file mode 100644 index 00000000000..a3b3fc80af2 --- /dev/null +++ b/temba/msgs/migrations/0216_alter_broadcast_send_all.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.9 on 2023-02-10 16:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0215_scheduled_bcasts_urns_to_contacts"), + ] + + operations = [ + migrations.AlterField( + model_name="broadcast", + name="send_all", + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/temba/msgs/migrations/0217_remove_broadcast_send_all_remove_broadcast_urns.py b/temba/msgs/migrations/0217_remove_broadcast_send_all_remove_broadcast_urns.py new file mode 100644 index 00000000000..ef99d3d6d34 --- /dev/null +++ b/temba/msgs/migrations/0217_remove_broadcast_send_all_remove_broadcast_urns.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.9 on 2023-02-13 19:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0216_alter_broadcast_send_all"), + ] + + operations = [ + migrations.RemoveField( + model_name="broadcast", + name="send_all", + ), + migrations.RemoveField( + model_name="broadcast", + name="urns", + ), + ] diff --git a/temba/msgs/migrations/0218_broadcast_urns.py b/temba/msgs/migrations/0218_broadcast_urns.py new file mode 100644 index 00000000000..5790acd659b --- /dev/null +++ b/temba/msgs/migrations/0218_broadcast_urns.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.9 on 2023-02-14 16:31 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0217_remove_broadcast_send_all_remove_broadcast_urns"), + ] + + operations = [ + migrations.AddField( + model_name="broadcast", + name="urns", + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None), + ), + ] diff --git a/temba/msgs/migrations/0219_remove_broadcast_raw_urns.py b/temba/msgs/migrations/0219_remove_broadcast_raw_urns.py new file mode 100644 index 00000000000..78ea869f57b --- /dev/null +++ b/temba/msgs/migrations/0219_remove_broadcast_raw_urns.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.9 on 2023-02-16 15:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0218_broadcast_urns"), + ] + + operations = [ + migrations.RemoveField( + model_name="broadcast", + name="raw_urns", + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 4d9a0f3c184..860161f8c93 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -183,15 +183,9 @@ class Broadcast(models.Model): # recipients of this broadcast groups = models.ManyToManyField(ContactGroup, related_name="addressed_broadcasts") contacts = models.ManyToManyField(Contact, related_name="addressed_broadcasts") - urns = models.ManyToManyField(ContactURN, related_name="addressed_broadcasts") + urns = ArrayField(models.TextField(), null=True) query = models.TextField(null=True) - # URN strings that mailroom will turn into contacts and URN objects - raw_urns = ArrayField(models.TextField(), null=True) - - # whether this broadcast should send to all URNs for each contact - send_all = models.BooleanField(default=False) - # message content in different languages, e.g. {"eng": {"text": "Hello", "attachments": [...]}, "spa": ...} translations = models.JSONField() base_language = models.CharField(max_length=3) # ISO-639-3 @@ -215,16 +209,16 @@ def create( cls, org, user, - text: dict, + text: dict[str, str], *, + attachments: dict[str, list] = None, + base_language: str = None, groups=None, contacts=None, urns: list[str] = None, contact_ids: list[int] = None, - base_language: str = None, channel: Channel = None, ticket=None, - send_all: bool = False, **kwargs, ): if not base_language: @@ -233,13 +227,20 @@ def create( assert base_language in text, "no translation for base language" assert groups or contacts or contact_ids or urns, "can't create broadcast without recipients" + # merge text and attachments into single dict of translations + translations = {lang: {"text": t} for lang, t in text.items()} + if attachments: + for lang, atts in attachments.items(): + if lang not in translations: + translations[lang] = {} + translations[lang]["attachments"] = atts + broadcast = cls.objects.create( org=org, channel=channel, ticket=ticket, - send_all=send_all, + translations=translations, base_language=base_language, - translations={lang: {"text": t} for lang, t in text.items()}, created_by=user, modified_by=user, **kwargs, @@ -323,8 +324,8 @@ def _set_recipients(self, *, groups=None, contacts=None, urns: list[str] = None, self.contacts.add(*contacts) if urns: - self.raw_urns = urns - self.save(update_fields=("raw_urns",)) + self.urns = urns + self.save(update_fields=("urns",)) if contact_ids: RelatedModel = self.contacts.through diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 991333079ff..eb52ae0cd6f 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -28,6 +28,7 @@ from temba.tests import AnonymousOrg, CRUDLTestMixin, TembaTest, mock_uuids from temba.tests.engine import MockSessionWriter from temba.tests.s3 import MockS3Client, jsonlgz_encode +from temba.utils.views import TEMBA_MENU_SELECTION from .tasks import squash_msg_counts from .templatetags.sms import as_icon @@ -1693,7 +1694,8 @@ def test_filter(self): self.assertRedirect(response, reverse("orgs.org_choose")) # can as org viewer user - response = self.requestView(label3_url, self.user) + response = self.requestView(label3_url, self.user, HTTP_TEMBA_SPA=1) + self.assertEqual(f"/msg/labels/{label3.uuid}", response.headers[TEMBA_MENU_SELECTION]) self.assertEqual(200, response.status_code) self.assertEqual(("label",), response.context["actions"]) self.assertContentMenu(label3_url, self.user, ["Download", "Usages"]) # no update or delete @@ -1939,35 +1941,12 @@ def test_send(self, mock_queue_broadcast): omnibox.value(), ) - # initialize form based on an existing URN - response = self.client.get(f"{send_url}?u={self.joe.get_urn().id}") - omnibox = response.context["form"]["omnibox"] - self.assertEqual( - [ - { - "id": "tel:+12025550149", - "name": "(202) 555-0149", - "type": "urn", - "contact": "Joe Blow", - "scheme": "tel", - } - ], - omnibox.value(), - ) - - # submit with a send to a group, a contact, an existing URN and a raw URN + # submit with a send to a group and a contact response = self.client.post( send_url, { "text": "Hey Joe, where you goin?", - "omnibox": omnibox_serialize( - self.org, - [self.joe_and_frank], - [self.frank], - urns=[self.joe.get_urn()], - raw_urns=["tel:0780000001"], - json_encode=True, - ), + "omnibox": omnibox_serialize(self.org, [self.joe_and_frank], [self.frank], json_encode=True), }, ) self.assertEqual(200, response.status_code) @@ -1976,7 +1955,6 @@ def test_send(self, mock_queue_broadcast): self.assertEqual({"und": {"text": "Hey Joe, where you goin?"}}, broadcast.translations) self.assertEqual({self.joe_and_frank}, set(broadcast.groups.all())) self.assertEqual({self.frank}, set(broadcast.contacts.all())) - self.assertEqual(["tel:+12025550149", "tel:0780000001"], broadcast.raw_urns) mock_queue_broadcast.assert_called_once_with(broadcast) @@ -1986,16 +1964,6 @@ def test_send(self, mock_queue_broadcast): ) self.assertFormError(response, "form", "omnibox", "At least one recipient is required.") - # try to submit with an invalid URN - response = self.client.post( - send_url, - { - "text": "Broken", - "omnibox": omnibox_serialize(self.org, [], [], raw_urns=["tel:$$$$$$"], json_encode=True), - }, - ) - self.assertFormError(response, "form", "omnibox", "'tel:$$$$$$' is not a valid URN.") - # if we release our send channel we also can't start send self.channel.release(self.admin) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index e81bff21d8c..65f25573900 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -201,6 +201,7 @@ class Scheduled(MsgListView): search_fields = ("translations__und__icontains", "contacts__urns__path__icontains") system_label = SystemLabel.TYPE_SCHEDULED default_order = ("-created_on",) + menu_path = "/msg/scheduled" def build_content_menu(self, menu): if self.has_org_perm("msgs.broadcast_scheduled_create"): @@ -218,7 +219,7 @@ def get_queryset(self, **kwargs): .get_queryset(**kwargs) .filter(is_active=True) .select_related("org", "schedule") - .prefetch_related("groups", "contacts", "urns") + .prefetch_related("groups", "contacts") ) class ScheduledCreate(OrgPermsMixin, ModalMixin, SmartFormView): @@ -226,14 +227,13 @@ class Form(ScheduleFormMixin, Form): omnibox = OmniboxField( label=_("Recipients"), required=True, - help_text=_("The contacts to send the message to"), + help_text=_("The contacts to send the message to."), widget=OmniboxChoice( attrs={ - "placeholder": _("Recipients, enter contacts or groups"), + "placeholder": _("Search for contacts or groups"), "widget_only": True, "groups": True, "contacts": True, - "urns": True, } ), ) @@ -252,7 +252,7 @@ def __init__(self, org, *args, **kwargs): def clean_omnibox(self): recipients = omnibox_deserialize(self.org, self.cleaned_data["omnibox"]) - if not (recipients["groups"] or recipients["contacts"] or recipients["urns"]): + if not (recipients["groups"] or recipients["contacts"]): raise forms.ValidationError(_("At least one recipient is required.")) return recipients @@ -291,13 +291,14 @@ def form_valid(self, form): {"und": text}, groups=list(recipients["groups"]), contacts=list(recipients["contacts"]), - urns=list(recipients["urns"]), schedule=schedule, ) return self.render_modal_response(form) class ScheduledRead(SpaMixin, ContentMenuMixin, FormaxMixin, OrgObjPermsMixin, SmartReadView): + menu_path = "/msg/scheduled" + def derive_title(self): return _("Scheduled Message") @@ -344,7 +345,7 @@ def derive_initial(self): return { "message": self.object.get_text(), - "omnibox": omnibox_results_to_dict(org, recipients, version="2"), + "omnibox": omnibox_results_to_dict(org, recipients), } def save(self, *args, **kwargs): @@ -357,7 +358,7 @@ def save(self, *args, **kwargs): # set our new message broadcast.translations = {broadcast.base_language: {"text": form.cleaned_data["message"]}} - broadcast.update_recipients(groups=omnibox["groups"], contacts=omnibox["contacts"], urns=omnibox["urns"]) + broadcast.update_recipients(groups=omnibox["groups"], contacts=omnibox["contacts"]) broadcast.save() return broadcast @@ -381,14 +382,13 @@ class Form(Form): omnibox = OmniboxField( label=_("Recipients"), required=False, - help_text=_("The contacts to send the message to"), + help_text=_("The contacts to send the message to."), widget=OmniboxChoice( attrs={ - "placeholder": _("Recipients, enter contacts or groups"), + "placeholder": _("Search for contacts or groups"), "widget_only": True, "groups": True, "contacts": True, - "urns": True, } ), ) @@ -432,20 +432,17 @@ def clean(self): def derive_initial(self): initial = super().derive_initial() - org = self.request.org - urn_ids = [_ for _ in self.request.GET.get("u", "").split(",") if _] + org = self.request.org contact_uuids = [_ for _ in self.request.GET.get("c", "").split(",") if _] - if contact_uuids or urn_ids: + if contact_uuids: params = {} if len(contact_uuids) > 0: params["c"] = ",".join(contact_uuids) - if len(urn_ids) > 0: - params["u"] = ",".join(urn_ids) results = omnibox_query(org, **params) - initial["omnibox"] = omnibox_results_to_dict(org, results, version="2") + initial["omnibox"] = omnibox_results_to_dict(org, results) initial["step_node"] = self.request.GET.get("step_node", None) return initial @@ -495,19 +492,16 @@ def form_valid(self, form): omnibox = omnibox_deserialize(org, form.cleaned_data["omnibox"]) groups = list(omnibox["groups"]) contacts = list(omnibox["contacts"]) - urns = list(omnibox["urns"]) broadcast = Broadcast.create( - org, user, {"und": text}, groups=groups, contacts=contacts, urns=urns, status=Msg.STATUS_QUEUED + org, user, {"und": text}, groups=groups, contacts=contacts, status=Msg.STATUS_QUEUED ) self.post_save(broadcast) super().form_valid(form) analytics.track( - self.request.user, - "temba.broadcast_created", - dict(contacts=len(contacts), groups=len(groups), urns=len(urns)), + self.request.user, "temba.broadcast_created", dict(contacts=len(contacts), groups=len(groups)) ) response = self.render_to_response(self.get_context_data()) @@ -548,21 +542,21 @@ def derive_menu(self): menu = [ self.create_menu_item( + menu_id="inbox", name=_("Inbox"), href=reverse("msgs.msg_inbox"), count=counts[SystemLabel.TYPE_INBOX], icon="icon.inbox", ), self.create_menu_item( + menu_id="flow", name=_("Flows"), - verbose_name=_("Flow Messages"), href=reverse("msgs.msg_flow"), count=counts[SystemLabel.TYPE_FLOWS], icon="icon.flow", ), self.create_menu_item( name=_("Archived"), - verbose_name=_("Archived Messages"), href=reverse("msgs.msg_archived"), count=counts[SystemLabel.TYPE_ARCHIVED], icon="icon.archive", @@ -575,20 +569,17 @@ def derive_menu(self): ), self.create_menu_item( name=_("Sent"), - verbose_name=_("Sent Messages"), href=reverse("msgs.msg_sent"), count=counts[SystemLabel.TYPE_SENT], ), self.create_menu_item( name=_("Failed"), - verbose_name=_("Failed Messages"), href=reverse("msgs.msg_failed"), count=counts[SystemLabel.TYPE_FAILED], ), self.create_divider(), self.create_menu_item( name=_("Scheduled"), - verbose_name=_("Scheduled Messages"), href=reverse("msgs.broadcast_scheduled"), count=counts[SystemLabel.TYPE_SCHEDULED], ), @@ -757,7 +748,7 @@ def get_context_data(self, **kwargs): org=self.request.org, status=Broadcast.STATUS_QUEUED, schedule=None, is_active=True ) .select_related("org") - .prefetch_related("groups", "contacts", "urns") + .prefetch_related("groups", "contacts") .order_by("-created_on") ) return context @@ -793,6 +784,9 @@ class Filter(MsgListView): template_name = "msgs/msg_filter.haml" bulk_actions = ("label",) + def derive_menu_path(self): + return f"/msg/labels/{self.label.uuid}" + def derive_title(self, *args, **kwargs): return self.label.name diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 25817da75a6..8ad3024a140 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -57,6 +57,7 @@ from temba.tickets.types.mailgun import MailgunType from temba.triggers.models import Trigger from temba.utils import brands, json, languages +from temba.utils.views import TEMBA_MENU_SELECTION from .context_processors import RolePermsWrapper from .models import BackupToken, Invitation, Org, OrgMembership, OrgRole, User @@ -2841,15 +2842,6 @@ def test_contacts(self): class OrgCRUDLTest(TembaTest, CRUDLTestMixin): - def test_spa(self): - Group.objects.get(name="Beta").user_set.add(self.admin) - - self.login(self.admin) - - deep_link = reverse("spa.level_2", args=["tickets", "all", "open"]) - response = self.client.get(deep_link) - self.assertEqual(200, response.status_code) - def assertMenu(self, url, count, contains_names=[]): response = self.assertListFetch(url, allow_viewers=True, allow_editors=True, allow_agents=True) menu = response.json()["results"] @@ -3849,9 +3841,11 @@ def test_administration(self): self.assertStaffOnly(manage_url) self.assertStaffOnly(update_url) + self.new_ui() def assertOrgFilter(query: str, expected_orgs: list): response = self.client.get(manage_url + query) + self.assertIsNotNone(response.headers.get(TEMBA_MENU_SELECTION, None)) self.assertEqual(expected_orgs, list(response.context["object_list"])) assertOrgFilter("", [self.org2, self.org]) @@ -4131,8 +4125,9 @@ def test_list(self): self.assertStaffOnly(list_url) - response = self.requestView(list_url, self.customer_support) + response = self.requestView(list_url, self.customer_support, new_ui=True) self.assertEqual(9, len(response.context["object_list"])) + self.assertEqual("/staff/users/all", response.headers[TEMBA_MENU_SELECTION]) response = self.requestView(list_url + "?filter=beta", self.customer_support) self.assertEqual(set(), set(response.context["object_list"])) diff --git a/temba/orgs/urls.py b/temba/orgs/urls.py index d62df281806..62c906af7f4 100644 --- a/temba/orgs/urls.py +++ b/temba/orgs/urls.py @@ -6,7 +6,6 @@ ConfirmAccessView, LoginView, OrgCRUDL, - SpaView, TwoFactorBackupView, TwoFactorVerifyView, UserCRUDL, @@ -27,15 +26,6 @@ if integration_urls: integration_type_urls.append(re_path("^%s/" % integration.slug, include(integration_urls))) - -spa = SpaView.as_view() -sections = r"campaigns|contacts|tickets|triggers|messages|channels|flows|plugins|settings|staff" -level_0 = rf"^(?P{sections})/" -level_1 = rf"{level_0}(?P.+)/" -level_2 = rf"{level_1}(?P.+)/" -level_3 = rf"{level_2}(?P.+)/" -level_4 = rf"{level_3}(?P.+)/" - urlpatterns += [ re_path(r"^login/$", check_login, name="users.user_check_login"), re_path(r"^users/login/$", LoginView.as_view(), name="users.login"), @@ -43,11 +33,4 @@ re_path(r"^users/two-factor/backup/$", TwoFactorBackupView.as_view(), name="users.two_factor_backup"), re_path(r"^users/confirm-access/$", ConfirmAccessView.as_view(), name="users.confirm_access"), re_path(r"^integrations/", include(integration_type_urls)), - # for spa - re_path(rf"{level_0}$", spa, name="spa"), - re_path(rf"{level_1}$", spa, name="spa.level_1"), - re_path(rf"{level_2}$", spa, name="spa.level_2"), - re_path(rf"{level_3}$", spa, name="spa.level_3"), - re_path(rf"{level_4}$", spa, name="spa.level_4"), - re_path(rf"{level_4}.*$", spa, name="spa.level_max"), ] diff --git a/temba/orgs/views.py b/temba/orgs/views.py index c8c60e3cf1d..8bf37cc2cc2 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -681,6 +681,9 @@ class List(StaffOnlyMixin, SpaMixin, SmartListView): search_fields = ("email__icontains", "first_name__icontains", "last_name__icontains") filters = (("all", _("All")), ("beta", _("Beta")), ("staff", _("Staff"))) + def derive_menu_path(self): + return f"/staff/users/{self.request.GET.get('filter', 'all')}" + @csrf_exempt def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) @@ -939,6 +942,7 @@ def clean_password(self): submit_button_name = _("Enable") permission = "orgs.org_two_factor" title = _("Enable Two-factor Authentication") + menu_path = "/settings/2fa" def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -985,6 +989,7 @@ def clean_password(self): submit_button_name = _("Disable") permission = "orgs.org_two_factor" title = _("Disable Two-factor Authentication") + menu_path = "/settings/2fa" def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -1002,6 +1007,7 @@ class TwoFactorTokens( ): permission = "orgs.org_two_factor" title = _("Two-factor Authentication") + menu_path = "/settings/2fa" def pre_process(self, request, *args, **kwargs): # if 2FA isn't enabled for this user, take them to the enable view instead @@ -1028,6 +1034,7 @@ def get_context_data(self, **kwargs): class Account(SpaMixin, FormaxMixin, InferOrgMixin, OrgPermsMixin, SmartReadView): title = _("Account") permission = "orgs.org_account" + menu_path = "/settings/account" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -1039,57 +1046,6 @@ def derive_formax_sections(self, formax, context): formax.add_section("org", reverse("orgs.user_edit"), icon="icon-user") -class SpaView(InferOrgMixin, OrgPermsMixin, SmartTemplateView): - permission = "orgs.org_home" - template_name = "spa_frame.haml" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["org"] = self.request.org - context["is_spa"] = True - context["servicing"] = self.request.org in self.request.user.get_orgs() - - dev_mode = getattr(settings, "EDITOR_DEV_MODE", False) - prefix = "/dev" if dev_mode else settings.STATIC_URL - - # get our list of assets to incude - scripts = [] - styles = [] - - if dev_mode: # pragma: no cover - response = requests.get("http://localhost:3000/asset-manifest.json") - data = response.json() - else: - with open("node_modules/@nyaruka/flow-editor/build/asset-manifest.json") as json_file: - data = json.load(json_file) - - for key, filename in data.get("files").items(): - - # tack on our prefix for dev mode - filename = prefix + filename - - # ignore precache manifest - if key.startswith("precache-manifest") or key.startswith("service-worker"): - continue - - # css files - if key.endswith(".css") and filename.endswith(".css"): - styles.append(filename) - - # javascript - if key.endswith(".js") and filename.endswith(".js"): - scripts.append(filename) - - context["scripts"] = scripts - context["styles"] = styles - context["dev_mode"] = dev_mode - - return context - - def has_permission(self, request, *args, **kwargs): - return not request.user.is_anonymous and request.user.is_beta - - class MenuMixin(OrgPermsMixin): def create_divider(self): return {"type": "divider"} @@ -1134,7 +1090,6 @@ def create_menu_item( inline=False, bottom=False, popup=False, - verbose_name=None, event=False, ): @@ -1144,7 +1099,6 @@ def create_menu_item( menu_item = {"name": name, "inline": inline} menu_item["id"] = menu_id if menu_id else slugify(name) menu_item["bottom"] = bottom - menu_item["verbose_name"] = verbose_name menu_item["popup"] = popup menu_item["avatar"] = avatar @@ -1272,7 +1226,7 @@ def derive_menu(self): if self.request.user.settings.two_factor_enabled: menu.append( self.create_menu_item( - menu_id="security", + menu_id="2fa", name=_("Security"), icon="icon.two_factor_enabled", href=reverse("orgs.user_two_factor_tokens"), @@ -1281,7 +1235,7 @@ def derive_menu(self): else: menu.append( self.create_menu_item( - menu_id="authentication", + menu_id="2fa", name=_("Enable 2FA"), icon="icon.two_factor_disabled", href=reverse("orgs.user_two_factor_enable"), @@ -1347,11 +1301,13 @@ def derive_menu(self): items = [ self.create_menu_item( + menu_id="message", name=_("Messages"), icon="icon.message", href=reverse("archives.archive_message"), ), self.create_menu_item( + menu_id="run", name=_("Flow Runs"), icon="icon.flow", href=reverse("archives.archive_run"), @@ -1422,20 +1378,20 @@ def derive_menu(self): menu += [ self.create_menu_item( - menu_id="messages", name=_("Messages"), icon="icon.messages", endpoint="msgs.msg_menu" + menu_id="msg", name=_("Messages"), icon="icon.messages", endpoint="msgs.msg_menu" ), self.create_menu_item( - menu_id="contacts", name=_("Contacts"), icon="icon.contacts", endpoint="contacts.contact_menu" + menu_id="contact", name=_("Contacts"), icon="icon.contacts", endpoint="contacts.contact_menu" ), - self.create_menu_item(menu_id="flows", name=_("Flows"), icon="icon.flows", endpoint="flows.flow_menu"), + self.create_menu_item(menu_id="flow", name=_("Flows"), icon="icon.flows", endpoint="flows.flow_menu"), self.create_menu_item( - menu_id="triggers", name=_("Triggers"), icon="icon.triggers", endpoint="triggers.trigger_menu" + menu_id="trigger", name=_("Triggers"), icon="icon.triggers", endpoint="triggers.trigger_menu" ), self.create_menu_item( - menu_id="campaigns", name=_("Campaigns"), icon="icon.campaigns", endpoint="campaigns.campaign_menu" + menu_id="campaign", name=_("Campaigns"), icon="icon.campaigns", endpoint="campaigns.campaign_menu" ), self.create_menu_item( - menu_id="tickets", + menu_id="ticket", name=_("Tickets"), icon="icon.tickets", endpoint="tickets.ticket_menu", @@ -1498,9 +1454,10 @@ def clean_import_file(self): success_message = _("Import successful") form_class = FlowImportForm + title = _("Import Flows") def get_success_url(self): # pragma: needs cover - return reverse("orgs.org_home") + return reverse("flows.flow_list") def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -1522,6 +1479,8 @@ def form_valid(self, form): return super().form_valid(form) # pragma: needs cover class Export(SpaMixin, InferOrgMixin, OrgPermsMixin, SmartTemplateView): + title = _("Create Export") + def post(self, request, *args, **kwargs): org = self.get_object() @@ -2115,6 +2074,9 @@ class Manage(StaffOnlyMixin, SpaMixin, SmartListView): def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) + def derive_menu_path(self): + return f"/staff/{self.request.GET.get('filter', 'all')}" + def get_owner(self, obj): owner = obj.get_owner() return f"{owner.name} ({owner.email})" @@ -2446,6 +2408,7 @@ class Meta: success_message = "" submit_button_name = _("Save Changes") title = _("Users") + menu_path = "/settings/users" def pre_process(self, request, *args, **kwargs): if Org.FEATURE_USERS not in request.org.features: @@ -2568,6 +2531,7 @@ class SubOrgs(SpaMixin, ContentMenuMixin, OrgPermsMixin, InferOrgMixin, SmartLis fields = ("name", "contacts", "manage", "created_on") title = _("Workspaces") link_fields = [] + menu_path = "/settings/workspaces" def build_content_menu(self, menu): org = self.get_object() @@ -3200,6 +3164,7 @@ class Meta: success_message = "" title = _("Resthooks") success_url = "@orgs.org_resthooks" + menu_path = "/settings/resthooks" def get_form(self): form = super().get_form() @@ -3272,6 +3237,7 @@ def get_token(self, org): class Workspace(SpaMixin, FormaxMixin, ContentMenuMixin, InferOrgMixin, OrgPermsMixin, SmartReadView): title = _("Workspace") + menu_path = "/settings/workspace" def build_content_menu(self, menu): menu.add_link(_("New Channel"), reverse("channels.channel_claim"), as_button=True) @@ -3567,9 +3533,8 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) org = self.get_object() - context["sub_orgs"] = org.children.filter(is_active=True) - context["is_spa"] = "HTTP_TEMBA_SPA" in self.request.META + context["is_spa"] = self.request.COOKIES.get("nav") == "2" return context class EditSubOrg(SpaMixin, ModalMixin, Edit): diff --git a/temba/request_logs/tests.py b/temba/request_logs/tests.py index 8b7529486f2..5da2a6ac60e 100644 --- a/temba/request_logs/tests.py +++ b/temba/request_logs/tests.py @@ -10,6 +10,7 @@ from temba.tests import CRUDLTestMixin, TembaTest, mock_object from temba.tickets.models import Ticketer from temba.tickets.types.mailgun import MailgunType +from temba.utils.views import TEMBA_MENU_SELECTION from .models import HTTPLog from .tasks import trim_http_logs @@ -88,7 +89,9 @@ def test_webhooks(self): webhooks_url = reverse("request_logs.httplog_webhooks") log_url = reverse("request_logs.httplog_read", args=[l1.id]) - response = self.assertListFetch(webhooks_url, allow_viewers=False, allow_editors=True, context_objects=[l1]) + response = self.assertListFetch( + webhooks_url, allow_viewers=False, allow_editors=True, context_objects=[l1], new_ui=True + ) self.assertContains(response, "Webhook Calls") self.assertContains(response, log_url) @@ -128,14 +131,19 @@ def test_classifier(self): log_url = reverse("request_logs.httplog_read", args=[l1.id]) response = self.assertListFetch( - list_url, allow_viewers=False, allow_editors=False, allow_org2=False, context_objects=[l1] + list_url, allow_viewers=False, allow_editors=False, allow_org2=False, context_objects=[l1], new_ui=True ) + + self.assertEqual(f"/settings/classifiers/{c1.uuid}", response.headers[TEMBA_MENU_SELECTION]) self.assertContains(response, "Intents Synced") self.assertContains(response, log_url) self.assertNotContains(response, "Classifier Called") # view the individual log item - response = self.assertReadFetch(log_url, allow_viewers=False, allow_editors=False, context_object=l1) + response = self.assertReadFetch( + log_url, allow_viewers=False, allow_editors=False, context_object=l1, new_ui=True + ) + self.assertEqual(f"/settings/classifiers/{c1.uuid}", response.headers[TEMBA_MENU_SELECTION]) self.assertContains(response, "200") self.assertContains(response, "org1.bar") self.assertNotContains(response, "org2.bar") diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 667602f70bd..89ff298ad47 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -73,6 +73,9 @@ class Classifier(BaseObjLogsView): source_url = "uuid@classifiers.classifier_read" title = _("Recent Classifier Events") + def derive_menu_path(self): + return f"/settings/classifiers/{self.source.uuid}" + def get_source(self, uuid): return Classifier.objects.filter(uuid=uuid, is_active=True) @@ -91,8 +94,13 @@ class Read(SpaMixin, ContentMenuMixin, OrgObjPermsMixin, SmartReadView): def permission(self): return "request_logs.httplog_webhooks" if self.get_object().flow else "request_logs.httplog_read" + def derive_menu_path(self): + if self.get_object().classifier: + return f"/settings/classifiers/{self.object.classifier.uuid}" + def build_content_menu(self, menu): - if self.object.classifier: + object = self.get_object() + if object and object.classifier: menu.add_link( - _("Classifier Log"), reverse("request_logs.httplog_classifier", args=[self.object.classifier.uuid]) + _("Classifier Log"), reverse("request_logs.httplog_classifier", args=[object.classifier.uuid]) ) diff --git a/temba/settings_common.py b/temba/settings_common.py index a4e3bb5ae88..5fc938925e6 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -855,7 +855,8 @@ "check-elasticsearch-lag": {"task": "check_elasticsearch_lag", "schedule": timedelta(seconds=300)}, "delete-released-orgs": {"task": "delete_released_orgs", "schedule": crontab(hour=4, minute=0)}, "fail-old-messages": {"task": "fail_old_messages", "schedule": crontab(hour=0, minute=0)}, - "resolve-twitter-ids-task": {"task": "resolve_twitter_ids", "schedule": timedelta(seconds=900)}, + "interrupt-flow-sessions": {"task": "interrupt_flow_sessions", "schedule": crontab(hour=23, minute=30)}, + "resolve-twitter-ids": {"task": "resolve_twitter_ids", "schedule": timedelta(seconds=900)}, "refresh-whatsapp-tokens": {"task": "refresh_whatsapp_tokens", "schedule": crontab(hour=6, minute=0)}, "refresh-whatsapp-templates": {"task": "refresh_whatsapp_templates", "schedule": timedelta(seconds=900)}, "send-notification-emails": {"task": "send_notification_emails", "schedule": timedelta(seconds=60)}, diff --git a/temba/tests/base.py b/temba/tests/base.py index a00792e11d0..837ba8bff3e 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -136,6 +136,9 @@ def setUpLocations(self): def make_beta(self, user): user.groups.add(Group.objects.get(name="Beta")) + def unbeta(self, user): + user.groups.remove(Group.objects.get(name="Beta")) + def clear_cache(self): """ Clears the redis cache. We are extra paranoid here and check that redis host is 'localhost' @@ -167,6 +170,14 @@ def login(self, user, update_last_auth_on: bool = True, choose_org=None): session.update({"org_id": choose_org.id}) session.save() + def old_ui(self): + self.unbeta(self.admin) + self.client.cookies.load({"nav": "1"}) + + def new_ui(self): + self.make_beta(self.admin) + self.client.cookies.load({"nav": "2"}) + def import_file(self, filename, site="http://rapidpro.io", substitutions=None): data = self.get_import_json(filename, substitutions=substitutions) self.org.import_app(data, self.admin, site=site) diff --git a/temba/tests/crudl.py b/temba/tests/crudl.py index 46a805c9541..d802f881367 100644 --- a/temba/tests/crudl.py +++ b/temba/tests/crudl.py @@ -8,7 +8,7 @@ class CRUDLTestMixin: def get_test_users(self): return self.user, self.editor, self.agent, self.admin, self.admin2 - def requestView(self, url, user, *, post_data=None, checks=(), choose_org=None, **kwargs): + def requestView(self, url, user, *, post_data=None, checks=(), choose_org=None, new_ui=False, **kwargs): """ Requests the given URL as a specific user and runs a set of checks """ @@ -25,15 +25,27 @@ def requestView(self, url, user, *, post_data=None, checks=(), choose_org=None, for check in checks: check.pre_check(self, pre_msg_prefix) + if new_ui or "HTTP_TEMBA_SPA" in kwargs: + self.client.cookies.load({"nav": "2"}) + if user: + self.make_beta(user) + response = self.client.post(url, post_data, **kwargs) if method == "POST" else self.client.get(url, **kwargs) + # remove our spa cookie if we added it + if new_ui or "HTTP_TEMBA_SPA" in kwargs: + self.client.cookies.load({"nav": "1"}) + + if user: + self.unbeta(user) + for check in checks: check.check(self, response, msg_prefix) return response def assertReadFetch( - self, url, *, allow_viewers, allow_editors, allow_agents=False, context_object=None, status=200 + self, url, *, allow_viewers, allow_editors, allow_agents=False, context_object=None, status=200, new_ui=False ): """ Fetches a read view as different users @@ -48,7 +60,7 @@ def as_user(user, allowed): else: checks = [LoginRedirectOr404()] - return self.requestView(url, user, checks=checks, choose_org=self.org) + return self.requestView(url, user, checks=checks, choose_org=self.org, new_ui=new_ui) as_user(None, allowed=False) as_user(viewer, allowed=allow_viewers) @@ -68,6 +80,7 @@ def assertListFetch( context_objects=None, context_object_count=None, status=200, + new_ui=False, ): viewer, editor, agent, admin, org2_admin = self.get_test_users() @@ -82,7 +95,7 @@ def as_user(user, allowed): else: checks = [LoginRedirect()] - return self.requestView(url, user, checks=checks, choose_org=self.org) + return self.requestView(url, user, checks=checks, choose_org=self.org, new_ui=new_ui) as_user(None, allowed=False) as_user(viewer, allowed=allow_viewers) diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index a5dea02f267..bf72f3bca48 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -197,7 +197,7 @@ def parse_query(self, org_id: int, query: str, parse_only: bool = False, group_u ) @_client_method - def contact_search(self, org_id, group_uuid, query, sort, offset=0, exclude_ids=()): + def contact_search(self, org_id, group_id, query, sort, offset=0, exclude_ids=()): mock = self.mocks._contact_search.get(query or "") assert mock, f"missing contact_search mock for query '{query}'" diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index ccd6255f75e..b4013bcf4e6 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -239,7 +239,9 @@ def test_list(self): # deep link into a page that doesn't have our ticket deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" + self.make_beta(self.admin) self.login(self.admin) + response = self.client.get(deep_link) # now our ticket is listed as the uuid and we were redirected to all/open @@ -248,13 +250,14 @@ def test_list(self): self.assertEqual(str(ticket.uuid), response.context["uuid"]) # fetch with spa flag + self.new_ui() response = self.client.get( list_url, content_type="application/json", - HTTP_TEMBA_SPA="1", HTTP_TEMBA_REFERER_PATH=f"/tickets/mine/open/{ticket.uuid}", ) - self.assertEqual("spa.html", response.context["base_template"]) + + self.assertEqual("spa_frame.haml", response.context["base_template"]) self.assertEqual(("tickets", "mine", "open", str(ticket.uuid)), response.context["temba_referer"]) def test_menu(self): @@ -270,15 +273,14 @@ def test_menu(self): menu = response.json()["results"] self.assertEqual( [ - {"id": "mine", "name": "My Tickets", "icon": "icon.tickets_mine", "count": 2, "verbose_name": None}, + {"id": "mine", "name": "My Tickets", "icon": "icon.tickets_mine", "count": 2}, { "id": "unassigned", "name": "Unassigned", "icon": "icon.tickets_unassigned", "count": 1, - "verbose_name": "Unassigned Tickets", }, - {"id": "all", "name": "All", "icon": "icon.tickets_all", "count": 3, "verbose_name": "All Tickets"}, + {"id": "all", "name": "All", "icon": "icon.tickets_all", "count": 3}, ], menu, ) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 38d0e041e67..76afdab8812 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -113,6 +113,9 @@ def get_notification_scope(self) -> tuple: return "tickets:activity", "" return "", "" + def derive_menu_path(self): + return f"/ticket/{self.kwargs.get('folder', 'mine')}/" + @cached_property def tickets_path(self) -> tuple: """ @@ -123,15 +126,6 @@ def tickets_path(self) -> tuple: uuid = self.kwargs.get("uuid") in_page = False - path = self.spa_referrer_path - if path and len(path) > 1 and path[0] == "tickets": - if not folder and len(path) > 1: - folder = path[1] - if not status and len(path) > 2: - status = path[2] - if not uuid and len(path) > 3: - uuid = path[3] - # if we have a uuid make sure it is in our first page of tickets if uuid: status_code = Ticket.STATUS_OPEN if status == "open" else Ticket.STATUS_CLOSED @@ -191,7 +185,6 @@ def derive_menu(self): { "id": folder.slug, "name": folder.name, - "verbose_name": folder.verbose_name, "icon": folder.icon, "count": counts[folder.slug], } diff --git a/temba/triggers/migrations/0028_alter_trigger_channel_alter_trigger_flow_and_more.py b/temba/triggers/migrations/0028_alter_trigger_channel_alter_trigger_flow_and_more.py new file mode 100644 index 00000000000..f9f7015d90e --- /dev/null +++ b/temba/triggers/migrations/0028_alter_trigger_channel_alter_trigger_flow_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.0.9 on 2023-02-10 15:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("channels", "0158_squashed"), + ("flows", "0317_alter_flow_base_language"), + ("triggers", "0027_squashed"), + ] + + operations = [ + migrations.AlterField( + model_name="trigger", + name="channel", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="triggers", to="channels.channel" + ), + ), + migrations.AlterField( + model_name="trigger", + name="flow", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="triggers", to="flows.flow" + ), + ), + migrations.AlterField( + model_name="trigger", + name="keyword", + field=models.CharField(max_length=16, null=True), + ), + migrations.AlterField( + model_name="trigger", + name="match_type", + field=models.CharField( + choices=[("F", "Message starts with the keyword"), ("O", "Message contains only the keyword")], + max_length=1, + null=True, + ), + ), + ] diff --git a/temba/triggers/models.py b/temba/triggers/models.py index 76bc261f6d3..f32aedec872 100644 --- a/temba/triggers/models.py +++ b/temba/triggers/models.py @@ -47,6 +47,7 @@ def export_def(self, trigger) -> dict: "exclude_groups": [group.as_export_ref() for group in trigger.exclude_groups.order_by("name")], "channel": trigger.channel.uuid if trigger.channel else None, "keyword": trigger.keyword, + "match_type": trigger.match_type, } return {f: all_fields[f] for f in self.export_fields} @@ -87,50 +88,19 @@ class Trigger(SmartModel): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="triggers") trigger_type = models.CharField(max_length=1, default=TYPE_KEYWORD) is_archived = models.BooleanField(default=False) - - keyword = models.CharField( - verbose_name=_("Keyword"), - max_length=KEYWORD_MAX_LEN, - null=True, - blank=True, - help_text=_("Word to match in the message text."), - ) - - referrer_id = models.CharField(max_length=255, null=True) - - flow = models.ForeignKey( - Flow, - on_delete=models.PROTECT, - verbose_name=_("Flow"), - help_text=_("Which flow will be started."), - related_name="triggers", - ) + flow = models.ForeignKey(Flow, on_delete=models.PROTECT, related_name="triggers") + channel = models.ForeignKey(Channel, on_delete=models.PROTECT, null=True, related_name="triggers") # who trigger applies to groups = models.ManyToManyField(ContactGroup, related_name="triggers_included") exclude_groups = models.ManyToManyField(ContactGroup, related_name="triggers_excluded") contacts = models.ManyToManyField(Contact, related_name="triggers") # scheduled triggers only + keyword = models.CharField(max_length=KEYWORD_MAX_LEN, null=True) + match_type = models.CharField(max_length=1, choices=MATCH_TYPES, null=True) + referrer_id = models.CharField(max_length=255, null=True) schedule = models.OneToOneField("schedules.Schedule", on_delete=models.PROTECT, null=True, related_name="trigger") - match_type = models.CharField( - max_length=1, - choices=MATCH_TYPES, - default=MATCH_FIRST_WORD, - null=True, - verbose_name=_("Trigger When"), - help_text=_("How to match a message with a keyword."), - ) - - channel = models.ForeignKey( - Channel, - on_delete=models.PROTECT, - verbose_name=_("Channel"), - null=True, - related_name="triggers", - help_text=_("The associated channel."), - ) - @classmethod def create( cls, @@ -145,6 +115,7 @@ def create( contacts=(), keyword=None, schedule=None, + match_type=None, **kwargs, ): assert flow.flow_type != Flow.TYPE_SURVEY, "can't create triggers for surveyor flows" @@ -161,6 +132,7 @@ def create( channel=channel, keyword=keyword, schedule=schedule, + match_type=match_type, created_by=user, modified_by=user, **kwargs, @@ -239,6 +211,7 @@ def get_conflicts( return cls.objects.none() conflicts = org.triggers.filter(is_active=True, trigger_type=trigger_type) + if not include_archived: conflicts = conflicts.filter(is_archived=False) @@ -298,6 +271,10 @@ def import_def(cls, org, user, definition: dict, same_site: bool = False): flow_uuid = trigger_def["flow"]["uuid"] flow = org.flows.get(uuid=flow_uuid, is_active=True) + match_type = None + if trigger_type.code == Trigger.TYPE_KEYWORD: + match_type = trigger_def.get("match_type", Trigger.MATCH_FIRST_WORD) + # see if that trigger already exists conflicts = cls.get_conflicts( org, @@ -329,6 +306,7 @@ def import_def(cls, org, user, definition: dict, same_site: bool = False): groups=groups, exclude_groups=exclude_groups, keyword=trigger_def.get("keyword"), + match_type=match_type, ) @classmethod diff --git a/temba/triggers/tests.py b/temba/triggers/tests.py index be443b765c9..4b7644a032f 100644 --- a/temba/triggers/tests.py +++ b/temba/triggers/tests.py @@ -12,6 +12,7 @@ from temba.flows.models import Flow from temba.schedules.models import Schedule from temba.tests import CRUDLTestMixin, TembaTest +from temba.utils.views import TEMBA_MENU_SELECTION from .models import Trigger from .types import KeywordTriggerType @@ -120,7 +121,6 @@ def assert_export_import(self, trigger: Trigger, expected_def: dict): # do import to clean workspace Trigger.objects.all().delete() self.org.import_app(export_def, self.admin) - # should have a single identical trigger imported = Trigger.objects.get( org=trigger.org, @@ -293,6 +293,7 @@ def test_export_import_keyword(self): groups=[doctors, farmers], exclude_groups=[testers], keyword="join", + match_type=Trigger.MATCH_FIRST_WORD, ) self.assert_export_import( @@ -306,6 +307,7 @@ def test_export_import_keyword(self): ], "exclude_groups": [{"uuid": str(testers.uuid), "name": "Testers"}], "keyword": "join", + "match_type": "F", }, ) @@ -1146,10 +1148,16 @@ def test_update_keyword(self): self.assertEqual({group1, group2}, set(trigger.groups.all())) self.assertEqual({group3}, set(trigger.exclude_groups.all())) - # error if keyword is not defined + # error if keyword is not defined or invalid self.assertUpdateSubmit( update_url, {"keyword": "", "flow": flow.id, "match_type": "F"}, + form_errors={"keyword": "This field is required."}, + object_unchanged=trigger, + ) + self.assertUpdateSubmit( + update_url, + {"keyword": "two words", "flow": flow.id, "match_type": "F"}, form_errors={ "keyword": "Must be a single word containing only letters and numbers, or a single emoji character." }, @@ -1379,6 +1387,7 @@ def test_archived(self): ) self.assertEqual(("restore",), response.context["actions"]) + self.new_ui() # can restore it self.client.post(reverse("triggers.trigger_archived"), {"action": "restore", "objects": trigger1.id}) @@ -1462,8 +1471,9 @@ def test_type_lists(self): catchall_url = reverse("triggers.trigger_type", kwargs={"type": "catch_all"}) response = self.assertListFetch( - keyword_url, allow_viewers=True, allow_editors=True, context_objects=[trigger2, trigger1] + keyword_url, allow_viewers=True, allow_editors=True, context_objects=[trigger2, trigger1], new_ui=True ) + self.assertEqual("/trigger/keyword", response.headers[TEMBA_MENU_SELECTION]) self.assertEqual(("archive",), response.context["actions"]) # can search by keyword diff --git a/temba/triggers/types.py b/temba/triggers/types.py index d6b0f1206a8..18fc98f0e80 100644 --- a/temba/triggers/types.py +++ b/temba/triggers/types.py @@ -26,6 +26,18 @@ class KeywordTriggerType(TriggerType): ) class Form(BaseTriggerForm): + keyword = forms.CharField( + max_length=Trigger.KEYWORD_MAX_LEN, + label=_("Keyword"), + help_text=_("Word to match in the message text."), + ) + match_type = forms.ChoiceField( + choices=Trigger.MATCH_TYPES, + initial=Trigger.MATCH_FIRST_WORD, + label=_("Trigger When"), + help_text=_("How to match a message with a keyword."), + ) + def __init__(self, org, user, *args, **kwargs): super().__init__(org, user, Trigger.TYPE_KEYWORD, *args, **kwargs) @@ -43,7 +55,7 @@ class Meta(BaseTriggerForm.Meta): name = _("Keyword") title = _("Keyword Triggers") allowed_flow_types = (Flow.TYPE_MESSAGE, Flow.TYPE_VOICE) - export_fields = TriggerType.export_fields + ("keyword",) + export_fields = TriggerType.export_fields + ("keyword", "match_type") required_fields = TriggerType.required_fields + ("keyword",) form = Form @@ -214,7 +226,9 @@ class NewConversationTriggerType(TriggerType): """ class Form(BaseTriggerForm): - channel = TembaChoiceField(Channel.objects.none(), label=_("Channel"), required=True) + channel = TembaChoiceField( + Channel.objects.none(), label=_("Channel"), help_text=_("The associated channel."), required=True + ) def __init__(self, org, user, *args, **kwargs): super().__init__(org, user, Trigger.TYPE_NEW_CONVERSATION, *args, **kwargs) diff --git a/temba/triggers/views.py b/temba/triggers/views.py index d95d3613e7e..5ec0ee28f01 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -37,6 +37,7 @@ class BaseTriggerForm(forms.ModelForm): flow = TembaChoiceField( Flow.objects.none(), label=_("Flow"), + help_text=_("Which flow will be started."), required=True, widget=SelectWidget(attrs={"placeholder": _("Select a flow"), "searchable": True}), ) @@ -219,7 +220,6 @@ def derive_menu(self): menu.append( self.create_menu_item( name=_("Active"), - verbose_name=_("Active Triggers"), count=org_triggers.filter(is_archived=False).count(), href=reverse("triggers.trigger_list"), icon="icon.active", @@ -229,7 +229,6 @@ def derive_menu(self): menu.append( self.create_menu_item( name=_("Archived"), - verbose_name=_("Archived Triggers"), icon="icon.archive", count=org_triggers.filter(is_archived=True).count(), href=reverse("triggers.trigger_archived"), @@ -255,6 +254,7 @@ def derive_menu(self): class Create(SpaMixin, FormaxMixin, OrgFilterMixin, OrgPermsMixin, SmartTemplateView): title = _("New Trigger") + menu_path = "/trigger/new-trigger" def derive_formax_sections(self, formax, context): def add_section(name, url, icon): @@ -322,7 +322,7 @@ class CreateKeyword(BaseCreate): trigger_type = Trigger.TYPE_KEYWORD def get_create_kwargs(self, user, cleaned_data): - return {"keyword": cleaned_data["keyword"]} + return {"keyword": cleaned_data["keyword"], "match_type": cleaned_data["match_type"]} class CreateRegister(BaseCreate): form_class = RegisterTriggerForm @@ -509,6 +509,7 @@ class List(BaseList): bulk_actions = ("archive",) title = _("Active Triggers") + menu_path = "/trigger/active" def pre_process(self, request, *args, **kwargs): # if they have no triggers and no search performed, send them to create page @@ -527,6 +528,7 @@ class Archived(BaseList): bulk_actions = ("restore",) title = _("Archived Triggers") + menu_path = "/trigger/archived" def get_queryset(self, *args, **kwargs): return super().get_queryset(*args, **kwargs).filter(is_archived=True) @@ -548,6 +550,9 @@ def derive_url_pattern(cls, path, action): def trigger_type(self): return Trigger.get_type(slug=self.kwargs["type"]) + def derive_menu_path(self): + return f"/trigger/{self.trigger_type.slug}" + def derive_title(self): return self.trigger_type.title diff --git a/temba/utils/fields.py b/temba/utils/fields.py index e33312a7f4f..84eb6ce5a8f 100644 --- a/temba/utils/fields.py +++ b/temba/utils/fields.py @@ -207,17 +207,11 @@ class OmniboxField(JSONField): default_country = None def validate(self, value): - from temba.contacts.models import URN - assert isinstance(value, list) for item in value: assert isinstance(item, dict) and "id" in item and "type" in item - if item["type"] == "urn": - if not URN.validate(item["id"], self.default_country): - raise ValidationError(_("'%s' is not a valid URN.") % item["id"]) - class TembaChoiceIterator(forms.models.ModelChoiceIterator): def __init__(self, field): diff --git a/temba/utils/views.py b/temba/utils/views.py index 4d3f54e7d6d..240a83c7aa3 100644 --- a/temba/utils/views.py +++ b/temba/utils/views.py @@ -1,7 +1,11 @@ +import json import logging from urllib.parse import quote, urlencode +import requests + from django import forms +from django.conf import settings from django.db import transaction from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse from django.urls import reverse @@ -15,6 +19,8 @@ logger = logging.getLogger(__name__) +TEMBA_MENU_SELECTION = "temba_menu_selection" + class SpaMixin(View): """ @@ -29,7 +35,17 @@ def spa_path(self) -> tuple: def spa_referrer_path(self) -> tuple: return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) + def has_permission(self, request, *args, **kwargs): + + is_beta = not request.user.is_anonymous and request.user.is_beta + if self.is_spa() and not self.is_content_only() and not is_beta: + return False + return super().has_permission(request, *args, **kwargs) + def is_spa(self): + return self.request.COOKIES.get("nav") == "2" or self.is_content_only() + + def is_content_only(self): return "HTTP_TEMBA_SPA" in self.request.META def get_template_names(self): @@ -47,15 +63,67 @@ def get_template_names(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - if self.is_spa(): - context["base_template"] = "spa.html" + if self.is_content_only(): + context["base_template"] = "spa.html" + else: + context["base_template"] = "spa_frame.haml" + context["is_spa"] = True context["temba_path"] = self.spa_path context["temba_referer"] = self.spa_referrer_path + context[TEMBA_MENU_SELECTION] = self.derive_menu_path() + + # the base page should prep the flow editor + if not self.is_content_only(): + dev_mode = getattr(settings, "EDITOR_DEV_MODE", False) + prefix = "/dev" if dev_mode else settings.STATIC_URL + + # get our list of assets to incude + scripts = [] + styles = [] + + if dev_mode: # pragma: no cover + response = requests.get("http://localhost:3000/asset-manifest.json") + data = response.json() + else: + with open("node_modules/@nyaruka/flow-editor/build/asset-manifest.json") as json_file: + data = json.load(json_file) + + for key, filename in data.get("files").items(): + + # tack on our prefix for dev mode + filename = prefix + filename + + # ignore precache manifest + if key.startswith("precache-manifest") or key.startswith("service-worker"): + continue + + # css files + if key.endswith(".css") and filename.endswith(".css"): + styles.append(filename) + + # javascript + if key.endswith(".js") and filename.endswith(".js"): + scripts.append(filename) + + context["scripts"] = scripts + context["styles"] = styles + context["dev_mode"] = dev_mode return context + def derive_menu_path(self): + if hasattr(self, "menu_path"): + return self.menu_path + return self.request.path + + def render_to_response(self, context, **response_kwargs): + response = super().render_to_response(context, **response_kwargs) + if self.is_spa(): + response.headers[TEMBA_MENU_SELECTION] = context[TEMBA_MENU_SELECTION] + return response + class ComponentFormMixin(View): """ diff --git a/templates/contacts/contact_history.haml b/templates/contacts/contact_history.haml index 2666363ccb0..7adda9e00cc 100644 --- a/templates/contacts/contact_history.haml +++ b/templates/contacts/contact_history.haml @@ -194,8 +194,10 @@ %tr{ ic-append-from:"/contact/history/{{contact.uuid}}/?after={{ next_after }}&before={{ next_before }}", ic-trigger-on:"scrolled-into-view", ic-target:"table.activity tbody.previous", - ic-indicator:"#indicator" } - + ic-indicator:"#indicator", + ic-on-success:"checkForPJAX" + } + -if not has_older and not recent_only and start_date -if start_date %tr.archive-note diff --git a/templates/contacts/contact_list_spa.haml b/templates/contacts/contact_list_spa.haml index 097bae9f939..c887804575e 100644 --- a/templates/contacts/contact_list_spa.haml +++ b/templates/contacts/contact_list_spa.haml @@ -18,6 +18,7 @@ -block spa-title .page-title #title-text + {{title}} -block content .flex diff --git a/templates/contacts/contact_read.haml b/templates/contacts/contact_read.haml index 6f013223b54..eadcba8e4c2 100644 --- a/templates/contacts/contact_read.haml +++ b/templates/contacts/contact_read.haml @@ -12,11 +12,10 @@ .flex.flex-wrap.flex-row.items-center .mr-3 {{contact|name_or_urn:user_org|default:"Contact Details"}} - -for urn in contact_urns - -if not user_org.is_anon + -if not user_org.is_anon + -for urn in contact_urns -if urn.sendable - %temba-modax.text-base{ header:'Send Message', endpoint:"{% url 'msgs.broadcast_send' %}?u={{urn.id}}"} - .mr-3.hover-linked{class:"glyph {{urn|urn_icon}}", style:"margin-top:0px"} + .mr-3{class:"glyph {{urn|urn_icon}}", style:"margin-top:0px"} -block subtitle @@ -242,7 +241,10 @@ ic-trigger-on:"scrolled-into-view", ic-poll:"5s", ic-target:"table .recent", - ic-poll-repeats:"30" } + ic-poll-repeats:"30", + ic-on-success:"checkForPJAX" + + } %tbody.recent @@ -250,7 +252,9 @@ %tr{ ic-append-from:"/contact/history/{{contact.uuid}}/?before={{recent_start}}", ic-trigger-on:"scrolled-into-view", ic-target:"table .previous", - ic-indicator:"#indicator" } + ic-indicator:"#indicator", + ic-on-success:"checkForPJAX" + } %tbody.previous @@ -483,6 +487,15 @@ :javascript + function checkForPJAX(selection, data, status, xhr) { + if (data.indexOf(" -1) { + // we shouldnt have an html tag for pjax + document.location.reload(); + return false; + } + return true; + } + var startTime = null; function contactUpdated() { document.location.reload(); diff --git a/templates/dashboard/home_spa.haml b/templates/dashboard/home_spa.haml index 9172c6f8b78..c479ee95a9c 100644 --- a/templates/dashboard/home_spa.haml +++ b/templates/dashboard/home_spa.haml @@ -14,6 +14,8 @@ {{block.super}} :javascript + var redrawMarker = null; + function setChartOptions(begin, end, direction) { var from = Highcharts.dateFormat('%A, %B %e, %Y', begin); var to = Highcharts.dateFormat('%A, %B %e, %Y', end) @@ -22,12 +24,6 @@ document.querySelector("#range-to").innerText = to; } - Highcharts.setOptions({ - lang: { - thousandsSep: ',' - } - }); - function selectionChanged(chart) { var direction = ""; if (chart.series[0].visible) { @@ -42,7 +38,6 @@ setChartOptions(axis.min, axis.max, direction); } - var redrawMarker = null; function markDirty(chart) { if (redrawMarker != null) { window.clearTimeout(redrawMarker); @@ -50,55 +45,66 @@ redrawMarker = window.setTimeout(selectionChanged.bind(null, chart), 200); } - var store = document.querySelector("temba-store"); - store.getUrl('/dashboard/message_history').then(function(response){ - var data = response.json; - // Create the chart - Highcharts.stockChart('message-chart', { - chart: { - zoomType: 'x', - events: { - // mark dirty out the gate - load: function(e) { - markDirty(this); - }, - redraw: function(e) { - markDirty(this); + function loadCharts() { + Highcharts.setOptions({ + lang: { + thousandsSep: ',' + } + }); + + var store = document.querySelector("temba-store"); + store.getUrl('/dashboard/message_history').then(function(response){ + var data = response.json; + // Create the chart + Highcharts.stockChart('message-chart', { + chart: { + zoomType: 'x', + events: { + // mark dirty out the gate + load: function(e) { + markDirty(this); + }, + redraw: function(e) { + markDirty(this); + } } - } - }, - plotOptions: { - series: { - showInLegend: true, - stacking: 'normal' - } - }, - legend: { - enabled: true - }, - rangeSelector: { - buttons: [ - {type: 'week', count: 1, text: 'W'}, - {type: 'month', count: 1, text: 'M'}, - {type: 'year', count: 1, text: 'Y'}, - {type: 'all', count: 1, text: 'all'} - ], - inputEnabled: false, - selected: 1 - }, - xAxis: { - minRange: 3600000 * 24 - }, - credits: { - enabled: false - }, - navigator: { - series: { - stacking: 'normal', - type: 'column' - } - }, - series: data + }, + plotOptions: { + series: { + showInLegend: true, + stacking: 'normal' + } + }, + legend: { + enabled: true + }, + rangeSelector: { + buttons: [ + {type: 'week', count: 1, text: 'W'}, + {type: 'month', count: 1, text: 'M'}, + {type: 'year', count: 1, text: 'Y'}, + {type: 'all', count: 1, text: 'all'} + ], + inputEnabled: false, + selected: 1 + }, + xAxis: { + minRange: 3600000 * 24 + }, + credits: { + enabled: false + }, + navigator: { + series: { + stacking: 'normal', + type: 'column' + } + }, + series: data + }); + }); + } + + onSpload(loadCharts); - }); diff --git a/templates/flows/flow_editor.haml b/templates/flows/flow_editor.haml index 104e443e55e..6b85c526b0d 100644 --- a/templates/flows/flow_editor.haml +++ b/templates/flows/flow_editor.haml @@ -119,6 +119,10 @@ #grid { overflow: auto; z-index: 5; + border: 0px solid #e7e7e7; + margin-bottom: 2.5em; + margin-top: 0px; + height: 100%; } #shadow { @@ -170,9 +174,22 @@ margin-bottom: 0; } + #canvas::after { + background-color: #ffffff; + } + + #canvas-container > div { + background-color: #ffffff; + } + + .flow_node { + box-shadow: 0px 0px 4px 2px rgba(0,0,0,.05); + border: 1px solid transparent; + } + -block extra-script {{ block.super }} - + -for script in scripts %script(type="text/javascript" src="{{script}}") @@ -255,8 +272,8 @@ languages: base + 'language', environment: base + 'environment', activity: '/flow/activity/{{object.uuid}}/', - recipients: '/contact/omnibox?v=2&types=gc', - contacts: '/contact/omnibox?v=2&types=c', + recipients: '/contact/omnibox?types=gc', + contacts: '/contact/omnibox?types=c', contact: '/contact/read/', {% if can_simulate %} diff --git a/templates/flows/flow_editor_spa.haml b/templates/flows/flow_editor_spa.haml index 0ba06e9c950..3477ecd1af4 100644 --- a/templates/flows/flow_editor_spa.haml +++ b/templates/flows/flow_editor_spa.haml @@ -1,4 +1,4 @@ --extends "flows/flow_editor.html" +-extends "smartmin/base.html" -load compress temba i18n -block extra-style @@ -64,6 +64,112 @@ border: 1px solid transparent; } +-block extra-script + {{ block.super }} + + -for script in scripts + %script(type="text/javascript" src="{{script}}") + + :javascript + var base = '/flow/assets/{{object.org.id}}/' + new Date().getTime() + '/'; + var api = '/api/v2/'; + var flowType = '{{ object.engine_type }}'; + + onSpload(function() { + var confirmation = document.getElementById("confirm-language"); + confirmation.addEventListener("temba-button-clicked", function(event){ + var code = confirmation.dataset.code; + if(!event.detail.button.secondary) { + posterize('{% url "flows.flow_change_language" object.id %}?language=' + code); + } + confirmation.open = false; + }); + }) + + function handleEditorLoaded() { + $('.editor-loader').hide(); + $('#rp-flow-editor > div').css('opacity', '1'); + } + + function handleActivityClicked(nodeUUID, count) { + var endpoint = '{% url "msgs.broadcast_send" %}'; + var modal = document.querySelector("#send-message-modal"); + modal.endpoint = endpoint + "?step_node=" + nodeUUID + "&count=" + count; + modal.open = true; + } + + function handleChangeLanguage(code, name) { + + var confirmation = document.getElementById("confirm-language"); + confirmation.classList.remove("hide"); + confirmation.dataset.code = code; + + var body = confirmation.querySelector('.body'); + body.innerHTML = body.innerHTML.replace(/%LANG%/g, name); + confirmation.open = true; + } + + var config = { + flow: '{{object.uuid}}', + flowType: flowType, + localStorage: true, + onLoad: handleEditorLoaded, + onActivityClicked: handleActivityClicked, + onChangeLanguage: handleChangeLanguage, + mutable: {{mutable|lower}}, + filters: {{feature_filters|to_json}}, + brand: '{{brand.name|escapejs}}', + + help: { + legacy_extra: 'https://help.nyaruka.com/', + missing_dependency: 'https://help.nyaruka.com/en/article/fixing-missing-dependencies-1toe127/', + invalid_regex: 'https://help.nyaruka.com/en/article/invalid-regular-expressions-814k8d/' + }, + + endpoints: { + + groups: api + 'groups.json', + fields: api + 'fields.json', + labels: api + 'labels.json', + channels: api + 'channels.json', + classifiers: api + 'classifiers.json', + ticketers: api + 'ticketers.json', + resthooks: api + 'resthooks.json', + templates: api + 'templates.json', + flows: api + 'flows.json?archived=false', + globals: api + 'globals.json', + users: api + 'users.json', + topics: api + 'topics.json', + editor: '/flow/editor', + + // TODO: migrate to API? + revisions: '/flow/revisions/{{object.uuid}}/', + recents: '/flow/recent_contacts/{{object.uuid}}/', + attachments: '{% url "msgs.media_upload" %}', + languages: base + 'language', + environment: base + 'environment', + activity: '/flow/activity/{{object.uuid}}/', + recipients: '/contact/omnibox?v=2&types=gc', + contacts: '/contact/omnibox?v=2&types=c', + contact: '/contact/read/', + + {% if can_simulate %} + simulateStart: '/flow/simulate/{{object.id}}/', + simulateResume: '/flow/simulate/{{object.id}}/' + {% endif %} + } + }; + + // wait for our component to load, then show the editor + if (window.showFlowEditor) { + window.showFlowEditor(document.getElementById("rp-flow-editor"), config); + } else { + document.addEventListener("temba-floweditor-loaded", function(){ + window.showFlowEditor(document.getElementById("rp-flow-editor"), config); + }); + } + + -block alert-messages -if is_starting or messages or user_org.is_suspended .pt-5.pr-5.pl-5 @@ -73,8 +179,7 @@ -blocktrans trimmed This flow is in the process of being sent, this message will disappear once all contacts have been added to the flow. --block page-container - +-block content %temba-modax#send-message-modal{ header:"Send Message" } %temba-dialog.hide#confirm-language(header='{{_("Change Language")|escapejs}}' primaryButtonName='{{_("Update")|escapejs}}') diff --git a/templates/flows/flow_results_spa.haml b/templates/flows/flow_results_spa.haml index 065abba27f9..535b640cd3e 100644 --- a/templates/flows/flow_results_spa.haml +++ b/templates/flows/flow_results_spa.haml @@ -347,7 +347,7 @@ runs.paused = tabs.index != 2; if (tabs.index == 0) { - fetchAjax('/flow/activity_chart/{{object.id}}', "#overview-charts", {}); + fetchAjax('/flow/activity_chart/{{object.id}}/', "#overview-charts", {}); } if (tabs.index == 1) { diff --git a/templates/frame.haml b/templates/frame.haml index e21617615f7..747629214f8 100644 --- a/templates/frame.haml +++ b/templates/frame.haml @@ -14,57 +14,17 @@ %meta{name:"description", content:"{% block page-description %}{{brand.name}} lets you visually build interactive SMS applications and launch them anywhere in the world.{% endblock %}"} %meta{name:"author", content:"Nyaruka Ltd"} %meta{http-equiv:"X-UA-Compatible", content:"IE=10"} - - -block ui-switch-js - -if user.is_beta - -compress js - %script(src="{{ STATIC_URL }}js/urls.js") - - :javascript - window.STATIC_URL = "{{STATIC_URL}}"; - function loadNewInterface(force) { - var location = window.location; - var excluded = window.excludeUrls.find(function(url) { - return location.pathname.indexOf(url) > -1; - }); - - if (!excluded) { - var newUrl = mapUrl(location.pathname + (location.search || "")); - if (newUrl != window.location.pathname) { - window.location = newUrl; - } else { - if (force) { - window.location = "/messages/"; - } - } - } - - } - - function getCookie(name) { - var value = `; ${document.cookie}`; - var parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); - } - - function switchToNewInterface() { - document.cookie = "nav=2; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/;"; - loadNewInterface(true); - } - - -block auto-route - :javascript - (function(){ - var nav = getCookie("nav"); - if (nav == "2") { - loadNewInterface(false); - } - })(); + + -if user.is_beta + :javascript + function switchToNewInterface() { + document.cookie = "nav=2; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/;"; + document.location.reload(); + } :javascript - + window.STATIC_URL = "{{STATIC_URL}}"; window.supportEmail = '{{brand.support_email}}'; - function conditionalLoad(local, remote) { if (local != null && (window.location.hostname == "localhost" || remote == null)) { loadResource("{{ STATIC_URL }}" + local); @@ -104,7 +64,115 @@ %script{src:"{{ STATIC_URL }}js/temba.js"} %script{src:"{{ STATIC_URL }}js/labels.js"} + :javascript + function fetchPJAXContent(url, container, options) { + options = options || {}; + + // hijack any pjax requests made from spa pages and route the content there instead + if (container == "#pjax" && document.querySelector(".spa-content")) { + container = ".spa-content"; + options["headers"] = (options["headers"] || {}) + options["headers"]["TEMBA-SPA"] = 1; + } + + var triggerEvents = true; + if (!!options["ignoreEvents"]) { + triggerEvents = false; + } + + var type = 'GET'; + var data = undefined; + var processData = true; + var contentType = 'application/x-www-form-urlencoded; charset=UTF-8'; + + if (options) { + if ('postData' in options) { + type = 'POST'; + data = options['postData']; + } + + if('formData' in options) { + type = 'POST'; + processData = false; + data = options['formData']; + contentType = false; + } + } + + var headers = { 'X-PJAX': true }; + if (options && 'headers' in options) { + for (key in options['headers']) { + headers[key] = options['headers'][key]; + } + } + + if (triggerEvents) { + document.dispatchEvent(new Event("temba-pjax-begin")); + } + // see if we should skip our fetch + if (options) { + if ('shouldIgnore' in options && options['shouldIgnore']()) { + if ('onIgnore' in options) { + options['onIgnore'](); + } + return; + } + } + + var request = { + headers: headers, + type: type, + url: url, + contentType: contentType, + processData: processData, + data: data, + success: function(response, status, jqXHR) { + + if ('followRedirects' in options && options['followRedirects'] == true) { + var redirect = jqXHR.getResponseHeader('REDIRECT'); + if (redirect) { + window.document.location.href = redirect; + return; + } + } + + // double check before replacing content + if (options) { + if (('shouldIgnore' in options && options['shouldIgnore'](response))) { + if ('onIgnore' in options) { + options['onIgnore'](jqXHR); + } + + return; + } + } + + $(container).html(response); + + if (triggerEvents) { + document.dispatchEvent(new Event("temba-pjax-complete")); + } + + if (options) { + if ('onSuccess' in options) { + options['onSuccess'](); + } + } + } + } + $.ajax(request); + } + + -block full-page-script + :javascript + document.addEventListener("temba-redirected", function(event){ + document.location.href = event.detail.url; + }); + + + + -include "includes/frame_top.html" -if not COMPONENTS_DEV_MODE @@ -404,11 +472,6 @@ }); } - -block full-page-script - :javascript - document.addEventListener("temba-redirected", function(event){ - document.location.href = event.detail.url; - }); diff --git a/templates/msgs/broadcast_scheduled.haml b/templates/msgs/broadcast_scheduled.haml index f54203ce90c..e09f4187dbe 100644 --- a/templates/msgs/broadcast_scheduled.haml +++ b/templates/msgs/broadcast_scheduled.haml @@ -15,7 +15,7 @@ %tr.select-row.cursor-pointer{class:'{% cycle row1 row1 %}', onclick:'document.location="{% url "msgs.broadcast_scheduled_read" object.id %}"'} %td.value-contacts.field_phone - -include "includes/recipients.haml" with groups=object.groups.all contacts=object.contacts.all urns=object.raw_urns + -include "includes/recipients.haml" with groups=object.groups.all contacts=object.contacts.all %td.value-text.field_text {{ object.get_text }} diff --git a/templates/msgs/broadcast_scheduled_spa.haml b/templates/msgs/broadcast_scheduled_spa.haml index 531fc40564c..a971d89024e 100644 --- a/templates/msgs/broadcast_scheduled_spa.haml +++ b/templates/msgs/broadcast_scheduled_spa.haml @@ -7,7 +7,10 @@ -block action-buttons --block page-container +-block spa-title + {{title}} + +-block content .mt-4 %table.list.lined.w-full %tbody @@ -15,7 +18,7 @@ %tr.select-row.cursor-pointer{class:'{% cycle row1 row1 %}', onclick:'goto(event)', href:'{%url "msgs.broadcast_scheduled_read" object.id%}'} %td.value-contacts.field_phone - -include "includes/recipients.haml" with groups=object.groups.all contacts=object.contacts.all urns=object.raw_urns + -include "includes/recipients.haml" with groups=object.groups.all contacts=object.contacts.all %td.value-text.field_text {{ object.get_text }} diff --git a/templates/msgs/broadcast_scheduled_update.haml b/templates/msgs/broadcast_scheduled_update.haml index 6d1899929df..af7bab77bb5 100644 --- a/templates/msgs/broadcast_scheduled_update.haml +++ b/templates/msgs/broadcast_scheduled_update.haml @@ -9,7 +9,7 @@ -render_field 'message' -block summary - -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.raw_urns + -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all .mt-2 {{ object.get_text }} diff --git a/templates/msgs/msg_outbox.haml b/templates/msgs/msg_outbox.haml index d7c5342f138..3d906a623b7 100644 --- a/templates/msgs/msg_outbox.haml +++ b/templates/msgs/msg_outbox.haml @@ -13,7 +13,7 @@ %span.glyph.icon-bullhorn.text-gray-500 %td.value-recipient .pt-1.inline-block - -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.raw_urns + -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.urns %td.value-text {{ broadcast.get_text }} %td.created_on diff --git a/templates/msgs/msg_outbox_spa.haml b/templates/msgs/msg_outbox_spa.haml index 0b7283aab25..99ad908c9ec 100644 --- a/templates/msgs/msg_outbox_spa.haml +++ b/templates/msgs/msg_outbox_spa.haml @@ -13,7 +13,7 @@ %span.glyph.icon-bullhorn.text-gray-500 %td.value-recipient .pt-1.inline-block - -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.raw_urns + -include "includes/recipients.haml" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.urns %td.value-text.w-full {{ broadcast.get_text }} %td.created_on diff --git a/templates/orgs/org_export.haml b/templates/orgs/org_export.haml index 43a0185767b..b1ec615dc66 100644 --- a/templates/orgs/org_export.haml +++ b/templates/orgs/org_export.haml @@ -7,16 +7,6 @@ -block content - %div.hidden - .float-right - -if not archived - .button-light{onclick:"goto(event)", href:'?archived=1'} - -trans "Show Archived" - - -else - .button-light{onclick:"goto(event)", href:'?'} - -trans "Hide Archived" - .flex.w-full.mb-4.items-end.flex-wrap.justify-end{style:"min-height:41px"} -blocktrans trimmed Select all of the items below that you would like to include in your export. We've grouped them @@ -24,7 +14,7 @@ be included in the export. .flex.w-full.mb-4.items-end.flex-wrap.justify-end{style:"min-height:41px"} - %form.export.w-full{method:'POST'} + %form.export.w-full(method="POST" action="{{request.get_full_path}}") -csrf_token .flex.flex-col -for bucket in buckets diff --git a/templates/orgs/org_export_spa.haml b/templates/orgs/org_export_spa.haml new file mode 100644 index 00000000000..72e0cce7d54 --- /dev/null +++ b/templates/orgs/org_export_spa.haml @@ -0,0 +1,18 @@ +-extends "orgs/org_export.haml" + +-load temba compress i18n humanize + +-block extra-style + {{block.super}} + :css + .bucket .title { + font-size: 1.2em; + } + + .card > .bg-gray-100 { + + } + + .spa-container { + background: #fbfbfb; + } diff --git a/templates/orgs/org_import.haml b/templates/orgs/org_import.haml index 1b6da581f67..f448d51ffef 100644 --- a/templates/orgs/org_import.haml +++ b/templates/orgs/org_import.haml @@ -12,7 +12,7 @@ -block import-status .flex.w-full.mb-4.items-end.flex-wrap{style:"min-height:41px"} - %form#import-form{method:"post", action:"{% url 'orgs.org_import' %}", enctype:"multipart/form-data"} + %form#import-form(method="post" action="{% url 'orgs.org_import' %}" enctype="multipart/form-data") - if form.non_field_errors .text-error {{ form.non_field_errors }} diff --git a/templates/smartmin/base.html b/templates/smartmin/base.html index 81670bdf9c5..7c5ae1becdf 100644 --- a/templates/smartmin/base.html +++ b/templates/smartmin/base.html @@ -8,117 +8,6 @@ {% block extra-script %} {{ block.super}} - - - {# embed refresh script if refresh is active #} {% if refresh %}