From 6b372aeecaaa477eb16ae22406ffced2da31376c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 19 Aug 2024 19:41:36 +0000 Subject: [PATCH 001/557] Drop APIToken.role field --- .../api/migrations/0047_remove_apitoken_role.py | 17 +++++++++++++++++ temba/api/models.py | 4 ---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 temba/api/migrations/0047_remove_apitoken_role.py diff --git a/temba/api/migrations/0047_remove_apitoken_role.py b/temba/api/migrations/0047_remove_apitoken_role.py new file mode 100644 index 00000000000..0b269c02d21 --- /dev/null +++ b/temba/api/migrations/0047_remove_apitoken_role.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1 on 2024-08-19 19:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0046_alter_apitoken_role"), + ] + + operations = [ + migrations.RemoveField( + model_name="apitoken", + name="role", + ), + ] diff --git a/temba/api/models.py b/temba/api/models.py index 7de74ca81a1..66d6ef93a57 100644 --- a/temba/api/models.py +++ b/temba/api/models.py @@ -5,7 +5,6 @@ from smartmin.models import SmartModel from django.conf import settings -from django.contrib.auth.models import Group from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -215,9 +214,6 @@ class APIToken(models.Model): last_used_on = models.DateTimeField(null=True) is_active = models.BooleanField(default=True) - # TODO remove - role = models.ForeignKey(Group, on_delete=models.PROTECT, null=True) - @classmethod def create(cls, org, user): """ From 7b6ae146132f2952b2f36936749fe04137e5ea63 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 19 Aug 2024 19:49:53 +0000 Subject: [PATCH 002/557] Fix migration depedency --- temba/api/migrations/0046_alter_apitoken_role.py | 1 + temba/orgs/migrations/0150_backfill_org_prometheus_token.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/temba/api/migrations/0046_alter_apitoken_role.py b/temba/api/migrations/0046_alter_apitoken_role.py index b65815f00f1..5355b47d71a 100644 --- a/temba/api/migrations/0046_alter_apitoken_role.py +++ b/temba/api/migrations/0046_alter_apitoken_role.py @@ -9,6 +9,7 @@ class Migration(migrations.Migration): dependencies = [ ("api", "0045_apitoken_last_used_on"), ("auth", "0012_alter_user_first_name_max_length"), + ("orgs", "0150_backfill_org_prometheus_token"), ] operations = [ diff --git a/temba/orgs/migrations/0150_backfill_org_prometheus_token.py b/temba/orgs/migrations/0150_backfill_org_prometheus_token.py index a27dea2ab79..6264c980b9c 100644 --- a/temba/orgs/migrations/0150_backfill_org_prometheus_token.py +++ b/temba/orgs/migrations/0150_backfill_org_prometheus_token.py @@ -13,6 +13,6 @@ def backfill_prometheus_token(apps, schema_editor): # pragma: no cover class Migration(migrations.Migration): - dependencies = [("orgs", "0149_org_prometheus_token")] + dependencies = [("orgs", "0149_org_prometheus_token"), ("api", "0043_squashed")] operations = [migrations.RunPython(backfill_prometheus_token, migrations.RunPython.noop)] From be940d06d69b140d2c075a5fc49128c0f58e95d1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 19 Aug 2024 15:28:32 -0500 Subject: [PATCH 003/557] Update CHANGELOG.md for v9.3.22 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85369474d..484539a453d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.22 (2024-08-19) +------------------------- + * Drop APIToken.role field + v9.3.21 (2024-08-19) ------------------------- * Use correct URL when breaking spa-container diff --git a/pyproject.toml b/pyproject.toml index f277de383fe..a30c4107d09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.21" +version = "9.3.22" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 4610cc59fac..674cd965581 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.21" +__version__ = "9.3.22" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 09a33e1f71d1ccbd2b4164f5d812f33cbee0dde4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 10:34:36 -0500 Subject: [PATCH 004/557] Add option for connection pooling --- poetry.lock | 17 ++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index cc2f118e255..18f9679ff5e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1357,6 +1357,7 @@ files = [ ] [package.dependencies] +psycopg-pool = {version = "*", optional = true, markers = "extra == \"pool\""} typing-extensions = ">=4.4" tzdata = {version = "*", markers = "sys_platform == \"win32\""} @@ -1368,6 +1369,20 @@ docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)" pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +[[package]] +name = "psycopg-pool" +version = "3.2.2" +description = "Connection Pool for Psycopg" +optional = false +python-versions = ">=3.8" +files = [ + {file = "psycopg_pool-3.2.2-py3-none-any.whl", hash = "sha256:273081d0fbfaced4f35e69200c89cb8fbddfe277c38cc86c235b90a2ec2c8153"}, + {file = "psycopg_pool-3.2.2.tar.gz", hash = "sha256:9e22c370045f6d7f2666a5ad1b0caf345f9f1912195b0b25d0d3bcc4f3a7389c"}, +] + +[package.dependencies] +typing-extensions = ">=4.4" + [[package]] name = "pyasn1" version = "0.6.0" @@ -2238,4 +2253,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "1e5eca221256ae4e280d282c9442ab9d6cf06f03700b08e05474ab4bec0c704b" +content-hash = "dd4e4e0da39f136c61fee0f5bfa608a66ac857477d2d54551d95107cd3d52c61" diff --git a/pyproject.toml b/pyproject.toml index a30c4107d09..e497713534e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ openpyxl = "^3.1.5" ffmpeg-python = "^0.2.0" slack-sdk = "3.17.0" django-formtools = "^2.4.1" -psycopg = "^3.1.9" +psycopg = {extras = ["pool"], version = "^3.2.1"} pillow = "^10.1.0" django-imagekit = "^5.0.0" iso639-lang = "^2.2.3" From eeb010cc0deec50bffea67ddf5c4ed61f85b05c1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 20:52:54 +0000 Subject: [PATCH 005/557] Add management command to create DynamoDB tables --- .github/workflows/ci.yml | 4 +++ temba/settings_common.py | 16 +++++++-- temba/utils/dynamo/__init__.py | 30 ++++++++++++++++ .../management/commands/migrate_dynamo.py | 35 +++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 temba/utils/dynamo/__init__.py create mode 100644 temba/utils/management/commands/migrate_dynamo.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2454a06062..2b1d98d08de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: with: node-version: ${{ env.node-version }} + - name: Install and start DynamoDB + uses: rrainn/dynamodb-action@v2.0.1 + - name: Initialize environment run: | poetry install @@ -64,6 +67,7 @@ jobs: sudo yarn global add less ln -s ${{ github.workspace }}/temba/settings.py.dev ${{ github.workspace }}/temba/settings.py poetry run python manage.py migrate + poetry run python manage.py migrate_dynamo # fetch, extract and start mailroom wget https://github.com/${{ github.repository_owner }}/mailroom/releases/download/v${{ env.mailroom-version }}/mailroom_${{ env.mailroom-version }}_linux_amd64.tar.gz tar -xvf mailroom_${{ env.mailroom-version }}_linux_amd64.tar.gz mailroom diff --git a/temba/settings_common.py b/temba/settings_common.py index 70574b8fde6..c2ee755b798 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -34,10 +34,12 @@ _db_host = "postgres" _redis_host = "redis" _minio_host = "minio" + _dynamo_host = "dynamo" else: _db_host = "localhost" _redis_host = "localhost" _minio_host = "localhost" + _dynamo_host = "localhost" # ----------------------------------------------------------------------------------- # Email @@ -56,6 +58,16 @@ # their own SMTP server. FLOW_FROM_EMAIL = "no-reply@temba.io" +# ----------------------------------------------------------------------------------- +# AWS +# ----------------------------------------------------------------------------------- + +AWS_ACCESS_KEY_ID = "root" +AWS_SECRET_ACCESS_KEY = "tembatemba" +AWS_REGION = "us-east-1" + +DYNAMO_ENDPOINT_URL = f"http://{_dynamo_host}:8000" + # ----------------------------------------------------------------------------------- # Storage # ----------------------------------------------------------------------------------- @@ -93,9 +105,7 @@ } # settings used by django-storages (defaults to local Minio server) -AWS_ACCESS_KEY_ID = "root" -AWS_SECRET_ACCESS_KEY = "tembatemba" -AWS_S3_REGION_NAME = "us-east-1" +AWS_S3_REGION_NAME = AWS_REGION AWS_S3_ENDPOINT_URL = f"http://{_minio_host}:9000" AWS_S3_ADDRESSING_STYLE = "path" AWS_S3_FILE_OVERWRITE = False diff --git a/temba/utils/dynamo/__init__.py b/temba/utils/dynamo/__init__.py new file mode 100644 index 00000000000..648901b38b6 --- /dev/null +++ b/temba/utils/dynamo/__init__.py @@ -0,0 +1,30 @@ +import boto3 +from botocore.client import Config + +from django.conf import settings + +_client = None + + +def get_client(): # pragma: no cover + """ + Returns our shared DynamoDB client + """ + + global _client + + if not _client: + if settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY: + session = boto3.Session( + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_REGION, + ) + else: + session = boto3.Session() + + _client = session.client( + "dynamodb", endpoint_url=settings.DYNAMO_ENDPOINT_URL, config=Config(retries={"max_attempts": 3}) + ) + + return _client diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py new file mode 100644 index 00000000000..de0348995b8 --- /dev/null +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.core.management import BaseCommand + +from temba.utils import dynamo + +TABLES = [ + { + "TableName": "ChannelLogsAttached", + "KeySchema": [{"AttributeName": "UUID", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "UUID", "AttributeType": "S"}], + "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + "TimeToLiveSpecification": {"AttributeName": "ExpireOn", "Enabled": True}, + } +] + + +class Command(BaseCommand): + help = "Creates DynamoDB tables that don't already exist." + + def handle(self, *args, **kwargs): + client = dynamo.get_client() + + for table in TABLES: + # if we're running against a local install of dynamodb, we need to remove the TTL spec + if settings.AWS_ACCESS_KEY_ID == "root" and "TimeToLiveSpecification" in table: + del table["TimeToLiveSpecification"] + + try: + client.describe_table(TableName=table["TableName"]) + + self.stdout.write(f"{table['TableName']}: already exists") + except client.exceptions.ResourceNotFoundException: + client.create_table(**table) + + self.stdout.write(f"{table['TableName']}: created") From ef2cc21a80215032190885dec24706394c757091 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 16:30:29 -0500 Subject: [PATCH 006/557] Update CHANGELOG.md for v9.3.23 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484539a453d..8ea707a4130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.23 (2024-08-20) +------------------------- + * Add management command to create DynamoDB tables + * Add option for connection pooling + v9.3.22 (2024-08-19) ------------------------- * Drop APIToken.role field diff --git a/pyproject.toml b/pyproject.toml index e497713534e..96dcde12878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.22" +version = "9.3.23" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 674cd965581..a2082c327f9 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.22" +__version__ = "9.3.23" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From b4511df801437dccfc7bbb8a6d9f05c4504e53c1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 21:52:05 +0000 Subject: [PATCH 007/557] Add dynamo table prefix setting --- temba/settings_common.py | 1 + temba/utils/management/commands/migrate_dynamo.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/temba/settings_common.py b/temba/settings_common.py index c2ee755b798..b37b08ee4e5 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -67,6 +67,7 @@ AWS_REGION = "us-east-1" DYNAMO_ENDPOINT_URL = f"http://{_dynamo_host}:8000" +DYNAMO_TABLE_PREFIX = "Local" # ----------------------------------------------------------------------------------- # Storage diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index de0348995b8..55fd6c4d4d4 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -21,6 +21,10 @@ def handle(self, *args, **kwargs): client = dynamo.get_client() for table in TABLES: + # add optional prefix to name to allow multiple deploys in same region + name = settings.DYNAMO_TABLE_PREFIX + table["TableName"] + table["TableName"] = name + # if we're running against a local install of dynamodb, we need to remove the TTL spec if settings.AWS_ACCESS_KEY_ID == "root" and "TimeToLiveSpecification" in table: del table["TimeToLiveSpecification"] From d7ed17218b818c6669a2fe98cadefe64b759e25d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 17:26:27 -0500 Subject: [PATCH 008/557] Update CHANGELOG.md for v9.3.24 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea707a4130..3d694b9ee49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.24 (2024-08-20) +------------------------- + * Add dynamo table prefix setting + v9.3.23 (2024-08-20) ------------------------- * Add management command to create DynamoDB tables diff --git a/pyproject.toml b/pyproject.toml index 96dcde12878..6ae25bda84c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.23" +version = "9.3.24" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a2082c327f9..f24d58f2942 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.23" +__version__ = "9.3.24" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From beacaee0dec9f5cc7fa8f68c77773d3c84cf6e32 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 22:41:22 +0000 Subject: [PATCH 009/557] Set TTL on dynamodb tables in separate call --- temba/utils/management/commands/migrate_dynamo.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 55fd6c4d4d4..9679103142e 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -25,9 +25,8 @@ def handle(self, *args, **kwargs): name = settings.DYNAMO_TABLE_PREFIX + table["TableName"] table["TableName"] = name - # if we're running against a local install of dynamodb, we need to remove the TTL spec - if settings.AWS_ACCESS_KEY_ID == "root" and "TimeToLiveSpecification" in table: - del table["TimeToLiveSpecification"] + # ttl isn't actually part of the create_table call + ttlSpec = table.pop("TimeToLiveSpecification", None) try: client.describe_table(TableName=table["TableName"]) @@ -36,4 +35,10 @@ def handle(self, *args, **kwargs): except client.exceptions.ResourceNotFoundException: client.create_table(**table) + if ttlSpec: + client.update_time_to_live( + TableName=table["TableName"], + TimeToLiveSpecification=ttlSpec, + ) + self.stdout.write(f"{table['TableName']}: created") From 3987f6099b85affc094f4b2ead911c6ca7d13737 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 20 Aug 2024 23:09:32 +0000 Subject: [PATCH 010/557] Tweak migrate_dynamo command --- .../management/commands/migrate_dynamo.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 9679103142e..724112d0ad5 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -18,7 +18,7 @@ class Command(BaseCommand): help = "Creates DynamoDB tables that don't already exist." def handle(self, *args, **kwargs): - client = dynamo.get_client() + self.client = dynamo.get_client() for table in TABLES: # add optional prefix to name to allow multiple deploys in same region @@ -28,17 +28,21 @@ def handle(self, *args, **kwargs): # ttl isn't actually part of the create_table call ttlSpec = table.pop("TimeToLiveSpecification", None) - try: - client.describe_table(TableName=table["TableName"]) + if not self._table_exists(name): + self.client.create_table(**table) - self.stdout.write(f"{table['TableName']}: already exists") - except client.exceptions.ResourceNotFoundException: - client.create_table(**table) + self.stdout.write(f"{name}: created") if ttlSpec: - client.update_time_to_live( - TableName=table["TableName"], - TimeToLiveSpecification=ttlSpec, - ) - - self.stdout.write(f"{table['TableName']}: created") + self.client.update_time_to_live(TableName=name, TimeToLiveSpecification=ttlSpec) + + self.stdout.write(f"{name}: updated TTL") + else: + self.stdout.write(f"{name}: already exists") + + def _table_exists(self, name: str) -> bool: + try: + self.client.describe_table(TableName=name) + return True + except self.client.exceptions.ResourceNotFoundException: + return False From 265c0e1e026912a0320d9f325f42bb665e495775 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 21 Aug 2024 11:53:54 +0200 Subject: [PATCH 011/557] Fix matching for invites with email case insensitively --- temba/orgs/tests.py | 24 ++++++++++++++++++++++++ temba/orgs/views.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index ea60774d91b..66f983b185a 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2093,6 +2093,30 @@ def test_join(self): # should be logged out as the other user self.assertEqual(0, len(self.client.session.keys())) + # invitation with mismatching case email + invitation2 = Invitation.objects.create( + org=self.org2, + user_group="E", + email="eDwin@nyaruka.com", + created_by=self.admin2, + modified_by=self.admin2, + ) + + join_accept_url = reverse("orgs.org_join_accept", args=[invitation2.secret]) + join_url = reverse("orgs.org_join", args=[invitation2.secret]) + + self.login(user) + + response = self.client.get(join_url) + self.assertRedirect(response, join_accept_url) + + # but only if they're the currently logged in user + self.login(self.admin) + + response = self.client.get(join_url) + self.assertContains(response, "Sign in to join the Trileet Inc. workspace") + self.assertContains(response, f"/users/login/?next={join_accept_url}") + def test_join_signup(self): # if invitation secret is invalid, redirect to root response = self.client.get(reverse("orgs.org_join_signup", args=["invalid"])) diff --git a/temba/orgs/views.py b/temba/orgs/views.py index 2d170145210..b8ea58b66ee 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -2406,7 +2406,7 @@ def pre_process(self, request, *args, **kwargs): # if user exists and is logged in then they just need to accept user = User.get_by_email(self.invitation.email) - if user and self.invitation.email == request.user.username: + if user and self.invitation.email.lower() == request.user.username.lower(): return HttpResponseRedirect(reverse("orgs.org_join_accept", args=[secret])) logout(request) From 3ef669476871c01d2a7043265fd1377bd3d7d972 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 10:23:25 -0500 Subject: [PATCH 012/557] Update CHANGELOG.md for v9.3.25 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d694b9ee49..f457e17c3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.25 (2024-08-21) +------------------------- + * Fix matching for invites with email case insensitively + * Tweak migrate_dynamo command + v9.3.24 (2024-08-20) ------------------------- * Add dynamo table prefix setting diff --git a/pyproject.toml b/pyproject.toml index 6ae25bda84c..2eed047029d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.24" +version = "9.3.25" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index f24d58f2942..4af43a3e04e 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.24" +__version__ = "9.3.25" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From b44f5636307eca906a358b86c454af0877307515 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 16:18:32 +0000 Subject: [PATCH 013/557] Create dynamo table with on-demand billing by default --- temba/utils/management/commands/migrate_dynamo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 724112d0ad5..3bfc0483b71 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -8,7 +8,7 @@ "TableName": "ChannelLogsAttached", "KeySchema": [{"AttributeName": "UUID", "KeyType": "HASH"}], "AttributeDefinitions": [{"AttributeName": "UUID", "AttributeType": "S"}], - "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + "BillingMode": "PAY_PER_REQUEST", "TimeToLiveSpecification": {"AttributeName": "ExpireOn", "Enabled": True}, } ] From 9686cc68116325079d428af901475b7c56cc5229 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 21 Aug 2024 16:29:22 +0000 Subject: [PATCH 014/557] Add redirect for interrupt --- temba/contacts/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 5c6e3a36692..da6e6c39e86 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -841,6 +841,7 @@ class Interrupt(OrgObjPermsMixin, SmartUpdateView): """ fields = () + success_url = "uuid@contacts.contact_read" def save(self, obj): obj.interrupt(self.request.user) From 22efa959d7ed9d7848aa82dca849143a264ad8eb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 16:55:41 +0000 Subject: [PATCH 015/557] Tweak migrate_dynamo command --- temba/utils/dynamo/__init__.py | 11 ++++++-- temba/utils/dynamo/tests.py | 12 +++++++++ .../management/commands/migrate_dynamo.py | 25 ++++++++++--------- 3 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 temba/utils/dynamo/tests.py diff --git a/temba/utils/dynamo/__init__.py b/temba/utils/dynamo/__init__.py index 648901b38b6..d5592abe188 100644 --- a/temba/utils/dynamo/__init__.py +++ b/temba/utils/dynamo/__init__.py @@ -6,7 +6,7 @@ _client = None -def get_client(): # pragma: no cover +def get_client(): """ Returns our shared DynamoDB client """ @@ -20,7 +20,7 @@ def get_client(): # pragma: no cover aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, region_name=settings.AWS_REGION, ) - else: + else: # pragma: no cover session = boto3.Session() _client = session.client( @@ -28,3 +28,10 @@ def get_client(): # pragma: no cover ) return _client + + +def table_name(logical_name: str) -> str: + """ + Add optional prefix to name to allow multiple deploys in same region + """ + return settings.DYNAMO_TABLE_PREFIX + logical_name diff --git a/temba/utils/dynamo/tests.py b/temba/utils/dynamo/tests.py new file mode 100644 index 00000000000..5168f75e69c --- /dev/null +++ b/temba/utils/dynamo/tests.py @@ -0,0 +1,12 @@ +from temba.tests import TembaTest +from temba.utils import dynamo + + +class DynamoTest(TembaTest): + def test_get_client(self): + client1 = dynamo.get_client() + client2 = dynamo.get_client() + self.assertIs(client1, client2) + + def test_table_name(self): + self.assertEqual("LocalThings", dynamo.table_name("Things")) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 3bfc0483b71..2be485ac9f5 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.core.management import BaseCommand from temba.utils import dynamo @@ -21,28 +20,30 @@ def handle(self, *args, **kwargs): self.client = dynamo.get_client() for table in TABLES: - # add optional prefix to name to allow multiple deploys in same region - name = settings.DYNAMO_TABLE_PREFIX + table["TableName"] - table["TableName"] = name + name = table["TableName"] + real_name = dynamo.table_name(name) # ttl isn't actually part of the create_table call ttlSpec = table.pop("TimeToLiveSpecification", None) - if not self._table_exists(name): - self.client.create_table(**table) + if not self._table_exists(real_name): + spec = table.copy() + spec["TableName"] = real_name - self.stdout.write(f"{name}: created") + self.client.create_table(**spec) + + self.stdout.write(f"{real_name}: created") if ttlSpec: - self.client.update_time_to_live(TableName=name, TimeToLiveSpecification=ttlSpec) + self.client.update_time_to_live(TableName=real_name, TimeToLiveSpecification=ttlSpec) - self.stdout.write(f"{name}: updated TTL") + self.stdout.write(f"{real_name}: updated TTL") else: - self.stdout.write(f"{name}: already exists") + self.stdout.write(f"{real_name}: already exists") - def _table_exists(self, name: str) -> bool: + def _table_exists(self, real_name: str) -> bool: try: - self.client.describe_table(TableName=name) + self.client.describe_table(TableName=real_name) return True except self.client.exceptions.ResourceNotFoundException: return False From e93a587b1c17238570cdab5cdd9ba6ffe5a92ac5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 12:02:23 -0500 Subject: [PATCH 016/557] Update CHANGELOG.md for v9.3.26 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f457e17c3cb..e3aaf8c6646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.26 (2024-08-21) +------------------------- + * Add redirect for contact interrupt + * Create dynamo table with on-demand billing by default + v9.3.25 (2024-08-21) ------------------------- * Fix matching for invites with email case insensitively diff --git a/pyproject.toml b/pyproject.toml index 2eed047029d..ba501b398a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.25" +version = "9.3.26" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 4af43a3e04e..541ffd18fda 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.25" +__version__ = "9.3.26" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From b0523da24268265d84c756b6b56104d241a5648c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 20:42:08 +0000 Subject: [PATCH 017/557] Updates to migrate_dynamo command --- temba/settings_common.py | 2 +- temba/utils/dynamo/__init__.py | 39 +---------- temba/utils/dynamo/base.py | 37 +++++++++++ temba/utils/dynamo/signals.py | 3 + temba/utils/dynamo/tests.py | 2 +- .../management/commands/migrate_dynamo.py | 65 +++++++++++++------ temba/utils/management/commands/tests.py | 39 +++++++++++ 7 files changed, 129 insertions(+), 58 deletions(-) create mode 100644 temba/utils/dynamo/base.py create mode 100644 temba/utils/dynamo/signals.py create mode 100644 temba/utils/management/commands/tests.py diff --git a/temba/settings_common.py b/temba/settings_common.py index b37b08ee4e5..12c377adfb9 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -67,7 +67,7 @@ AWS_REGION = "us-east-1" DYNAMO_ENDPOINT_URL = f"http://{_dynamo_host}:8000" -DYNAMO_TABLE_PREFIX = "Local" +DYNAMO_TABLE_PREFIX = "Test" if TESTING else "Temba" # ----------------------------------------------------------------------------------- # Storage diff --git a/temba/utils/dynamo/__init__.py b/temba/utils/dynamo/__init__.py index d5592abe188..9aede895713 100644 --- a/temba/utils/dynamo/__init__.py +++ b/temba/utils/dynamo/__init__.py @@ -1,37 +1,2 @@ -import boto3 -from botocore.client import Config - -from django.conf import settings - -_client = None - - -def get_client(): - """ - Returns our shared DynamoDB client - """ - - global _client - - if not _client: - if settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY: - session = boto3.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - region_name=settings.AWS_REGION, - ) - else: # pragma: no cover - session = boto3.Session() - - _client = session.client( - "dynamodb", endpoint_url=settings.DYNAMO_ENDPOINT_URL, config=Config(retries={"max_attempts": 3}) - ) - - return _client - - -def table_name(logical_name: str) -> str: - """ - Add optional prefix to name to allow multiple deploys in same region - """ - return settings.DYNAMO_TABLE_PREFIX + logical_name +from . import signals # noqa +from .base import * # noqa diff --git a/temba/utils/dynamo/base.py b/temba/utils/dynamo/base.py new file mode 100644 index 00000000000..d5592abe188 --- /dev/null +++ b/temba/utils/dynamo/base.py @@ -0,0 +1,37 @@ +import boto3 +from botocore.client import Config + +from django.conf import settings + +_client = None + + +def get_client(): + """ + Returns our shared DynamoDB client + """ + + global _client + + if not _client: + if settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY: + session = boto3.Session( + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_REGION, + ) + else: # pragma: no cover + session = boto3.Session() + + _client = session.client( + "dynamodb", endpoint_url=settings.DYNAMO_ENDPOINT_URL, config=Config(retries={"max_attempts": 3}) + ) + + return _client + + +def table_name(logical_name: str) -> str: + """ + Add optional prefix to name to allow multiple deploys in same region + """ + return settings.DYNAMO_TABLE_PREFIX + logical_name diff --git a/temba/utils/dynamo/signals.py b/temba/utils/dynamo/signals.py new file mode 100644 index 00000000000..197cceb4280 --- /dev/null +++ b/temba/utils/dynamo/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +pre_create_table = Signal() diff --git a/temba/utils/dynamo/tests.py b/temba/utils/dynamo/tests.py index 5168f75e69c..eff450bedc5 100644 --- a/temba/utils/dynamo/tests.py +++ b/temba/utils/dynamo/tests.py @@ -9,4 +9,4 @@ def test_get_client(self): self.assertIs(client1, client2) def test_table_name(self): - self.assertEqual("LocalThings", dynamo.table_name("Things")) + self.assertEqual("TestThings", dynamo.table_name("Things")) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 2be485ac9f5..135a5c22e49 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -1,3 +1,5 @@ +import time + from django.core.management import BaseCommand from temba.utils import dynamo @@ -7,8 +9,8 @@ "TableName": "ChannelLogsAttached", "KeySchema": [{"AttributeName": "UUID", "KeyType": "HASH"}], "AttributeDefinitions": [{"AttributeName": "UUID", "AttributeType": "S"}], - "BillingMode": "PAY_PER_REQUEST", "TimeToLiveSpecification": {"AttributeName": "ExpireOn", "Enabled": True}, + "BillingMode": "PAY_PER_REQUEST", } ] @@ -20,30 +22,55 @@ def handle(self, *args, **kwargs): self.client = dynamo.get_client() for table in TABLES: - name = table["TableName"] - real_name = dynamo.table_name(name) + self._migrate_table(table) + + def _migrate_table(self, table: dict): + name = table["TableName"] + real_name = dynamo.table_name(name) + status = self._table_status(real_name) + + if status == "": + spec = table.copy() + spec["TableName"] = real_name + + # invoke pre-create signal to allow for table modifications + dynamo.signals.pre_create_table.send(self.__class__, spec=spec) + + # ttl isn't actually part of the create call + ttlSpec = spec.pop("TimeToLiveSpecification", None) + + self.stdout.write(f"Creating {real_name}...", ending="") + self.stdout.flush() + + self._create_table(spec) - # ttl isn't actually part of the create_table call - ttlSpec = table.pop("TimeToLiveSpecification", None) + self.stdout.write(self.style.SUCCESS(" OK")) - if not self._table_exists(real_name): - spec = table.copy() - spec["TableName"] = real_name + if ttlSpec: + self.client.update_time_to_live(TableName=real_name, TimeToLiveSpecification=ttlSpec) - self.client.create_table(**spec) + self.stdout.write(f"Updated TTL for {real_name}") + else: + self.stdout.write(f"Skipping {real_name} which already exists") - self.stdout.write(f"{real_name}: created") + def _create_table(self, spec: dict): + """ + Creates the given table and waits for it to become active. + """ + self.client.create_table(**spec) - if ttlSpec: - self.client.update_time_to_live(TableName=real_name, TimeToLiveSpecification=ttlSpec) + while True: + time.sleep(1.0) - self.stdout.write(f"{real_name}: updated TTL") - else: - self.stdout.write(f"{real_name}: already exists") + if self._table_status(spec["TableName"]) == "ACTIVE": + break - def _table_exists(self, real_name: str) -> bool: + def _table_status(self, real_name: str) -> str: + """ + Returns the status of a table, or an empty string if it doesn't exist. + """ try: - self.client.describe_table(TableName=real_name) - return True + desc = self.client.describe_table(TableName=real_name) + return desc["Table"]["TableStatus"] except self.client.exceptions.ResourceNotFoundException: - return False + return "" diff --git a/temba/utils/management/commands/tests.py b/temba/utils/management/commands/tests.py new file mode 100644 index 00000000000..b0d0bdc0e0d --- /dev/null +++ b/temba/utils/management/commands/tests.py @@ -0,0 +1,39 @@ +from io import StringIO + +from django.core.management import call_command +from django.test.utils import override_settings + +from temba.tests import TembaTest +from temba.utils import dynamo + + +class MigrateDynamoTest(TembaTest): + def tearDown(self): + client = dynamo.get_client() + + for table_name in client.list_tables()["TableNames"]: + if table_name.startswith("Temp"): + client.delete_table(TableName=table_name) + + return super().tearDown() + + @override_settings(DYNAMO_TABLE_PREFIX="Temp") + def test_migrate_dynamo(self): + def pre_create_table(sender, spec, **kwargs): + spec["Tags"] = [{"Key": "Foo", "Value": "Bar"}] + + dynamo.signals.pre_create_table.connect(pre_create_table) + + out = StringIO() + call_command("migrate_dynamo", stdout=out) + + self.assertIn("Creating TempChannelLogsAttached", out.getvalue()) + + client = dynamo.get_client() + desc = client.describe_table(TableName="TempChannelLogsAttached") + self.assertEqual("ACTIVE", desc["Table"]["TableStatus"]) + + out = StringIO() + call_command("migrate_dynamo", stdout=out) + + self.assertIn("Skipping TempChannelLogsAttached", out.getvalue()) From 970c6d66ad146528e3cb41258ccc732132ef3cb7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 21 Aug 2024 17:27:04 -0500 Subject: [PATCH 018/557] Update CHANGELOG.md for v9.3.27 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3aaf8c6646..44653734691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.27 (2024-08-21) +------------------------- + * Updates to migrate_dynamo command + v9.3.26 (2024-08-21) ------------------------- * Add redirect for contact interrupt diff --git a/pyproject.toml b/pyproject.toml index ba501b398a8..f5fc46ad6c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.26" +version = "9.3.27" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 541ffd18fda..78536a4c0e8 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.26" +__version__ = "9.3.27" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 21c5ffa7d1ad7211b604cd807a68d9388b6ef558 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 22 Aug 2024 16:42:40 -0500 Subject: [PATCH 019/557] Update README.md --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8eb04bd6689..726103a424d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,20 @@ -# TextIt +# RapidPro [![Build Status](https://github.com/nyaruka/rapidpro/workflows/CI/badge.svg)](https://github.com/nyaruka/rapidpro/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/nyaruka/rapidpro/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/rapidpro) -TextIt is a hosted service for visually building interactive messaging applications. You can signup at +RapidPro is a cloud based service developed by [TextIt](https://textit.com) for visually building interactive messaging applications. You can signup at [textit.com](https://textit.com) or host it yourself. -### Stable Versions +## Technology Stack -The set of versions that make up the latest stable release are: + * [PostgreSQL](https://www.postgresql.org) + * [Redis](https://redis.io) + * [Elasticsearch](https://www.elastic.co/elasticsearch) + * [S3](https://aws.amazon.com/s3/) + * [DynamoDB](https://aws.amazon.com/dynamodb/) - * [RapidPro 9.2.5](https://github.com/nyaruka/rapidpro/releases/tag/v9.2.5) - * [Mailroom 9.2.2](https://github.com/nyaruka/mailroom/releases/tag/v9.2.2) - * [Courier 9.2.1](https://github.com/nyaruka/courier/releases/tag/v9.2.1) - * [Indexer 9.2.0](https://github.com/nyaruka/rp-indexer/releases/tag/v9.2.0) - * [Archiver 9.2.0](https://github.com/nyaruka/rp-archiver/releases/tag/v9.2.0) - -### Versioning +## Versioning Major releases are made every 6 months on a set schedule. We target January as a major release (e.g. `9.0.0`), then July as the stable dot release (e.g. `9.2.0`). Unstable releases (i.e. *development* versions) have odd minor versions @@ -29,3 +27,13 @@ for the latest stable release you are on, then every stable release afterwards. Generally we only do bug fixes (patch releases) on stable releases for the first two weeks after we put out that release. After that you either have to wait for the next stable release or take your chances with an unstable release. + +### Stable Versions + +The set of versions that make up the latest stable release are: + + * [RapidPro 9.2.5](https://github.com/nyaruka/rapidpro/releases/tag/v9.2.5) + * [Mailroom 9.2.2](https://github.com/nyaruka/mailroom/releases/tag/v9.2.2) + * [Courier 9.2.1](https://github.com/nyaruka/courier/releases/tag/v9.2.1) + * [Indexer 9.2.0](https://github.com/nyaruka/rp-indexer/releases/tag/v9.2.0) + * [Archiver 9.2.0](https://github.com/nyaruka/rp-archiver/releases/tag/v9.2.0) From 11eef1f31d73fe50124a3d8a2078821f512a93dd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 22 Aug 2024 16:46:38 -0500 Subject: [PATCH 020/557] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 726103a424d..77ca72d1f84 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://github.com/nyaruka/rapidpro/workflows/CI/badge.svg)](https://github.com/nyaruka/rapidpro/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/nyaruka/rapidpro/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/rapidpro) -RapidPro is a cloud based service developed by [TextIt](https://textit.com) for visually building interactive messaging applications. You can signup at +RapidPro is a cloud based SaaS developed by [TextIt](https://textit.com) for visually building interactive messaging applications. You can signup at [textit.com](https://textit.com) or host it yourself. ## Technology Stack From 16a02851b0b77802de61350bf5fe6508dafb3c3f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 23 Aug 2024 13:47:37 +0000 Subject: [PATCH 021/557] Only import real flows in tests where it's required --- temba/api/v2/tests.py | 4 +- temba/channels/tests.py | 2 +- temba/contacts/tests.py | 5 +-- temba/flows/tests.py | 94 +++++++++++++++++++---------------------- temba/globals/tests.py | 4 +- temba/mailroom/tests.py | 6 +-- temba/tickets/tests.py | 2 +- 7 files changed, 54 insertions(+), 63 deletions(-) diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 7adaf2e4d9c..930c66db048 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -3184,7 +3184,7 @@ def test_flow_starts(self, mock_async_start): self.assertPostNotPermitted(endpoint_url, [None, self.agent, self.user]) self.assertDeleteNotAllowed(endpoint_url) - flow = self.get_flow("favorites_v13") + flow = self.create_flow("Test") # try to create an empty flow start self.assertPost(endpoint_url, self.editor, {}, errors={"flow": "This field is required."}) @@ -3402,7 +3402,7 @@ def test_flow_starts(self, mock_async_start): { "id": start3.id, "uuid": str(start3.uuid), - "flow": {"uuid": flow.uuid, "name": "Favorites"}, + "flow": {"uuid": flow.uuid, "name": "Test"}, "contacts": [{"uuid": self.joe.uuid, "name": "Joe Blow"}], "groups": [{"uuid": hans_group.uuid, "name": "hans"}], "restart_participants": False, diff --git a/temba/channels/tests.py b/temba/channels/tests.py index 049201b8513..4343f34e3c2 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -178,7 +178,7 @@ def test_release(self, mr_mocks): channel2 = Channel.create(self.org, self.user, "", "T", "Test Channel", "0785553333") # add channel trigger - flow = self.get_flow("color") + flow = self.create_flow("Test") Trigger.create(self.org, self.admin, Trigger.TYPE_CATCH_ALL, flow, channel=channel1) # create some activity on this channel diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index ec46dd1c8e4..2bd3c34c1a2 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -986,9 +986,8 @@ def test_delete(self): @mock_mailroom def test_start(self, mr_mocks): sample_flows = list(self.org.flows.order_by("name")) - background_flow = self.get_flow("background") - self.get_flow("media_survey") - archived_flow = self.get_flow("color") + background_flow = self.create_flow("Background") + archived_flow = self.create_flow("Archived") archived_flow.archive(self.admin) contact = self.create_contact("Joe", phone="+593979000111") diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 0d267f37bda..da0f35f24b3 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -96,7 +96,7 @@ def test_clean_name(self): @patch("temba.mailroom.queue_interrupt") def test_archive(self, mock_queue_interrupt): - flow = self.get_flow("color") + flow = self.create_flow("Test") flow.archive(self.admin) mock_queue_interrupt.assert_called_once_with(self.org, flow=flow) @@ -220,7 +220,7 @@ def test_flow_archive_with_campaign(self): self.assertTrue(flow.is_archived) def test_editor(self): - flow = self.get_flow("color") + flow = self.create_flow("Test") self.login(self.admin) @@ -1245,7 +1245,7 @@ def test_group_send(self): self.get_flow("group_send_flow") def test_flow_delete_of_inactive_flow(self): - flow = self.get_flow("favorites") + flow = self.create_flow("Test") flow.release(self.admin) self.login(self.admin) @@ -1630,7 +1630,7 @@ def test_views(self): create_url = reverse("flows.flow_create") self.create_contact("Eric", phone="+250788382382") - flow = self.get_flow("color") + flow = self.create_flow("Test") # create a flow for another org other_flow = Flow.create(self.org2, self.admin2, "Flow2") @@ -1821,7 +1821,7 @@ def test_views(self): self.assertEqual(language_flow.base_language, "eng") def test_update_messaging_flow(self): - flow = self.get_flow("color_v13") + flow = self.create_flow("Test") update_url = reverse("flows.flow_update", args=[flow.id]) def assert_triggers(expected: list): @@ -1833,9 +1833,9 @@ def assert_triggers(expected: list): update_url, [self.editor, self.admin], form_fields={ - "name": "Colors", + "name": "Test", "keyword_triggers": [], - "expires_after_minutes": 720, + "expires_after_minutes": 10080, "ignore_triggers": False, }, ) @@ -1931,7 +1931,7 @@ def assert_triggers(expected: list): ) def test_update_voice_flow(self): - flow = self.get_flow("ivr") + flow = self.create_flow("IVR Test", flow_type=Flow.TYPE_VOICE) update_url = reverse("flows.flow_update", args=[flow.id]) self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2]) @@ -1979,7 +1979,7 @@ def test_update_voice_flow(self): self.assertEqual(30, flow.metadata["ivr_retry"]) def test_update_surveyor_flow(self): - flow = self.get_flow("media_survey") + flow = self.create_flow("Survey", flow_type=Flow.TYPE_SURVEY) update_url = reverse("flows.flow_update", args=[flow.id]) # we should only see name and contact creation option on form @@ -1994,7 +1994,7 @@ def test_update_surveyor_flow(self): self.assertEqual("login", flow.metadata.get("contact_creation")) def test_update_background_flow(self): - flow = self.get_flow("background") + flow = self.create_flow("Background", flow_type=Flow.TYPE_BACKGROUND) update_url = reverse("flows.flow_update", args=[flow.id]) # we should only see name on form @@ -2008,14 +2008,14 @@ def test_update_background_flow(self): self.assertEqual("New Name", flow.name) def test_list_views(self): - flow1 = self.get_flow("color_v13") - flow2 = self.get_flow("no_ruleset_flow") + flow1 = self.create_flow("Flow 1") + flow2 = self.create_flow("Flow 2") # archive second flow flow2.is_archived = True flow2.save(update_fields=("is_archived",)) - flow3 = Flow.create(self.org, self.admin, "Flow 3") + flow3 = self.create_flow("Flow 3") self.login(self.admin) @@ -2293,7 +2293,7 @@ def test_save_revisions(self): self.assertResponseError(response, "description", "Your flow has been upgraded to the latest version") def test_inactive_flow(self): - flow = self.get_flow("color_v13") + flow = self.create_flow("Deleted") flow.release(self.admin) self.login(self.admin) @@ -2898,6 +2898,16 @@ def test_results(self): self.assertEqual(24, len(data["hod"])) self.assertEqual(7, len(data["dow"])) + # check that views return 404 for inactive flows + flow = self.create_flow("Deleted") + flow.release(self.admin) + + response = self.client.get(reverse("flows.flow_activity_chart", args=[flow.id])) + self.assertEqual(404, response.status_code) + + response = self.client.get(reverse("flows.flow_category_counts", args=[flow.uuid])) + self.assertEqual(404, response.status_code) + def test_activity(self): flow = self.get_flow("favorites_v13") flow_nodes = flow.get_definition()["nodes"] @@ -2939,24 +2949,6 @@ def test_activity(self): response.json(), ) - def test_activity_chart_of_inactive_flow(self): - flow = self.get_flow("favorites") - flow.release(self.admin) - - self.login(self.admin) - response = self.client.get(reverse("flows.flow_activity_chart", args=[flow.id])) - - self.assertEqual(404, response.status_code) - - def test_category_counts_of_inactive_flow(self): - flow = self.get_flow("favorites") - flow.release(self.admin) - - self.login(self.admin) - response = self.client.get(reverse("flows.flow_category_counts", args=[flow.uuid])) - - self.assertEqual(404, response.status_code) - def test_write_protection(self): flow = self.get_flow("favorites_v13") flow_json = flow.get_definition() @@ -3738,7 +3730,7 @@ def create_session(org, created_on: datetime): def test_trim(self): contact = self.create_contact("Ben Haggerty", phone="+250788123123") - flow = self.get_flow("color") + flow = self.create_flow("Test") # create some runs that have sessions session1 = FlowSession.objects.create( @@ -4953,7 +4945,7 @@ def test_from_archives(self): def test_no_responses(self): today = timezone.now().astimezone(self.org.timezone).date() - flow = self.get_flow("color_v13") + flow = self.create_flow("Test") self.assertEqual(flow.get_run_stats()["total"], 0) @@ -4963,7 +4955,7 @@ def test_no_responses(self): # every sheet has only the head row self.assertEqual(1, len(list(workbook.worksheets[0].rows))) - self.assertEqual(11, len(list(workbook.worksheets[0].columns))) + self.assertEqual(8, len(list(workbook.worksheets[0].columns))) class FlowLabelTest(TembaTest): @@ -5308,28 +5300,28 @@ class FlowRevisionTest(TembaTest): def test_trim_revisions(self): start = timezone.now() - color = self.get_flow("color") - clinic = self.get_flow("the_clinic") + flow1 = self.create_flow("Flow 1") + flow2 = self.create_flow("Flow 2") revision = 100 FlowRevision.objects.all().update(revision=revision) # create a single old clinic revision FlowRevision.objects.create( - flow=clinic, + flow=flow2, definition=dict(), revision=99, created_on=timezone.now() - timedelta(days=7), created_by=self.admin, ) - # make a bunch of revisions for color on the same day + # make a bunch of revisions for flow 1 on the same day created = timezone.now().replace(hour=6) - timedelta(days=1) for i in range(25): revision -= 1 created = created - timedelta(minutes=1) FlowRevision.objects.create( - flow=color, definition=dict(), revision=revision, created_by=self.admin, created_on=created + flow=flow1, definition=dict(), revision=revision, created_by=self.admin, created_on=created ) # then for 5 days prior, make a few more @@ -5339,32 +5331,32 @@ def test_trim_revisions(self): revision -= 1 created = created - timedelta(minutes=1) FlowRevision.objects.create( - flow=color, definition=dict(), revision=revision, created_by=self.admin, created_on=created + flow=flow1, definition=dict(), revision=revision, created_by=self.admin, created_on=created ) # trim our flow revisions, should be left with original (today), 25 from yesterday, 1 per day for 5 days = 31 - self.assertEqual(76, FlowRevision.objects.filter(flow=color).count()) + self.assertEqual(76, FlowRevision.objects.filter(flow=flow1).count()) self.assertEqual(45, FlowRevision.trim(start)) - self.assertEqual(31, FlowRevision.objects.filter(flow=color).count()) + self.assertEqual(31, FlowRevision.objects.filter(flow=flow1).count()) self.assertEqual( 7, - FlowRevision.objects.filter(flow=color) + FlowRevision.objects.filter(flow=flow1) .annotate(created_date=TruncDate("created_on")) .distinct("created_date") .count(), ) # trim our clinic flow manually, should remain unchanged - self.assertEqual(2, FlowRevision.objects.filter(flow=clinic).count()) - self.assertEqual(0, FlowRevision.trim_for_flow(clinic.id)) - self.assertEqual(2, FlowRevision.objects.filter(flow=clinic).count()) + self.assertEqual(2, FlowRevision.objects.filter(flow=flow2).count()) + self.assertEqual(0, FlowRevision.trim_for_flow(flow2.id)) + self.assertEqual(2, FlowRevision.objects.filter(flow=flow2).count()) # call our task trim_flow_revisions() - self.assertEqual(2, FlowRevision.objects.filter(flow=clinic).count()) - self.assertEqual(31, FlowRevision.objects.filter(flow=color).count()) + self.assertEqual(2, FlowRevision.objects.filter(flow=flow2).count()) + self.assertEqual(31, FlowRevision.objects.filter(flow=flow1).count()) # call again (testing reading redis key) trim_flow_revisions() - self.assertEqual(2, FlowRevision.objects.filter(flow=clinic).count()) - self.assertEqual(31, FlowRevision.objects.filter(flow=color).count()) + self.assertEqual(2, FlowRevision.objects.filter(flow=flow2).count()) + self.assertEqual(31, FlowRevision.objects.filter(flow=flow1).count()) diff --git a/temba/globals/tests.py b/temba/globals/tests.py index 43e3315243d..2c937f68d09 100644 --- a/temba/globals/tests.py +++ b/temba/globals/tests.py @@ -29,8 +29,8 @@ def test_model(self): self.assertEqual("Secret Value", global3.name) self.assertEqual("", global3.value) - flow1 = self.get_flow("color") - flow2 = self.get_flow("favorites") + flow1 = self.create_flow("Flow 1") + flow2 = self.create_flow("Flow 2") flow1.global_dependencies.add(global1, global2) flow2.global_dependencies.add(global1) diff --git a/temba/mailroom/tests.py b/temba/mailroom/tests.py index a0e0be3f377..f5f0b6cb34e 100644 --- a/temba/mailroom/tests.py +++ b/temba/mailroom/tests.py @@ -20,7 +20,7 @@ class MailroomQueueTest(TembaTest): def test_queue_flow_start(self): - flow = self.get_flow("favorites") + flow = self.create_flow("Test") jim = self.create_contact("Jim", phone="+12065551212") bobs = self.create_group("Bobs", [self.create_contact("Bob", phone="+12065551313")]) @@ -101,7 +101,7 @@ def test_queue_interrupt_by_contacts(self): ) def test_queue_interrupt_by_flow(self): - flow = self.get_flow("favorites") + flow = self.create_flow("Test") flow.archive(self.admin) self.assert_org_queued(self.org, "batch") @@ -472,7 +472,7 @@ def test_from_flow_run(self): ) def test_from_event_fire(self): - flow = self.get_flow("color_v13") + flow = self.create_flow("Test") group = self.create_group("Reporters", contacts=[]) registered = self.create_field("registered", "Registered", value_type="D") campaign = Campaign.create(self.org, self.admin, "Welcomes", group) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index b495ded7c06..b63863b999a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -365,7 +365,7 @@ def test_list(self): self.assertEqual(("tickets", "mine", "open", str(ticket.uuid)), response.context["temba_referer"]) # contacts in a flow get interrupt menu option instead - flow = self.get_flow("color") + flow = self.create_flow("Test") self.contact.current_flow = flow self.contact.save() deep_link = f"{list_url}all/open/{str(ticket.uuid)}/" From ce2bb0ea99298de9f42d7dc4bad3a0feb73c28de Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 23 Aug 2024 13:49:57 +0000 Subject: [PATCH 022/557] TembaTest.create_flow should return a flow in latest version without migrating --- temba/tests/base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/temba/tests/base.py b/temba/tests/base.py index abea0847540..34a7140e44f 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -543,7 +543,7 @@ def create_flow(self, name: str, *, flow_type=Flow.TYPE_MESSAGE, nodes=None, is_ "name": name, "type": Flow.GOFLOW_TYPES[flow_type], "revision": 1, - "spec_version": "13.1.0", + "spec_version": Flow.CURRENT_SPEC_VERSION, "expire_after_minutes": Flow.EXPIRES_DEFAULTS[flow_type], "language": "eng", "nodes": nodes, @@ -551,9 +551,7 @@ def create_flow(self, name: str, *, flow_type=Flow.TYPE_MESSAGE, nodes=None, is_ flow.version_number = definition["spec_version"] flow.save() - - json_flow = Flow.migrate_definition(definition, flow) - flow.save_revision(self.admin, json_flow) + flow.save_revision(self.admin, definition) return flow From 97fafd6dbd483a596f161c718d30986eab86c363 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 23 Aug 2024 17:40:46 +0000 Subject: [PATCH 023/557] Simplify functions for loading flows in tests and move flows used by legacy migration tests into their own directory --- media/test_flows/dual_webhook.json | 132 ----- .../favorites_bad_group_name_v10.json | 432 ----------------- .../favorites_bad_group_name_v4.json | 342 ------------- .../favorites_bad_group_name_v5.json | 449 ----------------- .../favorites_bad_group_name_v6.json | 450 ----------------- .../favorites_bad_group_name_v7.json | 446 ----------------- .../favorites_bad_group_name_v8.json | 420 ---------------- .../favorites_bad_group_name_v9.json | 419 ---------------- media/test_flows/ivr_v3.json | 161 ------ .../legacy/invalid/no_base_language_v8.json | 44 ++ .../legacy/invalid/non_localized_ruleset.json | 39 ++ .../invalid/non_localized_with_language.json | 326 +++++++++++++ .../legacy/invalid/not_fully_localized.json | 31 ++ .../legacy/migrations/dual_webhook.json | 132 +++++ .../legacy/migrations/favorites.json | 315 ++++++++++++ .../favorites_bad_group_name_v10.json | 430 ++++++++++++++++ .../favorites_bad_group_name_v4.json | 352 ++++++++++++++ .../favorites_bad_group_name_v5.json | 421 ++++++++++++++++ .../favorites_bad_group_name_v6.json | 422 ++++++++++++++++ .../favorites_bad_group_name_v7.json | 418 ++++++++++++++++ .../favorites_bad_group_name_v8.json | 418 ++++++++++++++++ .../favorites_bad_group_name_v9.json | 417 ++++++++++++++++ .../legacy/migrations/favorites_v4.json | 332 +++++++++++++ .../test_flows/legacy/migrations/ivr_v3.json | 161 ++++++ .../legacy/migrations/malformed_groups.json | 49 ++ .../migrations/malformed_single_message.json | 31 ++ .../legacy/migrations/migrate_to_11_0.json | 42 ++ .../legacy/migrations/migrate_to_11_10.json | 239 +++++++++ .../legacy/migrations/migrate_to_11_11.json | 107 ++++ .../legacy/migrations/migrate_to_11_12.json | 197 ++++++++ .../migrations/migrate_to_11_12_one_node.json | 38 ++ .../migrate_to_11_12_other_org.json | 39 ++ .../legacy/migrations/migrate_to_11_3.json | 84 ++++ .../legacy/migrations/migrate_to_11_4.json | 168 +++++++ .../legacy/migrations/migrate_to_11_5.json | 398 +++++++++++++++ .../legacy/migrations/migrate_to_11_6.json | 252 ++++++++++ .../legacy/migrations/migrate_to_11_7.json | 246 ++++++++++ .../legacy/migrations/migrate_to_11_8.json | 341 +++++++++++++ .../legacy/migrations/migrate_to_11_9.json | 458 ++++++++++++++++++ .../legacy/migrations/migrate_to_9.json | 148 ++++++ .../migrations/multi_language_flow.json | 176 +++++++ .../legacy/migrations/old_expressions.json | 118 +++++ .../single_message_bad_localization.json | 25 + .../legacy/migrations/type_flow.json | 394 +++++++++++++++ media/test_flows/malformed_groups.json | 44 -- .../test_flows/malformed_single_message.json | 31 -- media/test_flows/migrate_to_11_0.json | 42 -- media/test_flows/migrate_to_11_10.json | 239 --------- media/test_flows/migrate_to_11_11.json | 107 ---- media/test_flows/migrate_to_11_12.json | 197 -------- .../test_flows/migrate_to_11_12_one_node.json | 38 -- .../migrate_to_11_12_other_org.json | 35 -- media/test_flows/migrate_to_11_3.json | 84 ---- media/test_flows/migrate_to_11_4.json | 168 ------- media/test_flows/migrate_to_11_5.json | 398 --------------- media/test_flows/migrate_to_11_6.json | 252 ---------- media/test_flows/migrate_to_11_7.json | 246 ---------- media/test_flows/migrate_to_11_8.json | 341 ------------- media/test_flows/migrate_to_11_9.json | 458 ------------------ media/test_flows/migrate_to_9.json | 148 ------ media/test_flows/multi_language_flow.json | 176 ------- media/test_flows/no_base_language_v8.json | 50 -- media/test_flows/non_localized_ruleset.json | 45 -- .../non_localized_with_language.json | 332 ------------- media/test_flows/not_fully_localized.json | 37 -- media/test_flows/old_expressions.json | 118 ----- .../single_message_bad_localization.json | 29 -- media/test_flows/type_flow.json | 394 --------------- temba/api/v2/tests.py | 6 +- temba/campaigns/tests.py | 29 +- temba/flows/legacy/tests.py | 96 ++-- temba/flows/tests.py | 44 +- temba/orgs/tests.py | 36 +- temba/tests/base.py | 36 +- 74 files changed, 7934 insertions(+), 7381 deletions(-) delete mode 100644 media/test_flows/dual_webhook.json delete mode 100644 media/test_flows/favorites_bad_group_name_v10.json delete mode 100644 media/test_flows/favorites_bad_group_name_v4.json delete mode 100644 media/test_flows/favorites_bad_group_name_v5.json delete mode 100644 media/test_flows/favorites_bad_group_name_v6.json delete mode 100644 media/test_flows/favorites_bad_group_name_v7.json delete mode 100644 media/test_flows/favorites_bad_group_name_v8.json delete mode 100644 media/test_flows/favorites_bad_group_name_v9.json delete mode 100644 media/test_flows/ivr_v3.json create mode 100644 media/test_flows/legacy/invalid/no_base_language_v8.json create mode 100644 media/test_flows/legacy/invalid/non_localized_ruleset.json create mode 100644 media/test_flows/legacy/invalid/non_localized_with_language.json create mode 100644 media/test_flows/legacy/invalid/not_fully_localized.json create mode 100644 media/test_flows/legacy/migrations/dual_webhook.json create mode 100644 media/test_flows/legacy/migrations/favorites.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v10.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v4.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v5.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v6.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v7.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v8.json create mode 100644 media/test_flows/legacy/migrations/favorites_bad_group_name_v9.json create mode 100644 media/test_flows/legacy/migrations/favorites_v4.json create mode 100644 media/test_flows/legacy/migrations/ivr_v3.json create mode 100644 media/test_flows/legacy/migrations/malformed_groups.json create mode 100644 media/test_flows/legacy/migrations/malformed_single_message.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_0.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_10.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_11.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_12.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_12_one_node.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_12_other_org.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_3.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_4.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_5.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_6.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_7.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_8.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_11_9.json create mode 100644 media/test_flows/legacy/migrations/migrate_to_9.json create mode 100644 media/test_flows/legacy/migrations/multi_language_flow.json create mode 100644 media/test_flows/legacy/migrations/old_expressions.json create mode 100644 media/test_flows/legacy/migrations/single_message_bad_localization.json create mode 100644 media/test_flows/legacy/migrations/type_flow.json delete mode 100644 media/test_flows/malformed_groups.json delete mode 100644 media/test_flows/malformed_single_message.json delete mode 100644 media/test_flows/migrate_to_11_0.json delete mode 100644 media/test_flows/migrate_to_11_10.json delete mode 100644 media/test_flows/migrate_to_11_11.json delete mode 100644 media/test_flows/migrate_to_11_12.json delete mode 100644 media/test_flows/migrate_to_11_12_one_node.json delete mode 100644 media/test_flows/migrate_to_11_12_other_org.json delete mode 100644 media/test_flows/migrate_to_11_3.json delete mode 100644 media/test_flows/migrate_to_11_4.json delete mode 100644 media/test_flows/migrate_to_11_5.json delete mode 100644 media/test_flows/migrate_to_11_6.json delete mode 100644 media/test_flows/migrate_to_11_7.json delete mode 100644 media/test_flows/migrate_to_11_8.json delete mode 100644 media/test_flows/migrate_to_11_9.json delete mode 100644 media/test_flows/migrate_to_9.json delete mode 100644 media/test_flows/multi_language_flow.json delete mode 100644 media/test_flows/no_base_language_v8.json delete mode 100644 media/test_flows/non_localized_ruleset.json delete mode 100644 media/test_flows/non_localized_with_language.json delete mode 100644 media/test_flows/not_fully_localized.json delete mode 100644 media/test_flows/old_expressions.json delete mode 100644 media/test_flows/single_message_bad_localization.json delete mode 100644 media/test_flows/type_flow.json diff --git a/media/test_flows/dual_webhook.json b/media/test_flows/dual_webhook.json deleted file mode 100644 index 0ed7a034442..00000000000 --- a/media/test_flows/dual_webhook.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "campaigns": [], - "version": 9, - "site": "https://textit.in", - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "0aabad42-3ec6-40c7-a4cc-c5190b8b4465", - "uuid": "ff642bb5-14fa-4bb6-8040-0ceec395a164", - "actions": [ - { - "msg": { - "eng": "This is the first message" - }, - "type": "reply" - } - ] - }, - { - "y": 310, - "x": 129, - "destination": "6304e1d5-3c0c-44ea-9519-39389227e3c0", - "uuid": "d7523614-1b39-481f-a451-4c4ac9201095", - "actions": [ - { - "msg": { - "eng": "Great, your code is @extra.code. Enter your name" - }, - "type": "reply" - } - ] - } - ], - "version": 9, - "flow_type": "F", - "entry": "ff642bb5-14fa-4bb6-8040-0ceec395a164", - "rule_sets": [ - { - "uuid": "0aabad42-3ec6-40c7-a4cc-c5190b8b4465", - "webhook_action": "POST", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "d7523614-1b39-481f-a451-4c4ac9201095", - "uuid": "1717d336-6fb3-4da0-ac51-4588792e46d2", - "destination_type": "A" - } - ], - "webhook": "http://localhost:49999/code", - "ruleset_type": "webhook", - "label": "Webhook", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 169, - "x": 286, - "config": {} - }, - { - "uuid": "6304e1d5-3c0c-44ea-9519-39389227e3c0", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "8ad78c14-7ebe-4968-82dc-b66dc27d4d96", - "uuid": "da800d48-b1c8-44cf-8e2c-b6c6d5c98aa3", - "destination_type": "R" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Name", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 457, - "x": 265, - "config": {} - }, - { - "uuid": "8ad78c14-7ebe-4968-82dc-b66dc27d4d96", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "uuid": "4dd0f3e7-cc15-41fa-8a84-d53d76d46d66" - } - ], - "webhook": "http://localhost:49999/success", - "ruleset_type": "webhook", - "label": "Webhook 2", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 617, - "x": 312, - "config": {} - } - ], - "metadata": { - "expires": 10080, - "revision": 16, - "uuid": "099d0d1e-3769-472f-9ea7-f3bd5a11c8ff", - "name": "Webhook Migration", - "saved_on": "2016-08-16T16:34:56.351428Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v10.json b/media/test_flows/favorites_bad_group_name_v10.json deleted file mode 100644 index 5b3795fb0b9..00000000000 --- a/media/test_flows/favorites_bad_group_name_v10.json +++ /dev/null @@ -1,432 +0,0 @@ -{ - "version":10, - "flows":[ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type": "add_group", - "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups": [ - { - "uuid": "0fdffdb4-3ca4-4d35-b6a7-129b0dfc7d39", - "name": "< 25" - } - ] - }, - { - "type": "del_group", - "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups": [ - { - "uuid": "e5e7bfaf-7c35-4590-8039-c33da2b98d8c", - "name": "> 100" - } - ] - } - ] - }, - { - "y": 437, - "x": 131, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions": [ - { - "msg": { - "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y": 8, - "x": 456, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions": [ - { - "msg": { - "base": "I don't know that color. Try again." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y": 835, - "x": 191, - "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions": [ - { - "msg": { - "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y": 465, - "x": 512, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions": [ - { - "msg": { - "base": "I don't know that one, try again please." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y": 1105, - "x": 191, - "destination": null, - "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions": [ - { - "msg": { - "base": "Thanks @flow.name, we are all done!" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "flow_type": "F", - "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", - "rule_sets": [ - { - "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", - "rules": [ - { - "test": { - "test": { - "base": "Red" - }, - "type": "contains_any" - }, - "category": { - "base": "Red" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "663f667d-561a-4920-9375-3ce367615bdc", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Green" - }, - "type": "contains_any" - }, - "category": { - "base": "Green" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Blue" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Navy" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Cyan" - }, - "type": "contains_any" - }, - "category": { - "base": "Cyan" - }, - "destination": null, - "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type": null - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": null, - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "expression", - "label": "Color", - "operand": "@extra.value", - "finished_key": null, - "y": 329, - "x": 98, - "config": {} - }, - { - "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", - "rules": [ - { - "test": { - "test": { - "base": "Mutzig" - }, - "type": "contains_any" - }, - "category": { - "base": "Mutzig" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Primus" - }, - "type": "contains_any" - }, - "category": { - "base": "Primus" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Turbo King" - }, - "type": "contains_any" - }, - "category": { - "base": "Turbo King" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Skol" - }, - "type": "contains_any" - }, - "category": { - "base": "Skol" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "expression", - "label": "Beer", - "y": 687, - "finished_key": null, - "operand": "@(LOWER(step.value))", - "x": 112, - "config": {} - }, - { - "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Name", - "y": 1002, - "finished_key": null, - "operand": "@step.value", - "x": 191, - "config": {} - }, - { - "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "wait_message", - "label": "Color Response", - "y": 129, - "finished_key": null, - "operand": "@step.value", - "x": 98, - "config": {} - }, - { - "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "rules": [ - { - "category": { - "base": "Success" - }, - "test": { - "status": "success", - "type": "webhook_status" - }, - "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - }, - { - "category": { - "base": "Failure" - }, - "test": { - "status": "failure", - "type": "webhook_status" - }, - "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid": "cb902a40-780f-4e9b-a31e-e7d1021d05ed", - "destination_type": null - } - ], - "ruleset_type": "webhook", - "label": "Color Webhook", - "y": 229, - "finished_key": null, - "operand": "@step.value", - "x": 98, - "config": { - "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "webhook_action": "POST" - } - }, - { - "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Beer Response", - "operand": "@step.value", - "finished_key": null, - "y": 587, - "x": 112, - "config": {} - } - ], - "metadata": { - "uuid": null, - "notes": [], - "expires": 720, - "name": "Favorites", - "saved_on": null, - "revision": 1 - } - } - ], - "triggers":[ - - ] -} \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v4.json b/media/test_flows/favorites_bad_group_name_v4.json deleted file mode 100644 index c545eed0e39..00000000000 --- a/media/test_flows/favorites_bad_group_name_v4.json +++ /dev/null @@ -1,342 +0,0 @@ -{ - "version": 4, - "flows": [ - { - "definition": { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type": "add_group", - "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups": [{"name": "< 25", "id": 15572}] - }, - { - "type": "del_group", - "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups": [{"name": "> 100", "id": 15573}] - } - ] - }, - { - "y": 237, - "x": 131, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions": [ - { - "msg": { - "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y": 8, - "x": 456, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions": [ - { - "msg": { - "base": "I don't know that color. Try again." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y": 535, - "x": 191, - "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions": [ - { - "msg": { - "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y": 265, - "x": 512, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions": [ - { - "msg": { - "base": "I don't know that one, try again please." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y": 805, - "x": 191, - "destination": null, - "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions": [ - { - "msg": { - "base": "Thanks @flow.name, we are all done!" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "rule_sets": [ - { - "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules": [ - { - "test": { - "test": { - "base": "Red" - }, - "type": "contains_any" - }, - "category": { - "base": "Red" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "663f667d-561a-4920-9375-3ce367615bdc", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Green" - }, - "type": "contains_any" - }, - "category": { - "base": "Green" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Blue" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Navy" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Cyan" - }, - "type": "contains_any" - }, - "category": { - "base": "Cyan" - }, - "destination": null, - "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type": null - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": null, - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": null, - "label": "Color", - "finished_key": null, - "response_type": "C", - "y": 129, - "x": 98, - "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "webhook_action": "POST", - "operand": "@extra.value", - "config": {} - }, - { - "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules": [ - { - "test": { - "test": { - "base": "Mutzig" - }, - "type": "contains_any" - }, - "category": { - "base": "Mutzig" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Primus" - }, - "type": "contains_any" - }, - "category": { - "base": "Primus" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Turbo King" - }, - "type": "contains_any" - }, - "category": { - "base": "Turbo King" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Skol" - }, - "type": "contains_any" - }, - "category": { - "base": "Skol" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": null, - "label": "Beer", - "operand": "@step.value|lower_case", - "finished_key": null, - "response_type": "C", - "y": 387, - "x": 112, - "config": {} - }, - { - "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type": "A" - } - ], - "ruleset_type": null, - "label": "Name", - "operand": "@step.value", - "finished_key": null, - "response_type": "C", - "y": 702, - "x": 191, - "config": {} - } - ], - "metadata": { - "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", - "notes": [], - "expires": 720, - "name": "Favorites", - "revision": 1, - "saved_on": "2017-08-16T23:10:18.579169Z" - } - }, - "version": 4, - "flow_type": "F", - "name": "Favorites", - "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v5.json b/media/test_flows/favorites_bad_group_name_v5.json deleted file mode 100644 index 50a3d96cc81..00000000000 --- a/media/test_flows/favorites_bad_group_name_v5.json +++ /dev/null @@ -1,449 +0,0 @@ -{ - "version":5, - "flows":[ - { - "definition":{ - "base_language":"base", - "rule_sets":[ - { - "uuid":"c564c56f-0341-471e-8bb1-e303090fea6a", - "rules":[ - { - "test":{ - "test":{ - "base":"Red" - }, - "type":"contains_any" - }, - "category":{ - "base":"Red" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"663f667d-561a-4920-9375-3ce367615bdc", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Green" - }, - "type":"contains_any" - }, - "category":{ - "base":"Green" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Blue" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Navy" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Cyan" - }, - "type":"contains_any" - }, - "category":{ - "base":"Cyan" - }, - "destination":null, - "uuid":"cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type":null - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":null, - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"expression", - "label":"Color", - "operand":"@extra.value", - "finished_key":null, - "y":329, - "x":98, - "config":{ - - } - }, - { - "uuid":"8b941374-1b65-4154-afa3-27b871f7be6b", - "rules":[ - { - "test":{ - "test":{ - "base":"Mutzig" - }, - "type":"contains_any" - }, - "category":{ - "base":"Mutzig" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Primus" - }, - "type":"contains_any" - }, - "category":{ - "base":"Primus" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Turbo King" - }, - "type":"contains_any" - }, - "category":{ - "base":"Turbo King" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Skol" - }, - "type":"contains_any" - }, - "category":{ - "base":"Skol" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type":"A" - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"expression", - "label":"Beer", - "y":687, - "finished_key":null, - "operand":"@step.value|lower_case", - "x":112, - "config":{ - - } - }, - { - "uuid":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid":"cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Name", - "y":1002, - "finished_key":null, - "operand":"@step.value", - "x":191, - "config":{ - - } - }, - { - "uuid":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"wait_message", - "label":"Color Response", - "y":129, - "finished_key":null, - "operand":"@step.value", - "x":98, - "config":{ - - } - }, - { - "uuid":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "webhook_action":"POST", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "webhook":"http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "ruleset_type":"webhook", - "label":"Color Webhook", - "y":229, - "finished_key":null, - "operand":"@step.value", - "x":98, - "config":{ - - } - }, - { - "uuid":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Beer Response", - "operand":"@step.value", - "finished_key":null, - "y":587, - "x":112, - "config":{ - - } - } - ], - "action_sets":[ - { - "y":0, - "x":100, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"a6676605-332a-4309-a8b8-79b33e73adcd", - "actions":[ - { - "msg":{ - "base":"What is your favorite color?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type":"add_group", - "uuid":"5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups":[ - { - "name":"< 25", - "id":15572 - } - ] - }, - { - "type":"del_group", - "uuid":"2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups":[ - { - "name":"> 100", - "id":15573 - } - ] - } - ] - }, - { - "y":437, - "x":131, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions":[ - { - "msg":{ - "base":"Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y":8, - "x":456, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions":[ - { - "msg":{ - "base":"I don't know that color. Try again." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y":835, - "x":191, - "destination":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions":[ - { - "msg":{ - "base":"Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y":465, - "x":512, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions":[ - { - "msg":{ - "base":"I don't know that one, try again please." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y":1105, - "x":191, - "destination":null, - "uuid":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions":[ - { - "msg":{ - "base":"Thanks @flow.name, we are all done!" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "metadata":{ - "uuid":"77ae372d-a937-4d9b-a703-cc1c75c4c6f1", - "notes":[ - - ], - "expires":720, - "name":"Favorites", - "saved_on":"2017-08-16T23:10:18.579169Z", - "revision":1 - } - }, - "version":5, - "flow_type":"F", - "name":"Favorites", - "entry":"a6676605-332a-4309-a8b8-79b33e73adcd" - } - ], - "triggers":[ - - ] - } \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v6.json b/media/test_flows/favorites_bad_group_name_v6.json deleted file mode 100644 index 8ac98bf704f..00000000000 --- a/media/test_flows/favorites_bad_group_name_v6.json +++ /dev/null @@ -1,450 +0,0 @@ -{ - "version":6, - "flows":[ - { - "definition":{ - "base_language":"base", - "rule_sets":[ - { - "uuid":"c564c56f-0341-471e-8bb1-e303090fea6a", - "rules":[ - { - "test":{ - "test":{ - "base":"Red" - }, - "type":"contains_any" - }, - "category":{ - "base":"Red" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"663f667d-561a-4920-9375-3ce367615bdc", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Green" - }, - "type":"contains_any" - }, - "category":{ - "base":"Green" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Blue" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Navy" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Cyan" - }, - "type":"contains_any" - }, - "category":{ - "base":"Cyan" - }, - "destination":null, - "uuid":"cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type":null - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":null, - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"expression", - "label":"Color", - "y":329, - "finished_key":null, - "operand":"@extra.value", - "x":98, - "config":{ - - } - }, - { - "uuid":"8b941374-1b65-4154-afa3-27b871f7be6b", - "rules":[ - { - "test":{ - "test":{ - "base":"Mutzig" - }, - "type":"contains_any" - }, - "category":{ - "base":"Mutzig" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Primus" - }, - "type":"contains_any" - }, - "category":{ - "base":"Primus" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Turbo King" - }, - "type":"contains_any" - }, - "category":{ - "base":"Turbo King" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Skol" - }, - "type":"contains_any" - }, - "category":{ - "base":"Skol" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type":"A" - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"expression", - "label":"Beer", - "operand":"@step.value|lower_case", - "finished_key":null, - "y":687, - "x":112, - "config":{ - - } - }, - { - "uuid":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid":"cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Name", - "operand":"@step.value", - "finished_key":null, - "y":1002, - "x":191, - "config":{ - - } - }, - { - "uuid":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"wait_message", - "label":"Color Response", - "operand":"@step.value", - "finished_key":null, - "y":129, - "x":98, - "config":{ - - } - }, - { - "uuid":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "webhook_action":"POST", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "webhook":"http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "ruleset_type":"webhook", - "label":"Color Webhook", - "operand":"@step.value", - "finished_key":null, - "y":229, - "x":98, - "config":{ - - } - }, - { - "uuid":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Beer Response", - "y":587, - "finished_key":null, - "operand":"@step.value", - "x":112, - "config":{ - - } - } - ], - "action_sets":[ - { - "y":0, - "x":100, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"a6676605-332a-4309-a8b8-79b33e73adcd", - "actions":[ - { - "msg":{ - "base":"What is your favorite color?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type":"add_group", - "uuid":"5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups":[ - { - "name":"< 25", - "id":15572 - } - ] - }, - { - "type":"del_group", - "uuid":"2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups":[ - { - "name":"> 100", - "id":15573 - } - ] - } - ] - }, - { - "y":437, - "x":131, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions":[ - { - "msg":{ - "base":"Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y":8, - "x":456, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions":[ - { - "msg":{ - "base":"I don't know that color. Try again." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y":835, - "x":191, - "destination":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions":[ - { - "msg":{ - "base":"Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y":465, - "x":512, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions":[ - { - "msg":{ - "base":"I don't know that one, try again please." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y":1105, - "x":191, - "destination":null, - "uuid":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions":[ - { - "msg":{ - "base":"Thanks @flow.name, we are all done!" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "entry":"a6676605-332a-4309-a8b8-79b33e73adcd", - "metadata":{ - "uuid":"77ae372d-a937-4d9b-a703-cc1c75c4c6f1", - "notes":[ - - ], - "expires":720, - "name":"Favorites", - "saved_on":"2017-08-16T23:10:18.579169Z", - "revision":1 - } - }, - "version":6, - "flow_type":"F", - "name":"Favorites", - "entry":"a6676605-332a-4309-a8b8-79b33e73adcd" - } - ], - "triggers":[ - - ] - } \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v7.json b/media/test_flows/favorites_bad_group_name_v7.json deleted file mode 100644 index d573c0524cb..00000000000 --- a/media/test_flows/favorites_bad_group_name_v7.json +++ /dev/null @@ -1,446 +0,0 @@ -{ - "version":7, - "flows":[ - { - "base_language":"base", - "action_sets":[ - { - "y":0, - "x":100, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"a6676605-332a-4309-a8b8-79b33e73adcd", - "actions":[ - { - "msg":{ - "base":"What is your favorite color?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type":"add_group", - "uuid":"5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups":[ - { - "name":"< 25", - "id":15572 - } - ] - }, - { - "type":"del_group", - "uuid":"2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups":[ - { - "name":"> 100", - "id":15573 - } - ] - } - ] - }, - { - "y":437, - "x":131, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions":[ - { - "msg":{ - "base":"Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y":8, - "x":456, - "destination":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid":"37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions":[ - { - "msg":{ - "base":"I don't know that color. Try again." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y":835, - "x":191, - "destination":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions":[ - { - "msg":{ - "base":"Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y":465, - "x":512, - "destination":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions":[ - { - "msg":{ - "base":"I don't know that one, try again please." - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y":1105, - "x":191, - "destination":null, - "uuid":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions":[ - { - "msg":{ - "base":"Thanks @flow.name, we are all done!" - }, - "media":{ - - }, - "send_all":false, - "type":"reply", - "uuid":"512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "flow_type":"F", - "entry":"a6676605-332a-4309-a8b8-79b33e73adcd", - "rule_sets":[ - { - "uuid":"c564c56f-0341-471e-8bb1-e303090fea6a", - "rules":[ - { - "test":{ - "test":{ - "base":"Red" - }, - "type":"contains_any" - }, - "category":{ - "base":"Red" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"663f667d-561a-4920-9375-3ce367615bdc", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Green" - }, - "type":"contains_any" - }, - "category":{ - "base":"Green" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Blue" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Navy" - }, - "type":"contains_any" - }, - "category":{ - "base":"Blue" - }, - "destination":"00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid":"08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Cyan" - }, - "type":"contains_any" - }, - "category":{ - "base":"Cyan" - }, - "destination":null, - "uuid":"cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type":null - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":null, - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"expression", - "label":"Color", - "operand":"@extra.value", - "finished_key":null, - "y":329, - "x":98, - "config":{ - - } - }, - { - "uuid":"8b941374-1b65-4154-afa3-27b871f7be6b", - "rules":[ - { - "test":{ - "test":{ - "base":"Mutzig" - }, - "type":"contains_any" - }, - "category":{ - "base":"Mutzig" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Primus" - }, - "type":"contains_any" - }, - "category":{ - "base":"Primus" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Turbo King" - }, - "type":"contains_any" - }, - "category":{ - "base":"Turbo King" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type":"A" - }, - { - "test":{ - "test":{ - "base":"Skol" - }, - "type":"contains_any" - }, - "category":{ - "base":"Skol" - }, - "destination":"92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid":"c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type":"A" - }, - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"Other" - }, - "destination":"7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"expression", - "label":"Beer", - "y":687, - "finished_key":null, - "operand":"@step.value|lower_case", - "x":112, - "config":{ - - } - }, - { - "uuid":"c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid":"cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Name", - "y":1002, - "finished_key":null, - "operand":"@step.value", - "x":191, - "config":{ - - } - }, - { - "uuid":"0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "ruleset_type":"wait_message", - "label":"Color Response", - "y":129, - "finished_key":null, - "operand":"@step.value", - "x":98, - "config":{ - - } - }, - { - "uuid":"c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "webhook_action":"POST", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid":"955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type":null - } - ], - "webhook":"http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "ruleset_type":"webhook", - "label":"Color Webhook", - "y":229, - "finished_key":null, - "operand":"@step.value", - "x":98, - "config":{ - - } - }, - { - "uuid":"58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules":[ - { - "test":{ - "test":"true", - "type":"true" - }, - "category":{ - "base":"All Responses" - }, - "destination":"8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid":"fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type":"A" - } - ], - "ruleset_type":"wait_message", - "label":"Beer Response", - "operand":"@step.value", - "finished_key":null, - "y":587, - "x":112, - "config":{ - - } - } - ], - "metadata":{ - "uuid":null, - "notes":[ - - ], - "expires":720, - "name":"Favorites", - "saved_on":null, - "id":null, - "revision":1 - } - } - ], - "triggers":[ - - ] -} \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v8.json b/media/test_flows/favorites_bad_group_name_v8.json deleted file mode 100644 index 54be9f6846a..00000000000 --- a/media/test_flows/favorites_bad_group_name_v8.json +++ /dev/null @@ -1,420 +0,0 @@ -{ - "version":8, - "flows":[ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type": "add_group", - "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups": [ - { - "name": "< 25", - "id": 15572 - } - ] - }, - { - "type": "del_group", - "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups": [ - { - "name": "> 100", - "id": 15573 - } - ] - } - ] - }, - { - "y": 437, - "x": 131, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions": [ - { - "msg": { - "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y": 8, - "x": 456, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions": [ - { - "msg": { - "base": "I don't know that color. Try again." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y": 835, - "x": 191, - "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions": [ - { - "msg": { - "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y": 465, - "x": 512, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions": [ - { - "msg": { - "base": "I don't know that one, try again please." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y": 1105, - "x": 191, - "destination": null, - "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions": [ - { - "msg": { - "base": "Thanks @flow.name, we are all done!" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "flow_type": "F", - "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", - "rule_sets": [ - { - "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", - "rules": [ - { - "test": { - "test": { - "base": "Red" - }, - "type": "contains_any" - }, - "category": { - "base": "Red" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "663f667d-561a-4920-9375-3ce367615bdc", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Green" - }, - "type": "contains_any" - }, - "category": { - "base": "Green" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Blue" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Navy" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Cyan" - }, - "type": "contains_any" - }, - "category": { - "base": "Cyan" - }, - "destination": null, - "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type": null - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": null, - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "expression", - "label": "Color", - "y": 329, - "finished_key": null, - "operand": "@extra.value", - "x": 98, - "config": {} - }, - { - "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", - "rules": [ - { - "test": { - "test": { - "base": "Mutzig" - }, - "type": "contains_any" - }, - "category": { - "base": "Mutzig" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Primus" - }, - "type": "contains_any" - }, - "category": { - "base": "Primus" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Turbo King" - }, - "type": "contains_any" - }, - "category": { - "base": "Turbo King" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Skol" - }, - "type": "contains_any" - }, - "category": { - "base": "Skol" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "expression", - "label": "Beer", - "operand": "@(LOWER(step.value))", - "finished_key": null, - "y": 687, - "x": 112, - "config": {} - }, - { - "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Name", - "operand": "@step.value", - "finished_key": null, - "y": 1002, - "x": 191, - "config": {} - }, - { - "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "wait_message", - "label": "Color Response", - "operand": "@step.value", - "finished_key": null, - "y": 129, - "x": 98, - "config": {} - }, - { - "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "webhook_action": "POST", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "ruleset_type": "webhook", - "label": "Color Webhook", - "operand": "@step.value", - "finished_key": null, - "y": 229, - "x": 98, - "config": {} - }, - { - "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Beer Response", - "y": 587, - "finished_key": null, - "operand": "@step.value", - "x": 112, - "config": {} - } - ], - "metadata": { - "uuid": null, - "notes": [], - "expires": 720, - "name": "Favorites", - "revision": 1, - "id": null, - "saved_on": null - } - } - ], - "triggers":[ - - ] -} \ No newline at end of file diff --git a/media/test_flows/favorites_bad_group_name_v9.json b/media/test_flows/favorites_bad_group_name_v9.json deleted file mode 100644 index 1ff3e4354f8..00000000000 --- a/media/test_flows/favorites_bad_group_name_v9.json +++ /dev/null @@ -1,419 +0,0 @@ -{ - "version":9, - "flows":[ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" - }, - { - "type": "add_group", - "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "groups": [ - { - "uuid": "0fdffdb4-3ca4-4d35-b6a7-129b0dfc7d39", - "name": "< 25" - } - ] - }, - { - "type": "del_group", - "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", - "groups": [ - { - "uuid": "e5e7bfaf-7c35-4590-8039-c33da2b98d8c", - "name": "> 100" - } - ] - } - ] - }, - { - "y": 437, - "x": 131, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "actions": [ - { - "msg": { - "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" - } - ] - }, - { - "y": 8, - "x": 456, - "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", - "actions": [ - { - "msg": { - "base": "I don't know that color. Try again." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" - } - ] - }, - { - "y": 835, - "x": 191, - "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "actions": [ - { - "msg": { - "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" - } - ] - }, - { - "y": 465, - "x": 512, - "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "actions": [ - { - "msg": { - "base": "I don't know that one, try again please." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" - } - ] - }, - { - "y": 1105, - "x": 191, - "destination": null, - "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "actions": [ - { - "msg": { - "base": "Thanks @flow.name, we are all done!" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" - } - ] - } - ], - "flow_type": "F", - "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", - "rule_sets": [ - { - "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", - "rules": [ - { - "test": { - "test": { - "base": "Red" - }, - "type": "contains_any" - }, - "category": { - "base": "Red" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "663f667d-561a-4920-9375-3ce367615bdc", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Green" - }, - "type": "contains_any" - }, - "category": { - "base": "Green" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Blue" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Navy" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", - "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Cyan" - }, - "type": "contains_any" - }, - "category": { - "base": "Cyan" - }, - "destination": null, - "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", - "destination_type": null - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": null, - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "expression", - "label": "Color", - "y": 329, - "finished_key": null, - "operand": "@extra.value", - "x": 98, - "config": {} - }, - { - "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", - "rules": [ - { - "test": { - "test": { - "base": "Mutzig" - }, - "type": "contains_any" - }, - "category": { - "base": "Mutzig" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Primus" - }, - "type": "contains_any" - }, - "category": { - "base": "Primus" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Turbo King" - }, - "type": "contains_any" - }, - "category": { - "base": "Turbo King" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Skol" - }, - "type": "contains_any" - }, - "category": { - "base": "Skol" - }, - "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", - "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "expression", - "label": "Beer", - "operand": "@(LOWER(step.value))", - "finished_key": null, - "y": 687, - "x": 112, - "config": {} - }, - { - "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", - "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Name", - "operand": "@step.value", - "finished_key": null, - "y": 1002, - "x": 191, - "config": {} - }, - { - "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "ruleset_type": "wait_message", - "label": "Color Response", - "operand": "@step.value", - "finished_key": null, - "y": 129, - "x": 98, - "config": {} - }, - { - "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", - "webhook_action": "POST", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", - "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", - "destination_type": null - } - ], - "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", - "ruleset_type": "webhook", - "label": "Color Webhook", - "operand": "@step.value", - "finished_key": null, - "y": 229, - "x": 98, - "config": {} - }, - { - "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", - "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", - "destination_type": "A" - } - ], - "ruleset_type": "wait_message", - "label": "Beer Response", - "y": 587, - "finished_key": null, - "operand": "@step.value", - "x": 112, - "config": {} - } - ], - "metadata": { - "uuid": null, - "notes": [], - "expires": 720, - "name": "Favorites", - "revision": 1, - "saved_on": null - } - } - ], - "triggers":[ - - ] -} \ No newline at end of file diff --git a/media/test_flows/ivr_v3.json b/media/test_flows/ivr_v3.json deleted file mode 100644 index d4b3aec48fc..00000000000 --- a/media/test_flows/ivr_v3.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "entry": "d1dd7b53-dafe-493f-a267-62301e76ee85", - "rule_sets": [ - { - "uuid": "c939d4cf-0294-4478-952b-a630ba972ba1", - "webhook_action": null, - "response_type": "C", - "rules": [ - { - "test": { - "test": "1", - "type": "eq" - }, - "category": "Yes", - "destination": "92367194-924b-4c47-9250-e47363855e32", - "uuid": "4cdf62ea-5cba-4261-992c-246c34667dc3" - }, - { - "test": { - "test": "2", - "type": "eq" - }, - "category": "No", - "destination": "866e80ae-128e-4e49-98b9-51317ec847e3", - "uuid": "a9b6086e-a423-4790-a342-df2c9972fc8c" - }, - { - "test": { - "test": "3", - "type": "eq" - }, - "category": "Maybe", - "destination": "096de08e-b260-4025-a2fd-f61996a3f4eb", - "uuid": "a4e661de-9ec1-424d-a383-362a456925e0" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "13549d38-f341-4ad5-ad44-1e4e5cedd032", - "uuid": "f8d4e9b0-846a-4508-a3d7-2f910fa04fc0" - } - ], - "webhook": null, - "label": "Call Me", - "operand": "@step.value", - "finished_key": null, - "y": 165, - "x": 204 - } - ], - "action_sets": [ - { - "y": 91, - "x": 655, - "destination": "c939d4cf-0294-4478-952b-a630ba972ba1", - "uuid": "13549d38-f341-4ad5-ad44-1e4e5cedd032", - "actions": [ - { - "recording": null, - "msg": "Press one, two, or three. Thanks.", - "type": "say", - "uuid": "6d8d0bd4-7b72-4a91-ad78-2ac3a5220637" - } - ] - }, - { - "y": 294, - "x": 531, - "destination": null, - "uuid": "096de08e-b260-4025-a2fd-f61996a3f4eb", - "actions": [ - { - "recording": null, - "msg": "This might be crazy.", - "type": "say", - "uuid": "80cd8158-6e2a-4adb-8ddc-f9b5b036a7ad" - } - ] - }, - { - "y": 294, - "x": 310, - "destination": null, - "uuid": "866e80ae-128e-4e49-98b9-51317ec847e3", - "actions": [ - { - "recording": null, - "msg": "Fine, this is the last time we shall speak.", - "type": "say", - "uuid": "14849fb6-3a7d-41c8-9595-9e97cf17f9dd" - } - ] - }, - { - "y": 291, - "x": 91, - "destination": null, - "uuid": "92367194-924b-4c47-9250-e47363855e32", - "actions": [ - { - "recording": null, - "msg": "Great, I can't wait to give you a call later.", - "type": "say", - "uuid": "cc6c5044-ec52-4861-ba66-b2ee741b668c" - } - ] - }, - { - "y": 0, - "x": 101, - "destination": "c939d4cf-0294-4478-952b-a630ba972ba1", - "uuid": "d1dd7b53-dafe-493f-a267-62301e76ee85", - "actions": [ - { - "recording": null, - "msg": "Would you like me to call you? Press one for yes, two for no, or three for maybe.", - "type": "say", - "uuid": "03290af7-4748-46e7-ac8d-1967375de33a" - } - ] - } - ], - "metadata": { - "notes": [] - } - }, - "id": 100, - "flow_type": "V", - "name": "Call me maybe" - } - ], - "triggers": [ - { - "flow": { - "name": "Call me maybe", - "id": 100 - }, - "groups": [], - "keyword": "callme", - "trigger_type": "K" - }, - { - "flow": { - "name": "Call me maybe", - "id": 100 - }, - "groups": [], - "keyword": null, - "trigger_type": "V" - } - ] -} diff --git a/media/test_flows/legacy/invalid/no_base_language_v8.json b/media/test_flows/legacy/invalid/no_base_language_v8.json new file mode 100644 index 00000000000..69c4cfcea53 --- /dev/null +++ b/media/test_flows/legacy/invalid/no_base_language_v8.json @@ -0,0 +1,44 @@ +{ + "base_language": null, + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": null, + "uuid": "f614e8c9-eeb6-4c94-bd07-b4bbe8a95b47", + "actions": [ + { + "type": "add_group", + "groups": [ + { + "name": "A New Group", + "id": 44899 + } + ] + }, + { + "field": "location", + "type": "save", + "value": "Seattle, WA", + "label": "Location" + }, + { + "lang": "eng", + "type": "lang", + "name": "English" + } + ] + } + ], + "version": 8, + "flow_type": "F", + "entry": "f614e8c9-eeb6-4c94-bd07-b4bbe8a95b47", + "rule_sets": [], + "metadata": { + "expires": 720, + "saved_on": "2015-11-19T00:30:09.477009Z", + "id": 42104, + "name": "Join New Group", + "revision": 6 + } +} \ No newline at end of file diff --git a/media/test_flows/legacy/invalid/non_localized_ruleset.json b/media/test_flows/legacy/invalid/non_localized_ruleset.json new file mode 100644 index 00000000000..746d7dda4cf --- /dev/null +++ b/media/test_flows/legacy/invalid/non_localized_ruleset.json @@ -0,0 +1,39 @@ +{ + "base_language": "eng", + "action_sets": [], + "version": 8, + "flow_type": "F", + "entry": "99696ed8-2555-4d18-ac0b-f9b9d85abf30", + "rule_sets": [ + { + "uuid": "99696ed8-2555-4d18-ac0b-f9b9d85abf30", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": "All Responses", + "uuid": "9b31bbfe-23d7-4838-806a-1a3989de3f37" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Response 1", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 0, + "x": 100, + "config": {} + } + ], + "metadata": { + "expires": 10080, + "revision": 1, + "id": 42135, + "name": "Empty", + "saved_on": "2015-11-19T22:31:15.972687Z" + } +} \ No newline at end of file diff --git a/media/test_flows/legacy/invalid/non_localized_with_language.json b/media/test_flows/legacy/invalid/non_localized_with_language.json new file mode 100644 index 00000000000..60169c3cba0 --- /dev/null +++ b/media/test_flows/legacy/invalid/non_localized_with_language.json @@ -0,0 +1,326 @@ +{ + "base_language": "eng", + "action_sets": [ + { + "y": 991, + "x": 389, + "destination": "7d1b7019-b611-4132-9ba4-af36cc167398", + "uuid": "49189b3e-8e2b-473f-bec2-10378f5a7c06", + "actions": [ + { + "msg": "Thanks @extra.name, we'll be in touch ASAP about order # @extra.order.", + "type": "reply" + }, + { + "msg": "Customer @extra.name has a problem with their order @extra.order for @extra.description. Please look into it ASAP and call them back with the status.\n \nCustomer Comment: \"@flow.comment\"\nCustomer Name: @extra.name\nCustomer Phone: @contact.tel ", + "type": "email", + "emails": [ + "name@domain.com" + ], + "subject": "Order Comment: @flow.lookup: @extra.order" + } + ] + }, + { + "y": 574, + "x": 612, + "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", + "uuid": "8618411e-a35e-472b-b867-3339aa46027a", + "actions": [ + { + "msg": "Uh oh @extra.name! Our record indicate that your order for @extra.description was cancelled on @extra.cancel_date. If you think this is in error, please reply with a comment and our orders department will get right on it!", + "type": "reply" + } + ] + }, + { + "y": 572, + "x": 389, + "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", + "uuid": "32bb903e-44c2-40f9-b65f-c8cda6490ee6", + "actions": [ + { + "msg": "Hi @extra.name. Hope you are patient because we haven't shipped your order for @extra.description yet. We expect to ship it by @extra.ship_date though. If you have any questions, just reply and our customer service department will be notified.", + "type": "reply" + } + ] + }, + { + "y": 572, + "x": 167, + "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", + "uuid": "bf36a209-4e21-44ac-835a-c3d5889aa2fb", + "actions": [ + { + "msg": "Great news @extra.name! We shipped your order for @extra.description on @extra.ship_date and we expect it will be delivered on @extra.delivery_date. If you have any questions, just reply and our customer service department will be notified.", + "type": "reply" + } + ] + }, + { + "y": 99, + "x": 787, + "destination": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", + "uuid": "7f4c29e3-f022-420d-8e2f-6165c572b991", + "actions": [ + { + "msg": "Sorry that doesn't look like a valid order number. Maybe try: CU001, CU002 or CU003?", + "type": "reply" + } + ] + }, + { + "y": 0, + "x": 409, + "destination": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", + "uuid": "4f79034a-51e0-4210-99cc-17f385de4de8", + "actions": [ + { + "msg": "Thanks for contacting the ThriftShop order status system. Please send your order # and we'll help you in a jiffy!", + "type": "reply" + } + ] + }, + { + "y": 854, + "x": 776, + "destination": "2cb5adcd-31b1-4d21-a0df-c5375cea1963", + "uuid": "6f550596-98a2-44fb-b769-b3c529f1b963", + "actions": [ + { + "msg": "@flow.lookup_response", + "type": "reply" + } + ] + }, + { + "y": 1430, + "x": 233, + "destination": "ad1d5767-8dfd-4c5d-b2e8-a997adb3a276", + "uuid": "81613e37-414c-4d73-884b-4ee7ae0fd913", + "actions": [ + { + "msg": "asdf", + "type": "reply" + } + ] + } + ], + "version": 8, + "flow_type": "F", + "entry": "4f79034a-51e0-4210-99cc-17f385de4de8", + "rule_sets": [ + { + "uuid": "2cb5adcd-31b1-4d21-a0df-c5375cea1963", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": "All Responses", + "destination": "49189b3e-8e2b-473f-bec2-10378f5a7c06", + "uuid": "088470d7-c4a9-4dd7-8be4-d10faf02fcea", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Comment", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 955, + "x": 762, + "config": {} + }, + { + "uuid": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", + "webhook_action": null, + "rules": [ + { + "category": "All Responses", + "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", + "destination": "b3bd5abb-3f70-4af5-85eb-d07900f9cb85", + "destination_type": "R", + "test": { + "test": "true", + "type": "true" + }, + "config": { + "type": "true", + "verbose_name": "contains anything", + "name": "Other", + "operands": 0 + } + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Lookup Responses", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 198, + "x": 356, + "config": {} + }, + { + "uuid": "7d1b7019-b611-4132-9ba4-af36cc167398", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": "All Responses", + "destination": "81613e37-414c-4d73-884b-4ee7ae0fd913", + "uuid": "124f3266-bc62-4743-b4b1-79fee0d45ad9", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Extra Comments", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 1252, + "x": 389, + "config": {} + }, + { + "uuid": "6baa1d6b-ee70-4d7c-85b3-22ed94281227", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "Shipped", + "type": "contains" + }, + "category": "Shipped", + "destination": "bf36a209-4e21-44ac-835a-c3d5889aa2fb", + "uuid": "bb336f83-3a5f-4a2e-ad42-757a0a79892b", + "destination_type": "A" + }, + { + "test": { + "test": "Pending", + "type": "contains" + }, + "category": "Pending", + "destination": "32bb903e-44c2-40f9-b65f-c8cda6490ee6", + "uuid": "91826255-5a81-418c-aadb-3378802a1134", + "destination_type": "A" + }, + { + "test": { + "test": "Cancelled", + "type": "contains" + }, + "category": "Cancelled", + "destination": "8618411e-a35e-472b-b867-3339aa46027a", + "uuid": "1efa73d0-e30c-4495-a5c8-724b48385839", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": "Other", + "destination": "7f4c29e3-f022-420d-8e2f-6165c572b991", + "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "expression", + "label": "Lookup", + "operand": "@extra.status", + "finished_key": null, + "response_type": "", + "y": 398, + "x": 356, + "config": {} + }, + { + "uuid": "b3bd5abb-3f70-4af5-85eb-d07900f9cb85", + "webhook_action": "POST", + "rules": [ + { + "category": "All Responses", + "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", + "destination": "6baa1d6b-ee70-4d7c-85b3-22ed94281227", + "destination_type": "R", + "test": { + "test": "true", + "type": "true" + }, + "config": { + "type": "true", + "verbose_name": "contains anything", + "name": "Other", + "operands": 0 + } + } + ], + "webhook": "https://api.textit.in/demo/status/", + "ruleset_type": "webhook", + "label": "Lookup Webhook", + "operand": "@extra.status", + "finished_key": null, + "response_type": "", + "y": 298, + "x": 356, + "config": {} + }, + { + "uuid": "ad1d5767-8dfd-4c5d-b2e8-a997adb3a276", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": "All Responses", + "config": { + "type": "true", + "verbose_name": "contains anything", + "name": "Other", + "operands": 0 + }, + "uuid": "439c839b-f04a-4394-9b8b-be91ca0991bd" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Boo", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 1580, + "x": 362, + "config": {} + } + ], + "metadata": { + "uuid": "2ed28d6a-61cd-436a-9159-01b024992e78", + "notes": [ + { + "body": "This flow demonstrates looking up an order using a webhook and giving the user different options based on the results. After looking up the order the user has the option to send additional comments which are forwarded to customer support representatives.\n\nUse order numbers CU001, CU002 or CU003 to see the different cases in action.", + "x": 59, + "y": 0, + "title": "Using Your Own Data" + } + ], + "expires": 720, + "name": "Sample Flow - Order Status Checker", + "saved_on": "2015-11-19T19:32:17.523441Z", + "id": 42133, + "revision": 1 + } +} \ No newline at end of file diff --git a/media/test_flows/legacy/invalid/not_fully_localized.json b/media/test_flows/legacy/invalid/not_fully_localized.json new file mode 100644 index 00000000000..bff2bfbe74e --- /dev/null +++ b/media/test_flows/legacy/invalid/not_fully_localized.json @@ -0,0 +1,31 @@ +{ + "version": 7, + "flow_type": "F", + "base_language": "eng", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": null, + "uuid": "127f3736-77ce-4006-9ab0-0c07cea88956", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "type": "reply" + } + ] + } + ], + "last_saved": "2015-09-15T02:37:08.805578Z", + "entry": "127f3736-77ce-4006-9ab0-0c07cea88956", + "rule_sets": [], + "metadata": { + "notes": [], + "name": "Not fully localized", + "id": 35559, + "expires": 720, + "revision": 1 + } +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/dual_webhook.json b/media/test_flows/legacy/migrations/dual_webhook.json new file mode 100644 index 00000000000..bd081f6ba4b --- /dev/null +++ b/media/test_flows/legacy/migrations/dual_webhook.json @@ -0,0 +1,132 @@ +{ + "campaigns": [], + "version": 9, + "site": "https://textit.in", + "flows": [ + { + "base_language": "eng", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0aabad42-3ec6-40c7-a4cc-c5190b8b4465", + "uuid": "ff642bb5-14fa-4bb6-8040-0ceec395a164", + "actions": [ + { + "msg": { + "eng": "This is the first message" + }, + "type": "reply" + } + ] + }, + { + "y": 310, + "x": 129, + "destination": "6304e1d5-3c0c-44ea-9519-39389227e3c0", + "uuid": "d7523614-1b39-481f-a451-4c4ac9201095", + "actions": [ + { + "msg": { + "eng": "Great, your code is @extra.code. Enter your name" + }, + "type": "reply" + } + ] + } + ], + "version": 9, + "flow_type": "F", + "entry": "ff642bb5-14fa-4bb6-8040-0ceec395a164", + "rule_sets": [ + { + "uuid": "0aabad42-3ec6-40c7-a4cc-c5190b8b4465", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "eng": "All Responses" + }, + "destination": "d7523614-1b39-481f-a451-4c4ac9201095", + "uuid": "1717d336-6fb3-4da0-ac51-4588792e46d2", + "destination_type": "A" + } + ], + "webhook": "http://localhost:49999/code", + "ruleset_type": "webhook", + "label": "Webhook", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 169, + "x": 286, + "config": {} + }, + { + "uuid": "6304e1d5-3c0c-44ea-9519-39389227e3c0", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "eng": "All Responses" + }, + "destination": "8ad78c14-7ebe-4968-82dc-b66dc27d4d96", + "uuid": "da800d48-b1c8-44cf-8e2c-b6c6d5c98aa3", + "destination_type": "R" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 457, + "x": 265, + "config": {} + }, + { + "uuid": "8ad78c14-7ebe-4968-82dc-b66dc27d4d96", + "webhook_action": "GET", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "eng": "All Responses" + }, + "uuid": "4dd0f3e7-cc15-41fa-8a84-d53d76d46d66" + } + ], + "webhook": "http://localhost:49999/success", + "ruleset_type": "webhook", + "label": "Webhook 2", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 617, + "x": 312, + "config": {} + } + ], + "metadata": { + "expires": 10080, + "revision": 16, + "uuid": "099d0d1e-3769-472f-9ea7-f3bd5a11c8ff", + "name": "Webhook Migration", + "saved_on": "2016-08-16T16:34:56.351428Z" + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites.json b/media/test_flows/legacy/migrations/favorites.json new file mode 100644 index 00000000000..8e328ddbd18 --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites.json @@ -0,0 +1,315 @@ +{ + "version": 7, + "flows": [ + { + "version": 7, + "flow_type": "M", + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", + "uuid": "127f3736-77ce-4006-9ab0-0c07cea88956", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "type": "reply" + } + ] + }, + { + "y": 237, + "x": 131, + "destination": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", + "uuid": "44471ade-7979-4c94-8028-6cfb68836337", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "type": "reply" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", + "uuid": "f9adf38f-ab18-49d3-a8ac-db2fe8f1e77f", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "type": "reply" + } + ] + }, + { + "y": 535, + "x": 191, + "destination": "ba95c5cd-e428-4a15-8b4b-23dd43943f2c", + "uuid": "89c5624e-3320-4668-a066-308865133080", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @flow.color|lower_case @flow.beer.category! Lastly, what is your name?" + }, + "type": "reply" + } + ] + }, + { + "y": 265, + "x": 512, + "destination": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", + "uuid": "a269683d-8229-4870-8585-be8320b9d8ca", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "type": "reply" + } + ] + }, + { + "y": 805, + "x": 191, + "destination": null, + "uuid": "10e483a8-5ffb-4c4f-917b-d43ce86c1d65", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "type": "reply" + } + ] + } + ], + "last_saved": "2015-09-15T02:37:08.805578Z", + "entry": "127f3736-77ce-4006-9ab0-0c07cea88956", + "rule_sets": [ + { + "uuid": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", + "webhook_action": null, + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "44471ade-7979-4c94-8028-6cfb68836337", + "uuid": "8cd25a3f-0be2-494b-8b4c-3a4f0de7f9b2", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "44471ade-7979-4c94-8028-6cfb68836337", + "uuid": "db2863cf-7fda-4489-9345-d44dacf4e750", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "44471ade-7979-4c94-8028-6cfb68836337", + "uuid": "2f462678-b176-49c1-bb5c-6e152502b0db", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "44471ade-7979-4c94-8028-6cfb68836337", + "uuid": "ecaeb59a-d7f1-4c21-a207-b2a29cc2488f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "6f463a78-b176-49c1-bb5c-6e152502b0db", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "f9adf38f-ab18-49d3-a8ac-db2fe8f1e77f", + "uuid": "df4455c2-806b-4af4-8ea9-f40278ec10e4", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Color", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 129, + "x": 98, + "config": {} + }, + { + "uuid": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", + "webhook_action": null, + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "89c5624e-3320-4668-a066-308865133080", + "uuid": "ea304225-332e-49d4-9768-1e804cd0b6c2", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "89c5624e-3320-4668-a066-308865133080", + "uuid": "57f8688e-c263-43d7-bd06-bdb98f0c58a8", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "89c5624e-3320-4668-a066-308865133080", + "uuid": "670f0205-bb39-4e12-ae95-5e29251b8a3e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "89c5624e-3320-4668-a066-308865133080", + "uuid": "2ff4713f-c62f-445c-880c-de8f6532d090", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "a269683d-8229-4870-8585-be8320b9d8ca", + "uuid": "1fc4c133-d038-4f75-a69e-6e7e3190e5d8", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Beer", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 387, + "x": 112, + "config": {} + }, + { + "uuid": "ba95c5cd-e428-4a15-8b4b-23dd43943f2c", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "10e483a8-5ffb-4c4f-917b-d43ce86c1d65", + "uuid": "c072ecb5-0686-40ea-8ed3-898dc1349783", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "wait_message", + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 702, + "x": 191, + "config": {} + } + ], + "metadata": { + "notes": [], + "name": "Favorites", + "id": 35559, + "expires": 720, + "revision": 1 + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v10.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v10.json new file mode 100644 index 00000000000..3b28a37b65a --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v10.json @@ -0,0 +1,430 @@ +{ + "version": 10, + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "uuid": "0fdffdb4-3ca4-4d35-b6a7-129b0dfc7d39", + "name": "< 25" + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "uuid": "e5e7bfaf-7c35-4590-8039-c33da2b98d8c", + "name": "> 100" + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "flow_type": "F", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "operand": "@extra.value", + "finished_key": null, + "y": 329, + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "y": 687, + "finished_key": null, + "operand": "@(LOWER(step.value))", + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "y": 1002, + "finished_key": null, + "operand": "@step.value", + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "y": 129, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "rules": [ + { + "category": { + "base": "Success" + }, + "test": { + "status": "success", + "type": "webhook_status" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + }, + { + "category": { + "base": "Failure" + }, + "test": { + "status": "failure", + "type": "webhook_status" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "cb902a40-780f-4e9b-a31e-e7d1021d05ed", + "destination_type": null + } + ], + "ruleset_type": "webhook", + "label": "Color Webhook", + "y": 229, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": { + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "webhook_action": "POST" + } + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "operand": "@step.value", + "finished_key": null, + "y": 587, + "x": 112, + "config": {} + } + ], + "metadata": { + "uuid": null, + "notes": [], + "expires": 720, + "name": "Favorites", + "saved_on": null, + "revision": 1 + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v4.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v4.json new file mode 100644 index 00000000000..4463ede5a9d --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v4.json @@ -0,0 +1,352 @@ +{ + "version": 4, + "flows": [ + { + "definition": { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "name": "< 25", + "id": 15572 + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "name": "> 100", + "id": 15573 + } + ] + } + ] + }, + { + "y": 237, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 535, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 265, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 805, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "rule_sets": [ + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": null, + "label": "Color", + "finished_key": null, + "response_type": "C", + "y": 129, + "x": 98, + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "webhook_action": "POST", + "operand": "@extra.value", + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": null, + "label": "Beer", + "operand": "@step.value|lower_case", + "finished_key": null, + "response_type": "C", + "y": 387, + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": null, + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "response_type": "C", + "y": 702, + "x": 191, + "config": {} + } + ], + "metadata": { + "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", + "notes": [], + "expires": 720, + "name": "Favorites", + "revision": 1, + "saved_on": "2017-08-16T23:10:18.579169Z" + } + }, + "version": 4, + "flow_type": "F", + "name": "Favorites", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v5.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v5.json new file mode 100644 index 00000000000..cb95631ce7d --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v5.json @@ -0,0 +1,421 @@ +{ + "version": 5, + "flows": [ + { + "definition": { + "base_language": "base", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "operand": "@extra.value", + "finished_key": null, + "y": 329, + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "y": 687, + "finished_key": null, + "operand": "@step.value|lower_case", + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "y": 1002, + "finished_key": null, + "operand": "@step.value", + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "y": 129, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "ruleset_type": "webhook", + "label": "Color Webhook", + "y": 229, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "operand": "@step.value", + "finished_key": null, + "y": 587, + "x": 112, + "config": {} + } + ], + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "name": "< 25", + "id": 15572 + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "name": "> 100", + "id": 15573 + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "metadata": { + "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", + "notes": [], + "expires": 720, + "name": "Favorites", + "saved_on": "2017-08-16T23:10:18.579169Z", + "revision": 1 + } + }, + "version": 5, + "flow_type": "F", + "name": "Favorites", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v6.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v6.json new file mode 100644 index 00000000000..9025e045606 --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v6.json @@ -0,0 +1,422 @@ +{ + "version": 6, + "flows": [ + { + "definition": { + "base_language": "base", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "y": 329, + "finished_key": null, + "operand": "@extra.value", + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "operand": "@step.value|lower_case", + "finished_key": null, + "y": 687, + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "y": 1002, + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "operand": "@step.value", + "finished_key": null, + "y": 129, + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "ruleset_type": "webhook", + "label": "Color Webhook", + "operand": "@step.value", + "finished_key": null, + "y": 229, + "x": 98, + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "y": 587, + "finished_key": null, + "operand": "@step.value", + "x": 112, + "config": {} + } + ], + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "name": "< 25", + "id": 15572 + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "name": "> 100", + "id": 15573 + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", + "metadata": { + "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", + "notes": [], + "expires": 720, + "name": "Favorites", + "saved_on": "2017-08-16T23:10:18.579169Z", + "revision": 1 + } + }, + "version": 6, + "flow_type": "F", + "name": "Favorites", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v7.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v7.json new file mode 100644 index 00000000000..14407c17a5c --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v7.json @@ -0,0 +1,418 @@ +{ + "version": 7, + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "name": "< 25", + "id": 15572 + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "name": "> 100", + "id": 15573 + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "flow_type": "F", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "operand": "@extra.value", + "finished_key": null, + "y": 329, + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "y": 687, + "finished_key": null, + "operand": "@step.value|lower_case", + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "y": 1002, + "finished_key": null, + "operand": "@step.value", + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "y": 129, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "ruleset_type": "webhook", + "label": "Color Webhook", + "y": 229, + "finished_key": null, + "operand": "@step.value", + "x": 98, + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "operand": "@step.value", + "finished_key": null, + "y": 587, + "x": 112, + "config": {} + } + ], + "metadata": { + "uuid": null, + "notes": [], + "expires": 720, + "name": "Favorites", + "saved_on": null, + "id": null, + "revision": 1 + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v8.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v8.json new file mode 100644 index 00000000000..0d512ae8ef2 --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v8.json @@ -0,0 +1,418 @@ +{ + "version": 8, + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "name": "< 25", + "id": 15572 + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "name": "> 100", + "id": 15573 + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "flow_type": "F", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "y": 329, + "finished_key": null, + "operand": "@extra.value", + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "operand": "@(LOWER(step.value))", + "finished_key": null, + "y": 687, + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "y": 1002, + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "operand": "@step.value", + "finished_key": null, + "y": 129, + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "ruleset_type": "webhook", + "label": "Color Webhook", + "operand": "@step.value", + "finished_key": null, + "y": 229, + "x": 98, + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "y": 587, + "finished_key": null, + "operand": "@step.value", + "x": 112, + "config": {} + } + ], + "metadata": { + "uuid": null, + "notes": [], + "expires": 720, + "name": "Favorites", + "revision": 1, + "id": null, + "saved_on": null + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_bad_group_name_v9.json b/media/test_flows/legacy/migrations/favorites_bad_group_name_v9.json new file mode 100644 index 00000000000..ee7c978446d --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_bad_group_name_v9.json @@ -0,0 +1,417 @@ +{ + "version": 9, + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + }, + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "groups": [ + { + "uuid": "0fdffdb4-3ca4-4d35-b6a7-129b0dfc7d39", + "name": "< 25" + } + ] + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "groups": [ + { + "uuid": "e5e7bfaf-7c35-4590-8039-c33da2b98d8c", + "name": "> 100" + } + ] + } + ] + }, + { + "y": 437, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 835, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 465, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 1105, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "flow_type": "F", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd", + "rule_sets": [ + { + "uuid": "c564c56f-0341-471e-8bb1-e303090fea6a", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "expression", + "label": "Color", + "y": 329, + "finished_key": null, + "operand": "@extra.value", + "x": 98, + "config": {} + }, + { + "uuid": "8b941374-1b65-4154-afa3-27b871f7be6b", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "expression", + "label": "Beer", + "operand": "@(LOWER(step.value))", + "finished_key": null, + "y": 687, + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "y": 1002, + "x": 191, + "config": {} + }, + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": "wait_message", + "label": "Color Response", + "operand": "@step.value", + "finished_key": null, + "y": 129, + "x": 98, + "config": {} + }, + { + "uuid": "c7dae7c5-129d-44f7-8c05-c32a0efc6058", + "webhook_action": "POST", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "c564c56f-0341-471e-8bb1-e303090fea6a", + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "webhook": "http://localhost:49999/echo?content=%7B%20%22status%22%3A%20%22valid%22%20%7D", + "ruleset_type": "webhook", + "label": "Color Webhook", + "operand": "@step.value", + "finished_key": null, + "y": 229, + "x": 98, + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "8b941374-1b65-4154-afa3-27b871f7be6b", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": "wait_message", + "label": "Beer Response", + "y": 587, + "finished_key": null, + "operand": "@step.value", + "x": 112, + "config": {} + } + ], + "metadata": { + "uuid": null, + "notes": [], + "expires": 720, + "name": "Favorites", + "revision": 1, + "saved_on": null + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/favorites_v4.json b/media/test_flows/legacy/migrations/favorites_v4.json new file mode 100644 index 00000000000..eb1e13ec4f0 --- /dev/null +++ b/media/test_flows/legacy/migrations/favorites_v4.json @@ -0,0 +1,332 @@ +{ + "version": 4, + "flows": [ + { + "definition": { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "msg": { + "base": "What is your favorite color?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "98388930-7a0f-4eb8-9a0a-09be2f006420" + } + ] + }, + { + "y": 237, + "x": 131, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "actions": [ + { + "msg": { + "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "cf57f270-c9d7-4826-b3cc-7bfc22ac4ef6" + } + ] + }, + { + "y": 8, + "x": 456, + "destination": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "uuid": "37f62180-025e-4360-a72b-59af7ac6d1ab", + "actions": [ + { + "msg": { + "base": "I don't know that color. Try again." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "d6aee40b-3710-4358-b0a6-c0ddc1d7734e" + } + ] + }, + { + "y": 535, + "x": 191, + "destination": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "uuid": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "actions": [ + { + "msg": { + "base": "Mmmmm... delicious @flow.beer.category. If only they made @(LOWER(flow.color)) @flow.beer.category! Lastly, what is your name?" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "ca798d2d-2c95-468a-a857-74797a4d5301" + } + ] + }, + { + "y": 265, + "x": 512, + "destination": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "uuid": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "actions": [ + { + "msg": { + "base": "I don't know that one, try again please." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "be5c0c50-b3a4-486f-9e2e-335bdb542385" + } + ] + }, + { + "y": 805, + "x": 191, + "destination": null, + "uuid": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "actions": [ + { + "msg": { + "base": "Thanks @flow.name, we are all done!" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "512aa0ca-0c57-4b99-a7ad-e67d290e0c2c" + } + ] + } + ], + "rule_sets": [ + { + "uuid": "0ecf7914-05e0-4b71-8816-495d2c0921b5", + "rules": [ + { + "test": { + "test": { + "base": "Red" + }, + "type": "contains_any" + }, + "category": { + "base": "Red" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "663f667d-561a-4920-9375-3ce367615bdc", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Green" + }, + "type": "contains_any" + }, + "category": { + "base": "Green" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "8977bc24-d10c-4b1a-9b07-13e3447165d1", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Blue" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "56e47151-0a7d-4dd8-89cf-35fdcb5288ef", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Navy" + }, + "type": "contains_any" + }, + "category": { + "base": "Blue" + }, + "destination": "00c0ebde-1d4f-4bf5-b5db-22f72b2551b7", + "uuid": "08403c82-043d-4744-8e1a-c863e5e92fb7", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Cyan" + }, + "type": "contains_any" + }, + "category": { + "base": "Cyan" + }, + "destination": null, + "uuid": "cc43e621-c759-4976-8088-e89a0bce7749", + "destination_type": null + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": null, + "uuid": "955bd46a-29b2-49eb-bc70-0fc573d1cceb", + "destination_type": null + } + ], + "ruleset_type": null, + "label": "Color", + "finished_key": null, + "response_type": "C", + "y": 129, + "x": 98, + "webhook": "http://localhost:49999/status", + "webhook_action": "POST", + "operand": "@extra.value", + "config": {} + }, + { + "uuid": "58ec23b9-70bb-4d70-a739-8cee2d2f1e75", + "rules": [ + { + "test": { + "test": { + "base": "Mutzig" + }, + "type": "contains_any" + }, + "category": { + "base": "Mutzig" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "d9c89b0e-2083-42e4-93c9-4d75e5f6c86f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Primus" + }, + "type": "contains_any" + }, + "category": { + "base": "Primus" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "777a04d2-aa27-4024-9b15-99f699a65a2f", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Turbo King" + }, + "type": "contains_any" + }, + "category": { + "base": "Turbo King" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "ad519b79-9738-449d-80a1-e8fc3aebd08e", + "destination_type": "A" + }, + { + "test": { + "test": { + "base": "Skol" + }, + "type": "contains_any" + }, + "category": { + "base": "Skol" + }, + "destination": "92a6a4c6-c976-405a-97c8-76bf7edd214a", + "uuid": "c27f16dc-519c-44a9-bee7-fbfe76ade983", + "destination_type": "A" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "Other" + }, + "destination": "7c3c0319-20ee-4c30-a276-55dba0d049de", + "uuid": "fbccf8e5-b167-43f1-ace3-72802ba6db92", + "destination_type": "A" + } + ], + "ruleset_type": null, + "label": "Beer", + "operand": "@step.value|lower_case", + "finished_key": null, + "response_type": "C", + "y": 387, + "x": 112, + "config": {} + }, + { + "uuid": "c85670d3-e550-40f7-9ce2-e22c5d3fbcea", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "base": "All Responses" + }, + "destination": "fbb7df74-627a-45bb-83f6-e3e4d2d8020c", + "uuid": "cf3633cc-d2e4-4f25-b318-a2ddc61b6849", + "destination_type": "A" + } + ], + "ruleset_type": null, + "label": "Name", + "operand": "@step.value", + "finished_key": null, + "response_type": "C", + "y": 702, + "x": 191, + "config": {} + } + ], + "metadata": { + "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", + "notes": [], + "expires": 720, + "name": "Favorites", + "revision": 1, + "saved_on": "2017-08-16T23:10:18.579169Z" + } + }, + "version": 4, + "flow_type": "F", + "name": "Favorites", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/ivr_v3.json b/media/test_flows/legacy/migrations/ivr_v3.json new file mode 100644 index 00000000000..97a282b2b30 --- /dev/null +++ b/media/test_flows/legacy/migrations/ivr_v3.json @@ -0,0 +1,161 @@ +{ + "campaigns": [], + "version": 3, + "site": "http://rapidpro.io", + "flows": [ + { + "definition": { + "entry": "d1dd7b53-dafe-493f-a267-62301e76ee85", + "rule_sets": [ + { + "uuid": "c939d4cf-0294-4478-952b-a630ba972ba1", + "webhook_action": null, + "response_type": "C", + "rules": [ + { + "test": { + "test": "1", + "type": "eq" + }, + "category": "Yes", + "destination": "92367194-924b-4c47-9250-e47363855e32", + "uuid": "4cdf62ea-5cba-4261-992c-246c34667dc3" + }, + { + "test": { + "test": "2", + "type": "eq" + }, + "category": "No", + "destination": "866e80ae-128e-4e49-98b9-51317ec847e3", + "uuid": "a9b6086e-a423-4790-a342-df2c9972fc8c" + }, + { + "test": { + "test": "3", + "type": "eq" + }, + "category": "Maybe", + "destination": "096de08e-b260-4025-a2fd-f61996a3f4eb", + "uuid": "a4e661de-9ec1-424d-a383-362a456925e0" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": "Other", + "destination": "13549d38-f341-4ad5-ad44-1e4e5cedd032", + "uuid": "f8d4e9b0-846a-4508-a3d7-2f910fa04fc0" + } + ], + "webhook": null, + "label": "Call Me", + "operand": "@step.value", + "finished_key": null, + "y": 165, + "x": 204 + } + ], + "action_sets": [ + { + "y": 91, + "x": 655, + "destination": "c939d4cf-0294-4478-952b-a630ba972ba1", + "uuid": "13549d38-f341-4ad5-ad44-1e4e5cedd032", + "actions": [ + { + "recording": null, + "msg": "Press one, two, or three. Thanks.", + "type": "say", + "uuid": "6d8d0bd4-7b72-4a91-ad78-2ac3a5220637" + } + ] + }, + { + "y": 294, + "x": 531, + "destination": null, + "uuid": "096de08e-b260-4025-a2fd-f61996a3f4eb", + "actions": [ + { + "recording": null, + "msg": "This might be crazy.", + "type": "say", + "uuid": "80cd8158-6e2a-4adb-8ddc-f9b5b036a7ad" + } + ] + }, + { + "y": 294, + "x": 310, + "destination": null, + "uuid": "866e80ae-128e-4e49-98b9-51317ec847e3", + "actions": [ + { + "recording": null, + "msg": "Fine, this is the last time we shall speak.", + "type": "say", + "uuid": "14849fb6-3a7d-41c8-9595-9e97cf17f9dd" + } + ] + }, + { + "y": 291, + "x": 91, + "destination": null, + "uuid": "92367194-924b-4c47-9250-e47363855e32", + "actions": [ + { + "recording": null, + "msg": "Great, I can't wait to give you a call later.", + "type": "say", + "uuid": "cc6c5044-ec52-4861-ba66-b2ee741b668c" + } + ] + }, + { + "y": 0, + "x": 101, + "destination": "c939d4cf-0294-4478-952b-a630ba972ba1", + "uuid": "d1dd7b53-dafe-493f-a267-62301e76ee85", + "actions": [ + { + "recording": null, + "msg": "Would you like me to call you? Press one for yes, two for no, or three for maybe.", + "type": "say", + "uuid": "03290af7-4748-46e7-ac8d-1967375de33a" + } + ] + } + ], + "metadata": { + "notes": [] + } + }, + "id": 100, + "flow_type": "V", + "name": "Call me maybe" + } + ], + "triggers": [ + { + "flow": { + "name": "Call me maybe", + "id": 100 + }, + "groups": [], + "keyword": "callme", + "trigger_type": "K" + }, + { + "flow": { + "name": "Call me maybe", + "id": 100 + }, + "groups": [], + "keyword": null, + "trigger_type": "V" + } + ] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/malformed_groups.json b/media/test_flows/legacy/migrations/malformed_groups.json new file mode 100644 index 00000000000..850c4f1a303 --- /dev/null +++ b/media/test_flows/legacy/migrations/malformed_groups.json @@ -0,0 +1,49 @@ +{ + "version": 4, + "flows": [ + { + "definition": { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": null, + "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", + "actions": [ + { + "type": "add_group", + "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", + "group": { + "name": "< 25", + "id": 15572 + } + }, + { + "type": "del_group", + "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", + "group": { + "id": 15573 + } + } + ] + } + ], + "rule_sets": [], + "metadata": { + "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", + "notes": [], + "expires": 720, + "name": "Bad Mojo", + "revision": 1, + "saved_on": "2017-08-16T23:10:18.579169Z" + } + }, + "version": 4, + "flow_type": "F", + "name": "Bad Mojo", + "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/malformed_single_message.json b/media/test_flows/legacy/migrations/malformed_single_message.json new file mode 100644 index 00000000000..4a11d0d3979 --- /dev/null +++ b/media/test_flows/legacy/migrations/malformed_single_message.json @@ -0,0 +1,31 @@ +{ + "campaigns": [], + "triggers": [], + "version": 3, + "site": "http://rapidpro.io", + "flows": [ + { + "name": "Single Message Flow", + "id": -1, + "uuid": "f467561a-3b95-4a4a-94bc-97bc6b4268c0", + "definition": { + "entry": "2d702ba6-461e-442c-96bc-2b8a87c9ceca", + "action_sets": [ + { + "x": 0, + "y": 0, + "uuid": "2d702ba6-461e-442c-96bc-2b8a87c9ceca", + "destination": null, + "actions": [ + { + "msg": "Single message text", + "type": "reply" + } + ] + } + ], + "rulesets": [] + } + } + ] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_0.json b/media/test_flows/legacy/migrations/migrate_to_11_0.json new file mode 100644 index 00000000000..34355cc297d --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_0.json @@ -0,0 +1,42 @@ +{ + "version": "10.4", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "d96947d0-f975-47ee-be7d-3dfe68a52703", + "action_sets": [ + { + "uuid": "d96947d0-f975-47ee-be7d-3dfe68a52703", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "msg": { + "base": { + "base": "@date Something went wrong once. I shouldn't be a dict inside a dict." + } + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "1ee58c31-3504-49d3-914b-324d484fed1d" + } + ], + "exit_uuid": "f2566f59-5d36-4de7-8581-dcc5de7e8340" + } + ], + "rule_sets": [], + "base_language": "base", + "flow_type": "M", + "version": "10.4", + "metadata": { + "name": "Migrate to 11.0", + "saved_on": "2017-11-15T22:56:36.039558Z", + "revision": 5, + "uuid": "5a8deb77-23b8-46ee-a775-48ed32742e31", + "expires": 720 + } + } + ] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_10.json b/media/test_flows/legacy/migrations/migrate_to_11_10.json new file mode 100644 index 00000000000..92e6896057d --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_10.json @@ -0,0 +1,239 @@ +{ + "version": "11.9", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "bd6ca3fc-0505-4ea6-a1c6-60d0296a7db0", + "action_sets": [ + { + "uuid": "bd6ca3fc-0505-4ea6-a1c6-60d0296a7db0", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "say", + "uuid": "0738e369-279d-4e2f-a14c-08714b0d6f74", + "msg": { + "eng": "Hi there this is an IVR flow.. how did you get here?" + }, + "recording": null + } + ], + "exit_uuid": "0e78ff3d-8307-4c0e-a3b0-af4019930835" + } + ], + "rule_sets": [], + "base_language": "eng", + "flow_type": "V", + "version": "11.9", + "metadata": { + "name": "Migrate to 11.10 IVR Child", + "saved_on": "2019-01-25T21:14:37.475679Z", + "revision": 2, + "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5", + "expires": 5, + "ivr_retry": 60, + "ivr_retry_failed_events": false + } + }, + { + "entry": "920ce708-31d3-4870-804f-190fb37b9b8c", + "action_sets": [ + { + "uuid": "920ce708-31d3-4870-804f-190fb37b9b8c", + "x": 59, + "y": 0, + "destination": "90363d00-a669-4d84-ab57-eb27bf9c3284", + "actions": [ + { + "type": "reply", + "uuid": "3071cb5d-4caf-4a15-87c7-daae4a436ee7", + "msg": { + "eng": "hi" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "f646245c-ac46-4565-9215-cef53c34da09" + }, + { + "uuid": "bbd1c25f-ab01-4539-8f3e-b0ca18f366f4", + "x": 48, + "y": 345, + "destination": null, + "actions": [ + { + "type": "flow", + "uuid": "edb70527-47fa-463e-8318-359254b1bc0e", + "flow": { + "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5", + "name": "Migrate to 11.10 IVR Child" + } + } + ], + "exit_uuid": "330f0f9a-154b-49de-9ff9-a7891d4a11af" + }, + { + "uuid": "62e29de4-d85e-459d-ad38-220d1048b714", + "x": 412, + "y": 348, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "41ed5ba3-41c7-4e6f-b394-d451204bcf0f", + "msg": { + "eng": "Expired" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "0040f402-a6ac-4de4-8775-a4938b9011b8" + } + ], + "rule_sets": [ + { + "uuid": "90363d00-a669-4d84-ab57-eb27bf9c3284", + "x": 218, + "y": 82, + "label": "Response 1", + "rules": [ + { + "uuid": "4c6ac0ad-e8a8-4b1e-b958-ef2f22728821", + "category": { + "eng": "Completed" + }, + "destination": "e5dae061-2c94-45ae-a3bb-4822989e636a", + "destination_type": "R", + "test": { + "type": "subflow", + "exit_type": "completed" + }, + "label": null + }, + { + "uuid": "288dfab6-5171-4cf0-92af-e73af44dbeee", + "category": { + "eng": "Expired" + }, + "destination": "e5dae061-2c94-45ae-a3bb-4822989e636a", + "destination_type": "R", + "test": { + "type": "subflow", + "exit_type": "expired" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "subflow", + "response_type": "", + "operand": "@step.value", + "config": { + "flow": { + "name": "Migrate to 11.10 SMS Child", + "uuid": "a492288a-7b26-4507-b8db-173d28b83ad0" + } + } + }, + { + "uuid": "e5dae061-2c94-45ae-a3bb-4822989e636a", + "x": 218, + "y": 228, + "label": "Response 2", + "rules": [ + { + "uuid": "b9f763d2-82d7-4334-8ed8-806b803d32c1", + "category": { + "eng": "Completed" + }, + "destination": "bbd1c25f-ab01-4539-8f3e-b0ca18f366f4", + "destination_type": "A", + "test": { + "type": "subflow", + "exit_type": "completed" + }, + "label": null + }, + { + "uuid": "54b51a30-8c52-49aa-afc1-24d827a17a8d", + "category": { + "eng": "Expired" + }, + "destination": "62e29de4-d85e-459d-ad38-220d1048b714", + "destination_type": "A", + "test": { + "type": "subflow", + "exit_type": "expired" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "subflow", + "response_type": "", + "operand": "@step.value", + "config": { + "flow": { + "name": "Migrate to 11.10 IVR Child", + "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5" + } + } + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.9", + "metadata": { + "name": "Migrate to 11.10 Parent", + "saved_on": "2019-01-28T19:51:28.310305Z", + "revision": 52, + "uuid": "880cea73-fab6-4353-9db2-bf2e16067941", + "expires": 10080 + } + }, + { + "entry": "762fb8ad-1ec5-4246-a577-e08f0fe497e5", + "action_sets": [ + { + "uuid": "762fb8ad-1ec5-4246-a577-e08f0fe497e5", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "69a7f227-5f44-4ddc-80e1-b9dd855868eb", + "msg": { + "eng": "I'm just a regular honest messaging flow" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "8ec7a5ed-675c-4102-b211-ea10258ac5f9" + } + ], + "rule_sets": [], + "base_language": "eng", + "flow_type": "M", + "version": "11.9", + "metadata": { + "name": "Migrate to 11.10 SMS Child", + "saved_on": "2019-01-28T19:03:29.579743Z", + "revision": 2, + "uuid": "a492288a-7b26-4507-b8db-173d28b83ad0", + "expires": 10080, + "ivr_retry_failed_events": null + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_11.json b/media/test_flows/legacy/migrations/migrate_to_11_11.json new file mode 100644 index 00000000000..f9ca3c4ca58 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_11.json @@ -0,0 +1,107 @@ +{ + "version": "11.10", + "site": "https://textit.in", + "flows": [ + { + "entry": "22505d46-43c5-42ba-975e-725c01ea440f", + "action_sets": [ + { + "uuid": "22505d46-43c5-42ba-975e-725c01ea440f", + "x": 100, + "y": 0, + "destination": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", + "actions": [ + { + "type": "reply", + "uuid": "27dfd8ac-55c5-49c9-88e3-3fb84a9894ff", + "msg": { + "eng": "Hey" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "6e2b09ec-3cc0-4ee6-ae7b-b76bad3ab6d3" + }, + { + "uuid": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", + "x": 95, + "y": 101, + "destination": "78c20ee4-94bd-45e6-8510-8e602568fb6e", + "actions": [ + { + "type": "add_label", + "uuid": "bc82c11d-7654-44e4-966c-fb39e2851df0", + "labels": [ + { + "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", + "name": "Hello" + } + ] + } + ], + "exit_uuid": "84bf44a1-13fd-44cb-8014-d6feb06e010f" + }, + { + "uuid": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", + "x": 146, + "y": 358, + "destination": null, + "actions": [ + { + "type": "add_label", + "uuid": "910bf3b5-951f-47a8-93df-11a6eac8bf0f", + "labels": [ + { + "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", + "name": "Hello" + } + ] + } + ], + "exit_uuid": "6d579c28-9f3f-4584-bd2e-74009612fdbb" + } + ], + "rule_sets": [ + { + "uuid": "78c20ee4-94bd-45e6-8510-8e602568fb6e", + "x": 85, + "y": 219, + "label": "Response 1", + "rules": [ + { + "uuid": "33438bbf-49bd-4468-9a74-bbd7e1f58f57", + "category": { + "eng": "All Responses" + }, + "destination": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "", + "operand": "@step.value", + "config": {} + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.10", + "metadata": { + "name": "Add Label", + "saved_on": "2019-02-12T09:23:05.746930Z", + "revision": 7, + "uuid": "e9b5b8ba-43f4-4bc2-a790-811ee1cfe392", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_12.json b/media/test_flows/legacy/migrations/migrate_to_11_12.json new file mode 100644 index 00000000000..c4aec731cad --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_12.json @@ -0,0 +1,197 @@ +{ + "version": "11.12", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "456b7f83-a96b-4f17-aa0a-116a30ee0d52", + "action_sets": [ + { + "uuid": "456b7f83-a96b-4f17-aa0a-116a30ee0d52", + "x": 100, + "y": 0, + "destination": "cfea15b5-3761-41d0-ad3e-33df7a9b835a", + "actions": [ + { + "type": "channel", + "uuid": "338300e8-b433-4372-8a12-87a0f543ee8a", + "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", + "name": "Android: 1234" + } + ], + "exit_uuid": "6fb525e7-bc24-4358-acde-f2d712b28f2b" + }, + { + "uuid": "cfea15b5-3761-41d0-ad3e-33df7a9b835a", + "x": 114, + "y": 156, + "destination": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", + "actions": [ + { + "type": "reply", + "uuid": "bbdd28f0-824f-41b4-af25-5d6f9a4afefb", + "msg": { + "base": "Hey there, Yes or No?" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "902db0bc-f6a7-45d2-93b2-f47f3af1261e" + }, + { + "uuid": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", + "x": 181, + "y": 452, + "destination": "85d88c16-fafe-4b8e-8e58-a6dc6e1e0e77", + "actions": [ + { + "type": "channel", + "uuid": "437d71a2-bb17-4e71-bef7-ad6b58f0eb85", + "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", + "name": "Android: 1234" + } + ], + "exit_uuid": "cec84721-7f8f-43c3-9af2-4d5d6a15f9de" + }, + { + "uuid": "76e091fe-62a5-4786-9465-7c1fb2446694", + "x": 460, + "y": 117, + "destination": "ef9afd2d-d106-4168-a104-20ddc14f9444", + "actions": [ + { + "type": "reply", + "uuid": "f7d12748-440e-4ef1-97d4-8a9efddf4454", + "msg": { + "base": "Yo, What? Repeat Yes or No" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "f5ce0ce5-8023-4b8d-b635-762a2c18726f" + }, + { + "uuid": "9eef8677-8598-4e87-9e21-3ad245d87aee", + "x": 193, + "y": 633, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "1d3ec932-6b6f-45c2-b4d6-9a0e07721686", + "msg": { + "base": "Bye" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "839dd7c4-64b9-428f-b1d0-c386f493fc4f" + }, + { + "uuid": "85d88c16-fafe-4b8e-8e58-a6dc6e1e0e77", + "x": 173, + "y": 550, + "destination": "9eef8677-8598-4e87-9e21-3ad245d87aee", + "actions": [ + { + "type": "channel", + "uuid": "0afa546d-8308-41c2-a70c-979846108bec", + "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", + "name": "Android: 1234" + } + ], + "exit_uuid": "835a5ca9-d518-452f-865c-ca8e5cde4777" + }, + { + "uuid": "ef9afd2d-d106-4168-a104-20ddc14f9444", + "x": 501, + "y": 242, + "destination": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", + "actions": [ + { + "type": "channel", + "uuid": "28d63382-40ea-4741-ba3a-2930348fab0e", + "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", + "name": "Android: 1234" + } + ], + "exit_uuid": "be8ca9a5-0f61-4c9d-93e4-02aa6bb27afc" + } + ], + "rule_sets": [ + { + "uuid": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", + "x": 134, + "y": 315, + "label": "Response 1", + "rules": [ + { + "uuid": "2924a1d0-be47-4f8e-aefb-f7ff3a563a43", + "category": { + "base": "Yes" + }, + "destination": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "base": "Yes" + } + }, + "label": null + }, + { + "uuid": "0107f9e4-b46c-40d7-b25b-058cac3a167e", + "category": { + "base": "No" + }, + "destination": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "base": "No" + } + }, + "label": null + }, + { + "uuid": "ad81cc6d-1973-4eed-b97d-6edd9ebdeedc", + "category": { + "base": "Other" + }, + "destination": "76e091fe-62a5-4786-9465-7c1fb2446694", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "", + "operand": "@step.value", + "config": {} + } + ], + "base_language": "base", + "flow_type": "M", + "version": "11.12", + "metadata": { + "name": "channels", + "saved_on": "2019-02-26T21:16:32.055957Z", + "revision": 24, + "uuid": "e5fdf453-428f-4da1-9703-0decdf7cf6f9", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_12_one_node.json b/media/test_flows/legacy/migrations/migrate_to_11_12_one_node.json new file mode 100644 index 00000000000..3f4585c1277 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_12_one_node.json @@ -0,0 +1,38 @@ +{ + "version": "11.11", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "b0b6559d-e5bd-4deb-a4ab-9e5f04001dd4", + "action_sets": [ + { + "uuid": "b0b6559d-e5bd-4deb-a4ab-9e5f04001dd4", + "x": 100, + "y": 0, + "actions": [ + { + "type": "channel", + "uuid": "4b34b85d-da31-40c9-af65-6d76ca54b1b5", + "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", + "name": "Android: 1234" + } + ], + "exit_uuid": "be37f250-f992-45e0-97fd-a3c0f57584dc" + } + ], + "rule_sets": [], + "base_language": "base", + "flow_type": "M", + "version": "11.11", + "metadata": { + "name": "channel", + "saved_on": "2019-02-28T08:55:17.275670Z", + "revision": 2, + "uuid": "8a8612bc-ff3a-45ea-b7a5-2673ce901cd9", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_12_other_org.json b/media/test_flows/legacy/migrations/migrate_to_11_12_other_org.json new file mode 100644 index 00000000000..14b2cdbd297 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_12_other_org.json @@ -0,0 +1,39 @@ +{ + "version": "11.11", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "a1c00b3e-a904-4085-851d-e5e386d728b8", + "action_sets": [ + { + "uuid": "a1c00b3e-a904-4085-851d-e5e386d728b8", + "x": 124, + "y": 16, + "actions": [ + { + "type": "channel", + "channel": "CHANNEL-UUID", + "uuid": "84889e4d-e7e8-4415-9ad9-db27d9972558", + "name": "Not Ours" + } + ], + "exit_uuid": "eada09b7-7136-4f24-a34f-62ca7b404423" + } + ], + "rule_sets": [], + "base_language": "eng", + "flow_type": "M", + "version": "11.11", + "metadata": { + "name": "Other Org Channel", + "saved_on": "2019-02-25T20:36:14.155001Z", + "revision": 19, + "uuid": "bb8ca54b-7dcb-431f-bd86-ec3082b63469", + "expires": 43200, + "ivr_retry_failed_events": null, + "notes": [] + }, + "type": "M" + } + ] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_3.json b/media/test_flows/legacy/migrations/migrate_to_11_3.json new file mode 100644 index 00000000000..d8271973ddc --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_3.json @@ -0,0 +1,84 @@ +{ + "version": "11.2", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", + "action_sets": [ + { + "uuid": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", + "x": 412, + "y": 814, + "destination": null, + "actions": [ + { + "type": "api", + "uuid": "9b46779a-f680-450f-8f3c-005f3b7efccd", + "webhook": "http://example.com/?thing=@flow.response_1&foo=bar", + "action": "POST", + "webhook_headers": [] + } + ], + "exit_uuid": "25d8d2ae-ea82-4214-9561-42e0bf420a93" + } + ], + "rule_sets": [ + { + "uuid": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", + "x": 100, + "y": 0, + "label": "Response 1", + "rules": [ + { + "uuid": "c799def9-345b-46f9-a838-a59191cdb181", + "category": { + "eng": "Success" + }, + "destination": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", + "destination_type": "R", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "1ace9344-3053-4dc2-aced-9a6e3c8a6e9d", + "category": { + "eng": "Failure" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "webhook", + "response_type": "", + "operand": "@step.value", + "config": { + "webhook": "http://example.com/webhook1", + "webhook_action": "POST", + "webhook_headers": [] + } + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.2", + "metadata": { + "name": "Migrate to 11.3 Test", + "saved_on": "2018-09-25T14:57:23.429081Z", + "revision": 97, + "uuid": "915144c5-605e-46f3-afa3-53aae2c9b8ee", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_4.json b/media/test_flows/legacy/migrations/migrate_to_11_4.json new file mode 100644 index 00000000000..6ca69cddacc --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_4.json @@ -0,0 +1,168 @@ +{ + "version": "11.3", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "019d0fab-eb51-4431-9f51-ddf207d0a744", + "action_sets": [ + { + "uuid": "92fb739f-4a99-4e29-8078-1f8fb06d127e", + "x": 241, + "y": 425, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "0382e5aa-bfda-42c8-84d3-7893aba002f8", + "msg": { + "eng": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "send", + "uuid": "b5860896-db39-4ebb-b842-d38edf46fb61", + "msg": { + "eng": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" + }, + "contacts": [ + { + "id": 277738, + "name": "05fe51bf5a434b9", + "uuid": "74eed75b-dd4f-4d24-9fc5-474052dbc086", + "urns": [ + { + "scheme": "tel", + "path": "+2353265262", + "priority": 90 + } + ] + } + ], + "groups": [], + "variables": [], + "media": {} + }, + { + "type": "email", + "uuid": "c9130ab6-d2b2-419c-8109-65b5afc47039", + "emails": [ + "test@test.com" + ], + "subject": "Testing", + "msg": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" + } + ], + "exit_uuid": "ea5640be-105b-4277-b04e-7ad55d2c898e" + } + ], + "rule_sets": [ + { + "uuid": "019d0fab-eb51-4431-9f51-ddf207d0a744", + "x": 226, + "y": 118, + "label": "Response 1", + "rules": [ + { + "uuid": "7fd3aae5-66ca-4d8d-9923-3ef4424e7658", + "category": { + "eng": "All Responses" + }, + "destination": "fc1b062c-52c0-4c9e-87bd-1f9437d513bf", + "destination_type": "R", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "fc1b062c-52c0-4c9e-87bd-1f9437d513bf", + "x": 226, + "y": 232, + "label": "Response 2", + "rules": [ + { + "uuid": "58a4e6f6-fe44-4ac9-bf98-edffd6dfad04", + "category": { + "eng": "All Responses" + }, + "destination": "518b6f12-0192-4a75-8900-43a5dea02340", + "destination_type": "R", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "expression", + "response_type": "", + "operand": "@contact.uuid", + "config": {} + }, + { + "uuid": "518b6f12-0192-4a75-8900-43a5dea02340", + "x": 226, + "y": 335, + "label": "Response 3", + "rules": [ + { + "uuid": "0d1b5fd9-bfee-4df6-9837-9883787f0661", + "category": { + "eng": "Bucket 1" + }, + "destination": "92fb739f-4a99-4e29-8078-1f8fb06d127e", + "destination_type": "A", + "test": { + "type": "between", + "min": "0", + "max": "0.5" + }, + "label": null + }, + { + "uuid": "561b7ce2-5975-4925-a76a-f4a618b11c8b", + "category": { + "eng": "Bucket 2" + }, + "destination": "92fb739f-4a99-4e29-8078-1f8fb06d127e", + "destination_type": "A", + "test": { + "type": "between", + "min": "0.5", + "max": "1" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "random", + "response_type": "", + "operand": "@(RAND())", + "config": {} + } + ], + "base_language": "eng", + "flow_type": "F", + "version": "11.3", + "metadata": { + "name": "Migrate to 11.4", + "saved_on": "2018-06-25T21:58:04.000768Z", + "revision": 123, + "uuid": "025f1d6e-ec87-4045-8471-0a028b9483aa", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_5.json b/media/test_flows/legacy/migrations/migrate_to_11_5.json new file mode 100644 index 00000000000..86c2ee6af78 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_5.json @@ -0,0 +1,398 @@ +{ + "version": "11.4", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", + "action_sets": [ + { + "uuid": "35707236-5dd6-487d-bea4-6a73822852bf", + "x": 122, + "y": 458, + "destination": "51956031-9f42-475f-9d43-3ab2f87f4dd2", + "actions": [ + { + "type": "reply", + "uuid": "c82df796-9d8f-4e9b-b76c-97027fa74ef7", + "msg": { + "eng": "@flow.response_1\n@flow.response_1.value\n@flow.response_1.category\n@(upper(flow.response_1))\n@(upper(flow.response_1.category))\n\n@flow.response_2\n@flow.response_2.value\n@flow.response_2.category\n@(upper(flow.response_2))\n@(upper(flow.response_2.category))\n\n@flow.response_3\n@flow.response_3.value\n@flow.response_3.category\n@(upper(flow.response_3))\n@(upper(flow.response_3.category))" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "65af1dca-b48e-4b36-867c-2ace47038093" + }, + { + "uuid": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", + "x": 412, + "y": 814, + "destination": null, + "actions": [ + { + "type": "api", + "uuid": "9b46779a-f680-450f-8f3c-005f3b7efccd", + "webhook": "http://example.com/?thing=@flow.response_1&foo=bar", + "action": "GET", + "webhook_headers": [] + }, + { + "type": "save", + "uuid": "e0ecf2a5-0429-45ec-a9d7-e2c122274484", + "label": "Contact Name", + "field": "name", + "value": "@flow.response_3.value" + } + ], + "exit_uuid": "25d8d2ae-ea82-4214-9561-42e0bf420a93" + } + ], + "rule_sets": [ + { + "uuid": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", + "x": 100, + "y": 0, + "label": "Response 1", + "rules": [ + { + "uuid": "c799def9-345b-46f9-a838-a59191cdb181", + "category": { + "eng": "Success" + }, + "destination": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", + "destination_type": "R", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "1ace9344-3053-4dc2-aced-9a6e3c8a6e9d", + "category": { + "eng": "Failure" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "webhook", + "response_type": "", + "operand": "@step.value", + "config": { + "webhook": "http://example.com/webhook1", + "webhook_action": "GET", + "webhook_headers": [] + } + }, + { + "uuid": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", + "x": 103, + "y": 125, + "label": "Response 2", + "rules": [ + { + "uuid": "ce50f51d-f052-4ff1-8a9b-a79faa62dfc2", + "category": { + "eng": "Success" + }, + "destination": "5906c8f3-46f2-4319-8743-44fb26f2b109", + "destination_type": "R", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "338e6c08-3597-4d22-beef-80d27b870a93", + "category": { + "eng": "Failure" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "webhook", + "response_type": "", + "operand": "@step.value", + "config": { + "webhook": "http://example.com/webhook2", + "webhook_action": "GET", + "webhook_headers": [] + } + }, + { + "uuid": "5906c8f3-46f2-4319-8743-44fb26f2b109", + "x": 105, + "y": 243, + "label": "Response 2", + "rules": [ + { + "uuid": "6328e346-49c6-4607-a573-e8dc6e60bfcd", + "category": { + "eng": "All Responses" + }, + "destination": "728a9a97-f28e-4fb3-a96a-7a7a8d5e5a4c", + "destination_type": "R", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "expression", + "response_type": "", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "728a9a97-f28e-4fb3-a96a-7a7a8d5e5a4c", + "x": 112, + "y": 346, + "label": "Response 3", + "rules": [ + { + "uuid": "fb64dd04-8dd3-4e28-8607-468d1748a81f", + "category": { + "eng": "Success" + }, + "destination": "35707236-5dd6-487d-bea4-6a73822852bf", + "destination_type": "A", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "992c7429-221a-40f0-80be-fd6fbe858f57", + "category": { + "eng": "Failure" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "resthook", + "response_type": "", + "operand": "@step.value", + "config": { + "resthook": "test-resthook-event" + } + }, + { + "uuid": "51956031-9f42-475f-9d43-3ab2f87f4dd2", + "x": 411, + "y": 513, + "label": "Response 5", + "rules": [ + { + "uuid": "c06fb4fe-09a0-4990-b32e-e233de7edfda", + "category": { + "eng": "All Responses" + }, + "destination": "f39a6d73-57d9-4d10-9055-57446addc87a", + "destination_type": "R", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "expression", + "response_type": "", + "operand": "@(flow.response_1 & flow.response_2 & flow.response_3)", + "config": {} + }, + { + "uuid": "f39a6d73-57d9-4d10-9055-57446addc87a", + "x": 414, + "y": 625, + "label": "Response 6", + "rules": [ + { + "uuid": "820f0020-0c72-44cd-9c12-a2b05c13e470", + "category": { + "eng": "Yes" + }, + "destination": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", + "destination_type": "R", + "test": { + "type": "contains_any", + "test": { + "eng": "yes" + } + }, + "label": null + }, + { + "uuid": "8e55e70f-acf0-45a2-b7f9-2f95ccbbfc4d", + "category": { + "eng": "Matching" + }, + "destination": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", + "destination_type": "R", + "test": { + "type": "contains_any", + "test": { + "eng": "@flow.response_1" + } + }, + "label": null + }, + { + "uuid": "d1c61a49-64f5-4ff6-b17f-1f22472f829f", + "category": { + "eng": "Other" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "flow_field", + "response_type": "", + "operand": "@flow.response_1", + "config": {} + }, + { + "uuid": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", + "x": 489, + "y": 722, + "label": "Response 7", + "rules": [ + { + "uuid": "234fff68-780f-442f-a1c6-757131fbc213", + "category": { + "eng": "Success" + }, + "destination": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", + "destination_type": "A", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "70b79516-40a5-439c-9dee-45b242d6bb8b", + "category": { + "eng": "Failure" + }, + "destination": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", + "destination_type": "A", + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "webhook", + "response_type": "", + "operand": "@step.value", + "config": { + "webhook": "http://example.com/?thing=@flow.response_1.value", + "webhook_action": "GET", + "webhook_headers": [] + } + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.4", + "metadata": { + "name": "Migrate to 11.5 Test", + "saved_on": "2018-09-25T14:57:23.429081Z", + "revision": 97, + "uuid": "915144c5-605e-46f3-afa3-53aae2c9b8ee", + "expires": 10080, + "notes": [ + { + "x": 357, + "y": 0, + "title": "New Note", + "body": "@flow.response_1" + }, + { + "x": 358, + "y": 117, + "title": "New Note", + "body": "flow.response_2" + }, + { + "x": 358, + "y": 236, + "title": "New Note", + "body": "reuses flow.response_2" + }, + { + "x": 360, + "y": 346, + "title": "New Note", + "body": "@flow.response_3" + }, + { + "x": 671, + "y": 498, + "title": "New Note", + "body": "operand should be migrated too" + }, + { + "x": 717, + "y": 608, + "title": "New Note", + "body": "rule test should be migrated" + }, + { + "x": 747, + "y": 712, + "title": "New Note", + "body": "webhook URL in config should be migrated" + }, + { + "x": 681, + "y": 830, + "title": "New Note", + "body": "webhook URL on action should be migrated" + }, + { + "x": 682, + "y": 934, + "title": "New Note", + "body": "field value should be migrated" + } + ] + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_6.json b/media/test_flows/legacy/migrations/migrate_to_11_6.json new file mode 100644 index 00000000000..97898acd159 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_6.json @@ -0,0 +1,252 @@ +{ + "version": "11.5", + "site": "https://textit.in", + "flows": [ + { + "entry": "c4462613-5936-42cc-a286-82e5f1816793", + "action_sets": [ + { + "uuid": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", + "x": 76, + "y": 99, + "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", + "actions": [ + { + "type": "add_group", + "uuid": "feb7a33e-bc8b-44d8-9112-bc4e910fe304", + "groups": [ + { + "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", + "name": "Cat Fanciers" + } + ] + }, + { + "type": "add_group", + "uuid": "ca82f0e0-43ca-426c-a77c-93cf297b8e7c", + "groups": [ + { + "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", + "name": "Catnado" + } + ] + }, + { + "type": "reply", + "uuid": "d57e9e9f-ada4-4a22-99ef-b8bf3dbcdcae", + "msg": { + "eng": "You are a cat fan! Purrrrr." + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "55f88a1e-73ad-4b6d-9a04-626046bbe5a8" + }, + { + "uuid": "ef389049-d2e3-4343-b91f-13ea2db5f943", + "x": 558, + "y": 94, + "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", + "actions": [ + { + "type": "del_group", + "uuid": "cea907a8-af81-49af-92e6-f246e52179fe", + "groups": [ + { + "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", + "name": "Catnado" + } + ] + }, + { + "type": "reply", + "uuid": "394a328f-f829-43f2-9975-fe2f27c8b786", + "msg": { + "eng": "You are not a cat fan. Hissssss." + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "9ba78afa-948e-44c5-992f-84030f2eaa6b" + }, + { + "uuid": "d21be990-5e48-4e4b-995f-c9df8f38e517", + "x": 319, + "y": 323, + "destination": "35416fea-787d-48c1-b839-76eca089ad2e", + "actions": [ + { + "type": "channel", + "uuid": "78c58574-9f91-4c27-855e-73eacc99c395", + "channel": "bd55bb31-8ed4-4f89-b903-7103aa3762be", + "name": "Telegram: TextItBot" + } + ], + "exit_uuid": "c86638a9-2688-47c9-83ec-7f10ef49de1e" + }, + { + "uuid": "35416fea-787d-48c1-b839-76eca089ad2e", + "x": 319, + "y": 468, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "30d35b8f-f439-482a-91b1-d3b1a4351071", + "msg": { + "eng": "All done." + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "send", + "uuid": "a7b6def8-d315-49bd-82e4-85887f39babe", + "msg": { + "eng": "Hey Cat Fans!" + }, + "contacts": [], + "groups": [ + { + "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", + "name": "Cat Blasts" + } + ], + "variables": [], + "media": {} + }, + { + "type": "trigger-flow", + "uuid": "540965e5-bdfe-4416-b4dd-449220b1c588", + "flow": { + "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", + "name": "Cataclysmic" + }, + "contacts": [], + "groups": [ + { + "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", + "name": "Cat Alerts" + } + ], + "variables": [] + } + ], + "exit_uuid": "f2ef5066-434d-42bc-a5cb-29c59e51432f" + } + ], + "rule_sets": [ + { + "uuid": "c4462613-5936-42cc-a286-82e5f1816793", + "x": 294, + "y": 0, + "label": "Response 1", + "rules": [ + { + "uuid": "17d69564-60c9-4a56-be8b-34e98a2ce14a", + "category": { + "eng": "Cat Facts" + }, + "destination": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", + "destination_type": "A", + "test": { + "type": "in_group", + "test": { + "name": "Cat Facts", + "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9" + } + }, + "label": null + }, + { + "uuid": "a9ec4d0a-2ddd-4a13-a1d2-c63ce9916a04", + "category": { + "eng": "Other" + }, + "destination": "ef389049-d2e3-4343-b91f-13ea2db5f943", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "group", + "response_type": "", + "operand": "@step.value", + "config": {} + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.5", + "metadata": { + "name": "Cataclysmic", + "saved_on": "2018-10-18T17:03:54.835916Z", + "revision": 49, + "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", + "expires": 10080, + "notes": [] + } + }, + { + "entry": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", + "action_sets": [ + { + "uuid": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "add_group", + "uuid": "11f61fc6-834e-4cbc-88ee-c834279345e6", + "groups": [ + { + "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", + "name": "Cat Alerts" + }, + { + "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9", + "name": "Cat Facts" + }, + { + "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", + "name": "Cat Blasts" + }, + { + "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", + "name": "Cat Fanciers" + }, + { + "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", + "name": "Catnado" + } + ] + } + ], + "exit_uuid": "029a7c9d-c935-4ed1-9573-543ded29d954" + } + ], + "rule_sets": [], + "base_language": "eng", + "flow_type": "M", + "version": "11.5", + "metadata": { + "name": "Catastrophe", + "saved_on": "2018-10-18T19:03:07.702388Z", + "revision": 1, + "uuid": "d6dd96b1-d500-4c7a-9f9c-eae3f2a2a7c5", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_7.json b/media/test_flows/legacy/migrations/migrate_to_11_7.json new file mode 100644 index 00000000000..108cbf2c376 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_7.json @@ -0,0 +1,246 @@ +{ + "version": "11.6", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", + "action_sets": [ + { + "uuid": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", + "x": 102, + "y": 0, + "destination": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", + "actions": [ + { + "type": "api", + "uuid": "82d23a8c-af4b-4a33-8d56-03139b1168cc", + "webhook": "http://example.com/hook1", + "action": "GET", + "webhook_headers": [ + { + "name": "Header1", + "value": "Value1" + } + ] + } + ], + "exit_uuid": "787517ce-9a6d-479e-bc81-c3f4dcbb3d1d" + }, + { + "uuid": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", + "x": 149, + "y": 107, + "destination": "efe05d14-7a96-4ec5-870c-5183408821ae", + "actions": [ + { + "type": "reply", + "uuid": "544fd45b-f9a9-4543-b352-06b67dc0c32c", + "msg": { + "eng": "Action before 1" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "reply", + "uuid": "252b59b0-3664-4a36-8b9f-9317e78011da", + "msg": { + "eng": "Action before 2" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "api", + "uuid": "55c868c0-f6f7-49a8-856c-809bd082ae3b", + "webhook": "http://example.com/hook2", + "action": "POST", + "webhook_headers": [] + }, + { + "type": "reply", + "uuid": "f7ec546c-9adf-4d51-ab8e-8a1cbde8d910", + "msg": { + "eng": "Action after 1" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "reply", + "uuid": "a44ec0b8-085d-4e80-b361-7529e659e5e6", + "msg": { + "eng": "Action after 2" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "32c5dba9-17d1-4d5d-a992-19c1ec6cf825" + }, + { + "uuid": "efe05d14-7a96-4ec5-870c-5183408821ae", + "x": 199, + "y": 446, + "destination": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", + "actions": [ + { + "type": "api", + "uuid": "05377f3c-d9b0-428d-ae14-219d2f3d0f9a", + "webhook": "http://example.com/hook3", + "action": "GET", + "webhook_headers": [] + }, + { + "type": "api", + "uuid": "61fadf6d-d2ba-4bbb-b312-1db3e336a661", + "webhook": "http://example.com/hook4", + "action": "GET", + "webhook_headers": [] + } + ], + "exit_uuid": "c2236afe-c3cb-43a5-9fa0-ee6cbfb92f42" + }, + { + "uuid": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", + "x": 245, + "y": 608, + "destination": "64d8b8a5-aca0-4406-b417-5827262e67e2", + "actions": [ + { + "type": "reply", + "uuid": "be4dbed8-7334-4700-a94d-50275015c048", + "msg": { + "eng": "Actionset without webhook" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "40b890ab-8fab-459f-8d5e-48d2ea57f7ce" + }, + { + "uuid": "d6da8268-0c61-4154-8659-dd073878541c", + "x": 1036, + "y": 265, + "destination": null, + "actions": [ + { + "type": "api", + "uuid": "b8a8715b-0fb5-4dde-a1fe-4fef045bb16c", + "webhook": "http://example.com/hook5", + "action": "GET", + "webhook_headers": [] + } + ], + "exit_uuid": "15170baf-8b15-4104-990c-13635f0bafbb" + } + ], + "rule_sets": [ + { + "uuid": "64d8b8a5-aca0-4406-b417-5827262e67e2", + "x": 673, + "y": 54, + "label": "Response 1", + "rules": [ + { + "uuid": "4bc64a60-b848-4f07-bbe8-8b82e72b6dea", + "category": { + "eng": "1" + }, + "destination": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "eng": "1" + } + }, + "label": null + }, + { + "uuid": "2faff885-6ac4-4cef-bd11-53802be22508", + "category": { + "eng": "2" + }, + "destination": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "eng": "2" + } + }, + "label": null + }, + { + "uuid": "05efb767-1319-4f93-ba3f-8d3860a915af", + "category": { + "eng": "3" + }, + "destination": "efe05d14-7a96-4ec5-870c-5183408821ae", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "eng": "3" + } + }, + "label": null + }, + { + "uuid": "2bfbb15e-fb54-41a5-ba43-c67c219e8c57", + "category": { + "eng": "4" + }, + "destination": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", + "destination_type": "A", + "test": { + "type": "contains_any", + "test": { + "eng": "4" + } + }, + "label": null + }, + { + "uuid": "d091ea29-07b9-48b8-bc52-1de00687af1b", + "category": { + "eng": "Other" + }, + "destination": "d6da8268-0c61-4154-8659-dd073878541c", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "", + "operand": "@step.value", + "config": {} + } + ], + "base_language": "eng", + "flow_type": "M", + "version": "11.6", + "metadata": { + "name": "Webhook Action Migration", + "saved_on": "2018-11-05T19:21:37.062932Z", + "revision": 61, + "uuid": "c9b9d79a-93b4-41e5-8ca3-f0b09faa2457", + "expires": 10080, + "notes": [] + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_8.json b/media/test_flows/legacy/migrations/migrate_to_11_8.json new file mode 100644 index 00000000000..db0f0f372c4 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_8.json @@ -0,0 +1,341 @@ +{ + "version": 11.7, + "site": null, + "flows": [ + { + "entry": "fde99613-a3e9-4f97-9e88-81ebc0ea6211", + "action_sets": [ + { + "uuid": "788064a1-fe23-4f6e-8041-200412dff55e", + "x": 389, + "y": 991, + "destination": "d8be5901-e847-4b6f-a603-51eb571718a1", + "actions": [ + { + "type": "reply", + "uuid": "fdee102d-5259-4153-8e43-0b7df1d3a1ee", + "msg": { + "base": "Thanks @extra.name, we'll be in touch ASAP about order # @extra.order." + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "email", + "uuid": "66c4a60f-3d63-4eed-bd03-c801baa0d793", + "emails": [ + "rowanseymour@gmail.com" + ], + "subject": "Order Comment: @flow.lookup: @extra.order", + "msg": "Customer @extra.name has a problem with their order @extra.order for @extra.description. Please look into it ASAP and call them back with the status.\n \nCustomer Comment: \"@flow.comment\"\nCustomer Name: @extra.name\nCustomer Phone: @contact.tel " + } + ], + "exit_uuid": "b193a69a-d5d9-423a-9f1f-0ad51847a075" + }, + { + "uuid": "1bdc3242-ef13-4c1b-a3b1-11554bffff7a", + "x": 612, + "y": 574, + "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", + "actions": [ + { + "type": "reply", + "uuid": "fc90459d-243c-4207-a26b-258e2c42cff3", + "msg": { + "base": "Uh oh @extra.name! Our record indicate that your order for @extra.description was cancelled on @extra.cancel_date. If you think this is in error, please reply with a comment and our orders department will get right on it!" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "7e943c3d-b560-436f-bd7e-5c52e9162254" + }, + { + "uuid": "601c7150-7a3e-40aa-8f79-92f936e17cf9", + "x": 389, + "y": 572, + "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", + "actions": [ + { + "type": "reply", + "uuid": "459ed2db-9921-4326-87a1-5157e0a9b38a", + "msg": { + "base": "Hi @extra.name. Hope you are patient because we haven't shipped your order for @extra.description yet. We expect to ship it by @extra.ship_date though. If you have any questions, just reply and our customer service department will be notified." + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "5747ab91-d20c-4fff-8246-9d29a6ef1511" + }, + { + "uuid": "f87e2df4-5cbb-4961-b3c9-41eed35f8dbe", + "x": 167, + "y": 572, + "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", + "actions": [ + { + "type": "reply", + "uuid": "661ac1e4-2f13-48b1-adcf-0ff151833a86", + "msg": { + "base": "Great news @extra.name! We shipped your order for @extra.description on @extra.ship_date and we expect it will be delivered on @extra.delivery_date. If you have any questions, just reply and our customer service department will be notified." + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "aee36df6-a421-43b9-be55-a4a298c35f86" + }, + { + "uuid": "81c3ff98-3552-4962-ab05-8f7948ebac24", + "x": 787, + "y": 99, + "destination": "659f67c6-cf6d-4d43-bd64-a50318fd5168", + "actions": [ + { + "type": "reply", + "uuid": "7645e8cd-34a1-44d0-8b11-7f4f06bd5ac7", + "msg": { + "base": "Sorry that doesn't look like a valid order number. Maybe try: CU001, CU002 or CU003?" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "b6e7b7f2-88e5-4457-ba7b-6edb9fb81d9f" + }, + { + "uuid": "fde99613-a3e9-4f97-9e88-81ebc0ea6211", + "x": 409, + "y": 0, + "destination": "659f67c6-cf6d-4d43-bd64-a50318fd5168", + "actions": [ + { + "type": "reply", + "uuid": "c007a761-85c7-48eb-9b38-8d056d1d44ee", + "msg": { + "base": "Thanks for contacting the ThriftShop order status system. Please send your order # and we'll help you in a jiffy!" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "0a300e24-c7fa-473d-b06e-2826fa25b447" + } + ], + "rule_sets": [ + { + "uuid": "691e8175-f6a1-45b3-b377-c8bda223e52b", + "x": 389, + "y": 875, + "label": "Comment", + "rules": [ + { + "uuid": "567cac39-5ee4-4dac-b29a-97dfef2a2eb1", + "category": { + "base": "All Responses" + }, + "destination": "788064a1-fe23-4f6e-8041-200412dff55e", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "O", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "659f67c6-cf6d-4d43-bd64-a50318fd5168", + "x": 356, + "y": 198, + "label": "Lookup Response", + "rules": [ + { + "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", + "category": { + "base": "All Responses" + }, + "destination": "541382fd-e897-4f77-b468-1f2c7bacf30c", + "destination_type": "R", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "C", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "d8be5901-e847-4b6f-a603-51eb571718a1", + "x": 389, + "y": 1252, + "label": "Extra Comments", + "rules": [ + { + "uuid": "bba334ec-321e-4ead-8d1d-f34d7bc983ad", + "category": { + "base": "All Responses" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "O", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "726f6b34-d6be-46fa-8061-bf1f081b15ce", + "x": 356, + "y": 398, + "label": "Lookup", + "rules": [ + { + "uuid": "d26ac82f-90dc-4f95-b105-7d3ca4effc20", + "category": { + "base": "Shipped" + }, + "destination": "f87e2df4-5cbb-4961-b3c9-41eed35f8dbe", + "destination_type": "A", + "test": { + "type": "contains", + "test": { + "base": "Shipped" + } + }, + "label": null + }, + { + "uuid": "774e6911-cb63-4700-99bc-5e16966393b8", + "category": { + "base": "Pending" + }, + "destination": "601c7150-7a3e-40aa-8f79-92f936e17cf9", + "destination_type": "A", + "test": { + "type": "contains", + "test": { + "base": "Pending" + } + }, + "label": null + }, + { + "uuid": "fee4858c-2545-435b-ae65-d9e6b8f8d106", + "category": { + "base": "Cancelled" + }, + "destination": "1bdc3242-ef13-4c1b-a3b1-11554bffff7a", + "destination_type": "A", + "test": { + "type": "contains", + "test": { + "base": "Cancelled" + } + }, + "label": null + }, + { + "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", + "category": { + "base": "Other" + }, + "destination": "81c3ff98-3552-4962-ab05-8f7948ebac24", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "expression", + "response_type": "", + "operand": "@extra.status", + "config": {} + }, + { + "uuid": "541382fd-e897-4f77-b468-1f2c7bacf30c", + "x": 356, + "y": 298, + "label": "Lookup Webhook", + "rules": [ + { + "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", + "category": { + "base": "Success" + }, + "destination": "726f6b34-d6be-46fa-8061-bf1f081b15ce", + "destination_type": "R", + "test": { + "type": "webhook_status", + "status": "success" + }, + "label": null + }, + { + "uuid": "008f4050-7979-42d5-a2cb-d1b4f6bc144f", + "category": { + "base": "Failure" + }, + "destination": "726f6b34-d6be-46fa-8061-bf1f081b15ce", + "destination_type": "R", + "test": { + "type": "webhook_status", + "status": "failure" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "webhook", + "response_type": "", + "operand": "@step.value", + "config": { + "webhook": "https://textit.in/demo/status/", + "webhook_action": null + } + } + ], + "base_language": "base", + "flow_type": "M", + "version": "11.7", + "metadata": { + "notes": [ + { + "body": "This flow demonstrates looking up an order using a webhook and giving the user different options based on the results. After looking up the order the user has the option to send additional comments which are forwarded to customer support representatives.\n\nUse order numbers CU001, CU002 or CU003 to see the different cases in action.", + "x": 59, + "y": 0, + "title": "Using Your Own Data" + } + ], + "saved_on": "2019-01-09T18:29:40.288510Z", + "uuid": "3825c65e-5aa8-4619-8de9-963f68483cb3", + "name": "Sample Flow - Order Status Checker", + "revision": 11, + "expires": 720 + } + } + ] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_11_9.json b/media/test_flows/legacy/migrations/migrate_to_11_9.json new file mode 100644 index 00000000000..b1c3f8b56a5 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_11_9.json @@ -0,0 +1,458 @@ +{ + "version": "11.8", + "site": "https://app.rapidpro.io", + "flows": [ + { + "entry": "edea0cb4-00b9-4a53-a923-f4aa38cf18c5", + "action_sets": [ + { + "uuid": "edea0cb4-00b9-4a53-a923-f4aa38cf18c5", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "0b6745ce-6d8b-40d4-bb4f-f18f407bdcdf", + "msg": { + "base": "hi valid" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "d79e8a16-62df-4b48-aff9-fae2633f2b77" + } + ], + "rule_sets": [], + "base_language": "base", + "flow_type": "M", + "version": "11.8", + "metadata": { + "name": "Valid1", + "saved_on": "2018-12-17T12:08:54.146452Z", + "revision": 2, + "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", + "expires": 10080, + "ivr_retry_failed_events": null + } + }, + { + "entry": "d3e2b506-50cd-4c1e-9573-295bd2087258", + "action_sets": [ + { + "uuid": "d3e2b506-50cd-4c1e-9573-295bd2087258", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "d17b512e-87ed-4717-9461-bc2ffde23b77", + "msg": { + "base": "Hi flow invalid 1" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "c231d9a6-3b53-4670-ac41-247736126ffd" + } + ], + "rule_sets": [], + "base_language": "base", + "flow_type": "M", + "version": "11.8", + "metadata": { + "name": "Invalid1", + "saved_on": "2018-12-17T12:09:52.155509Z", + "revision": 2, + "uuid": "ad40071e-a665-4df3-af14-0bc0fe589244", + "expires": 10080, + "ivr_retry_failed_events": null + } + }, + { + "entry": "932b19a7-245c-4a2b-9249-66d4eb7cfdf7", + "action_sets": [ + { + "uuid": "932b19a7-245c-4a2b-9249-66d4eb7cfdf7", + "x": 100, + "y": 0, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "2f6cdd62-9b29-4597-8af0-3dd410ae46f0", + "msg": { + "base": "Hi flow invalid two" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "0035ccc2-6359-4954-bbc9-bddb90076c25" + } + ], + "rule_sets": [], + "base_language": "base", + "flow_type": "M", + "version": "11.8", + "metadata": { + "name": "Invalid2", + "saved_on": "2018-12-17T12:10:13.269437Z", + "revision": 3, + "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", + "expires": 10080, + "ivr_retry_failed_events": null + } + }, + { + "entry": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", + "action_sets": [ + { + "uuid": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", + "x": 375, + "y": 1, + "destination": "a7de0caa-5ab0-4edc-8fc8-33eb31f79cba", + "actions": [ + { + "type": "reply", + "uuid": "64ce02e3-8ea8-414a-a7cf-7f5d3938aa03", + "msg": { + "base": "Hi" + }, + "media": {}, + "quick_replies": [], + "send_all": false + } + ], + "exit_uuid": "33d4947d-bdda-4226-bc27-55a6b6e56b36" + }, + { + "uuid": "c9e48e85-b91c-4e2b-bb17-fb670f1559c0", + "x": 420, + "y": 622, + "destination": "861c6312-b2a8-4586-8688-6621d7065497", + "actions": [ + { + "type": "reply", + "uuid": "24b49e66-2520-4f9c-a6f7-f7a43793db53", + "msg": { + "base": "tnx" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "trigger-flow", + "uuid": "bf31a0f8-73d8-4c81-8f90-ea0d4008a212", + "flow": { + "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", + "name": "Invalid2" + }, + "contacts": [], + "groups": [ + { + "uuid": "ad17d536-0085-4e6b-abc6-222b22d57caa", + "name": "Empty" + } + ], + "variables": [] + } + ], + "exit_uuid": "c8b6b99d-1af5-4bd1-b2d8-b3e87de702e8" + }, + { + "uuid": "6adc7de8-6a84-490a-b3d3-3d1ec607d465", + "x": 580, + "y": 316, + "destination": null, + "actions": [ + { + "type": "reply", + "uuid": "09f0ddee-c27e-4397-bb3c-4a6cf35da77a", + "msg": { + "base": "tyvm" + }, + "media": {}, + "quick_replies": [], + "send_all": false + }, + { + "type": "flow", + "uuid": "95e9750f-9bf4-4ae9-aa07-5c4cde604956", + "flow": { + "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", + "name": "Invalid2" + } + } + ], + "exit_uuid": "1f5d4b7e-7ceb-47cf-91e7-94790f63c9db" + }, + { + "uuid": "ed891a32-6e6d-49b1-88d0-399d2002bce0", + "x": 323, + "y": 993, + "destination": null, + "actions": [ + { + "type": "flow", + "uuid": "70acb970-8b3a-47d0-9fb6-56c5974a582b", + "flow": { + "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", + "name": "Valid1" + } + } + ], + "exit_uuid": "b5bfc0a1-701e-4e19-ad3e-bf9d7470b241" + }, + { + "uuid": "a750fe69-167b-4ae3-af72-7aae4c2d8b1a", + "x": 598, + "y": 993, + "destination": null, + "actions": [ + { + "type": "flow", + "uuid": "9b0f11bd-fbda-4efe-a41a-8ef101412d95", + "flow": { + "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", + "name": "Invalid2" + } + } + ], + "exit_uuid": "1231547d-8f57-4a12-82d1-b1bf3e664010" + }, + { + "uuid": "0bfb7527-c6e9-4452-b780-6755d2041144", + "x": 576, + "y": 169, + "destination": null, + "actions": [ + { + "type": "flow", + "uuid": "e8e85830-1aef-4947-af91-1a2653f3627d", + "flow": { + "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", + "name": "Valid1" + } + } + ], + "exit_uuid": "8d2c4101-330a-49f7-a4e5-972513c1a995" + } + ], + "rule_sets": [ + { + "uuid": "a7de0caa-5ab0-4edc-8fc8-33eb31f79cba", + "x": 61, + "y": 190, + "label": "Response 1", + "rules": [ + { + "uuid": "a16424a4-95df-4839-813a-bf6bee37f735", + "category": { + "base": "1" + }, + "destination": "9baa6aaf-61bf-4686-8059-1c373a43e5a6", + "destination_type": "R", + "test": { + "type": "eq", + "test": "1" + }, + "label": null + }, + { + "uuid": "f6b45161-f1fe-475f-a4db-7eb300f26415", + "category": { + "base": "2" + }, + "destination": "c9e48e85-b91c-4e2b-bb17-fb670f1559c0", + "destination_type": "A", + "test": { + "type": "eq", + "test": "2" + }, + "label": null + }, + { + "uuid": "59bfd40b-8b94-4555-ac2e-e6883d280df2", + "category": { + "base": "3" + }, + "destination": "6adc7de8-6a84-490a-b3d3-3d1ec607d465", + "destination_type": "A", + "test": { + "type": "eq", + "test": "3" + }, + "label": null + }, + { + "uuid": "7b25509c-94c4-45c1-86cf-2995916ac825", + "category": { + "base": "Other" + }, + "destination": "0bfb7527-c6e9-4452-b780-6755d2041144", + "destination_type": "A", + "test": { + "type": "true" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "wait_message", + "response_type": "", + "operand": "@step.value", + "config": {} + }, + { + "uuid": "9baa6aaf-61bf-4686-8059-1c373a43e5a6", + "x": 51, + "y": 659, + "label": "Response 2", + "rules": [ + { + "uuid": "049a4d45-d50d-468a-ae61-9e55c5dda0ea", + "category": { + "base": "Completed" + }, + "destination": "54c31965-d727-4b0a-a37e-6231551343dc", + "destination_type": "R", + "test": { + "type": "subflow", + "exit_type": "completed" + }, + "label": null + }, + { + "uuid": "101dea88-83bf-4219-973b-d11de45589ae", + "category": { + "base": "Expired" + }, + "destination": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", + "destination_type": "A", + "test": { + "type": "subflow", + "exit_type": "expired" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "subflow", + "response_type": "", + "operand": "@step.value", + "config": { + "flow": { + "name": "Invalid1", + "uuid": "ad40071e-a665-4df3-af14-0bc0fe589244" + } + } + }, + { + "uuid": "54c31965-d727-4b0a-a37e-6231551343dc", + "x": 36, + "y": 875, + "label": "Response 3", + "rules": [ + { + "uuid": "6a9a30cc-0400-4148-b760-ff342d7ef496", + "category": { + "base": "Completed" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "subflow", + "exit_type": "completed" + }, + "label": null + }, + { + "uuid": "e9e0ad89-6d63-4744-ba35-8042af052a95", + "category": { + "base": "Expired" + }, + "destination": null, + "destination_type": null, + "test": { + "type": "subflow", + "exit_type": "expired" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "subflow", + "response_type": "", + "operand": "@step.value", + "config": { + "flow": { + "name": "Valid1", + "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3" + } + } + }, + { + "uuid": "861c6312-b2a8-4586-8688-6621d7065497", + "x": 409, + "y": 863, + "label": "Response 4", + "rules": [ + { + "uuid": "451cb651-7a59-4d2b-bfe5-753643ad7db2", + "category": { + "base": "1" + }, + "destination": "ed891a32-6e6d-49b1-88d0-399d2002bce0", + "destination_type": "A", + "test": { + "type": "between", + "min": "0", + "max": "0.5" + }, + "label": null + }, + { + "uuid": "39c05550-91a3-4497-9595-2478b5ab6ae4", + "category": { + "base": "2" + }, + "destination": "a750fe69-167b-4ae3-af72-7aae4c2d8b1a", + "destination_type": "A", + "test": { + "type": "between", + "min": "0.5", + "max": "1" + }, + "label": null + } + ], + "finished_key": null, + "ruleset_type": "random", + "response_type": "", + "operand": "@(RAND())", + "config": {} + } + ], + "base_language": "base", + "flow_type": "M", + "version": "11.8", + "metadata": { + "name": "Master", + "saved_on": "2018-12-17T13:54:21.769976Z", + "revision": 56, + "uuid": "8d3f72ef-60b9-4902-b792-d664df502f3f", + "expires": 10080 + } + } + ], + "campaigns": [], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/migrate_to_9.json b/media/test_flows/legacy/migrations/migrate_to_9.json new file mode 100644 index 00000000000..908081987d9 --- /dev/null +++ b/media/test_flows/legacy/migrations/migrate_to_9.json @@ -0,0 +1,148 @@ +{ + "campaigns": [ + { + "events": [ + { + "event_type": "M", + "relative_to": { + "id": 1134, + "key": "next_appointment", + "label": "Next Show" + }, + "flow": { + "name": "Single Message", + "id": 2814 + }, + "offset": -1, + "delivery_hour": -1, + "message": "Hi there, your next show is @contact.next_show. Don't miss it!", + "id": 9959, + "unit": "H" + } + ], + "group": { + "name": "Pending Appointments", + "id": 2308 + }, + "id": 405, + "name": "Appointment Schedule" + } + ], + "version": 9, + "site": "https://app.rapidpro.io", + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "a04f3046-e053-444f-b018-eff019766ad9", + "uuid": "e4a03298-dd43-4afb-b185-2782fc36a006", + "actions": [ + { + "msg": { + "base": "Hi there!" + }, + "type": "reply" + }, + { + "uuid": "c756af8f-4480-4a91-875d-c0600597c0ae", + "contacts": [ + { + "id": contact_id, + "name": "Trey Anastasio" + } + ], + "groups": [], + "variables": [], + "msg": { + "base": "You're phantastic" + }, + "action": "GET", + "type": "send" + }, + { + "labels": [ + { + "name": "this label", + "id": label_id + } + ], + "type": "add_label" + }, + { + "field": "concat_test", + "type": "save", + "value": "@(CONCAT(extra.flow.divided, extra.flow.sky))", + "label": "Concat Test" + }, + { + "field": "normal_test", + "type": "save", + "value": "@extra.contact.name", + "label": "Normal Test" + } + ] + }, + { + "y": 142, + "x": 166, + "destination": null, + "uuid": "a04f3046-e053-444f-b018-eff019766ad9", + "actions": [ + { + "type": "add_group", + "groups": [ + { + "name": "Survey Audience", + "id": group_id + }, + "@(\"Phans\")", + "Survey Audience" + ] + }, + { + "type": "del_group", + "groups": [ + { + "name": "Unsatisfied Customers", + "id": group_id + } + ] + }, + { + "name": "Test flow", + "contacts": [], + "variables": [ + { + "id": "@contact.tel_e164" + } + ], + "groups": [], + "type": "trigger-flow", + "id": start_flow_id + }, + { + "type": "flow", + "name": "Parent Flow", + "id": start_flow_id + } + ] + } + ], + "version": 9, + "flow_type": "F", + "entry": "e4a03298-dd43-4afb-b185-2782fc36a006", + "rule_sets": [], + "metadata": { + "expires": 10080, + "revision": 11, + "id": previous_flow_id, + "name": "Migrate to 9", + "saved_on": "2016-06-22T15:05:12.074490Z" + } + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/multi_language_flow.json b/media/test_flows/legacy/migrations/multi_language_flow.json new file mode 100644 index 00000000000..d4fadda4704 --- /dev/null +++ b/media/test_flows/legacy/migrations/multi_language_flow.json @@ -0,0 +1,176 @@ +{ + "version": 4, + "flows": [ + { + "definition": { + "base_language": "eng", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", + "uuid": "d563e7ca-aa0f-4615-ba8c-eab5e13ff4bf", + "actions": [ + { + "msg": { + "spa": "\u00a1Hola amigo! \u00bfCu\u00e1l es tu color favorito?", + "eng": "Hello friend! What is your favorite color?" + }, + "type": "reply" + } + ] + }, + { + "y": 266, + "x": 351, + "destination": null, + "uuid": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", + "actions": [ + { + "msg": { + "spa": "\u00a1Gracias! Me gusta @flow.color.", + "eng": "Thank you! I like @flow.color." + }, + "type": "reply" + }, + { + "msg": { + "eng": "This message was not translated." + }, + "type": "reply" + } + ] + }, + { + "y": 179, + "x": 683, + "destination": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", + "uuid": "6ea52610-838c-4f64-8e24-99754135da67", + "actions": [ + { + "msg": { + "spa": "Por favor, una vez m\u00e1s", + "eng": "Please try again." + }, + "type": "reply" + } + ] + } + ], + "last_saved": "2015-02-19T05:55:32.232993Z", + "entry": "d563e7ca-aa0f-4615-ba8c-eab5e13ff4bf", + "rule_sets": [ + { + "uuid": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", + "webhook_action": null, + "rules": [ + { + "test": { + "test": { + "spa": "rojo", + "eng": "Red" + }, + "base": "Red", + "type": "contains_any" + }, + "category": { + "spa": "Rojo", + "base": "Red", + "eng": "Red" + }, + "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", + "config": { + "type": "contains_any", + "verbose_name": "has any of these words", + "name": "Contains any", + "localized": true, + "operands": 1 + }, + "uuid": "de555b2c-2616-49ff-8564-409a01b0bd79" + }, + { + "test": { + "test": { + "spa": "verde", + "eng": "Green" + }, + "base": "Green", + "type": "contains_any" + }, + "category": { + "spa": "Verde", + "base": "Green", + "eng": "Green" + }, + "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", + "config": { + "type": "contains_any", + "verbose_name": "has any of these words", + "name": "Contains any", + "localized": true, + "operands": 1 + }, + "uuid": "e09c7ad3-46c8-4024-9fcf-8a0d26d97d6a" + }, + { + "test": { + "test": { + "spa": "azul", + "eng": "Blue" + }, + "base": "Blue", + "type": "contains_any" + }, + "category": { + "spa": "Azul", + "base": "Blue", + "eng": "Blue" + }, + "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", + "config": { + "type": "contains_any", + "verbose_name": "has any of these words", + "name": "Contains any", + "localized": true, + "operands": 1 + }, + "uuid": "aafd9e60-4d74-40cb-a923-3501560cb5c1" + }, + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "spa": "Otro", + "base": "Other", + "eng": "Other" + }, + "destination": "6ea52610-838c-4f64-8e24-99754135da67", + "config": { + "type": "true", + "verbose_name": "contains anything", + "name": "Other", + "operands": 0 + }, + "uuid": "2263684a-0354-448e-8213-c57644e91798" + } + ], + "webhook": null, + "label": "Color", + "operand": "@step.value", + "finished_key": null, + "response_type": "C", + "y": 132, + "x": 242 + } + ], + "metadata": {} + }, + "id": 1400, + "flow_type": "F", + "name": "Multi Language Flow" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/old_expressions.json b/media/test_flows/legacy/migrations/old_expressions.json new file mode 100644 index 00000000000..17ef733d82e --- /dev/null +++ b/media/test_flows/legacy/migrations/old_expressions.json @@ -0,0 +1,118 @@ +{ + "version": 7, + "flows": [ + { + "definition": { + "base_language": "eng", + "action_sets": [ + { + "y": 0, + "x": 100, + "destination": "a32d0ebb-57aa-452e-bd8d-ae5febee4440", + "uuid": "a26285b1-134b-421b-9853-af0f26d13777", + "actions": [ + { + "msg": { + "eng": "Hi @contact.name|upper_case. Today is =(date.now)" + }, + "type": "reply" + } + ] + }, + { + "y": 350, + "x": 164, + "destination": null, + "uuid": "054d9e01-8e68-4f6d-9cf3-44407256670e", + "actions": [ + { + "type": "add_group", + "groups": [ + "=flow.response_1.category" + ] + }, + { + "msg": { + "eng": "Was @contact.name|lower_case|title_case." + }, + "variables": [ + { + "id": "=flow.response_1.category" + } + ], + "type": "send", + "groups": [], + "contacts": [] + } + ] + } + ], + "last_saved": "2015-09-23T07:54:10.928652Z", + "entry": "a26285b1-134b-421b-9853-af0f26d13777", + "rule_sets": [ + { + "uuid": "a32d0ebb-57aa-452e-bd8d-ae5febee4440", + "webhook_action": "GET", + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "eng": "All Responses" + }, + "destination": "028c71a3-0696-4d98-8ff3-0dc700811124", + "uuid": "bf879f78-aff8-4c64-9326-e92f677af5cf", + "destination_type": "R" + } + ], + "webhook": "http://example.com/query.php?contact=@contact.name|upper_case", + "ruleset_type": "webhook", + "label": "Response 1", + "operand": "=(step.value)", + "finished_key": null, + "response_type": "", + "y": 134, + "x": 237, + "config": {} + }, + { + "uuid": "028c71a3-0696-4d98-8ff3-0dc700811124", + "webhook_action": null, + "rules": [ + { + "test": { + "test": "true", + "type": "true" + }, + "category": { + "eng": "All Responses" + }, + "destination": "054d9e01-8e68-4f6d-9cf3-44407256670e", + "uuid": "35ba932c-d45a-4cf5-bd0b-41fd9b80cc27", + "destination_type": "A" + } + ], + "webhook": null, + "ruleset_type": "expression", + "label": "Response 2", + "operand": "@step.value|time_delta:\"3\"", + "finished_key": null, + "response_type": "", + "y": 240, + "x": 203, + "config": {} + } + ], + "type": "F", + "metadata": {} + }, + "expires": 10080, + "id": 31427, + "flow_type": "F", + "name": "Old Expressions" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/single_message_bad_localization.json b/media/test_flows/legacy/migrations/single_message_bad_localization.json new file mode 100644 index 00000000000..8c13b95e015 --- /dev/null +++ b/media/test_flows/legacy/migrations/single_message_bad_localization.json @@ -0,0 +1,25 @@ +{ + "version": 10, + "flows": [ + { + "base_language": "eng", + "rule_sets": [], + "action_sets": [ + { + "y": 0, + "x": 100, + "uuid": "37fe93f8-edf5-40f3-b029-3b391fa528d0", + "actions": [ + { + "msg": "Campaign Message 12", + "type": "reply", + "uuid": "9bdb1aab-e42e-4585-8395-6504c4a683ed" + } + ] + } + ], + "entry": "37fe93f8-edf5-40f3-b029-3b391fa528d0" + } + ], + "triggers": [] +} \ No newline at end of file diff --git a/media/test_flows/legacy/migrations/type_flow.json b/media/test_flows/legacy/migrations/type_flow.json new file mode 100644 index 00000000000..ed3976e9958 --- /dev/null +++ b/media/test_flows/legacy/migrations/type_flow.json @@ -0,0 +1,394 @@ +{ + "campaigns": [], + "version": "10.1", + "site": "https://app.rapidpro.io", + "flows": [ + { + "base_language": "base", + "action_sets": [ + { + "y": 0, + "x": 92, + "destination": "9c941ba5-e4df-47e0-9a4f-594986ae1b1a", + "uuid": "bc3da5f2-6fe5-41f1-ac0e-ec2701189ef2", + "actions": [ + { + "msg": { + "base": "Hey @contact.nickname, you joined on @contact.joined_on in @contact.district." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "4dc98ff5-8d86-45f5-8336-8949029e893e" + }, + { + "msg": { + "base": "It's @date. The time is @date.now on @date.today." + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "058e5d4a-3447-49d9-a033-ebe3010b5875" + }, + { + "msg": { + "base": "Send text" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "9568e1c8-04f2-45ef-a477-4521d19bfaf6" + } + ] + }, + { + "y": 257, + "x": 78, + "destination": "a4904b78-08b8-42fd-9479-27bcb1764bc4", + "uuid": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", + "actions": [ + { + "msg": { + "base": "You said @flow.text at @flow.text.time. Send date" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "6f4fc213-3037-49e5-ac45-b956c48fd546" + } + ] + }, + { + "y": 540, + "x": 95, + "destination": "9994619b-e68d-4c94-90d6-af19fb944f7d", + "uuid": "9bbdc63c-4385-44e1-b573-a127f50d3d34", + "actions": [ + { + "msg": { + "base": "You said @flow.date which was in category @flow.date.category Send number" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "7177ef30-33ca-4b25-8af7-3213e0483b56" + } + ] + }, + { + "y": 825, + "x": 96, + "destination": "01cc820b-c516-4e68-8903-aa69866b11b6", + "uuid": "a4a37023-de22-4ac4-b431-da2a333c93cd", + "actions": [ + { + "msg": { + "base": "You said @flow.number. Send state" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "34d622bc-e2ad-44aa-b047-cfb38e2dc2cc" + } + ] + }, + { + "y": 1084, + "x": 94, + "destination": "9769918c-8ca4-4ec5-8b5b-bf94cc6746a9", + "uuid": "7e8dfcd5-6510-4060-9608-2c8faa3a8e0a", + "actions": [ + { + "msg": { + "base": "You said @flow.state which was in category @flow.state.category. Send district" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "a4428571-9b86-49b8-97e1-6ffee3cddbaa" + } + ] + }, + { + "y": 1460, + "x": 73, + "destination": "ea2244de-7b23-4fbb-8f99-38cde3100de8", + "uuid": "605e2fe7-321a-4cce-b97b-877d75bd3b12", + "actions": [ + { + "msg": { + "base": "You said @flow.district. Send ward" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "5f8eb5aa-249b-4718-a502-8406dd0ae418" + } + ] + }, + { + "y": 1214, + "x": 284, + "destination": "498b1953-02f1-47dd-b9cb-1b51913e348f", + "uuid": "9769918c-8ca4-4ec5-8b5b-bf94cc6746a9", + "actions": [ + { + "msg": { + "base": "You said @flow.ward.", + "fre": "Tu as dit @flow.ward" + }, + "media": {}, + "send_all": false, + "type": "reply", + "uuid": "b95b88c8-a85c-4bac-931d-310d678c286a" + }, + { + "lang": "fre", + "type": "lang", + "name": "French", + "uuid": "56a4bca5-b9e5-4d04-883c-ca65d7c4d538" + } + ] + } + ], + "version": "10.1", + "flow_type": "F", + "entry": "bc3da5f2-6fe5-41f1-ac0e-ec2701189ef2", + "rule_sets": [ + { + "uuid": "9c941ba5-e4df-47e0-9a4f-594986ae1b1a", + "rules": [ + { + "category": { + "base": "All Responses" + }, + "uuid": "a4682f52-7869-4e64-bf9f-8d2c0a341d19", + "destination": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", + "label": null, + "destination_type": "A", + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "Text", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 146, + "x": 265, + "config": {} + }, + { + "uuid": "a4904b78-08b8-42fd-9479-27bcb1764bc4", + "rules": [ + { + "category": { + "base": "is a date" + }, + "uuid": "e410616b-b5cd-4fd1-af42-9c6b6c9fe282", + "destination": "9bbdc63c-4385-44e1-b573-a127f50d3d34", + "label": null, + "destination_type": "A", + "test": { + "type": "date" + } + }, + { + "category": { + "base": "Other" + }, + "uuid": "a720d0b1-0686-47be-a306-1543e470c6de", + "destination": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", + "label": null, + "destination_type": "A", + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "Date", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 391, + "x": 273, + "config": {} + }, + { + "uuid": "9994619b-e68d-4c94-90d6-af19fb944f7d", + "rules": [ + { + "category": { + "base": "numeric" + }, + "uuid": "c4881d22-57aa-4964-abbc-aaf26b875614", + "destination": "a4a37023-de22-4ac4-b431-da2a333c93cd", + "label": null, + "destination_type": "A", + "test": { + "type": "number" + } + }, + { + "category": { + "base": "Other" + }, + "uuid": "6cd3fb0c-070d-4060-bafc-badaebe5134e", + "destination": null, + "label": null, + "destination_type": null, + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "Number", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 679, + "x": 267, + "config": {} + }, + { + "uuid": "01cc820b-c516-4e68-8903-aa69866b11b6", + "rules": [ + { + "category": { + "base": "state" + }, + "uuid": "4ef398b1-d3f1-4023-b608-8803cc05dd20", + "destination": "7e8dfcd5-6510-4060-9608-2c8faa3a8e0a", + "label": null, + "destination_type": "A", + "test": { + "type": "state" + } + }, + { + "category": { + "base": "Other" + }, + "uuid": "38a4583c-cf73-454c-80e5-09910cf92f4b", + "destination": null, + "label": null, + "destination_type": null, + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "State", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 956, + "x": 271, + "config": {} + }, + { + "uuid": "498b1953-02f1-47dd-b9cb-1b51913e348f", + "rules": [ + { + "category": { + "base": "district", + "fre": "le district" + }, + "uuid": "47147597-00c6-44bc-95d2-bebec9f1a45b", + "destination": "605e2fe7-321a-4cce-b97b-877d75bd3b12", + "label": null, + "destination_type": "A", + "test": { + "test": "@flow.state", + "type": "district" + } + }, + { + "category": { + "base": "Other" + }, + "uuid": "1145c620-2512-4228-b561-80024bbd91ee", + "destination": null, + "label": null, + "destination_type": null, + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "District", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 1355, + "x": 266, + "config": {} + }, + { + "uuid": "ea2244de-7b23-4fbb-8f99-38cde3100de8", + "rules": [ + { + "category": { + "base": "ward" + }, + "uuid": "b5159826-a55a-4803-a656-64d47803e8bf", + "destination": null, + "label": null, + "destination_type": null, + "test": { + "state": "@flow.state.", + "type": "ward", + "district": "@flow.district" + } + }, + { + "category": { + "base": "Other" + }, + "uuid": "c1aa2a53-4d85-4fdd-953e-7e24b06cc7ea", + "destination": null, + "label": null, + "destination_type": null, + "test": { + "type": "true" + } + } + ], + "ruleset_type": "wait_message", + "label": "Ward", + "operand": "@step.value", + "finished_key": null, + "response_type": "", + "y": 1584, + "x": 268, + "config": {} + } + ], + "metadata": { + "expires": 10080, + "revision": 19, + "uuid": "d7468d97-b8d7-482e-a09c-d0bfe839c555", + "name": "Type Flow", + "saved_on": "2017-10-30T19:38:39.814935Z" + } + } + ], + "triggers": [ + { + "trigger_type": "K", + "flow": { + "name": "Type Flow", + "uuid": "d7468d97-b8d7-482e-a09c-d0bfe839c555" + }, + "groups": [], + "keyword": "types", + "channel": null + } + ] +} \ No newline at end of file diff --git a/media/test_flows/malformed_groups.json b/media/test_flows/malformed_groups.json deleted file mode 100644 index 44ca9c05b60..00000000000 --- a/media/test_flows/malformed_groups.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "version": 4, - "flows": [ - { - "definition": { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "a6676605-332a-4309-a8b8-79b33e73adcd", - "actions": [ - { - "type": "add_group", - "uuid": "5f5a2aac-f4f4-4b47-af6e-186f6dafb9f0", - "group": {"name": "< 25", "id": 15572} - }, - { - "type": "del_group", - "uuid": "2a385c5b-e27c-43ac-bbc6-49653fede421", - "group": {"id": 15573} - } - ] - } - ], - "rule_sets": [], - "metadata": { - "uuid": "77ae372d-a937-4d9b-a703-cc1c75c4c6f1", - "notes": [], - "expires": 720, - "name": "Bad Mojo", - "revision": 1, - "saved_on": "2017-08-16T23:10:18.579169Z" - } - }, - "version": 4, - "flow_type": "F", - "name": "Bad Mojo", - "entry": "a6676605-332a-4309-a8b8-79b33e73adcd" - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/malformed_single_message.json b/media/test_flows/malformed_single_message.json deleted file mode 100644 index 9c753ed3a9e..00000000000 --- a/media/test_flows/malformed_single_message.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "campaigns": [], - "triggers": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "name": "Single Message Flow", - "id": -1, - "uuid": "f467561a-3b95-4a4a-94bc-97bc6b4268c0", - "definition": { - "entry": "2d702ba6-461e-442c-96bc-2b8a87c9ceca", - "action_sets": [ - { - "x": 0, - "y": 0, - "uuid": "2d702ba6-461e-442c-96bc-2b8a87c9ceca", - "destination": null, - "actions":[ - { - "msg": "Single message text", - "type": "reply" - } - ] - } - ], - "rulesets": [] - } - } - ] -} diff --git a/media/test_flows/migrate_to_11_0.json b/media/test_flows/migrate_to_11_0.json deleted file mode 100644 index 62a9bb2ce6d..00000000000 --- a/media/test_flows/migrate_to_11_0.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "version": "10.4", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "d96947d0-f975-47ee-be7d-3dfe68a52703", - "action_sets": [ - { - "uuid": "d96947d0-f975-47ee-be7d-3dfe68a52703", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "msg": { - "base": { - "base": "@date Something went wrong once. I shouldn't be a dict inside a dict." - } - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "1ee58c31-3504-49d3-914b-324d484fed1d" - } - ], - "exit_uuid": "f2566f59-5d36-4de7-8581-dcc5de7e8340" - } - ], - "rule_sets": [], - "base_language": "base", - "flow_type": "M", - "version": "10.4", - "metadata": { - "name": "Migrate to 11.0", - "saved_on": "2017-11-15T22:56:36.039558Z", - "revision": 5, - "uuid": "5a8deb77-23b8-46ee-a775-48ed32742e31", - "expires": 720 - } - } - ] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_10.json b/media/test_flows/migrate_to_11_10.json deleted file mode 100644 index 8f3ed9b8ea0..00000000000 --- a/media/test_flows/migrate_to_11_10.json +++ /dev/null @@ -1,239 +0,0 @@ -{ - "version": "11.9", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "bd6ca3fc-0505-4ea6-a1c6-60d0296a7db0", - "action_sets": [ - { - "uuid": "bd6ca3fc-0505-4ea6-a1c6-60d0296a7db0", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "say", - "uuid": "0738e369-279d-4e2f-a14c-08714b0d6f74", - "msg": { - "eng": "Hi there this is an IVR flow.. how did you get here?" - }, - "recording": null - } - ], - "exit_uuid": "0e78ff3d-8307-4c0e-a3b0-af4019930835" - } - ], - "rule_sets": [], - "base_language": "eng", - "flow_type": "V", - "version": "11.9", - "metadata": { - "name": "Migrate to 11.10 IVR Child", - "saved_on": "2019-01-25T21:14:37.475679Z", - "revision": 2, - "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5", - "expires": 5, - "ivr_retry": 60, - "ivr_retry_failed_events": false - } - }, - { - "entry": "920ce708-31d3-4870-804f-190fb37b9b8c", - "action_sets": [ - { - "uuid": "920ce708-31d3-4870-804f-190fb37b9b8c", - "x": 59, - "y": 0, - "destination": "90363d00-a669-4d84-ab57-eb27bf9c3284", - "actions": [ - { - "type": "reply", - "uuid": "3071cb5d-4caf-4a15-87c7-daae4a436ee7", - "msg": { - "eng": "hi" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "f646245c-ac46-4565-9215-cef53c34da09" - }, - { - "uuid": "bbd1c25f-ab01-4539-8f3e-b0ca18f366f4", - "x": 48, - "y": 345, - "destination": null, - "actions": [ - { - "type": "flow", - "uuid": "edb70527-47fa-463e-8318-359254b1bc0e", - "flow": { - "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5", - "name": "Migrate to 11.10 IVR Child" - } - } - ], - "exit_uuid": "330f0f9a-154b-49de-9ff9-a7891d4a11af" - }, - { - "uuid": "62e29de4-d85e-459d-ad38-220d1048b714", - "x": 412, - "y": 348, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "41ed5ba3-41c7-4e6f-b394-d451204bcf0f", - "msg": { - "eng": "Expired" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "0040f402-a6ac-4de4-8775-a4938b9011b8" - } - ], - "rule_sets": [ - { - "uuid": "90363d00-a669-4d84-ab57-eb27bf9c3284", - "x": 218, - "y": 82, - "label": "Response 1", - "rules": [ - { - "uuid": "4c6ac0ad-e8a8-4b1e-b958-ef2f22728821", - "category": { - "eng": "Completed" - }, - "destination": "e5dae061-2c94-45ae-a3bb-4822989e636a", - "destination_type": "R", - "test": { - "type": "subflow", - "exit_type": "completed" - }, - "label": null - }, - { - "uuid": "288dfab6-5171-4cf0-92af-e73af44dbeee", - "category": { - "eng": "Expired" - }, - "destination": "e5dae061-2c94-45ae-a3bb-4822989e636a", - "destination_type": "R", - "test": { - "type": "subflow", - "exit_type": "expired" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "subflow", - "response_type": "", - "operand": "@step.value", - "config": { - "flow": { - "name": "Migrate to 11.10 SMS Child", - "uuid": "a492288a-7b26-4507-b8db-173d28b83ad0" - } - } - }, - { - "uuid": "e5dae061-2c94-45ae-a3bb-4822989e636a", - "x": 218, - "y": 228, - "label": "Response 2", - "rules": [ - { - "uuid": "b9f763d2-82d7-4334-8ed8-806b803d32c1", - "category": { - "eng": "Completed" - }, - "destination": "bbd1c25f-ab01-4539-8f3e-b0ca18f366f4", - "destination_type": "A", - "test": { - "type": "subflow", - "exit_type": "completed" - }, - "label": null - }, - { - "uuid": "54b51a30-8c52-49aa-afc1-24d827a17a8d", - "category": { - "eng": "Expired" - }, - "destination": "62e29de4-d85e-459d-ad38-220d1048b714", - "destination_type": "A", - "test": { - "type": "subflow", - "exit_type": "expired" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "subflow", - "response_type": "", - "operand": "@step.value", - "config": { - "flow": { - "name": "Migrate to 11.10 IVR Child", - "uuid": "5331c09c-2bd6-47a5-ac0d-973caf9d4cb5" - } - } - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.9", - "metadata": { - "name": "Migrate to 11.10 Parent", - "saved_on": "2019-01-28T19:51:28.310305Z", - "revision": 52, - "uuid": "880cea73-fab6-4353-9db2-bf2e16067941", - "expires": 10080 - } - }, - { - "entry": "762fb8ad-1ec5-4246-a577-e08f0fe497e5", - "action_sets": [ - { - "uuid": "762fb8ad-1ec5-4246-a577-e08f0fe497e5", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "69a7f227-5f44-4ddc-80e1-b9dd855868eb", - "msg": { - "eng": "I'm just a regular honest messaging flow" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "8ec7a5ed-675c-4102-b211-ea10258ac5f9" - } - ], - "rule_sets": [], - "base_language": "eng", - "flow_type": "M", - "version": "11.9", - "metadata": { - "name": "Migrate to 11.10 SMS Child", - "saved_on": "2019-01-28T19:03:29.579743Z", - "revision": 2, - "uuid": "a492288a-7b26-4507-b8db-173d28b83ad0", - "expires": 10080, - "ivr_retry_failed_events": null - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_11.json b/media/test_flows/migrate_to_11_11.json deleted file mode 100644 index 9a41dc555db..00000000000 --- a/media/test_flows/migrate_to_11_11.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "version": "11.10", - "site": "https://textit.in", - "flows": [ - { - "entry": "22505d46-43c5-42ba-975e-725c01ea440f", - "action_sets": [ - { - "uuid": "22505d46-43c5-42ba-975e-725c01ea440f", - "x": 100, - "y": 0, - "destination": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", - "actions": [ - { - "type": "reply", - "uuid": "27dfd8ac-55c5-49c9-88e3-3fb84a9894ff", - "msg": { - "eng": "Hey" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "6e2b09ec-3cc0-4ee6-ae7b-b76bad3ab6d3" - }, - { - "uuid": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", - "x": 95, - "y": 101, - "destination": "78c20ee4-94bd-45e6-8510-8e602568fb6e", - "actions": [ - { - "type": "add_label", - "uuid": "bc82c11d-7654-44e4-966c-fb39e2851df0", - "labels": [ - { - "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", - "name": "Hello" - } - ] - } - ], - "exit_uuid": "84bf44a1-13fd-44cb-8014-d6feb06e010f" - }, - { - "uuid": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", - "x": 146, - "y": 358, - "destination": null, - "actions": [ - { - "type": "add_label", - "uuid": "910bf3b5-951f-47a8-93df-11a6eac8bf0f", - "labels": [ - { - "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", - "name": "Hello" - } - ] - } - ], - "exit_uuid": "6d579c28-9f3f-4584-bd2e-74009612fdbb" - } - ], - "rule_sets": [ - { - "uuid": "78c20ee4-94bd-45e6-8510-8e602568fb6e", - "x": 85, - "y": 219, - "label": "Response 1", - "rules": [ - { - "uuid": "33438bbf-49bd-4468-9a74-bbd7e1f58f57", - "category": { - "eng": "All Responses" - }, - "destination": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.10", - "metadata": { - "name": "Add Label", - "saved_on": "2019-02-12T09:23:05.746930Z", - "revision": 7, - "uuid": "e9b5b8ba-43f4-4bc2-a790-811ee1cfe392", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_12.json b/media/test_flows/migrate_to_11_12.json deleted file mode 100644 index 142c3151dbe..00000000000 --- a/media/test_flows/migrate_to_11_12.json +++ /dev/null @@ -1,197 +0,0 @@ -{ - "version": "11.12", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "456b7f83-a96b-4f17-aa0a-116a30ee0d52", - "action_sets": [ - { - "uuid": "456b7f83-a96b-4f17-aa0a-116a30ee0d52", - "x": 100, - "y": 0, - "destination": "cfea15b5-3761-41d0-ad3e-33df7a9b835a", - "actions": [ - { - "type": "channel", - "uuid": "338300e8-b433-4372-8a12-87a0f543ee8a", - "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", - "name": "Android: 1234" - } - ], - "exit_uuid": "6fb525e7-bc24-4358-acde-f2d712b28f2b" - }, - { - "uuid": "cfea15b5-3761-41d0-ad3e-33df7a9b835a", - "x": 114, - "y": 156, - "destination": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", - "actions": [ - { - "type": "reply", - "uuid": "bbdd28f0-824f-41b4-af25-5d6f9a4afefb", - "msg": { - "base": "Hey there, Yes or No?" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "902db0bc-f6a7-45d2-93b2-f47f3af1261e" - }, - { - "uuid": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", - "x": 181, - "y": 452, - "destination": "85d88c16-fafe-4b8e-8e58-a6dc6e1e0e77", - "actions": [ - { - "type": "channel", - "uuid": "437d71a2-bb17-4e71-bef7-ad6b58f0eb85", - "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", - "name": "Android: 1234" - } - ], - "exit_uuid": "cec84721-7f8f-43c3-9af2-4d5d6a15f9de" - }, - { - "uuid": "76e091fe-62a5-4786-9465-7c1fb2446694", - "x": 460, - "y": 117, - "destination": "ef9afd2d-d106-4168-a104-20ddc14f9444", - "actions": [ - { - "type": "reply", - "uuid": "f7d12748-440e-4ef1-97d4-8a9efddf4454", - "msg": { - "base": "Yo, What? Repeat Yes or No" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "f5ce0ce5-8023-4b8d-b635-762a2c18726f" - }, - { - "uuid": "9eef8677-8598-4e87-9e21-3ad245d87aee", - "x": 193, - "y": 633, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "1d3ec932-6b6f-45c2-b4d6-9a0e07721686", - "msg": { - "base": "Bye" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "839dd7c4-64b9-428f-b1d0-c386f493fc4f" - }, - { - "uuid": "85d88c16-fafe-4b8e-8e58-a6dc6e1e0e77", - "x": 173, - "y": 550, - "destination": "9eef8677-8598-4e87-9e21-3ad245d87aee", - "actions": [ - { - "type": "channel", - "uuid": "0afa546d-8308-41c2-a70c-979846108bec", - "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", - "name": "Android: 1234" - } - ], - "exit_uuid": "835a5ca9-d518-452f-865c-ca8e5cde4777" - }, - { - "uuid": "ef9afd2d-d106-4168-a104-20ddc14f9444", - "x": 501, - "y": 242, - "destination": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", - "actions": [ - { - "type": "channel", - "uuid": "28d63382-40ea-4741-ba3a-2930348fab0e", - "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", - "name": "Android: 1234" - } - ], - "exit_uuid": "be8ca9a5-0f61-4c9d-93e4-02aa6bb27afc" - } - ], - "rule_sets": [ - { - "uuid": "3bb1fb6d-f0a3-4ec7-abba-cc5fac4c6a9d", - "x": 134, - "y": 315, - "label": "Response 1", - "rules": [ - { - "uuid": "2924a1d0-be47-4f8e-aefb-f7ff3a563a43", - "category": { - "base": "Yes" - }, - "destination": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "base": "Yes" - } - }, - "label": null - }, - { - "uuid": "0107f9e4-b46c-40d7-b25b-058cac3a167e", - "category": { - "base": "No" - }, - "destination": "af882e66-9ae2-4bc1-9af7-c8c2e7373766", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "base": "No" - } - }, - "label": null - }, - { - "uuid": "ad81cc6d-1973-4eed-b97d-6edd9ebdeedc", - "category": { - "base": "Other" - }, - "destination": "76e091fe-62a5-4786-9465-7c1fb2446694", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "base", - "flow_type": "M", - "version": "11.12", - "metadata": { - "name": "channels", - "saved_on": "2019-02-26T21:16:32.055957Z", - "revision": 24, - "uuid": "e5fdf453-428f-4da1-9703-0decdf7cf6f9", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_12_one_node.json b/media/test_flows/migrate_to_11_12_one_node.json deleted file mode 100644 index 9bf4eedd68d..00000000000 --- a/media/test_flows/migrate_to_11_12_one_node.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "version": "11.11", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "b0b6559d-e5bd-4deb-a4ab-9e5f04001dd4", - "action_sets": [ - { - "uuid": "b0b6559d-e5bd-4deb-a4ab-9e5f04001dd4", - "x": 100, - "y": 0, - "actions": [ - { - "type": "channel", - "uuid": "4b34b85d-da31-40c9-af65-6d76ca54b1b5", - "channel": "228cc824-6740-482a-ac2f-4f08ca449e06", - "name": "Android: 1234" - } - ], - "exit_uuid": "be37f250-f992-45e0-97fd-a3c0f57584dc" - } - ], - "rule_sets": [], - "base_language": "base", - "flow_type": "M", - "version": "11.11", - "metadata": { - "name": "channel", - "saved_on": "2019-02-28T08:55:17.275670Z", - "revision": 2, - "uuid": "8a8612bc-ff3a-45ea-b7a5-2673ce901cd9", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_12_other_org.json b/media/test_flows/migrate_to_11_12_other_org.json deleted file mode 100644 index 7deb686c5bd..00000000000 --- a/media/test_flows/migrate_to_11_12_other_org.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "version": "11.11", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "a1c00b3e-a904-4085-851d-e5e386d728b8", - "action_sets": [{ - "uuid": "a1c00b3e-a904-4085-851d-e5e386d728b8", - "x": 124, - "y": 16, - "actions": [{ - "type": "channel", - "channel": "CHANNEL-UUID", - "uuid": "84889e4d-e7e8-4415-9ad9-db27d9972558", - "name": "Not Ours" - }], - "exit_uuid": "eada09b7-7136-4f24-a34f-62ca7b404423" - }], - "rule_sets": [], - "base_language": "eng", - "flow_type": "M", - "version": "11.11", - "metadata": { - "name": "Other Org Channel", - "saved_on": "2019-02-25T20:36:14.155001Z", - "revision": 19, - "uuid": "bb8ca54b-7dcb-431f-bd86-ec3082b63469", - "expires": 43200, - "ivr_retry_failed_events": null, - "notes": [] - }, - "type": "M" -} - ] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_3.json b/media/test_flows/migrate_to_11_3.json deleted file mode 100644 index ea6a22c7fc5..00000000000 --- a/media/test_flows/migrate_to_11_3.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "version": "11.2", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", - "action_sets": [ - { - "uuid": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", - "x": 412, - "y": 814, - "destination": null, - "actions": [ - { - "type": "api", - "uuid": "9b46779a-f680-450f-8f3c-005f3b7efccd", - "webhook": "http://example.com/?thing=@flow.response_1&foo=bar", - "action": "POST", - "webhook_headers": [] - } - ], - "exit_uuid": "25d8d2ae-ea82-4214-9561-42e0bf420a93" - } - ], - "rule_sets": [ - { - "uuid": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", - "x": 100, - "y": 0, - "label": "Response 1", - "rules": [ - { - "uuid": "c799def9-345b-46f9-a838-a59191cdb181", - "category": { - "eng": "Success" - }, - "destination": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", - "destination_type": "R", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "1ace9344-3053-4dc2-aced-9a6e3c8a6e9d", - "category": { - "eng": "Failure" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "webhook", - "response_type": "", - "operand": "@step.value", - "config": { - "webhook": "http://example.com/webhook1", - "webhook_action": "POST", - "webhook_headers": [] - } - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.2", - "metadata": { - "name": "Migrate to 11.3 Test", - "saved_on": "2018-09-25T14:57:23.429081Z", - "revision": 97, - "uuid": "915144c5-605e-46f3-afa3-53aae2c9b8ee", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_4.json b/media/test_flows/migrate_to_11_4.json deleted file mode 100644 index 9a89c6fdf88..00000000000 --- a/media/test_flows/migrate_to_11_4.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "version": "11.3", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "019d0fab-eb51-4431-9f51-ddf207d0a744", - "action_sets": [ - { - "uuid": "92fb739f-4a99-4e29-8078-1f8fb06d127e", - "x": 241, - "y": 425, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "0382e5aa-bfda-42c8-84d3-7893aba002f8", - "msg": { - "eng": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "send", - "uuid": "b5860896-db39-4ebb-b842-d38edf46fb61", - "msg": { - "eng": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" - }, - "contacts": [ - { - "id": 277738, - "name": "05fe51bf5a434b9", - "uuid": "74eed75b-dd4f-4d24-9fc5-474052dbc086", - "urns": [ - { - "scheme": "tel", - "path": "+2353265262", - "priority": 90 - } - ] - } - ], - "groups": [], - "variables": [], - "media": {} - }, - { - "type": "email", - "uuid": "c9130ab6-d2b2-419c-8109-65b5afc47039", - "emails": [ - "test@test.com" - ], - "subject": "Testing", - "msg": "@flow.response_1.text\n@flow.response_2.text\n@flow.response_3.text\n@flow.response_3\n@(CONCATENATE(flow.response_2.text, \"blerg\"))" - } - ], - "exit_uuid": "ea5640be-105b-4277-b04e-7ad55d2c898e" - } - ], - "rule_sets": [ - { - "uuid": "019d0fab-eb51-4431-9f51-ddf207d0a744", - "x": 226, - "y": 118, - "label": "Response 1", - "rules": [ - { - "uuid": "7fd3aae5-66ca-4d8d-9923-3ef4424e7658", - "category": { - "eng": "All Responses" - }, - "destination": "fc1b062c-52c0-4c9e-87bd-1f9437d513bf", - "destination_type": "R", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "fc1b062c-52c0-4c9e-87bd-1f9437d513bf", - "x": 226, - "y": 232, - "label": "Response 2", - "rules": [ - { - "uuid": "58a4e6f6-fe44-4ac9-bf98-edffd6dfad04", - "category": { - "eng": "All Responses" - }, - "destination": "518b6f12-0192-4a75-8900-43a5dea02340", - "destination_type": "R", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "expression", - "response_type": "", - "operand": "@contact.uuid", - "config": {} - }, - { - "uuid": "518b6f12-0192-4a75-8900-43a5dea02340", - "x": 226, - "y": 335, - "label": "Response 3", - "rules": [ - { - "uuid": "0d1b5fd9-bfee-4df6-9837-9883787f0661", - "category": { - "eng": "Bucket 1" - }, - "destination": "92fb739f-4a99-4e29-8078-1f8fb06d127e", - "destination_type": "A", - "test": { - "type": "between", - "min": "0", - "max": "0.5" - }, - "label": null - }, - { - "uuid": "561b7ce2-5975-4925-a76a-f4a618b11c8b", - "category": { - "eng": "Bucket 2" - }, - "destination": "92fb739f-4a99-4e29-8078-1f8fb06d127e", - "destination_type": "A", - "test": { - "type": "between", - "min": "0.5", - "max": "1" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "random", - "response_type": "", - "operand": "@(RAND())", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "F", - "version": "11.3", - "metadata": { - "name": "Migrate to 11.4", - "saved_on": "2018-06-25T21:58:04.000768Z", - "revision": 123, - "uuid": "025f1d6e-ec87-4045-8471-0a028b9483aa", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_5.json b/media/test_flows/migrate_to_11_5.json deleted file mode 100644 index 13725b4a54f..00000000000 --- a/media/test_flows/migrate_to_11_5.json +++ /dev/null @@ -1,398 +0,0 @@ -{ - "version": "11.4", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", - "action_sets": [ - { - "uuid": "35707236-5dd6-487d-bea4-6a73822852bf", - "x": 122, - "y": 458, - "destination": "51956031-9f42-475f-9d43-3ab2f87f4dd2", - "actions": [ - { - "type": "reply", - "uuid": "c82df796-9d8f-4e9b-b76c-97027fa74ef7", - "msg": { - "eng": "@flow.response_1\n@flow.response_1.value\n@flow.response_1.category\n@(upper(flow.response_1))\n@(upper(flow.response_1.category))\n\n@flow.response_2\n@flow.response_2.value\n@flow.response_2.category\n@(upper(flow.response_2))\n@(upper(flow.response_2.category))\n\n@flow.response_3\n@flow.response_3.value\n@flow.response_3.category\n@(upper(flow.response_3))\n@(upper(flow.response_3.category))" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "65af1dca-b48e-4b36-867c-2ace47038093" - }, - { - "uuid": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", - "x": 412, - "y": 814, - "destination": null, - "actions": [ - { - "type": "api", - "uuid": "9b46779a-f680-450f-8f3c-005f3b7efccd", - "webhook": "http://example.com/?thing=@flow.response_1&foo=bar", - "action": "GET", - "webhook_headers": [] - }, - { - "type": "save", - "uuid": "e0ecf2a5-0429-45ec-a9d7-e2c122274484", - "label": "Contact Name", - "field": "name", - "value": "@flow.response_3.value" - } - ], - "exit_uuid": "25d8d2ae-ea82-4214-9561-42e0bf420a93" - } - ], - "rule_sets": [ - { - "uuid": "2831f7ad-23e6-4ab3-91d9-936f14fcf35e", - "x": 100, - "y": 0, - "label": "Response 1", - "rules": [ - { - "uuid": "c799def9-345b-46f9-a838-a59191cdb181", - "category": { - "eng": "Success" - }, - "destination": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", - "destination_type": "R", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "1ace9344-3053-4dc2-aced-9a6e3c8a6e9d", - "category": { - "eng": "Failure" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "webhook", - "response_type": "", - "operand": "@step.value", - "config": { - "webhook": "http://example.com/webhook1", - "webhook_action": "GET", - "webhook_headers": [] - } - }, - { - "uuid": "7e0afb0a-8ca2-479f-8f72-49f8c1081d60", - "x": 103, - "y": 125, - "label": "Response 2", - "rules": [ - { - "uuid": "ce50f51d-f052-4ff1-8a9b-a79faa62dfc2", - "category": { - "eng": "Success" - }, - "destination": "5906c8f3-46f2-4319-8743-44fb26f2b109", - "destination_type": "R", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "338e6c08-3597-4d22-beef-80d27b870a93", - "category": { - "eng": "Failure" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "webhook", - "response_type": "", - "operand": "@step.value", - "config": { - "webhook": "http://example.com/webhook2", - "webhook_action": "GET", - "webhook_headers": [] - } - }, - { - "uuid": "5906c8f3-46f2-4319-8743-44fb26f2b109", - "x": 105, - "y": 243, - "label": "Response 2", - "rules": [ - { - "uuid": "6328e346-49c6-4607-a573-e8dc6e60bfcd", - "category": { - "eng": "All Responses" - }, - "destination": "728a9a97-f28e-4fb3-a96a-7a7a8d5e5a4c", - "destination_type": "R", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "expression", - "response_type": "", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "728a9a97-f28e-4fb3-a96a-7a7a8d5e5a4c", - "x": 112, - "y": 346, - "label": "Response 3", - "rules": [ - { - "uuid": "fb64dd04-8dd3-4e28-8607-468d1748a81f", - "category": { - "eng": "Success" - }, - "destination": "35707236-5dd6-487d-bea4-6a73822852bf", - "destination_type": "A", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "992c7429-221a-40f0-80be-fd6fbe858f57", - "category": { - "eng": "Failure" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "resthook", - "response_type": "", - "operand": "@step.value", - "config": { - "resthook": "test-resthook-event" - } - }, - { - "uuid": "51956031-9f42-475f-9d43-3ab2f87f4dd2", - "x": 411, - "y": 513, - "label": "Response 5", - "rules": [ - { - "uuid": "c06fb4fe-09a0-4990-b32e-e233de7edfda", - "category": { - "eng": "All Responses" - }, - "destination": "f39a6d73-57d9-4d10-9055-57446addc87a", - "destination_type": "R", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "expression", - "response_type": "", - "operand": "@(flow.response_1 & flow.response_2 & flow.response_3)", - "config": {} - }, - { - "uuid": "f39a6d73-57d9-4d10-9055-57446addc87a", - "x": 414, - "y": 625, - "label": "Response 6", - "rules": [ - { - "uuid": "820f0020-0c72-44cd-9c12-a2b05c13e470", - "category": { - "eng": "Yes" - }, - "destination": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", - "destination_type": "R", - "test": { - "type": "contains_any", - "test": { - "eng": "yes" - } - }, - "label": null - }, - { - "uuid": "8e55e70f-acf0-45a2-b7f9-2f95ccbbfc4d", - "category": { - "eng": "Matching" - }, - "destination": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", - "destination_type": "R", - "test": { - "type": "contains_any", - "test": { - "eng": "@flow.response_1" - } - }, - "label": null - }, - { - "uuid": "d1c61a49-64f5-4ff6-b17f-1f22472f829f", - "category": { - "eng": "Other" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "flow_field", - "response_type": "", - "operand": "@flow.response_1", - "config": {} - }, - { - "uuid": "0e0c0e1f-e4ae-4531-ba19-48300de0f86d", - "x": 489, - "y": 722, - "label": "Response 7", - "rules": [ - { - "uuid": "234fff68-780f-442f-a1c6-757131fbc213", - "category": { - "eng": "Success" - }, - "destination": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", - "destination_type": "A", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "70b79516-40a5-439c-9dee-45b242d6bb8b", - "category": { - "eng": "Failure" - }, - "destination": "ab700bd7-480b-4e34-bd59-5be7c453aa4e", - "destination_type": "A", - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "webhook", - "response_type": "", - "operand": "@step.value", - "config": { - "webhook": "http://example.com/?thing=@flow.response_1.value", - "webhook_action": "GET", - "webhook_headers": [] - } - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.4", - "metadata": { - "name": "Migrate to 11.5 Test", - "saved_on": "2018-09-25T14:57:23.429081Z", - "revision": 97, - "uuid": "915144c5-605e-46f3-afa3-53aae2c9b8ee", - "expires": 10080, - "notes": [ - { - "x": 357, - "y": 0, - "title": "New Note", - "body": "@flow.response_1" - }, - { - "x": 358, - "y": 117, - "title": "New Note", - "body": "flow.response_2" - }, - { - "x": 358, - "y": 236, - "title": "New Note", - "body": "reuses flow.response_2" - }, - { - "x": 360, - "y": 346, - "title": "New Note", - "body": "@flow.response_3" - }, - { - "x": 671, - "y": 498, - "title": "New Note", - "body": "operand should be migrated too" - }, - { - "x": 717, - "y": 608, - "title": "New Note", - "body": "rule test should be migrated" - }, - { - "x": 747, - "y": 712, - "title": "New Note", - "body": "webhook URL in config should be migrated" - }, - { - "x": 681, - "y": 830, - "title": "New Note", - "body": "webhook URL on action should be migrated" - }, - { - "x": 682, - "y": 934, - "title": "New Note", - "body": "field value should be migrated" - } - ] - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_6.json b/media/test_flows/migrate_to_11_6.json deleted file mode 100644 index 64500d8117c..00000000000 --- a/media/test_flows/migrate_to_11_6.json +++ /dev/null @@ -1,252 +0,0 @@ -{ - "version": "11.5", - "site": "https://textit.in", - "flows": [ - { - "entry": "c4462613-5936-42cc-a286-82e5f1816793", - "action_sets": [ - { - "uuid": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", - "x": 76, - "y": 99, - "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "actions": [ - { - "type": "add_group", - "uuid": "feb7a33e-bc8b-44d8-9112-bc4e910fe304", - "groups": [ - { - "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", - "name": "Cat Fanciers" - } - ] - }, - { - "type": "add_group", - "uuid": "ca82f0e0-43ca-426c-a77c-93cf297b8e7c", - "groups": [ - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - }, - { - "type": "reply", - "uuid": "d57e9e9f-ada4-4a22-99ef-b8bf3dbcdcae", - "msg": { - "eng": "You are a cat fan! Purrrrr." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "55f88a1e-73ad-4b6d-9a04-626046bbe5a8" - }, - { - "uuid": "ef389049-d2e3-4343-b91f-13ea2db5f943", - "x": 558, - "y": 94, - "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "actions": [ - { - "type": "del_group", - "uuid": "cea907a8-af81-49af-92e6-f246e52179fe", - "groups": [ - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - }, - { - "type": "reply", - "uuid": "394a328f-f829-43f2-9975-fe2f27c8b786", - "msg": { - "eng": "You are not a cat fan. Hissssss." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "9ba78afa-948e-44c5-992f-84030f2eaa6b" - }, - { - "uuid": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "x": 319, - "y": 323, - "destination": "35416fea-787d-48c1-b839-76eca089ad2e", - "actions": [ - { - "type": "channel", - "uuid": "78c58574-9f91-4c27-855e-73eacc99c395", - "channel": "bd55bb31-8ed4-4f89-b903-7103aa3762be", - "name": "Telegram: TextItBot" - } - ], - "exit_uuid": "c86638a9-2688-47c9-83ec-7f10ef49de1e" - }, - { - "uuid": "35416fea-787d-48c1-b839-76eca089ad2e", - "x": 319, - "y": 468, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "30d35b8f-f439-482a-91b1-d3b1a4351071", - "msg": { - "eng": "All done." - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "send", - "uuid": "a7b6def8-d315-49bd-82e4-85887f39babe", - "msg": { - "eng": "Hey Cat Fans!" - }, - "contacts": [], - "groups": [ - { - "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", - "name": "Cat Blasts" - } - ], - "variables": [], - "media": {} - }, - { - "type": "trigger-flow", - "uuid": "540965e5-bdfe-4416-b4dd-449220b1c588", - "flow": { - "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", - "name": "Cataclysmic" - }, - "contacts": [], - "groups": [ - { - "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", - "name": "Cat Alerts" - } - ], - "variables": [] - } - ], - "exit_uuid": "f2ef5066-434d-42bc-a5cb-29c59e51432f" - } - ], - "rule_sets": [ - { - "uuid": "c4462613-5936-42cc-a286-82e5f1816793", - "x": 294, - "y": 0, - "label": "Response 1", - "rules": [ - { - "uuid": "17d69564-60c9-4a56-be8b-34e98a2ce14a", - "category": { - "eng": "Cat Facts" - }, - "destination": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", - "destination_type": "A", - "test": { - "type": "in_group", - "test": { - "name": "Cat Facts", - "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9" - } - }, - "label": null - }, - { - "uuid": "a9ec4d0a-2ddd-4a13-a1d2-c63ce9916a04", - "category": { - "eng": "Other" - }, - "destination": "ef389049-d2e3-4343-b91f-13ea2db5f943", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "group", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.5", - "metadata": { - "name": "Cataclysmic", - "saved_on": "2018-10-18T17:03:54.835916Z", - "revision": 49, - "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", - "expires": 10080, - "notes": [] - } - }, - { - "entry": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", - "action_sets": [ - { - "uuid": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "add_group", - "uuid": "11f61fc6-834e-4cbc-88ee-c834279345e6", - "groups": [ - { - "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", - "name": "Cat Alerts" - }, - { - "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9", - "name": "Cat Facts" - }, - { - "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", - "name": "Cat Blasts" - }, - { - "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", - "name": "Cat Fanciers" - }, - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - } - ], - "exit_uuid": "029a7c9d-c935-4ed1-9573-543ded29d954" - } - ], - "rule_sets": [], - "base_language": "eng", - "flow_type": "M", - "version": "11.5", - "metadata": { - "name": "Catastrophe", - "saved_on": "2018-10-18T19:03:07.702388Z", - "revision": 1, - "uuid": "d6dd96b1-d500-4c7a-9f9c-eae3f2a2a7c5", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_7.json b/media/test_flows/migrate_to_11_7.json deleted file mode 100644 index 7598f82934d..00000000000 --- a/media/test_flows/migrate_to_11_7.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "version": "11.6", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", - "action_sets": [ - { - "uuid": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", - "x": 102, - "y": 0, - "destination": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", - "actions": [ - { - "type": "api", - "uuid": "82d23a8c-af4b-4a33-8d56-03139b1168cc", - "webhook": "http://example.com/hook1", - "action": "GET", - "webhook_headers": [ - { - "name": "Header1", - "value": "Value1" - } - ] - } - ], - "exit_uuid": "787517ce-9a6d-479e-bc81-c3f4dcbb3d1d" - }, - { - "uuid": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", - "x": 149, - "y": 107, - "destination": "efe05d14-7a96-4ec5-870c-5183408821ae", - "actions": [ - { - "type": "reply", - "uuid": "544fd45b-f9a9-4543-b352-06b67dc0c32c", - "msg": { - "eng": "Action before 1" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "reply", - "uuid": "252b59b0-3664-4a36-8b9f-9317e78011da", - "msg": { - "eng": "Action before 2" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "api", - "uuid": "55c868c0-f6f7-49a8-856c-809bd082ae3b", - "webhook": "http://example.com/hook2", - "action": "POST", - "webhook_headers": [] - }, - { - "type": "reply", - "uuid": "f7ec546c-9adf-4d51-ab8e-8a1cbde8d910", - "msg": { - "eng": "Action after 1" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "reply", - "uuid": "a44ec0b8-085d-4e80-b361-7529e659e5e6", - "msg": { - "eng": "Action after 2" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "32c5dba9-17d1-4d5d-a992-19c1ec6cf825" - }, - { - "uuid": "efe05d14-7a96-4ec5-870c-5183408821ae", - "x": 199, - "y": 446, - "destination": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", - "actions": [ - { - "type": "api", - "uuid": "05377f3c-d9b0-428d-ae14-219d2f3d0f9a", - "webhook": "http://example.com/hook3", - "action": "GET", - "webhook_headers": [] - }, - { - "type": "api", - "uuid": "61fadf6d-d2ba-4bbb-b312-1db3e336a661", - "webhook": "http://example.com/hook4", - "action": "GET", - "webhook_headers": [] - } - ], - "exit_uuid": "c2236afe-c3cb-43a5-9fa0-ee6cbfb92f42" - }, - { - "uuid": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", - "x": 245, - "y": 608, - "destination": "64d8b8a5-aca0-4406-b417-5827262e67e2", - "actions": [ - { - "type": "reply", - "uuid": "be4dbed8-7334-4700-a94d-50275015c048", - "msg": { - "eng": "Actionset without webhook" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "40b890ab-8fab-459f-8d5e-48d2ea57f7ce" - }, - { - "uuid": "d6da8268-0c61-4154-8659-dd073878541c", - "x": 1036, - "y": 265, - "destination": null, - "actions": [ - { - "type": "api", - "uuid": "b8a8715b-0fb5-4dde-a1fe-4fef045bb16c", - "webhook": "http://example.com/hook5", - "action": "GET", - "webhook_headers": [] - } - ], - "exit_uuid": "15170baf-8b15-4104-990c-13635f0bafbb" - } - ], - "rule_sets": [ - { - "uuid": "64d8b8a5-aca0-4406-b417-5827262e67e2", - "x": 673, - "y": 54, - "label": "Response 1", - "rules": [ - { - "uuid": "4bc64a60-b848-4f07-bbe8-8b82e72b6dea", - "category": { - "eng": "1" - }, - "destination": "eb59aed8-2eeb-43cd-adfc-9c44721436a2", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "eng": "1" - } - }, - "label": null - }, - { - "uuid": "2faff885-6ac4-4cef-bd11-53802be22508", - "category": { - "eng": "2" - }, - "destination": "cd2d8a3e-c267-40ef-8481-37d4076a57d3", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "eng": "2" - } - }, - "label": null - }, - { - "uuid": "05efb767-1319-4f93-ba3f-8d3860a915af", - "category": { - "eng": "3" - }, - "destination": "efe05d14-7a96-4ec5-870c-5183408821ae", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "eng": "3" - } - }, - "label": null - }, - { - "uuid": "2bfbb15e-fb54-41a5-ba43-c67c219e8c57", - "category": { - "eng": "4" - }, - "destination": "b5ea564c-4acd-4ce4-aeff-37e5c73047e7", - "destination_type": "A", - "test": { - "type": "contains_any", - "test": { - "eng": "4" - } - }, - "label": null - }, - { - "uuid": "d091ea29-07b9-48b8-bc52-1de00687af1b", - "category": { - "eng": "Other" - }, - "destination": "d6da8268-0c61-4154-8659-dd073878541c", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.6", - "metadata": { - "name": "Webhook Action Migration", - "saved_on": "2018-11-05T19:21:37.062932Z", - "revision": 61, - "uuid": "c9b9d79a-93b4-41e5-8ca3-f0b09faa2457", - "expires": 10080, - "notes": [] - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_11_8.json b/media/test_flows/migrate_to_11_8.json deleted file mode 100644 index 8f51778859f..00000000000 --- a/media/test_flows/migrate_to_11_8.json +++ /dev/null @@ -1,341 +0,0 @@ -{ - "version": 11.7, - "site": null, - "flows": [ - { - "entry": "fde99613-a3e9-4f97-9e88-81ebc0ea6211", - "action_sets": [ - { - "uuid": "788064a1-fe23-4f6e-8041-200412dff55e", - "x": 389, - "y": 991, - "destination": "d8be5901-e847-4b6f-a603-51eb571718a1", - "actions": [ - { - "type": "reply", - "uuid": "fdee102d-5259-4153-8e43-0b7df1d3a1ee", - "msg": { - "base": "Thanks @extra.name, we'll be in touch ASAP about order # @extra.order." - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "email", - "uuid": "66c4a60f-3d63-4eed-bd03-c801baa0d793", - "emails": [ - "rowanseymour@gmail.com" - ], - "subject": "Order Comment: @flow.lookup: @extra.order", - "msg": "Customer @extra.name has a problem with their order @extra.order for @extra.description. Please look into it ASAP and call them back with the status.\n \nCustomer Comment: \"@flow.comment\"\nCustomer Name: @extra.name\nCustomer Phone: @contact.tel " - } - ], - "exit_uuid": "b193a69a-d5d9-423a-9f1f-0ad51847a075" - }, - { - "uuid": "1bdc3242-ef13-4c1b-a3b1-11554bffff7a", - "x": 612, - "y": 574, - "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", - "actions": [ - { - "type": "reply", - "uuid": "fc90459d-243c-4207-a26b-258e2c42cff3", - "msg": { - "base": "Uh oh @extra.name! Our record indicate that your order for @extra.description was cancelled on @extra.cancel_date. If you think this is in error, please reply with a comment and our orders department will get right on it!" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "7e943c3d-b560-436f-bd7e-5c52e9162254" - }, - { - "uuid": "601c7150-7a3e-40aa-8f79-92f936e17cf9", - "x": 389, - "y": 572, - "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", - "actions": [ - { - "type": "reply", - "uuid": "459ed2db-9921-4326-87a1-5157e0a9b38a", - "msg": { - "base": "Hi @extra.name. Hope you are patient because we haven't shipped your order for @extra.description yet. We expect to ship it by @extra.ship_date though. If you have any questions, just reply and our customer service department will be notified." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "5747ab91-d20c-4fff-8246-9d29a6ef1511" - }, - { - "uuid": "f87e2df4-5cbb-4961-b3c9-41eed35f8dbe", - "x": 167, - "y": 572, - "destination": "691e8175-f6a1-45b3-b377-c8bda223e52b", - "actions": [ - { - "type": "reply", - "uuid": "661ac1e4-2f13-48b1-adcf-0ff151833a86", - "msg": { - "base": "Great news @extra.name! We shipped your order for @extra.description on @extra.ship_date and we expect it will be delivered on @extra.delivery_date. If you have any questions, just reply and our customer service department will be notified." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "aee36df6-a421-43b9-be55-a4a298c35f86" - }, - { - "uuid": "81c3ff98-3552-4962-ab05-8f7948ebac24", - "x": 787, - "y": 99, - "destination": "659f67c6-cf6d-4d43-bd64-a50318fd5168", - "actions": [ - { - "type": "reply", - "uuid": "7645e8cd-34a1-44d0-8b11-7f4f06bd5ac7", - "msg": { - "base": "Sorry that doesn't look like a valid order number. Maybe try: CU001, CU002 or CU003?" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "b6e7b7f2-88e5-4457-ba7b-6edb9fb81d9f" - }, - { - "uuid": "fde99613-a3e9-4f97-9e88-81ebc0ea6211", - "x": 409, - "y": 0, - "destination": "659f67c6-cf6d-4d43-bd64-a50318fd5168", - "actions": [ - { - "type": "reply", - "uuid": "c007a761-85c7-48eb-9b38-8d056d1d44ee", - "msg": { - "base": "Thanks for contacting the ThriftShop order status system. Please send your order # and we'll help you in a jiffy!" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "0a300e24-c7fa-473d-b06e-2826fa25b447" - } - ], - "rule_sets": [ - { - "uuid": "691e8175-f6a1-45b3-b377-c8bda223e52b", - "x": 389, - "y": 875, - "label": "Comment", - "rules": [ - { - "uuid": "567cac39-5ee4-4dac-b29a-97dfef2a2eb1", - "category": { - "base": "All Responses" - }, - "destination": "788064a1-fe23-4f6e-8041-200412dff55e", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "O", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "659f67c6-cf6d-4d43-bd64-a50318fd5168", - "x": 356, - "y": 198, - "label": "Lookup Response", - "rules": [ - { - "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", - "category": { - "base": "All Responses" - }, - "destination": "541382fd-e897-4f77-b468-1f2c7bacf30c", - "destination_type": "R", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "C", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "d8be5901-e847-4b6f-a603-51eb571718a1", - "x": 389, - "y": 1252, - "label": "Extra Comments", - "rules": [ - { - "uuid": "bba334ec-321e-4ead-8d1d-f34d7bc983ad", - "category": { - "base": "All Responses" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "O", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "726f6b34-d6be-46fa-8061-bf1f081b15ce", - "x": 356, - "y": 398, - "label": "Lookup", - "rules": [ - { - "uuid": "d26ac82f-90dc-4f95-b105-7d3ca4effc20", - "category": { - "base": "Shipped" - }, - "destination": "f87e2df4-5cbb-4961-b3c9-41eed35f8dbe", - "destination_type": "A", - "test": { - "type": "contains", - "test": { - "base": "Shipped" - } - }, - "label": null - }, - { - "uuid": "774e6911-cb63-4700-99bc-5e16966393b8", - "category": { - "base": "Pending" - }, - "destination": "601c7150-7a3e-40aa-8f79-92f936e17cf9", - "destination_type": "A", - "test": { - "type": "contains", - "test": { - "base": "Pending" - } - }, - "label": null - }, - { - "uuid": "fee4858c-2545-435b-ae65-d9e6b8f8d106", - "category": { - "base": "Cancelled" - }, - "destination": "1bdc3242-ef13-4c1b-a3b1-11554bffff7a", - "destination_type": "A", - "test": { - "type": "contains", - "test": { - "base": "Cancelled" - } - }, - "label": null - }, - { - "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", - "category": { - "base": "Other" - }, - "destination": "81c3ff98-3552-4962-ab05-8f7948ebac24", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "expression", - "response_type": "", - "operand": "@extra.status", - "config": {} - }, - { - "uuid": "541382fd-e897-4f77-b468-1f2c7bacf30c", - "x": 356, - "y": 298, - "label": "Lookup Webhook", - "rules": [ - { - "uuid": "24b3a3a5-1ce8-45d4-87e5-0fa0159a9cab", - "category": { - "base": "Success" - }, - "destination": "726f6b34-d6be-46fa-8061-bf1f081b15ce", - "destination_type": "R", - "test": { - "type": "webhook_status", - "status": "success" - }, - "label": null - }, - { - "uuid": "008f4050-7979-42d5-a2cb-d1b4f6bc144f", - "category": { - "base": "Failure" - }, - "destination": "726f6b34-d6be-46fa-8061-bf1f081b15ce", - "destination_type": "R", - "test": { - "type": "webhook_status", - "status": "failure" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "webhook", - "response_type": "", - "operand": "@step.value", - "config": { - "webhook": "https://textit.in/demo/status/", - "webhook_action": null - } - } - ], - "base_language": "base", - "flow_type": "M", - "version": "11.7", - "metadata": { - "notes": [ - { - "body": "This flow demonstrates looking up an order using a webhook and giving the user different options based on the results. After looking up the order the user has the option to send additional comments which are forwarded to customer support representatives.\n\nUse order numbers CU001, CU002 or CU003 to see the different cases in action.", - "x": 59, - "y": 0, - "title": "Using Your Own Data" - } - ], - "saved_on": "2019-01-09T18:29:40.288510Z", - "uuid": "3825c65e-5aa8-4619-8de9-963f68483cb3", - "name": "Sample Flow - Order Status Checker", - "revision": 11, - "expires": 720 - } - } - ] -} diff --git a/media/test_flows/migrate_to_11_9.json b/media/test_flows/migrate_to_11_9.json deleted file mode 100644 index b889215b9ba..00000000000 --- a/media/test_flows/migrate_to_11_9.json +++ /dev/null @@ -1,458 +0,0 @@ -{ - "version": "11.8", - "site": "https://app.rapidpro.io", - "flows": [ - { - "entry": "edea0cb4-00b9-4a53-a923-f4aa38cf18c5", - "action_sets": [ - { - "uuid": "edea0cb4-00b9-4a53-a923-f4aa38cf18c5", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "0b6745ce-6d8b-40d4-bb4f-f18f407bdcdf", - "msg": { - "base": "hi valid" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "d79e8a16-62df-4b48-aff9-fae2633f2b77" - } - ], - "rule_sets": [], - "base_language": "base", - "flow_type": "M", - "version": "11.8", - "metadata": { - "name": "Valid1", - "saved_on": "2018-12-17T12:08:54.146452Z", - "revision": 2, - "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", - "expires": 10080, - "ivr_retry_failed_events": null - } - }, - { - "entry": "d3e2b506-50cd-4c1e-9573-295bd2087258", - "action_sets": [ - { - "uuid": "d3e2b506-50cd-4c1e-9573-295bd2087258", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "d17b512e-87ed-4717-9461-bc2ffde23b77", - "msg": { - "base": "Hi flow invalid 1" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "c231d9a6-3b53-4670-ac41-247736126ffd" - } - ], - "rule_sets": [], - "base_language": "base", - "flow_type": "M", - "version": "11.8", - "metadata": { - "name": "Invalid1", - "saved_on": "2018-12-17T12:09:52.155509Z", - "revision": 2, - "uuid": "ad40071e-a665-4df3-af14-0bc0fe589244", - "expires": 10080, - "ivr_retry_failed_events": null - } - }, - { - "entry": "932b19a7-245c-4a2b-9249-66d4eb7cfdf7", - "action_sets": [ - { - "uuid": "932b19a7-245c-4a2b-9249-66d4eb7cfdf7", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "2f6cdd62-9b29-4597-8af0-3dd410ae46f0", - "msg": { - "base": "Hi flow invalid two" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "0035ccc2-6359-4954-bbc9-bddb90076c25" - } - ], - "rule_sets": [], - "base_language": "base", - "flow_type": "M", - "version": "11.8", - "metadata": { - "name": "Invalid2", - "saved_on": "2018-12-17T12:10:13.269437Z", - "revision": 3, - "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", - "expires": 10080, - "ivr_retry_failed_events": null - } - }, - { - "entry": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", - "action_sets": [ - { - "uuid": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", - "x": 375, - "y": 1, - "destination": "a7de0caa-5ab0-4edc-8fc8-33eb31f79cba", - "actions": [ - { - "type": "reply", - "uuid": "64ce02e3-8ea8-414a-a7cf-7f5d3938aa03", - "msg": { - "base": "Hi" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "33d4947d-bdda-4226-bc27-55a6b6e56b36" - }, - { - "uuid": "c9e48e85-b91c-4e2b-bb17-fb670f1559c0", - "x": 420, - "y": 622, - "destination": "861c6312-b2a8-4586-8688-6621d7065497", - "actions": [ - { - "type": "reply", - "uuid": "24b49e66-2520-4f9c-a6f7-f7a43793db53", - "msg": { - "base": "tnx" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "trigger-flow", - "uuid": "bf31a0f8-73d8-4c81-8f90-ea0d4008a212", - "flow": { - "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", - "name": "Invalid2" - }, - "contacts": [], - "groups": [ - { - "uuid": "ad17d536-0085-4e6b-abc6-222b22d57caa", - "name": "Empty" - } - ], - "variables": [] - } - ], - "exit_uuid": "c8b6b99d-1af5-4bd1-b2d8-b3e87de702e8" - }, - { - "uuid": "6adc7de8-6a84-490a-b3d3-3d1ec607d465", - "x": 580, - "y": 316, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "09f0ddee-c27e-4397-bb3c-4a6cf35da77a", - "msg": { - "base": "tyvm" - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "flow", - "uuid": "95e9750f-9bf4-4ae9-aa07-5c4cde604956", - "flow": { - "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", - "name": "Invalid2" - } - } - ], - "exit_uuid": "1f5d4b7e-7ceb-47cf-91e7-94790f63c9db" - }, - { - "uuid": "ed891a32-6e6d-49b1-88d0-399d2002bce0", - "x": 323, - "y": 993, - "destination": null, - "actions": [ - { - "type": "flow", - "uuid": "70acb970-8b3a-47d0-9fb6-56c5974a582b", - "flow": { - "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", - "name": "Valid1" - } - } - ], - "exit_uuid": "b5bfc0a1-701e-4e19-ad3e-bf9d7470b241" - }, - { - "uuid": "a750fe69-167b-4ae3-af72-7aae4c2d8b1a", - "x": 598, - "y": 993, - "destination": null, - "actions": [ - { - "type": "flow", - "uuid": "9b0f11bd-fbda-4efe-a41a-8ef101412d95", - "flow": { - "uuid": "136cdab3-e9d1-458c-b6eb-766afd92b478", - "name": "Invalid2" - } - } - ], - "exit_uuid": "1231547d-8f57-4a12-82d1-b1bf3e664010" - }, - { - "uuid": "0bfb7527-c6e9-4452-b780-6755d2041144", - "x": 576, - "y": 169, - "destination": null, - "actions": [ - { - "type": "flow", - "uuid": "e8e85830-1aef-4947-af91-1a2653f3627d", - "flow": { - "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3", - "name": "Valid1" - } - } - ], - "exit_uuid": "8d2c4101-330a-49f7-a4e5-972513c1a995" - } - ], - "rule_sets": [ - { - "uuid": "a7de0caa-5ab0-4edc-8fc8-33eb31f79cba", - "x": 61, - "y": 190, - "label": "Response 1", - "rules": [ - { - "uuid": "a16424a4-95df-4839-813a-bf6bee37f735", - "category": { - "base": "1" - }, - "destination": "9baa6aaf-61bf-4686-8059-1c373a43e5a6", - "destination_type": "R", - "test": { - "type": "eq", - "test": "1" - }, - "label": null - }, - { - "uuid": "f6b45161-f1fe-475f-a4db-7eb300f26415", - "category": { - "base": "2" - }, - "destination": "c9e48e85-b91c-4e2b-bb17-fb670f1559c0", - "destination_type": "A", - "test": { - "type": "eq", - "test": "2" - }, - "label": null - }, - { - "uuid": "59bfd40b-8b94-4555-ac2e-e6883d280df2", - "category": { - "base": "3" - }, - "destination": "6adc7de8-6a84-490a-b3d3-3d1ec607d465", - "destination_type": "A", - "test": { - "type": "eq", - "test": "3" - }, - "label": null - }, - { - "uuid": "7b25509c-94c4-45c1-86cf-2995916ac825", - "category": { - "base": "Other" - }, - "destination": "0bfb7527-c6e9-4452-b780-6755d2041144", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - }, - { - "uuid": "9baa6aaf-61bf-4686-8059-1c373a43e5a6", - "x": 51, - "y": 659, - "label": "Response 2", - "rules": [ - { - "uuid": "049a4d45-d50d-468a-ae61-9e55c5dda0ea", - "category": { - "base": "Completed" - }, - "destination": "54c31965-d727-4b0a-a37e-6231551343dc", - "destination_type": "R", - "test": { - "type": "subflow", - "exit_type": "completed" - }, - "label": null - }, - { - "uuid": "101dea88-83bf-4219-973b-d11de45589ae", - "category": { - "base": "Expired" - }, - "destination": "544e4ef3-4c54-4bb0-8f89-a1e098b3f030", - "destination_type": "A", - "test": { - "type": "subflow", - "exit_type": "expired" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "subflow", - "response_type": "", - "operand": "@step.value", - "config": { - "flow": { - "name": "Invalid1", - "uuid": "ad40071e-a665-4df3-af14-0bc0fe589244" - } - } - }, - { - "uuid": "54c31965-d727-4b0a-a37e-6231551343dc", - "x": 36, - "y": 875, - "label": "Response 3", - "rules": [ - { - "uuid": "6a9a30cc-0400-4148-b760-ff342d7ef496", - "category": { - "base": "Completed" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "subflow", - "exit_type": "completed" - }, - "label": null - }, - { - "uuid": "e9e0ad89-6d63-4744-ba35-8042af052a95", - "category": { - "base": "Expired" - }, - "destination": null, - "destination_type": null, - "test": { - "type": "subflow", - "exit_type": "expired" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "subflow", - "response_type": "", - "operand": "@step.value", - "config": { - "flow": { - "name": "Valid1", - "uuid": "b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3" - } - } - }, - { - "uuid": "861c6312-b2a8-4586-8688-6621d7065497", - "x": 409, - "y": 863, - "label": "Response 4", - "rules": [ - { - "uuid": "451cb651-7a59-4d2b-bfe5-753643ad7db2", - "category": { - "base": "1" - }, - "destination": "ed891a32-6e6d-49b1-88d0-399d2002bce0", - "destination_type": "A", - "test": { - "type": "between", - "min": "0", - "max": "0.5" - }, - "label": null - }, - { - "uuid": "39c05550-91a3-4497-9595-2478b5ab6ae4", - "category": { - "base": "2" - }, - "destination": "a750fe69-167b-4ae3-af72-7aae4c2d8b1a", - "destination_type": "A", - "test": { - "type": "between", - "min": "0.5", - "max": "1" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "random", - "response_type": "", - "operand": "@(RAND())", - "config": {} - } - ], - "base_language": "base", - "flow_type": "M", - "version": "11.8", - "metadata": { - "name": "Master", - "saved_on": "2018-12-17T13:54:21.769976Z", - "revision": 56, - "uuid": "8d3f72ef-60b9-4902-b792-d664df502f3f", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/migrate_to_9.json b/media/test_flows/migrate_to_9.json deleted file mode 100644 index e7e50ed1eb8..00000000000 --- a/media/test_flows/migrate_to_9.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "campaigns": [ - { - "events": [ - { - "event_type": "M", - "relative_to": { - "id": 1134, - "key": "next_appointment", - "label": "Next Show" - }, - "flow": { - "name": "Single Message", - "id": 2814 - }, - "offset": -1, - "delivery_hour": -1, - "message": "Hi there, your next show is @contact.next_show. Don't miss it!", - "id": 9959, - "unit": "H" - } - ], - "group": { - "name": "Pending Appointments", - "id": 2308 - }, - "id": 405, - "name": "Appointment Schedule" - } - ], - "version": 9, - "site": "https://app.rapidpro.io", - "flows": [ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "a04f3046-e053-444f-b018-eff019766ad9", - "uuid": "e4a03298-dd43-4afb-b185-2782fc36a006", - "actions": [ - { - "msg": { - "base": "Hi there!" - }, - "type": "reply" - }, - { - "uuid": "c756af8f-4480-4a91-875d-c0600597c0ae", - "contacts": [ - { - "id": contact_id, - "name": "Trey Anastasio" - } - ], - "groups": [], - "variables": [], - "msg": { - "base": "You're phantastic" - }, - "action": "GET", - "type": "send" - }, - { - "labels": [ - { - "name": "this label", - "id": label_id - } - ], - "type": "add_label" - }, - { - "field": "concat_test", - "type": "save", - "value": "@(CONCAT(extra.flow.divided, extra.flow.sky))", - "label": "Concat Test" - }, - { - "field": "normal_test", - "type": "save", - "value": "@extra.contact.name", - "label": "Normal Test" - } - ] - }, - { - "y": 142, - "x": 166, - "destination": null, - "uuid": "a04f3046-e053-444f-b018-eff019766ad9", - "actions": [ - { - "type": "add_group", - "groups": [ - { - "name": "Survey Audience", - "id": group_id - }, - "@(\"Phans\")", - "Survey Audience" - ] - }, - { - "type": "del_group", - "groups": [ - { - "name": "Unsatisfied Customers", - "id": group_id - } - ] - }, - { - "name": "Test flow", - "contacts": [], - "variables": [ - { - "id": "@contact.tel_e164" - } - ], - "groups": [], - "type": "trigger-flow", - "id": start_flow_id - }, - { - "type": "flow", - "name": "Parent Flow", - "id": start_flow_id - } - ] - } - ], - "version": 9, - "flow_type": "F", - "entry": "e4a03298-dd43-4afb-b185-2782fc36a006", - "rule_sets": [], - "metadata": { - "expires": 10080, - "revision": 11, - "id": previous_flow_id, - "name": "Migrate to 9", - "saved_on": "2016-06-22T15:05:12.074490Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/multi_language_flow.json b/media/test_flows/multi_language_flow.json deleted file mode 100644 index a7a10f91c3e..00000000000 --- a/media/test_flows/multi_language_flow.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "version": 4, - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", - "uuid": "d563e7ca-aa0f-4615-ba8c-eab5e13ff4bf", - "actions": [ - { - "msg": { - "spa": "\u00a1Hola amigo! \u00bfCu\u00e1l es tu color favorito?", - "eng": "Hello friend! What is your favorite color?" - }, - "type": "reply" - } - ] - }, - { - "y": 266, - "x": 351, - "destination": null, - "uuid": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", - "actions": [ - { - "msg": { - "spa": "\u00a1Gracias! Me gusta @flow.color.", - "eng": "Thank you! I like @flow.color." - }, - "type": "reply" - }, - { - "msg": { - "eng": "This message was not translated." - }, - "type": "reply" - } - ] - }, - { - "y": 179, - "x": 683, - "destination": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", - "uuid": "6ea52610-838c-4f64-8e24-99754135da67", - "actions": [ - { - "msg": { - "spa": "Por favor, una vez m\u00e1s", - "eng": "Please try again." - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2015-02-19T05:55:32.232993Z", - "entry": "d563e7ca-aa0f-4615-ba8c-eab5e13ff4bf", - "rule_sets": [ - { - "uuid": "c969c5ba-8595-4e2c-86d0-c2e375afe3e0", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "spa": "rojo", - "eng": "Red" - }, - "base": "Red", - "type": "contains_any" - }, - "category": { - "spa": "Rojo", - "base": "Red", - "eng": "Red" - }, - "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "de555b2c-2616-49ff-8564-409a01b0bd79" - }, - { - "test": { - "test": { - "spa": "verde", - "eng": "Green" - }, - "base": "Green", - "type": "contains_any" - }, - "category": { - "spa": "Verde", - "base": "Green", - "eng": "Green" - }, - "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "e09c7ad3-46c8-4024-9fcf-8a0d26d97d6a" - }, - { - "test": { - "test": { - "spa": "azul", - "eng": "Blue" - }, - "base": "Blue", - "type": "contains_any" - }, - "category": { - "spa": "Azul", - "base": "Blue", - "eng": "Blue" - }, - "destination": "5532bc8e-ecf8-42ad-9654-bb4b3374001e", - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "aafd9e60-4d74-40cb-a923-3501560cb5c1" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "spa": "Otro", - "base": "Other", - "eng": "Other" - }, - "destination": "6ea52610-838c-4f64-8e24-99754135da67", - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "2263684a-0354-448e-8213-c57644e91798" - } - ], - "webhook": null, - "label": "Color", - "operand": "@step.value", - "finished_key": null, - "response_type": "C", - "y": 132, - "x": 242 - } - ], - "metadata": {} - }, - "id": 1400, - "flow_type": "F", - "name": "Multi Language Flow" - } - ], - "triggers": [] -} diff --git a/media/test_flows/no_base_language_v8.json b/media/test_flows/no_base_language_v8.json deleted file mode 100644 index 18f5ccfc07d..00000000000 --- a/media/test_flows/no_base_language_v8.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": null, - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "f614e8c9-eeb6-4c94-bd07-b4bbe8a95b47", - "actions": [ - { - "type": "add_group", - "groups": [ - { - "name": "A New Group", - "id": 44899 - } - ] - }, - { - "field": "location", - "type": "save", - "value": "Seattle, WA", - "label": "Location" - }, - { - "lang": "eng", - "type": "lang", - "name": "English" - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "f614e8c9-eeb6-4c94-bd07-b4bbe8a95b47", - "rule_sets": [], - "metadata": { - "expires": 720, - "saved_on": "2015-11-19T00:30:09.477009Z", - "id": 42104, - "name": "Join New Group", - "revision": 6 - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/non_localized_ruleset.json b/media/test_flows/non_localized_ruleset.json deleted file mode 100644 index a4cbc27c3e8..00000000000 --- a/media/test_flows/non_localized_ruleset.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": "eng", - "action_sets": [], - "version": 8, - "flow_type": "F", - "entry": "99696ed8-2555-4d18-ac0b-f9b9d85abf30", - "rule_sets": [ - { - "uuid": "99696ed8-2555-4d18-ac0b-f9b9d85abf30", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": "All Responses", - "uuid": "9b31bbfe-23d7-4838-806a-1a3989de3f37" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 0, - "x": 100, - "config": {} - } - ], - "metadata": { - "expires": 10080, - "revision": 1, - "id": 42135, - "name": "Empty", - "saved_on": "2015-11-19T22:31:15.972687Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/non_localized_with_language.json b/media/test_flows/non_localized_with_language.json deleted file mode 100644 index 03daa5be452..00000000000 --- a/media/test_flows/non_localized_with_language.json +++ /dev/null @@ -1,332 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 991, - "x": 389, - "destination": "7d1b7019-b611-4132-9ba4-af36cc167398", - "uuid": "49189b3e-8e2b-473f-bec2-10378f5a7c06", - "actions": [ - { - "msg": "Thanks @extra.name, we'll be in touch ASAP about order # @extra.order.", - "type": "reply" - }, - { - "msg": "Customer @extra.name has a problem with their order @extra.order for @extra.description. Please look into it ASAP and call them back with the status.\n \nCustomer Comment: \"@flow.comment\"\nCustomer Name: @extra.name\nCustomer Phone: @contact.tel ", - "type": "email", - "emails": [ - "name@domain.com" - ], - "subject": "Order Comment: @flow.lookup: @extra.order" - } - ] - }, - { - "y": 574, - "x": 612, - "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", - "uuid": "8618411e-a35e-472b-b867-3339aa46027a", - "actions": [ - { - "msg": "Uh oh @extra.name! Our record indicate that your order for @extra.description was cancelled on @extra.cancel_date. If you think this is in error, please reply with a comment and our orders department will get right on it!", - "type": "reply" - } - ] - }, - { - "y": 572, - "x": 389, - "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", - "uuid": "32bb903e-44c2-40f9-b65f-c8cda6490ee6", - "actions": [ - { - "msg": "Hi @extra.name. Hope you are patient because we haven't shipped your order for @extra.description yet. We expect to ship it by @extra.ship_date though. If you have any questions, just reply and our customer service department will be notified.", - "type": "reply" - } - ] - }, - { - "y": 572, - "x": 167, - "destination": "6f550596-98a2-44fb-b769-b3c529f1b963", - "uuid": "bf36a209-4e21-44ac-835a-c3d5889aa2fb", - "actions": [ - { - "msg": "Great news @extra.name! We shipped your order for @extra.description on @extra.ship_date and we expect it will be delivered on @extra.delivery_date. If you have any questions, just reply and our customer service department will be notified.", - "type": "reply" - } - ] - }, - { - "y": 99, - "x": 787, - "destination": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", - "uuid": "7f4c29e3-f022-420d-8e2f-6165c572b991", - "actions": [ - { - "msg": "Sorry that doesn't look like a valid order number. Maybe try: CU001, CU002 or CU003?", - "type": "reply" - } - ] - }, - { - "y": 0, - "x": 409, - "destination": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", - "uuid": "4f79034a-51e0-4210-99cc-17f385de4de8", - "actions": [ - { - "msg": "Thanks for contacting the ThriftShop order status system. Please send your order # and we'll help you in a jiffy!", - "type": "reply" - } - ] - }, - { - "y": 854, - "x": 776, - "destination": "2cb5adcd-31b1-4d21-a0df-c5375cea1963", - "uuid": "6f550596-98a2-44fb-b769-b3c529f1b963", - "actions": [ - { - "msg": "@flow.lookup_response", - "type": "reply" - } - ] - }, - { - "y": 1430, - "x": 233, - "destination": "ad1d5767-8dfd-4c5d-b2e8-a997adb3a276", - "uuid": "81613e37-414c-4d73-884b-4ee7ae0fd913", - "actions": [ - { - "msg": "asdf", - "type": "reply" - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "4f79034a-51e0-4210-99cc-17f385de4de8", - "rule_sets": [ - { - "uuid": "2cb5adcd-31b1-4d21-a0df-c5375cea1963", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": "All Responses", - "destination": "49189b3e-8e2b-473f-bec2-10378f5a7c06", - "uuid": "088470d7-c4a9-4dd7-8be4-d10faf02fcea", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Comment", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 955, - "x": 762, - "config": {} - }, - { - "uuid": "69c427a4-b9b6-4f67-9e35-f783b3e81bfd", - "webhook_action": null, - "rules": [ - { - "category": "All Responses", - "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", - "destination": "b3bd5abb-3f70-4af5-85eb-d07900f9cb85", - "destination_type": "R", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Lookup Responses", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 198, - "x": 356, - "config": {} - }, - { - "uuid": "7d1b7019-b611-4132-9ba4-af36cc167398", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": "All Responses", - "destination": "81613e37-414c-4d73-884b-4ee7ae0fd913", - "uuid": "124f3266-bc62-4743-b4b1-79fee0d45ad9", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Extra Comments", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 1252, - "x": 389, - "config": {} - }, - { - "uuid": "6baa1d6b-ee70-4d7c-85b3-22ed94281227", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "Shipped", - "type": "contains" - }, - "category": "Shipped", - "destination": "bf36a209-4e21-44ac-835a-c3d5889aa2fb", - "uuid": "bb336f83-3a5f-4a2e-ad42-757a0a79892b", - "destination_type": "A" - }, - { - "test": { - "test": "Pending", - "type": "contains" - }, - "category": "Pending", - "destination": "32bb903e-44c2-40f9-b65f-c8cda6490ee6", - "uuid": "91826255-5a81-418c-aadb-3378802a1134", - "destination_type": "A" - }, - { - "test": { - "test": "Cancelled", - "type": "contains" - }, - "category": "Cancelled", - "destination": "8618411e-a35e-472b-b867-3339aa46027a", - "uuid": "1efa73d0-e30c-4495-a5c8-724b48385839", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "7f4c29e3-f022-420d-8e2f-6165c572b991", - "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "expression", - "label": "Lookup", - "operand": "@extra.status", - "finished_key": null, - "response_type": "", - "y": 398, - "x": 356, - "config": {} - }, - { - "uuid": "b3bd5abb-3f70-4af5-85eb-d07900f9cb85", - "webhook_action": "POST", - "rules": [ - { - "category": "All Responses", - "uuid": "c85136c2-dcdd-4c4b-835d-a083ebde5e07", - "destination": "6baa1d6b-ee70-4d7c-85b3-22ed94281227", - "destination_type": "R", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": "https://api.textit.in/demo/status/", - "ruleset_type": "webhook", - "label": "Lookup Webhook", - "operand": "@extra.status", - "finished_key": null, - "response_type": "", - "y": 298, - "x": 356, - "config": {} - }, - { - "uuid": "ad1d5767-8dfd-4c5d-b2e8-a997adb3a276", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": "All Responses", - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "439c839b-f04a-4394-9b8b-be91ca0991bd" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Boo", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 1580, - "x": 362, - "config": {} - } - ], - "metadata": { - "uuid": "2ed28d6a-61cd-436a-9159-01b024992e78", - "notes": [ - { - "body": "This flow demonstrates looking up an order using a webhook and giving the user different options based on the results. After looking up the order the user has the option to send additional comments which are forwarded to customer support representatives.\n\nUse order numbers CU001, CU002 or CU003 to see the different cases in action.", - "x": 59, - "y": 0, - "title": "Using Your Own Data" - } - ], - "expires": 720, - "name": "Sample Flow - Order Status Checker", - "saved_on": "2015-11-19T19:32:17.523441Z", - "id": 42133, - "revision": 1 - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/not_fully_localized.json b/media/test_flows/not_fully_localized.json deleted file mode 100644 index b64ef4690a7..00000000000 --- a/media/test_flows/not_fully_localized.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "version": 7, - "flows": [ - { - "version": 7, - "flow_type": "F", - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "127f3736-77ce-4006-9ab0-0c07cea88956", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "type": "reply" - } - ] - }, - ], - "last_saved": "2015-09-15T02:37:08.805578Z", - "entry": "127f3736-77ce-4006-9ab0-0c07cea88956", - "rule_sets": [], - "metadata": { - "notes": [], - "name": "Not fully localized", - "id": 35559, - "expires": 720, - "revision": 1 - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/old_expressions.json b/media/test_flows/old_expressions.json deleted file mode 100644 index 2b36334bd42..00000000000 --- a/media/test_flows/old_expressions.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "version": 7, - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "a32d0ebb-57aa-452e-bd8d-ae5febee4440", - "uuid": "a26285b1-134b-421b-9853-af0f26d13777", - "actions": [ - { - "msg": { - "eng": "Hi @contact.name|upper_case. Today is =(date.now)" - }, - "type": "reply" - } - ] - }, - { - "y": 350, - "x": 164, - "destination": null, - "uuid": "054d9e01-8e68-4f6d-9cf3-44407256670e", - "actions": [ - { - "type": "add_group", - "groups": [ - "=flow.response_1.category" - ] - }, - { - "msg": { - "eng": "Was @contact.name|lower_case|title_case." - }, - "variables": [ - { - "id": "=flow.response_1.category" - } - ], - "type": "send", - "groups": [], - "contacts": [] - } - ] - } - ], - "last_saved": "2015-09-23T07:54:10.928652Z", - "entry": "a26285b1-134b-421b-9853-af0f26d13777", - "rule_sets": [ - { - "uuid": "a32d0ebb-57aa-452e-bd8d-ae5febee4440", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "028c71a3-0696-4d98-8ff3-0dc700811124", - "uuid": "bf879f78-aff8-4c64-9326-e92f677af5cf", - "destination_type": "R" - } - ], - "webhook": "http://example.com/query.php?contact=@contact.name|upper_case", - "ruleset_type": "webhook", - "label": "Response 1", - "operand": "=(step.value)", - "finished_key": null, - "response_type": "", - "y": 134, - "x": 237, - "config": {} - }, - { - "uuid": "028c71a3-0696-4d98-8ff3-0dc700811124", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "054d9e01-8e68-4f6d-9cf3-44407256670e", - "uuid": "35ba932c-d45a-4cf5-bd0b-41fd9b80cc27", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "expression", - "label": "Response 2", - "operand": "@step.value|time_delta:\"3\"", - "finished_key": null, - "response_type": "", - "y": 240, - "x": 203, - "config": {} - } - ], - "type": "F", - "metadata": {} - }, - "expires": 10080, - "id": 31427, - "flow_type": "F", - "name": "Old Expressions" - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/single_message_bad_localization.json b/media/test_flows/single_message_bad_localization.json deleted file mode 100644 index 9ac97190f1d..00000000000 --- a/media/test_flows/single_message_bad_localization.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "version":10, - "flows":[ - { - "base_language":"eng", - "rule_sets":[ - - ], - "action_sets":[ - { - "y":0, - "x":100, - "uuid":"37fe93f8-edf5-40f3-b029-3b391fa528d0", - "actions":[ - { - "msg":"Campaign Message 12", - "type":"reply", - "uuid":"9bdb1aab-e42e-4585-8395-6504c4a683ed" - } - ] - } - ], - "entry":"37fe93f8-edf5-40f3-b029-3b391fa528d0" - } - ], - "triggers":[ - - ] -} \ No newline at end of file diff --git a/media/test_flows/type_flow.json b/media/test_flows/type_flow.json deleted file mode 100644 index 7675ff831ca..00000000000 --- a/media/test_flows/type_flow.json +++ /dev/null @@ -1,394 +0,0 @@ -{ - "campaigns": [], - "version": "10.1", - "site": "https://app.rapidpro.io", - "flows": [ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 92, - "destination": "9c941ba5-e4df-47e0-9a4f-594986ae1b1a", - "uuid": "bc3da5f2-6fe5-41f1-ac0e-ec2701189ef2", - "actions": [ - { - "msg": { - "base": "Hey @contact.nickname, you joined on @contact.joined_on in @contact.district." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "4dc98ff5-8d86-45f5-8336-8949029e893e" - }, - { - "msg": { - "base": "It's @date. The time is @date.now on @date.today." - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "058e5d4a-3447-49d9-a033-ebe3010b5875" - }, - { - "msg": { - "base": "Send text" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "9568e1c8-04f2-45ef-a477-4521d19bfaf6" - } - ] - }, - { - "y": 257, - "x": 78, - "destination": "a4904b78-08b8-42fd-9479-27bcb1764bc4", - "uuid": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", - "actions": [ - { - "msg": { - "base": "You said @flow.text at @flow.text.time. Send date" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "6f4fc213-3037-49e5-ac45-b956c48fd546" - } - ] - }, - { - "y": 540, - "x": 95, - "destination": "9994619b-e68d-4c94-90d6-af19fb944f7d", - "uuid": "9bbdc63c-4385-44e1-b573-a127f50d3d34", - "actions": [ - { - "msg": { - "base": "You said @flow.date which was in category @flow.date.category Send number" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "7177ef30-33ca-4b25-8af7-3213e0483b56" - } - ] - }, - { - "y": 825, - "x": 96, - "destination": "01cc820b-c516-4e68-8903-aa69866b11b6", - "uuid": "a4a37023-de22-4ac4-b431-da2a333c93cd", - "actions": [ - { - "msg": { - "base": "You said @flow.number. Send state" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "34d622bc-e2ad-44aa-b047-cfb38e2dc2cc" - } - ] - }, - { - "y": 1084, - "x": 94, - "destination": "9769918c-8ca4-4ec5-8b5b-bf94cc6746a9", - "uuid": "7e8dfcd5-6510-4060-9608-2c8faa3a8e0a", - "actions": [ - { - "msg": { - "base": "You said @flow.state which was in category @flow.state.category. Send district" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "a4428571-9b86-49b8-97e1-6ffee3cddbaa" - } - ] - }, - { - "y": 1460, - "x": 73, - "destination": "ea2244de-7b23-4fbb-8f99-38cde3100de8", - "uuid": "605e2fe7-321a-4cce-b97b-877d75bd3b12", - "actions": [ - { - "msg": { - "base": "You said @flow.district. Send ward" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "5f8eb5aa-249b-4718-a502-8406dd0ae418" - } - ] - }, - { - "y": 1214, - "x": 284, - "destination": "498b1953-02f1-47dd-b9cb-1b51913e348f", - "uuid": "9769918c-8ca4-4ec5-8b5b-bf94cc6746a9", - "actions": [ - { - "msg": { - "base": "You said @flow.ward.", - "fre": "Tu as dit @flow.ward" - }, - "media": {}, - "send_all": false, - "type": "reply", - "uuid": "b95b88c8-a85c-4bac-931d-310d678c286a" - }, - { - "lang": "fre", - "type": "lang", - "name": "French", - "uuid": "56a4bca5-b9e5-4d04-883c-ca65d7c4d538" - } - ] - } - ], - "version": "10.1", - "flow_type": "F", - "entry": "bc3da5f2-6fe5-41f1-ac0e-ec2701189ef2", - "rule_sets": [ - { - "uuid": "9c941ba5-e4df-47e0-9a4f-594986ae1b1a", - "rules": [ - { - "category": { - "base": "All Responses" - }, - "uuid": "a4682f52-7869-4e64-bf9f-8d2c0a341d19", - "destination": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", - "label": null, - "destination_type": "A", - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "Text", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 146, - "x": 265, - "config": {} - }, - { - "uuid": "a4904b78-08b8-42fd-9479-27bcb1764bc4", - "rules": [ - { - "category": { - "base": "is a date" - }, - "uuid": "e410616b-b5cd-4fd1-af42-9c6b6c9fe282", - "destination": "9bbdc63c-4385-44e1-b573-a127f50d3d34", - "label": null, - "destination_type": "A", - "test": { - "type": "date" - } - }, - { - "category": { - "base": "Other" - }, - "uuid": "a720d0b1-0686-47be-a306-1543e470c6de", - "destination": "dac0c91f-3f3f-43d5-a2d9-5c1059998134", - "label": null, - "destination_type": "A", - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "Date", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 391, - "x": 273, - "config": {} - }, - { - "uuid": "9994619b-e68d-4c94-90d6-af19fb944f7d", - "rules": [ - { - "category": { - "base": "numeric" - }, - "uuid": "c4881d22-57aa-4964-abbc-aaf26b875614", - "destination": "a4a37023-de22-4ac4-b431-da2a333c93cd", - "label": null, - "destination_type": "A", - "test": { - "type": "number" - } - }, - { - "category": { - "base": "Other" - }, - "uuid": "6cd3fb0c-070d-4060-bafc-badaebe5134e", - "destination": null, - "label": null, - "destination_type": null, - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "Number", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 679, - "x": 267, - "config": {} - }, - { - "uuid": "01cc820b-c516-4e68-8903-aa69866b11b6", - "rules": [ - { - "category": { - "base": "state" - }, - "uuid": "4ef398b1-d3f1-4023-b608-8803cc05dd20", - "destination": "7e8dfcd5-6510-4060-9608-2c8faa3a8e0a", - "label": null, - "destination_type": "A", - "test": { - "type": "state" - } - }, - { - "category": { - "base": "Other" - }, - "uuid": "38a4583c-cf73-454c-80e5-09910cf92f4b", - "destination": null, - "label": null, - "destination_type": null, - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "State", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 956, - "x": 271, - "config": {} - }, - { - "uuid": "498b1953-02f1-47dd-b9cb-1b51913e348f", - "rules": [ - { - "category": { - "base": "district", - "fre": "le district" - }, - "uuid": "47147597-00c6-44bc-95d2-bebec9f1a45b", - "destination": "605e2fe7-321a-4cce-b97b-877d75bd3b12", - "label": null, - "destination_type": "A", - "test": { - "test": "@flow.state", - "type": "district" - } - }, - { - "category": { - "base": "Other" - }, - "uuid": "1145c620-2512-4228-b561-80024bbd91ee", - "destination": null, - "label": null, - "destination_type": null, - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "District", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 1355, - "x": 266, - "config": {} - }, - { - "uuid": "ea2244de-7b23-4fbb-8f99-38cde3100de8", - "rules": [ - { - "category": { - "base": "ward" - }, - "uuid": "b5159826-a55a-4803-a656-64d47803e8bf", - "destination": null, - "label": null, - "destination_type": null, - "test": { - "state": "@flow.state.", - "type": "ward", - "district": "@flow.district" - } - }, - { - "category": { - "base": "Other" - }, - "uuid": "c1aa2a53-4d85-4fdd-953e-7e24b06cc7ea", - "destination": null, - "label": null, - "destination_type": null, - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "Ward", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 1584, - "x": 268, - "config": {} - } - ], - "metadata": { - "expires": 10080, - "revision": 19, - "uuid": "d7468d97-b8d7-482e-a09c-d0bfe839c555", - "name": "Type Flow", - "saved_on": "2017-10-30T19:38:39.814935Z" - } - } - ], - "triggers": [ - { - "trigger_type": "K", - "flow": { - "name": "Type Flow", - "uuid": "d7468d97-b8d7-482e-a09c-d0bfe839c555" - }, - "groups": [], - "keyword": "types", - "channel": null - } - ] -} \ No newline at end of file diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 930c66db048..756fb7c07e7 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -2788,7 +2788,7 @@ def test_definitions(self): self.assertPostNotAllowed(endpoint_url) self.assertDeleteNotAllowed(endpoint_url) - self.import_file("subflow") + self.import_file("test_flows/subflow.json") flow = Flow.objects.get(name="Parent Flow") # all flow dependencies and we should get the child flow @@ -2806,7 +2806,7 @@ def test_definitions(self): ) # import the clinic app which has campaigns - self.import_file("the_clinic") + self.import_file("test_flows/the_clinic.json") # our catchall flow, all alone flow = Flow.objects.get(name="Catch All") @@ -2874,7 +2874,7 @@ def test_definitions(self): ) # test that flows are migrated - self.import_file("favorites_v13") + self.import_file("test_flows/favorites_v13.json") flow = Flow.objects.get(name="Favorites") self.assertGet( diff --git a/temba/campaigns/tests.py b/temba/campaigns/tests.py index c08f9aa0816..0f13426a3f7 100644 --- a/temba/campaigns/tests.py +++ b/temba/campaigns/tests.py @@ -10,7 +10,7 @@ from temba.campaigns.views import CampaignEventCRUDL from temba.contacts.models import ContactField -from temba.flows.models import Flow, FlowRevision +from temba.flows.models import Flow from temba.msgs.models import Msg from temba.orgs.models import DefinitionExport, Org from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom @@ -159,7 +159,7 @@ def test_get_sorted_events(self): # create a campaign campaign = Campaign.create(self.org, self.user, "Planting Reminders", self.farmers) - flow = self.create_flow("Test") + flow = self.create_flow("Test 1") event1 = CampaignEvent.create_flow_event( self.org, self.admin, campaign, self.planting_date, offset=1, unit="W", flow=flow, delivery_hour="13" @@ -173,22 +173,15 @@ def test_get_sorted_events(self): self.assertEqual(campaign.get_sorted_events(), [event2, event1, event3]) - flow_json = self.get_flow_json("favorites") - flow = Flow.objects.create( - name="Call Me Maybe", - org=self.org, - is_system=True, - created_by=self.admin, - modified_by=self.admin, - saved_by=self.admin, - version_number="13.5.0", - flow_type="V", - ) - - FlowRevision.objects.create(flow=flow, definition=flow_json, spec_version=3, revision=1, created_by=self.admin) - event4 = CampaignEvent.create_flow_event( - self.org, self.admin, campaign, self.planting_date, offset=2, unit="W", flow=flow, delivery_hour="5" + self.org, + self.admin, + campaign, + self.planting_date, + offset=2, + unit="W", + flow=self.create_flow("Test 2"), + delivery_hour="5", ) self.assertEqual(campaign.get_sorted_events(), [event2, event1, event3, event4]) @@ -801,7 +794,7 @@ def test_eventfire_get_relative_to_value(self): self.assertIsNotNone(ev4.get_relative_to_value()) def test_import(self): - self.import_file("the_clinic") + self.import_file("test_flows/the_clinic.json") self.assertEqual(1, Campaign.objects.count()) campaign = Campaign.objects.get() diff --git a/temba/flows/legacy/tests.py b/temba/flows/legacy/tests.py index 9350fb45ea4..4e2e5a6b22b 100644 --- a/temba/flows/legacy/tests.py +++ b/temba/flows/legacy/tests.py @@ -88,6 +88,12 @@ def test_migrate_v7_template(self): class FlowMigrationTest(TembaTest): + def load_flow(self, filename: str, substitutions=None, name=None): + return self.get_flow(f"legacy/migrations/{filename}", substitutions=substitutions, name=name) + + def load_flow_def(self, filename: str, substitutions=None): + return self.load_json(f"test_flows/legacy/migrations/{filename}.json", substitutions=substitutions)["flows"][0] + def migrate_flow(self, flow, to_version=None): if not to_version: to_version = Flow.FINAL_LEGACY_VERSION @@ -120,7 +126,7 @@ def test_migrate_malformed_single_message_flow(self): version_number="3", ) - flow_json = self.get_flow_json("malformed_single_message")["definition"] + flow_json = self.load_flow_def("malformed_single_message")["definition"] FlowRevision.objects.create(flow=flow, definition=flow_json, spec_version=3, revision=1, created_by=self.admin) @@ -132,7 +138,7 @@ def test_migrate_malformed_single_message_flow(self): self.assertEqual(2, flow_json["revision"]) def test_migrate_to_11_12(self): - flow = self.get_flow("favorites") + flow = self.load_flow("favorites") definition = { "entry": "79b4776b-a995-475d-ae06-1cab9af8a28e", "rule_sets": [], @@ -225,23 +231,23 @@ def test_migrate_to_11_12(self): # removed the invalid reference self.assertEqual(len(migrated["action_sets"]), 2) - flow = self.get_flow("migrate_to_11_12") - flow_json = self.get_flow_json("migrate_to_11_12") + flow = self.load_flow("migrate_to_11_12") + flow_json = self.load_flow_def("migrate_to_11_12") migrated = migrate_to_version_11_12(flow_json, flow) self.assertEqual(migrated["action_sets"][0]["actions"][0]["msg"]["base"], "Hey there, Yes or No?") self.assertEqual(len(migrated["action_sets"]), 3) def test_migrate_to_11_12_with_one_node(self): - flow = self.get_flow("migrate_to_11_12_one_node") - flow_json = self.get_flow_json("migrate_to_11_12_one_node") + flow = self.load_flow("migrate_to_11_12_one_node") + flow_json = self.load_flow_def("migrate_to_11_12_one_node") migrated = migrate_to_version_11_12(flow_json, flow) self.assertEqual(len(migrated["action_sets"]), 0) def test_migrate_to_11_12_other_org_existing_flow(self): - flow = self.get_flow("migrate_to_11_12_other_org", {"CHANNEL-UUID": str(self.channel.uuid)}) - flow_json = self.get_flow_json("migrate_to_11_12_other_org", {"CHANNEL-UUID": str(self.channel.uuid)}) + flow = self.load_flow("migrate_to_11_12_other_org", {"CHANNEL-UUID": str(self.channel.uuid)}) + flow_json = self.load_flow_def("migrate_to_11_12_other_org", {"CHANNEL-UUID": str(self.channel.uuid)}) # change ownership of the channel it's referencing self.channel.org = self.org2 @@ -256,14 +262,14 @@ def test_migrate_to_11_12_channel_dependencies(self): self.channel.name = "1234" self.channel.save() - self.get_flow("migrate_to_11_12_one_node") + self.load_flow("migrate_to_11_12_one_node") flow = Flow.objects.filter(name="channel").first() self.assertEqual(flow.channel_dependencies.count(), 1) def test_migrate_to_11_11(self): - flow = self.get_flow("migrate_to_11_11") - flow_json = self.get_flow_json("migrate_to_11_11") + flow = self.load_flow("migrate_to_11_11") + flow_json = self.load_flow_def("migrate_to_11_11") migrated = migrate_to_version_11_11(flow_json, flow) migrated_labels = get_labels(migrated) @@ -271,7 +277,7 @@ def test_migrate_to_11_11(self): self.assertTrue(Label.objects.filter(uuid=uuid, name=name).exists(), msg="Label UUID mismatch") def test_migrate_to_11_10(self): - import_def = self.get_import_json("migrate_to_11_10") + import_def = self.load_json("test_flows/legacy/migrations/migrate_to_11_10.json") migrated_import = migrate_export_to_version_11_10(import_def, self.org) migrated = migrated_import["flows"][1] @@ -322,14 +328,14 @@ def test_migrate_to_11_10(self): ) def test_migrate_to_11_9(self): - flow = self.get_flow("migrate_to_11_9", name="Master") + flow = self.load_flow("migrate_to_11_9", name="Master") # give our flows same UUIDs as in import and make 2 of them invalid Flow.objects.filter(name="Valid1").update(uuid="b823cc3b-aaa6-4cd1-b7a5-28d6b492cfa3") Flow.objects.filter(name="Invalid1").update(uuid="ad40071e-a665-4df3-af14-0bc0fe589244", is_archived=True) Flow.objects.filter(name="Invalid2").update(uuid="136cdab3-e9d1-458c-b6eb-766afd92b478", is_active=False) - import_def = self.get_import_json("migrate_to_11_9") + import_def = self.load_json("test_flows/legacy/migrations/migrate_to_11_9.json") flow_def = import_def["flows"][-1] self.assertEqual(len(flow_def["rule_sets"]), 4) @@ -349,7 +355,7 @@ def get_rule_uuids(f): uuids.append(rule["uuid"]) return uuids - original = self.get_flow_json("migrate_to_11_8") + original = self.load_flow_def("migrate_to_11_8") original_uuids = get_rule_uuids(original) self.assertEqual(len(original_uuids), 9) @@ -363,7 +369,7 @@ def get_rule_uuids(f): self.assertEqual(len(set(migrated_uuids).difference(original_uuids)), 2) def test_migrate_to_11_7(self): - original = self.get_flow_json("migrate_to_11_7") + original = self.load_flow_def("migrate_to_11_7") self.assertEqual(len(original["action_sets"]), 5) self.assertEqual(len(original["rule_sets"]), 1) @@ -374,8 +380,8 @@ def test_migrate_to_11_7(self): self.assertEqual(len(migrated["rule_sets"]), 6) def test_migrate_to_11_6(self): - flow = self.get_flow("migrate_to_11_6") - flow_json = self.get_flow_json("migrate_to_11_6") + flow = self.load_flow("migrate_to_11_6") + flow_json = self.load_flow_def("migrate_to_11_6") migrated = migrate_to_version_11_6(flow_json, flow) migrated_groups = get_legacy_groups(migrated) @@ -383,7 +389,7 @@ def test_migrate_to_11_6(self): self.assertTrue(ContactGroup.objects.filter(uuid=uuid, name=name).exists(), msg="Group UUID mismatch") def test_migrate_to_11_5(self): - flow_json = self.get_flow_json("migrate_to_11_5") + flow_json = self.load_flow_def("migrate_to_11_5") flow_json = migrate_to_version_11_5(flow_json) # check text was updated in the reply action @@ -436,7 +442,7 @@ def test_migrate_to_11_5(self): @mock_mailroom def test_migrate_to_11_4(self, mr_mocks): - flow_json = self.get_flow_json("migrate_to_11_4") + flow_json = self.load_flow_def("migrate_to_11_4") migrated = migrate_to_version_11_4(flow_json.copy()) # gather up replies to check expressions were migrated @@ -463,7 +469,7 @@ def test_migrate_to_11_4(self, mr_mocks): self.assertEqual("", migrated["action_sets"][0]["actions"][0]["msg"]["eng"]) def test_migrate_to_11_3(self): - flow_json = self.get_flow_json("migrate_to_11_3") + flow_json = self.load_flow_def("migrate_to_11_3") migrated = migrate_to_version_11_3(flow_json) @@ -643,8 +649,8 @@ def test_migrate_to_11_0(self): self.create_field("district", "District", ContactField.TYPE_DISTRICT) self.create_field("joined_on", "Joined On", ContactField.TYPE_DATETIME) - flow = self.get_flow("type_flow") - flow_def = self.get_flow_json("type_flow") + flow = self.load_flow("type_flow") + flow_def = self.load_flow_def("type_flow") migrated = migrate_to_version_11_0(flow_def, flow) # gather up replies to check expressions were migrated @@ -672,7 +678,7 @@ def test_migrate_to_11_0(self): ) def test_migrate_to_11_0_with_null_ruleset_label(self): - flow = self.get_flow("migrate_to_11_0") + flow = self.load_flow("migrate_to_11_0") definition = { "rule_sets": [ { @@ -693,7 +699,7 @@ def test_migrate_to_11_0_with_null_ruleset_label(self): self.assertEqual(migrated, definition) def test_migrate_to_11_0_with_null_msg_text(self): - flow = self.get_flow("migrate_to_11_0") + flow = self.load_flow("migrate_to_11_0") definition = { "action_sets": [ { @@ -710,8 +716,8 @@ def test_migrate_to_11_0_with_null_msg_text(self): self.assertEqual(migrated, definition) def test_migrate_to_11_0_with_broken_localization(self): - flow = self.get_flow("migrate_to_11_0") - flow_def = self.get_flow_json("migrate_to_11_0") + flow = self.load_flow("migrate_to_11_0") + flow_def = self.load_flow_def("migrate_to_11_0") migrated = migrate_to_version_11_0(flow_def, flow) self.assertEqual( @@ -741,7 +747,7 @@ def test_migrate_to_10_4(self): self.assertIsNotNone(action["uuid"]) def test_migrate_to_10_3(self): - flow_def = self.get_flow_json("favorites") + flow_def = self.load_flow_def("favorites") migrated = migrate_to_version_10_3(flow_def, flow=None) # make sure all of our action sets have an exit uuid @@ -749,13 +755,13 @@ def test_migrate_to_10_3(self): self.assertIsNotNone(actionset.get("exit_uuid")) def test_migrate_to_10_2(self): - flow_def = self.get_flow_json("single_message_bad_localization") + flow_def = self.load_flow_def("single_message_bad_localization") migrated = migrate_to_version_10_2(flow_def) self.assertEqual("Campaign Message 12", migrated["action_sets"][0]["actions"][0]["msg"]["eng"]) def test_migrate_to_10_1(self): - flow_def = self.get_flow_json("favorites") + flow_def = self.load_flow_def("favorites") migrated = migrate_to_version_10_1(flow_def, flow=None) # make sure all of our actions have uuids set @@ -765,8 +771,8 @@ def test_migrate_to_10_1(self): def test_migrate_to_10(self): # this is really just testing our rewriting of webhook rulesets - flow = self.get_flow("dual_webhook") - flow_def = self.get_flow_json("dual_webhook") + flow = self.load_flow("dual_webhook") + flow_def = self.load_flow_def("dual_webhook") # get our definition out migrated = migrate_to_version_10(flow_def, flow=flow) @@ -793,7 +799,7 @@ def test_migrate_to_9(self): label_id=label.pk, ) - exported_json = self.get_import_json("migrate_to_9", substitutions) + exported_json = self.load_json("test_flows/legacy/migrations/migrate_to_9.json", substitutions) exported_json = migrate_export_to_version_9(exported_json, self.org, True) # our campaign events shouldn't have ids @@ -855,23 +861,23 @@ def test_migrate_to_9(self): self.assertNotIn("id", flow_json["metadata"]) # import the same thing again, should have the same uuids - new_exported_json = self.get_import_json("migrate_to_9", substitutions) + new_exported_json = self.load_json("test_flows/legacy/migrations/migrate_to_9.json", substitutions) new_exported_json = migrate_export_to_version_9(new_exported_json, self.org, True) self.assertEqual(flow_json["metadata"]["uuid"], new_exported_json["flows"][0]["metadata"]["uuid"]) # but when done as a different site, it should be unique - new_exported_json = self.get_import_json("migrate_to_9", substitutions) + new_exported_json = self.load_json("test_flows/legacy/migrations/migrate_to_9.json", substitutions) new_exported_json = migrate_export_to_version_9(new_exported_json, self.org, False) self.assertNotEqual(flow_json["metadata"]["uuid"], new_exported_json["flows"][0]["metadata"]["uuid"]) # can also just import a single flow - exported_json = self.get_import_json("migrate_to_9", substitutions) + exported_json = self.load_json("test_flows/legacy/migrations/migrate_to_9.json", substitutions) flow_json = migrate_to_version_9(exported_json["flows"][0], start_flow) self.assertIn("uuid", flow_json["metadata"]) self.assertNotIn("id", flow_json["metadata"]) # try it with missing metadata - flow_json = self.get_import_json("migrate_to_9", substitutions)["flows"][0] + flow_json = self.load_json("test_flows/legacy/migrations/migrate_to_9.json", substitutions)["flows"][0] del flow_json["metadata"] flow_json = migrate_to_version_9(flow_json, start_flow) self.assertEqual(1, flow_json["metadata"]["revision"]) @@ -885,7 +891,7 @@ def test_migrate_to_9(self): def test_migrate_to_8(self): # file uses old style expressions - flow_json = self.get_flow_json("old_expressions") + flow_json = self.load_flow_def("old_expressions") # migrate to the version right before us first flow_json = migrate_to_version_7(flow_json) @@ -904,7 +910,7 @@ def test_migrate_to_8(self): self.assertEqual(flow_json["rule_sets"][1]["operand"], "@(step.value + 3)") def test_migrate_to_7(self): - flow_json = self.get_flow_json("ivr_v3") + flow_json = self.load_flow_def("ivr_v3") # migrate to the version right before us first flow_json = migrate_to_version_5(flow_json) @@ -926,7 +932,7 @@ def test_migrate_to_7(self): def test_migrate_to_6(self): # file format is old non-localized format - voice_json = self.get_flow_json("ivr_v3") + voice_json = self.load_flow_def("ivr_v3") definition = voice_json.get("definition") # no language set @@ -948,7 +954,7 @@ def test_migrate_to_6(self): self.assertEqual("/recording.mp3", definition["action_sets"][0]["actions"][0]["recording"]["base"]) # now try one that doesn't have a recording set - voice_json = self.get_flow_json("ivr_v3") + voice_json = self.load_flow_def("ivr_v3") definition = voice_json.get("definition") del definition["action_sets"][0]["actions"][0]["recording"] voice_json = migrate_to_version_5(voice_json) @@ -957,7 +963,7 @@ def test_migrate_to_6(self): self.assertNotIn("recording", definition["action_sets"][0]["actions"][0]) def test_migrate_to_5_language(self): - flow_json = self.get_flow_json("multi_language_flow") + flow_json = self.load_flow_def("multi_language_flow") ruleset = flow_json["definition"]["rule_sets"][0] ruleset["operand"] = "@step.value|lower_case" @@ -980,7 +986,7 @@ def test_migrate_to_5_language(self): self.assertEqual("Otro", rules[0]["category"]["spa"]) def test_migrate_to_5(self): - flow = self.get_flow_json("favorites_v4") + flow = self.load_flow_def("favorites_v4") migrated = migrate_to_version_5(flow)["definition"] # first node should be a wait node @@ -1042,7 +1048,7 @@ def test_migrate_bad_group_names(self): # at the time of this fix for v in ("4", "5", "6", "7", "8", "9", "10"): error = 'Failure migrating group names "%s" forward from v%s' - flow = self.get_flow("favorites_bad_group_name_v%s" % v) + flow = self.load_flow("favorites_bad_group_name_v%s" % v) self.assertIsNotNone(flow, "Failure importing favorites from v%s" % v) self.assertTrue(ContactGroup.objects.filter(name="Contacts < 25").exists(), error % ("< 25", v)) self.assertTrue(ContactGroup.objects.filter(name="Contacts > 100").exists(), error % ("> 100", v)) @@ -1052,7 +1058,7 @@ def test_migrate_bad_group_names(self): flow.release(self.admin) def test_migrate_malformed_groups(self): - flow = self.get_flow("malformed_groups") + flow = self.load_flow("malformed_groups") self.assertIsNotNone(flow) self.assertTrue(ContactGroup.objects.filter(name="Contacts < 25").exists()) self.assertTrue(ContactGroup.objects.filter(name="Unknown").exists()) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index da0f35f24b3..eee59e9a529 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -1156,24 +1156,6 @@ def test_flow_results_with_hidden_results(self): ], ) - def test_legacy_validate_definition(self): - with self.assertRaises(ValueError): - FlowRevision.validate_legacy_definition({"flow_type": "U", "nodes": []}) - - with self.assertRaises(ValueError): - FlowRevision.validate_legacy_definition(self.get_flow_json("not_fully_localized")) - - # base_language of null, but spec version 8 - with self.assertRaises(ValueError): - FlowRevision.validate_legacy_definition(self.get_flow_json("no_base_language_v8")) - - # base_language of 'eng' but non localized actions - with self.assertRaises(ValueError): - FlowRevision.validate_legacy_definition(self.get_flow_json("non_localized_with_language")) - - with self.assertRaises(ValueError): - FlowRevision.validate_legacy_definition(self.get_flow_json("non_localized_ruleset")) - def test_importing_dependencies(self): # create channel to be matched by name channel = self.create_channel("TG", "RapidPro Test", "12345324635") @@ -2143,7 +2125,7 @@ def test_get_definition(self): # make a flow that looks like a legacy flow flow = self.get_flow("color_v11") - original_def = self.get_flow_json("color_v11") + original_def = self.load_json("test_flows/color_v11.json")["flows"][0] flow.version_number = "11.12" flow.save(update_fields=("version_number",)) @@ -2173,7 +2155,7 @@ def test_fetch_revisions(self): # we should have one revision for an imported flow flow = self.get_flow("color_v11") - original_def = self.get_flow_json("color_v11") + original_def = self.load_json("test_flows/color_v11.json")["flows"][0] # rewind definition to legacy spec revision = flow.revisions.get() @@ -5297,6 +5279,28 @@ def test_mailroom_url(self): class FlowRevisionTest(TembaTest): + def test_validate_legacy_definition(self): + def validate(flow_def: dict, expected_error: str): + with self.assertRaises(ValueError) as cm: + FlowRevision.validate_legacy_definition(flow_def) + self.assertEqual(expected_error, str(cm.exception)) + + validate({"flow_type": "U", "nodes": []}, "unsupported flow type") + validate(self.load_json("test_flows/legacy/invalid/not_fully_localized.json"), "non-localized flow definition") + + # base_language of null, but spec version 8 + validate(self.load_json("test_flows/legacy/invalid/no_base_language_v8.json"), "non-localized flow definition") + + # base_language of 'eng' but non localized actions + validate( + self.load_json("test_flows/legacy/invalid/non_localized_with_language.json"), + "non-localized flow definition", + ) + + validate( + self.load_json("test_flows/legacy/invalid/non_localized_ruleset.json"), "non-localized flow definition" + ) + def test_trim_revisions(self): start = timezone.now() diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 66f983b185a..ef2af8363c4 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -3901,7 +3901,7 @@ def test_import_validation(self): def test_trigger_dependency(self): # tests the case of us doing an export of only a single flow (despite dependencies) and making sure we # don't include the triggers of our dependent flows (which weren't exported) - self.import_file("parent_child_trigger") + self.import_file("test_flows/parent_child_trigger.json") parent = Flow.objects.filter(name="Parent Flow").first() @@ -3913,7 +3913,7 @@ def test_trigger_dependency(self): self.assertFalse(exported["triggers"]) def test_subflow_dependencies(self): - self.import_file("subflow") + self.import_file("test_flows/subflow.json") parent = Flow.objects.filter(name="Parent Flow").first() child = Flow.objects.filter(name="Child Flow").first() @@ -3993,7 +3993,7 @@ def test_import_errors(self): self.assertIsNone(Flow.objects.filter(org=self.org, name="New Mother").first()) def test_import_campaign_with_translations(self): - self.import_file("campaign_import_with_translations") + self.import_file("test_flows/campaign_import_with_translations.json") campaign = Campaign.objects.all().first() event = campaign.events.all().first() @@ -4011,7 +4011,7 @@ def test_import_campaign_with_translations(self): self.assertEqual(flow_def["localization"]["eng"][action["uuid"]]["text"], ["Hey"]) def test_reimport(self): - self.import_file("survey_campaign") + self.import_file("test_flows/survey_campaign.json") campaign = Campaign.objects.filter(is_active=True).last() event = campaign.events.filter(is_active=True).last() @@ -4021,7 +4021,7 @@ def test_reimport(self): campaign.group.contacts.add(sally) # importing it again shouldn't result in failures - self.import_file("survey_campaign") + self.import_file("test_flows/survey_campaign.json") # get our latest campaign and event new_campaign = Campaign.objects.filter(is_active=True).last() @@ -4032,7 +4032,7 @@ def test_reimport(self): self.assertNotEqual(event.id, new_event.id) def test_import_mixed_flow_versions(self): - self.import_file("mixed_versions") + self.import_file("test_flows/mixed_versions.json") group = ContactGroup.objects.get(name="Survey Audience") @@ -4051,7 +4051,7 @@ def test_import_mixed_flow_versions(self): self.assertEqual(dep_graph[parent], {child}) def test_import_dependency_types(self): - self.import_file("all_dependency_types") + self.import_file("test_flows/all_dependency_types.json") parent = Flow.objects.get(name="All Dep Types") child = Flow.objects.get(name="New Child") @@ -4081,7 +4081,7 @@ def test_import_flow_issues(self, mr_mocks): # final call is after new flows and dependencies have been committed so mailroom can see them mr_mocks.flow_inspect(dependencies=[{"key": "age", "name": "", "type": "field", "missing": False}]) - self.import_file("color") + self.import_file("test_flows/color.json") flow = Flow.objects.get() @@ -4089,7 +4089,7 @@ def test_import_flow_issues(self, mr_mocks): def test_import_missing_flow_dependency(self): # in production this would blow up validating the flow but we can't do that during tests - self.import_file("parent_without_its_child") + self.import_file("test_flows/parent_without_its_child.json") parent = Flow.objects.get(name="Single Parent") self.assertEqual(set(parent.flow_dependencies.all()), set()) @@ -4097,7 +4097,7 @@ def test_import_missing_flow_dependency(self): # create child with that name and re-import child1 = Flow.create(self.org, self.admin, "New Child", Flow.TYPE_MESSAGE) - self.import_file("parent_without_its_child") + self.import_file("test_flows/parent_without_its_child.json") self.assertEqual(set(parent.flow_dependencies.all()), {child1}) # create child with that UUID and re-import @@ -4105,7 +4105,7 @@ def test_import_missing_flow_dependency(self): self.org, self.admin, "New Child 2", Flow.TYPE_MESSAGE, uuid="a925453e-ad31-46bd-858a-e01136732181" ) - self.import_file("parent_without_its_child") + self.import_file("test_flows/parent_without_its_child.json") self.assertEqual(set(parent.flow_dependencies.all()), {child2}) def validate_flow_dependencies(self, definition): @@ -4132,7 +4132,7 @@ def test_implicit_field_and_group_imports(self): """ Tests importing flow definitions without fields and groups included in the export """ - data = self.get_import_json("cataclysm") + data = self.load_json("test_flows/cataclysm.json") del data["fields"] del data["groups"] @@ -4154,7 +4154,7 @@ def test_implicit_field_and_explicit_group_imports(self, mr_mocks): """ Tests importing flow definitions with groups included in the export but not fields """ - data = self.get_import_json("cataclysm") + data = self.load_json("test_flows/cataclysm.json") del data["fields"] mr_mocks.contact_parse_query("facts_per_day = 1", fields=["facts_per_day"]) @@ -4197,7 +4197,7 @@ def test_explicit_field_and_group_imports(self, mr_mocks): mr_mocks.contact_parse_query("facts_per_day = 1", fields=["facts_per_day"]) mr_mocks.contact_parse_query("likes_cats = true", cleaned='likes_cats = "true"', fields=["likes_cats"]) - self.import_file("cataclysm") + self.import_file("test_flows/cataclysm.json") flow = Flow.objects.get(name="Cataclysmic") self.validate_flow_dependencies(flow.get_definition()) @@ -4242,7 +4242,7 @@ def test_import_flow_with_triggers(self): self.org, self.admin, Trigger.TYPE_KEYWORD, flow2, keywords=["rating"], match_type=Trigger.MATCH_FIRST_WORD ) - data = self.get_import_json("rating_10") + data = self.load_json("test_flows/rating_10.json") self.org.import_app(data, self.admin, site="http://rapidpro.io") @@ -4264,7 +4264,7 @@ def test_import_flow_with_triggers(self): flow_trigger.archive(self.admin) # re import again will restore the trigger - data = self.get_import_json("rating_10") + data = self.load_json("test_flows/rating_10.json") self.org.import_app(data, self.admin, site="http://rapidpro.io") flow_trigger.refresh_from_db() @@ -4312,7 +4312,7 @@ def assert_object_counts(): ) # import all our bits - self.import_file("the_clinic") + self.import_file("test_flows/the_clinic.json") confirm_appointment = Flow.objects.get(name="Confirm Appointment") self.assertEqual(10080, confirm_appointment.expires_after_minutes) @@ -4334,7 +4334,7 @@ def assert_object_counts(): message_flow.update_single_message_flow(self.admin, {"eng": "No reminders for you!"}, base_language="eng") # now reimport - self.import_file("the_clinic") + self.import_file("test_flows/the_clinic.json") # our flow should get reset from the import confirm_appointment.refresh_from_db() diff --git a/temba/tests/base.py b/temba/tests/base.py index 34a7140e44f..1bc0b49a731 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -1,4 +1,5 @@ import copy +import os from datetime import datetime from functools import wraps from io import BytesIO @@ -165,26 +166,31 @@ def login(self, user, update_last_auth_on: bool = True, choose_org=None): session.update({"org_id": choose_org.id}) session.save() - 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) + def load_json(self, path: str, substitutions=None) -> dict: + """ + Loads a JSON test file from a path relatve to the media directory + """ - def get_import_json(self, filename, substitutions=None): - handle = open("%s/test_flows/%s.json" % (settings.MEDIA_ROOT, filename), "r+") - data = handle.read() - handle.close() + with open(os.path.join(settings.MEDIA_ROOT, path), "r") as f: + data = f.read() - if substitutions: - for k, v in substitutions.items(): - print('Replacing "%s" with "%s"' % (k, v)) - data = data.replace(k, str(v)) + if substitutions: + for k, v in substitutions.items(): + data = data.replace(k, str(v)) - return json.loads(data) + return json.loads(data) + + def import_file(self, path: str, substitutions=None): + """ + Imports definitions from a JSON file + """ + + self.org.import_app(self.load_json(path, substitutions), self.admin, site="http://rapidpro.io") def get_flow(self, filename, substitutions=None, name=None): now = timezone.now() - self.import_file(filename, substitutions=substitutions) + self.import_file(f"test_flows/{filename}.json", substitutions=substitutions) imported_flows = Flow.objects.filter(org=self.org, saved_on__gt=now) flow = imported_flows.filter(name=name).first() if name else imported_flows.order_by("id").last() @@ -194,10 +200,6 @@ def get_flow(self, filename, substitutions=None, name=None): flow.org = self.org return flow - def get_flow_json(self, filename, substitutions=None): - data = self.get_import_json(filename, substitutions=substitutions) - return data["flows"][0] - def create_user(self, email, group_names=(), **kwargs): user = User.objects.create_user(username=email, email=email, **kwargs) user.set_password(self.default_password) From b71fa5c66f0506cc3b53137552321384d27ff25a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 23 Aug 2024 18:08:22 +0000 Subject: [PATCH 024/557] Delete no longer used test flows --- media/test_flows/add_label.json | 107 --- media/test_flows/bad_send_action.json | 57 -- media/test_flows/cataclysm_legacy.json | 252 ------ media/test_flows/child.json | 37 - media/test_flows/color_gender_age.json | 215 ----- media/test_flows/favorites_timeout.json | 341 -------- media/test_flows/flow_starts.json | 141 --- media/test_flows/group_split.json | 296 ------- media/test_flows/loop_detection.json | 275 ------ media/test_flows/no_ruleset_flow.json | 36 - .../numeric_rule_allows_variables.json | 104 --- media/test_flows/parent.json | 59 -- media/test_flows/pick_a_number.json | 89 -- media/test_flows/preprocess.json | 71 -- media/test_flows/quick_replies.json | 156 ---- media/test_flows/random_word.json | 74 -- media/test_flows/rules_first.json | 120 --- media/test_flows/ruleset_loop.json | 142 --- media/test_flows/send_all.json | 40 - media/test_flows/sms_form.json | 299 ------- media/test_flows/start_missing_flow.json | 164 ---- .../start_missing_flow_from_actionset.json | 50 -- media/test_flows/substitution.json | 98 --- media/test_flows/test_db.json | 822 ------------------ media/test_flows/triggered.json | 106 --- media/test_flows/triggered_flow.json | 69 -- media/test_flows/two_to_all.json | 48 - media/test_flows/ussd_example.json | 160 ---- media/test_flows/webhook_rule_first.json | 69 -- media/test_flows/with_message_topic.json | 47 - 30 files changed, 4544 deletions(-) delete mode 100644 media/test_flows/add_label.json delete mode 100644 media/test_flows/bad_send_action.json delete mode 100644 media/test_flows/cataclysm_legacy.json delete mode 100644 media/test_flows/child.json delete mode 100644 media/test_flows/color_gender_age.json delete mode 100644 media/test_flows/favorites_timeout.json delete mode 100644 media/test_flows/flow_starts.json delete mode 100644 media/test_flows/group_split.json delete mode 100644 media/test_flows/loop_detection.json delete mode 100644 media/test_flows/no_ruleset_flow.json delete mode 100644 media/test_flows/numeric_rule_allows_variables.json delete mode 100644 media/test_flows/parent.json delete mode 100644 media/test_flows/pick_a_number.json delete mode 100644 media/test_flows/preprocess.json delete mode 100644 media/test_flows/quick_replies.json delete mode 100644 media/test_flows/random_word.json delete mode 100644 media/test_flows/rules_first.json delete mode 100644 media/test_flows/ruleset_loop.json delete mode 100644 media/test_flows/send_all.json delete mode 100644 media/test_flows/sms_form.json delete mode 100644 media/test_flows/start_missing_flow.json delete mode 100644 media/test_flows/start_missing_flow_from_actionset.json delete mode 100644 media/test_flows/substitution.json delete mode 100644 media/test_flows/test_db.json delete mode 100644 media/test_flows/triggered.json delete mode 100644 media/test_flows/triggered_flow.json delete mode 100644 media/test_flows/two_to_all.json delete mode 100644 media/test_flows/ussd_example.json delete mode 100644 media/test_flows/webhook_rule_first.json delete mode 100644 media/test_flows/with_message_topic.json diff --git a/media/test_flows/add_label.json b/media/test_flows/add_label.json deleted file mode 100644 index 9a41dc555db..00000000000 --- a/media/test_flows/add_label.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "version": "11.10", - "site": "https://textit.in", - "flows": [ - { - "entry": "22505d46-43c5-42ba-975e-725c01ea440f", - "action_sets": [ - { - "uuid": "22505d46-43c5-42ba-975e-725c01ea440f", - "x": 100, - "y": 0, - "destination": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", - "actions": [ - { - "type": "reply", - "uuid": "27dfd8ac-55c5-49c9-88e3-3fb84a9894ff", - "msg": { - "eng": "Hey" - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "6e2b09ec-3cc0-4ee6-ae7b-b76bad3ab6d3" - }, - { - "uuid": "f3a1a671-5f5b-489e-9410-9a09fa5eaafb", - "x": 95, - "y": 101, - "destination": "78c20ee4-94bd-45e6-8510-8e602568fb6e", - "actions": [ - { - "type": "add_label", - "uuid": "bc82c11d-7654-44e4-966c-fb39e2851df0", - "labels": [ - { - "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", - "name": "Hello" - } - ] - } - ], - "exit_uuid": "84bf44a1-13fd-44cb-8014-d6feb06e010f" - }, - { - "uuid": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", - "x": 146, - "y": 358, - "destination": null, - "actions": [ - { - "type": "add_label", - "uuid": "910bf3b5-951f-47a8-93df-11a6eac8bf0f", - "labels": [ - { - "uuid": "0bfecd01-9612-48ab-8c49-72170de6ee49", - "name": "Hello" - } - ] - } - ], - "exit_uuid": "6d579c28-9f3f-4584-bd2e-74009612fdbb" - } - ], - "rule_sets": [ - { - "uuid": "78c20ee4-94bd-45e6-8510-8e602568fb6e", - "x": 85, - "y": 219, - "label": "Response 1", - "rules": [ - { - "uuid": "33438bbf-49bd-4468-9a74-bbd7e1f58f57", - "category": { - "eng": "All Responses" - }, - "destination": "7ca2b0ef-0b23-4c6e-bccb-c5f2d62d2663", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "wait_message", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.10", - "metadata": { - "name": "Add Label", - "saved_on": "2019-02-12T09:23:05.746930Z", - "revision": 7, - "uuid": "e9b5b8ba-43f4-4bc2-a790-811ee1cfe392", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/bad_send_action.json b/media/test_flows/bad_send_action.json deleted file mode 100644 index d5b66d3eae2..00000000000 --- a/media/test_flows/bad_send_action.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "base_language": "base", - "action_sets": [ - { - "y": 795, - "x": 705, - "destination": "610d3c9d-7d2c-4aa4-b0eb-a07c823f6964", - "uuid": "0406607c-e711-4cbb-9c69-cbe3ce785dbd", - "actions": [ - { - "uuid": "c7e3dc19-dc4a-45a0-a4f7-7f57720c3ce5", - "contacts": [ - { - "urns": [ - { - "priority": 50, - "path": "+14255551212", - "scheme": "tel" - } - ], - "id": "contact1_id", - "name": "Mark" - }, - { - "urns": [ - { - "priority": 50, - "path": "+12065551212", - "scheme": "tel" - } - ], - "id": "contact2_id", - "name": "Gregg" - } - ], - "variables": [], - "groups": [], - "msg": { - "base": "Hey there, here's a message." - }, - "type": "send" - } - ] - }], - "version": 8, - "flow_type": "F", - "entry": "d41f3f4d-1742-44a0-b5d5-3d814c804832", - "rule_sets": [], - "type": "F", - "metadata": { - "revision": 110, - "expires": 5, - "saved_on": "2015-12-08T17:55:01.020719Z", - "uuid": "9e73669a-e71b-4e2d-ba3e-336ff0e6447b", - "name": "Send Action Test" - } -} \ No newline at end of file diff --git a/media/test_flows/cataclysm_legacy.json b/media/test_flows/cataclysm_legacy.json deleted file mode 100644 index 64500d8117c..00000000000 --- a/media/test_flows/cataclysm_legacy.json +++ /dev/null @@ -1,252 +0,0 @@ -{ - "version": "11.5", - "site": "https://textit.in", - "flows": [ - { - "entry": "c4462613-5936-42cc-a286-82e5f1816793", - "action_sets": [ - { - "uuid": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", - "x": 76, - "y": 99, - "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "actions": [ - { - "type": "add_group", - "uuid": "feb7a33e-bc8b-44d8-9112-bc4e910fe304", - "groups": [ - { - "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", - "name": "Cat Fanciers" - } - ] - }, - { - "type": "add_group", - "uuid": "ca82f0e0-43ca-426c-a77c-93cf297b8e7c", - "groups": [ - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - }, - { - "type": "reply", - "uuid": "d57e9e9f-ada4-4a22-99ef-b8bf3dbcdcae", - "msg": { - "eng": "You are a cat fan! Purrrrr." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "55f88a1e-73ad-4b6d-9a04-626046bbe5a8" - }, - { - "uuid": "ef389049-d2e3-4343-b91f-13ea2db5f943", - "x": 558, - "y": 94, - "destination": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "actions": [ - { - "type": "del_group", - "uuid": "cea907a8-af81-49af-92e6-f246e52179fe", - "groups": [ - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - }, - { - "type": "reply", - "uuid": "394a328f-f829-43f2-9975-fe2f27c8b786", - "msg": { - "eng": "You are not a cat fan. Hissssss." - }, - "media": {}, - "quick_replies": [], - "send_all": false - } - ], - "exit_uuid": "9ba78afa-948e-44c5-992f-84030f2eaa6b" - }, - { - "uuid": "d21be990-5e48-4e4b-995f-c9df8f38e517", - "x": 319, - "y": 323, - "destination": "35416fea-787d-48c1-b839-76eca089ad2e", - "actions": [ - { - "type": "channel", - "uuid": "78c58574-9f91-4c27-855e-73eacc99c395", - "channel": "bd55bb31-8ed4-4f89-b903-7103aa3762be", - "name": "Telegram: TextItBot" - } - ], - "exit_uuid": "c86638a9-2688-47c9-83ec-7f10ef49de1e" - }, - { - "uuid": "35416fea-787d-48c1-b839-76eca089ad2e", - "x": 319, - "y": 468, - "destination": null, - "actions": [ - { - "type": "reply", - "uuid": "30d35b8f-f439-482a-91b1-d3b1a4351071", - "msg": { - "eng": "All done." - }, - "media": {}, - "quick_replies": [], - "send_all": false - }, - { - "type": "send", - "uuid": "a7b6def8-d315-49bd-82e4-85887f39babe", - "msg": { - "eng": "Hey Cat Fans!" - }, - "contacts": [], - "groups": [ - { - "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", - "name": "Cat Blasts" - } - ], - "variables": [], - "media": {} - }, - { - "type": "trigger-flow", - "uuid": "540965e5-bdfe-4416-b4dd-449220b1c588", - "flow": { - "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", - "name": "Cataclysmic" - }, - "contacts": [], - "groups": [ - { - "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", - "name": "Cat Alerts" - } - ], - "variables": [] - } - ], - "exit_uuid": "f2ef5066-434d-42bc-a5cb-29c59e51432f" - } - ], - "rule_sets": [ - { - "uuid": "c4462613-5936-42cc-a286-82e5f1816793", - "x": 294, - "y": 0, - "label": "Response 1", - "rules": [ - { - "uuid": "17d69564-60c9-4a56-be8b-34e98a2ce14a", - "category": { - "eng": "Cat Facts" - }, - "destination": "eca0f1d7-59ef-4a7c-a4a9-9bbd049eb144", - "destination_type": "A", - "test": { - "type": "in_group", - "test": { - "name": "Cat Facts", - "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9" - } - }, - "label": null - }, - { - "uuid": "a9ec4d0a-2ddd-4a13-a1d2-c63ce9916a04", - "category": { - "eng": "Other" - }, - "destination": "ef389049-d2e3-4343-b91f-13ea2db5f943", - "destination_type": "A", - "test": { - "type": "true" - }, - "label": null - } - ], - "finished_key": null, - "ruleset_type": "group", - "response_type": "", - "operand": "@step.value", - "config": {} - } - ], - "base_language": "eng", - "flow_type": "M", - "version": "11.5", - "metadata": { - "name": "Cataclysmic", - "saved_on": "2018-10-18T17:03:54.835916Z", - "revision": 49, - "uuid": "ef9603ff-3886-4e5e-8870-0f643b6098de", - "expires": 10080, - "notes": [] - } - }, - { - "entry": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", - "action_sets": [ - { - "uuid": "0429d1f9-82ed-4198-80a2-3b213aa11fd5", - "x": 100, - "y": 0, - "destination": null, - "actions": [ - { - "type": "add_group", - "uuid": "11f61fc6-834e-4cbc-88ee-c834279345e6", - "groups": [ - { - "uuid": "22a48356-71e9-4ae1-9f93-4021855c0bd5", - "name": "Cat Alerts" - }, - { - "uuid": "c7bc1eef-b7aa-4959-ab90-3e33e0d3b1f9", - "name": "Cat Facts" - }, - { - "uuid": "47b1b36c-7736-47b9-b63a-c0ebfb610e61", - "name": "Cat Blasts" - }, - { - "uuid": "1966e54a-fc30-4a96-81ea-9b0185b8b7de", - "name": "Cat Fanciers" - }, - { - "uuid": "bc4d7100-60ac-44f0-aa78-0ec9373d2c2f", - "name": "Catnado" - } - ] - } - ], - "exit_uuid": "029a7c9d-c935-4ed1-9573-543ded29d954" - } - ], - "rule_sets": [], - "base_language": "eng", - "flow_type": "M", - "version": "11.5", - "metadata": { - "name": "Catastrophe", - "saved_on": "2018-10-18T19:03:07.702388Z", - "revision": 1, - "uuid": "d6dd96b1-d500-4c7a-9f9c-eae3f2a2a7c5", - "expires": 10080 - } - } - ], - "campaigns": [], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/child.json b/media/test_flows/child.json deleted file mode 100644 index 3edf4d2111e..00000000000 --- a/media/test_flows/child.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "entry": "f692e793-75a8-45a7-ba8c-4d568bd9d8a8", - "rule_sets": [], - "action_sets": [ - { - "y": 1, - "x": 107, - "destination": null, - "uuid": "f692e793-75a8-45a7-ba8c-4d568bd9d8a8", - "actions": [ - { - "uuid": "c9d2abd9-0966-435a-8663-d716b4393df5", - "value": "@date", - "label": "Campaign Date", - "field": "campaign_date", - "action": "GET", - "type": "save" - } - ] - } - ], - "last_saved": "2014-11-20T20:49:08.254645Z", - "metadata": {} - }, - "flow_type": "F", - "name": "Child", - "id": 300 - } - ], - "triggers": [] -} diff --git a/media/test_flows/color_gender_age.json b/media/test_flows/color_gender_age.json deleted file mode 100644 index 93387ef4308..00000000000 --- a/media/test_flows/color_gender_age.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "entry": "5dc9d8e1-90c6-4043-bf97-73d35138dc00", - "rule_sets": [ - { - "uuid": "c1911a46-ba8e-48e2-be8f-7c0be30edcdd", - "response_type": "C", - "rules": [ - { - "test": { - "test": "red", - "type": "contains_any" - }, - "category": "Red", - "destination": "f3b33749-c799-47f2-b242-160be2001550", - "uuid": "5a6cd1ec-6d09-4356-9fc0-7b7b71739add" - }, - { - "test": { - "test": "blue", - "type": "contains_any" - }, - "category": "Blue", - "destination": "f3b33749-c799-47f2-b242-160be2001550", - "uuid": "9e496bfe-227c-484e-9a3d-2ba607383c52" - }, - { - "test": { - "test": "green", - "type": "contains_any" - }, - "category": "Green", - "destination": "f3b33749-c799-47f2-b242-160be2001550", - "uuid": "075bb3b0-b104-4acf-8ee7-33046b207343" - }, - { - "test": { - "test": "maroon", - "type": "contains_any" - }, - "category": "Red", - "destination": "f3b33749-c799-47f2-b242-160be2001550", - "uuid": "2c9eb7fe-6084-47f3-9a70-745b11e76991" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "f3b33749-c799-47f2-b242-160be2001550", - "uuid": "7d217ae2-0a07-45db-95bb-32a887dc1f94" - } - ], - "label": "Color", - "operand": "@step.value", - "y": 146, - "x": 290 - }, - { - "uuid": "fd47736c-6b31-4330-8d49-7dfff3e391a1", - "response_type": "C", - "rules": [ - { - "test": { - "test": "Male", - "type": "contains_any" - }, - "category": "Male", - "destination": "5ad1b145-58ba-4b65-8cb5-84d98172b221", - "uuid": "e86168bc-64fc-4458-a970-884b11b96ffa" - }, - { - "test": { - "test": "Female", - "type": "contains_any" - }, - "category": "Female", - "destination": "5ad1b145-58ba-4b65-8cb5-84d98172b221", - "uuid": "5caa68fe-d64e-4ca6-a782-305930095c62" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "5ad1b145-58ba-4b65-8cb5-84d98172b221", - "uuid": "129ee4dc-2dff-4973-9d59-d9ccfc8748e1" - } - ], - "label": "Gender", - "operand": "@step.value", - "y": 414, - "x": 384 - }, - { - "uuid": "edec8286-536b-4924-8b94-0af69b41d4c2", - "response_type": "C", - "rules": [ - { - "test": { - "test": "18", - "type": "lt" - }, - "category": "Child", - "destination": "f25f5e8b-dc93-442b-a92d-8d01730e1d99", - "uuid": "c299e0cd-9c6a-4a3c-b7d5-9aa162d58062" - }, - { - "test": { - "test": "65", - "type": "lt" - }, - "category": "Adult", - "destination": "f25f5e8b-dc93-442b-a92d-8d01730e1d99", - "uuid": "139189b0-b4bf-4cfd-ab66-91ea3b40b406" - }, - { - "test": { - "type": "number" - }, - "category": "Senior", - "destination": "f25f5e8b-dc93-442b-a92d-8d01730e1d99", - "uuid": "23265c0d-4a6e-44a7-8d55-f549c801ccd9" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "f25f5e8b-dc93-442b-a92d-8d01730e1d99", - "uuid": "323b6e22-ae27-405e-b19d-b266f50b2db8" - } - ], - "label": "Age", - "operand": "@step.value", - "y": 695, - "x": 390 - } - ], - "action_sets": [ - { - "y": 0, - "x": 228, - "destination": "c1911a46-ba8e-48e2-be8f-7c0be30edcdd", - "uuid": "5dc9d8e1-90c6-4043-bf97-73d35138dc00", - "actions": [ - { - "msg": "What is your favorite color?", - "type": "reply" - } - ] - }, - { - "y": 298, - "x": 223, - "destination": "fd47736c-6b31-4330-8d49-7dfff3e391a1", - "uuid": "f3b33749-c799-47f2-b242-160be2001550", - "actions": [ - { - "msg": "What is your gender?", - "type": "reply" - } - ] - }, - { - "y": 557, - "x": 224, - "destination": "edec8286-536b-4924-8b94-0af69b41d4c2", - "uuid": "5ad1b145-58ba-4b65-8cb5-84d98172b221", - "actions": [ - { - "type": "save", - "field": "gender", - "label": "Gender", - "value": "@flow.gender" - }, - { - "msg": "What is your age?", - "type": "reply" - } - ] - }, - { - "y": 832, - "x": 217, - "destination": null, - "uuid": "f25f5e8b-dc93-442b-a92d-8d01730e1d99", - "actions": [ - { - "msg": "Thanks.", - "type": "reply" - } - ] - } - ], - "last_saved": "2014-06-26T14:18:09.205715Z", - "metadata": { - "notes": [] - } - }, - "flow_type": "F", - "name": "Color Age Gender", - "id": 23323 - } - ], - "triggers": [] -} diff --git a/media/test_flows/favorites_timeout.json b/media/test_flows/favorites_timeout.json deleted file mode 100644 index 276578cf45f..00000000000 --- a/media/test_flows/favorites_timeout.json +++ /dev/null @@ -1,341 +0,0 @@ -{ - "version": 7, - "flows": [ - { - "version": 7, - "flow_type": "M", - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", - "uuid": "127f3736-77ce-4006-9ab0-0c07cea88956", - "actions": [ - { - "msg": { - "base": "What is your favorite color?" - }, - "type": "reply" - } - ] - }, - { - "y": 237, - "x": 131, - "destination": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", - "uuid": "44471ade-7979-4c94-8028-6cfb68836337", - "actions": [ - { - "msg": { - "base": "Good choice, I like @flow.color.category too! What is your favorite beer?" - }, - "type": "reply" - } - ] - }, - { - "y": 8, - "x": 456, - "destination": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", - "uuid": "f9adf38f-ab18-49d3-a8ac-db2fe8f1e77f", - "actions": [ - { - "msg": { - "base": "I don't know that color. Try again." - }, - "type": "reply" - } - ] - }, - { - "y": 535, - "x": 191, - "destination": "ba95c5cd-e428-4a15-8b4b-23dd43943f2c", - "uuid": "89c5624e-3320-4668-a066-308865133080", - "actions": [ - { - "msg": { - "base": "Mmmmm... delicious @flow.beer.category. If only they made @flow.color|lower_case @flow.beer.category! Lastly, what is your name?" - }, - "type": "reply" - } - ] - }, - { - "y": 265, - "x": 512, - "destination": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", - "uuid": "a269683d-8229-4870-8585-be8320b9d8ca", - "actions": [ - { - "msg": { - "base": "I don't know that one, try again please." - }, - "type": "reply" - } - ] - }, - { - "y": 805, - "x": 191, - "destination": null, - "uuid": "10e483a8-5ffb-4c4f-917b-d43ce86c1d65", - "actions": [ - { - "msg": { - "base": "Thanks @flow.name, we are all done!" - }, - "type": "reply" - } - ] - }, - { - "uuid": "ba96d1c6-c721-470a-a04c-74015b1fdd35", - "x": 752, - "y": 1278, - "destination": null, - "actions": [ - { - "type": "reply", - "msg": { - "base": "Sorry you can't participate right now, I'll try again later." - } - } - ] - } - ], - "last_saved": "2015-09-15T02:37:08.805578Z", - "entry": "127f3736-77ce-4006-9ab0-0c07cea88956", - "rule_sets": [ - { - "uuid": "2bff5c33-9d29-4cfc-8bb7-0a1b9f97d830", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "base": "Red" - }, - "type": "contains_any" - }, - "category": { - "base": "Red" - }, - "destination": "44471ade-7979-4c94-8028-6cfb68836337", - "uuid": "8cd25a3f-0be2-494b-8b4c-3a4f0de7f9b2", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Green" - }, - "type": "contains_any" - }, - "category": { - "base": "Green" - }, - "destination": "44471ade-7979-4c94-8028-6cfb68836337", - "uuid": "db2863cf-7fda-4489-9345-d44dacf4e750", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Blue" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "44471ade-7979-4c94-8028-6cfb68836337", - "uuid": "2f462678-b176-49c1-bb5c-6e152502b0db", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Navy" - }, - "type": "contains_any" - }, - "category": { - "base": "Blue" - }, - "destination": "44471ade-7979-4c94-8028-6cfb68836337", - "uuid": "ecaeb59a-d7f1-4c21-a207-b2a29cc2488f", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Cyan" - }, - "type": "contains_any" - }, - "category": { - "base": "Cyan" - }, - "destination": null, - "uuid": "6f463a78-b176-49c1-bb5c-6e152502b0db", - "destination_type": null - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "f9adf38f-ab18-49d3-a8ac-db2fe8f1e77f", - "uuid": "df4455c2-806b-4af4-8ea9-f40278ec10e4", - "destination_type": "A" - }, - { - "uuid": "1023e76b-bd81-4720-a95e-a54a8fc3c328", - "category": { - "base": "No Response" - }, - "destination": "ba96d1c6-c721-470a-a04c-74015b1fdd35", - "destination_type": "A", - "test": { - "type": "timeout", - "minutes": 5 - } - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Color", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 129, - "x": 98, - "config": {} - }, - { - "uuid": "a5fc5f8a-f562-4b03-a54f-51928f9df07e", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "base": "Mutzig" - }, - "type": "contains_any" - }, - "category": { - "base": "Mutzig" - }, - "destination": "89c5624e-3320-4668-a066-308865133080", - "uuid": "ea304225-332e-49d4-9768-1e804cd0b6c2", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Primus" - }, - "type": "contains_any" - }, - "category": { - "base": "Primus" - }, - "destination": "89c5624e-3320-4668-a066-308865133080", - "uuid": "57f8688e-c263-43d7-bd06-bdb98f0c58a8", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Turbo King" - }, - "type": "contains_any" - }, - "category": { - "base": "Turbo King" - }, - "destination": "89c5624e-3320-4668-a066-308865133080", - "uuid": "670f0205-bb39-4e12-ae95-5e29251b8a3e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Skol" - }, - "type": "contains_any" - }, - "category": { - "base": "Skol" - }, - "destination": "89c5624e-3320-4668-a066-308865133080", - "uuid": "2ff4713f-c62f-445c-880c-de8f6532d090", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "a269683d-8229-4870-8585-be8320b9d8ca", - "uuid": "1fc4c133-d038-4f75-a69e-6e7e3190e5d8", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Beer", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 387, - "x": 112, - "config": {} - }, - { - "uuid": "ba95c5cd-e428-4a15-8b4b-23dd43943f2c", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "destination": "10e483a8-5ffb-4c4f-917b-d43ce86c1d65", - "uuid": "c072ecb5-0686-40ea-8ed3-898dc1349783", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Name", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 702, - "x": 191, - "config": {} - } - ], - "metadata": { - "notes": [], - "name": "Favorites", - "id": 35559, - "expires": 720, - "revision": 1 - } - } - ], - "triggers": [] -} diff --git a/media/test_flows/flow_starts.json b/media/test_flows/flow_starts.json deleted file mode 100644 index 702c333367a..00000000000 --- a/media/test_flows/flow_starts.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "campaigns": [], - "version": 4, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "f4eba3d0-3bfc-4564-a3ee-662cb0fac950", - "uuid": "f1eaf53b-27d8-4e93-818e-4c4808b21976", - "actions": [] - } - ], - "last_saved": "2015-03-31T13:24:32.741812Z", - "entry": "f1eaf53b-27d8-4e93-818e-4c4808b21976", - "rule_sets": [ - { - "uuid": "f4eba3d0-3bfc-4564-a3ee-662cb0fac950", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses", - "eng": "Other" - }, - "destination": null, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "e0d2f9fb-a300-4a21-b258-e594bd260310" - } - ], - "webhook": null, - "label": "Message Response", - "operand": "@step.value", - "finished_key": null, - "response_type": "C", - "y": 0, - "x": 200 - } - ], - "metadata": {} - }, - "id": 13968, - "flow_type": "F", - "name": "Child Flow" - }, - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 163, - "x": 201, - "destination": null, - "uuid": "59fa8d6c-50fd-4686-a45f-d9a30f616b80", - "actions": [ - { - "type": "flow", - "name": "Child Flow", - "id": 13968 - } - ] - } - ], - "last_saved": "2015-03-31T13:27:02.568148Z", - "entry": "f4eba3d0-3bfc-4564-a3ee-662cb0fac9f0", - "rule_sets": [ - { - "uuid": "f4eba3d0-3bfc-4564-a3ee-662cb0fac9f0", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": { - "eng": "Foo" - }, - "base": "Foo", - "type": "contains_any" - }, - "category": { - "base": "Foo", - "eng": "Foo" - }, - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "0265fb76-e6df-458f-8727-0b2d08f040ec" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses", - "eng": "Other" - }, - "destination": "59fa8d6c-50fd-4686-a45f-d9a30f616b80", - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "e0d2f9fb-a300-4a21-b258-e594bd26030d" - } - ], - "webhook": null, - "label": "Response 1", - "operand": "@contact.groups", - "finished_key": null, - "response_type": "C", - "y": 0, - "x": 129 - } - ], - "metadata": {} - }, - "id": 600, - "flow_type": "F", - "name": "Parent Flow" - } - ], - "triggers": [] -} diff --git a/media/test_flows/group_split.json b/media/test_flows/group_split.json deleted file mode 100644 index 02be28c5123..00000000000 --- a/media/test_flows/group_split.json +++ /dev/null @@ -1,296 +0,0 @@ -{ - "campaigns": [], - "version": 10, - "site": "https://textit.in", - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 83, - "destination": "fa26edf9-e78f-4131-b5df-3bd84a1390f5", - "uuid": "1bf3f286-be43-45c3-8146-020a8f224591", - "actions": [ - { - "msg": { - "eng": "Group management! Toggle group membership with:\n(Add|Remove) \n\n@contact.groups" - }, - "type": "reply" - } - ] - }, - { - "y": 376, - "x": 545, - "destination": "403ded9b-edcc-4392-adf1-3de81088cdc1", - "uuid": "21b3cd69-e67c-402a-b977-566f94e8e7ec", - "actions": [ - { - "type": "add_group", - "groups": [ - "@flow.group_name" - ] - } - ] - }, - { - "y": 375, - "x": 786, - "destination": "403ded9b-edcc-4392-adf1-3de81088cdc1", - "uuid": "60d74019-f9c2-4665-bcab-0a4ba659b6af", - "actions": [ - { - "type": "del_group", - "groups": [ - "@flow.group_name" - ] - } - ] - }, - { - "y": 24, - "x": 733, - "destination": "1bf3f286-be43-45c3-8146-020a8f224591", - "uuid": "514fc3f0-fd04-4c27-92fb-bc348121c8ef", - "actions": [ - { - "msg": { - "eng": "Sorry, don't get that command." - }, - "type": "reply" - } - ] - }, - { - "y": 493, - "x": 177, - "destination": "403ded9b-edcc-4392-adf1-3de81088cdc1", - "uuid": "a9cd6587-61a7-4ab6-9a20-edf1f9cff033", - "actions": [ - { - "msg": { - "eng": "You are in @flow.member.category" - }, - "type": "reply" - } - ] - }, - { - "y": 466, - "x": 397, - "destination": "403ded9b-edcc-4392-adf1-3de81088cdc1", - "uuid": "f0c02e2b-6f24-48a7-9c8b-d49165387014", - "actions": [ - { - "msg": { - "eng": "You aren't in either group." - }, - "type": "reply" - } - ] - }, - { - "y": 587, - "x": 545, - "destination": "fa26edf9-e78f-4131-b5df-3bd84a1390f5", - "uuid": "403ded9b-edcc-4392-adf1-3de81088cdc1", - "actions": [ - { - "msg": { - "eng": "Awaiting command." - }, - "type": "reply" - } - ] - } - ], - "version": 10, - "flow_type": "F", - "entry": "1bf3f286-be43-45c3-8146-020a8f224591", - "rule_sets": [ - { - "uuid": "fa26edf9-e78f-4131-b5df-3bd84a1390f5", - "rules": [ - { - "test": { - "test": { - "eng": "split" - }, - "type": "starts" - }, - "category": { - "eng": "Split" - }, - "destination": "ec361b3c-7979-4cfb-b7ca-997d985aba40", - "uuid": "ef6d80e8-775d-4872-8dfc-4f61ae09c814", - "destination_type": "R" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other" - }, - "destination": "e7feaa40-b815-4619-b7b2-4a28c8fd4d10", - "uuid": "bd9ee747-fb19-4752-8dd5-d4a3f666b278", - "destination_type": "R" - } - ], - "ruleset_type": "wait_message", - "label": "Response", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 139, - "x": 351, - "config": {} - }, - { - "uuid": "2e616cfc-217d-4782-9522-c2bb0ee38ff8", - "rules": [ - { - "test": { - "test": { - "eng": "add" - }, - "type": "starts" - }, - "category": { - "eng": "Add" - }, - "destination": "21b3cd69-e67c-402a-b977-566f94e8e7ec", - "uuid": "51280d07-a741-4d2a-8b7c-199739e7f17e", - "destination_type": "A" - }, - { - "test": { - "test": { - "eng": "remove" - }, - "type": "starts" - }, - "category": { - "eng": "Remove" - }, - "destination": "60d74019-f9c2-4665-bcab-0a4ba659b6af", - "uuid": "90d49357-be6f-4a26-a92c-35571b949bf0", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other" - }, - "destination": "514fc3f0-fd04-4c27-92fb-bc348121c8ef", - "uuid": "1af92c00-e94f-4a49-97d2-1486083f2342", - "destination_type": "A" - } - ], - "ruleset_type": "expression", - "label": "Response 3", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 257, - "x": 633, - "config": {} - }, - { - "uuid": "e7feaa40-b815-4619-b7b2-4a28c8fd4d10", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "2e616cfc-217d-4782-9522-c2bb0ee38ff8", - "uuid": "ba782061-da19-4f97-9a64-d8b22c78641d", - "destination_type": "R" - } - ], - "ruleset_type": "expression", - "label": "Group Name", - "operand": "@(REMOVE_FIRST_WORD(step.value))", - "finished_key": null, - "response_type": "", - "y": 140, - "x": 671, - "config": {} - }, - { - "uuid": "ec361b3c-7979-4cfb-b7ca-997d985aba40", - "rules": [ - { - "test": { - "test": { - "name": "Group A", - "uuid": "ebccb83d-f407-4e66-86ff-b174c952b7d3" - }, - "type": "in_group" - }, - "category": { - "eng": "Group A" - }, - "destination": "a9cd6587-61a7-4ab6-9a20-edf1f9cff033", - "uuid": "e7da6d33-8b82-4d42-8b20-454a4460f0f6", - "destination_type": "A" - }, - { - "test": { - "test": { - "name": "Group B", - "uuid": "61d455f9-52e5-40c7-ae88-809644ffb028" - }, - "type": "in_group" - }, - "category": { - "eng": "Group B" - }, - "destination": "a9cd6587-61a7-4ab6-9a20-edf1f9cff033", - "uuid": "2b240091-2cc5-45e4-ad18-ade35d0bd320", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other" - }, - "destination": "f0c02e2b-6f24-48a7-9c8b-d49165387014", - "uuid": "66410d8d-a539-4e9a-b039-717da23bbdd2", - "destination_type": "A" - } - ], - "ruleset_type": "group", - "label": "Member", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 255, - "x": 196, - "config": {} - } - ], - "metadata": { - "uuid": "2d60a782-5805-488b-bf4d-b8154614c170", - "notes": [], - "expires": 10080, - "name": "Grouppo", - "saved_on": "2016-09-14T22:51:59.257419Z", - "revision": 304 - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/loop_detection.json b/media/test_flows/loop_detection.json deleted file mode 100644 index 91542485279..00000000000 --- a/media/test_flows/loop_detection.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "campaigns": [], - "version": 7, - "site": "http://rapidpro.io", - "flows": [ - { - "version": 7, - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 132, - "destination": "9e348f0c-f7fa-4c06-a78b-9ffa839e5779", - "uuid": "13977cf2-68ee-49b9-8d88-2b9dbce12c5b", - "actions": [ - { - "msg": { - "eng": "Message One" - }, - "type": "reply" - } - ] - }, - { - "y": 167, - "x": 133, - "destination": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "uuid": "fb3e6f98-2cf3-40e8-ba1a-ea87dfcbd458", - "actions": [ - { - "msg": { - "eng": "You are in Group A" - }, - "type": "reply" - } - ] - }, - { - "y": 400, - "x": 434, - "destination": null, - "uuid": "3a0f77d1-f6bf-47f1-b194-de2051ba0738", - "actions": [ - { - "msg": { - "eng": "You picked @flow.message_split_a.category" - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2015-05-04T19:48:06.359817Z", - "entry": "13977cf2-68ee-49b9-8d88-2b9dbce12c5b", - "rule_sets": [ - { - "uuid": "9e348f0c-f7fa-4c06-a78b-9ffa839e5779", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": { - "eng": "Group A" - }, - "base": "Group A", - "type": "contains_any" - }, - "category": { - "eng": "Group A", - "base": "Group A" - }, - "destination": "fb3e6f98-2cf3-40e8-ba1a-ea87dfcbd458", - "uuid": "605e4e98-5d85-45e7-a885-9c198977b63c" - }, - { - "test": { - "test": { - "eng": "Group B" - }, - "base": "Group B", - "type": "contains_any" - }, - "category": { - "eng": "Group B", - "base": "Group B" - }, - "destination": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "uuid": "81ba32a2-b3ea-4d46-aa7e-2ef32d7ced1e" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other", - "base": "Other" - }, - "destination": "782e9e71-c116-4195-add3-1867132f95b6", - "uuid": "9e712fa4-d988-483b-9820-e6bcc6d0cfba" - } - ], - "webhook": null, - "label": "Group Split A", - "operand": "@contact.groups", - "finished_key": null, - "ruleset_type": "expression", - "y": 70, - "x": 401 - }, - { - "uuid": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "eng": "Red" - }, - "base": "Red", - "type": "contains_any" - }, - "category": { - "eng": "Red", - "base": "Red" - }, - "destination": "3a0f77d1-f6bf-47f1-b194-de2051ba0738", - "uuid": "77f97500-0f06-443d-aec1-8d045962c7b8" - }, - { - "test": { - "test": { - "eng": "Green" - }, - "base": "Green", - "type": "contains_any" - }, - "category": { - "eng": "Green", - "base": "Green" - }, - "destination": "3a0f77d1-f6bf-47f1-b194-de2051ba0738", - "uuid": "15fa4511-c63e-4e45-be09-c63c87480189" - }, - { - "test": { - "test": { - "eng": "Blue" - }, - "base": "Blue", - "type": "contains_any" - }, - "category": { - "eng": "Blue", - "base": "Blue" - }, - "destination": "3a0f77d1-f6bf-47f1-b194-de2051ba0738", - "uuid": "8b8dc778-7d49-4572-af9f-97d5aee5dce8" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other", - "base": "All Responses" - }, - "destination": "3a0f77d1-f6bf-47f1-b194-de2051ba0738", - "uuid": "1d9900bc-9315-4ee2-892f-60013dd9541d" - } - ], - "webhook": null, - "label": "Message Split A", - "operand": "@step.value", - "finished_key": null, - "ruleset_type": "wait_message", - "y": 265, - "x": 356 - }, - { - "uuid": "782e9e71-c116-4195-add3-1867132f95b6", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": { - "eng": "Rowan" - }, - "base": "Rowan", - "type": "contains_any" - }, - "category": { - "eng": "Rowan", - "base": "Rowan" - }, - "destination": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "uuid": "f78edeea-4339-4f06-b95e-141975b97cb8" - }, - { - "test": { - "test": { - "eng": "Norbert" - }, - "base": "Norbert", - "type": "contains_any" - }, - "category": { - "eng": "Norbert", - "base": "Norbert" - }, - "destination": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "uuid": "e399c915-6226-4b00-bd9a-8347bd03a85a" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "Other", - "base": "Other" - }, - "destination": "1f1adefb-0791-4e3c-9e8f-10dc6d56d3a5", - "uuid": "7247d462-6ac5-4302-8ace-5a61c714377d" - } - ], - "webhook": null, - "label": "Name Split", - "operand": "@contact.name", - "finished_key": null, - "ruleset_type": "contact_field", - "y": 153, - "x": 735 - }, - { - "uuid": "771088fd-fc77-4966-8541-93c3c59c923d", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses", - "eng": "All Responses" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "865baac0-da29-4752-be1e-1488457f708c" - } - ], - "webhook": null, - "label": "Message Split B", - "operand": "@step.value", - "finished_key": null, - "ruleset_type": "wait_message", - "y": 555, - "x": 419 - } - ], - "flow_type": "F", - "metadata": { - "name": "Loop Detection", - "id": 1000 - } - } - ], - "triggers": [] -} diff --git a/media/test_flows/no_ruleset_flow.json b/media/test_flows/no_ruleset_flow.json deleted file mode 100644 index 46c1806a98c..00000000000 --- a/media/test_flows/no_ruleset_flow.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "e41e7aad-de93-4cc0-ae56-d6af15ba1ac5", - "actions": [ - { - "msg": { - "eng": "Hello world" - }, - "type": "reply" - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "e41e7aad-de93-4cc0-ae56-d6af15ba1ac5", - "rule_sets": [], - "metadata": { - "expires": 10080, - "revision": 1, - "id": 41049, - "name": "No ruleset flow", - "saved_on": "2015-11-20T11:02:19.790131Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/numeric_rule_allows_variables.json b/media/test_flows/numeric_rule_allows_variables.json deleted file mode 100644 index 7b5fe83750b..00000000000 --- a/media/test_flows/numeric_rule_allows_variables.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": "base", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "31065f6b-4054-4560-adac-d8f4a0ec57c7", - "uuid": "f08c61ae-8757-4b4a-924b-9e97afdf15f4", - "actions": [ - { - "msg": { - "base": "How old will you be in the next world cup?" - }, - "type": "reply" - } - ] - }, - { - "y": 370, - "x": 59, - "destination": null, - "uuid": "116b7cc1-5086-4e0d-b0ee-ea3f73d0f06f", - "actions": [ - { - "msg": { - "base": "Good count" - }, - "type": "reply" - } - ] - }, - { - "y": 358, - "x": 429, - "destination": null, - "uuid": "34ca7cb8-b899-46b3-a5d2-11dd13f89541", - "actions": [ - { - "msg": { - "base": "Try again" - }, - "type": "reply" - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "f08c61ae-8757-4b4a-924b-9e97afdf15f4", - "rule_sets": [ - { - "uuid": "31065f6b-4054-4560-adac-d8f4a0ec57c7", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "@contact.age", - "type": "gt" - }, - "category": { - "base": "> @contact.age" - }, - "destination": "116b7cc1-5086-4e0d-b0ee-ea3f73d0f06f", - "uuid": "d164c264-1f48-478c-9ffb-f7207e679ed5", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "34ca7cb8-b899-46b3-a5d2-11dd13f89541", - "uuid": "2d0e800d-ec33-4fcd-a660-3782cf65dcff", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 210, - "x": 153, - "config": {} - } - ], - "metadata": { - "expires": 10080, - "revision": 7, - "id": 41052, - "name": "Numeric rule allows variables", - "saved_on": "2015-11-30T19:29:37.385369Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/parent.json b/media/test_flows/parent.json deleted file mode 100644 index a413e7369ce..00000000000 --- a/media/test_flows/parent.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "entry": "2f0e3397-3b9e-4593-b1d2-04ecfddb4f8f", - "rule_sets": [], - "action_sets": [ - { - "y": 1, - "x": 127, - "destination": null, - "uuid": "2f0e3397-3b9e-4593-b1d2-04ecfddb4f8f", - "actions": [ - { - "uuid": "59d5ba21-e61d-4bb7-a898-33bb2164987e", - "value": "None", - "label": "Campaign Date", - "field": "campaign_date", - "action": "GET", - "type": "save" - }, - { - "uuid": "40b35b95-fdbc-4ba4-b91e-c3c1911c1f3c", - "type": "flow", - "name": "Child Flow", - "id": CHILD_ID - }, - { - "action": "GET", - "type": "add_group", - "uuid": "4ea70294-ca92-478c-b0f4-ffc4fd858412", - "groups": [ - { - "name": "Campaign" - } - ] - }, - { - "msg": "Added to campaign.", - "action": "GET", - "type": "reply", - "uuid": "8a267e99-1b75-4e6d-bafc-9bc65629ad0a" - } - ] - } - ], - "last_saved": "2014-11-20T21:14:51.848399Z", - "metadata": {} - }, - "flow_type": "F", - "name": "Parent", - "id": 2000 - } - ], - "triggers": [] -} diff --git a/media/test_flows/pick_a_number.json b/media/test_flows/pick_a_number.json deleted file mode 100644 index 8db008735f1..00000000000 --- a/media/test_flows/pick_a_number.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "rule_sets": [ - { - "y": 106, - "x": 100, - "response_type": "C", - "rules": [ - { - "test": { - "max": "10", - "type": "between", - "min": "1" - }, - "destination": "9a8ba8b2-8c80-4635-9f5d-015c15fdc44a", - "uuid": "41418f9d-73e5-43b8-a341-3f7af70e13c1" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": null, - "uuid": "e53c2616-7b8d-4821-968a-4488e9980454" - } - ], - "uuid": "06bb3899-5de4-4cbc-ad5f-70b9634d80c4", - "label": "number" - }, - { - "y": 300, - "x": 300, - "response_type": "C", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": "All Responses", - "destination": "06bb3899-5de4-4cbc-ad5f-70b9634d80c4", - "uuid": "9df37f4c-73ca-4876-8490-35f984486df6" - } - ], - "uuid": "c1a5c78e-560b-45b1-83b1-1dad9ce57a06", - "label": "passive", - "operand": "@contact.name" - } - ], - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "06bb3899-5de4-4cbc-ad5f-70b9634d80c4", - "uuid": "2f2adf23-87db-41d3-9436-afe48ab5403c", - "actions": [ - { - "msg": "Pick a number between 1-10.", - "type": "reply" - } - ] - }, - { - "y": 228, - "x": 118, - "destination": null, - "uuid": "9a8ba8b2-8c80-4635-9f5d-015c15fdc44a", - "actions": [ - { - "msg": "You picked @flow.number!", - "type": "reply" - } - ] - } - ] - }, - "flow_type": "F", - "name": "Pick a Number", - "id": 2100 - } - ], - "triggers": [] -} diff --git a/media/test_flows/preprocess.json b/media/test_flows/preprocess.json deleted file mode 100644 index d8616b5068a..00000000000 --- a/media/test_flows/preprocess.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "rule_sets": [ - { - "y": 106, - "x": 100, - "webhook": "http://preprocessor.com/endpoint.php", - "response_type": "N", - "rules": [ - { - "test": { - "max": "10", - "type": "between", - "min": "1" - }, - "destination": "9a8ba8b2-8c80-4635-9f5d-015c15fdc44a", - "uuid": "41418f9d-73e5-43b8-a341-3f7af70e13c1" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": null, - "uuid": "e53c2616-7b8d-4821-968a-4488e9980454" - } - ], - "uuid": "06bb3899-5de4-4cbc-ad5f-70b9634d80c4", - "label": "number" - } - ], - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "06bb3899-5de4-4cbc-ad5f-70b9634d80c4", - "uuid": "2f2adf23-87db-41d3-9436-afe48ab5403c", - "actions": [ - { - "msg": "Pick a number between 1-10.", - "type": "reply" - } - ] - }, - { - "y": 228, - "x": 118, - "destination": null, - "uuid": "9a8ba8b2-8c80-4635-9f5d-015c15fdc44a", - "actions": [ - { - "msg": "You picked @flow.number!", - "type": "reply" - } - ] - } - ] - }, - "flow_type": "F", - "name": "Preprocess", - "id": 2200 - } - ], - "triggers": [] -} diff --git a/media/test_flows/quick_replies.json b/media/test_flows/quick_replies.json deleted file mode 100644 index c48ef44d30b..00000000000 --- a/media/test_flows/quick_replies.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "campaigns": [], - "version": 10, - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 44, - "x": 223, - "destination": "4297b822-734e-44cb-a1c9-2e20bc2cdb19", - "uuid": "163462f8-8a82-49df-ab8a-4eee3f7b9feb", - "actions": [ - { - "msg": { - "por": "Voc\u00ea gosta de jogar futebol?", - "eng": "Do you like to play football?" - }, - "media": {}, - "send_all": false, - "type": "reply", - "quick_replies": [ - { - "por": "Sim", - "eng": "Yes" - }, - { - "eng": "No" - } - ] - } - ] - }, - { - "y": 319, - "x": 262, - "destination": null, - "uuid": "a26a36b9-d4d7-4355-aad3-2fc86e84a7f1", - "actions": [ - { - "msg": { - "eng": "Good!" - }, - "media": {}, - "send_all": false, - "type": "reply", - "quick_replies": [] - } - ] - }, - { - "y": 318, - "x": 509, - "destination": null, - "uuid": "cdf0b558-6a44-44c9-8bee-6a6b6e8ad9c4", - "actions": [ - { - "msg": { - "eng": ":(" - }, - "media": {}, - "send_all": false, - "type": "reply", - "quick_replies": [] - } - ] - }, - { - "y": 173, - "x": 749, - "destination": "4297b822-734e-44cb-a1c9-2e20bc2cdb19", - "uuid": "4b7366eb-6099-4135-9a00-72492e6fdb8d", - "actions": [ - { - "msg": { - "eng": "Sorry, I don't understand." - }, - "media": {}, - "send_all": false, - "type": "reply", - "quick_replies": [] - } - ] - } - ], - "version": 10, - "flow_type": "F", - "entry": "163462f8-8a82-49df-ab8a-4eee3f7b9feb", - "rule_sets": [ - { - "uuid": "4297b822-734e-44cb-a1c9-2e20bc2cdb19", - "rules": [ - { - "category": { - "eng": "Yes" - }, - "uuid": "e8014483-e9ee-4384-85ea-f88c67ddf494", - "destination": "a26a36b9-d4d7-4355-aad3-2fc86e84a7f1", - "label": null, - "destination_type": "A", - "test": { - "test": { - "eng": "Yes, Sim" - }, - "type": "contains_any" - } - }, - { - "category": { - "eng": "No" - }, - "uuid": "a0b594ae-491f-4f2c-93af-0f158f69a5d8", - "destination": "cdf0b558-6a44-44c9-8bee-6a6b6e8ad9c4", - "label": null, - "destination_type": "A", - "test": { - "test": { - "eng": "No" - }, - "type": "contains_any" - } - }, - { - "category": { - "eng": "Other" - }, - "uuid": "b27d6067-f3fd-4b9b-a79a-127791987ec5", - "destination": "4b7366eb-6099-4135-9a00-72492e6fdb8d", - "label": null, - "destination_type": "A", - "test": { - "type": "true" - } - } - ], - "ruleset_type": "wait_message", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 160, - "x": 381, - "config": {} - } - ], - "metadata": { - "expires": 10080, - "revision": 106, - "uuid": "711489ef-87c9-4fbc-8e6e-92af2b671fc4", - "name": "Quick Replies", - "saved_on": "2017-10-13T13:09:02.747795Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/random_word.json b/media/test_flows/random_word.json deleted file mode 100644 index 45cd0b0e5a2..00000000000 --- a/media/test_flows/random_word.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "fbb21cb8-eaf0-45fc-a5e3-31c5f5c1d55e", - "uuid": "8731e312-cdf1-412c-8a7d-6cc603de9cf9", - "actions": [ - { - "msg": { - "eng": "Write me a random word." - }, - "type": "reply" - } - ] - }, - { - "y": 406, - "x": 228, - "destination": null, - "uuid": "395f0a8e-b4fa-4a73-af33-98134505a3d7", - "actions": [ - { - "msg": { - "eng": "Thank you" - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2014-08-11T15:08:22.724512Z", - "entry": "8731e312-cdf1-412c-8a7d-6cc603de9cf9", - "rule_sets": [ - { - "uuid": "fbb21cb8-eaf0-45fc-a5e3-31c5f5c1d55e", - "response_type": "O", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "395f0a8e-b4fa-4a73-af33-98134505a3d7", - "uuid": "3ec97b15-cfd8-4500-947f-56cae2441c99" - } - ], - "label": "Random", - "operand": "@step.value", - "y": 239, - "x": 136 - } - ], - "metadata": { - "notes": [] - } - }, - "flow_type": "F", - "name": "Random Word", - "id": 2300 - } - ], - "triggers": [] -} diff --git a/media/test_flows/rules_first.json b/media/test_flows/rules_first.json deleted file mode 100644 index 35d52c9b652..00000000000 --- a/media/test_flows/rules_first.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "version": 7, - "flows": [ - { - "version": 7, - "base_language": "base", - "action_sets": [ - { - "y": 161, - "x": 114, - "destination": null, - "uuid": "0fa491db-a447-4940-a7c8-c682f0e9ae3b", - "actions": [ - { - "msg": { - "base": "You've got to be kitten me" - }, - "type": "reply" - } - ] - }, - { - "y": 160, - "x": 342, - "destination": null, - "uuid": "29825823-69e3-47d7-a139-90c4851de0a3", - "actions": [ - { - "msg": { - "base": "Raise the woof!" - }, - "type": "reply" - } - ] - }, - { - "y": 100, - "x": 602, - "destination": "737527ae-ade5-4b55-944a-94a67b79cec5", - "uuid": "8e89b350-4b96-480c-b4e5-31f38f40bfe5", - "actions": [ - { - "msg": { - "base": "Is that even an animal?" - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2015-09-15T02:38:14.494272Z", - "entry": "737527ae-ade5-4b55-944a-94a67b79cec5", - "rule_sets": [ - { - "uuid": "737527ae-ade5-4b55-944a-94a67b79cec5", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "base": "Cats" - }, - "type": "contains_any" - }, - "category": { - "base": "Cats" - }, - "destination": "0fa491db-a447-4940-a7c8-c682f0e9ae3b", - "uuid": "be41b657-cbfa-433a-9ffe-4fbcaf7fe15e", - "destination_type": "A" - }, - { - "test": { - "test": { - "base": "Dogs" - }, - "type": "contains_any" - }, - "category": { - "base": "Dogs" - }, - "destination": "29825823-69e3-47d7-a139-90c4851de0a3", - "uuid": "f1b8745b-beb3-4431-9e8e-01a214f20e3e", - "destination_type": "A" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": "8e89b350-4b96-480c-b4e5-31f38f40bfe5", - "uuid": "a329fbaa-49cf-4a5e-8e12-8df801344715", - "destination_type": "A" - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Animal", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 0, - "x": 260, - "config": {} - } - ], - "flow_type": "F", - "metadata": { - "notes": [], - "expires": 720, - "id": 35560, - "name": "Rules First" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/ruleset_loop.json b/media/test_flows/ruleset_loop.json deleted file mode 100644 index 71a1dc327e4..00000000000 --- a/media/test_flows/ruleset_loop.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "campaigns": [], - "version": 4, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "base_language": "ind", - "action_sets": [ - { - "y": 189, - "x": 157, - "destination": null, - "uuid": "c1474749-cfcb-4911-a93f-31ba67b64d57", - "actions": [ - { - "type": "flow", - "name": "Flow 2", - "id": 27668 - } - ] - } - ], - "last_saved": "2015-03-25T23:18:57.977877Z", - "entry": "e41fa402-0946-451f-8971-ac6adb6a0cc6", - "rule_sets": [ - { - "uuid": "e41fa402-0946-451f-8971-ac6adb6a0cc6", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": { - "ind": "awesome" - }, - "type": "contains_any" - }, - "category": { - "ind": "Awesome", - "base": "Awesome" - }, - "destination": null, - "uuid": "d0f480bb-a1e4-4aed-bbfe-04bb34ada1f2" - }, - { - "test": { - "type": "true" - }, - "category": { - "ind": "Other", - "base": "Other" - }, - "destination": "c1474749-cfcb-4911-a93f-31ba67b64d57", - "uuid": "5a2aa691-9103-4cf0-a2af-7df9be3658b7" - } - ], - "webhook": null, - "label": "Response 1", - "operand": "@contact.name", - "finished_key": null, - "response_type": "C", - "y": 0, - "x": 110 - } - ], - "metadata": { - "notes": [] - } - }, - "id": 27667, - "flow_type": "F", - "name": "Flow 1" - }, - { - "definition": { - "base_language": "ind", - "action_sets": [ - { - "y": 241, - "x": 180, - "destination": null, - "uuid": "e32f191b-d667-4a9e-9820-6faaf98d9a27", - "actions": [ - { - "type": "flow", - "name": "Flow 1", - "id": 27667 - } - ] - } - ], - "last_saved": "2015-03-25T23:18:28.682837Z", - "entry": "1b155cb4-1457-4430-899e-72a21a1843e8", - "rule_sets": [ - { - "uuid": "1b155cb4-1457-4430-899e-72a21a1843e8", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": { - "ind": "awesome" - }, - "type": "contains_any" - }, - "category": { - "ind": "Awesome", - "base": "Awesome" - }, - "destination": null, - "uuid": "024aea08-84e2-4ce7-9032-422bdf9d4a79" - }, - { - "test": { - "type": "true" - }, - "category": { - "ind": "Other", - "base": "All Responses" - }, - "destination": "e32f191b-d667-4a9e-9820-6faaf98d9a27", - "uuid": "1469f833-3b09-4a25-bccf-fb5c05d50876" - } - ], - "webhook": null, - "label": "Response 1", - "operand": "@contact.name", - "finished_key": null, - "response_type": "C", - "y": 0, - "x": 126 - } - ], - "metadata": {} - }, - "id": 2700, - "flow_type": "F", - "name": "Flow 2" - } - ], - "triggers": [] -} diff --git a/media/test_flows/send_all.json b/media/test_flows/send_all.json deleted file mode 100644 index 292a1991240..00000000000 --- a/media/test_flows/send_all.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "campaigns": [], - "version": 10, - "site": "https://app.rapidpro.io", - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "14fb87aa-22cc-4e9a-a8d6-dd4640426bed", - "actions": [ - { - "msg": { - "eng": "Hey, how are you?" - }, - "media": {}, - "send_all": true, - "type": "reply" - } - ] - } - ], - "version": 10, - "flow_type": "F", - "entry": "14fb87aa-22cc-4e9a-a8d6-dd4640426bed", - "rule_sets": [], - "metadata": { - "expires": 10080, - "revision": 1, - "uuid": "4b8089fb-bb10-4cbc-800f-a0aa8bb21713", - "name": "Send All", - "saved_on": "2017-03-16T14:53:04.119831Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/sms_form.json b/media/test_flows/sms_form.json deleted file mode 100644 index 3b7767fdfe0..00000000000 --- a/media/test_flows/sms_form.json +++ /dev/null @@ -1,299 +0,0 @@ -{ - "version": 5, - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 119, - "destination": "d0e01dde-dcd2-43e9-8f8e-ae1699a80395", - "uuid": "2e6aaa75-ffb7-4c48-baee-57e4149e452c", - "actions": [ - { - "msg": { - "eng": "What is your age, sex, location? Separate your responses with a space. For example \"15 f seattle\"." - }, - "type": "reply" - } - ] - }, - { - "y": 265, - "x": 904, - "destination": "d0e01dde-dcd2-43e9-8f8e-ae1699a80395", - "uuid": "9f1c79ae-581a-45ff-a9ea-4096f8231aad", - "actions": [ - { - "msg": { - "eng": "Sorry, @flow.age doesn't look like a valid age, please try again." - }, - "type": "reply" - } - ] - }, - { - "y": 414, - "x": 831, - "destination": "d0e01dde-dcd2-43e9-8f8e-ae1699a80395", - "uuid": "1cc063a7-afea-460d-b8a0-c8c2a2e37e35", - "actions": [ - { - "msg": { - "eng": "Sorry, @flow.gender doesn't look like a valid gender. Try again." - }, - "type": "reply" - } - ] - }, - { - "y": 571, - "x": 735, - "destination": "d0e01dde-dcd2-43e9-8f8e-ae1699a80395", - "uuid": "6be94ef5-bffc-4864-bd71-8e7cd87d7178", - "actions": [ - { - "msg": { - "eng": "I don't know the location @flow.location. Please try again." - }, - "type": "reply" - } - ] - }, - { - "y": 234, - "x": 116, - "destination": null, - "uuid": "0b18b474-00ab-40a0-af25-5d7c91aa64d7", - "actions": [ - { - "msg": { - "eng": "Thanks for your submission. We have that as:\n\n@flow.age / @flow.gender / @flow.location" - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2015-08-05T19:02:29.296446Z", - "entry": "2e6aaa75-ffb7-4c48-baee-57e4149e452c", - "rule_sets": [ - { - "uuid": "d0e01dde-dcd2-43e9-8f8e-ae1699a80395", - "webhook_action": null, - "rules": [ - { - "category": { - "base": "All Responses", - "eng": "All Responses" - }, - "uuid": "bb0c523f-d216-4bf3-8794-664a9d9b3ccb", - "destination": "b7563d6f-279a-4b19-bff6-0ee3ccfa5d5f", - "destination_type": "R", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": null, - "ruleset_type": "wait_message", - "label": "Message Form", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 117, - "x": 459, - "config": {} - }, - { - "uuid": "b7563d6f-279a-4b19-bff6-0ee3ccfa5d5f", - "webhook_action": null, - "rules": [ - { - "test": { - "max": "100", - "type": "between", - "min": "0" - }, - "category": { - "base": "0 - 100", - "eng": "0 - 100" - }, - "destination": "eb669471-fadf-489b-9ce6-c10bb4add673", - "uuid": "a9c7276e-2f5d-4e6d-9efd-2b8d39c3ec50", - "destination_type": "R" - }, - { - "category": { - "base": "Other", - "eng": "Other" - }, - "uuid": "83ce1500-c6e6-4eb1-8feb-76cd439c6e36", - "destination": "9f1c79ae-581a-45ff-a9ea-4096f8231aad", - "destination_type": "A", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": null, - "ruleset_type": "form_field", - "label": "Age", - "operand": "@flow.message_form", - "finished_key": null, - "response_type": "", - "y": 226, - "x": 460, - "config": { - "field_delimiter": " ", - "field_index": 0 - } - }, - { - "uuid": "eb669471-fadf-489b-9ce6-c10bb4add673", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "eng": "Male m" - }, - "base": "Male m", - "type": "contains_any" - }, - "category": { - "base": "Male", - "eng": "Male" - }, - "destination": "3d8c9a32-fe04-4b95-9b2c-c66dd4ff2b24", - "uuid": "bda1d6b0-2b8d-4ddb-a888-3e41b3243a0f", - "destination_type": "R" - }, - { - "test": { - "test": { - "eng": "Female f" - }, - "base": "Female f", - "type": "contains_any" - }, - "category": { - "base": "Female", - "eng": "Female" - }, - "destination": "3d8c9a32-fe04-4b95-9b2c-c66dd4ff2b24", - "uuid": "4b8421ab-209f-4638-a267-82c4f83c73b2", - "destination_type": "R" - }, - { - "category": { - "base": "Other", - "eng": "Other" - }, - "uuid": "b61d7b97-21ab-4df5-a475-d16122aba572", - "destination": "1cc063a7-afea-460d-b8a0-c8c2a2e37e35", - "destination_type": "A", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": null, - "ruleset_type": "form_field", - "label": "Gender", - "operand": "@flow.message_form", - "finished_key": null, - "response_type": "", - "y": 344, - "x": 385, - "config": { - "field_delimiter": " ", - "field_index": 1 - } - }, - { - "uuid": "3d8c9a32-fe04-4b95-9b2c-c66dd4ff2b24", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "eng": "seattle chicago miami" - }, - "base": "seattle chicago miami", - "type": "contains_any" - }, - "category": { - "base": "Valid", - "eng": "Valid" - }, - "destination": "0b18b474-00ab-40a0-af25-5d7c91aa64d7", - "uuid": "1b36cb64-b0ce-43cc-9b50-8f45f29c9643", - "destination_type": "A" - }, - { - "category": { - "base": "Other", - "eng": "Other" - }, - "uuid": "b9419b3c-0cd0-4956-93ac-b6fd0da2964a", - "destination": "6be94ef5-bffc-4864-bd71-8e7cd87d7178", - "destination_type": "A", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": null, - "ruleset_type": "form_field", - "label": "Location", - "operand": "@flow.message_form", - "finished_key": null, - "response_type": "", - "y": 486, - "x": 366, - "config": { - "field_delimiter": " ", - "field_index": 2 - } - } - ], - "metadata": {} - }, - "expires": 10080, - "id": 34393, - "flow_type": "F", - "name": "SMS Form" - } - ], - "triggers": [] -} diff --git a/media/test_flows/start_missing_flow.json b/media/test_flows/start_missing_flow.json deleted file mode 100644 index 3dc5f4ee8bb..00000000000 --- a/media/test_flows/start_missing_flow.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "campaigns": [], - "version": 4, - "site": "http://textit.in", - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "10cca2b1-f587-410b-b07d-10ef75df3590", - "uuid": "3839c698-832f-4584-88c8-f57bb1a6ef5a", - "actions": [ - { - "msg": { - "eng": "Hi there, would you like to start a flow?" - }, - "type": "reply" - } - ] - }, - { - "y": 160, - "x": 90, - "destination": null, - "uuid": "418d4ec8-976e-4f4e-aea8-28147bb93ae1", - "actions": [ - { - "type": "flow", - "name": "Missing Flow", - "id": 27122 - } - ] - }, - { - "y": 233, - "x": 395, - "destination": null, - "uuid": "6e8d145c-1b20-477c-a839-f703eeafe1fa", - "actions": [ - { - "name": "Missing Flow", - "contacts": [], - "variables": [ - { - "id": "@step.contact.tel" - } - ], - "groups": [], - "type": "trigger-flow", - "id": 27122 - } - ] - }, - { - "y": 145, - "x": 731, - "destination": null, - "uuid": "a56641a9-c62c-4361-8960-fa2a03b5757a", - "actions": [ - { - "msg": { - "eng": "This actionset should stay." - }, - "type": "reply" - }, - { - "type": "flow", - "name": "Missing Flow", - "id": 27122 - } - ] - } - ], - "last_saved": "2015-03-16T18:04:39.520660Z", - "entry": "3839c698-832f-4584-88c8-f57bb1a6ef5a", - "rule_sets": [ - { - "uuid": "10cca2b1-f587-410b-b07d-10ef75df3590", - "webhook_action": null, - "rules": [ - { - "test": { - "test": { - "eng": "Yes" - }, - "base": "Yes", - "type": "contains_any" - }, - "category": { - "base": "Yes", - "eng": "Yes" - }, - "destination": "418d4ec8-976e-4f4e-aea8-28147bb93ae1", - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "53de7473-1439-40fa-9c08-25a609264416" - }, - { - "test": { - "test": { - "eng": "No" - }, - "base": "No", - "type": "contains_any" - }, - "category": { - "base": "No", - "eng": "No" - }, - "destination": "6e8d145c-1b20-477c-a839-f703eeafe1fa", - "config": { - "type": "contains_any", - "verbose_name": "has any of these words", - "name": "Contains any", - "localized": true, - "operands": 1 - }, - "uuid": "dda639b2-f775-47c2-9f4d-fa5e35f79839" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses", - "eng": "Other" - }, - "destination": "a56641a9-c62c-4361-8960-fa2a03b5757a", - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - }, - "uuid": "942d62cd-9f56-4d06-bde0-6816989a41f0" - } - ], - "webhook": null, - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "C", - "y": 77, - "x": 361 - } - ], - "metadata": {} - }, - "id": 2800, - "flow_type": "F", - "name": "Start Missing Flow" - } - ], - "triggers": [] -} diff --git a/media/test_flows/start_missing_flow_from_actionset.json b/media/test_flows/start_missing_flow_from_actionset.json deleted file mode 100644 index 6e122c2eba2..00000000000 --- a/media/test_flows/start_missing_flow_from_actionset.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "campaigns": [], - "version": 6, - "site": "https://textit.in", - "flows": [ - { - "definition": { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": "53ab0927-f33b-46ab-a32c-50cd144cc9e7", - "uuid": "68df3ab0-a39d-48b1-81c6-72cc73c86f2f", - "actions": [ - { - "msg": { - "eng": "This is the first message." - }, - "type": "reply" - } - ] - }, - { - "y": 126, - "x": 272, - "destination": null, - "uuid": "53ab0927-f33b-46ab-a32c-50cd144cc9e7", - "actions": [ - { - "type": "flow", - "name": "Missing Flow", - "id": 35582 - } - ] - } - ], - "last_saved": "2015-10-12T21:21:09.106022Z", - "entry": "68df3ab0-a39d-48b1-81c6-72cc73c86f2f", - "rule_sets": [], - "metadata": {} - }, - "expires": 10080, - "id": 35583, - "flow_type": "F", - "name": "Start Missing Flow" - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/substitution.json b/media/test_flows/substitution.json deleted file mode 100644 index 995b40c8f9e..00000000000 --- a/media/test_flows/substitution.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapidpro.io", - "flows": [ - { - "definition": { - "rule_sets": [ - { - "uuid": "6c67aed5-a7ac-472f-903e-4eb1d43f913e", - "response_type": "C", - "rules": [ - { - "test": { - "type": "phone" - }, - "category": "phone", - "destination": "96c41cd1-b177-4e4e-b1bc-3359588be10b", - "uuid": "50f988f0-8401-4d24-82c4-165c474e9cca" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": "Other", - "destination": "77663a2e-cb80-46dd-9fed-944514301bf4", - "uuid": "b83a7dcc-122e-4164-8334-23e5837e0bfe" - } - ], - "label": "Phone", - "operand": "@step.value", - "y": 207, - "x": 276 - } - ], - "entry": "632bd152-98c6-4b83-8a5d-0f9343fcf884", - "action_sets": [ - { - "y": 351, - "x": 175, - "destination": null, - "uuid": "96c41cd1-b177-4e4e-b1bc-3359588be10b", - "actions": [ - { - "msg": "Thanks, you typed @flow.phone", - "type": "reply" - }, - { - "msg": "Hi from @step.contact! Your phone is @contact.tel.", - "variables": [ - { - "id": "@flow.phone", - "name": "@flow.phone" - } - ], - "type": "send", - "groups": [], - "contacts": [] - } - ] - }, - { - "y": 309, - "x": 576, - "destination": "6c67aed5-a7ac-472f-903e-4eb1d43f913e", - "uuid": "77663a2e-cb80-46dd-9fed-944514301bf4", - "actions": [ - { - "msg": "Sorry, that isn't a valid phone.", - "type": "reply" - } - ] - }, - { - "y": 0, - "x": 100, - "destination": "6c67aed5-a7ac-472f-903e-4eb1d43f913e", - "uuid": "632bd152-98c6-4b83-8a5d-0f9343fcf884", - "actions": [ - { - "msg": "Hi @step.contact, what is your phone number?", - "type": "reply" - } - ] - } - ], - "metadata": { - "notes": [] - } - }, - "flow_type": "F", - "name": "Substitution", - "id": 2900 - } - ], - "triggers": [] -} diff --git a/media/test_flows/test_db.json b/media/test_flows/test_db.json deleted file mode 100644 index ce62910e0b5..00000000000 --- a/media/test_flows/test_db.json +++ /dev/null @@ -1,822 +0,0 @@ -{ - "version": "13", - "site": "https://textit.com", - "flows": [ - { - "_ui": { - "nodes": { - "5bac8056-d24b-4134-9620-dbc0a4b81492": { - "position": { - "left": 0, - "top": 0 - }, - "type": "execute_actions" - }, - "5ff349ab-e74a-47d3-9ada-9fe1bd99416e": { - "position": { - "left": 480, - "top": 60 - }, - "type": "execute_actions" - }, - "70e72b75-eb66-436a-a0c9-ce890ff8f537": { - "type": "wait_for_response", - "position": { - "left": 0, - "top": 120 - }, - "config": { - "cases": {} - } - }, - "b2bd251a-d241-4bb1-a60b-6caf16014eda": { - "position": { - "left": 0, - "top": 280 - }, - "type": "execute_actions" - }, - "2e539a4c-68ff-4bf7-be23-b57845d2a550": { - "position": { - "left": 520, - "top": 560 - }, - "type": "execute_actions" - }, - "34ae02f2-4cb2-4b63-8ec5-38b5c128e497": { - "position": { - "left": 80, - "top": 480 - }, - "type": "wait_for_response" - }, - "8362d6e8-6bf9-43a5-8f74-44fd0955ec75": { - "position": { - "left": 120, - "top": 680 - }, - "type": "execute_actions" - }, - "0406725d-7701-463e-86a5-88a8af1ca42a": { - "position": { - "left": 120, - "top": 900 - }, - "type": "wait_for_response" - }, - "440d670a-4ed5-46ff-9906-228e6ed498a4": { - "position": { - "left": 120, - "top": 1040 - }, - "type": "execute_actions" - }, - "60d2ee45-5570-4ab9-8dfd-8d512732f765": { - "position": { - "left": 260, - "top": 300 - }, - "type": "execute_actions" - } - }, - "stickies": {} - }, - "expire_after_minutes": 720, - "language": "und", - "localization": {}, - "name": "Favorites", - "nodes": [ - { - "actions": [ - { - "text": "What is your favorite color?", - "type": "send_msg", - "uuid": "cbcd7a22-2835-4ef9-889d-1a0ae9c9293e", - "quick_replies": [] - } - ], - "exits": [ - { - "destination_uuid": "70e72b75-eb66-436a-a0c9-ce890ff8f537", - "uuid": "79c8dfd7-bfc9-47a9-a39a-0daba71b7e47" - } - ], - "uuid": "5bac8056-d24b-4134-9620-dbc0a4b81492" - }, - { - "actions": [ - { - "text": "I don't know that color. Try again.", - "type": "send_msg", - "uuid": "557efd8d-1e92-4150-94cd-18b26204b23d", - "quick_replies": [] - } - ], - "exits": [ - { - "destination_uuid": "70e72b75-eb66-436a-a0c9-ce890ff8f537", - "uuid": "7bb84345-0ba5-4c63-86f8-bc02f24be7c5" - } - ], - "uuid": "5ff349ab-e74a-47d3-9ada-9fe1bd99416e" - }, - { - "uuid": "70e72b75-eb66-436a-a0c9-ce890ff8f537", - "actions": [], - "router": { - "type": "switch", - "default_category_uuid": "1c864609-e85d-42fb-ad7c-0819825a1295", - "cases": [ - { - "arguments": [ - "Red" - ], - "type": "has_any_word", - "uuid": "0e33aa43-bc0d-47e5-8b6f-0a76154e1956", - "category_uuid": "4397bdc7-749b-4441-9e5b-299cb6405c16" - }, - { - "arguments": [ - "Green" - ], - "type": "has_any_word", - "uuid": "86e49280-ba0a-4960-a94d-315cfb2bf323", - "category_uuid": "95b43ef3-9e17-453c-8bf3-b0c92f6f2e54" - }, - { - "arguments": [ - "Blue" - ], - "type": "has_any_word", - "uuid": "4d9224e3-fa56-4601-b190-26a095912804", - "category_uuid": "1e95e408-b060-420a-a4c4-b9d6a1bb3ea0" - }, - { - "arguments": [ - "Navy" - ], - "type": "has_any_word", - "uuid": "e4bebbcf-cc80-4751-a0c6-e0912a815381", - "category_uuid": "1e95e408-b060-420a-a4c4-b9d6a1bb3ea0" - } - ], - "categories": [ - { - "exit_uuid": "cc45b6f3-08fd-40a8-a4d3-b910f27a98bc", - "name": "Red", - "uuid": "4397bdc7-749b-4441-9e5b-299cb6405c16" - }, - { - "exit_uuid": "832c7893-d2fb-4431-b76a-2cb948aa16c0", - "name": "Green", - "uuid": "95b43ef3-9e17-453c-8bf3-b0c92f6f2e54" - }, - { - "exit_uuid": "ad5c10d9-d5cc-4123-abe1-649a471241cc", - "name": "Blue", - "uuid": "1e95e408-b060-420a-a4c4-b9d6a1bb3ea0" - }, - { - "exit_uuid": "ddc28771-2373-4a0e-a93b-f5dbf50130b0", - "name": "Other", - "uuid": "1c864609-e85d-42fb-ad7c-0819825a1295" - }, - { - "exit_uuid": "272fe4ef-0151-479b-9710-2df520a96aa0", - "name": "No Response", - "uuid": "0ed4ec87-3146-4e51-939f-6ce04e9dc372" - } - ], - "operand": "@input.text", - "wait": { - "type": "msg", - "timeout": { - "seconds": 300, - "category_uuid": "0ed4ec87-3146-4e51-939f-6ce04e9dc372" - } - }, - "result_name": "Color" - }, - "exits": [ - { - "destination_uuid": "b2bd251a-d241-4bb1-a60b-6caf16014eda", - "uuid": "cc45b6f3-08fd-40a8-a4d3-b910f27a98bc" - }, - { - "destination_uuid": "b2bd251a-d241-4bb1-a60b-6caf16014eda", - "uuid": "832c7893-d2fb-4431-b76a-2cb948aa16c0" - }, - { - "destination_uuid": "b2bd251a-d241-4bb1-a60b-6caf16014eda", - "uuid": "ad5c10d9-d5cc-4123-abe1-649a471241cc" - }, - { - "destination_uuid": "5ff349ab-e74a-47d3-9ada-9fe1bd99416e", - "uuid": "ddc28771-2373-4a0e-a93b-f5dbf50130b0" - }, - { - "destination_uuid": "60d2ee45-5570-4ab9-8dfd-8d512732f765", - "uuid": "272fe4ef-0151-479b-9710-2df520a96aa0" - } - ] - }, - { - "actions": [ - { - "text": "Good choice, I like @results.color.category_localized too! What is your favorite beer?", - "type": "send_msg", - "uuid": "0760d41c-0b19-416e-a456-0fa07d1f9d1d", - "quick_replies": [] - } - ], - "exits": [ - { - "destination_uuid": "34ae02f2-4cb2-4b63-8ec5-38b5c128e497", - "uuid": "344bc8ef-75d6-4462-ab30-346d0e8328b1" - } - ], - "uuid": "b2bd251a-d241-4bb1-a60b-6caf16014eda" - }, - { - "actions": [ - { - "text": "Sorry you can't participate right now, I'll try again later.", - "type": "send_msg", - "uuid": "f6aa4c18-b7b6-4076-9ece-516bacf90214", - "quick_replies": [] - } - ], - "exits": [ - { - "uuid": "4c1d34db-478b-47d1-ad17-4f03a9598333" - } - ], - "uuid": "60d2ee45-5570-4ab9-8dfd-8d512732f765" - }, - { - "exits": [ - { - "destination_uuid": "8362d6e8-6bf9-43a5-8f74-44fd0955ec75", - "uuid": "7644fb28-942f-4fca-a515-3e52f2678bae" - }, - { - "destination_uuid": "8362d6e8-6bf9-43a5-8f74-44fd0955ec75", - "uuid": "165d9dce-7a8b-4d12-a633-8b82552678db" - }, - { - "destination_uuid": "8362d6e8-6bf9-43a5-8f74-44fd0955ec75", - "uuid": "d4bf9e11-f2ac-4b72-aa92-91b39fa8ba8e" - }, - { - "destination_uuid": "8362d6e8-6bf9-43a5-8f74-44fd0955ec75", - "uuid": "8eaea6ac-f9a7-490e-9aaa-f76dba22b298" - }, - { - "destination_uuid": "2e539a4c-68ff-4bf7-be23-b57845d2a550", - "uuid": "fa156c46-bd39-4bc2-91ca-c9710ad2cd5f" - } - ], - "router": { - "cases": [ - { - "arguments": [ - "Mutzig" - ], - "category_uuid": "afa671f5-8425-44be-ac8d-6c8508055739", - "type": "has_any_word", - "uuid": "dc2b1193-a214-4269-b3fd-9f20863e822d" - }, - { - "arguments": [ - "Primus" - ], - "category_uuid": "a6549d2f-38c5-4b07-be95-a599f6d468fa", - "type": "has_any_word", - "uuid": "21e62688-4e9c-4f9a-b4e7-476b87b37517" - }, - { - "arguments": [ - "Turbo King" - ], - "category_uuid": "81469d86-54c4-451a-b0b0-525f404d2b05", - "type": "has_any_word", - "uuid": "d9c60b83-fe0e-4ad6-8f48-57878f2b9185" - }, - { - "arguments": [ - "Skol" - ], - "category_uuid": "b98bdd8f-97d5-4fc5-b7dc-d352e467f8f1", - "type": "has_any_word", - "uuid": "35bfdcd2-45e9-483c-aa2b-3b2dc61e60f6" - } - ], - "categories": [ - { - "exit_uuid": "7644fb28-942f-4fca-a515-3e52f2678bae", - "name": "Mutzig", - "uuid": "afa671f5-8425-44be-ac8d-6c8508055739" - }, - { - "exit_uuid": "165d9dce-7a8b-4d12-a633-8b82552678db", - "name": "Primus", - "uuid": "a6549d2f-38c5-4b07-be95-a599f6d468fa" - }, - { - "exit_uuid": "d4bf9e11-f2ac-4b72-aa92-91b39fa8ba8e", - "name": "Turbo King", - "uuid": "81469d86-54c4-451a-b0b0-525f404d2b05" - }, - { - "exit_uuid": "8eaea6ac-f9a7-490e-9aaa-f76dba22b298", - "name": "Skol", - "uuid": "b98bdd8f-97d5-4fc5-b7dc-d352e467f8f1" - }, - { - "exit_uuid": "fa156c46-bd39-4bc2-91ca-c9710ad2cd5f", - "name": "Other", - "uuid": "ebe96e2f-8a66-4974-848e-6524b0e8893b" - } - ], - "default_category_uuid": "ebe96e2f-8a66-4974-848e-6524b0e8893b", - "operand": "@input", - "result_name": "Beer", - "type": "switch", - "wait": { - "type": "msg" - } - }, - "uuid": "34ae02f2-4cb2-4b63-8ec5-38b5c128e497", - "actions": [] - }, - { - "actions": [ - { - "text": "I don't know that one, try again please.", - "type": "send_msg", - "uuid": "75bf7db1-1cb9-4c63-8936-4691f08ba1e1", - "quick_replies": [] - } - ], - "exits": [ - { - "destination_uuid": "34ae02f2-4cb2-4b63-8ec5-38b5c128e497", - "uuid": "72774a1d-858f-498c-ad69-d04bb49af876" - } - ], - "uuid": "2e539a4c-68ff-4bf7-be23-b57845d2a550" - }, - { - "actions": [ - { - "text": "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?", - "type": "send_msg", - "uuid": "5d6c182b-f5d9-4ac9-be02-81337b73c503", - "quick_replies": [] - } - ], - "exits": [ - { - "destination_uuid": "0406725d-7701-463e-86a5-88a8af1ca42a", - "uuid": "6a28d354-a156-45db-8ae9-e4fe67c263a5" - } - ], - "uuid": "8362d6e8-6bf9-43a5-8f74-44fd0955ec75" - }, - { - "exits": [ - { - "destination_uuid": "440d670a-4ed5-46ff-9906-228e6ed498a4", - "uuid": "d740a951-37b2-4851-8fe8-406268f3eeec" - } - ], - "router": { - "cases": [], - "categories": [ - { - "exit_uuid": "d740a951-37b2-4851-8fe8-406268f3eeec", - "name": "All Responses", - "uuid": "b38fdc7f-b2e2-4dd6-acf2-68bb8e9cc84f" - } - ], - "default_category_uuid": "b38fdc7f-b2e2-4dd6-acf2-68bb8e9cc84f", - "operand": "@input", - "result_name": "Name", - "type": "switch", - "wait": { - "type": "msg" - } - }, - "uuid": "0406725d-7701-463e-86a5-88a8af1ca42a", - "actions": [] - }, - { - "actions": [ - { - "text": "Thanks @results.name, we are all done!", - "type": "send_msg", - "uuid": "1d09b2b7-8fc5-48ca-8c69-70a7f4e4ba0b", - "quick_replies": [] - }, - { - "uuid": "90a1eb56-bf99-42ba-82c8-e4bfcaf738d7", - "type": "set_contact_name", - "name": "@results.name" - } - ], - "exits": [ - { - "uuid": "ac30d632-1389-45c2-8b36-a5394feadf7f" - } - ], - "uuid": "440d670a-4ed5-46ff-9906-228e6ed498a4" - } - ], - "spec_version": "13.5.0", - "type": "messaging", - "uuid": "4fad232a-ca3a-4da7-be93-21492d407a33", - "revision": 38 - }, - { - "name": "Support", - "uuid": "de428d9c-3f63-4c66-bfa0-0c67e65aed66", - "spec_version": "13.5.0", - "language": "eng", - "type": "messaging", - "nodes": [ - { - "uuid": "6d796df5-0e15-4d93-98ee-0d4ffa78adef", - "actions": [ - { - "attachments": [], - "text": "Hi there, thanks for reaching out. Please give me as much detail as possible and I'll make sure we get somebody over to you right away!", - "type": "send_msg", - "quick_replies": [], - "uuid": "f05569c5-7a83-49d5-a09f-cf0d24780fae" - } - ], - "exits": [ - { - "uuid": "59fc2ad8-1728-4ff4-a9b3-cd10eac8da32", - "destination_uuid": "d634053c-c012-42d6-97d3-e18d4d8499d7" - } - ] - }, - { - "uuid": "d634053c-c012-42d6-97d3-e18d4d8499d7", - "actions": [ - { - "uuid": "b0a8d89b-b278-4129-bb41-d3f7f5075e02", - "type": "open_ticket", - "body": "", - "topic": { - "uuid": "ba121ac9-e7ff-4ef7-bf62-af81a6511f5a", - "name": "General", - "counts": { - "open": 0, - "closed": 0 - }, - "system": true, - "created_on": "2024-05-01T22:45:59.841309Z" - }, - "assignee": null, - "result_name": "Result" - } - ], - "router": { - "type": "switch", - "operand": "@results.result", - "cases": [ - { - "uuid": "0d7e136d-4d52-410f-861a-8e900468b145", - "type": "has_category", - "arguments": [ - "Success" - ], - "category_uuid": "f0652a4e-7b3f-4a68-b565-010d18386c19" - } - ], - "categories": [ - { - "uuid": "f0652a4e-7b3f-4a68-b565-010d18386c19", - "name": "Success", - "exit_uuid": "6166dc7f-b343-4f1a-879f-b03e75fa4766" - }, - { - "uuid": "0af54548-598d-40ef-8e68-20bad9d7c03e", - "name": "Failure", - "exit_uuid": "0068e8de-10e3-4bd7-9bf6-57294dccfbc9" - } - ], - "default_category_uuid": "0af54548-598d-40ef-8e68-20bad9d7c03e" - }, - "exits": [ - { - "uuid": "6166dc7f-b343-4f1a-879f-b03e75fa4766", - "destination_uuid": null - }, - { - "uuid": "0068e8de-10e3-4bd7-9bf6-57294dccfbc9", - "destination_uuid": null - } - ] - } - ], - "_ui": { - "nodes": { - "6d796df5-0e15-4d93-98ee-0d4ffa78adef": { - "position": { - "left": 20, - "top": 0 - }, - "type": "execute_actions" - }, - "d634053c-c012-42d6-97d3-e18d4d8499d7": { - "type": "split_by_ticket", - "position": { - "left": 20, - "top": 200 - }, - "config": {} - } - } - }, - "revision": 11, - "expire_after_minutes": 10080, - "localization": {} - }, - { - "name": "New Chat", - "uuid": "5fe7d119-9fca-41f4-adde-a4171301152f", - "spec_version": "13.5.0", - "language": "eng", - "type": "messaging", - "nodes": [ - { - "uuid": "f805ec81-be27-4d1d-bd1a-a513df850235", - "actions": [ - { - "attachments": [], - "text": "\ud83d\udc4b Welcome! Thanks for visiting our web page. Is there any thing I can answer for you?", - "type": "send_msg", - "quick_replies": [], - "uuid": "a0610c91-5e1c-43dd-bb31-88ce650d56ae" - } - ], - "exits": [ - { - "uuid": "941a4343-6eee-439f-9c1d-77cb81cbf78f", - "destination_uuid": "6fe16e87-40ea-46dd-8975-21e734270b86" - } - ] - }, - { - "uuid": "6fe16e87-40ea-46dd-8975-21e734270b86", - "actions": [], - "router": { - "type": "switch", - "default_category_uuid": "53c5cee4-963c-47d0-92fc-817647758d6b", - "cases": [ - { - "arguments": [ - "yes" - ], - "type": "has_any_word", - "uuid": "c14c8f2f-74bc-46ed-87d6-e5676a4baf73", - "category_uuid": "be9f6f8d-7401-46df-85ba-a929f5e36430" - }, - { - "arguments": [ - "no" - ], - "type": "has_any_word", - "uuid": "3b2875fe-ce4c-4ece-b518-af875b3bcef3", - "category_uuid": "8b1387c9-319c-4d21-851a-489404324390" - } - ], - "categories": [ - { - "uuid": "be9f6f8d-7401-46df-85ba-a929f5e36430", - "name": "Yes", - "exit_uuid": "606b7207-eb1c-4f7b-a0ee-12c7da8d0e7b" - }, - { - "uuid": "8b1387c9-319c-4d21-851a-489404324390", - "name": "No", - "exit_uuid": "9bcc3740-5fee-4829-84be-fe85f9498792" - }, - { - "uuid": "53c5cee4-963c-47d0-92fc-817647758d6b", - "name": "Other", - "exit_uuid": "5389008f-d3c7-4d86-97bb-952c116f6762" - } - ], - "operand": "@input.text", - "wait": { - "type": "msg" - }, - "result_name": "Result 1" - }, - "exits": [ - { - "uuid": "606b7207-eb1c-4f7b-a0ee-12c7da8d0e7b", - "destination_uuid": "81f59084-b989-4bfc-9fab-b944c65647c2" - }, - { - "uuid": "9bcc3740-5fee-4829-84be-fe85f9498792", - "destination_uuid": "dcf59feb-82d7-4eae-a63f-23905e8961b3" - }, - { - "uuid": "5389008f-d3c7-4d86-97bb-952c116f6762", - "destination_uuid": "81f59084-b989-4bfc-9fab-b944c65647c2" - } - ] - }, - { - "uuid": "dcf59feb-82d7-4eae-a63f-23905e8961b3", - "actions": [ - { - "attachments": [], - "text": "Hey, no problem, if you need anything, you know where to find me!", - "type": "send_msg", - "quick_replies": [], - "uuid": "31e3b9fc-719f-4ed1-8f68-4e9d6d10a9ea" - } - ], - "exits": [ - { - "uuid": "95c0c45b-effe-4350-bc7d-d128680925a5", - "destination_uuid": null - } - ] - }, - { - "uuid": "81f59084-b989-4bfc-9fab-b944c65647c2", - "actions": [ - { - "attachments": [], - "text": "Ok, please add as much additional detail as possible and I'll get somebody over to help.", - "type": "send_msg", - "quick_replies": [], - "uuid": "3f3dc468-043f-4386-93e2-c448428c8f38" - } - ], - "exits": [ - { - "uuid": "82398b8f-df44-4069-af05-f8a41f3056ff", - "destination_uuid": "f43743df-3d6c-4ae2-8f24-c6bef3e999fc" - } - ] - }, - { - "uuid": "f43743df-3d6c-4ae2-8f24-c6bef3e999fc", - "actions": [ - { - "uuid": "8b26cc8c-85d7-490c-9494-5986141a751c", - "type": "open_ticket", - "body": "", - "topic": { - "uuid": "ba121ac9-e7ff-4ef7-bf62-af81a6511f5a", - "name": "General", - "counts": { - "open": 0, - "closed": 0 - }, - "system": true, - "created_on": "2024-05-01T22:45:59.841309Z" - }, - "assignee": null, - "result_name": "Result" - } - ], - "router": { - "type": "switch", - "operand": "@results.result", - "cases": [ - { - "uuid": "7e7ac0f5-1249-484d-8774-eaffced73e4d", - "type": "has_category", - "arguments": [ - "Success" - ], - "category_uuid": "380bb0c4-913f-45e0-806c-f1e6ea75933e" - } - ], - "categories": [ - { - "uuid": "380bb0c4-913f-45e0-806c-f1e6ea75933e", - "name": "Success", - "exit_uuid": "f36e4bb3-5f73-4334-b25a-e4f43e0f1955" - }, - { - "uuid": "d43e8367-cd56-4063-88cc-5be83643c0d9", - "name": "Failure", - "exit_uuid": "797d6c62-34c0-4486-96e3-a7a2808c8784" - } - ], - "default_category_uuid": "d43e8367-cd56-4063-88cc-5be83643c0d9" - }, - "exits": [ - { - "uuid": "f36e4bb3-5f73-4334-b25a-e4f43e0f1955", - "destination_uuid": null - }, - { - "uuid": "797d6c62-34c0-4486-96e3-a7a2808c8784", - "destination_uuid": null - } - ] - } - ], - "_ui": { - "nodes": { - "f805ec81-be27-4d1d-bd1a-a513df850235": { - "position": { - "left": 0, - "top": 0 - }, - "type": "execute_actions" - }, - "6fe16e87-40ea-46dd-8975-21e734270b86": { - "type": "wait_for_response", - "position": { - "left": 0, - "top": 160 - }, - "config": { - "cases": {} - } - }, - "dcf59feb-82d7-4eae-a63f-23905e8961b3": { - "position": { - "left": 300, - "top": 320 - }, - "type": "execute_actions" - }, - "81f59084-b989-4bfc-9fab-b944c65647c2": { - "position": { - "left": 0, - "top": 360 - }, - "type": "execute_actions" - }, - "f43743df-3d6c-4ae2-8f24-c6bef3e999fc": { - "type": "split_by_ticket", - "position": { - "left": 0, - "top": 540 - }, - "config": {} - } - } - }, - "revision": 72, - "expire_after_minutes": 10080, - "localization": {} - } - ], - "campaigns": [], - "triggers": [ - { - "trigger_type": "K", - "flow": { - "uuid": "4fad232a-ca3a-4da7-be93-21492d407a33", - "name": "Favorites" - }, - "groups": [], - "exclude_groups": [], - "channel": null, - "keywords": [ - "fav" - ], - "match_type": "F" - }, - { - "trigger_type": "N", - "flow": { - "uuid": "5fe7d119-9fca-41f4-adde-a4171301152f", - "name": "New Chat" - }, - "groups": [], - "exclude_groups": [], - "channel": null - }, - { - "trigger_type": "K", - "flow": { - "uuid": "de428d9c-3f63-4c66-bfa0-0c67e65aed66", - "name": "Support" - }, - "groups": [], - "exclude_groups": [], - "channel": null, - "keywords": [ - "help" - ], - "match_type": "F" - } - ], - "fields": [], - "groups": [] -} \ No newline at end of file diff --git a/media/test_flows/triggered.json b/media/test_flows/triggered.json deleted file mode 100644 index 87f3505df1e..00000000000 --- a/media/test_flows/triggered.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "campaigns": [], - "version": 8, - "site": "http://rapidpro.io", - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "7b99bb2e-054f-4a60-a986-367ecf114879", - "actions": [ - { - "msg": { - "eng": "Honey, I triggered the flow! @extra.text" - }, - "type": "reply" - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "7b99bb2e-054f-4a60-a986-367ecf114879", - "rule_sets": [], - "metadata": { - "expires": 10080, - "saved_on": "2016-07-21T16:34:32.457154Z", - "id": 25994, - "name": "Triggeree", - "revision": 1 - } - }, - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "06a3be8b-36f9-4f73-b31a-95a1e8ee920d", - "actions": [ - { - "name": "Triggeree", - "contacts": [ - { - "name": "Marshawn", - "id": contact_id - } - ], - "variables": [], - "groups": [], - "type": "trigger-flow", - "id": 25994 - }, - { - "name": "Triggeree", - "type": "flow", - "id": 25994 - } - ] - } - ], - "version": 8, - "flow_type": "F", - "entry": "98d0948b-c50d-4033-b07c-403d324aa147", - "rule_sets": [{ - "uuid": "98d0948b-c50d-4033-b07c-403d324aa147", - "webhook_action": "GET", - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "eng": "All Responses" - }, - "destination": "06a3be8b-36f9-4f73-b31a-95a1e8ee920d", - "uuid": "1e89ff33-80fe-4d34-9ced-3b96f5aacd50", - "destination_type": "A" - } - ], - "webhook": "http://localhost:49999/where", - "ruleset_type": "webhook", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 0, - "x": 328, - "config": {} - }], - "metadata": { - "expires": 10080, - "revision": 1, - "id": 25995, - "name": "Triggerer", - "saved_on": "2016-07-21T16:35:05.717556Z" - } - } - ], - "triggers": [] -} diff --git a/media/test_flows/triggered_flow.json b/media/test_flows/triggered_flow.json deleted file mode 100644 index cfe6901a54e..00000000000 --- a/media/test_flows/triggered_flow.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "campaigns": [], - "version": 3, - "site": "http://rapdipro.io", - "flows": [ - { - "definition": { - "entry": "4ec3d47a-eef3-4d80-b5b1-38dab8e518dc", - "rule_sets": [], - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "4ec3d47a-eef3-4d80-b5b1-38dab8e518dc", - "actions": [ - { - "msg": "This is the triggered flow", - "type": "reply" - } - ] - } - ], - "metadata": { - "notes": [] - } - }, - "id": 12140, - "flow_type": "F", - "name": "Triggered Flow" - }, - { - "definition": { - "entry": "b6c4d782-5165-4541-bb2e-7348c9676882", - "rule_sets": [], - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "b6c4d782-5165-4541-bb2e-7348c9676882", - "actions": [ - { - "name": "Triggered Flow", - "contacts": [], - "variables": [], - "groups": [ - { - "name": "Survey Audience", - "id": 6250 - } - ], - "type": "trigger-flow", - "id": 12140 - } - ] - } - ], - "metadata": { - "notes": [] - } - }, - "id": 12141, - "flow_type": "F", - "name": "Trigger a Flow" - } - ], - "triggers": [] -} diff --git a/media/test_flows/two_to_all.json b/media/test_flows/two_to_all.json deleted file mode 100644 index b9d3e9e2b66..00000000000 --- a/media/test_flows/two_to_all.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "campaigns": [], - "version": 10, - "site": "https://app.rapidpro.io", - "flows": [ - { - "base_language": "eng", - "action_sets": [ - { - "y": 0, - "x": 100, - "destination": null, - "uuid": "a42b6981-1b8c-44a9-8260-3bf504c9bb25", - "actions": [ - { - "msg": { - "eng": "first message" - }, - "media": {}, - "send_all": true, - "type": "reply" - }, - { - "msg": { - "eng": "second message" - }, - "media": {}, - "send_all": true, - "type": "reply" - } - ] - } - ], - "version": 10, - "flow_type": "F", - "entry": "a42b6981-1b8c-44a9-8260-3bf504c9bb25", - "rule_sets": [], - "metadata": { - "expires": 10080, - "revision": 3, - "uuid": "a9de95b7-2959-40b7-afdd-99ef1975b812", - "name": "Two to all", - "saved_on": "2017-03-17T14:27:29.032085Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/ussd_example.json b/media/test_flows/ussd_example.json deleted file mode 100644 index 8f52a2ea9a7..00000000000 --- a/media/test_flows/ussd_example.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "version": 8, - "flows": [ - { - "base_language": "base", - "action_sets": [], - "version": 8, - "flow_type": "U", - "entry": "5e0fe53f-1caa-434d-97e7-189f33353372", - "rule_sets": [ - { - "uuid": "5e0fe53f-1caa-434d-97e7-189f33353372", - "webhook_action": null, - "rules": [ - { - "category": { - "base": "Sports" - }, - "test": { - "test": 1, - "type": "eq" - }, - "destination": "66aa0bb5-d1e5-4026-a056-fd22c353539e", - "uuid": "337e5e25-204b-4786-bee6-ff4c431986eb", - "destination_type": "R" - }, - { - "category": { - "base": "Politics" - }, - "test": { - "test": 2, - "type": "eq" - }, - "destination": "66aa0bb5-d1e5-4026-a056-fd22c353539e", - "uuid": "45803c40-aaf3-44d3-a301-f7eb35fa6be4", - "destination_type": "R" - }, - { - "category": { - "base": "Movies" - }, - "test": { - "test": 3, - "type": "eq" - }, - "destination": "66aa0bb5-d1e5-4026-a056-fd22c353539e", - "uuid": "13f3ed00-44d0-4119-b5fd-269c8f09fce3", - "destination_type": "R" - }, - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "Other" - }, - "destination": null, - "uuid": "6006a206-10f0-4937-a33f-7ec80deb8540" - } - ], - "webhook": null, - "ruleset_type": "wait_menu", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 0, - "x": 624, - "config": { - "ussd_menu": [ - { - "category": { - "base": "Sports" - }, - "uuid": "337e5e25-204b-4786-bee6-ff4c431986eb", - "option": 1, - "label": "I'm interested in sports" - }, - { - "category": { - "base": "Politics" - }, - "uuid": "45803c40-aaf3-44d3-a301-f7eb35fa6be4", - "option": 2, - "label": "I'm interested in politics" - }, - { - "category": { - "base": "Movies" - }, - "uuid": "13f3ed00-44d0-4119-b5fd-269c8f09fce3", - "option": 3, - "label": "I'm interested in movies" - } - ], - "ussd_message": { - "base": "What would you like to read about?" - } - } - }, - { - "uuid": "66aa0bb5-d1e5-4026-a056-fd22c353539e", - "webhook_action": null, - "rules": [ - { - "test": { - "test": "true", - "type": "true" - }, - "category": { - "base": "All Responses" - }, - "uuid": "0df9b0ac-d241-460c-9b7c-f9f350a661bf" - } - ], - "webhook": null, - "ruleset_type": "wait_ussd", - "label": "Response 2", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 310, - "x": 725, - "config": { - "ussd_menu": [ - { - "category": { - "base": "Dfd" - }, - "uuid": "03f39461-b649-4cb0-97f9-9ce6ecb5c606", - "option": 1, - "label": "dfdf" - }, - { - "category": {}, - "uuid": "8211b1dc-b443-4b1c-8849-bf18a69e13ef", - "option": 2, - "label": "" - } - ], - "ussd_message": { - "base": "Thank you!" - } - } - } - ], - "metadata": { - "name": "USSD example", - "notes": [], - "expires": 10080, - "revision": 37, - "id": 26, - "saved_on": "2016-02-17T16:17:48.396242Z" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/webhook_rule_first.json b/media/test_flows/webhook_rule_first.json deleted file mode 100644 index 6be5a7e436b..00000000000 --- a/media/test_flows/webhook_rule_first.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "version": 7, - "flows": [ - { - "version": 7, - "base_language": "eng", - "action_sets": [ - { - "y": 140, - "x": 190, - "destination": null, - "uuid": "c81d60ec-9a74-48d6-a55f-e70a5d7195d3", - "actions": [ - { - "msg": { - "eng": "Testing this out" - }, - "type": "reply" - } - ] - } - ], - "last_saved": "2015-07-29T20:57:32.146036Z", - "entry": "9b3b6b7d-13ec-46ea-8918-a83a4099be33", - "rule_sets": [ - { - "uuid": "9b3b6b7d-13ec-46ea-8918-a83a4099be33", - "webhook_action": "GET", - "rules": [ - { - "category": { - "base": "All Responses", - "eng": "All Responses" - }, - "uuid": "0734d69a-99c1-45f6-a3df-7246408c4565", - "destination": "c81d60ec-9a74-48d6-a55f-e70a5d7195d3", - "destination_type": "A", - "test": { - "test": "true", - "type": "true" - }, - "config": { - "type": "true", - "verbose_name": "contains anything", - "name": "Other", - "operands": 0 - } - } - ], - "webhook": "http://google.com", - "ruleset_type": "webhook", - "label": "Response 1", - "operand": "@step.value", - "finished_key": null, - "response_type": "", - "y": 0, - "x": 100 - } - ], - "flow_type": "F", - "metadata": { - "expires": 10080, - "id": 33868, - "name": "Test Webhook First" - } - } - ], - "triggers": [] -} \ No newline at end of file diff --git a/media/test_flows/with_message_topic.json b/media/test_flows/with_message_topic.json deleted file mode 100644 index b99e4a0aafa..00000000000 --- a/media/test_flows/with_message_topic.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "version": "13", - "flows": [ - { - "uuid": "c07d368e-fe1d-469d-b267-bca02d17c3e3", - "name": "Topic Flow", - "spec_version": "13.0.0", - "language": "eng", - "type": "messaging", - "revision": 1, - "expire_after_minutes": 10080, - "localization": {}, - "nodes": [ - { - "uuid": "5bf98a89-d38d-40ba-8e5e-d54cad1777f1", - "actions": [ - { - "attachments": [], - "text": "This is a message with a topic.", - "type": "send_msg", - "quick_replies": [], - "uuid": "b103d563-9320-40fe-8f96-c9d3fbe0262d", - "templating": null, - "topic": "agent" - } - ], - "exits": [ - { - "uuid": "29dd720b-00ed-4304-97b0-38c65bc38e30" - } - ] - } - ], - "_ui": { - "nodes": { - "5bf98a89-d38d-40ba-8e5e-d54cad1777f1": { - "position": { - "left": 20, - "top": 40 - }, - "type": "execute_actions" - } - } - } - } - ] -} From 1364d5c40fd1cd512bedffec34f6f1301670ac0e Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 27 Aug 2024 16:01:30 +0200 Subject: [PATCH 025/557] Fix channel URLs to have a trailing slash --- temba/channels/types/facebookapp/type.py | 2 +- temba/channels/types/instagram/type.py | 2 +- temba/channels/types/plivo/type.py | 4 ++-- temba/channels/types/twilio/type.py | 4 ++-- temba/channels/types/whatsapp/type.py | 10 ++++++---- temba/channels/types/whatsapp_legacy/type.py | 2 +- temba/orgs/integrations/dtone/type.py | 2 +- templates/channels/types/whatsapp/connect.html | 4 ++-- 8 files changed, 16 insertions(+), 14 deletions(-) diff --git a/temba/channels/types/facebookapp/type.py b/temba/channels/types/facebookapp/type.py index c263e739ba9..347c2367291 100644 --- a/temba/channels/types/facebookapp/type.py +++ b/temba/channels/types/facebookapp/type.py @@ -38,7 +38,7 @@ def get_urls(self): return [ self.get_claim_url(), re_path( - r"^(?P[a-z0-9\-]+)/refresh_token$", RefreshToken.as_view(channel_type=self), name="refresh_token" + r"^(?P[a-z0-9\-]+)/refresh_token/$", RefreshToken.as_view(channel_type=self), name="refresh_token" ), ] diff --git a/temba/channels/types/instagram/type.py b/temba/channels/types/instagram/type.py index 67656ca72f3..a36a2e73110 100644 --- a/temba/channels/types/instagram/type.py +++ b/temba/channels/types/instagram/type.py @@ -35,7 +35,7 @@ def get_urls(self): return [ self.get_claim_url(), re_path( - r"^(?P[a-z0-9\-]+)/refresh_token$", + r"^(?P[a-z0-9\-]+)/refresh_token/$", RefreshToken.as_view(channel_type=self), name="refresh_token", ), diff --git a/temba/channels/types/plivo/type.py b/temba/channels/types/plivo/type.py index 12d7521b352..e83df251568 100644 --- a/temba/channels/types/plivo/type.py +++ b/temba/channels/types/plivo/type.py @@ -45,6 +45,6 @@ def deactivate(self, channel): def get_urls(self): return [ self.get_claim_url(), - re_path(r"^search$", SearchView.as_view(channel_type=self), name="search"), - re_path(r"^connect$", Connect.as_view(channel_type=self), name="connect"), + re_path(r"^search/$", SearchView.as_view(channel_type=self), name="search"), + re_path(r"^connect/$", Connect.as_view(channel_type=self), name="connect"), ] diff --git a/temba/channels/types/twilio/type.py b/temba/channels/types/twilio/type.py index e318af8fd86..02d04c5fb73 100644 --- a/temba/channels/types/twilio/type.py +++ b/temba/channels/types/twilio/type.py @@ -81,8 +81,8 @@ def deactivate(self, channel): def get_urls(self): return [ self.get_claim_url(), - re_path(r"^search$", SearchView.as_view(channel_type=self), name="search"), - re_path(r"^connect$", Connect.as_view(channel_type=self), name="connect"), + re_path(r"^search/$", SearchView.as_view(channel_type=self), name="search"), + re_path(r"^connect/$", Connect.as_view(channel_type=self), name="connect"), ] def get_error_ref_url(self, channel, code: str) -> str: diff --git a/temba/channels/types/whatsapp/type.py b/temba/channels/types/whatsapp/type.py index 3c864becd36..faad6a00b08 100644 --- a/temba/channels/types/whatsapp/type.py +++ b/temba/channels/types/whatsapp/type.py @@ -39,12 +39,14 @@ class WhatsAppType(ChannelType): def get_urls(self): return [ self.get_claim_url(), - re_path(r"^clear_session_token$", ClearSessionToken.as_view(channel_type=self), name="clear_session_token"), re_path( - r"^(?P[a-z0-9\-]+)/request_code$", RequestCode.as_view(channel_type=self), name="request_code" + r"^clear_session_token/$", ClearSessionToken.as_view(channel_type=self), name="clear_session_token" ), - re_path(r"^(?P[a-z0-9\-]+)/verify_code$", VerifyCode.as_view(channel_type=self), name="verify_code"), - re_path(r"^connect$", Connect.as_view(channel_type=self), name="connect"), + re_path( + r"^(?P[a-z0-9\-]+)/request_code/$", RequestCode.as_view(channel_type=self), name="request_code" + ), + re_path(r"^(?P[a-z0-9\-]+)/verify_code/$", VerifyCode.as_view(channel_type=self), name="verify_code"), + re_path(r"^connect/$", Connect.as_view(channel_type=self), name="connect"), ] def activate(self, channel): diff --git a/temba/channels/types/whatsapp_legacy/type.py b/temba/channels/types/whatsapp_legacy/type.py index 577ba145a9a..e7e4eab5a76 100644 --- a/temba/channels/types/whatsapp_legacy/type.py +++ b/temba/channels/types/whatsapp_legacy/type.py @@ -49,7 +49,7 @@ class WhatsAppLegacyType(ChannelType): def get_urls(self): return [ self.get_claim_url(), - re_path(r"^(?P[a-z0-9\-]+)/refresh$", RefreshView.as_view(channel_type=self), name="refresh"), + re_path(r"^(?P[a-z0-9\-]+)/refresh/$", RefreshView.as_view(channel_type=self), name="refresh"), ] def get_api_headers(self, channel): diff --git a/temba/orgs/integrations/dtone/type.py b/temba/orgs/integrations/dtone/type.py index 51eaac24123..a96df1eaded 100644 --- a/temba/orgs/integrations/dtone/type.py +++ b/temba/orgs/integrations/dtone/type.py @@ -40,4 +40,4 @@ def management_ui(self, org, formax): formax.add_section(self.slug, account_url, icon=self.icon, action="redirect", nobutton=True) def get_urls(self): - return [re_path(r"^account$", AccountView.as_view(integration_type=self), name="account")] + return [re_path(r"^account/$", AccountView.as_view(integration_type=self), name="account")] diff --git a/templates/channels/types/whatsapp/connect.html b/templates/channels/types/whatsapp/connect.html index 04fb20620cb..c19eb4a8dda 100644 --- a/templates/channels/types/whatsapp/connect.html +++ b/templates/channels/types/whatsapp/connect.html @@ -100,7 +100,7 @@ submitClaimForm(response.authResponse.code); } else { console.log('User cancelled login or did not fully authorize, redirect to the dialog auth'); - location.replace("https://www.facebook.com/v18.0/dialog/oauth?client_id={{ facebook_app_id }}&redirect_uri=" + window.location.origin + window.location.pathname + "&config_id={{ facebook_login_whatsapp_config_id }}&response_type=code&override_default_response_type=true") + location.replace("https://www.facebook.com/v18.0/dialog/oauth?client_id={{ facebook_app_id }}&redirect_uri=" + window.location.origin + "{{connect_url}}" + "&config_id={{ facebook_login_whatsapp_config_id }}&response_type=code&override_default_response_type=true") } }, { config_id: '{{ facebook_login_whatsapp_config_id }}', @@ -113,7 +113,7 @@ submitClaimForm(response.authResponse.accessToken); } else { console.log('User cancelled login or did not fully authorize, redirect to the dialog auth'); - location.replace("https://www.facebook.com/v18.0/dialog/oauth?client_id={{ facebook_app_id }}&redirect_uri=" + window.location.origin + window.location.pathname + "&scope=business_management,whatsapp_business_management,whatsapp_business_messaging&response_type=token") + location.replace("https://www.facebook.com/v18.0/dialog/oauth?client_id={{ facebook_app_id }}&redirect_uri=" + window.location.origin + "{{connect_url}}" + "&scope=business_management,whatsapp_business_management,whatsapp_business_messaging&response_type=token") } }, { scope: 'business_management,whatsapp_business_management,whatsapp_business_messaging', From a0f1d976e36067f793c94bee5f03f4d78dec663c Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 27 Aug 2024 18:24:00 +0200 Subject: [PATCH 026/557] Authorization code cannot be debugged --- temba/channels/types/whatsapp/tests.py | 37 +++++++++++++++++++------- temba/channels/types/whatsapp/views.py | 5 ++-- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/temba/channels/types/whatsapp/tests.py b/temba/channels/types/whatsapp/tests.py index d15b4bd9034..b2929f52835 100644 --- a/temba/channels/types/whatsapp/tests.py +++ b/temba/channels/types/whatsapp/tests.py @@ -63,10 +63,15 @@ def test_claim(self, mock_randint): with patch("requests.get") as wa_cloud_get: wa_cloud_get.side_effect = [ MockJsonResponse(400, {}), + # debug not valid + MockJsonResponse( + 200, + {"data": {"scopes": [], "is_valid": False}}, + ), # missing permissions MockJsonResponse( 200, - {"data": {"scopes": []}}, + {"data": {"scopes": [], "is_valid": True}}, ), # success MockJsonResponse( @@ -77,7 +82,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } }, ), @@ -89,7 +95,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } }, ), @@ -101,7 +108,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } }, ), @@ -115,6 +123,12 @@ def test_claim(self, mock_randint): response.context["form"].errors["__all__"][0], "Sorry account could not be connected. Please try again" ) + # 200 but has invalid key + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) + self.assertEqual( + response.context["form"].errors["__all__"][0], "Sorry account could not be connected. Please try again" + ) + # missing permissions response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) self.assertEqual( @@ -153,7 +167,8 @@ def test_claim(self, mock_randint): "scopes": [ "business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } } ), @@ -184,7 +199,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } } ), @@ -256,7 +272,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } } ), @@ -405,7 +422,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } } ), @@ -473,7 +491,8 @@ def test_claim(self, mock_randint): "business_management", "whatsapp_business_management", "whatsapp_business_messaging", - ] + ], + "is_valid": True, } } ), diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 2d667eae534..1839fb0e6d7 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -356,7 +356,8 @@ def clean(self): params = {"access_token": f"{app_id}|{app_secret}", "input_token": auth_token} response = requests.get(url, params=params) - if response.status_code != 200: # pragma: no cover + response_json = response.json() + if response.status_code != 200 or not response_json["data"]["is_valid"]: # pragma: no cover auth_code = auth_token token_request_data = { @@ -382,8 +383,6 @@ def clean(self): else: raise Exception("Failed to debug user token") - response_json = response.json() - for perm in ["business_management", "whatsapp_business_management", "whatsapp_business_messaging"]: if perm not in response_json.get("data", dict()).get("scopes", []): raise Exception( From a2efdab7fa37785e5f42b607c303150f198791ec Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 27 Aug 2024 19:44:35 +0200 Subject: [PATCH 027/557] Update CHANGELOG.md for v9.3.28 --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44653734691..1a5784b3d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +v9.3.28 (2024-08-27) +------------------------- + * Authorization code cannot be debugged + * Fix channel URLs to have a trailing slash + * Delete no longer used test flows + * Simplify functions for loading flows in tests and move flows used by legacy migration tests into their own directory + * TembaTest.create_flow should return a flow in latest version without migrating + * Only import real flows in tests where it's required + * Update README.md + v9.3.27 (2024-08-21) ------------------------- * Updates to migrate_dynamo command diff --git a/pyproject.toml b/pyproject.toml index f5fc46ad6c9..f0b3515797f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.27" +version = "9.3.28" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 78536a4c0e8..5f4c049b710 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.27" +__version__ = "9.3.28" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From b0996b6bb202099a7b6167a3f7bf1dc4c5260643 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 27 Aug 2024 21:57:49 +0200 Subject: [PATCH 028/557] Fix authorization code, verification, redirect URI --- temba/channels/types/whatsapp/tests.py | 178 +++++++++++++------------ temba/channels/types/whatsapp/views.py | 39 +++--- 2 files changed, 115 insertions(+), 102 deletions(-) diff --git a/temba/channels/types/whatsapp/tests.py b/temba/channels/types/whatsapp/tests.py index b2929f52835..efcec116c66 100644 --- a/temba/channels/types/whatsapp/tests.py +++ b/temba/channels/types/whatsapp/tests.py @@ -20,6 +20,7 @@ class WhatsAppTypeTest(TembaTest): FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET", WHATSAPP_FACEBOOK_BUSINESS_ID="FB_BUSINESS_ID", WHATSAPP_ADMIN_SYSTEM_USER_TOKEN="WA_ADMIN_TOKEN", + FACEBOOK_LOGIN_WHATSAPP_CONFIG_ID="100", ) @patch("temba.channels.types.whatsapp.views.randint") def test_claim(self, mock_randint): @@ -61,92 +62,99 @@ def test_claim(self, mock_randint): self.assertEqual(response.request["PATH_INFO"], connect_whatsapp_cloud_url) with patch("requests.get") as wa_cloud_get: - wa_cloud_get.side_effect = [ - MockJsonResponse(400, {}), - # debug not valid - MockJsonResponse( - 200, - {"data": {"scopes": [], "is_valid": False}}, - ), + with patch("requests.post") as wa_cloud_post: + wa_cloud_get.side_effect = [ + MockJsonResponse(400, {}), + # debug not valid + MockJsonResponse( + 200, + {"data": {"scopes": [], "is_valid": False}}, + ), + # missing permissions + MockJsonResponse( + 200, + {"data": {"scopes": [], "is_valid": True}}, + ), + # success + MockJsonResponse( + 200, + { + "data": { + "scopes": [ + "business_management", + "whatsapp_business_management", + "whatsapp_business_messaging", + ], + "is_valid": True, + } + }, + ), + MockJsonResponse( + 200, + { + "data": { + "scopes": [ + "business_management", + "whatsapp_business_management", + "whatsapp_business_messaging", + ], + "is_valid": True, + } + }, + ), + MockJsonResponse( + 200, + { + "data": { + "scopes": [ + "business_management", + "whatsapp_business_management", + "whatsapp_business_messaging", + ], + "is_valid": True, + } + }, + ), + ] + + wa_cloud_post.return_value = MockResponse(200, json.dumps({"access_token": "Z" * 48})) + + response = self.client.get(connect_whatsapp_cloud_url) + self.assertEqual(response.status_code, 200) + + # 400 status + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) + self.assertEqual( + response.context["form"].errors["__all__"][0], + "Sorry account could not be connected. Please try again", + ) + + # 200 but has invalid key + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) + self.assertEqual( + response.context["form"].errors["__all__"][0], + "Sorry account could not be connected. Please try again", + ) + # missing permissions - MockJsonResponse( - 200, - {"data": {"scopes": [], "is_valid": True}}, - ), - # success - MockJsonResponse( - 200, - { - "data": { - "scopes": [ - "business_management", - "whatsapp_business_management", - "whatsapp_business_messaging", - ], - "is_valid": True, - } - }, - ), - MockJsonResponse( - 200, - { - "data": { - "scopes": [ - "business_management", - "whatsapp_business_management", - "whatsapp_business_messaging", - ], - "is_valid": True, - } - }, - ), - MockJsonResponse( - 200, - { - "data": { - "scopes": [ - "business_management", - "whatsapp_business_management", - "whatsapp_business_messaging", - ], - "is_valid": True, - } - }, - ), - ] - response = self.client.get(connect_whatsapp_cloud_url) - self.assertEqual(response.status_code, 200) - - # 400 status - response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) - self.assertEqual( - response.context["form"].errors["__all__"][0], "Sorry account could not be connected. Please try again" - ) - - # 200 but has invalid key - response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) - self.assertEqual( - response.context["form"].errors["__all__"][0], "Sorry account could not be connected. Please try again" - ) - - # missing permissions - response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) - self.assertEqual( - response.context["form"].errors["__all__"][0], "Sorry account could not be connected. Please try again" - ) - - response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36)) - self.assertIn(WhatsAppType.SESSION_USER_TOKEN, self.client.session) - self.assertEqual(response.url, claim_whatsapp_cloud_url) - - response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) - self.assertEqual(response.status_code, 200) - - self.assertEqual(wa_cloud_get.call_args_list[0][0][0], "https://graph.facebook.com/v18.0/debug_token") - self.assertEqual( - wa_cloud_get.call_args_list[0][1], - {"params": {"access_token": "FB_APP_ID|FB_APP_SECRET", "input_token": "X" * 36}}, - ) + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) + self.assertEqual( + response.context["form"].errors["__all__"][0], + "Sorry account could not be connected. Please try again", + ) + + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36)) + self.assertIn(WhatsAppType.SESSION_USER_TOKEN, self.client.session) + self.assertEqual(response.url, claim_whatsapp_cloud_url) + + response = self.client.post(connect_whatsapp_cloud_url, dict(user_access_token="X" * 36), follow=True) + self.assertEqual(response.status_code, 200) + + self.assertEqual(wa_cloud_get.call_args_list[0][0][0], "https://graph.facebook.com/v18.0/debug_token") + self.assertEqual( + wa_cloud_get.call_args_list[0][1], + {"params": {"access_token": "FB_APP_ID|FB_APP_SECRET", "input_token": "Z" * 48}}, + ) # make sure the token is set on the session session = self.client.session diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 1839fb0e6d7..5a641981eea 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -345,6 +345,10 @@ class Connect(ChannelTypeMixin, OrgPermsMixin, SmartFormView): class WhatsappCloudConnectForm(forms.Form): user_access_token = forms.CharField(min_length=32, required=True) + def __init__(self, org, *args, **kwargs): + self.org = org + super().__init__(*args, **kwargs) + def clean(self): try: auth_token = self.cleaned_data.get("user_access_token", None) @@ -352,36 +356,32 @@ def clean(self): app_id = settings.FACEBOOK_APPLICATION_ID app_secret = settings.FACEBOOK_APPLICATION_SECRET - url = "https://graph.facebook.com/v18.0/debug_token" - params = {"access_token": f"{app_id}|{app_secret}", "input_token": auth_token} - - response = requests.get(url, params=params) - response_json = response.json() - if response.status_code != 200 or not response_json["data"]["is_valid"]: # pragma: no cover - auth_code = auth_token - + if settings.FACEBOOK_LOGIN_WHATSAPP_CONFIG_ID: token_request_data = { "client_id": app_id, "client_secret": app_secret, - "code": auth_code, + "code": auth_token, "grant_type": "authorization_code", "redirect_uri": "https://" - + self.derive_org().get_brand_domain() + + self.org.get_brand_domain() + reverse("channels.types.whatsapp.connect"), } token_url = "https://graph.facebook.com/v18.0/oauth/access_token" response = requests.post(token_url, json=token_request_data) response_json = response.json() + if int(response.status_code / 100) == 2: + auth_token = response_json["access_token"] - auth_token = response_json["access_token"] + url = "https://graph.facebook.com/v18.0/debug_token" + params = {"access_token": f"{app_id}|{app_secret}", "input_token": auth_token} - params = {"access_token": f"{app_id}|{app_secret}", "input_token": auth_token} + response = requests.get(url, params=params) + response_json = response.json() - response = requests.get(url, params=params) - if response.status_code == 200: - self.cleaned_data["user_access_token"] = auth_token - else: - raise Exception("Failed to debug user token") + if response.status_code == 200: + self.cleaned_data["user_access_token"] = auth_token + else: + raise Exception("Failed to debug user token") for perm in ["business_management", "whatsapp_business_management", "whatsapp_business_messaging"]: if perm not in response_json.get("data", dict()).get("scopes", []): @@ -413,6 +413,11 @@ def pre_process(self, request, *args, **kwargs): return super().pre_process(request, *args, **kwargs) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs + def form_valid(self, form): auth_token = form.cleaned_data["user_access_token"] From abb82200ac2243202bb41bec29d5f4de0acf2410 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 27 Aug 2024 22:24:43 +0200 Subject: [PATCH 029/557] Update CHANGELOG.md for v9.3.29 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5784b3d88..b701c2ea709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.29 (2024-08-27) +------------------------- + * Fix authorization code, verification, redirect URI + v9.3.28 (2024-08-27) ------------------------- * Authorization code cannot be debugged diff --git a/pyproject.toml b/pyproject.toml index f0b3515797f..b4dac297c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.28" +version = "9.3.29" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 5f4c049b710..dad85c64a84 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.28" +__version__ = "9.3.29" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 9e604a1e72110bac0147db951eeb33870c63b646 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 2 Sep 2024 14:53:48 +0200 Subject: [PATCH 030/557] Import cell data value instead of formulas using data_only flag to load the workbook --- media/test_imports/formula_data.xlsx | Bin 0 -> 10089 bytes temba/contacts/models.py | 4 ++-- temba/contacts/tests.py | 26 ++++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 media/test_imports/formula_data.xlsx diff --git a/media/test_imports/formula_data.xlsx b/media/test_imports/formula_data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ab0e53e892a5399a571d5d515559b9de34440120 GIT binary patch literal 10089 zcmeHt1y>x|7H(s~-CY_DPH@-83GNWw-3bX6oZu3i;O-Cz1ec%zg44JKC%D7wWbVAo zWahm;aBr>Eb-GraZ?8J1_P4)XXCGyG7+4&@6957L0H6e9G1)oVLID66Z~y={00CN0 z!qLIa+`;XIy0??Ls{xCby&Xj^EHwRl05qik|BnCS87NQeRO)6!2|1GaB$32uQ|By- zPysgXz@%j-qo6({Vph!MB?g1nElkCW%fkLe5nPDftB-`n~)qbMM zUvN-rb~Nvn>WG@Ftlbg+FIK|{SmlEbbKw4)S%g^6nXJ##girCcOK5C5AKKfQs5 zHR9laq69Ae7(51&t^aHaRfQC={(eh%Jv%V2$Xt+3#;1*Xp&N6@59ayk5N5nW3}s&{ z6iM^GZ2jm*Jg7-M6i&i7Fn5!OtFc}E-ipi|PMAu~-|k8r@jJcq5z)T;`5$50*SS6y z$H)zti1pyQ9f&J@T*H>|X{T2vT@qZhoM45x*W)7$K>2SRZO~u?ogk;?U`=V zRa(xQ-lH|FwC}ub7oG`c@Ms7J`fm5Uc3a6tACQDnD$9D zHw*v(gy;ZGVVZzJ`HgIvdXG?D|Ke>r5hS8HA^qC&AMtnWZ0W z8;b^^ioD>4?I!ZmE|xtN#pgLE65Z)*YO45zFPB^o*3rvqMPu}>Da!~ZfcQYe0uJn{ z*KgbP>6F6axXTB%DH+Mu=_-#y6E2UU->C`>7rYV$DDMz_>zl-+Kio}RB0OY zQ-J=owqJc7)ZQsKV+T(8qM%8fgs;VPZ8;6eoiF#7w)gIeyivJqAE%coo05a=^a1Kt zZ*Hw7tOwIyjp#OqO`Th6&MV*&;3}l+(4vf*2>R#fpJXCVO|FA*j0zlN7-;GA_+HQx zh#pD@ua=sGJU25JwISYpWGut;iI>MV_b zy*(N&R!fDh6FCp#Q?}A51*+CQW;}GsC^wgnP2N&VnJVshOZFkt8EyA*|1q@Z+EvyO z->MM~ZvYR1&x6~nYwLG>-n<)?r#hx5ifznpZ(qwCX6SU*a-hZogQznPE6NIdt+oWcqa*hvj^|5P}L|#u+1eacRu;E{Hbzd1&P`vdhHFV(l*bR* z^su2wT?RkA8E8PGpH|CW{nU?qP0f3wP8zGy)9ku#&gSq=AEhoBcMU)uLq+w_aqFCLC65@&>wl z!La~kUOAM~S!j;8B;zR7bjNlsPuN7!C7}@g>nIn6$OKNPYJf;$BSl0`%$G;omQou| zA%XkxxJ_g{j_+fAaFPLSw3^@b=;AOp%x_21E=Wej6K=H!k4KUTYPks8GDMz8^k9E3 z!QJ513HtZ~8)RCRjl%5_$k&j};?ihCkMi>A^6cl~(K>d=$Ikw`*>58M>O)*_KQ$u3 z0su{j0004`$A7}KtChLAn=9+jhW$sp&PW7D&$D5K95J4gQY|L{DZ&uSY#PCplgN{c z_Ek~ODDj*#=C|@okCtj&0f>k>QbunMuD7xW9P!YlRlI$a(}wtgapYL&HngsWjvOZs z7lLV+&xv)v)yH$0ZNR1pGKu)UZq2<7P&^mf9y`vu^%#9wly{Pv735w#*=W>qlGl-oAW@ z+w8hFD@cwEaah3}_Nh}f>>NqgUxj{|1Un?fmNCdj)WzMYrfjw$pXjT&kLbCAANr>4 z>TpMutc@Y-afR!>=lFR@<%=9>3xE`;r|!W}{$oQ9YZH#H?4kdNSn8^eS5Rq}x_0Kn zgoQhUo(gw0zE|qw+#*7`xgwX~;4aUoVmO0}1RC6;#?~j^(*$a?1o|wLbdk+ff$xge z99X6ZU~8aMdth@W<;2|?Y}zWS*s3pQO_Wh>7v9P?qC*uwe+C&?RRAtUGCjdvz~RG+ zp+3Bo#5fTP&Nm5sqT4#LZ3C43RMVA(XVqyf;-3xZzZh(rr}M);*e@0X7~SWb4>6AK znghXat(QBjIa(MBxJqpw{eJq}&r^9qr&18ad0qsc<&TT%ZP(f%4Y0&|eU0>#sBWSi zB#PL(xlkg6DX`LzhS4SEQht$36DBu3Nm*ZHdFS)7NBmD zs^*JhMHHq(qBda!W?^r}YsW|eBw`XIfRE2UB0`Jdr0)t4v<;9cu`2F;^K87nc*tFu zF&tnuA0uGZdaK3~^qxtUM2Y}ccRe_oikd1ij6_OiHX@@vQKmdpo_}I+80)OI{_NW( z8$&xil9&oFLxdG4eMAwM_`)t!r8;{(3sD*qi!oB0FCz9z789#@TY<5dJ|$}c|JOvR zM~NYIiK0kVahR>jWqeo}wB`v!$PLS)n6D?bOE*s2J)cs?sgp5`&nCyOE)?pUzkFj| z5>KMaHa;^V*(FAYkGHB+L&?Oj70XmhlM~xD9&npuq<$X@bu+I{gxvB`WETE2cnm}K z7!oD^sXGKydLoX?1(`1ybhCyXrhsPjSOhj|e_A0Jrh1mRE zHLSFKhdBxyyD>qgv9=9Vcdj=Q#Cv2ymF&xBHf6J=5~nq`VXcE>5tQ9H)1gmNgE__A z5vFJt$^8$eUej^B&j{8%?9<+w$W4DxQZtjbe^Q%I`AkiHfJHHLnXv0QS`LW;s@f%+ zb#g#$J2nDx&y;CU#mS zt9?=PCk;OCnByM^kE;nad|Mh7cX7Fto|Swl(NE*+$z8Zdtk0i)RBA8kyx@dk6TJ^H zbc=zadembzDlJnR+H%D!J~4aeGS*E2r7iw!txvW5A-`vA^yR1pvi-83W*+*A4d+b@ za~}8GzLu05__S3+Sz|dH&R6>QXzYeA6C`Oz&FOn*r(X`Z)EG46b07T=!fO4Wagxsy z7|^`)mxms*0*d5FADMikWQ zqp0HX2_5?bu8-8TIXzDp5m8H3?tH_BJZ}cULP^r4`oKQ2!Wmz=^UT72tr^nuV)z~E z?nhkDTK7l|_P##zYf^2Jq^JrDIxKt8*y-mu6T(#$!4u_sm*xI(dje!L1#$(KdDM!j z#``py)dT7YTj^GAY7W#sOs`zzr&B=|)#&F!t=SrWo!NZPJA!GsH{;CX7r8cVU(R-j zmq@o+%+Cz^&2XBMt@B2QsO7?@ZiXb`j~I6W!- z*cq<3g+!rKQnuyUD87qzHG@!k`fJH~8B2LfTkWeCixgfKdpibklj$GUR-a`&rwGrj zBN3(ukjI#{A(eJMq2UOF^1h-+Wiz9|PG}+Xh9=nVj%L#B^pK=hQ_!IRcX4*`SHFm4 zh&h~=81JYi;Pd#^W9-i!UKxGz@ZGBI3C2p+taBNX zt0P;zh(2!UmLpEHh>n1c7lP~@!yo;TnhT>J1*T+V)Id2BEjC1uUAc zfzwL3>E`HIbkmA|H$od@t#@`VUYJKGRA}650_Xov-t9Qr(h4zV}0jv@y#h(l}l=|TwyzbW?vIWbkN%pM-;ibIed1;#ge$P zABIfBD_|l*o44%A>=$4PwglK1+v3YPdGrKhWsrK8lX`#5fh86L4Cy{9V6~_a23L)z zJP*esx9Ub`%6x!m?>|*pSlwO#0a8JDAOrwV|4?ODH*Y(0*B`0;g3h929xsNU$j$?N zk52?*0IEuVsceKpfrh)P&(~IdNYWe?-mydL+5C8KVP~~HeOUMc-1;WeKQ#4vB-GN* z^2Ygv%eUkyMV0WL^U<(9wm6MFaXYNbm_0W(k4%tiP2ndjCTH|=XOSn!)Toy^B0r^s|FmjWz*|yhG3OXN?A&RW?rsyfz!s_-zd;rh6G6($|A4YASQ4K zNXf|Vv9=sglnpW`^_H##3)1UUs#zpFCsvtq>xJ4~A|D@~yHyc#yKpa(j1nGG!_zU~ z(qI`Ct<>7|j;jfEGG)E6@<@b@&!!td7vlJ2dE4e&m>;KA2Zrwc_NCC9#%$eeN~ymG z6+qu?bRwnxLMnnf%;|Q@ZvJj_K@|^k6qVog^170Ge}7v+FLlfx74Dm-5^;{7kr8+BetbC|!zbQ@ z({p2FZyF;itFYacqgSmZJDubqMb9{KS*OEH>YB(C2+YgpRhkoULD&-G5)lD>a7h~B zRiFGy@d{A%n@V@NCSzGng#oXGIMZE{BM(QQ+O4{`9a>?>6OUMC9s=TpwHvq zg63h&X`MGw6T{(kO4Vj0a4SL*+O)KB5)*{s`$^Z1UR8DHMBT1_u->Et7mA21a%<2~ zfC!rK!G%ffItxov+y}*vMfQ#)JO|C5g9AL#;63m(o&tS4;rbmC@@Dq60cszpeU!9@ zaYEHMFd2&>%m^Rf`O2o3i* z`JP-}SCR=Gk>uhVb3YoD{cLH~NK*mXi*`VqA6qva|p2`NM3} zl(^U$W{Ay>r*1UZ&B%&p*CrI9>fEA$C-Qr2leOGraC)~RLrL>6jv0pvf>^i((pS5e z!-Dif!*ABPBT&JwK2o>>15Ys=u|H7sf+cmn9Q(?Q_bXgjEvG00P3nY#-ZCF|8t12C zSfzz?d^mYuUL!^1lA6zqu*5B7kww5#hrY&(klVjC-rE&sWPGZJ)0vthl{P}QQN5$% zUs!ZKpWGT_-oNf`bg{ioXcAfCZDG7M%hc;!haPKkLG04A%em|8PDveY|1!244^Hp! zy>A`aFvGK53hfT_2!`Fp5hDSbXnVnwNLnFGeY_({_JyribtCZRI*@Q9z|YHa zu9XLF_VxZT8`Za*=TEFf?>gyo*E@NUy2OZXy3IM$UIbasc9RL8%4B=BRXvi3eqWWu zqgfB$o`zw*+k3EDTiXMIybP#6t&{WA`Tgs9brKHh&^9Dg#z5}k{3m2u8M~O9skymW zJ6QgVoMy3$a^LHCFQ&;cELcu6 zGd8GSUZ&>qisi)CBpejRK|A%ctjrFq8~eScgR7CUGzD#M5Xu}5ZwAwvsIJO=Gy9lquFi>D9W4kbDt1WQ(xf3)j-VXvcXwp$eYVO}uAap_U$fM{waG+=3}8v}@kC0T ziO$}hQg3gRn;zXk9d!Asar<2Tb@4T%p!H8N(J{>GN`uTuGvtTy$IO@-+nGvO8CyI2 z&=bLE6{IdU49Q2hBaDk>hYAWR#YluKQFM&24T#j~@u<0tJb^dQ*c^>4ZjK4xyi~@^ z*6#jbErto^^v>d1sC+j9m59@wfEX^@9SPfK+`3zVc(MQ1QiE8Vvq?1>n5!*4E|Y+_ zmbRc4U8tUJnMjk6+Jl)yP;Cnq>eB^CIBHBg%?F&E!xVp4xm!JqWbAm{{r=?N#!-p( zs3rh;yV8(RVnUWn%^XdYT^yZUSxp^X%zq37QoQ@$Dk0Q(S3RkT&Xo=%2D26ZD(>2P!fOk3dw(h&HxLHEAG zBjt}kUdKT`-btS2coZo{z&e8Jmd~8WPu*zuO4UY zgLvA1j*NIkNAQoG3@{huu#4Dpi^?bgg~32G3f`yW z|DFMWpG$n}Jb@{FD$pZcikVt37g)j@;(8$Hk*z3%fA3pTf10~AjZve} zBEo#5KDJliJ&Qs+!*wN;Jet-RwF)>aV&u8rA3~15?4bA^_dzIvuB`1{s`w;)Anfz|$_qEbrR;nSo3K`1pE2qC3`2Dl)mjMu@ zb^|f+>m%=X=3|o(BA~kU;j+!rzjD-_d^;-(Oe&K#v>%_=gn# l4*z>3{VRNg@-OiJ#8qW^IEZij*qnn5XoIMI67`R({{!JucxwOv literal 0 HcmV?d00001 diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 894184d83bd..deb99d4d6f2 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1985,7 +1985,7 @@ def try_to_parse(cls, org: Org, file, filename: str) -> tuple[list, int]: total number of records. Otherwise raises a ValidationError. """ - workbook = load_workbook(filename=file, read_only=True) + workbook = load_workbook(filename=file, read_only=True, data_only=True) ws = workbook.active # see https://openpyxl.readthedocs.io/en/latest/optimized.html#worksheet-dimensions but even with this we need @@ -2203,7 +2203,7 @@ def start(self): self.save(update_fields=("group",)) # parse each row, creating batch tasks for mailroom - workbook = load_workbook(filename=self.file, read_only=True) + workbook = load_workbook(filename=self.file, read_only=True, data_only=True) ws = workbook.active data = ws.iter_rows(min_row=2) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 2bd3c34c1a2..af9989183e6 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -4243,6 +4243,32 @@ def test_batches_from_xlsx(self, mr_mocks): batch.specs, ) + @mock_mailroom + def test_batches_from_xlsx_with_formulas(self, mr_mocks): + imp = self.create_contact_import("media/test_imports/formula_data.xlsx") + imp.start() + batch = imp.batches.get() + + self.assertEqual( + [ + { + "_import_row": 2, + "fields": {"team": "Managers"}, + "name": "John Smith", + "urns": ["tel:+12025550199"], + "groups": [str(imp.group.uuid)], + }, + { + "_import_row": 3, + "fields": {"team": "Advisors"}, + "name": "Mary Green", + "urns": ["tel:+14045550178"], + "groups": [str(imp.group.uuid)], + }, + ], + batch.specs, + ) + @mock_mailroom def test_detect_spamminess(self, mr_mocks): imp = self.create_contact_import("media/test_imports/sequential_tels.xlsx") From eaa0e220214ef15322bf3e4d43575ee97e198fdd Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 2 Sep 2024 15:29:36 +0200 Subject: [PATCH 031/557] Update CHANGELOG.md for v9.3.30 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b701c2ea709..03e4d2545fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.30 (2024-09-02) +------------------------- + * Import cell data value instead of formulas using data_only flag to load the workbook + v9.3.29 (2024-08-27) ------------------------- * Fix authorization code, verification, redirect URI diff --git a/pyproject.toml b/pyproject.toml index b4dac297c0f..cfbbcd759ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.29" +version = "9.3.30" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index dad85c64a84..19a98c9a9c4 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.29" +__version__ = "9.3.30" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From abb7988b67809ebfc757cab456e61eafd43e6d30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 00:41:52 +0000 Subject: [PATCH 032/557] Bump cryptography from 42.0.8 to 43.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.8 to 43.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/42.0.8...43.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 65 +++++++++++++++++++++++--------------------------- pyproject.toml | 2 +- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/poetry.lock b/poetry.lock index 18f9679ff5e..f6a398edd36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -524,43 +524,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.8" +version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, ] [package.dependencies] @@ -573,7 +568,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -2253,4 +2248,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "dd4e4e0da39f136c61fee0f5bfa608a66ac857477d2d54551d95107cd3d52c61" +content-hash = "7e079a52200e541bc641f437c0bae1120fc9108ee50056b16cad0541896d07c1" diff --git a/pyproject.toml b/pyproject.toml index cfbbcd759ff..f7d45099a0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ smartmin = "^5.1.0" celery = "^5.4.0" redis = "^5.0.7" boto3 = "^1.34.137" -cryptography = "^42.0.4" +cryptography = "^43.0.1" vonage = "2.5.2" pyotp = "2.4.1" twilio = "6.24.0" From 543400796e33cde7586de60c8177812dcd9abc77 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 5 Sep 2024 21:19:10 +0000 Subject: [PATCH 033/557] Add an org limit for too many messages in outbox --- temba/flows/tests.py | 19 +++++++++++++++++++ temba/flows/views.py | 7 +++++++ temba/msgs/models.py | 7 +++++++ temba/msgs/tests.py | 11 +++++++++++ temba/msgs/views.py | 7 +++++++ temba/orgs/models.py | 1 + temba/orgs/tests.py | 3 +++ temba/settings_common.py | 1 + 8 files changed, 56 insertions(+) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index eee59e9a529..6628cd9c594 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2407,6 +2407,25 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): 'To start this flow you need to add a voice channel to your workspace which will allow you to make and receive calls.', ) + # if we have too many messages in our outbox we should block + with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + mr_mocks.flow_start_preview(query="age > 30", total=10000) + + response = self.client.post( + preview_url, + { + "query": "age > 30", + }, + content_type="application/json", + ) + self.assertEqual( + [ + "Your outbox currently has too many queued messages to start a flow. Please wait for these messages to finish sending and try again." + ], + response.json()["blockers"], + ) + # check warning for lots of contacts preview_url = reverse("flows.flow_preview_start", args=[flow.id]) mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=10000) diff --git a/temba/flows/views.py b/temba/flows/views.py index 5536a4c9637..04259be3c44 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -34,6 +34,7 @@ from temba.flows.models import Flow, FlowRevision, FlowRun, FlowSession, FlowStart from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call +from temba.msgs.models import SystemLabel from temba.orgs.models import IntegrationType, Org from temba.orgs.views import ( BaseExportView, @@ -1520,6 +1521,10 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): 'To start this flow you need to add a voice channel to your workspace which will ' "allow you to make and receive calls." ), + "outbox_full": _( + "Your outbox currently has too many queued messages to start a flow. " + "Please wait for these messages to finish sending and try again." + ), } warnings = { @@ -1538,6 +1543,8 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): def get_blockers(self, flow) -> list: blockers = [] + if SystemLabel.is_outbox_full(flow.org): + blockers.append(self.blockers["outbox_full"]) if flow.org.is_suspended: blockers.append(Org.BLOCKER_SUSPENDED) elif flow.org.is_flagged: diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 04971a8cebd..71495aa94d3 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -884,6 +884,13 @@ def get_archive_query(cls, label_type: str) -> dict: elif label_type == cls.TYPE_FAILED: return dict(direction="out", visibility="visible", status="failed") + @classmethod + def is_outbox_full(cls, org): + outbox_limit = org.get_limit(Org.LIMIT_OUTBOX) + counts = SystemLabel.get_counts(org) + outbox = counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(org).count() + return outbox >= outbox_limit + class SystemLabelCount(SquashableModel): """ diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 103eaca94e1..a9d92ae42df 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2407,6 +2407,17 @@ def test_preview(self, mr_mocks): self.org.is_flagged = False self.org.save() + # if we have too many messages in our outbox we should block + mr_mocks.msg_broadcast_preview(query="age > 30", total=2) + with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): + response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") + self.assertEqual( + [ + "You currently have too many messages queued in your outbox. Please wait for these messages to send and try again later." + ], + response.json()["blockers"], + ) + # if we release our send channel we can't send a broadcast self.channel.release(self.admin) mr_mocks.msg_broadcast_preview(query='age > 30 AND status = "active"', total=100) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index afb31e93bfc..ea5431c83c2 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -627,15 +627,22 @@ class Preview(OrgPermsMixin, SmartCreateView): 'To get started you need to add a channel to your workspace which will allow ' "you to send messages to your contacts." ), + "outbox_full": _( + "You currently have too many messages queued in your outbox. Please wait for these messages to send and try again later." + ), } def get_blockers(self, org) -> list: blockers = [] + if SystemLabel.is_outbox_full(org): + blockers.append(self.blockers["outbox_full"]) + if org.is_suspended: blockers.append(Org.BLOCKER_SUSPENDED) elif org.is_flagged: blockers.append(Org.BLOCKER_FLAGGED) + if not org.get_send_channel(): blockers.append(self.blockers["no_send_channel"] % {"link": reverse("channels.channel_claim")}) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 096b5024c52..173bc473691 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -476,6 +476,7 @@ class Org(SmartModel): LIMIT_LABELS = "labels" LIMIT_TOPICS = "topics" LIMIT_TEAMS = "teams" + LIMIT_OUTBOX = "outbox" DELETE_DELAY_DAYS = 7 # how many days after releasing that an org is deleted diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index ef2af8363c4..913d3c0b083 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -3028,6 +3028,7 @@ def assertOrgFilter(query: str, expected_orgs: list): "labels_limit", "teams_limit", "topics_limit", + "outbox_limit", "loc", ], list(response.context["form"].fields.keys()), @@ -3045,6 +3046,7 @@ def assertOrgFilter(query: str, expected_orgs: list): "globals_limit": "", "groups_limit": 400, "labels_limit": "", + "outbox_limit": 1000, "teams_limit": "", "topics_limit": "", }, @@ -3058,6 +3060,7 @@ def assertOrgFilter(query: str, expected_orgs: list): self.assertEqual(self.org.get_limit(Org.LIMIT_GLOBALS), 250) # uses default self.assertEqual(self.org.get_limit(Org.LIMIT_GROUPS), 400) self.assertEqual(self.org.get_limit(Org.LIMIT_CHANNELS), 20) + self.assertEqual(self.org.get_limit(Org.LIMIT_OUTBOX), 1000) # flag org self.client.post(update_url, {"action": "flag"}) diff --git a/temba/settings_common.py b/temba/settings_common.py index 12c377adfb9..fc4d9ef8d13 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -938,6 +938,7 @@ "labels": 250, "teams": 50, "topics": 250, + "outbox": 1000000, } RETENTION_PERIODS = { From b775a7819c28f8b311fc8d81cf251786f5474654 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 5 Sep 2024 22:33:22 +0000 Subject: [PATCH 034/557] Move is_outbox_full to org --- temba/flows/views.py | 3 +-- temba/msgs/models.py | 7 ------- temba/msgs/views.py | 2 +- temba/orgs/models.py | 8 ++++++++ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/temba/flows/views.py b/temba/flows/views.py index 04259be3c44..382b80467fa 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -34,7 +34,6 @@ from temba.flows.models import Flow, FlowRevision, FlowRun, FlowSession, FlowStart from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call -from temba.msgs.models import SystemLabel from temba.orgs.models import IntegrationType, Org from temba.orgs.views import ( BaseExportView, @@ -1543,7 +1542,7 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): def get_blockers(self, flow) -> list: blockers = [] - if SystemLabel.is_outbox_full(flow.org): + if flow.org.is_outbox_full(): blockers.append(self.blockers["outbox_full"]) if flow.org.is_suspended: blockers.append(Org.BLOCKER_SUSPENDED) diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 71495aa94d3..04971a8cebd 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -884,13 +884,6 @@ def get_archive_query(cls, label_type: str) -> dict: elif label_type == cls.TYPE_FAILED: return dict(direction="out", visibility="visible", status="failed") - @classmethod - def is_outbox_full(cls, org): - outbox_limit = org.get_limit(Org.LIMIT_OUTBOX) - counts = SystemLabel.get_counts(org) - outbox = counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(org).count() - return outbox >= outbox_limit - class SystemLabelCount(SquashableModel): """ diff --git a/temba/msgs/views.py b/temba/msgs/views.py index ea5431c83c2..e464ba986a4 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -635,7 +635,7 @@ class Preview(OrgPermsMixin, SmartCreateView): def get_blockers(self, org) -> list: blockers = [] - if SystemLabel.is_outbox_full(org): + if org.is_outbox_full(): blockers.append(self.blockers["outbox_full"]) if org.is_suspended: diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 173bc473691..6380485f2f5 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -844,6 +844,14 @@ def export_definitions(cls, site_link, components, include_fields=True, include_ def supports_ivr(self): return self.get_call_channel() or self.get_answer_channel() + def is_outbox_full(self): + from temba.msgs.models import SystemLabel, Broadcast + + outbox_limit = self.get_limit(Org.LIMIT_OUTBOX) + counts = SystemLabel.get_counts(self) + outbox = counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(self).count() + return outbox >= outbox_limit + def get_channel(self, role: str, scheme: str): """ Gets a channel for this org which supports the given role and scheme From 79fe69942cbab4db32d5be8949983bf9d5f6aee6 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 5 Sep 2024 16:08:21 -0700 Subject: [PATCH 035/557] Update CHANGELOG.md for v9.3.31 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e4d2545fa..cf70dd4ad0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.31 (2024-09-05) +------------------------- + * Add an org limit for too many messages in outbox + v9.3.30 (2024-09-02) ------------------------- * Import cell data value instead of formulas using data_only flag to load the workbook diff --git a/pyproject.toml b/pyproject.toml index f7d45099a0e..21c0951bb50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.30" +version = "9.3.31" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 19a98c9a9c4..6045d23515d 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.30" +__version__ = "9.3.31" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From e82737d0881e305bca7e5393d4776fa126042eb1 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Sat, 7 Sep 2024 00:04:42 +0000 Subject: [PATCH 036/557] Add outbox monitor for large queues --- package.json | 2 +- templates/flows/flowstart_list.html | 144 ++++++++++--------- templates/frame.html | 2 + templates/request_logs/httplog_webhooks.html | 78 +++++----- yarn.lock | 8 +- 5 files changed, 120 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index 7297b7919a4..72e590ef6a3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.1", - "@nyaruka/temba-components": "0.104.1", + "@nyaruka/temba-components": "0.105.1", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index 7bed2f77a6e..87066f05a32 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -9,78 +9,80 @@ } {% endblock extra-style %} -{% block table %} - - - - - - - - {% for obj in object_list %} +{% block content %} +
+
-
-
-
{% trans "All" %}
- | -
{% trans "Manual" %}
-
-
-
+ - - - - {% empty %} - - + - {% endfor %} - -
- {% if obj.status == "P" or obj.status == "S" %} - - - {% elif obj.status == "C" %} - - - {% elif obj.status == "F" %} - - - {% endif %} - - {% if obj.flow.is_active %} - {{ obj.flow.name }} - {% else %} - {% trans "A deleted flow" %} - {% endif %} - {% if obj.start_type == "M" %} - {% blocktrans trimmed with user=obj.created_by %} - was started by {{ user }} - {% endblocktrans %} - {% elif obj.start_type == "Z" %} - {% trans "was started by Zapier" %} - {% else %} - {% trans "was started by an API call" %} - {% endif %} -
- {% include "includes/recipients.html" with groups=obj.groups.all contacts=obj.contacts.all query=obj.query %} - {% include "includes/exclusions.html" with exclusions=obj.exclusions %} -
-
-
-
{{ obj.created_on|timedate }}
-
- {% blocktrans trimmed with count=obj.run_count|intcomma count counter=obj.run_count %} - {{ count }} run - {% plural %} - {{ count }} runs - {% endblocktrans %} +
+
+
+
{% trans "All" %}
+ | +
{% trans "Manual" %}
- -
{% trans "No flow starts" %}
-{% endblock table %} + + + {% for obj in object_list %} + + + {% if obj.status == "P" or obj.status == "S" %} + + + {% elif obj.status == "C" %} + + + {% elif obj.status == "F" %} + + + {% endif %} + + + {% if obj.flow.is_active %} + {{ obj.flow.name }} + {% else %} + {% trans "A deleted flow" %} + {% endif %} + {% if obj.start_type == "M" %} + {% blocktrans trimmed with user=obj.created_by %} + was started by {{ user }} + {% endblocktrans %} + {% elif obj.start_type == "Z" %} + {% trans "was started by Zapier" %} + {% else %} + {% trans "was started by an API call" %} + {% endif %} +
+ {% include "includes/recipients.html" with groups=obj.groups.all contacts=obj.contacts.all query=obj.query %} + {% include "includes/exclusions.html" with exclusions=obj.exclusions %} +
+ + +
+
{{ obj.created_on|timedate }}
+
+ {% blocktrans trimmed with count=obj.run_count|intcomma count counter=obj.run_count %} + {{ count }} run + {% plural %} + {{ count }} runs + {% endblocktrans %} +
+
+ + + {% empty %} + + {% trans "No flow starts" %} + + {% endfor %} + + + +{% endblock content %} diff --git a/templates/frame.html b/templates/frame.html index 43cb2d39acf..2294ce687aa 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -211,6 +211,8 @@
+ +
diff --git a/templates/request_logs/httplog_webhooks.html b/templates/request_logs/httplog_webhooks.html index 2a723e0b0be..e821884c754 100644 --- a/templates/request_logs/httplog_webhooks.html +++ b/templates/request_logs/httplog_webhooks.html @@ -1,43 +1,45 @@ {% extends "smartmin/list.html" %} {% load i18n temba humanize %} -{% block table %} - - - - - - - - - - - - {% for obj in object_list %} - - - - - - - - {% empty %} +{% block content %} +
+
{% trans "Flow" %}{% trans "URL" %}{% trans "Status" %}{% trans "Elapsed" %}{% trans "Time" %}
- {{ obj.flow.name }} - - {{ obj.url|truncatechars:128 }} - - {{ obj.status_code|default:"--" }} - - {% if obj.request_time %} - {{ obj.request_time|intcomma }}ms - {% else %} - {{ "--" }} - {% endif %} - {{ obj.created_on|datetime }}
+ - + + + + + - {% endfor %} - -
{% trans "No webhook calls yet" %}{% trans "Flow" %}{% trans "URL" %}{% trans "Status" %}{% trans "Elapsed" %}{% trans "Time" %}
-{% endblock table %} + + + {% for obj in object_list %} + + + {{ obj.flow.name }} + + + {{ obj.url|truncatechars:128 }} + + + {{ obj.status_code|default:"--" }} + + + {% if obj.request_time %} + {{ obj.request_time|intcomma }}ms + {% else %} + {{ "--" }} + {% endif %} + + {{ obj.created_on|datetime }} + + {% empty %} + + {% trans "No webhook calls yet" %} + + {% endfor %} + + +
+{% endblock content %} diff --git a/yarn.lock b/yarn.lock index d98673b4160..164b2bf03dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.104.1": - version "0.104.1" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.104.1.tgz#0c1e3c271f647db4cab61f199299e33abc2a5067" - integrity sha512-6ZfegDR80nQ70CZHJ+r9QRgqPFRn8JQQHfkWymmvguBOTI9WUxtd7qtFQHY1PBI7EnDckAD/4/LsUeuN17zxfg== +"@nyaruka/temba-components@0.105.1": + version "0.105.1" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.105.1.tgz#92b7838fa8910417d5a069a1dc06bd7733b9187d" + integrity sha512-TbDZ/eIXz+Q4UnxxTP9Jz6bItvPV+sxfuHNiZo7lgwbnQBgOx7vGT+h1scGwwQAixNkWqgHDWBXINVULfnrSKw== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From a2e23a536d7b16cbaaded9406ce291dceed6fd65 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Fri, 6 Sep 2024 17:19:23 -0700 Subject: [PATCH 037/557] Update CHANGELOG.md for v9.3.32 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf70dd4ad0a..c294b057d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.32 (2024-09-07) +------------------------- + * Add outbox monitor for large queues + v9.3.31 (2024-09-05) ------------------------- * Add an org limit for too many messages in outbox diff --git a/pyproject.toml b/pyproject.toml index 21c0951bb50..61bc2cdd7e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.31" +version = "9.3.32" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 6045d23515d..c8d926620f2 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.31" +__version__ = "9.3.32" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From d28d24a07f64552933cbba0b30bf7a938773e7b0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 10 Sep 2024 19:35:57 -0500 Subject: [PATCH 038/557] Update github action for dynamodb and run on port 6000 --- .github/workflows/ci.yml | 4 +++- temba/settings_common.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1d98d08de..09894270ff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,9 @@ jobs: node-version: ${{ env.node-version }} - name: Install and start DynamoDB - uses: rrainn/dynamodb-action@v2.0.1 + uses: rrainn/dynamodb-action@v4.0.0 + with: + port: 6000 - name: Initialize environment run: | diff --git a/temba/settings_common.py b/temba/settings_common.py index fc4d9ef8d13..e5d86655b4e 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -66,7 +66,7 @@ AWS_SECRET_ACCESS_KEY = "tembatemba" AWS_REGION = "us-east-1" -DYNAMO_ENDPOINT_URL = f"http://{_dynamo_host}:8000" +DYNAMO_ENDPOINT_URL = f"http://{_dynamo_host}:6000" DYNAMO_TABLE_PREFIX = "Test" if TESTING else "Temba" # ----------------------------------------------------------------------------------- From 5af774d121a7edec0876a1d5cb1384a0f21ec792 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 13:51:58 -0500 Subject: [PATCH 039/557] Rename dynamodb channel logs table --- temba/utils/management/commands/migrate_dynamo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 135a5c22e49..825b416b2e1 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -6,7 +6,7 @@ TABLES = [ { - "TableName": "ChannelLogsAttached", + "TableName": "ChannelLogs", "KeySchema": [{"AttributeName": "UUID", "KeyType": "HASH"}], "AttributeDefinitions": [{"AttributeName": "UUID", "AttributeType": "S"}], "TimeToLiveSpecification": {"AttributeName": "ExpireOn", "Enabled": True}, From c9301f9ab50428693c14d238c440f8bddfbe3429 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 13:57:36 -0500 Subject: [PATCH 040/557] Update CHANGELOG.md for v9.3.33 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c294b057d6b..8d3dfaaa9e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.33 (2024-09-11) +------------------------- + * Rename dynamodb channel logs table + v9.3.32 (2024-09-07) ------------------------- * Add outbox monitor for large queues diff --git a/pyproject.toml b/pyproject.toml index 61bc2cdd7e9..ecdb6362e8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.32" +version = "9.3.33" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index c8d926620f2..32731bd772f 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.32" +__version__ = "9.3.33" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 850e2bb7a5acb8dccee5a48e1e408ff40ef40041 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 19:26:25 +0000 Subject: [PATCH 041/557] Fix MigrateDynamoTest --- temba/utils/management/commands/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/temba/utils/management/commands/tests.py b/temba/utils/management/commands/tests.py index b0d0bdc0e0d..ab160972654 100644 --- a/temba/utils/management/commands/tests.py +++ b/temba/utils/management/commands/tests.py @@ -27,13 +27,13 @@ def pre_create_table(sender, spec, **kwargs): out = StringIO() call_command("migrate_dynamo", stdout=out) - self.assertIn("Creating TempChannelLogsAttached", out.getvalue()) + self.assertIn("Creating TempChannelLogs", out.getvalue()) client = dynamo.get_client() - desc = client.describe_table(TableName="TempChannelLogsAttached") + desc = client.describe_table(TableName="TempChannelLogs") self.assertEqual("ACTIVE", desc["Table"]["TableStatus"]) out = StringIO() call_command("migrate_dynamo", stdout=out) - self.assertIn("Skipping TempChannelLogsAttached", out.getvalue()) + self.assertIn("Skipping TempChannelLogs", out.getvalue()) From acbebf24b50bd8930e1518ca828ac01b4cf03d4c Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 11 Sep 2024 19:26:32 +0000 Subject: [PATCH 042/557] Add some controls around flow starts * Add SEND_HOURS_WARNING and SEND_HOURS_LIMIT for flow starts and broadcasts * Only allow one flow start at a time * Add DEFAULT_EXCLUSIONS for flow starts and broadcasts --- temba/channels/models.py | 19 ++- temba/flows/tests.py | 265 +++++++++++++++++--------------- temba/flows/views.py | 55 ++++--- temba/msgs/tests.py | 2 +- temba/msgs/views.py | 23 ++- temba/orgs/models.py | 42 +++++ temba/settings_common.py | 6 + templates/flows/flow_start.html | 1 + 8 files changed, 250 insertions(+), 163 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 3560408effa..13a0d2166bb 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -708,22 +708,21 @@ def replace_variables(cls, text, variables, content_type=CONTENT_TYPE_URLENCODED return text - def get_count(self, count_types): - count = ( - ChannelCount.objects.filter(channel=self, count_type__in=count_types) - .aggregate(Sum("count")) - .get("count__sum", 0) - ) + def get_count(self, count_types, since=None): + qs = ChannelCount.objects.filter(channel=self, count_type__in=count_types) + if since: + qs = qs.filter(day__gte=since) + count = qs.aggregate(Sum("count")).get("count__sum", 0) return 0 if count is None else count - def get_msg_count(self): - return self.get_count([ChannelCount.INCOMING_MSG_TYPE, ChannelCount.OUTGOING_MSG_TYPE]) + def get_msg_count(self, since=None): + return self.get_count([ChannelCount.INCOMING_MSG_TYPE, ChannelCount.OUTGOING_MSG_TYPE], since) - def get_ivr_count(self): + def get_ivr_count(self, since=None): return self.get_count([ChannelCount.INCOMING_IVR_TYPE, ChannelCount.OUTGOING_IVR_TYPE]) - def get_log_count(self): + def get_log_count(self, since=None): return self.get_count([ChannelCount.SUCCESS_LOG_TYPE, ChannelCount.ERROR_LOG_TYPE]) class Meta: diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 6628cd9c594..4258f731c54 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -22,7 +22,7 @@ from temba.orgs.integrations.dtone import DTOneType from temba.orgs.models import Export from temba.templates.models import TemplateTranslation -from temba.tests import CRUDLTestMixin, MockJsonResponse, TembaTest, matchers, mock_mailroom, override_brand +from temba.tests import CRUDLTestMixin, MockJsonResponse, TembaTest, matchers, mock_mailroom from temba.tests.base import get_contact_search from temba.tests.engine import MockSessionWriter from temba.triggers.models import Trigger @@ -2289,147 +2289,156 @@ def test_inactive_flow(self): self.assertEqual(404, response.status_code) @mock_mailroom - @patch("temba.flows.models.Flow.is_starting") + @patch("temba.flows.models.Org.is_flow_starting") def test_preview_start(self, mr_mocks, mock_flow_is_starting): mock_flow_is_starting.return_value = False - with override_brand(inactive_threshold=1000): - flow = self.create_flow("Test") - self.create_field("age", "Age") - self.create_contact("Ann", phone="+16302222222", fields={"age": 40}) - self.create_contact("Bob", phone="+16303333333", fields={"age": 33}) + flow = self.create_flow("Test") + self.create_field("age", "Age") + self.create_contact("Ann", phone="+16302222222", fields={"age": 40}) + self.create_contact("Bob", phone="+16303333333", fields={"age": 33}) - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) - preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) - self.login(self.editor) + self.login(self.editor) - response = self.client.post( - preview_url, - { - "query": "age > 30", - "exclusions": {"non_active": True, "started_previously": True}, - }, - content_type="application/json", - ) - self.assertEqual( - { - "query": 'age > 30 AND status = "active" AND history != "Test Flow"', - "total": 100, - "warnings": [], - "blockers": [], - }, - response.json(), - ) + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True}, + }, + content_type="application/json", + ) + self.assertEqual( + { + "query": 'age > 30 AND status = "active" AND history != "Test Flow"', + "total": 100, + "send_time": 10.0, + "warnings": [], + "blockers": [], + }, + response.json(), + ) - # try with a bad query - mr_mocks.exception(mailroom.QueryValidationException("mismatched input at (((", "syntax")) + # try with a bad query + mr_mocks.exception(mailroom.QueryValidationException("mismatched input at (((", "syntax")) - response = self.client.post( - preview_url, - { - "query": "(((", - "exclusions": {"non_active": True, "started_previously": True}, - }, - content_type="application/json", - ) - self.assertEqual(400, response.status_code) - self.assertEqual({"query": "", "total": 0, "error": "Invalid query syntax."}, response.json()) - - # suspended orgs should block - self.org.is_suspended = True - self.org.save() - mr_mocks.flow_start_preview(query="age > 30", total=2) - response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") - self.assertEqual( - [ - "Sorry, your workspace is currently suspended. To re-enable starting flows and sending messages, please contact support." - ], - response.json()["blockers"], - ) + response = self.client.post( + preview_url, + { + "query": "(((", + "exclusions": {"non_active": True, "started_previously": True}, + }, + content_type="application/json", + ) + self.assertEqual(400, response.status_code) + self.assertEqual({"query": "", "total": 0, "error": "Invalid query syntax."}, response.json()) - # flagged orgs should block - self.org.is_suspended = False - self.org.is_flagged = True - self.org.save() - mr_mocks.flow_start_preview(query="age > 30", total=2) - response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") - self.assertEqual( - [ - "Sorry, your workspace is currently flagged. To re-enable starting flows and sending messages, please contact support." - ], - response.json()["blockers"], - ) + # suspended orgs should block + self.org.is_suspended = True + self.org.save() + mr_mocks.flow_start_preview(query="age > 30", total=2) + response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") + self.assertEqual( + [ + "Sorry, your workspace is currently suspended. To re-enable starting flows and sending messages, please contact support." + ], + response.json()["blockers"], + ) - self.org.is_flagged = False - self.org.save() + # flagged orgs should block + self.org.is_suspended = False + self.org.is_flagged = True + self.org.save() + mr_mocks.flow_start_preview(query="age > 30", total=2) + response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") + self.assertEqual( + [ + "Sorry, your workspace is currently flagged. To re-enable starting flows and sending messages, please contact support." + ], + response.json()["blockers"], + ) - # trying to start again should fail because there is already a pending start for this flow - mock_flow_is_starting.return_value = True - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + self.org.is_flagged = False + self.org.save() - response = self.client.post( - preview_url, - { - "query": "age > 30", - "exclusions": {"non_active": True, "started_previously": True}, - }, - content_type="application/json", - ) + # trying to start again should fail because there is already a pending start for this flow + mock_flow_is_starting.return_value = True + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) - self.assertEqual( - [ - "This flow is already being started - please wait until that process completes before starting more contacts." - ], - response.json()["blockers"], - ) + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True}, + }, + content_type="application/json", + ) - ivr_flow = self.create_flow("IVR Test", flow_type=Flow.TYPE_VOICE) + self.assertEqual( + [ + "A flow is already starting. You will need to wait until that process completes before starting another one." + ], + response.json()["blockers"], + ) - preview_url = reverse("flows.flow_preview_start", args=[ivr_flow.id]) + ivr_flow = self.create_flow("IVR Test", flow_type=Flow.TYPE_VOICE) - # shouldn't be able to since we don't have a call channel - mock_flow_is_starting.return_value = False - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + preview_url = reverse("flows.flow_preview_start", args=[ivr_flow.id]) + + # shouldn't be able to since we don't have a call channel + mock_flow_is_starting.return_value = False + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True}, + }, + content_type="application/json", + ) + + self.assertEqual( + response.json()["blockers"][0], + 'To start this flow you need to add a voice channel to your workspace which will allow you to make and receive calls.', + ) + + # if we have too many messages in our outbox we should block + with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + mr_mocks.flow_start_preview(query="age > 30", total=1000) response = self.client.post( preview_url, { "query": "age > 30", - "exclusions": {"non_active": True, "started_previously": True}, }, content_type="application/json", ) - self.assertEqual( - response.json()["blockers"][0], - 'To start this flow you need to add a voice channel to your workspace which will allow you to make and receive calls.', + [ + "Your outbox currently has too many queued messages to start a flow. Please wait for these messages to finish sending and try again." + ], + response.json()["blockers"], ) - # if we have too many messages in our outbox we should block - with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): - preview_url = reverse("flows.flow_preview_start", args=[flow.id]) - mr_mocks.flow_start_preview(query="age > 30", total=10000) + # check warning for lots of contacts + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) - response = self.client.post( - preview_url, - { - "query": "age > 30", - }, - content_type="application/json", - ) - self.assertEqual( - [ - "Your outbox currently has too many queued messages to start a flow. Please wait for these messages to finish sending and try again." - ], - response.json()["blockers"], - ) + # with patch("temba.orgs.models.Org.get_estimated_send_time") as mock_get_estimated_send_time: + with override_settings(SEND_HOURS_WARNING=24, SEND_HOURS_BLOCK=48): - # check warning for lots of contacts - preview_url = reverse("flows.flow_preview_start", args=[flow.id]) - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=10000) + # we send at 10 tps, so make the total take 24 hours + expected_tps = 10 + mr_mocks.flow_start_preview( + query='age > 30 AND status = "active" AND history != "Test Flow"', total=24 * 60 * 60 * expected_tps + ) + # mock_get_estimated_send_time.return_value = timedelta(days=2) response = self.client.post( preview_url, { @@ -2441,16 +2450,14 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): self.assertEqual( response.json()["warnings"][0], - "You've selected a lot of contacts! Depending on your channel " - "it could take days to reach everybody and could reduce response rates. " - "Filter for contacts that have sent a message recently " - "to limit your selection to contacts who are more likely to respond.", + "Your channels will likely take over a day to reach all of the selected contacts. Consider selecting fewer contacts before continuing.", ) - # if we release our send channel we also can't start a regular messaging flow - self.channel.release(self.admin) - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) - + # now really long so it should block + mr_mocks.flow_start_preview( + query='age > 30 AND status = "active" AND history != "Test Flow"', total=3 * 24 * 60 * 60 * expected_tps + ) + # mock_get_estimated_send_time.return_value = timedelta(days=7) response = self.client.post( preview_url, { @@ -2462,9 +2469,27 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): self.assertEqual( response.json()["blockers"][0], - 'To start this flow you need to add a channel to your workspace which will allow you to send messages to your contacts.', + "Your channels cannot send fast enough to reach all of the selected contacts in a reasonable time. Select fewer contacts to continue.", ) + # if we release our send channel we also can't start a regular messaging flow + self.channel.release(self.admin) + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True}, + }, + content_type="application/json", + ) + + self.assertEqual( + response.json()["blockers"][0], + 'To start this flow you need to add a channel to your workspace which will allow you to send messages to your contacts.', + ) + @mock_mailroom def test_template_warnings(self, mr_mocks): self.login(self.admin) diff --git a/temba/flows/views.py b/temba/flows/views.py index 382b80467fa..e95efe0d592 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1509,8 +1509,7 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): blockers = { "already_starting": _( - "This flow is already being started - please wait until that process completes before starting " - "more contacts." + "A flow is already starting. You will need to wait until that process completes before starting another one." ), "no_send_channel": _( 'To start this flow you need to add a channel to your workspace which will allow ' @@ -1524,6 +1523,10 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): "Your outbox currently has too many queued messages to start a flow. " "Please wait for these messages to finish sending and try again." ), + "too_many_recipients": _( + "Your channels cannot send fast enough to reach all of the selected contacts in a reasonable time. " + "Select fewer contacts to continue." + ), } warnings = { @@ -1531,15 +1534,13 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): "This flow does not use message templates. You may still start this flow but WhatsApp contacts who " "have not sent an incoming message in the last 24 hours may not receive it." ), - "inactive_threshold": _( - "You've selected a lot of contacts! Depending on your channel " - "it could take days to reach everybody and could reduce response rates. " - "Filter for contacts that have sent a message recently " - "to limit your selection to contacts who are more likely to respond." + "too_many_recipients": _( + "Your channels will likely take over a day to reach all of the selected contacts. Consider " + "selecting fewer contacts before continuing." ), } - def get_blockers(self, flow) -> list: + def get_blockers(self, flow, send_time) -> list: blockers = [] if flow.org.is_outbox_full(): @@ -1548,9 +1549,13 @@ def get_blockers(self, flow) -> list: blockers.append(Org.BLOCKER_SUSPENDED) elif flow.org.is_flagged: blockers.append(Org.BLOCKER_FLAGGED) - elif flow.is_starting(): + elif flow.org.is_flow_starting(): blockers.append(self.blockers["already_starting"]) + hours = send_time / timedelta(hours=1) + if settings.SEND_HOURS_BLOCK and hours >= settings.SEND_HOURS_BLOCK: + blockers.append(self.blockers["too_many_recipients"]) + if flow.flow_type == Flow.TYPE_MESSAGE and not flow.org.get_send_channel(): blockers.append(self.blockers["no_send_channel"] % {"link": reverse("channels.channel_claim")}) elif flow.flow_type == Flow.TYPE_VOICE and not flow.org.get_call_channel(): @@ -1558,13 +1563,11 @@ def get_blockers(self, flow) -> list: return blockers - def get_warnings(self, flow, query, total) -> list: + def get_warnings(self, flow, query, send_time) -> list: warnings = [] - - # if we are over our threshold, show the amount warning - threshold = self.request.branding.get("inactive_threshold", 0) - if "last_seen_on" not in query and threshold > 0 and total > threshold: - warnings.append(self.warnings["inactive_threshold"]) + hours = send_time / timedelta(hours=1) + if settings.SEND_HOURS_WARNING and hours >= settings.SEND_HOURS_WARNING: + warnings.append(self.warnings["too_many_recipients"]) # if we have a whatsapp channel that requires a message template; exclude twilio whatsApp whatsapp_channel = flow.org.channels.filter( @@ -1598,12 +1601,16 @@ def post(self, request, *args, **kwargs): except mailroom.QueryValidationException as e: return JsonResponse({"query": "", "total": 0, "error": str(e)}, status=400) + # calculate the estimated send time + send_time = flow.org.get_estimated_send_time(total) + return JsonResponse( { "query": query, "total": total, - "warnings": self.get_warnings(flow, query, total), - "blockers": self.get_blockers(flow), + "warnings": self.get_warnings(flow, query, send_time), + "blockers": self.get_blockers(flow, send_time), + "send_time": send_time.total_seconds(), } ) @@ -1619,7 +1626,12 @@ class Form(forms.ModelForm): contact_search = forms.JSONField( required=True, - widget=ContactSearchWidget(attrs={"widget_only": True, "placeholder": _("Enter contact query")}), + widget=ContactSearchWidget( + attrs={ + "widget_only": True, + "placeholder": _("Enter contact query"), + } + ), ) def __init__(self, org, flow, **kwargs): @@ -1687,7 +1699,12 @@ def derive_initial(self): recipients.append({"id": contact.uuid, "name": contact.name, "urn": urn, "type": "contact"}) return { - "contact_search": {"recipients": recipients, "advanced": False, "query": "", "exclusions": {}}, + "contact_search": { + "recipients": recipients, + "advanced": False, + "query": "", + "exclusions": settings.DEFAULT_EXCLUSIONS, + }, "flow": self.flow.id if self.flow else None, } diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index a9d92ae42df..941e5c5f18e 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2034,7 +2034,7 @@ def test_create(self, mr_mocks): ], "advanced": False, "query": None, - "exclusions": {}, + "exclusions": { "in_a_flow": True}, }, json.loads(contact_search.value()), ) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index e464ba986a4..cec9602d58a 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -16,6 +16,7 @@ ) from django import forms +from django.conf import settings from django.db.models.functions.text import Lower from django.forms import Form, ValidationError from django.http import HttpResponse, HttpResponseRedirect, JsonResponse @@ -388,24 +389,20 @@ def get_form_kwargs(self, step): return {"org": self.request.org} def get_form_initial(self, step): + initial = super().get_form_initial(step) if step == "target": - initial = {} org = self.request.org contact_uuids = [_ for _ in self.request.GET.get("c", "").split(",") if _] contacts = org.contacts.filter(uuid__in=contact_uuids) - if contact_uuids: - params = {} - if len(contact_uuids) > 0: - params["c"] = ",".join(contact_uuids) - initial["contact_search"] = { - "recipients": ContactSearchWidget.get_recipients(contacts), - "advanced": False, - "query": None, - "exclusions": {}, - } - return initial - return super().get_form_initial(step) + + initial["contact_search"] = { + "recipients": ContactSearchWidget.get_recipients(contacts), + "advanced": False, + "query": None, + "exclusions": settings.DEFAULT_EXCLUSIONS, + } + return initial def done(self, form_list, form_dict, **kwargs): user = self.request.user diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 6380485f2f5..48ea89d0f49 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -852,6 +852,48 @@ def is_outbox_full(self): outbox = counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(self).count() return outbox >= outbox_limit + def is_flow_starting(self): + from temba.flows.models import FlowStart + + return ( + FlowStart.objects.filter(org=self, status__in=(FlowStart.STATUS_STARTING, FlowStart.STATUS_PENDING)) + .exclude(created_by=None) + .exists() + ) + + def get_estimated_send_time(self, msg_count): + """ + Estimates the time it will take to send the given number of messages + """ + channels = self.channels.filter(is_active=True) + channel_counts = {} + month_ago = timezone.now() - timedelta(days=30) + total_count = 0 + + for channel in channels: + channel_count = channel.get_msg_count(since=month_ago) + total_count += channel_count + channel_counts[channel.uuid] = {"count": channel_count, "tps": channel.tps or 10} + + # balance all channels equally if we have nothing to go on + if not total_count: + for channel_uuid in channel_counts: + channel_counts[channel_uuid]["count"] = 1 + total_count = len(channel_counts) + + # calculate pct of messages that will go to each channel + for channel_uuid, channel_count in channel_counts.items(): + pct = channel_count["count"] / total_count + channel_counts[channel_uuid]["time"] = pct * msg_count / channel_count["tps"] + + longest_time = 0 + if channel_counts: + longest_time = max( + [channel_count["time"] if "time" in channel_count else 0 for channel_count in channel_counts.values()] + ) + + return timedelta(seconds=longest_time) + def get_channel(self, role: str, scheme: str): """ Gets a channel for this org which supports the given role and scheme diff --git a/temba/settings_common.py b/temba/settings_common.py index fc4d9ef8d13..e7adb018d99 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -326,6 +326,12 @@ FEATURES = {"locations"} +# The default checked options for flow starts and broadcasts +DEFAULT_EXCLUSIONS = {"in_a_flow": True} + +# Estimated send time limits before warning or blocking, zero is no limit +SEND_HOURS_WARNING = 0 +SEND_HOURS_BLOCK = 0 # ----------------------------------------------------------------------------------- # Permissions diff --git a/templates/flows/flow_start.html b/templates/flows/flow_start.html index 5173aaae6e3..05cc6906420 100644 --- a/templates/flows/flow_start.html +++ b/templates/flows/flow_start.html @@ -89,6 +89,7 @@ queryWidget.started_previously = true; queryWidget.not_seen_since_days = true; queryWidget.endpoint = "/flow/preview_start/" + this.value + "/"; + queryWidget.flow = this.values[0]; queryWidget.refresh(); } }); From da3e36f04bebec8d252aa87e8527c5d1b5c91cd1 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 11 Sep 2024 19:28:27 +0000 Subject: [PATCH 043/557] Update to latest contact search widget --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 72e590ef6a3..a01374849f6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.1", - "@nyaruka/temba-components": "0.105.1", + "@nyaruka/temba-components": "0.106.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index 164b2bf03dd..7743511631c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.105.1": - version "0.105.1" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.105.1.tgz#92b7838fa8910417d5a069a1dc06bd7733b9187d" - integrity sha512-TbDZ/eIXz+Q4UnxxTP9Jz6bItvPV+sxfuHNiZo7lgwbnQBgOx7vGT+h1scGwwQAixNkWqgHDWBXINVULfnrSKw== +"@nyaruka/temba-components@0.106.0": + version "0.106.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.106.0.tgz#378b1df023fa0935f0f0bb2b1e8a24b21288fa53" + integrity sha512-pvQTDPbekkG2z7ng9NgaCg51zcdOc29v/q6rxFP0Vv4fqmwxVR+2v6gL1GAjavDSYwCsMIjmcVedB4G5k+pwjA== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From fadaf0762384b1a3eccfc8bdc241d77773e872f6 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 11 Sep 2024 21:23:39 +0000 Subject: [PATCH 044/557] Formatting --- temba/msgs/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 941e5c5f18e..7f1638c1fff 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2034,7 +2034,7 @@ def test_create(self, mr_mocks): ], "advanced": False, "query": None, - "exclusions": { "in_a_flow": True}, + "exclusions": {"in_a_flow": True}, }, json.loads(contact_search.value()), ) From 8912fc29c069df61916647b6877ac52229d74b44 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 21:29:27 +0000 Subject: [PATCH 045/557] Fix code_check script --- code_check.py | 4 ++-- temba/contacts/models.py | 2 +- temba/orgs/models.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code_check.py b/code_check.py index 055a5aa6346..cbdb44a8ac6 100755 --- a/code_check.py +++ b/code_check.py @@ -36,10 +36,10 @@ def status(line): cmd("python manage.py makemigrations --check") status("Running isort") - cmd("isort temba") + cmd("isort --check temba") status("Running black") - cmd("black temba") + cmd("black --check temba") status("Running ruff") cmd("ruff check temba") diff --git a/temba/contacts/models.py b/temba/contacts/models.py index deb99d4d6f2..1035698e2a9 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -45,7 +45,7 @@ class URN: * No hex escaping in URN path """ - DELETED_SCHEME = "deleted" + DELETED_SCHEME = "deleted" DISCORD_SCHEME = "discord" EMAIL_SCHEME = "mailto" EXTERNAL_SCHEME = "ext" diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 6380485f2f5..0154bc5eacd 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -845,7 +845,7 @@ def supports_ivr(self): return self.get_call_channel() or self.get_answer_channel() def is_outbox_full(self): - from temba.msgs.models import SystemLabel, Broadcast + from temba.msgs.models import Broadcast, SystemLabel outbox_limit = self.get_limit(Org.LIMIT_OUTBOX) counts = SystemLabel.get_counts(self) From 61387a7acb0ba4267f54edb4ca25601ac73b00be Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 11 Sep 2024 14:31:34 -0700 Subject: [PATCH 046/557] Update CHANGELOG.md for v9.3.34 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3dfaaa9e1..a12d61bd267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.34 (2024-09-11) +------------------------- + * Add timing controls around flow starts + v9.3.33 (2024-09-11) ------------------------- * Rename dynamodb channel logs table diff --git a/pyproject.toml b/pyproject.toml index ecdb6362e8c..1730ed1da7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.33" +version = "9.3.34" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 32731bd772f..fff8eac055e 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.33" +__version__ = "9.3.34" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 036ec3da3ada8bc02e2bb4b3889e50c8c7c2d1ad Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 22:17:56 +0000 Subject: [PATCH 047/557] Add progress field to flow starts endpoint --- temba/api/v2/serializers.py | 8 +++++++- temba/api/v2/tests.py | 15 +++++++-------- temba/api/v2/views.py | 21 ++++++++++----------- temba/contacts/models.py | 2 +- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index fdff7667350..e5fe13bdaad 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -1084,6 +1084,7 @@ class FlowStartReadSerializer(ReadSerializer): flow = fields.FlowField() status = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() groups = fields.ContactGroupField(many=True) contacts = fields.ContactField(many=True) params = serializers.JSONField(required=False) @@ -1098,6 +1099,9 @@ class FlowStartReadSerializer(ReadSerializer): def get_status(self, obj): return self.STATUSES.get(obj.status) + def get_progress(self, obj): + return {"total": obj.contact_count or -1, "started": obj.run_count} + def get_restart_participants(self, obj): return not (obj.exclusions and obj.exclusions.get(FlowStart.EXCLUSION_STARTED_PREVIOUSLY, False)) @@ -1107,15 +1111,17 @@ def get_exclude_active(self, obj): class Meta: model = FlowStart fields = ( - "id", "uuid", "flow", "status", + "progress", "groups", "contacts", "params", "created_on", "modified_on", + # deprecated + "id", "extra", "restart_participants", "exclude_active", diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 756fb7c07e7..349d6c7744f 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -3395,23 +3395,25 @@ def test_flow_starts(self, mock_async_start): endpoint_url, [self.user, self.editor], results=[start4, start3, start2, start1], - num_queries=NUM_BASE_SESSION_QUERIES + 4, + num_queries=NUM_BASE_SESSION_QUERIES + 5, ) self.assertEqual( response.json()["results"][1], { - "id": start3.id, "uuid": str(start3.uuid), "flow": {"uuid": flow.uuid, "name": "Test"}, "contacts": [{"uuid": self.joe.uuid, "name": "Joe Blow"}], "groups": [{"uuid": hans_group.uuid, "name": "hans"}], - "restart_participants": False, - "exclude_active": False, "status": "pending", - "extra": {"first_name": "Bob", "last_name": "Marley"}, + "progress": {"total": -1, "started": 0}, "params": {"first_name": "Bob", "last_name": "Marley"}, "created_on": format_datetime(start3.created_on), "modified_on": format_datetime(start3.modified_on), + # deprecated + "id": start3.id, + "extra": {"first_name": "Bob", "last_name": "Marley"}, + "restart_participants": False, + "exclude_active": False, }, ) @@ -3421,9 +3423,6 @@ def test_flow_starts(self, mock_async_start): # check filtering by in invalid UUID self.assertGet(endpoint_url + "?uuid=xyz", [self.editor], errors={None: "Value for uuid must be a valid UUID"}) - # check filtering by id (deprecated) - response = self.assertGet(endpoint_url + f"?id={start2.id}", [self.editor], results=[start2]) - response = self.assertPost( endpoint_url, self.editor, diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index ff3f0002dab..05b0cfc1ab4 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -17,7 +17,7 @@ from temba.channels.models import Channel, ChannelEvent from temba.classifiers.models import Classifier from temba.contacts.models import Contact, ContactField, ContactGroup, ContactGroupCount, ContactNote, ContactURN -from temba.flows.models import Flow, FlowRun, FlowStart +from temba.flows.models import Flow, FlowRun, FlowStart, FlowStartCount from temba.globals.models import Global from temba.locations.models import AdminBoundary, BoundaryAlias from temba.msgs.models import Broadcast, Label, LabelCount, Media, Msg, OptIn, SystemLabel @@ -3094,13 +3094,12 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): By making a `GET` request you can list all the manual flow starts on your organization, in the order of last modified. Each flow start has the following attributes: - * **uuid** - the UUID of this flow start (string). + * **uuid** - the UUID of this flow start (string), filterable as `uuid`. * **flow** - the flow which was started (object). * **contacts** - the list of contacts that were started in the flow (objects). * **groups** - the list of groups that were started in the flow (objects). - * **restart_participants** - whether the contacts were restarted in this flow (boolean). - * **exclude_active** - whether the active contacts in other flows were excluded in this flow start (boolean). * **status** - the status of this flow start. + * **progress** - the progress of this flow start (object). * **params** - the dictionary of extra parameters passed to the flow start (object). * **created_on** - the datetime when this flow start was created (datetime). * **modified_on** - the datetime when this flow start was modified (datetime). @@ -3125,6 +3124,7 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): {"uuid": "f5901b62-ba76-4003-9c62-fjjajdsi15553", "name": "Wanz"} ], "status": "complete", + "progress": {"total": 10, "started": 5}, "params": { "first_name": "Ryan", "last_name": "Lewis" @@ -3172,7 +3172,8 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): "contacts": [ {"uuid": "f1ea776e-c923-4c1a-b3a3-0c466932b2cc", "name": "Wanz"} ], - "status": "complete", + "status": "pending", + "progress": {"total": -1, "started": 0}, "params": { "first_name": "Ryan", "last_name": "Lewis" @@ -3189,14 +3190,9 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): pagination_class = ModifiedOnCursorPagination def filter_queryset(self, queryset): - # ignore flow starts created by mailroom + # ignore flow starts created by flows or triggers queryset = queryset.exclude(created_by=None) - # filter by id (optional and deprecated) - start_id = self.get_int_param("id") - if start_id: - queryset = queryset.filter(id=start_id) - # filter by UUID (optional) uuid = self.get_uuid_param("uuid") if uuid: @@ -3220,6 +3216,9 @@ def post_save(self, instance): # actually start our flow instance.async_start() + def prepare_for_serialization(self, object_list, using: str): + FlowStartCount.bulk_annotate(object_list) + @classmethod def get_read_explorer(cls): return { diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 1035698e2a9..deb99d4d6f2 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -45,7 +45,7 @@ class URN: * No hex escaping in URN path """ - DELETED_SCHEME = "deleted" + DELETED_SCHEME = "deleted" DISCORD_SCHEME = "discord" EMAIL_SCHEME = "mailto" EXTERNAL_SCHEME = "ext" From 1b9d72b64e99c9772eaee0f5a1b02dea988afdd6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 11 Sep 2024 22:24:32 +0000 Subject: [PATCH 048/557] Fix formatting --- temba/contacts/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 1035698e2a9..deb99d4d6f2 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -45,7 +45,7 @@ class URN: * No hex escaping in URN path """ - DELETED_SCHEME = "deleted" + DELETED_SCHEME = "deleted" DISCORD_SCHEME = "discord" EMAIL_SCHEME = "mailto" EXTERNAL_SCHEME = "ext" From 93647510d7c0d8c1e3f02827d54e25218cfe5e2e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 12 Sep 2024 14:08:47 -0500 Subject: [PATCH 049/557] Update CHANGELOG.md for v9.3.35 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a12d61bd267..01b7f08637b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.35 (2024-09-12) +------------------------- + * Add progress field to flow starts endpoint + v9.3.34 (2024-09-11) ------------------------- * Add timing controls around flow starts diff --git a/pyproject.toml b/pyproject.toml index 1730ed1da7c..08e59b996ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.34" +version = "9.3.35" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index fff8eac055e..94e0e376bc3 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.34" +__version__ = "9.3.35" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 5d31e928593e253ab96092e2185cf9f27d6e8d31 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 12 Sep 2024 16:22:28 -0500 Subject: [PATCH 050/557] Use 'tasks:batch' queue name instead of 'batch' --- temba/mailroom/queue.py | 14 ++++---------- temba/mailroom/tests.py | 22 +++++++++++----------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/temba/mailroom/queue.py b/temba/mailroom/queue.py index 80e17cef65b..6e0a4be9a3b 100644 --- a/temba/mailroom/queue.py +++ b/temba/mailroom/queue.py @@ -10,9 +10,6 @@ HIGH_PRIORITY = -10000000 DEFAULT_PRIORITY = 0 -QUEUE_PATTERN = "%s:%d" -ACTIVE_PATTERN = "%s:active" - class BatchTask(Enum): START_FLOW = "start_flow" @@ -112,11 +109,11 @@ def _queue_batch_task(org_id, task_type, task, priority): r = get_redis_connection("default") pipe = r.pipeline() - _queue_task(pipe, org_id, "batch", task_type, task, priority) + _queue_task(pipe, org_id, task_type, task, priority) pipe.execute() -def _queue_task(pipe, org_id, queue, task_type, task, priority): +def _queue_task(pipe, org_id, task_type, task, priority): """ Queues a task to mailroom @@ -136,14 +133,11 @@ def _queue_task(pipe, org_id, queue, task_type, task, priority): # create our payload payload = _create_mailroom_task(task_type, task) - org_queue = QUEUE_PATTERN % (queue, org_id) - active_queue = ACTIVE_PATTERN % queue - # push onto our org queue - pipe.zadd(org_queue, {json.dumps(payload): score}) + pipe.zadd(f"tasks:batch:{org_id}", {json.dumps(payload): score}) # and mark that org as active - pipe.zincrby(active_queue, 0, org_id) + pipe.zincrby("tasks:batch:active", 0, org_id) def _create_mailroom_task(task_type, task): diff --git a/temba/mailroom/tests.py b/temba/mailroom/tests.py index f5f0b6cb34e..1bd72b987ac 100644 --- a/temba/mailroom/tests.py +++ b/temba/mailroom/tests.py @@ -35,7 +35,7 @@ def test_queue_flow_start(self): start.async_start() - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, { @@ -61,7 +61,7 @@ def test_queue_contact_import_batch(self): imp = self.create_contact_import("media/test_imports/simple.xlsx") imp.start() - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, { @@ -74,7 +74,7 @@ def test_queue_contact_import_batch(self): def test_queue_interrupt_channel(self): self.channel.release(self.admin) - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, { @@ -90,7 +90,7 @@ def test_queue_interrupt_by_contacts(self): queue_interrupt(self.org, contacts=[jim, bob]) - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, { @@ -104,7 +104,7 @@ def test_queue_interrupt_by_flow(self): flow = self.create_flow("Test") flow.archive(self.admin) - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, {"type": "interrupt_sessions", "task": {"flow_ids": [flow.id]}, "queued_on": matchers.ISODate()}, @@ -131,19 +131,19 @@ def test_queue_interrupt_by_session(self): session = run.session run.delete() - self.assert_org_queued(self.org, "batch") + self.assert_org_queued(self.org) self.assert_queued_batch_task( self.org, {"type": "interrupt_sessions", "task": {"session_ids": [session.id]}, "queued_on": matchers.ISODate()}, ) - def assert_org_queued(self, org, queue): + def assert_org_queued(self, org): r = get_redis_connection() # check we have one org with active tasks - self.assertEqual(r.zcard(f"{queue}:active"), 1) + self.assertEqual(r.zcard("tasks:batch:active"), 1) - queued_org = json.loads(r.zrange(f"{queue}:active", 0, 1)[0]) + queued_org = json.loads(r.zrange("tasks:batch:active", 0, 1)[0]) self.assertEqual(queued_org, org.id) @@ -151,10 +151,10 @@ def assert_queued_batch_task(self, org, expected_task): r = get_redis_connection() # check we have one task in the org's queue - self.assertEqual(r.zcard(f"batch:{org.id}"), 1) + self.assertEqual(r.zcard(f"tasks:batch:{org.id}"), 1) # load and check that task - actual_task = json.loads(r.zrange(f"batch:{org.id}", 0, 1)[0]) + actual_task = json.loads(r.zrange(f"tasks:batch:{org.id}", 0, 1)[0]) self.assertEqual(actual_task, expected_task) From eaf0b3d3b33c6cba027a4e97c2b001de8b2c07cb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 12 Sep 2024 17:03:13 -0500 Subject: [PATCH 051/557] Update CHANGELOG.md for v9.3.36 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b7f08637b..0a3f3e15b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.36 (2024-09-12) +------------------------- + * Use 'tasks:batch' queue name instead of 'batch' + v9.3.35 (2024-09-12) ------------------------- * Add progress field to flow starts endpoint diff --git a/pyproject.toml b/pyproject.toml index 08e59b996ff..eb9472bbacc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.35" +version = "9.3.36" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 94e0e376bc3..a8cdf54cf2d 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.35" +__version__ = "9.3.36" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 8859c30f885df5c222c78777c5e400170979e91a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 13 Sep 2024 09:26:51 -0500 Subject: [PATCH 052/557] Fix TTL attributename on channel logs table --- temba/utils/management/commands/migrate_dynamo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 825b416b2e1..9a5e9c42132 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -9,7 +9,7 @@ "TableName": "ChannelLogs", "KeySchema": [{"AttributeName": "UUID", "KeyType": "HASH"}], "AttributeDefinitions": [{"AttributeName": "UUID", "AttributeType": "S"}], - "TimeToLiveSpecification": {"AttributeName": "ExpireOn", "Enabled": True}, + "TimeToLiveSpecification": {"AttributeName": "ExpiresOn", "Enabled": True}, "BillingMode": "PAY_PER_REQUEST", } ] From 24240bd3d001b328de157b9f08d310cbfe786821 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 13 Sep 2024 15:50:47 +0000 Subject: [PATCH 053/557] Fix importing contacts from spreadsheet with broken dimensions --- temba/contacts/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index deb99d4d6f2..d556d7cdf20 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -2205,6 +2205,7 @@ def start(self): # parse each row, creating batch tasks for mailroom workbook = load_workbook(filename=self.file, read_only=True, data_only=True) ws = workbook.active + ws.reset_dimensions() # see https://openpyxl.readthedocs.io/en/latest/optimized.html#worksheet-dimensions data = ws.iter_rows(min_row=2) urns = [] From df0cff43bd8f442aa0b35139be17de6c90b8dc6b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 13 Sep 2024 15:52:36 +0000 Subject: [PATCH 054/557] fix import read page title --- temba/contacts/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index da6e6c39e86..b0659e5f37d 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -1408,6 +1408,7 @@ def post_save(self, obj): class Read(SpaMixin, OrgObjPermsMixin, NotificationTargetMixin, SmartReadView): menu_path = "/contact/import" + title = _("Contact Import") def get_notification_scope(self) -> tuple: return "import:finished", f"contact:{self.object.id}" From 97f297dec56d738b64cfdca5dd8b58ce21d34e0e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 13 Sep 2024 11:06:04 -0500 Subject: [PATCH 055/557] Update CHANGELOG.md for v9.3.37 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3f3e15b7d..ac6b31665d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.37 (2024-09-13) +------------------------- + * Fix import read page title + * Fix importing contacts from spreadsheet with broken dimensions + * Fix TTL attribute name on DynamoDB channel logs table + v9.3.36 (2024-09-12) ------------------------- * Use 'tasks:batch' queue name instead of 'batch' diff --git a/pyproject.toml b/pyproject.toml index eb9472bbacc..f3e351a7fa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.36" +version = "9.3.37" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a8cdf54cf2d..20aa33c1409 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.36" +__version__ = "9.3.37" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 8f64df09dd9598dd4f2319142b4443eb24df15d9 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Fri, 13 Sep 2024 21:17:05 +0000 Subject: [PATCH 056/557] Add flow start progress bar --- temba/flows/models.py | 19 ++++++++--- temba/flows/views.py | 5 ++- templates/flows/flow_editor.html | 21 ++++++------- templates/flows/flowstart_list.html | 49 ++++++++++++++++------------- 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/temba/flows/models.py b/temba/flows/models.py index 0b18e070772..c457098b2f7 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1,7 +1,7 @@ import logging from array import array from collections import defaultdict -from datetime import datetime, timezone as tzone +from datetime import datetime, timezone as tzone, timedelta import iso8601 from django_redis import get_redis_connection @@ -457,14 +457,14 @@ def get_activity(self): """ return self.get_node_counts(), self.get_segment_counts() - def is_starting(self): + def get_active_start(self): """ Returns whether this flow is already being started by a user """ return ( self.starts.filter(status__in=(FlowStart.STATUS_STARTING, FlowStart.STATUS_PENDING)) .exclude(created_by=None) - .exists() + .first() ) def import_definition(self, user, definition, dependency_mapping): @@ -1891,6 +1891,9 @@ def preview(cls, flow, *, include: mailroom.Inclusions, exclude: mailroom.Exclus return preview.query, preview.total + def is_starting(self): + return self.status == self.STATUS_STARTING or self.status == self.STATUS_PENDING + def async_start(self): on_transaction_commit(lambda: mailroom.queue_flow_start(self)) @@ -1969,7 +1972,15 @@ def bulk_annotate(cls, starts): counts_by_start = {c["start_id"]: c["count"] for c in counts} for start in starts: - start.run_count = counts_by_start.get(start.id, 0) + start.run_count = counts_by_start.get(start.id, 0) + 525274 + start.pct_complete = start.run_count / start.contact_count * 100 if start.contact_count else 0 + + # estimated time of completion + runs_per_second = start.run_count / (timezone.now() - start.created_on).total_seconds() + runs_remaining = start.contact_count - start.run_count + start.etc = ( + timezone.now() + timedelta(seconds=runs_remaining / runs_per_second) if runs_per_second else None + ) class Meta: indexes = [models.Index(fields=("start",), condition=Q(is_squashed=False), name="flowstartcounts_unsquashed")] diff --git a/temba/flows/views.py b/temba/flows/views.py index e95efe0d592..b22d38d52c0 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -888,7 +888,7 @@ def get_context_data(self, *args, **kwargs): context["can_start"] = flow.flow_type != Flow.TYPE_VOICE or flow.org.supports_ivr() context["can_simulate"] = True - context["is_starting"] = flow.is_starting() + context["active_start"] = flow.get_active_start() context["feature_filters"] = json.dumps(self.get_features(flow.org)) return context @@ -1422,8 +1422,7 @@ class Activity(AllowOnlyActiveFlowMixin, OrgObjPermsMixin, SmartReadView): def get(self, request, *args, **kwargs): flow = self.get_object(self.get_queryset()) (active, visited) = flow.get_activity() - - return JsonResponse(dict(nodes=active, segments=visited, is_starting=flow.is_starting())) + return JsonResponse(dict(nodes=active, segments=visited)) class Simulate(OrgObjPermsMixin, SmartReadView): permission = "flows.flow_editor" diff --git a/templates/flows/flow_editor.html b/templates/flows/flow_editor.html index f6e540afc5c..8df286aa02b 100644 --- a/templates/flows/flow_editor.html +++ b/templates/flows/flow_editor.html @@ -235,19 +235,16 @@ {% endblock extra-script %} {% block alert-messages %} - {% if is_starting or messages or user_org.is_suspended %} -
- {{ block.super }} - {% if is_starting %} - - {% blocktrans trimmed %} - This flow is in the process of being sent, this message will disappear once all contacts have been added to the flow. - {% endblocktrans %} - - {% endif %} -
- {% endif %} {% endblock alert-messages %} +{% block page-header %} + {% if active_start or messages or user_org.is_suspended %} + {% if active_start %} + + + {% endif %} + {% endif %} + {{ block.super }} +{% endblock page-header %} {% block content %} diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index 87066f05a32..9897804dba3 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -31,20 +31,8 @@ {% for obj in object_list %} - - - {% if obj.status == "P" or obj.status == "S" %} - - - {% elif obj.status == "C" %} - - - {% elif obj.status == "F" %} - - - {% endif %} - - + + {% if obj.flow.is_active %} {{ obj.flow.name }} {% else %} @@ -64,19 +52,36 @@ {% include "includes/exclusions.html" with exclusions=obj.exclusions %}
- +
-
{{ obj.created_on|timedate }}
-
- {% blocktrans trimmed with count=obj.run_count|intcomma count counter=obj.run_count %} - {{ count }} run - {% plural %} - {{ count }} runs - {% endblocktrans %} +
{{ obj.created_on|timedate }}
+
+ {% if obj.status == 'F' %} + Failed + {% else %} + {% if obj.is_starting %} + Starting + {% else %} + Started + {% endif %} + {% blocktrans trimmed with count=obj.contact_count|intcomma count counter=obj.contact_count %} + {{ count }} contact + {% plural %} + {{ count }} contacts + {% endblocktrans %} + {% endif %}
+ {% if obj.is_starting %} + + + + + + + {% endif %} {% empty %} {% trans "No flow starts" %} From 909cf04bbf047523342e3516809e0a7b20022394 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Sat, 14 Sep 2024 00:06:34 +0000 Subject: [PATCH 057/557] Add flow start progress --- package.json | 2 +- yarn.lock | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a01374849f6..3098f17bcf7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.1", - "@nyaruka/temba-components": "0.106.0", + "@nyaruka/temba-components": "0.107.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index 7743511631c..6030059e66c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.106.0": - version "0.106.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.106.0.tgz#378b1df023fa0935f0f0bb2b1e8a24b21288fa53" - integrity sha512-pvQTDPbekkG2z7ng9NgaCg51zcdOc29v/q6rxFP0Vv4fqmwxVR+2v6gL1GAjavDSYwCsMIjmcVedB4G5k+pwjA== +"@nyaruka/temba-components@0.107.0": + version "0.107.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.107.0.tgz#ffc4b388fa9212aee8a2bd44ad06b42470685563" + integrity sha512-34JRLbIV24LtS0pRYsl9sYW55G5kcGIPTJYf09ozgyCvg1PaYQylq+mWEQWswoMfIBYL+L/1Ned//L1/R/H69Q== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" @@ -117,7 +117,7 @@ lit "3.1.2" lit-element "^4.0.4" lit-html "^3.1.2" - luxon "^2.4.0" + luxon "3.5" remarkable "^2.0.1" serialize-javascript "^6.0.2" tiny-lru "^11.2.5" @@ -833,6 +833,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@3.5: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + luxon@^2.4.0: version "2.5.2" resolved "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz#17ed497f0277e72d58a4756d6a9abee4681457b6" From 99dc9adc9d4b2e05bd0438bd16db3fca75e50296 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Sat, 14 Sep 2024 00:13:10 +0000 Subject: [PATCH 058/557] Run isort --- temba/flows/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/flows/models.py b/temba/flows/models.py index c457098b2f7..a10b74c140b 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1,7 +1,7 @@ import logging from array import array from collections import defaultdict -from datetime import datetime, timezone as tzone, timedelta +from datetime import datetime, timedelta, timezone as tzone import iso8601 from django_redis import get_redis_connection From 4d0118563f83f31b198694362863f1188e51f5f9 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Sat, 14 Sep 2024 00:36:56 +0000 Subject: [PATCH 059/557] Fix tests --- temba/flows/models.py | 12 ++---------- temba/flows/tests.py | 2 -- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/temba/flows/models.py b/temba/flows/models.py index a10b74c140b..069c8e0d9f2 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1,7 +1,7 @@ import logging from array import array from collections import defaultdict -from datetime import datetime, timedelta, timezone as tzone +from datetime import datetime, timezone as tzone import iso8601 from django_redis import get_redis_connection @@ -1972,15 +1972,7 @@ def bulk_annotate(cls, starts): counts_by_start = {c["start_id"]: c["count"] for c in counts} for start in starts: - start.run_count = counts_by_start.get(start.id, 0) + 525274 - start.pct_complete = start.run_count / start.contact_count * 100 if start.contact_count else 0 - - # estimated time of completion - runs_per_second = start.run_count / (timezone.now() - start.created_on).total_seconds() - runs_remaining = start.contact_count - start.run_count - start.etc = ( - timezone.now() + timedelta(seconds=runs_remaining / runs_per_second) if runs_per_second else None - ) + start.run_count = counts_by_start.get(start.id, 0) class Meta: indexes = [models.Index(fields=("start",), condition=Q(is_squashed=False), name="flowstartcounts_unsquashed")] diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 4258f731c54..aeae3f0f5cd 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2964,7 +2964,6 @@ def test_activity(self): self.assertEqual(200, response.status_code) self.assertEqual( { - "is_starting": False, "nodes": {beer_split["uuid"]: 1}, "segments": { f'{color_prompt["exits"][0]["uuid"]}:{color_split["uuid"]}': 1, @@ -5297,7 +5296,6 @@ def test_list(self): self.assertContains(response, "was started by an API call") self.assertContains(response, "was started by Zapier") self.assertContains(response, "Not in a flow") - self.assertContains(response, "1,234 runs") response = self.assertListFetch(list_url + "?type=manual", [self.admin], context_objects=[start1]) self.assertTrue(response.context["filtered"]) From 78ae6e485b3f59a51edc8935ccee3fb11f419e46 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Fri, 13 Sep 2024 17:48:32 -0700 Subject: [PATCH 060/557] Update CHANGELOG.md for v9.3.38 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6b31665d6..cc8740f1414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.38 (2024-09-14) +------------------------- + * Add flow start progress bar + v9.3.37 (2024-09-13) ------------------------- * Fix import read page title diff --git a/pyproject.toml b/pyproject.toml index f3e351a7fa3..d67bf033bef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.37" +version = "9.3.38" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 20aa33c1409..a42f7f9af52 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.37" +__version__ = "9.3.38" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From e761f412cefd6eb1ca415b2e858ab3af330a3652 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 16 Sep 2024 11:43:52 +0200 Subject: [PATCH 061/557] Validate body for EX channel type will be valid JSON after replacing variables --- temba/channels/types/external/tests.py | 18 ++++++++++++++---- temba/channels/types/external/views.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/temba/channels/types/external/tests.py b/temba/channels/types/external/tests.py index 4d49b30491e..e348e72b207 100644 --- a/temba/channels/types/external/tests.py +++ b/temba/channels/types/external/tests.py @@ -29,7 +29,7 @@ def test_claim(self, mock_socket_hostname): post_data["country"] = "RW" post_data["url"] = "http://localhost:8000/foo" post_data["method"] = "POST" - post_data["body"] = "send=true" + post_data["body"] = '{"from":"{{from_no_plus}}","to":{{to_no_plus}},"text":\'{{text}}\' }' post_data["content_type"] = Channel.CONTENT_TYPE_JSON post_data["max_length"] = 180 post_data["send_authorization"] = "Token 123" @@ -55,6 +55,12 @@ def test_claim(self, mock_socket_hostname): post_data["scheme"] = "tel" post_data["number"] = "12345" response = self.client.post(url, post_data) + self.assertFormError( + response.context["form"], "body", "Invalid JSON, make sure to remove quotes around variables" + ) + + post_data["body"] = '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}} }' + response = self.client.post(url, post_data) channel = Channel.objects.get() self.assertEqual(channel.country, "RW") @@ -67,7 +73,10 @@ def test_claim(self, mock_socket_hostname): self.assertEqual(channel.config[ExternalType.CONFIG_SEND_AUTHORIZATION], "Token 123") self.assertEqual(channel.channel_type, "EX") self.assertEqual(Channel.ENCODING_SMART, channel.config[Channel.CONFIG_ENCODING]) - self.assertEqual("send=true", channel.config[ExternalType.CONFIG_SEND_BODY]) + self.assertEqual( + '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}} }', + channel.config[ExternalType.CONFIG_SEND_BODY], + ) self.assertEqual("SENT", channel.config[ExternalType.CONFIG_MT_RESPONSE_CHECK]) config_url = reverse("channels.channel_configuration", args=[channel.uuid]) @@ -131,9 +140,10 @@ def test_claim(self, mock_socket_hostname): post_data["scheme"] = "ext" post_data["address"] = "123456789" - post_data["url"] = "http://example.com/send.php?from={{from}}&text={{text}}&to={{to}}" - post_data["method"] = "GET" + post_data["url"] = "http://example.com/send.php" + post_data["method"] = "POST" post_data["content_type"] = Channel.CONTENT_TYPE_JSON + post_data["body"] = '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}} }' post_data["max_length"] = 180 post_data["encoding"] = Channel.ENCODING_SMART diff --git a/temba/channels/types/external/views.py b/temba/channels/types/external/views.py index 9031b6acd01..8271ffbdd66 100644 --- a/temba/channels/types/external/views.py +++ b/temba/channels/types/external/views.py @@ -1,3 +1,5 @@ +import json + from smartmin.views import SmartFormView from django import forms @@ -100,6 +102,25 @@ def clean(self): elif scheme != URN.TEL_SCHEME and not cleaned_data.get("address"): raise ValidationError({"address": _("This field is required.")}) + content_type = cleaned_data.get("content_type") + + variables = { + "text": "", + "from": "", + "from_no_plus": "", + "to": "", + "to_no_plus": "", + "id": "", + "quick_replies": "", + } + replaced_body = Channel.replace_variables(cleaned_data.get("body"), variables, content_type=content_type) + if content_type == Channel.CONTENT_TYPE_JSON: + try: + + json.loads(replaced_body) + except json.decoder.JSONDecodeError: + raise ValidationError({"body": _("Invalid JSON, make sure to remove quotes around variables")}) + class SendClaimForm(ClaimViewMixin.Form): url = ExternalURLField( max_length=1024, From 51910888b2065f6556a346bef206b5583d77729c Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 16 Sep 2024 21:35:30 +0200 Subject: [PATCH 062/557] Move replace_variables method to the EX channel type package --- temba/channels/models.py | 23 ------------------ temba/channels/types/external/tests.py | 12 ++++++---- temba/channels/types/external/type.py | 33 ++++++++++++++++++++++---- temba/channels/types/external/views.py | 6 ++++- 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 13a0d2166bb..13016c0c145 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -3,9 +3,7 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum -from urllib.parse import quote_plus from uuid import uuid4 -from xml.sax.saxutils import escape import phonenumbers from django_countries.fields import CountryField @@ -687,27 +685,6 @@ def trigger_sync(self): # pragma: no cover if fcm_id and settings.ANDROID_FCM_PROJECT_ID and settings.ANDROID_CREDENTIALS_FILE: on_transaction_commit(lambda: sync_channel_fcm_task.delay(fcm_id, channel_id=self.id)) - @classmethod - def replace_variables(cls, text, variables, content_type=CONTENT_TYPE_URLENCODED): - for key in variables.keys(): - replacement = str(variables[key]) - - # encode based on our content type - if content_type == Channel.CONTENT_TYPE_URLENCODED: - replacement = quote_plus(replacement) - - # if this is JSON, need to wrap in quotes (and escape them) - elif content_type == Channel.CONTENT_TYPE_JSON: - replacement = json.dumps(replacement) - - # XML needs to be escaped - elif content_type == Channel.CONTENT_TYPE_XML: - replacement = escape(replacement) - - text = text.replace("{{%s}}" % key, replacement) - - return text - def get_count(self, count_types, since=None): qs = ChannelCount.objects.filter(channel=self, count_type__in=count_types) if since: diff --git a/temba/channels/types/external/tests.py b/temba/channels/types/external/tests.py index e348e72b207..27f2fc1a1bc 100644 --- a/temba/channels/types/external/tests.py +++ b/temba/channels/types/external/tests.py @@ -94,31 +94,33 @@ def test_claim(self, mock_socket_hostname): # test substitution in our url self.assertEqual( "http://example.com/send.php?from=5080&text=test&to=%2B250788383383", - channel.replace_variables(ext_url, {"from": "5080", "text": "test", "to": "+250788383383"}), + ExternalType.replace_variables(ext_url, {"from": "5080", "text": "test", "to": "+250788383383"}), ) # test substitution with unicode self.assertEqual( "http://example.com/send.php?from=5080&text=Reply+%E2%80%9C1%E2%80%9D+for+good&to=%2B250788383383", - channel.replace_variables(ext_url, {"from": "5080", "text": "Reply “1” for good", "to": "+250788383383"}), + ExternalType.replace_variables( + ext_url, {"from": "5080", "text": "Reply “1” for good", "to": "+250788383383"} + ), ) # test substitution with XML encoding body = "{{text}}" self.assertEqual( "Hello & World", - channel.replace_variables(body, {"text": "Hello & World"}, Channel.CONTENT_TYPE_XML), + ExternalType.replace_variables(body, {"text": "Hello & World"}, Channel.CONTENT_TYPE_XML), ) self.assertEqual( - "التوطين", channel.replace_variables(body, {"text": "التوطين"}, Channel.CONTENT_TYPE_XML) + "التوطين", ExternalType.replace_variables(body, {"text": "التوطين"}, Channel.CONTENT_TYPE_XML) ) # test substitution with JSON encoding body = "{ body: {{text}} }" self.assertEqual( '{ body: "this is \\"quote\\"" }', - channel.replace_variables(body, {"text": 'this is "quote"'}, Channel.CONTENT_TYPE_JSON), + ExternalType.replace_variables(body, {"text": 'this is "quote"'}, Channel.CONTENT_TYPE_JSON), ) # raw content type should be loaded on setting page as is diff --git a/temba/channels/types/external/type.py b/temba/channels/types/external/type.py index 47839e3d2e5..731e377867f 100644 --- a/temba/channels/types/external/type.py +++ b/temba/channels/types/external/type.py @@ -1,3 +1,7 @@ +import json +from urllib.parse import quote_plus +from xml.sax.saxutils import escape + from django.utils.translation import gettext_lazy as _ from ...models import Channel, ChannelType, ConfigUI @@ -35,6 +39,27 @@ class ExternalType(ChannelType): "&channel={{channel}}" ) + @classmethod + def replace_variables(cls, text, variables, content_type=Channel.CONTENT_TYPE_URLENCODED): + for key in variables.keys(): + replacement = str(variables[key]) + + # encode based on our content type + if content_type == Channel.CONTENT_TYPE_URLENCODED: + replacement = quote_plus(replacement) + + # if this is JSON, need to wrap in quotes (and escape them) + elif content_type == Channel.CONTENT_TYPE_JSON: + replacement = json.dumps(replacement) + + # XML needs to be escaped + elif content_type == Channel.CONTENT_TYPE_XML: + replacement = escape(replacement) + + text = text.replace("{{%s}}" % key, replacement) + + return text + def get_config_ui_context(self, channel): context = super().get_config_ui_context(channel) @@ -55,8 +80,8 @@ def get_config_ui_context(self, channel): content_type = config.get(ExternalType.CONFIG_CONTENT_TYPE, Channel.CONTENT_TYPE_URLENCODED) context["example_content_type"] = "Content-Type: " + Channel.CONTENT_TYPES.get(content_type, content_type) - context["example_url"] = Channel.replace_variables(send_url, example_payload) - context["example_body"] = Channel.replace_variables(send_body, example_payload, content_type) + context["example_url"] = ExternalType.replace_variables(send_url, example_payload) + context["example_body"] = ExternalType.replace_variables(send_body, example_payload, content_type) quick_replies_payload = {} @@ -67,10 +92,10 @@ def get_config_ui_context(self, channel): else: quick_replies_payload["quick_replies"] = "&quick_reply=One&quick_reply=Two&quick_reply=Three" - context["example_url"] = Channel.replace_variables( + context["example_url"] = ExternalType.replace_variables( context["example_url"], quick_replies_payload, "don't encode" ) - context["example_body"] = Channel.replace_variables( + context["example_body"] = ExternalType.replace_variables( context["example_body"], quick_replies_payload, "don't encode" ) return context diff --git a/temba/channels/types/external/views.py b/temba/channels/types/external/views.py index 8271ffbdd66..68f99cb76a8 100644 --- a/temba/channels/types/external/views.py +++ b/temba/channels/types/external/views.py @@ -95,6 +95,8 @@ class ClaimForm(ClaimViewMixin.Form): ) def clean(self): + from .type import ExternalType + cleaned_data = super().clean() scheme = cleaned_data.get("scheme") if scheme == URN.TEL_SCHEME and not cleaned_data.get("number"): @@ -113,7 +115,9 @@ def clean(self): "id": "", "quick_replies": "", } - replaced_body = Channel.replace_variables(cleaned_data.get("body"), variables, content_type=content_type) + replaced_body = ExternalType.replace_variables( + cleaned_data.get("body"), variables, content_type=content_type + ) if content_type == Channel.CONTENT_TYPE_JSON: try: From 2ea1e1fa56167b850f38add6959513501edfdf6f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 16 Sep 2024 21:48:02 +0000 Subject: [PATCH 063/557] Simplify outbox limit to be hardcoded at 1M --- temba/flows/tests.py | 34 ++++++++++++++++++---------------- temba/flows/views.py | 3 +-- temba/msgs/tests.py | 17 +++++++++-------- temba/msgs/views.py | 2 +- temba/orgs/models.py | 10 +++------- temba/orgs/tests.py | 3 --- temba/settings_common.py | 1 - 7 files changed, 32 insertions(+), 38 deletions(-) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index aeae3f0f5cd..5f483d94ac5 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -19,6 +19,7 @@ from temba.classifiers.models import Classifier from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactURN from temba.globals.models import Global +from temba.msgs.models import SystemLabel, SystemLabelCount from temba.orgs.integrations.dtone import DTOneType from temba.orgs.models import Export from temba.templates.models import TemplateTranslation @@ -2408,23 +2409,24 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): ) # if we have too many messages in our outbox we should block - with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): - preview_url = reverse("flows.flow_preview_start", args=[flow.id]) - mr_mocks.flow_start_preview(query="age > 30", total=1000) + SystemLabelCount.objects.create(org=self.org, label_type=SystemLabel.TYPE_OUTBOX, count=1_000_001) + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + mr_mocks.flow_start_preview(query="age > 30", total=1000) - response = self.client.post( - preview_url, - { - "query": "age > 30", - }, - content_type="application/json", - ) - self.assertEqual( - [ - "Your outbox currently has too many queued messages to start a flow. Please wait for these messages to finish sending and try again." - ], - response.json()["blockers"], - ) + response = self.client.post( + preview_url, + { + "query": "age > 30", + }, + content_type="application/json", + ) + self.assertEqual( + [ + "You have too many messages queued in your outbox. Please wait for these messages to send and then try again." + ], + response.json()["blockers"], + ) + self.org.system_labels.all().delete() # check warning for lots of contacts preview_url = reverse("flows.flow_preview_start", args=[flow.id]) diff --git a/temba/flows/views.py b/temba/flows/views.py index b22d38d52c0..0e0de8b27e0 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1519,8 +1519,7 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): "allow you to make and receive calls." ), "outbox_full": _( - "Your outbox currently has too many queued messages to start a flow. " - "Please wait for these messages to finish sending and try again." + "You have too many messages queued in your outbox. Please wait for these messages to send and then try again." ), "too_many_recipients": _( "Your channels cannot send fast enough to reach all of the selected contacts in a reasonable time. " diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 7f1638c1fff..ec17ce085a0 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2409,14 +2409,15 @@ def test_preview(self, mr_mocks): # if we have too many messages in our outbox we should block mr_mocks.msg_broadcast_preview(query="age > 30", total=2) - with override_settings(ORG_LIMIT_DEFAULTS={"outbox": 0}): - response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") - self.assertEqual( - [ - "You currently have too many messages queued in your outbox. Please wait for these messages to send and try again later." - ], - response.json()["blockers"], - ) + SystemLabelCount.objects.create(org=self.org, label_type=SystemLabel.TYPE_OUTBOX, count=1_000_001) + response = self.client.post(preview_url, {"query": "age > 30"}, content_type="application/json") + self.assertEqual( + [ + "You have too many messages queued in your outbox. Please wait for these messages to send and then try again." + ], + response.json()["blockers"], + ) + self.org.system_labels.all().delete() # if we release our send channel we can't send a broadcast self.channel.release(self.admin) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index cec9602d58a..a7fee27d5dd 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -625,7 +625,7 @@ class Preview(OrgPermsMixin, SmartCreateView): "you to send messages to your contacts." ), "outbox_full": _( - "You currently have too many messages queued in your outbox. Please wait for these messages to send and try again later." + "You have too many messages queued in your outbox. Please wait for these messages to send and then try again." ), } diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 971cb1af83d..c34053b2e99 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -476,7 +476,6 @@ class Org(SmartModel): LIMIT_LABELS = "labels" LIMIT_TOPICS = "topics" LIMIT_TEAMS = "teams" - LIMIT_OUTBOX = "outbox" DELETE_DELAY_DAYS = 7 # how many days after releasing that an org is deleted @@ -844,13 +843,10 @@ def export_definitions(cls, site_link, components, include_fields=True, include_ def supports_ivr(self): return self.get_call_channel() or self.get_answer_channel() - def is_outbox_full(self): - from temba.msgs.models import Broadcast, SystemLabel + def is_outbox_full(self) -> bool: + from temba.msgs.models import SystemLabel - outbox_limit = self.get_limit(Org.LIMIT_OUTBOX) - counts = SystemLabel.get_counts(self) - outbox = counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(self).count() - return outbox >= outbox_limit + return SystemLabel.get_counts(self)[SystemLabel.TYPE_OUTBOX] >= 1_000_000 def is_flow_starting(self): from temba.flows.models import FlowStart diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 913d3c0b083..ef2af8363c4 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -3028,7 +3028,6 @@ def assertOrgFilter(query: str, expected_orgs: list): "labels_limit", "teams_limit", "topics_limit", - "outbox_limit", "loc", ], list(response.context["form"].fields.keys()), @@ -3046,7 +3045,6 @@ def assertOrgFilter(query: str, expected_orgs: list): "globals_limit": "", "groups_limit": 400, "labels_limit": "", - "outbox_limit": 1000, "teams_limit": "", "topics_limit": "", }, @@ -3060,7 +3058,6 @@ def assertOrgFilter(query: str, expected_orgs: list): self.assertEqual(self.org.get_limit(Org.LIMIT_GLOBALS), 250) # uses default self.assertEqual(self.org.get_limit(Org.LIMIT_GROUPS), 400) self.assertEqual(self.org.get_limit(Org.LIMIT_CHANNELS), 20) - self.assertEqual(self.org.get_limit(Org.LIMIT_OUTBOX), 1000) # flag org self.client.post(update_url, {"action": "flag"}) diff --git a/temba/settings_common.py b/temba/settings_common.py index 48e0b74802c..8e1807c24de 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -944,7 +944,6 @@ "labels": 250, "teams": 50, "topics": 250, - "outbox": 1000000, } RETENTION_PERIODS = { From d33cac1249736e16bb2d73c391d51631ef24dcad Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 16 Sep 2024 21:10:56 +0000 Subject: [PATCH 064/557] Start reading attached channel logs from DynamoDB instead of S3 --- temba/channels/models.py | 49 +++++++++++++++++++--------------- temba/channels/tests.py | 53 +++++++------------------------------ temba/ivr/models.py | 2 +- temba/msgs/models.py | 2 +- temba/settings_common.py | 7 +---- temba/tests/base.py | 24 ++++++++++++++++- temba/utils/checks.py | 2 +- temba/utils/dynamo/base.py | 17 ++++++++++++ temba/utils/dynamo/tests.py | 5 ++++ temba/utils/tests.py | 1 - 10 files changed, 86 insertions(+), 76 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 13016c0c145..6e788c62c68 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -1,7 +1,7 @@ import logging from abc import ABCMeta from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta, timezone as tzone from enum import Enum from uuid import uuid4 @@ -13,7 +13,6 @@ from django.conf import settings from django.contrib.postgres.fields import ArrayField -from django.core.files.storage import storages from django.db import models from django.db.models import Q, Sum from django.db.models.signals import pre_save @@ -25,7 +24,7 @@ from django.utils.translation import gettext_lazy as _ from temba.orgs.models import DependencyMixin, Org -from temba.utils import analytics, get_anonymous_user, json, on_transaction_commit, redact +from temba.utils import analytics, dynamo, get_anonymous_user, on_transaction_commit, redact from temba.utils.models import ( JSONAsTextField, LegacyUUIDMixin, @@ -35,7 +34,6 @@ generate_uuid, ) from temba.utils.text import generate_secret -from temba.utils.uuid import is_uuid logger = logging.getLogger(__name__) @@ -850,6 +848,7 @@ class ChannelLog(models.Model): A log of an interaction with a channel """ + DYNAMO_TABLE = "ChannelLogs" # unprefixed table name REDACT_MASK = "*" * 8 # used to mask redacted values LOG_TYPE_UNKNOWN = "unknown" @@ -949,24 +948,30 @@ def _anonymize(cls, data: dict, channel, urn): err["message"] = cls._anonymize_value(err["message"], urn) @classmethod - def get_logs(cls, channel, uuids: list) -> list: - # look for logs in the database - logs = {l.uuid: l._get_json() for l in cls.objects.filter(channel=channel, uuid__in=uuids)} - - # and in storage - for log_uuid in uuids: - assert is_uuid(log_uuid), f"{log_uuid} is not a valid log UUID" - - if log_uuid not in logs: - key = f"channels/{channel.uuid}/{str(log_uuid)[0:4]}/{log_uuid}.json" - try: - log_file = storages["logs"].open(key) - logs[log_uuid] = json.loads(log_file.read()) - log_file.close() - except Exception: - logger.exception("unable to read log from storage", extra={"key": key}) - - return sorted(logs.values(), key=lambda l: l["created_on"]) + def get_logs(cls, uuids: list) -> list: + if not uuids: + return [] + + client = dynamo.get_client() + resp = client.batch_get_item( + RequestItems={dynamo.table_name(cls.DYNAMO_TABLE): {"Keys": [{"UUID": {"S": str(u)}} for u in uuids]}} + ) + + logs = [] + for log in resp["Responses"][dynamo.table_name(cls.DYNAMO_TABLE)]: + data = dynamo.load_jsongz(log["DataGZ"]["B"]) + logs.append( + { + "uuid": log["UUID"]["S"], + "type": log["Type"]["S"], + "http_logs": data["http_logs"], + "errors": data["errors"], + "elapsed_ms": int(log["ElapsedMS"]["N"]), + "created_on": datetime.fromtimestamp(int(log["CreatedOn"]["N"]), tz=tzone.utc), + } + ) + + return sorted(logs, key=lambda l: l["created_on"]) def _get_json(self): """ diff --git a/temba/channels/tests.py b/temba/channels/tests.py index 4343f34e3c2..df59e3b54e1 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -1,7 +1,6 @@ import base64 import hashlib import hmac -import io import time from datetime import date, datetime, timedelta, timezone as tzone from unittest.mock import patch @@ -12,7 +11,6 @@ from django.conf import settings from django.contrib.auth.models import Group from django.core import mail -from django.core.files.storage import storages from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone @@ -1519,10 +1517,8 @@ class ChannelLogCRUDLTest(CRUDLTestMixin, TembaTest): def test_msg(self): contact = self.create_contact("Fred", phone="+12067799191") - log1 = ChannelLog.objects.create( - channel=self.channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log1 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://foo.bar/send1", @@ -1531,15 +1527,12 @@ def test_msg(self): "response": "HTTP/1.0 200 OK\r\r\r\n", "elapsed_ms": 12, "retries": 0, - "created_on": "2022-01-01T00:00:00Z", + "created_on": "2024-09-16T00:00:00Z", } ], - errors=[], ) - log2 = ChannelLog.objects.create( - channel=self.channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log2 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://foo.bar/send2", @@ -1548,29 +1541,26 @@ def test_msg(self): "response": "HTTP/1.0 200 OK\r\r\r\n", "elapsed_ms": 12, "retries": 0, - "created_on": "2022-01-01T00:00:00Z", + "created_on": "2024-09-16T00:00:00Z", } ], - errors=[], ) msg1 = self.create_outgoing_msg(contact, "success message", status="D", logs=[log1, log2]) # create another msg and log that shouldn't be included - log3 = ChannelLog.objects.create( - channel=self.channel, - is_error=False, + log3 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://foo.bar/send3", "status_code": 200, - "request": "POST https://foo.bar/send2\r\n\r\n{}", + "request": "POST https://foo.bar/send3\r\n\r\n{}", "response": "HTTP/1.0 200 OK\r\r\r\n", "elapsed_ms": 12, "retries": 0, - "created_on": "2022-01-01T00:00:00Z", + "created_on": "2024-09-16T00:00:00Z", } ], - errors=[], ) self.create_outgoing_msg(contact, "success message", status="D", logs=[log3]) @@ -1585,29 +1575,6 @@ def test_msg(self): response = self.client.get(msg1_url) self.assertEqual(f"/settings/channels/{self.channel.uuid}", response.headers[TEMBA_MENU_SELECTION]) - # try when log objects are in storage rather than the database - ChannelLog.objects.all().delete() - - storages["logs"].save( - f"channels/{self.channel.uuid}/{str(log1.uuid)[:4]}/{log1.uuid}.json", - io.StringIO(json.dumps(log1._get_json())), - ) - storages["logs"].save( - f"channels/{self.channel.uuid}/{str(log2.uuid)[:4]}/{log2.uuid}.json", - io.StringIO(json.dumps(log2._get_json())), - ) - - response = self.assertListFetch(msg1_url, [self.admin], context_objects=[]) - self.assertEqual(2, len(response.context["logs"])) - self.assertEqual("https://foo.bar/send1", response.context["logs"][0]["http_logs"][0]["url"]) - self.assertEqual("https://foo.bar/send2", response.context["logs"][1]["http_logs"][0]["url"]) - - # missing logs are logged as errors and ignored - storages["logs"].delete(f"channels/{self.channel.uuid}/{str(log2.uuid)[:4]}/{log2.uuid}.json") - - response = self.assertListFetch(msg1_url, [self.admin], context_objects=[]) - self.assertEqual(1, len(response.context["logs"])) - def test_call(self): contact = self.create_contact("Fred", phone="+12067799191") flow = self.create_flow("IVR") diff --git a/temba/ivr/models.py b/temba/ivr/models.py index c8fb736bee8..2d557c5e70f 100644 --- a/temba/ivr/models.py +++ b/temba/ivr/models.py @@ -104,7 +104,7 @@ def get_session(self): return None def get_logs(self) -> list: - return ChannelLog.get_logs(self.channel, self.log_uuids or []) + return ChannelLog.get_logs(self.log_uuids or []) def release(self): session = self.get_session() diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 04971a8cebd..ad2f7d49246 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -577,7 +577,7 @@ def get_attachments(self): return Attachment.parse_all(self.attachments) def get_logs(self) -> list: - return ChannelLog.get_logs(self.channel, self.log_uuids or []) + return ChannelLog.get_logs(self.log_uuids or []) def handle(self): # pragma: no cover """ diff --git a/temba/settings_common.py b/temba/settings_common.py index 48e0b74802c..a62e3ae8ca0 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -86,11 +86,6 @@ "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", "OPTIONS": {"bucket_name": f"{_bucket_prefix}-archives"}, }, - # wherever courier and mailroom are writing logs - "logs": { - "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", - "OPTIONS": {"bucket_name": f"{_bucket_prefix}-logs"}, - }, # media file uploads that need to be publicly accessible "public": { "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", @@ -949,7 +944,7 @@ RETENTION_PERIODS = { "channelevent": timedelta(days=90), - "channellog": timedelta(days=14), + "channellog": timedelta(days=7), "export": timedelta(days=90), "eventfire": timedelta(days=90), "flowsession": timedelta(days=7), diff --git a/temba/tests/base.py b/temba/tests/base.py index 1bc0b49a731..224a000c941 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -1,5 +1,6 @@ import copy import os +from collections import namedtuple from datetime import datetime from functools import wraps from io import BytesIO @@ -30,7 +31,7 @@ from temba.orgs.models import Org, OrgRole, User from temba.templates.models import Template from temba.tickets.models import Ticket, TicketEvent -from temba.utils import json +from temba.utils import dynamo, json from temba.utils.uuid import UUID, uuid4 from .mailroom import ( @@ -693,6 +694,27 @@ def create_channel( modified_by=self.admin, ) + def create_channel_log(self, log_type: str, *, http_logs=(), errors=()) -> dict: + # should be v7 but see https://discuss.python.org/t/add-uuid7-in-uuid-module-in-standard-library/44390/7 + uuid = uuid4() + created_on = timezone.now() + expires_on = created_on + timezone.timedelta(days=7) + + client = dynamo.get_client() + client.put_item( + TableName=dynamo.table_name(ChannelLog.DYNAMO_TABLE), + Item={ + "UUID": {"S": str(uuid)}, + "Type": {"S": log_type}, + "DataGZ": {"B": dynamo.dump_jsongz({"http_logs": http_logs, "errors": errors})}, + "ElapsedMS": {"N": "12"}, + "CreatedOn": {"N": str(int(created_on.timestamp()))}, + "ExpiresOn": {"N": str(int(expires_on.timestamp()))}, + }, + ) + + return namedtuple("ChannelLog", ["uuid", "created_on"])(uuid, created_on) + def create_channel_event(self, channel, urn, event_type, occurred_on=None, optin=None, extra=None): urn_obj = contact_urn_lookup(channel.org, urn) if urn_obj: diff --git a/temba/utils/checks.py b/temba/utils/checks.py index ae41ccbec05..81a0bd095b7 100644 --- a/temba/utils/checks.py +++ b/temba/utils/checks.py @@ -6,7 +6,7 @@ def storage(app_configs, **kwargs): errors = [] - for name in ("default", "archives", "logs", "public", "staticfiles"): + for name in ("default", "archives", "public", "staticfiles"): if name not in settings.STORAGES: errors.append( Error( diff --git a/temba/utils/dynamo/base.py b/temba/utils/dynamo/base.py index d5592abe188..563f238a490 100644 --- a/temba/utils/dynamo/base.py +++ b/temba/utils/dynamo/base.py @@ -1,3 +1,6 @@ +import json +import zlib + import boto3 from botocore.client import Config @@ -35,3 +38,17 @@ def table_name(logical_name: str) -> str: Add optional prefix to name to allow multiple deploys in same region """ return settings.DYNAMO_TABLE_PREFIX + logical_name + + +def load_jsongz(data: bytes) -> dict: + """ + Loads a value from gzipped JSON + """ + return json.loads(zlib.decompress(data, wbits=zlib.MAX_WBITS | 16)) + + +def dump_jsongz(value: dict) -> bytes: + """ + Dumps a value to gzipped JSON + """ + return zlib.compress(json.dumps(value).encode("utf-8"), wbits=zlib.MAX_WBITS | 16) diff --git a/temba/utils/dynamo/tests.py b/temba/utils/dynamo/tests.py index eff450bedc5..5454af56c46 100644 --- a/temba/utils/dynamo/tests.py +++ b/temba/utils/dynamo/tests.py @@ -10,3 +10,8 @@ def test_get_client(self): def test_table_name(self): self.assertEqual("TestThings", dynamo.table_name("Things")) + + def test_jsongz(self): + data = dynamo.dump_jsongz({"foo": "bar"}) + self.assertEqual(34, len(data)) + self.assertEqual({"foo": "bar"}, dynamo.load_jsongz(data)) diff --git a/temba/utils/tests.py b/temba/utils/tests.py index 170ee089cf8..8bd00082286 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -662,7 +662,6 @@ def test_storage(self): with override_settings(STORAGES={"default": {"BACKEND": "x"}, "staticfiles": {"BACKEND": "x"}}): self.assertEqual(storage(None)[0].msg, "Missing 'archives' storage config.") - self.assertEqual(storage(None)[1].msg, "Missing 'logs' storage config.") self.assertEqual(storage(None)[2].msg, "Missing 'public' storage config.") with override_settings(STORAGE_URL=None): From 9f51e20c41cde5592f0130caea57c9e2857002da Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Mon, 16 Sep 2024 23:38:25 +0000 Subject: [PATCH 065/557] Fix progress bar with high pcts --- package.json | 2 +- static/css/frame.css | 58 +++++++++++++++++++++++++++----------------- yarn.lock | 8 +++--- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 3098f17bcf7..070b2b10a10 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.1", - "@nyaruka/temba-components": "0.107.0", + "@nyaruka/temba-components": "0.107.1", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/static/css/frame.css b/static/css/frame.css index acc20dd4e46..18327a68bd9 100644 --- a/static/css/frame.css +++ b/static/css/frame.css @@ -4,6 +4,7 @@ html { font-size: var(--font-size); font-family: var(--font-family); height: 100%; + line-height: normal !important; } html.dragging * { @@ -29,7 +30,7 @@ body { } .attachment .attachment-preview { - background-color: #EAEAEA; + background-color: #eaeaea; border: 0; color: #777; display: flex; @@ -41,7 +42,7 @@ body { } .attachment .attachment-download { - background-color: #E0E0E0; + background-color: #e0e0e0; border: 0; color: #666; display: inline-block; @@ -60,7 +61,7 @@ body { display: none; } -temba-menu>div { +temba-menu > div { display: none; } @@ -74,14 +75,15 @@ temba-dialog { } temba-button { - --button-shadow: 0 0px 0px 1px rgba(0, 0, 0, 0.02), 0 1px 9px 0 rgba(0, 0, 0, 0.2); + --button-shadow: 0 0px 0px 1px rgba(0, 0, 0, 0.02), + 0 1px 9px 0 rgba(0, 0, 0, 0.2); } temba-button.light { - --button-shadow: rgba(0, 0, 0, 0.05) 0px 3px 7px 0px, rgba(0, 0, 0, 0.07) 0px 1px 1px 1px; + --button-shadow: rgba(0, 0, 0, 0.05) 0px 3px 7px 0px, + rgba(0, 0, 0, 0.07) 0px 1px 1px 1px; } - temba-button { --button-bg: var(--color-primary-dark); --button-text: var(--color-text-light); @@ -90,11 +92,16 @@ temba-button { } temba-button:hover { - --button-bg-img: linear-gradient(to bottom, rgba(var(--primary-rgb), .1), transparent, transparent); + --button-bg-img: linear-gradient( + to bottom, + rgba(var(--primary-rgb), 0.1), + transparent, + transparent + ); } temba-button.active { - --button-bg-img: linear-gradient(to bottom, transparent, rgba(0, 0, 0, .05)); + --button-bg-img: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05)); } temba-button.light { @@ -105,11 +112,15 @@ temba-button.light { } temba-button.light:hover { - --button-bg-img: linear-gradient(to bottom, transparent, rgba(0, 0, 0, .001)); + --button-bg-img: linear-gradient( + to bottom, + transparent, + rgba(0, 0, 0, 0.001) + ); } temba-button.light.active { - --button-bg-img: linear-gradient(to bottom, transparent, rgba(0, 0, 0, .02)); + --button-bg-img: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.02)); } temba-select:focus { @@ -138,7 +149,8 @@ temba-menu:defined { margin-left: 1em; } -.list-buttons-container.visible {} +.list-buttons-container.visible { +} .spa-container { background: #f7f7f7; @@ -147,7 +159,7 @@ temba-menu:defined { } .spa-container.loading .spa-content { - opacity: .3; + opacity: 0.3; pointer-events: none; } @@ -159,14 +171,14 @@ temba-menu:defined { .widget-container.loading .folders, .widget-container.loading .spa-content, .widget-container.loading .org-chooser { - opacity: .3; + opacity: 0.3; pointer-events: none; } .org-chooser { - background: rgba(0, 0, 0, .02); - box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, .1) inset; - color: rgba(0, 0, 0, .6); + background: rgba(0, 0, 0, 0.02); + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.1) inset; + color: rgba(0, 0, 0, 0.6); --color-widget-border: transparent; --color-widget-bg: transparent; --temba-select-selected-padding: 0em; @@ -196,7 +208,10 @@ temba-loading { .bg-gradient { background-repeat: no-repeat; - background-image: linear-gradient(rgb(255, 255, 255) 0%, rgb(236, 236, 236) 75%); + background-image: linear-gradient( + rgb(255, 255, 255) 0%, + rgb(236, 236, 236) 75% + ); } .lp-frame .lp-nav-item { @@ -241,7 +256,7 @@ temba-loading { } .object-list { - -webkit-transform: translate3d(0, 0, 0) + -webkit-transform: translate3d(0, 0, 0); } .button-action { @@ -256,7 +271,6 @@ temba-loading { color: #fff !important; } - #gear-container .button-light { padding-top: 0.62em; padding-bottom: 0.62em; @@ -292,7 +306,7 @@ temba-menu { temba-menu.servicing { --primary-rgb: 191, 84, 155; --color-primary-dark: rgb(var(--primary-rgb)); - --color-selection: rgba(var(--primary-rgb), .05); + --color-selection: rgba(var(--primary-rgb), 0.05); } .formax .formax-section.open { @@ -301,7 +315,7 @@ temba-menu.servicing { } .servicing:hover .hover { - background: rgba(0, 0, 0, .2); + background: rgba(0, 0, 0, 0.2); } .spa-content { @@ -328,4 +342,4 @@ temba-menu.servicing { border-color: var(--color-focus); background: var(--color-widget-bg-focused); box-shadow: var(--widget-box-shadow-focused); -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 6030059e66c..0023cdd4da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.107.0": - version "0.107.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.107.0.tgz#ffc4b388fa9212aee8a2bd44ad06b42470685563" - integrity sha512-34JRLbIV24LtS0pRYsl9sYW55G5kcGIPTJYf09ozgyCvg1PaYQylq+mWEQWswoMfIBYL+L/1Ned//L1/R/H69Q== +"@nyaruka/temba-components@0.107.1": + version "0.107.1" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.107.1.tgz#94a27ac43edc7fd9bcf8d85ba685db54fc7b4870" + integrity sha512-Tr1trvUv/qBlaGgnib/CRgYtwOgaPXNh9ZmBp5qTYDHHoeMjZ0jv7REFkvJtHrL12fujjbmuLYhrRUmiJJFDCA== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 5201713d793d61a2f9ebc90f9224f910d2ac7621 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 17 Sep 2024 15:21:10 +0200 Subject: [PATCH 066/557] Show bad file error as validation errors to the user --- media/test_imports/invalid.txt.xlsx | 1 + temba/contacts/models.py | 5 ++++- temba/contacts/tests.py | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 media/test_imports/invalid.txt.xlsx diff --git a/media/test_imports/invalid.txt.xlsx b/media/test_imports/invalid.txt.xlsx new file mode 100644 index 00000000000..0a757197bf8 --- /dev/null +++ b/media/test_imports/invalid.txt.xlsx @@ -0,0 +1 @@ +text file \ No newline at end of file diff --git a/temba/contacts/models.py b/temba/contacts/models.py index d556d7cdf20..6a4c9680604 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1985,7 +1985,10 @@ def try_to_parse(cls, org: Org, file, filename: str) -> tuple[list, int]: total number of records. Otherwise raises a ValidationError. """ - workbook = load_workbook(filename=file, read_only=True, data_only=True) + try: + workbook = load_workbook(filename=file, read_only=True, data_only=True) + except Exception: + raise ValidationError(_("Import file appears to be corrupted.")) ws = workbook.active # see https://openpyxl.readthedocs.io/en/latest/optimized.html#worksheet-dimensions but even with this we need diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index af9989183e6..2fd9fd22f24 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -3799,6 +3799,7 @@ def try_to_parse(name): ("reserved_field_key.xlsx", "Header 'Field:HAS' is not a valid field name."), ("no_urn_or_uuid.xlsx", "Import files must contain either UUID or a URN header."), ("uuid_only.xlsx", "Import files must contain columns besides UUID."), + ("invalid.txt.xlsx", "Import file appears to be corrupted."), ] for imp_file, imp_error in bad_files: From e20eaa42e28f49014a013dfbbfbdb0db61edb4f8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 10:00:46 -0500 Subject: [PATCH 067/557] Add UUIDv7 implementation for testing and fix tests --- temba/channels/models.py | 2 +- temba/channels/tests.py | 92 +++++++++++++++++++----------------- temba/tests/base.py | 27 +++-------- temba/utils/uuid/__init__.py | 25 ++++++++++ 4 files changed, 81 insertions(+), 65 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 6e788c62c68..e55709b9160 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -971,7 +971,7 @@ def get_logs(cls, uuids: list) -> list: } ) - return sorted(logs, key=lambda l: l["created_on"]) + return sorted(logs, key=lambda l: l["uuid"]) def _get_json(self): """ diff --git a/temba/channels/tests.py b/temba/channels/tests.py index df59e3b54e1..c47249cde7b 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -1579,12 +1579,22 @@ def test_call(self): contact = self.create_contact("Fred", phone="+12067799191") flow = self.create_flow("IVR") - call1 = self.create_incoming_call(flow, contact) - log1 = ChannelLog.objects.get() - log2 = ChannelLog.objects.create( - channel=self.channel, - log_type=ChannelLog.LOG_TYPE_IVR_START, - is_error=False, + log1 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, + http_logs=[ + { + "url": "https://foo.bar/call1", + "status_code": 200, + "request": "POST https://foo.bar/send1\r\n\r\n{}", + "response": "HTTP/1.0 200 OK\r\r\r\n", + "elapsed_ms": 12, + "retries": 0, + "created_on": "2024-09-16T00:00:00Z", + } + ], + ) + log2 = self.create_channel_log( + ChannelLog.LOG_TYPE_IVR_START, http_logs=[ { "url": "https://foo.bar/call2", @@ -1596,20 +1606,32 @@ def test_call(self): "created_on": "2022-01-01T00:00:00Z", } ], - errors=[], ) - call1.log_uuids = [log1.uuid, log2.uuid] - call1.save(update_fields=("log_uuids",)) + call1 = self.create_incoming_call(flow, contact, logs=[log1, log2]) # create another call and log that shouldn't be included - self.create_incoming_call(flow, contact) + log3 = self.create_channel_log( + ChannelLog.LOG_TYPE_IVR_START, + http_logs=[ + { + "url": "https://foo.bar/call2", + "status_code": 200, + "request": "POST /send2\r\n\r\n{}", + "response": "HTTP/1.0 200 OK\r\r\r\n", + "elapsed_ms": 12, + "retries": 0, + "created_on": "2022-01-01T00:00:00Z", + } + ], + ) + self.create_incoming_call(flow, contact, logs=[log3]) call1_url = reverse("channels.channellog_call", args=[self.channel.uuid, call1.id]) self.assertRequestDisallowed(call1_url, [None, self.user, self.editor, self.agent, self.admin2]) response = self.assertListFetch(call1_url, [self.admin], context_objects=[]) self.assertEqual(2, len(response.context["logs"])) - self.assertEqual("https://acme-calls.com/reply", response.context["logs"][0]["http_logs"][0]["url"]) + self.assertEqual("https://foo.bar/call1", response.context["logs"][0]["http_logs"][0]["url"]) self.assertEqual("https://foo.bar/call2", response.context["logs"][1]["http_logs"][0]["url"]) def test_read_and_list(self): @@ -1708,10 +1730,8 @@ def test_redaction_for_telegram(self): urn = "telegram:3527065" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("TG", "Test TG Channel", "234567") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://api.telegram.org/65474/sendMessage", @@ -1754,10 +1774,8 @@ def test_redaction_for_telegram_with_invalid_json(self): urn = "telegram:3527065" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("TG", "Test TG Channel", "234567") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://api.telegram.org/65474/sendMessage", @@ -1789,10 +1807,8 @@ def test_redaction_for_telegram_when_no_match(self): urn = "telegram:3527065" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("TG", "Test TG Channel", "234567") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://api.telegram.org/There is no contact identifying information", @@ -1824,10 +1840,8 @@ def test_redaction_for_twitter(self): urn = "twitterid:767659860" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("TWT", "Test TWT Channel", "nyaruka") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_RECEIVE, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://textit.in/c/twt/5c70a767-f3dc-4a99-9323-4774f6432af5/receive", @@ -1859,10 +1873,8 @@ def test_redaction_for_twitter_when_no_match(self): urn = "twitterid:767659860" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("TWT", "Test TWT Channel", "nyaruka") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://twitter.com/There is no contact identifying information", @@ -1894,10 +1906,8 @@ def test_redaction_for_facebook(self): urn = "facebook:2150393045080607" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("FB", "Test FB Channel", "54764868534") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_RECEIVE, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": f"https://textit.in/c/fb/{channel.uuid}/receive", @@ -1932,10 +1942,8 @@ def test_redaction_for_facebook_when_no_match(self): urn = "facebook:2150393045080607" contact = self.create_contact("Fred Jones", urns=[urn]) channel = self.create_channel("FB", "Test FB Channel", "54764868534") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_SEND, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://facebook.com/There is no contact identifying information", @@ -1966,10 +1974,8 @@ def test_redaction_for_facebook_when_no_match(self): def test_redaction_for_twilio(self): contact = self.create_contact("Fred Jones", phone="+593979099111") channel = self.create_channel("T", "Test Twilio Channel", "+12345") - log = ChannelLog.objects.create( - channel=channel, - log_type=ChannelLog.LOG_TYPE_MSG_STATUS, - is_error=False, + log = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[ { "url": "https://textit.in/c/t/1234-5678/status?id=2466753&action=callback", diff --git a/temba/tests/base.py b/temba/tests/base.py index 224a000c941..d4917af131c 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -32,7 +32,7 @@ from temba.templates.models import Template from temba.tickets.models import Ticket, TicketEvent from temba.utils import dynamo, json -from temba.utils.uuid import UUID, uuid4 +from temba.utils.uuid import UUID, uuid4, uuid7 from .mailroom import ( contact_urn_lookup, @@ -558,26 +558,12 @@ def create_flow(self, name: str, *, flow_type=Flow.TYPE_MESSAGE, nodes=None, is_ return flow - def create_incoming_call(self, flow, contact, status=Call.STATUS_COMPLETED, error_reason=None, created_on=None): + def create_incoming_call( + self, flow, contact, status=Call.STATUS_COMPLETED, error_reason=None, created_on=None, logs=() + ): """ Create something that looks like an incoming IVR call handled by mailroom """ - log = ChannelLog.objects.create( - channel=self.channel, - log_type=ChannelLog.LOG_TYPE_IVR_START, - is_error=status in (Call.STATUS_FAILED, Call.STATUS_ERRORED), - http_logs=[ - { - "url": "https://acme-calls.com/reply", - "status_code": 200, - "request": 'POST /reply\r\n\r\n{"say": "Hello"}', - "response": '{"status": "%s"}' % ("error" if status == Call.STATUS_FAILED else "OK"), - "elapsed_ms": 12, - "retries": 0, - "created_on": "2022-01-01T00:00:00Z", - } - ], - ) call = Call.objects.create( org=self.org, channel=self.channel, @@ -588,7 +574,7 @@ def create_incoming_call(self, flow, contact, status=Call.STATUS_COMPLETED, erro error_reason=error_reason, created_on=created_on or timezone.now(), duration=15, - log_uuids=[log.uuid], + log_uuids=[l.uuid for l in logs or []], ) session = FlowSession.objects.create( uuid=uuid4(), @@ -695,8 +681,7 @@ def create_channel( ) def create_channel_log(self, log_type: str, *, http_logs=(), errors=()) -> dict: - # should be v7 but see https://discuss.python.org/t/add-uuid7-in-uuid-module-in-standard-library/44390/7 - uuid = uuid4() + uuid = uuid7() created_on = timezone.now() expires_on = created_on + timezone.timedelta(days=7) diff --git a/temba/utils/uuid/__init__.py b/temba/utils/uuid/__init__.py index dcc6edfd1fa..64eb232696a 100644 --- a/temba/utils/uuid/__init__.py +++ b/temba/utils/uuid/__init__.py @@ -1,6 +1,8 @@ import random import re +import secrets import sys +import time from uuid import UUID, uuid4 as real_uuid4 default_generator = real_uuid4 @@ -45,3 +47,26 @@ def find_uuid(val: str) -> str | None: """ match = UUID_REGEX.search(val) return match.group(0) if match else None + + +_last_v7_timestamp = None + + +def uuid7() -> str: + """ + Until standard libnrary gets v7 support, this is adapted from https://github.com/oittaa/uuid6-python and only used + for tests. + """ + + global _last_v7_timestamp + + nanoseconds = time.time_ns() + timestamp_ms = nanoseconds // 10**6 + if _last_v7_timestamp is not None and timestamp_ms <= _last_v7_timestamp: + timestamp_ms = _last_v7_timestamp + 1 + _last_v7_timestamp = timestamp_ms + uuid_int = (timestamp_ms & 0xFFFFFFFFFFFF) << 80 + uuid_int |= secrets.randbits(76) + + hex = "%032x" % uuid_int + return "%s-%s-%s-%s-%s" % (hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) From 1b95f2836306d38a0e18cd37ba018412afda846d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 10:06:13 -0500 Subject: [PATCH 068/557] Update CHANGELOG.md for v9.3.39 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8740f1414..5ba3acf2f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +v9.3.39 (2024-09-17) +------------------------- + * Show bad import file error as validation errors to the user + * Fix flow start progress bar with high pcts + * Simplify outbox limit to be hardcoded at 1M + * Validate body for EX channel type will be valid JSON after replacing variables + v9.3.38 (2024-09-14) ------------------------- * Add flow start progress bar diff --git a/pyproject.toml b/pyproject.toml index d67bf033bef..65b772823ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.38" +version = "9.3.39" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a42f7f9af52..a93eca0a007 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.38" +__version__ = "9.3.39" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From a2056de8b8210b0ec884ddb0e2b5085376131a62 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 15:38:10 +0000 Subject: [PATCH 069/557] Add --testing argument to migrate_dynamo command --- .github/workflows/ci.yml | 2 +- temba/utils/management/commands/migrate_dynamo.py | 11 ++++++++++- temba/utils/tests.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09894270ff6..3f64fbfe0e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: sudo yarn global add less ln -s ${{ github.workspace }}/temba/settings.py.dev ${{ github.workspace }}/temba/settings.py poetry run python manage.py migrate - poetry run python manage.py migrate_dynamo + poetry run python manage.py migrate_dynamo --testing # fetch, extract and start mailroom wget https://github.com/${{ github.repository_owner }}/mailroom/releases/download/v${{ env.mailroom-version }}/mailroom_${{ env.mailroom-version }}_linux_amd64.tar.gz tar -xvf mailroom_${{ env.mailroom-version }}_linux_amd64.tar.gz mailroom diff --git a/temba/utils/management/commands/migrate_dynamo.py b/temba/utils/management/commands/migrate_dynamo.py index 9a5e9c42132..84cd7f1dd00 100644 --- a/temba/utils/management/commands/migrate_dynamo.py +++ b/temba/utils/management/commands/migrate_dynamo.py @@ -1,5 +1,6 @@ import time +from django.conf import settings from django.core.management import BaseCommand from temba.utils import dynamo @@ -18,9 +19,17 @@ class Command(BaseCommand): help = "Creates DynamoDB tables that don't already exist." - def handle(self, *args, **kwargs): + def add_arguments(self, parser): + parser.add_argument("--testing", action="store_true") + + def handle(self, testing: bool, *args, **kwargs): self.client = dynamo.get_client() + # during tests settings.TESTING is true so table prefix is "Test" - but this command is run with + # settings.TESTING == False, so when setting up tables for testing we need to override the prefix + if testing: + settings.DYNAMO_TABLE_PREFIX = "Test" + for table in TABLES: self._migrate_table(table) diff --git a/temba/utils/tests.py b/temba/utils/tests.py index 8bd00082286..05be70d4210 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -662,7 +662,7 @@ def test_storage(self): with override_settings(STORAGES={"default": {"BACKEND": "x"}, "staticfiles": {"BACKEND": "x"}}): self.assertEqual(storage(None)[0].msg, "Missing 'archives' storage config.") - self.assertEqual(storage(None)[2].msg, "Missing 'public' storage config.") + self.assertEqual(storage(None)[1].msg, "Missing 'public' storage config.") with override_settings(STORAGE_URL=None): self.assertEqual(storage(None)[0].msg, "No storage URL set.") From 03d56db2be807e136fe4614ac69c0d41e166e117 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 16:05:57 +0000 Subject: [PATCH 070/557] Improve comment on uuid7 --- temba/utils/uuid/__init__.py | 8 +++++--- temba/utils/uuid/tests.py | 9 ++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/temba/utils/uuid/__init__.py b/temba/utils/uuid/__init__.py index 64eb232696a..2e2da47340a 100644 --- a/temba/utils/uuid/__init__.py +++ b/temba/utils/uuid/__init__.py @@ -54,8 +54,10 @@ def find_uuid(val: str) -> str | None: def uuid7() -> str: """ - Until standard libnrary gets v7 support, this is adapted from https://github.com/oittaa/uuid6-python and only used - for tests. + Until standard library gets v7 support (see https://discuss.python.org/t/rfc-4122-9562-uuid-version-7-and-8-implementation/56725) + this is adapted from https://github.com/oittaa/uuid6-python and is ONLY FOR TESTS of code which consumes real v7 + UUIDs generated by mailroom and courier. Note also that it returns a str rather than a UUID instance because the + latter doesn't accept 7 as a version number. """ global _last_v7_timestamp @@ -69,4 +71,4 @@ def uuid7() -> str: uuid_int |= secrets.randbits(76) hex = "%032x" % uuid_int - return "%s-%s-%s-%s-%s" % (hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:]}" diff --git a/temba/utils/uuid/tests.py b/temba/utils/uuid/tests.py index 8eadd65016d..419a0f988ed 100644 --- a/temba/utils/uuid/tests.py +++ b/temba/utils/uuid/tests.py @@ -1,6 +1,6 @@ from temba.tests.base import TembaTest -from . import find_uuid, is_uuid +from . import find_uuid, is_uuid, uuid7 class UUIDTest(TembaTest): @@ -18,3 +18,10 @@ def test_find_uuid(self): self.assertEqual( "d749e4e9-2898-4e47-9418-7a89d9e51359", find_uuid("http://d749e4e9-2898-4e47-9418-7a89d9e51359/") ) + + def test_uuid7(self): + u1 = uuid7() + u2 = uuid7() + self.assertEqual(36, len(u1)) + self.assertEqual(36, len(u2)) + self.assertNotEqual(u1, u2) From c10e1f3c04d8378ae90b3cebc54a1f7bc13f5a34 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 18:19:10 +0000 Subject: [PATCH 071/557] Coverage --- temba/channels/tests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/temba/channels/tests.py b/temba/channels/tests.py index c47249cde7b..fdef77e3350 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -1338,6 +1338,27 @@ def test_trim_task(self): class ChannelLogTest(TembaTest): + def test_get_logs(self): + log1 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_SEND, + http_logs=[{"url": "https://foo.bar/send1"}], + errors=[{"code": "bad_response", "message": "response not right"}], + ) + log2 = self.create_channel_log( + ChannelLog.LOG_TYPE_MSG_STATUS, + http_logs=[{"url": "https://foo.bar/send2"}], + errors=[], + ) + + self.assertEqual([], ChannelLog.get_logs([])) + + logs = ChannelLog.get_logs([log1.uuid, log2.uuid]) + self.assertEqual(2, len(logs)) + self.assertEqual(log1.uuid, logs[0]["uuid"]) + self.assertEqual(ChannelLog.LOG_TYPE_MSG_SEND, logs[0]["type"]) + self.assertEqual(log2.uuid, logs[1]["uuid"]) + self.assertEqual(ChannelLog.LOG_TYPE_MSG_STATUS, logs[1]["type"]) + def test_get_display(self): channel = self.create_channel("TG", "Telegram", "mybot") contact = self.create_contact("Fred Jones", urns=["telegram:74747474"]) From c87e9d243a1becea0e710061ddb1730a67ac9ddf Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 17 Sep 2024 19:04:14 +0000 Subject: [PATCH 072/557] Switch flow starting blocker to warning --- temba/flows/tests.py | 4 ++-- temba/flows/views.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 5f483d94ac5..66437b00b72 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2381,9 +2381,9 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): self.assertEqual( [ - "A flow is already starting. You will need to wait until that process completes before starting another one." + "A flow is already starting. To avoid confusion, make sure you are not targeting the same contacts before continuing." ], - response.json()["blockers"], + response.json()["warnings"], ) ivr_flow = self.create_flow("IVR Test", flow_type=Flow.TYPE_VOICE) diff --git a/temba/flows/views.py b/temba/flows/views.py index 0e0de8b27e0..e86b535a195 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1507,9 +1507,6 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): permission = "flows.flow_start" blockers = { - "already_starting": _( - "A flow is already starting. You will need to wait until that process completes before starting another one." - ), "no_send_channel": _( 'To start this flow you need to add a channel to your workspace which will allow ' "you to send messages to your contacts." @@ -1528,6 +1525,9 @@ class PreviewStart(OrgObjPermsMixin, SmartReadView): } warnings = { + "already_starting": _( + "A flow is already starting. To avoid confusion, make sure you are not targeting the same contacts before continuing." + ), "no_templates": _( "This flow does not use message templates. You may still start this flow but WhatsApp contacts who " "have not sent an incoming message in the last 24 hours may not receive it." @@ -1547,8 +1547,6 @@ def get_blockers(self, flow, send_time) -> list: blockers.append(Org.BLOCKER_SUSPENDED) elif flow.org.is_flagged: blockers.append(Org.BLOCKER_FLAGGED) - elif flow.org.is_flow_starting(): - blockers.append(self.blockers["already_starting"]) hours = send_time / timedelta(hours=1) if settings.SEND_HOURS_BLOCK and hours >= settings.SEND_HOURS_BLOCK: @@ -1586,6 +1584,10 @@ def get_warnings(self, flow, query, send_time) -> list: ) elif not template.is_approved(): warnings.append(_(f"Your message template {template.name} is not approved and cannot be sent.")) + + if flow.org.is_flow_starting(): + warnings.append(self.warnings["already_starting"]) + return warnings def post(self, request, *args, **kwargs): From e5b89adae90af60cd37e4338cea4bef97fede15d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 19:00:19 +0000 Subject: [PATCH 073/557] Add INTERRUPTED as a status for flow starts --- ..._by_alter_flowstart_created_by_and_more.py | 39 +++++++++++++++++++ temba/flows/models.py | 22 ++++++++--- temba/flows/tests.py | 14 +++++++ templates/flows/flowstart_list.html | 8 ++-- 4 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 temba/flows/migrations/0335_flowstart_modified_by_alter_flowstart_created_by_and_more.py diff --git a/temba/flows/migrations/0335_flowstart_modified_by_alter_flowstart_created_by_and_more.py b/temba/flows/migrations/0335_flowstart_modified_by_alter_flowstart_created_by_and_more.py new file mode 100644 index 00000000000..5bf695d2f2a --- /dev/null +++ b/temba/flows/migrations/0335_flowstart_modified_by_alter_flowstart_created_by_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1 on 2024-09-17 19:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0334_remove_flowrun_submitted_by"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="flowstart", + name="modified_by", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="flowstart", + name="created_by", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="flowstart", + name="status", + field=models.CharField( + choices=[("P", "Pending"), ("S", "Starting"), ("C", "Complete"), ("F", "Failed"), ("I", "Interrupted")], + default="P", + max_length=1, + ), + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index 069c8e0d9f2..768776449af 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1795,11 +1795,13 @@ class FlowStart(models.Model): STATUS_STARTING = "S" STATUS_COMPLETE = "C" STATUS_FAILED = "F" + STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( (STATUS_PENDING, _("Pending")), (STATUS_STARTING, _("Starting")), (STATUS_COMPLETE, _("Complete")), (STATUS_FAILED, _("Failed")), + (STATUS_INTERRUPTED, _("Interrupted")), ) TYPE_MANUAL = "M" @@ -1828,7 +1830,7 @@ class FlowStart(models.Model): query = models.TextField(null=True) exclusions = models.JSONField(default=dict, null=True) - # the number of de-duped contacts that might be started, depending on options above + # number of contacts that will be started, only set when status becomes STARTING contact_count = models.IntegerField(default=0, null=True) campaign_event = models.ForeignKey( @@ -1840,10 +1842,9 @@ class FlowStart(models.Model): parent_summary = models.JSONField(null=True) session_history = models.JSONField(null=True) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, related_name="flow_starts" - ) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, related_name="+") created_on = models.DateTimeField(default=timezone.now) + modified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, related_name="+") modified_on = models.DateTimeField(default=timezone.now) @classmethod @@ -1897,6 +1898,15 @@ def is_starting(self): def async_start(self): on_transaction_commit(lambda: mailroom.queue_flow_start(self)) + def interrupt(self, user): + """ + Interrupts this flow start + """ + + self.status = self.STATUS_INTERRUPTED + self.modified_by = user + self.save(update_fields=("status", "modified_by", "modified_on")) + def delete(self): """ Deletes this flow start - called during org deletion or trimming task. @@ -1911,8 +1921,8 @@ def delete(self): super().delete() - def __str__(self): # pragma: no cover - return f"FlowStart[id={self.id}, flow={self.flow.uuid}]" + def __repr__(self): + return f'' class Meta: indexes = [ diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 5f483d94ac5..d0b12913ea8 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -5244,6 +5244,20 @@ def test_session_json(self): class FlowStartTest(TembaTest): + def test_model(self): + flow = self.create_flow("Test Flow") + contact = self.create_contact("Bob", phone="+1234567890") + start = FlowStart.create(flow, self.admin, contacts=[contact]) + + self.assertEqual(f'', repr(start)) + + start.interrupt(self.editor) + + start.refresh_from_db() + self.assertEqual(FlowStart.STATUS_INTERRUPTED, start.status) + self.assertEqual(self.editor, start.modified_by) + self.assertIsNotNone(self.admin, start.modified_on) + @mock_mailroom def test_preview(self, mr_mocks): flow = self.create_flow("Test") diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index 9897804dba3..8b8be5dab4d 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -57,12 +57,14 @@
{{ obj.created_on|timedate }}
{% if obj.status == 'F' %} - Failed + {% trans "Failed" %} + {% elif obj.status == 'I' %} + {% trans "Interrupted" %} {% else %} {% if obj.is_starting %} - Starting + {% trans "Starting" %} {% else %} - Started + {% trans "Started" %} {% endif %} {% blocktrans trimmed with count=obj.contact_count|intcomma count counter=obj.contact_count %} {{ count }} contact From 6505896fd8d4f5187888fa9abfadbc53c9168df5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Sep 2024 16:29:04 -0500 Subject: [PATCH 074/557] Update CHANGELOG.md for v9.3.40 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba3acf2f2f..fbd783ad140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.40 (2024-09-17) +------------------------- + * Add INTERRUPTED as a status for flow starts + * Switch flow starting blocker to warning + v9.3.39 (2024-09-17) ------------------------- * Show bad import file error as validation errors to the user diff --git a/pyproject.toml b/pyproject.toml index 65b772823ab..e854b0744de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.39" +version = "9.3.40" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a93eca0a007..98e5baeceef 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.39" +__version__ = "9.3.40" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 48cf2bf752127c342c82ea3babf19510e7285964 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 17 Sep 2024 23:10:32 +0000 Subject: [PATCH 075/557] Limit SetRunResult category length in editor --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 070b2b10a10..282b12f4766 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ] }, "dependencies": { - "@nyaruka/flow-editor": "1.35.1", + "@nyaruka/flow-editor": "1.35.2", "@nyaruka/temba-components": "0.107.1", "codemirror": "5.18.2", "colorette": "1.2.2", diff --git a/yarn.lock b/yarn.lock index 0023cdd4da3..1a9514c391b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -75,10 +75,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@nyaruka/flow-editor@1.35.1": - version "1.35.1" - resolved "https://registry.yarnpkg.com/@nyaruka/flow-editor/-/flow-editor-1.35.1.tgz#1a443252a2c974e2afe231f626cd998c4d558114" - integrity sha512-0DIgyiTt30wqfCPrZB9v5mIki+uhfCe9prL0hfhXaX1nUclUqQ+9Xl4PMTnQJTMnv+jLFGNir9t3k5EC63PyHQ== +"@nyaruka/flow-editor@1.35.2": + version "1.35.2" + resolved "https://registry.yarnpkg.com/@nyaruka/flow-editor/-/flow-editor-1.35.2.tgz#acd424d8ff7f5c973ec95230d6a3e6039cc4df80" + integrity sha512-QDrpXsyXyVvWx9WQ3bBH81NsQOuG69AZ1b9Lljy0xkpgMBY/NvAMLrO1DSX+AVZtL4/uY7Yz7FbRgUOJF5qyFw== dependencies: "@nyaruka/temba-components" "0.101.0" react "^16.8.6" From d6cbd6efea424003d1adb2381de436813a1375f8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 18 Sep 2024 08:53:19 -0500 Subject: [PATCH 076/557] Update CHANGELOG.md for v9.3.41 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd783ad140..67f44e408f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.41 (2024-09-18) +------------------------- + * Limit SetRunResult category length in editor + * Add --testing argument to migrate_dynamo command + * Start reading attached channel logs from DynamoDB instead of S3 + v9.3.40 (2024-09-17) ------------------------- * Add INTERRUPTED as a status for flow starts diff --git a/pyproject.toml b/pyproject.toml index e854b0744de..c7e486c55f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.40" +version = "9.3.41" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 98e5baeceef..271fb60bfdd 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.40" +__version__ = "9.3.41" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 53d25bbc2ed611fe2d8fee39309974394bf539f6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 18 Sep 2024 09:09:45 -0500 Subject: [PATCH 077/557] Remove old fix_flows command --- temba/flows/management/commands/fix_flows.py | 78 -------------------- 1 file changed, 78 deletions(-) delete mode 100644 temba/flows/management/commands/fix_flows.py diff --git a/temba/flows/management/commands/fix_flows.py b/temba/flows/management/commands/fix_flows.py deleted file mode 100644 index ecba792f092..00000000000 --- a/temba/flows/management/commands/fix_flows.py +++ /dev/null @@ -1,78 +0,0 @@ -from copy import deepcopy -from difflib import unified_diff - -from django.core.management.base import BaseCommand, CommandError - -from temba.orgs.models import Org -from temba.utils import json - - -def remove_invalid_translations(definition: dict): - """ - Removes translations of things that users shouldn't be able to localize and can't from the editor - """ - localization = definition.get("localization", {}) - ui_nodes = definition.get("_ui", {}).get("nodes", {}) - - def remove_from_localization(item_uuid: str, key: str): - for lang, trans in localization.items(): - item_trans = trans.get(item_uuid) - if item_trans and key in item_trans: - del item_trans[key] - - for node in definition.get("nodes", []): - ui_node_type = ui_nodes.get(node["uuid"], {}).get("type") - if ui_node_type in ("split_by_webhook", "split_by_subflow"): - for category in node["router"]["categories"]: - remove_from_localization(category["uuid"], "name") - for caze in node["router"]["cases"]: - remove_from_localization(caze["uuid"], "arguments") - - -fixers = [ - remove_invalid_translations, -] - - -class Command(BaseCommand): - help = "Fixes problems in flows" - - def add_arguments(self, parser): - parser.add_argument(type=int, action="store", dest="org_id", help="ID of org to fix flows for") - parser.add_argument("--preview", action="store_true", dest="preview", help="Just preview changes") - - def handle(self, org_id: int, preview: bool, *args, **options): - org = Org.objects.filter(id=org_id).first() - if not org: - raise CommandError(f"no such org with id {org_id}") - - self.stdout.write(f"Fixing flows for org '{org.name}'...") - - num_fixed = 0 - for flow in org.flows.filter(is_active=True): - if self.fix_flow(flow, preview): - num_fixed += 1 - - self.stdout.write(f" > fixed {num_fixed} flows") - - def fix_flow(self, flow, preview: bool) -> bool: - original = flow.get_definition() - definition = deepcopy(original) - - for fixer in fixers: - fixer(definition) - - old_lines = json.dumps(original, indent=2).splitlines(keepends=True) - new_lines = json.dumps(definition, indent=2).splitlines(keepends=True) - diff_lines = list(unified_diff(old_lines, new_lines, fromfile="original", tofile="fixed")) - - if diff_lines: - for line in diff_lines: - self.stdout.write(line, ending="") - - if not preview: - new_rev, issues = flow.save_revision(None, definition) - self.stdout.write(f" > new revision ({new_rev.revision}) saved for flow '{flow.name}'") - return True - else: - return False From 147f31c34e7dad92a81c60e28a1691fe378c3ffb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 18 Sep 2024 15:31:36 +0000 Subject: [PATCH 078/557] Cleanup how we read and anonymize channel logs --- temba/channels/models.py | 129 ++++++++++++------------ temba/channels/tests.py | 16 +-- temba/channels/views.py | 4 +- temba/ivr/models.py | 2 +- temba/msgs/models.py | 2 +- templates/channels/channellog_list.html | 6 +- 6 files changed, 79 insertions(+), 80 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index e55709b9160..90e9c657457 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -895,97 +895,96 @@ class ChannelLog(models.Model): elapsed_ms = models.IntegerField(default=0) created_on = models.DateTimeField(default=timezone.now) + @classmethod + def get_by_uuid(cls, channel, uuids: list) -> list: + """ + Get logs from DynamoDB and converts them to non-persistent instances of this class + """ + if not uuids: + return [] + + client = dynamo.get_client() + resp = client.batch_get_item( + RequestItems={dynamo.table_name(cls.DYNAMO_TABLE): {"Keys": [{"UUID": {"S": str(u)}} for u in uuids]}} + ) + + logs = [] + for log in resp["Responses"][dynamo.table_name(cls.DYNAMO_TABLE)]: + data = dynamo.load_jsongz(log["DataGZ"]["B"]) + logs.append( + ChannelLog( + uuid=log["UUID"]["S"], + channel=channel, + log_type=log["Type"]["S"], + http_logs=data["http_logs"], + errors=data["errors"], + elapsed_ms=int(log["ElapsedMS"]["N"]), + created_on=datetime.fromtimestamp(int(log["CreatedOn"]["N"]), tz=tzone.utc), + ) + ) + + return sorted(logs, key=lambda l: l.uuid) + def get_display(self, *, anonymize: bool, urn) -> dict: - return self.display(self._get_json(), anonymize=anonymize, channel=self.channel, urn=urn) + """ + Gets a dict representation of this log for display that is optionally anonymized + """ - @classmethod - def display(cls, data: dict, *, anonymize: bool, channel, urn) -> dict: # add reference URLs to errors - for err in data["errors"]: + errors = [e.copy() for e in self.errors or []] + for err in errors: ext_code = err.get("ext_code") - err["ref_url"] = channel.type.get_error_ref_url(channel, ext_code) if ext_code else None + err["ref_url"] = self.channel.type.get_error_ref_url(self.channel, ext_code) if ext_code else None + + data = { + "uuid": str(self.uuid), + "type": self.log_type, + "http_logs": [h.copy() for h in self.http_logs or []], + "errors": errors, + "elapsed_ms": self.elapsed_ms, + "created_on": self.created_on.isoformat(), + } if anonymize: - cls._anonymize(data, channel, urn) + self._anonymize(data, urn) # out of an abundance of caution, check that we're not returning one of our own credential values for log in data["http_logs"]: - for secret in channel.type.get_redact_values(channel): + for secret in self.channel.type.get_redact_values(self.channel): assert secret not in log["url"] and secret not in log["request"] and secret not in log["response"] return data - @classmethod - def _anonymize_value(cls, original: str, urn, redact_keys=()) -> str: + def _anonymize(self, data: dict, urn): + request_keys = self.channel.type.redact_request_keys + response_keys = self.channel.type.redact_response_keys + + for http_log in data["http_logs"]: + http_log["url"] = self._anonymize_value(http_log["url"], urn) + http_log["request"] = self._anonymize_value(http_log["request"], urn, redact_keys=request_keys) + http_log["response"] = self._anonymize_value(http_log.get("response", ""), urn, redact_keys=response_keys) + + for err in data["errors"]: + err["message"] = self._anonymize_value(err["message"], urn) + + def _anonymize_value(self, original: str, urn, redact_keys=()) -> str: # if log doesn't have an associated URN then we don't know what to anonymize, so redact completely if not original: return "" if not urn: - return original[:10] + cls.REDACT_MASK + return original[:10] + self.REDACT_MASK if redact_keys: - redacted = redact.http_trace(original, urn.path, cls.REDACT_MASK, redact_keys) + redacted = redact.http_trace(original, urn.path, self.REDACT_MASK, redact_keys) else: - redacted = redact.text(original, urn.path, cls.REDACT_MASK) + redacted = redact.text(original, urn.path, self.REDACT_MASK) # if nothing was redacted, don't risk returning sensitive information we didn't find if original == redacted and original: - return original[:10] + cls.REDACT_MASK + return original[:10] + self.REDACT_MASK return redacted - @classmethod - def _anonymize(cls, data: dict, channel, urn): - request_keys = channel.type.redact_request_keys - response_keys = channel.type.redact_response_keys - - for http_log in data["http_logs"]: - http_log["url"] = cls._anonymize_value(http_log["url"], urn) - http_log["request"] = cls._anonymize_value(http_log["request"], urn, redact_keys=request_keys) - http_log["response"] = cls._anonymize_value(http_log.get("response", ""), urn, redact_keys=response_keys) - - for err in data["errors"]: - err["message"] = cls._anonymize_value(err["message"], urn) - - @classmethod - def get_logs(cls, uuids: list) -> list: - if not uuids: - return [] - - client = dynamo.get_client() - resp = client.batch_get_item( - RequestItems={dynamo.table_name(cls.DYNAMO_TABLE): {"Keys": [{"UUID": {"S": str(u)}} for u in uuids]}} - ) - - logs = [] - for log in resp["Responses"][dynamo.table_name(cls.DYNAMO_TABLE)]: - data = dynamo.load_jsongz(log["DataGZ"]["B"]) - logs.append( - { - "uuid": log["UUID"]["S"], - "type": log["Type"]["S"], - "http_logs": data["http_logs"], - "errors": data["errors"], - "elapsed_ms": int(log["ElapsedMS"]["N"]), - "created_on": datetime.fromtimestamp(int(log["CreatedOn"]["N"]), tz=tzone.utc), - } - ) - - return sorted(logs, key=lambda l: l["uuid"]) - - def _get_json(self): - """ - Get a database instance in the same JSON format we write to S3 - """ - return { - "uuid": str(self.uuid), - "type": self.log_type, - "http_logs": [h.copy() for h in self.http_logs or []], - "errors": [e.copy() for e in self.errors or []], - "elapsed_ms": self.elapsed_ms, - "created_on": self.created_on.isoformat(), - } - class Meta: indexes = [models.Index(name="channellogs_by_channel", fields=("channel", "-created_on"))] diff --git a/temba/channels/tests.py b/temba/channels/tests.py index fdef77e3350..d0bc74a7a86 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -1338,7 +1338,7 @@ def test_trim_task(self): class ChannelLogTest(TembaTest): - def test_get_logs(self): + def test_get_by_uuid(self): log1 = self.create_channel_log( ChannelLog.LOG_TYPE_MSG_SEND, http_logs=[{"url": "https://foo.bar/send1"}], @@ -1350,14 +1350,16 @@ def test_get_logs(self): errors=[], ) - self.assertEqual([], ChannelLog.get_logs([])) + self.assertEqual([], ChannelLog.get_by_uuid(self.channel, [])) - logs = ChannelLog.get_logs([log1.uuid, log2.uuid]) + logs = ChannelLog.get_by_uuid(self.channel, [log1.uuid, log2.uuid]) self.assertEqual(2, len(logs)) - self.assertEqual(log1.uuid, logs[0]["uuid"]) - self.assertEqual(ChannelLog.LOG_TYPE_MSG_SEND, logs[0]["type"]) - self.assertEqual(log2.uuid, logs[1]["uuid"]) - self.assertEqual(ChannelLog.LOG_TYPE_MSG_STATUS, logs[1]["type"]) + self.assertEqual(log1.uuid, logs[0].uuid) + self.assertEqual(self.channel, logs[0].channel) + self.assertEqual(ChannelLog.LOG_TYPE_MSG_SEND, logs[0].log_type) + self.assertEqual(log2.uuid, logs[1].uuid) + self.assertEqual(self.channel, logs[1].channel) + self.assertEqual(ChannelLog.LOG_TYPE_MSG_STATUS, logs[1].log_type) def test_get_display(self): channel = self.create_channel("TG", "Telegram", "mybot") diff --git a/temba/channels/views.py b/temba/channels/views.py index c738484458f..10bc5b49249 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -932,9 +932,7 @@ def get_context_data(self, **kwargs): anonymize = self.request.org.is_anon and not (self.request.GET.get("break") and self.request.user.is_staff) logs = [] for log in self.owner.get_logs(): - logs.append( - ChannelLog.display(log, anonymize=anonymize, channel=self.owner.channel, urn=self.owner.contact_urn) - ) + logs.append(log.get_display(anonymize=anonymize, urn=self.owner.contact_urn)) context["logs"] = logs return context diff --git a/temba/ivr/models.py b/temba/ivr/models.py index 2d557c5e70f..f675288dd25 100644 --- a/temba/ivr/models.py +++ b/temba/ivr/models.py @@ -104,7 +104,7 @@ def get_session(self): return None def get_logs(self) -> list: - return ChannelLog.get_logs(self.log_uuids or []) + return ChannelLog.get_by_uuid(self.channel, self.log_uuids or []) def release(self): session = self.get_session() diff --git a/temba/msgs/models.py b/temba/msgs/models.py index ad2f7d49246..4cb393c4dfa 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -577,7 +577,7 @@ def get_attachments(self): return Attachment.parse_all(self.attachments) def get_logs(self) -> list: - return ChannelLog.get_logs(self.log_uuids or []) + return ChannelLog.get_by_uuid(self.channel, self.log_uuids or []) def handle(self): # pragma: no cover """ diff --git a/templates/channels/channellog_list.html b/templates/channels/channellog_list.html index e62324c02af..3fb104dabc1 100644 --- a/templates/channels/channellog_list.html +++ b/templates/channels/channellog_list.html @@ -14,9 +14,9 @@ {% endblock extra-style %} {% block content %}
- {% blocktrans trimmed %} + {% blocktrans trimmed with days=7 %} These are the logs which have no associated message or call. To access logs for a specific message or call, use the - link from the message or call itself. Logs are kept for 14 days. + link from the message or call itself. Logs are kept for {{ days }} days. {% endblocktrans %}
{% include "includes/short_pagination.html" %}
@@ -31,7 +31,7 @@
{{ obj.elapsed_ms|intcomma }}ms
-
{{ obj.created_on|datetime }}
+
{{ obj.created_on|timedate }}
{% endfor %}
From 2eab0ce065a2b17e66fdfe6cfbb514be15a3bd2e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 18 Sep 2024 14:43:06 -0500 Subject: [PATCH 079/557] Update CHANGELOG.md for v9.3.42 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f44e408f5..d1a24d4cdbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.42 (2024-09-18) +------------------------- + * Cleanup how we read and anonymize channel logs + v9.3.41 (2024-09-18) ------------------------- * Limit SetRunResult category length in editor diff --git a/pyproject.toml b/pyproject.toml index c7e486c55f9..d01e31389f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.41" +version = "9.3.42" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 271fb60bfdd..710a93b98f4 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.41" +__version__ = "9.3.42" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 4dcb6f2727895a7fb767f8ced3ff917fbc8a0969 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 19 Sep 2024 16:12:17 +0000 Subject: [PATCH 080/557] Put starts before webhooks on flow history menu --- temba/flows/tests.py | 2 +- temba/flows/views.py | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index e2edaf7418b..cb2d7d1bb2f 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -1469,7 +1469,7 @@ def test_menu(self): "Active", "Archived", "Globals", - ("History", ["Webhooks", "Flow Starts"]), + ("History", ["Starts", "Webhooks"]), ("Labels", ["Important (0)"]), ], ) diff --git a/temba/flows/views.py b/temba/flows/views.py index e86b535a195..9bc2afdeee8 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -245,6 +245,10 @@ def derive_menu(self): ) history_items = [] + if self.has_org_perm("flows.flowstart_list"): + history_items.append( + self.create_menu_item(menu_id="starts", name=_("Starts"), href=reverse("flows.flowstart_list")) + ) if self.has_org_perm("request_logs.httplog_webhooks"): history_items.append( self.create_menu_item( @@ -252,19 +256,8 @@ def derive_menu(self): ) ) - if self.has_org_perm("flows.flowstart_list"): - history_items.append( - self.create_menu_item(menu_id="starts", name=_("Flow Starts"), href=reverse("flows.flowstart_list")) - ) - if history_items: - menu.append( - self.create_menu_item( - name=_("History"), - items=history_items, - inline=True, - ) - ) + menu.append(self.create_menu_item(name=_("History"), items=history_items, inline=True)) if label_items: menu.append(self.create_menu_item(name=_("Labels"), items=label_items, inline=True)) From 200fd5ecf0231299629f18225dfdb4ebe48a344b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 19 Sep 2024 21:37:08 +0000 Subject: [PATCH 081/557] Remove broadcasts from Outbox now that they have their own page --- ...remove_broadcast_msgs_broadcasts_queued.py | 17 ++++++++++ temba/msgs/models.py | 13 -------- temba/msgs/tests.py | 13 -------- temba/msgs/views.py | 14 +-------- templates/msgs/msg_outbox.html | 31 +------------------ 5 files changed, 19 insertions(+), 69 deletions(-) create mode 100644 temba/msgs/migrations/0270_remove_broadcast_msgs_broadcasts_queued.py diff --git a/temba/msgs/migrations/0270_remove_broadcast_msgs_broadcasts_queued.py b/temba/msgs/migrations/0270_remove_broadcast_msgs_broadcasts_queued.py new file mode 100644 index 00000000000..f24b4e1e909 --- /dev/null +++ b/temba/msgs/migrations/0270_remove_broadcast_msgs_broadcasts_queued.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1 on 2024-09-19 21:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0269_msg_msgs_outgoing_android_to_fail"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="broadcast", + name="msgs_broadcasts_queued", + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 4cb393c4dfa..f756c6307aa 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -257,13 +257,6 @@ def create( schedule=schedule, ) - @classmethod - def get_queued(cls, org): - """ - Gets the queued broadcasts which will be prepended to the Outbox - """ - return org.broadcasts.filter(status=cls.STATUS_QUEUED, schedule=None, is_active=True) - @classmethod def preview(cls, org, *, include: mailroom.Inclusions, exclude: mailroom.Exclusions) -> tuple[str, int]: """ @@ -359,12 +352,6 @@ class Meta: fields=["org", "-created_on"], condition=Q(schedule__isnull=False, is_active=True), ), - # used to fetch queued broadcasts for the Outbox - models.Index( - name="msgs_broadcasts_queued", - fields=["org", "-created_on"], - condition=Q(schedule__isnull=True, status="Q", is_active=True), - ), ] diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index ec17ce085a0..dfcfa49db28 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -669,21 +669,8 @@ def test_outbox(self): ) msg4, msg3, msg2 = broadcast2.msgs.order_by("-id") - broadcast3 = self.create_broadcast( - self.admin, {"eng": {"text": "Pending broadcast"}}, contacts=[contact4], status="Q" - ) - self.create_broadcast( - self.admin, - {"eng": {"text": "Scheduled broadcast"}}, - contacts=[contact4], - schedule=Schedule.create(self.org, timezone.now(), Schedule.REPEAT_DAILY), - ) - response = self.assertListFetch(outbox_url, [self.admin], context_objects=[msg4, msg3, msg2, msg1]) - # should see queued broadcast but not the scheduled one - self.assertEqual([broadcast3], list(response.context_data["queued_broadcasts"])) - response = self.client.get(outbox_url + "?search=kevin") self.assertEqual([Msg.objects.get(contact=contact4)], list(response.context_data["object_list"])) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index a7fee27d5dd..f9bfe7dcd39 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -772,7 +772,7 @@ def derive_menu(self): menu_id="outbox", name=_("Outbox"), href=reverse("msgs.msg_outbox"), - count=counts[SystemLabel.TYPE_OUTBOX] + Broadcast.get_queued(org).count(), + count=counts[SystemLabel.TYPE_OUTBOX], ), self.create_menu_item( menu_id="sent", @@ -934,18 +934,6 @@ class Outbox(MsgListView): bulk_actions = () allow_export = True - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # stuff in any queued broadcasts - context["queued_broadcasts"] = ( - Broadcast.get_queued(self.request.org) - .select_related("org") - .prefetch_related("groups", "contacts") - .order_by("-created_on") - ) - return context - def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).select_related("contact", "channel", "flow") diff --git a/templates/msgs/msg_outbox.html b/templates/msgs/msg_outbox.html index 70c937ea93a..ecf20771e6e 100644 --- a/templates/msgs/msg_outbox.html +++ b/templates/msgs/msg_outbox.html @@ -7,35 +7,6 @@ {% block message-list %} - {% for broadcast in queued_broadcasts %} - {% with translation=broadcast.get_translation %} - - - - - - - {% endwith %} - {% endfor %} {% for object in object_list %} {% endfor %} - {% if not object_list and not queued_broadcasts %} + {% if not object_list %} From 01111e3f55e8e733ff006b5518e53670d109e65a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 19 Sep 2024 22:17:01 +0000 Subject: [PATCH 082/557] Add support broadcast status (C)COMPLETED --- temba/api/v2/serializers.py | 1 + temba/api/v2/views.py | 2 +- .../migrations/0271_alter_broadcast_status.py | 20 +++++++++++++++++++ temba/msgs/models.py | 10 ++++++++-- 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 temba/msgs/migrations/0271_alter_broadcast_status.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index e5fe13bdaad..18d9d8751e6 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -168,6 +168,7 @@ class BroadcastReadSerializer(ReadSerializer): "I": "queued", # may exist in older data Broadcast.STATUS_QUEUED: "queued", Broadcast.STATUS_SENT: "sent", + Broadcast.STATUS_COMPLETED: "completed", Broadcast.STATUS_FAILED: "failed", } diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 05b0cfc1ab4..018d13f114e 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -504,7 +504,7 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **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`. + * **status** - the status, one of `queued`, `completed`, `failed`. * **created_on** - when this broadcast was either created (datetime) (filterable as `before` and `after`). Example: diff --git a/temba/msgs/migrations/0271_alter_broadcast_status.py b/temba/msgs/migrations/0271_alter_broadcast_status.py new file mode 100644 index 00000000000..7de7343058c --- /dev/null +++ b/temba/msgs/migrations/0271_alter_broadcast_status.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1 on 2024-09-19 22:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0270_remove_broadcast_msgs_broadcasts_queued"), + ] + + operations = [ + migrations.AlterField( + model_name="broadcast", + name="status", + field=models.CharField( + choices=[("Q", "Queued"), ("S", "Sent"), ("C", "Completed"), ("F", "Failed")], default="Q", max_length=1 + ), + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index f756c6307aa..40113b3e4a3 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -185,9 +185,15 @@ class Broadcast(models.Model): """ STATUS_QUEUED = "Q" - STATUS_SENT = "S" + STATUS_SENT = "S" # deprecated + STATUS_COMPLETED = "C" STATUS_FAILED = "F" - STATUS_CHOICES = ((STATUS_QUEUED, "Queued"), (STATUS_SENT, "Sent"), (STATUS_FAILED, "Failed")) + STATUS_CHOICES = ( + (STATUS_QUEUED, "Queued"), + (STATUS_SENT, "Sent"), + (STATUS_COMPLETED, "Completed"), + (STATUS_FAILED, "Failed"), + ) org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="broadcasts") From d8ad831e0102a5b4d366e55abd294137ebcab9d4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 19 Sep 2024 17:41:05 -0500 Subject: [PATCH 083/557] Update CHANGELOG.md for v9.3.43 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a24d4cdbd..f6a293afa5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.43 (2024-09-19) +------------------------- + * Add support broadcast status (C)COMPLETED + * Remove broadcasts from Outbox now that they have their own page + * Put starts before webhooks on flow history menu + v9.3.42 (2024-09-18) ------------------------- * Cleanup how we read and anonymize channel logs diff --git a/pyproject.toml b/pyproject.toml index d01e31389f8..efb37c79b4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.42" +version = "9.3.43" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 710a93b98f4..8ba2301de3d 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.42" +__version__ = "9.3.43" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 9f59e4904a6beef463b7f7279221657200cf64a5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 20 Sep 2024 16:40:34 +0000 Subject: [PATCH 084/557] Replace broadcast status S with C --- temba/api/v2/serializers.py | 5 +-- temba/api/v2/tests.py | 2 +- temba/api/v2/views.py | 2 +- temba/flows/models.py | 10 ++--- .../migrations/0272_fix_broadcast_statuses.py | 37 +++++++++++++++++++ temba/msgs/models.py | 4 +- temba/msgs/tests.py | 2 +- temba/tests/base.py | 2 +- 8 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 temba/msgs/migrations/0272_fix_broadcast_statuses.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 18d9d8751e6..0dd45e3a5a9 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -165,11 +165,10 @@ class Meta: class BroadcastReadSerializer(ReadSerializer): STATUSES = { - "I": "queued", # may exist in older data Broadcast.STATUS_QUEUED: "queued", - Broadcast.STATUS_SENT: "sent", Broadcast.STATUS_COMPLETED: "completed", Broadcast.STATUS_FAILED: "failed", + Broadcast.STATUS_INTERRUPTED: "interrupted", } urns = serializers.SerializerMethodField() @@ -188,7 +187,7 @@ 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") + return self.STATUSES[obj.status] def get_urns(self, obj): if self.context["org"].is_anon: diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 349d6c7744f..a05aa65e6f3 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -968,7 +968,7 @@ def test_broadcasts(self, mr_mocks): bcast1 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 1"}}, urns=["twitter:franky"], status="Q") bcast2 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 2"}}, contacts=[self.joe], status="Q") - bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 3"}}, contacts=[self.frank], status="S") + bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 3"}}, contacts=[self.frank], status="C") bcast4 = self.create_broadcast( self.admin, {"eng": {"text": "Hello 4"}}, diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 018d13f114e..334b848eb4d 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -504,7 +504,7 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **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, one of `queued`, `completed`, `failed`. + * **status** - the status, one of `queued`, `completed`, `failed`, `interrupted`. * **created_on** - when this broadcast was either created (datetime) (filterable as `before` and `after`). Example: diff --git a/temba/flows/models.py b/temba/flows/models.py index 768776449af..266f511baf4 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1797,11 +1797,11 @@ class FlowStart(models.Model): STATUS_FAILED = "F" STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( - (STATUS_PENDING, _("Pending")), - (STATUS_STARTING, _("Starting")), - (STATUS_COMPLETE, _("Complete")), - (STATUS_FAILED, _("Failed")), - (STATUS_INTERRUPTED, _("Interrupted")), + (STATUS_PENDING, "Pending"), + (STATUS_STARTING, "Starting"), + (STATUS_COMPLETE, "Complete"), + (STATUS_FAILED, "Failed"), + (STATUS_INTERRUPTED, "Interrupted"), ) TYPE_MANUAL = "M" diff --git a/temba/msgs/migrations/0272_fix_broadcast_statuses.py b/temba/msgs/migrations/0272_fix_broadcast_statuses.py new file mode 100644 index 00000000000..a354aa69a3d --- /dev/null +++ b/temba/msgs/migrations/0272_fix_broadcast_statuses.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1 on 2024-09-20 16:27 + +from django.db import migrations, models + + +def fix_broadcast_statuses(apps, schema_editor): # pragma: no cover + Broadcast = apps.get_model("msgs", "Broadcast") + num_updated = 0 + + while True: + batch_ids = Broadcast.objects.filter(status="S").values_list("id", flat=True)[:5000] + if not batch_ids: + break + + Broadcast.objects.filter(id__in=batch_ids).update(status="C") + num_updated += len(batch_ids) + + if num_updated: + print(f"Updated {num_updated} broadcasts from status=S to status=C") + + +class Migration(migrations.Migration): + + dependencies = [("msgs", "0271_alter_broadcast_status")] + + operations = [ + migrations.RunPython(fix_broadcast_statuses, migrations.RunPython.noop), + migrations.AlterField( + model_name="broadcast", + name="status", + field=models.CharField( + choices=[("Q", "Queued"), ("C", "Completed"), ("F", "Failed"), ("I", "Interrupted")], + default="Q", + max_length=1, + ), + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 40113b3e4a3..88d0718f958 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -185,14 +185,14 @@ class Broadcast(models.Model): """ STATUS_QUEUED = "Q" - STATUS_SENT = "S" # deprecated STATUS_COMPLETED = "C" STATUS_FAILED = "F" + STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( (STATUS_QUEUED, "Queued"), - (STATUS_SENT, "Sent"), (STATUS_COMPLETED, "Completed"), (STATUS_FAILED, "Failed"), + (STATUS_INTERRUPTED, "Interrupted"), ) org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="broadcasts") diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index dfcfa49db28..58ba5fe0619 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -640,7 +640,7 @@ def test_outbox(self): self.admin, {"eng": {"text": "How is it going?"}}, contacts=[contact1], - status=Broadcast.STATUS_SENT, + status=Broadcast.STATUS_COMPLETED, msg_status=Msg.STATUS_INITIALIZING, ) msg1 = broadcast1.msgs.get() diff --git a/temba/tests/base.py b/temba/tests/base.py index d4917af131c..ddeddcbe86e 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -463,7 +463,7 @@ def create_broadcast( urns=(), optin=None, exclude=None, - status=Broadcast.STATUS_SENT, + status=Broadcast.STATUS_COMPLETED, msg_status=Msg.STATUS_SENT, parent=None, schedule=None, From 700cb8809e5d193adcf9bdae835e2cda400ecaf1 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 23 Sep 2024 12:34:45 +0200 Subject: [PATCH 085/557] Validate channel variable in the body for EX channels --- temba/channels/types/external/tests.py | 4 ++-- temba/channels/types/external/views.py | 1 + templates/channels/types/external/claim.html | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/temba/channels/types/external/tests.py b/temba/channels/types/external/tests.py index 27f2fc1a1bc..4589d08b8d2 100644 --- a/temba/channels/types/external/tests.py +++ b/temba/channels/types/external/tests.py @@ -59,7 +59,7 @@ def test_claim(self, mock_socket_hostname): response.context["form"], "body", "Invalid JSON, make sure to remove quotes around variables" ) - post_data["body"] = '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}} }' + post_data["body"] = '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}},"channel":{{channel}} }' response = self.client.post(url, post_data) channel = Channel.objects.get() @@ -74,7 +74,7 @@ def test_claim(self, mock_socket_hostname): self.assertEqual(channel.channel_type, "EX") self.assertEqual(Channel.ENCODING_SMART, channel.config[Channel.CONFIG_ENCODING]) self.assertEqual( - '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}} }', + '{"from":{{from_no_plus}},"to":{{to_no_plus}},"text":{{text}},"channel":{{channel}} }', channel.config[ExternalType.CONFIG_SEND_BODY], ) self.assertEqual("SENT", channel.config[ExternalType.CONFIG_MT_RESPONSE_CHECK]) diff --git a/temba/channels/types/external/views.py b/temba/channels/types/external/views.py index 68f99cb76a8..f4dc8f6430e 100644 --- a/temba/channels/types/external/views.py +++ b/temba/channels/types/external/views.py @@ -114,6 +114,7 @@ def clean(self): "to_no_plus": "", "id": "", "quick_replies": "", + "channel": "", } replaced_body = ExternalType.replace_variables( cleaned_data.get("body"), variables, content_type=content_type diff --git a/templates/channels/types/external/claim.html b/templates/channels/types/external/claim.html index 976bc1509bc..45fa58264ce 100644 --- a/templates/channels/types/external/claim.html +++ b/templates/channels/types/external/claim.html @@ -35,6 +35,10 @@
quick_replies
{% trans "the quick replies for this message, formatted according to send method and content type" %} +
  • +
    channel
    + {% trans "the channel UUID" %} +
  • {% blocktrans trimmed %} From 67b1a1be35b42e0be7a53d8d916edff249117c03 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 10:26:35 -0500 Subject: [PATCH 086/557] Update CHANGELOG.md for v9.3.44 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a293afa5c..1e9de2f60ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.44 (2024-09-23) +------------------------- + * Validate channel variable in the body for EX channels + * Replace broadcast status S with C + v9.3.43 (2024-09-19) ------------------------- * Add support broadcast status (C)COMPLETED diff --git a/pyproject.toml b/pyproject.toml index efb37c79b4f..28743d549eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.43" +version = "9.3.44" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 8ba2301de3d..0aef0767be5 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.43" +__version__ = "9.3.44" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 4c30b5babae5cabfbb9249869db04663edc0f772 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 16:37:55 +0000 Subject: [PATCH 087/557] Add PENDING/STARTED statuses and contact_count field to broadcasts --- temba/api/v2/serializers.py | 4 ++- temba/api/v2/tests.py | 2 +- temba/api/v2/views.py | 2 +- .../0268_trim_old_broadcasts_to_nodes.py | 4 +-- ...st_contact_count_alter_broadcast_status.py | 27 ++++++++++++++ temba/msgs/models.py | 11 ++++-- temba/msgs/tests.py | 36 +++---------------- temba/schedules/tests.py | 2 +- temba/tests/base.py | 2 +- templates/msgs/includes/broadcast.html | 2 +- 10 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 temba/msgs/migrations/0273_broadcast_contact_count_alter_broadcast_status.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 0dd45e3a5a9..2e562265646 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -165,7 +165,9 @@ class Meta: class BroadcastReadSerializer(ReadSerializer): STATUSES = { - Broadcast.STATUS_QUEUED: "queued", + "Q": "pending", + Broadcast.STATUS_PENDING: "pending", + Broadcast.STATUS_STARTED: "started", Broadcast.STATUS_COMPLETED: "completed", Broadcast.STATUS_FAILED: "failed", Broadcast.STATUS_INTERRUPTED: "interrupted", diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index a05aa65e6f3..ca99fc8ecc9 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -1003,7 +1003,7 @@ def test_broadcasts(self, mr_mocks): "text": {"eng": "Hello 2"}, "attachments": {"eng": []}, "base_language": "eng", - "status": "queued", + "status": "pending", "created_on": format_datetime(bcast2.created_on), }, resp_json["results"][2], diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 334b848eb4d..6689bfb44b5 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -504,7 +504,7 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **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, one of `queued`, `completed`, `failed`, `interrupted`. + * **status** - the status, one of `pending`, `started`, `completed`, `failed`, `interrupted`. * **created_on** - when this broadcast was either created (datetime) (filterable as `before` and `after`). Example: diff --git a/temba/msgs/migrations/0268_trim_old_broadcasts_to_nodes.py b/temba/msgs/migrations/0268_trim_old_broadcasts_to_nodes.py index bf7dffad99d..1e957a081fb 100644 --- a/temba/msgs/migrations/0268_trim_old_broadcasts_to_nodes.py +++ b/temba/msgs/migrations/0268_trim_old_broadcasts_to_nodes.py @@ -6,7 +6,7 @@ MAX_CONTACTS = 50 -def trim_old_broadcasts_to_nodes(apps, schema_editor): +def trim_old_broadcasts_to_nodes(apps, schema_editor): # pragma: no cover Broadcast = apps.get_model("msgs", "Broadcast") # find sent broadcasts to a lot of contacts that likely came from sending to a node in a flow @@ -20,7 +20,7 @@ def trim_old_broadcasts_to_nodes(apps, schema_editor): trim_broadcast_to_node(bcast) -def trim_broadcast_to_node(bcast): +def trim_broadcast_to_node(bcast): # pragma: no cover through_model = bcast.contacts.through num_trimmed = 0 diff --git a/temba/msgs/migrations/0273_broadcast_contact_count_alter_broadcast_status.py b/temba/msgs/migrations/0273_broadcast_contact_count_alter_broadcast_status.py new file mode 100644 index 00000000000..97f0c2ba3db --- /dev/null +++ b/temba/msgs/migrations/0273_broadcast_contact_count_alter_broadcast_status.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1 on 2024-09-23 16:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0272_fix_broadcast_statuses"), + ] + + operations = [ + migrations.AddField( + model_name="broadcast", + name="contact_count", + field=models.IntegerField(default=0, null=True), + ), + migrations.AlterField( + model_name="broadcast", + name="status", + field=models.CharField( + choices=[("P", "Pending"), ("S", "Started"), ("C", "Completed"), ("F", "Failed"), ("I", "Interrupted")], + default="P", + max_length=1, + ), + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 88d0718f958..166bf3d947b 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -184,18 +184,21 @@ class Broadcast(models.Model): messages sent from the same bundle together """ - STATUS_QUEUED = "Q" + STATUS_PENDING = "P" + STATUS_STARTED = "S" STATUS_COMPLETED = "C" STATUS_FAILED = "F" STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( - (STATUS_QUEUED, "Queued"), + (STATUS_PENDING, "Pending"), + (STATUS_STARTED, "Started"), (STATUS_COMPLETED, "Completed"), (STATUS_FAILED, "Failed"), (STATUS_INTERRUPTED, "Interrupted"), ) org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="broadcasts") + status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING) # recipients of this broadcast groups = models.ManyToManyField(ContactGroup, related_name="addressed_broadcasts") @@ -205,6 +208,9 @@ class Broadcast(models.Model): node_uuid = models.UUIDField(null=True) exclusions = models.JSONField(default=dict, null=True) + # number of contacts that will be started, only set when status becomes STARTING + contact_count = models.IntegerField(default=0, null=True) + # message content translations = models.JSONField() # text, attachments and quick replies by language base_language = models.CharField(max_length=3) # ISO-639-3 @@ -212,7 +218,6 @@ class Broadcast(models.Model): template = models.ForeignKey("templates.Template", null=True, on_delete=models.PROTECT) template_variables = ArrayField(models.TextField(), null=True) - status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_QUEUED) created_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="broadcast_creations") created_on = models.DateTimeField(default=timezone.now) modified_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="broadcast_modifications") diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 58ba5fe0619..505bbf3c1af 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -30,7 +30,7 @@ from temba.orgs.models import Export from temba.schedules.models import Schedule from temba.templates.models import TemplateTranslation -from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, mock_mailroom, mock_uuids +from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom, mock_uuids from temba.tests.engine import MockSessionWriter from temba.tickets.models import Ticket from temba.utils import s3 @@ -1800,7 +1800,7 @@ def test_model(self, mr_mocks): contacts=[self.kevin, self.lucy], schedule=schedule, ) - self.assertEqual("Q", bcast2.status) + self.assertEqual("P", bcast2.status) self.assertTrue(bcast2.is_active) # create a broadcast that looks like it has been sent @@ -2554,7 +2554,7 @@ def test_scheduled_read(self): self.admin, translations={"eng": {"text": text, "attachments": attachments}}, groups=[self.joe_and_frank], - status=Msg.STATUS_QUEUED, + status=Msg.STATUS_PENDING, parent=broadcast, ) ) @@ -2868,7 +2868,7 @@ def test_get_counts(self): self.create_incoming_msg(contact1, "Message 2") msg3 = self.create_incoming_msg(contact1, "Message 3") msg4 = self.create_incoming_msg(contact1, "Message 4") - self.create_broadcast(self.user, {"eng": {"text": "Broadcast 2"}}, contacts=[contact1, contact2], status="Q") + self.create_broadcast(self.user, {"eng": {"text": "Broadcast 2"}}, contacts=[contact1, contact2], status="P") self.create_broadcast( self.user, {"eng": {"text": "Broadcast 2"}}, @@ -3105,31 +3105,3 @@ def upload(user, path): self.login(self.customer_support, choose_org=self.org) response = self.client.get(list_url) self.assertEqual([media2, media1], list(response.context["object_list"])) - - -class TrimOldBroadcastsToNodesTest(MigrationTest): - app = "msgs" - migrate_from = "0267_broadcast_node_uuid" - migrate_to = "0268_trim_old_broadcasts_to_nodes" - - def setUpBeforeMigration(self, apps): - def create_broadcast(status: str, num_contacts: int): - contacts = [self.create_contact(f"Contact {i}", urns=[]) for i in range(num_contacts)] - bcast = Broadcast.objects.create( - org=self.org, - translations={"und": {"text": "Hi"}}, - base_language="und", - status=status, - created_by=self.admin, - ) - bcast.contacts.add(*contacts) - return bcast - - self.bcast1 = create_broadcast("P", 55) # should be ignored because of status - self.bcast2 = create_broadcast("S", 45) # should be ignored because of contact count - self.bcast3 = create_broadcast("S", 2005) # requires 2 batches of deletes - - def test_migration(self): - self.assertEqual(55, self.bcast1.contacts.count()) - self.assertEqual(45, self.bcast2.contacts.count()) - self.assertEqual(50, self.bcast3.contacts.count()) # trimmed diff --git a/temba/schedules/tests.py b/temba/schedules/tests.py index 947ac0ae8eb..410d2a72f39 100644 --- a/temba/schedules/tests.py +++ b/temba/schedules/tests.py @@ -224,7 +224,7 @@ def test_update_near_day_boundary(self): self.admin, {"eng": {"text": text}}, contacts=[self.joe], - status=Broadcast.STATUS_QUEUED, + status=Broadcast.STATUS_PENDING, schedule=sched, ) diff --git a/temba/tests/base.py b/temba/tests/base.py index ddeddcbe86e..49a3d2788ac 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -506,7 +506,7 @@ def create_broadcast( for group in bcast.groups.all(): contacts.update(group.contacts.all()) - if not schedule and status != Broadcast.STATUS_QUEUED: + if not schedule and status != Broadcast.STATUS_PENDING: for contact in contacts: translation = bcast.get_translation(contact) self._create_msg( diff --git a/templates/msgs/includes/broadcast.html b/templates/msgs/includes/broadcast.html index b9a9ce8905b..94175efdc87 100644 --- a/templates/msgs/includes/broadcast.html +++ b/templates/msgs/includes/broadcast.html @@ -59,7 +59,7 @@ {% if not broadcast.schedule %}
    - {% if broadcast.status == "Q" %} + {% if broadcast.status == "Q" or broadcast.status == "P" or broadcast.status == "S" %} {% endif %} From 1121f5998718f9135527fb85524e5b03fc6ff955 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 13:44:10 -0500 Subject: [PATCH 088/557] Update CHANGELOG.md for v9.3.45 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9de2f60ba..125fbe150a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.45 (2024-09-23) +------------------------- + * Add PENDING/STARTED statuses and contact_count field to broadcasts + v9.3.44 (2024-09-23) ------------------------- * Validate channel variable in the body for EX channels diff --git a/pyproject.toml b/pyproject.toml index 28743d549eb..c6ec01695f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.44" +version = "9.3.45" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 0aef0767be5..7f69ed25439 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.44" +__version__ = "9.3.45" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 9347ff912129ee50b8bc9f6eb197862a381fc0f8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 20:56:24 +0000 Subject: [PATCH 089/557] Add Broadcast.interrupt(user) --- temba/api/v2/serializers.py | 2 +- temba/api/v2/tests.py | 14 ++++++--- temba/flows/tests.py | 2 +- ...274_alter_broadcast_created_by_and_more.py | 30 +++++++++++++++++++ temba/msgs/models.py | 13 ++++++-- temba/msgs/tests.py | 7 +++++ templates/msgs/includes/broadcast.html | 2 +- 7 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 temba/msgs/migrations/0274_alter_broadcast_created_by_and_more.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 2e562265646..0ce28a2598e 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -165,7 +165,6 @@ class Meta: class BroadcastReadSerializer(ReadSerializer): STATUSES = { - "Q": "pending", Broadcast.STATUS_PENDING: "pending", Broadcast.STATUS_STARTED: "started", Broadcast.STATUS_COMPLETED: "completed", @@ -1082,6 +1081,7 @@ class FlowStartReadSerializer(ReadSerializer): FlowStart.STATUS_STARTING: "starting", FlowStart.STATUS_COMPLETE: "complete", FlowStart.STATUS_FAILED: "failed", + FlowStart.STATUS_INTERRUPTED: "interrupted", } flow = fields.FlowField() diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index ca99fc8ecc9..9637959a77a 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -966,16 +966,22 @@ def test_broadcasts(self, mr_mocks): reporters = self.create_group("Reporters", [self.joe, self.frank]) - bcast1 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 1"}}, urns=["twitter:franky"], status="Q") - bcast2 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 2"}}, contacts=[self.joe], status="Q") - bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hello 3"}}, contacts=[self.frank], status="C") + bcast1 = self.create_broadcast( + self.admin, {"eng": {"text": "Hello 1"}}, urns=["twitter:franky"], status=Broadcast.STATUS_PENDING + ) + bcast2 = self.create_broadcast( + self.admin, {"eng": {"text": "Hello 2"}}, contacts=[self.joe], status=Broadcast.STATUS_PENDING + ) + bcast3 = self.create_broadcast( + self.admin, {"eng": {"text": "Hello 3"}}, contacts=[self.frank], status=Broadcast.STATUS_COMPLETED + ) bcast4 = self.create_broadcast( self.admin, {"eng": {"text": "Hello 4"}}, urns=["twitter:franky"], contacts=[self.joe], groups=[reporters], - status="F", + status=Broadcast.STATUS_FAILED, ) self.create_broadcast( self.admin, diff --git a/temba/flows/tests.py b/temba/flows/tests.py index cb2d7d1bb2f..1dcd636b35b 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -5256,7 +5256,7 @@ def test_model(self): start.refresh_from_db() self.assertEqual(FlowStart.STATUS_INTERRUPTED, start.status) self.assertEqual(self.editor, start.modified_by) - self.assertIsNotNone(self.admin, start.modified_on) + self.assertIsNotNone(start.modified_on) @mock_mailroom def test_preview(self, mr_mocks): diff --git a/temba/msgs/migrations/0274_alter_broadcast_created_by_and_more.py b/temba/msgs/migrations/0274_alter_broadcast_created_by_and_more.py new file mode 100644 index 00000000000..29ed1a987ea --- /dev/null +++ b/temba/msgs/migrations/0274_alter_broadcast_created_by_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1 on 2024-09-23 20:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0273_broadcast_contact_count_alter_broadcast_status"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="broadcast", + name="created_by", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="broadcast", + name="modified_by", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 166bf3d947b..b2ee9aeb0e0 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -218,9 +218,9 @@ class Broadcast(models.Model): template = models.ForeignKey("templates.Template", null=True, on_delete=models.PROTECT) template_variables = ArrayField(models.TextField(), null=True) - created_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="broadcast_creations") + created_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="+") created_on = models.DateTimeField(default=timezone.now) - modified_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="broadcast_modifications") + modified_by = models.ForeignKey(User, null=True, on_delete=models.PROTECT, related_name="+") modified_on = models.DateTimeField(default=timezone.now) # used for scheduled broadcasts which are never actually sent themselves but spawn child broadcasts which are @@ -306,6 +306,15 @@ def trans(d): return trans(self.translations[self.base_language]) # should always be a base language translation + def interrupt(self, user): + """ + Interrupts this flow start + """ + + self.status = self.STATUS_INTERRUPTED + self.modified_by = user + self.save(update_fields=("status", "modified_by", "modified_on")) + def delete(self, user, *, soft: bool): if soft: assert self.schedule, "can only soft delete scheduled broadcasts" diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 505bbf3c1af..c46166f4c0b 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -1803,6 +1803,13 @@ def test_model(self, mr_mocks): self.assertEqual("P", bcast2.status) self.assertTrue(bcast2.is_active) + bcast2.interrupt(self.editor) + + bcast2.refresh_from_db() + self.assertEqual(Broadcast.STATUS_INTERRUPTED, bcast2.status) + self.assertEqual(self.editor, bcast2.modified_by) + self.assertIsNotNone(bcast2.modified_on) + # create a broadcast that looks like it has been sent bcast3 = self.create_broadcast(self.admin, {"eng": {"text": "Hi everyone"}}, contacts=[self.kevin, self.lucy]) diff --git a/templates/msgs/includes/broadcast.html b/templates/msgs/includes/broadcast.html index 94175efdc87..efaed35fa34 100644 --- a/templates/msgs/includes/broadcast.html +++ b/templates/msgs/includes/broadcast.html @@ -59,7 +59,7 @@ {% if not broadcast.schedule %}
    - {% if broadcast.status == "Q" or broadcast.status == "P" or broadcast.status == "S" %} + {% if broadcast.status == "P" or broadcast.status == "S" %} {% endif %} From 6394958165b272c5c63cbc830846f31956640da5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 21:53:09 +0000 Subject: [PATCH 090/557] Tweak flow start statuses for consistency with broadcasts --- temba/api/v2/serializers.py | 4 ++-- temba/api/v2/views.py | 2 +- .../migrations/0336_alter_flowstart_status.py | 22 +++++++++++++++++++ temba/flows/models.py | 18 +++++++++------ temba/flows/tests.py | 14 ++++++------ temba/flows/views.py | 2 +- temba/orgs/models.py | 9 -------- templates/flows/flow_editor.html | 8 +++---- 8 files changed, 47 insertions(+), 32 deletions(-) create mode 100644 temba/flows/migrations/0336_alter_flowstart_status.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 0ce28a2598e..26fa4455cf6 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -1078,8 +1078,8 @@ class Meta: class FlowStartReadSerializer(ReadSerializer): STATUSES = { FlowStart.STATUS_PENDING: "pending", - FlowStart.STATUS_STARTING: "starting", - FlowStart.STATUS_COMPLETE: "complete", + FlowStart.STATUS_STARTED: "started", + FlowStart.STATUS_COMPLETED: "completed", FlowStart.STATUS_FAILED: "failed", FlowStart.STATUS_INTERRUPTED: "interrupted", } diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 6689bfb44b5..41852343abd 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -3098,7 +3098,7 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **flow** - the flow which was started (object). * **contacts** - the list of contacts that were started in the flow (objects). * **groups** - the list of groups that were started in the flow (objects). - * **status** - the status of this flow start. + * **status** - the status, one of `pending`, `started`, `completed`, `failed`, `interrupted`. * **progress** - the progress of this flow start (object). * **params** - the dictionary of extra parameters passed to the flow start (object). * **created_on** - the datetime when this flow start was created (datetime). diff --git a/temba/flows/migrations/0336_alter_flowstart_status.py b/temba/flows/migrations/0336_alter_flowstart_status.py new file mode 100644 index 00000000000..229000cf056 --- /dev/null +++ b/temba/flows/migrations/0336_alter_flowstart_status.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1 on 2024-09-23 22:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0335_flowstart_modified_by_alter_flowstart_created_by_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="flowstart", + name="status", + field=models.CharField( + choices=[("P", "Pending"), ("S", "Started"), ("C", "Completed"), ("F", "Failed"), ("I", "Interrupted")], + default="P", + max_length=1, + ), + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index 266f511baf4..b9922bddf0f 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -462,7 +462,7 @@ def get_active_start(self): Returns whether this flow is already being started by a user """ return ( - self.starts.filter(status__in=(FlowStart.STATUS_STARTING, FlowStart.STATUS_PENDING)) + self.starts.filter(status__in=(FlowStart.STATUS_PENDING, FlowStart.STATUS_STARTED)) .exclude(created_by=None) .first() ) @@ -1792,14 +1792,14 @@ class FlowStart(models.Model): EXCLUSION_NOT_SEEN_SINCE_DAYS = "not_seen_since_days" # contacts not seen for more than this number of days STATUS_PENDING = "P" - STATUS_STARTING = "S" - STATUS_COMPLETE = "C" + STATUS_STARTED = "S" + STATUS_COMPLETED = "C" STATUS_FAILED = "F" STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( (STATUS_PENDING, "Pending"), - (STATUS_STARTING, "Starting"), - (STATUS_COMPLETE, "Complete"), + (STATUS_STARTED, "Started"), + (STATUS_COMPLETED, "Completed"), (STATUS_FAILED, "Failed"), (STATUS_INTERRUPTED, "Interrupted"), ) @@ -1892,8 +1892,12 @@ def preview(cls, flow, *, include: mailroom.Inclusions, exclude: mailroom.Exclus return preview.query, preview.total - def is_starting(self): - return self.status == self.STATUS_STARTING or self.status == self.STATUS_PENDING + def is_starting(self) -> bool: + return self.status in (self.STATUS_PENDING, self.STATUS_STARTED) + + @classmethod + def has_unfinished(cls, org) -> bool: + return org.flow_starts.filter(status__in=(cls.STATUS_PENDING, cls.STATUS_STARTED)).exists() def async_start(self): on_transaction_commit(lambda: mailroom.queue_flow_start(self)) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 1dcd636b35b..250b62a8059 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2290,10 +2290,7 @@ def test_inactive_flow(self): self.assertEqual(404, response.status_code) @mock_mailroom - @patch("temba.flows.models.Org.is_flow_starting") - def test_preview_start(self, mr_mocks, mock_flow_is_starting): - mock_flow_is_starting.return_value = False - + def test_preview_start(self, mr_mocks): flow = self.create_flow("Test") self.create_field("age", "Age") self.create_contact("Ann", phone="+16302222222", fields={"age": 40}) @@ -2366,8 +2363,9 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): self.org.is_flagged = False self.org.save() - # trying to start again should fail because there is already a pending start for this flow - mock_flow_is_starting.return_value = True + # create a pending flow start to test warning + FlowStart.create(flow, self.admin, query="age > 30") + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) response = self.client.post( @@ -2391,7 +2389,7 @@ def test_preview_start(self, mr_mocks, mock_flow_is_starting): preview_url = reverse("flows.flow_preview_start", args=[ivr_flow.id]) # shouldn't be able to since we don't have a call channel - mock_flow_is_starting.return_value = False + self.org.flow_starts.all().delete() mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) response = self.client.post( @@ -5250,6 +5248,7 @@ def test_model(self): start = FlowStart.create(flow, self.admin, contacts=[contact]) self.assertEqual(f'', repr(start)) + self.assertTrue(FlowStart.has_unfinished(self.org)) start.interrupt(self.editor) @@ -5257,6 +5256,7 @@ def test_model(self): self.assertEqual(FlowStart.STATUS_INTERRUPTED, start.status) self.assertEqual(self.editor, start.modified_by) self.assertIsNotNone(start.modified_on) + self.assertFalse(FlowStart.has_unfinished(self.org)) @mock_mailroom def test_preview(self, mr_mocks): diff --git a/temba/flows/views.py b/temba/flows/views.py index 9bc2afdeee8..78942fcbadb 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1578,7 +1578,7 @@ def get_warnings(self, flow, query, send_time) -> list: elif not template.is_approved(): warnings.append(_(f"Your message template {template.name} is not approved and cannot be sent.")) - if flow.org.is_flow_starting(): + if FlowStart.has_unfinished(flow.org): warnings.append(self.warnings["already_starting"]) return warnings diff --git a/temba/orgs/models.py b/temba/orgs/models.py index c34053b2e99..2db315cc1b2 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -848,15 +848,6 @@ def is_outbox_full(self) -> bool: return SystemLabel.get_counts(self)[SystemLabel.TYPE_OUTBOX] >= 1_000_000 - def is_flow_starting(self): - from temba.flows.models import FlowStart - - return ( - FlowStart.objects.filter(org=self, status__in=(FlowStart.STATUS_STARTING, FlowStart.STATUS_PENDING)) - .exclude(created_by=None) - .exists() - ) - def get_estimated_send_time(self, msg_count): """ Estimates the time it will take to send the given number of messages diff --git a/templates/flows/flow_editor.html b/templates/flows/flow_editor.html index 8df286aa02b..98274b25987 100644 --- a/templates/flows/flow_editor.html +++ b/templates/flows/flow_editor.html @@ -237,11 +237,9 @@ {% block alert-messages %} {% endblock alert-messages %} {% block page-header %} - {% if active_start or messages or user_org.is_suspended %} - {% if active_start %} - - - {% endif %} + {% if active_start %} + + {% endif %} {{ block.super }} {% endblock page-header %} From f1a4ccf4e17ab7740412decb35939af18e259842 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 23:00:27 +0000 Subject: [PATCH 091/557] Add progress field to broadcasts API endpoint --- temba/api/v2/serializers.py | 19 +++++++++++++++++-- temba/api/v2/tests.py | 8 +++++--- temba/api/v2/views.py | 5 ++++- temba/msgs/models.py | 13 +++++++++++++ temba/tests/base.py | 3 +++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 26fa4455cf6..d3259372783 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -172,13 +172,14 @@ class BroadcastReadSerializer(ReadSerializer): Broadcast.STATUS_INTERRUPTED: "interrupted", } + status = serializers.SerializerMethodField() + progress = 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=tzone.utc) def get_text(self, obj): @@ -190,6 +191,9 @@ def get_attachments(self, obj): def get_status(self, obj): return self.STATUSES[obj.status] + def get_progress(self, obj): + return {"total": obj.contact_count or -1, "started": obj.msg_count} + def get_urns(self, obj): if self.context["org"].is_anon: return None @@ -198,7 +202,18 @@ def get_urns(self, obj): class Meta: model = Broadcast - fields = ("id", "urns", "contacts", "groups", "text", "attachments", "base_language", "status", "created_on") + fields = ( + "id", + "status", + "progress", + "urns", + "contacts", + "groups", + "text", + "attachments", + "base_language", + "created_on", + ) class BroadcastWriteSerializer(WriteSerializer): diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 9637959a77a..db317816c8c 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -996,20 +996,21 @@ def test_broadcasts(self, mr_mocks): endpoint_url, [self.user, self.editor, self.admin], results=[bcast4, bcast3, bcast2, bcast1], - num_queries=NUM_BASE_SESSION_QUERIES + 3, + num_queries=NUM_BASE_SESSION_QUERIES + 4, ) resp_json = response.json() self.assertEqual( { "id": bcast2.id, + "status": "pending", + "progress": {"total": -1, "started": 0}, "urns": [], "contacts": [{"uuid": self.joe.uuid, "name": self.joe.name}], "groups": [], "text": {"eng": "Hello 2"}, "attachments": {"eng": []}, "base_language": "eng", - "status": "pending", "created_on": format_datetime(bcast2.created_on), }, resp_json["results"][2], @@ -1017,13 +1018,14 @@ def test_broadcasts(self, mr_mocks): self.assertEqual( { "id": bcast4.id, + "status": "failed", + "progress": {"total": 2, "started": 2}, "urns": ["twitter:franky"], "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), }, resp_json["results"][0], diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 41852343abd..c5e3ad2c830 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -20,7 +20,7 @@ from temba.flows.models import Flow, FlowRun, FlowStart, FlowStartCount from temba.globals.models import Global from temba.locations.models import AdminBoundary, BoundaryAlias -from temba.msgs.models import Broadcast, Label, LabelCount, Media, Msg, OptIn, SystemLabel +from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, OptIn, SystemLabel from temba.orgs.models import OrgMembership, User from temba.orgs.views import OrgPermsMixin from temba.tickets.models import Ticket, TicketCount, Topic @@ -586,6 +586,9 @@ def filter_queryset(self, queryset): return self.filter_before_after(queryset, "created_on") + def prepare_for_serialization(self, object_list, using: str): + BroadcastMsgCount.bulk_annotate(object_list) + @classmethod def get_read_explorer(cls): return { diff --git a/temba/msgs/models.py b/temba/msgs/models.py index b2ee9aeb0e0..8ec3dccabd5 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -793,6 +793,19 @@ def get_squash_query(cls, distinct_set): def get_count(cls, broadcast): return cls.sum(broadcast.counts.all()) + @classmethod + def bulk_annotate(cls, broadcasts): + counts = ( + cls.objects.filter(broadcast_id__in=[b.id for b in broadcasts]) + .values("broadcast_id") + .order_by("broadcast_id") + .annotate(count=Sum("count")) + ) + counts_by_bcast = {c["broadcast_id"]: c["count"] for c in counts} + + for bcast in broadcasts: + bcast.msg_count = counts_by_bcast.get(bcast.id, 0) + class SystemLabel: TYPE_INBOX = "I" diff --git a/temba/tests/base.py b/temba/tests/base.py index 49a3d2788ac..f747d48cbc7 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -507,6 +507,9 @@ def create_broadcast( contacts.update(group.contacts.all()) if not schedule and status != Broadcast.STATUS_PENDING: + bcast.contact_count = len(contacts) + bcast.save(update_fields=("contact_count",)) + for contact in contacts: translation = bcast.get_translation(contact) self._create_msg( From cfbdf446351128448d12c2264c26c59767c9e6cf Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 23 Sep 2024 18:19:09 -0500 Subject: [PATCH 092/557] Update CHANGELOG.md for v9.3.46 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125fbe150a7..3f0337d3aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.46 (2024-09-23) +------------------------- + * Add progress field to broadcasts API endpoint + * Add Broadcast.interrupt(user) + v9.3.45 (2024-09-23) ------------------------- * Add PENDING/STARTED statuses and contact_count field to broadcasts diff --git a/pyproject.toml b/pyproject.toml index c6ec01695f9..351ae27d312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.45" +version = "9.3.46" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 7f69ed25439..0fff131db0b 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.45" +__version__ = "9.3.46" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From d94ad5806e5960c73360384f0c64925d8df38182 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 24 Sep 2024 17:18:33 -0500 Subject: [PATCH 093/557] Remove progress field from flow starts endpoint docs --- temba/api/v2/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index c5e3ad2c830..91b8468842b 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -3102,7 +3102,6 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **contacts** - the list of contacts that were started in the flow (objects). * **groups** - the list of groups that were started in the flow (objects). * **status** - the status, one of `pending`, `started`, `completed`, `failed`, `interrupted`. - * **progress** - the progress of this flow start (object). * **params** - the dictionary of extra parameters passed to the flow start (object). * **created_on** - the datetime when this flow start was created (datetime). * **modified_on** - the datetime when this flow start was modified (datetime). From 5202c83893894b2da1a45c099704d7353721e6ed Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 25 Sep 2024 16:34:38 +0000 Subject: [PATCH 094/557] Re-introduce QUEUED status for FlowStarts and Broadcasts --- temba/api/v2/serializers.py | 2 ++ temba/api/v2/views.py | 4 +-- .../migrations/0337_alter_flowstart_status.py | 29 +++++++++++++++++++ temba/flows/models.py | 14 ++++----- .../migrations/0275_alter_broadcast_status.py | 29 +++++++++++++++++++ temba/msgs/models.py | 12 ++++---- 6 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 temba/flows/migrations/0337_alter_flowstart_status.py create mode 100644 temba/msgs/migrations/0275_alter_broadcast_status.py diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index d3259372783..df1ee920531 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -166,6 +166,7 @@ class Meta: class BroadcastReadSerializer(ReadSerializer): STATUSES = { Broadcast.STATUS_PENDING: "pending", + Broadcast.STATUS_QUEUED: "queued", Broadcast.STATUS_STARTED: "started", Broadcast.STATUS_COMPLETED: "completed", Broadcast.STATUS_FAILED: "failed", @@ -1093,6 +1094,7 @@ class Meta: class FlowStartReadSerializer(ReadSerializer): STATUSES = { FlowStart.STATUS_PENDING: "pending", + FlowStart.STATUS_QUEUED: "queued", FlowStart.STATUS_STARTED: "started", FlowStart.STATUS_COMPLETED: "completed", FlowStart.STATUS_FAILED: "failed", diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 91b8468842b..844c9307327 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -504,7 +504,7 @@ class BroadcastsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **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, one of `pending`, `started`, `completed`, `failed`, `interrupted`. + * **status** - the status, one of `pending`, `queued`, `started`, `completed`, `failed`, `interrupted`. * **created_on** - when this broadcast was either created (datetime) (filterable as `before` and `after`). Example: @@ -3101,7 +3101,7 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): * **flow** - the flow which was started (object). * **contacts** - the list of contacts that were started in the flow (objects). * **groups** - the list of groups that were started in the flow (objects). - * **status** - the status, one of `pending`, `started`, `completed`, `failed`, `interrupted`. + * **status** - the status, one of `pending`, `queued`, `started`, `completed`, `failed`, `interrupted`. * **params** - the dictionary of extra parameters passed to the flow start (object). * **created_on** - the datetime when this flow start was created (datetime). * **modified_on** - the datetime when this flow start was modified (datetime). diff --git a/temba/flows/migrations/0337_alter_flowstart_status.py b/temba/flows/migrations/0337_alter_flowstart_status.py new file mode 100644 index 00000000000..10f87d0d33d --- /dev/null +++ b/temba/flows/migrations/0337_alter_flowstart_status.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1 on 2024-09-25 16:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("flows", "0336_alter_flowstart_status"), + ] + + operations = [ + migrations.AlterField( + model_name="flowstart", + name="status", + field=models.CharField( + choices=[ + ("P", "Pending"), + ("Q", "Queued"), + ("S", "Started"), + ("C", "Completed"), + ("F", "Failed"), + ("I", "Interrupted"), + ], + default="P", + max_length=1, + ), + ), + ] diff --git a/temba/flows/models.py b/temba/flows/models.py index b9922bddf0f..61cf007894d 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1791,13 +1791,15 @@ class FlowStart(models.Model): EXCLUSION_STARTED_PREVIOUSLY = "started_previously" # contacts been in this flow in the last 90 days EXCLUSION_NOT_SEEN_SINCE_DAYS = "not_seen_since_days" # contacts not seen for more than this number of days - STATUS_PENDING = "P" - STATUS_STARTED = "S" - STATUS_COMPLETED = "C" + STATUS_PENDING = "P" # exists in the database + STATUS_QUEUED = "Q" # batch tasks created, count_count set + STATUS_STARTED = "S" # first batch task started + STATUS_COMPLETED = "C" # last batch task completed STATUS_FAILED = "F" STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( (STATUS_PENDING, "Pending"), + (STATUS_QUEUED, "Queued"), (STATUS_STARTED, "Started"), (STATUS_COMPLETED, "Completed"), (STATUS_FAILED, "Failed"), @@ -1821,7 +1823,8 @@ class FlowStart(models.Model): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="flow_starts") flow = models.ForeignKey(Flow, on_delete=models.PROTECT, related_name="starts") start_type = models.CharField(max_length=1, choices=TYPE_CHOICES) - status = models.CharField(max_length=1, default=STATUS_PENDING, choices=STATUS_CHOICES) + status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING) + contact_count = models.IntegerField(default=0, null=True) # null until status is QUEUED # who to start groups = models.ManyToManyField(ContactGroup) @@ -1830,9 +1833,6 @@ class FlowStart(models.Model): query = models.TextField(null=True) exclusions = models.JSONField(default=dict, null=True) - # number of contacts that will be started, only set when status becomes STARTING - contact_count = models.IntegerField(default=0, null=True) - campaign_event = models.ForeignKey( "campaigns.CampaignEvent", null=True, on_delete=models.PROTECT, related_name="flow_starts" ) diff --git a/temba/msgs/migrations/0275_alter_broadcast_status.py b/temba/msgs/migrations/0275_alter_broadcast_status.py new file mode 100644 index 00000000000..b377583cb59 --- /dev/null +++ b/temba/msgs/migrations/0275_alter_broadcast_status.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1 on 2024-09-25 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("msgs", "0274_alter_broadcast_created_by_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="broadcast", + name="status", + field=models.CharField( + choices=[ + ("P", "Pending"), + ("Q", "Queued"), + ("S", "Started"), + ("C", "Completed"), + ("F", "Failed"), + ("I", "Interrupted"), + ], + default="P", + max_length=1, + ), + ), + ] diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 8ec3dccabd5..1f9a03d29f2 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -184,13 +184,15 @@ class Broadcast(models.Model): messages sent from the same bundle together """ - STATUS_PENDING = "P" - STATUS_STARTED = "S" - STATUS_COMPLETED = "C" + STATUS_PENDING = "P" # exists in the database + STATUS_QUEUED = "Q" # batch tasks created, count_count set + STATUS_STARTED = "S" # first batch task started + STATUS_COMPLETED = "C" # last batch task completed STATUS_FAILED = "F" STATUS_INTERRUPTED = "I" STATUS_CHOICES = ( (STATUS_PENDING, "Pending"), + (STATUS_QUEUED, "Queued"), (STATUS_STARTED, "Started"), (STATUS_COMPLETED, "Completed"), (STATUS_FAILED, "Failed"), @@ -199,6 +201,7 @@ class Broadcast(models.Model): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="broadcasts") status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING) + contact_count = models.IntegerField(default=0, null=True) # null until status is QUEUED # recipients of this broadcast groups = models.ManyToManyField(ContactGroup, related_name="addressed_broadcasts") @@ -208,9 +211,6 @@ class Broadcast(models.Model): node_uuid = models.UUIDField(null=True) exclusions = models.JSONField(default=dict, null=True) - # number of contacts that will be started, only set when status becomes STARTING - contact_count = models.IntegerField(default=0, null=True) - # message content translations = models.JSONField() # text, attachments and quick replies by language base_language = models.CharField(max_length=3) # ISO-639-3 From 9022230e7f47dd1932ca0e9a46e27f0df64a0763 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 25 Sep 2024 17:02:14 +0000 Subject: [PATCH 095/557] Switch to id flow starts for progress --- temba/flows/views.py | 56 +++++++++++++++++++++++- temba/settings_common.py | 1 + templates/flows/flow_editor.html | 2 +- templates/flows/flowstart_interrupt.html | 25 +++++++++++ templates/flows/flowstart_list.html | 2 +- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 templates/flows/flowstart_interrupt.html diff --git a/temba/flows/views.py b/temba/flows/views.py index 78942fcbadb..1276cbf44c6 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1843,7 +1843,7 @@ def post_save(self, obj, *args, **kwargs): class FlowStartCRUDL(SmartCRUDL): model = FlowStart - actions = ("list",) + actions = ("list", "interrupt", "status") class List(SpaMixin, OrgFilterMixin, OrgPermsMixin, SmartListView): title = _("Flow Starts") @@ -1875,3 +1875,57 @@ def get_context_data(self, *args, **kwargs): FlowStartCount.bulk_annotate(context["object_list"]) return context + + class Status(OrgPermsMixin, SmartListView): + permission = "flows.flow_start" + + def derive_queryset(self, **kwargs): + qs = super().derive_queryset(**kwargs) + id = self.request.GET.get("id", None) + if id: + qs = qs.filter(id=id) + + status = self.request.GET.get("status", None) + if status: + qs = qs.filter(status=status) + + return qs.order_by("-created_on") + + def render_to_response(self, context, **response_kwargs): + # add run count + FlowStartCount.bulk_annotate(context["object_list"]) + + results = [] + for obj in context["object_list"]: + # created_on as an iso date + results.append( + { + "id": obj.id, + "status": obj.get_status_display(), + "created_on": obj.created_on.isoformat(), + "modified_on": obj.modified_on.isoformat(), + "flow": { + "name": obj.flow.name, + "uuid": obj.flow.uuid, + }, + "progress": {"total": obj.contact_count, "current": obj.run_count}, + } + ) + return JsonResponse({"results": results}) + + class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + default_template = "smartmin/delete_confirm.html" + permission = "flows.flow_start" + fields = () + cancel_url = "@flows.flowstart_list" + submit_button_name = _("Interrupt") + success_url = "@flows.flowstart_list" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + return context + + def post(self, request, *args, **kwargs): + flow_start = self.get_object() + flow_start.interrupt(self.request.user) + return super().post(request, *args, **kwargs) diff --git a/temba/settings_common.py b/temba/settings_common.py index 50dd29665b6..406765d2e33 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -359,6 +359,7 @@ "contacts.contactgroup": ("menu",), "contacts.contactimport": ("preview",), "flows.flow": ("assets", "copy", "editor", "export", "menu", "results", "start"), + "flows.flowstart": ("interrupt", "status"), "flows.flowsession": ("json",), "globals.global": ("unused",), "locations.adminboundary": ("alias", "boundaries", "geometry"), diff --git a/templates/flows/flow_editor.html b/templates/flows/flow_editor.html index 98274b25987..17626e98744 100644 --- a/templates/flows/flow_editor.html +++ b/templates/flows/flow_editor.html @@ -238,7 +238,7 @@ {% endblock alert-messages %} {% block page-header %} {% if active_start %} - + {% endif %} {{ block.super }} diff --git a/templates/flows/flowstart_interrupt.html b/templates/flows/flowstart_interrupt.html new file mode 100644 index 00000000000..c54d4f188bd --- /dev/null +++ b/templates/flows/flowstart_interrupt.html @@ -0,0 +1,25 @@ +{% extends "smartmin/base.html" %} +{% load smartmin i18n %} + +{% block page-top %} +{% endblock page-top %} +{% block modal-extra-style %} + {{ block.super }} + +{% endblock modal-extra-style %} +{% block modal %} + {% block delete-message %} + {% blocktrans trimmed with name=object.flow.name %} + Are you sure you want to interrupt starting {{ name }}? + Keep in mind that contacts who have already been started will continue in the flow. + {% endblocktrans %} + {% endblock delete-message %} + {% block delete-form %} +
    + {% csrf_token %} + + + {% endblock delete-form %} +{% endblock modal %} diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index 8b8be5dab4d..7db43d43bc5 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -79,7 +79,7 @@ {% if obj.is_starting %}
    From 76cf42b37f743203ea3e0f50aff5d78ff540f5c6 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Wed, 25 Sep 2024 18:54:05 +0000 Subject: [PATCH 096/557] Add status and interrupt for broadcasts and starts --- package.json | 2 +- temba/flows/tests.py | 24 +++++++++++++ temba/flows/views.py | 5 --- temba/msgs/views.py | 44 ++++++++++++++++++++++++ temba/settings_common.py | 2 +- templates/flows/flow_editor.html | 8 +++-- templates/flows/flowstart_interrupt.html | 2 +- templates/flows/flowstart_list.html | 9 +++-- templates/msgs/broadcast_interrupt.html | 25 ++++++++++++++ templates/msgs/includes/broadcast.html | 26 ++++++-------- yarn.lock | 8 ++--- 11 files changed, 123 insertions(+), 32 deletions(-) create mode 100644 templates/msgs/broadcast_interrupt.html diff --git a/package.json b/package.json index 282b12f4766..d83d5f7c0cb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.107.1", + "@nyaruka/temba-components": "0.108.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 250b62a8059..47ee4073f7f 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -5317,6 +5317,30 @@ def test_list(self): self.assertTrue(response.context["filtered"]) self.assertEqual(response.context["url_params"], "?type=manual&") + def test_status(self): + flow = self.create_flow("Test Flow 1") + contact = self.create_contact("Bob", phone="+1234567890") + start = FlowStart.create(flow, self.admin, contacts=[contact]) + + status_url = f"{reverse('flows.flowstart_status')}?id={start.id}&status=P" + self.assertRequestDisallowed(status_url, [None, self.user, self.agent]) + response = self.assertReadFetch(status_url, [self.editor, self.admin]) + + # status returns json + self.assertEqual("Pending", response.json()["results"][0]["status"]) + + def test_interrupt(self): + flow = self.create_flow("Test Flow 1") + contact = self.create_contact("Bob", phone="+1234567890") + start = FlowStart.create(flow, self.admin, contacts=[contact]) + + interrupt_url = reverse("flows.flowstart_interrupt", args=[start.id]) + self.assertRequestDisallowed(interrupt_url, [None, self.user, self.agent]) + self.requestView(interrupt_url, self.admin, post_data={}) + + start.refresh_from_db() + self.assertEqual(FlowStart.STATUS_INTERRUPTED, start.status) + class AssetServerTest(TembaTest): def test_languages(self): diff --git a/temba/flows/views.py b/temba/flows/views.py index 1276cbf44c6..930e3f9cdba 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1917,14 +1917,9 @@ class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): default_template = "smartmin/delete_confirm.html" permission = "flows.flow_start" fields = () - cancel_url = "@flows.flowstart_list" submit_button_name = _("Interrupt") success_url = "@flows.flowstart_list" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - return context - def post(self, request, *args, **kwargs): flow_start = self.get_object() flow_start.interrupt(self.request.user) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index f9bfe7dcd39..23b2a719e3a 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -323,6 +323,8 @@ class BroadcastCRUDL(SmartCRUDL): "scheduled_delete", "preview", "to_node", + "status", + "interrupt", ) model = Broadcast @@ -717,6 +719,48 @@ def form_valid(self, form): return self.render_modal_response(form) + class Status(OrgPermsMixin, SmartListView): + permission = "msgs.broadcast_create" + + def derive_queryset(self, **kwargs): + qs = super().derive_queryset(**kwargs) + id = self.request.GET.get("id", None) + if id: + qs = qs.filter(id=id) + + status = self.request.GET.get("status", None) + if status: + qs = qs.filter(status=status) + + return qs.order_by("-created_on") + + def render_to_response(self, context, **response_kwargs): + results = [] + for obj in context["object_list"]: + # created_on as an iso date + results.append( + { + "id": obj.id, + "status": obj.get_status_display(), + "created_on": obj.created_on.isoformat(), + "modified_on": obj.modified_on.isoformat(), + "progress": {"total": obj.contact_count, "current": obj.get_message_count()}, + } + ) + return JsonResponse({"results": results}) + + class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + default_template = "smartmin/delete_confirm.html" + permission = "msgs.broadcast_create" + fields = () + submit_button_name = _("Interrupt") + success_url = "@msgs.broadcast_list" + + def post(self, request, *args, **kwargs): + broadcast = self.get_object() + broadcast.interrupt(self.request.user) + return super().post(request, *args, **kwargs) + class MsgCRUDL(SmartCRUDL): model = Msg diff --git a/temba/settings_common.py b/temba/settings_common.py index 406765d2e33..b3ab555b332 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -363,7 +363,7 @@ "flows.flowsession": ("json",), "globals.global": ("unused",), "locations.adminboundary": ("alias", "boundaries", "geometry"), - "msgs.broadcast": ("scheduled", "scheduled_read", "scheduled_delete"), + "msgs.broadcast": ("scheduled", "scheduled_read", "scheduled_delete", "interrupt", "status"), "msgs.msg": ("archive", "export", "label", "menu"), "orgs.export": ("download",), "orgs.org": ( diff --git a/templates/flows/flow_editor.html b/templates/flows/flow_editor.html index 17626e98744..5c5d5c8512c 100644 --- a/templates/flows/flow_editor.html +++ b/templates/flows/flow_editor.html @@ -238,8 +238,12 @@ {% endblock alert-messages %} {% block page-header %} {% if active_start %} - - + + {% endif %} {{ block.super }} {% endblock page-header %} diff --git a/templates/flows/flowstart_interrupt.html b/templates/flows/flowstart_interrupt.html index c54d4f188bd..cfaad3ccf7a 100644 --- a/templates/flows/flowstart_interrupt.html +++ b/templates/flows/flowstart_interrupt.html @@ -11,7 +11,7 @@ {% block delete-message %} {% blocktrans trimmed with name=object.flow.name %} Are you sure you want to interrupt starting {{ name }}? - Keep in mind that contacts who have already been started will continue in the flow. + Contacts who have already been started will continue in the flow. {% endblocktrans %} {% endblock delete-message %} {% block delete-form %} diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index 7db43d43bc5..e823b1757ee 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -78,9 +78,12 @@ {% if obj.is_starting %} - {% endif %} diff --git a/templates/msgs/broadcast_interrupt.html b/templates/msgs/broadcast_interrupt.html new file mode 100644 index 00000000000..01800e6dd4a --- /dev/null +++ b/templates/msgs/broadcast_interrupt.html @@ -0,0 +1,25 @@ +{% extends "smartmin/base.html" %} +{% load smartmin i18n %} + +{% block page-top %} +{% endblock page-top %} +{% block modal-extra-style %} + {{ block.super }} + +{% endblock modal-extra-style %} +{% block modal %} + {% block delete-message %} + {% blocktrans trimmed %} + Are you sure you want to interrupt this broadcast? + Contacts who already have messages in the outbox will still receive it. + {% endblocktrans %} + {% endblock delete-message %} + {% block delete-form %} + + {% csrf_token %} + + + {% endblock delete-form %} +{% endblock modal %} diff --git a/templates/msgs/includes/broadcast.html b/templates/msgs/includes/broadcast.html index efaed35fa34..6e745c2094d 100644 --- a/templates/msgs/includes/broadcast.html +++ b/templates/msgs/includes/broadcast.html @@ -56,21 +56,17 @@ {% endfor %} - {% if not broadcast.schedule %} -
    -
    - {% if broadcast.status == "P" or broadcast.status == "S" %} - - - {% endif %} - {% blocktrans count message_count=broadcast.get_message_count %} - {{message_count}} message - {% plural %} - {{message_count}} messages - {% endblocktrans %} -
    -
    - {% else %} +
    + {% if broadcast.status == "P" or broadcast.status == "S" or broadcast.status == "Q" %} + + + {% endif %} +
    + {% if broadcast.schedule %}
    {% if broadcast.schedule and broadcast.schedule.repeat_period != "O" %} diff --git a/yarn.lock b/yarn.lock index 1a9514c391b..e7231e33e1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.107.1": - version "0.107.1" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.107.1.tgz#94a27ac43edc7fd9bcf8d85ba685db54fc7b4870" - integrity sha512-Tr1trvUv/qBlaGgnib/CRgYtwOgaPXNh9ZmBp5qTYDHHoeMjZ0jv7REFkvJtHrL12fujjbmuLYhrRUmiJJFDCA== +"@nyaruka/temba-components@0.108.0": + version "0.108.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.0.tgz#56f76e5b8fa6ca8aa560d66a149384da5f079acc" + integrity sha512-HJnXwmGqpwzF2rsvzBahBwKPU0COKhgA4lB2Ey1uc5XNLyOfkQYbU0HC6Cbgh2onfyAPlMlGRWEQNn78jPZSPA== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 0a95acfab2e8363698818d8b031281abb5938ab2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 25 Sep 2024 14:25:38 -0500 Subject: [PATCH 097/557] Remove mention of progress field from API docs --- temba/api/v2/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 844c9307327..0fcf47a9a7c 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -3126,7 +3126,6 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): {"uuid": "f5901b62-ba76-4003-9c62-fjjajdsi15553", "name": "Wanz"} ], "status": "complete", - "progress": {"total": 10, "started": 5}, "params": { "first_name": "Ryan", "last_name": "Lewis" @@ -3175,7 +3174,6 @@ class FlowStartsEndpoint(ListAPIMixin, WriteAPIMixin, BaseEndpoint): {"uuid": "f1ea776e-c923-4c1a-b3a3-0c466932b2cc", "name": "Wanz"} ], "status": "pending", - "progress": {"total": -1, "started": 0}, "params": { "first_name": "Ryan", "last_name": "Lewis" From f5e8a80c08b19b79d0f390e29a467c24fc761ed3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 25 Sep 2024 14:53:43 -0500 Subject: [PATCH 098/557] Update CHANGELOG.md for v9.3.47 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f0337d3aef..fd09441d232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.47 (2024-09-25) +------------------------- + * Re-introduce QUEUED status for FlowStarts and Broadcasts + * Remove progress field from flow starts endpoint docs + v9.3.46 (2024-09-23) ------------------------- * Add progress field to broadcasts API endpoint diff --git a/pyproject.toml b/pyproject.toml index 351ae27d312..c64186b0cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.46" +version = "9.3.47" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 0fff131db0b..6cc286b3894 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.46" +__version__ = "9.3.47" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From c3feba1394a78aa5091e73d23c09033e3dc2b7d5 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 01:58:27 +0000 Subject: [PATCH 099/557] Add tests for broadcast status and interrupt --- temba/flows/views.py | 15 +++++++++--- temba/msgs/tests.py | 30 +++++++++++++++++++++++ temba/msgs/views.py | 8 ++++-- temba/orgs/views.py | 4 +-- temba/settings_common.py | 6 ++++- templates/frame.html | 2 -- templates/msgs/includes/broadcast.html | 34 +++++++++++++++++--------- 7 files changed, 77 insertions(+), 22 deletions(-) diff --git a/temba/flows/views.py b/temba/flows/views.py index 930e3f9cdba..240f789d7a7 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1691,12 +1691,17 @@ def derive_initial(self): urn = urn.get_display(org=org, international=True) recipients.append({"id": contact.uuid, "name": contact.name, "urn": urn, "type": "contact"}) + exclusions = settings.DEFAULT_EXCLUSIONS.copy() + + if self.flow and self.flow.flow_type == Flow.TYPE_BACKGROUND: + del exclusions["in_a_flow"] + return { "contact_search": { "recipients": recipients, "advanced": False, "query": "", - "exclusions": settings.DEFAULT_EXCLUSIONS, + "exclusions": exclusions, }, "flow": self.flow.id if self.flow else None, } @@ -1877,10 +1882,14 @@ def get_context_data(self, *args, **kwargs): return context class Status(OrgPermsMixin, SmartListView): - permission = "flows.flow_start" + permission = "flows.flowstart_read" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) + + if not self.request.user.is_staff: + qs = qs.filter(org=self.request.org) + id = self.request.GET.get("id", None) if id: qs = qs.filter(id=id) @@ -1915,7 +1924,7 @@ def render_to_response(self, context, **response_kwargs): class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): default_template = "smartmin/delete_confirm.html" - permission = "flows.flow_start" + permission = "flows.flowstart_update" fields = () submit_button_name = _("Interrupt") success_url = "@flows.flowstart_list" diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index c46166f4c0b..bbdd17dd24b 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2603,6 +2603,36 @@ def test_scheduled_delete(self): self.assertIsNone(broadcast.schedule) self.assertEqual(0, Schedule.objects.count()) + def test_status(self): + broadcast = self.create_broadcast( + self.admin, + {"eng": {"text": "Daily reminder"}}, + groups=[self.joe_and_frank], + status=Broadcast.STATUS_PENDING, + ) + + status_url = f"{reverse('msgs.broadcast_status')}?id={broadcast.id}&status=P" + self.assertRequestDisallowed(status_url, [None, self.user, self.agent]) + response = self.assertReadFetch(status_url, [self.editor, self.admin]) + + # status returns json + self.assertEqual("Pending", response.json()["results"][0]["status"]) + + def test_interrupt(self): + broadcast = self.create_broadcast( + self.admin, + {"eng": {"text": "Daily reminder"}}, + groups=[self.joe_and_frank], + status=Broadcast.STATUS_PENDING, + ) + + interrupt_url = reverse("msgs.broadcast_interrupt", args=[broadcast.id]) + self.assertRequestDisallowed(interrupt_url, [None, self.user, self.agent]) + self.requestView(interrupt_url, self.admin, post_data={}) + + broadcast.refresh_from_db() + self.assertEqual(Broadcast.STATUS_INTERRUPTED, broadcast.status) + class LabelTest(TembaTest): def setUp(self): diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 23b2a719e3a..b7209c3c021 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -720,10 +720,14 @@ def form_valid(self, form): return self.render_modal_response(form) class Status(OrgPermsMixin, SmartListView): - permission = "msgs.broadcast_create" + permission = "msgs.broadcast_read" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) + + if not self.request.user.is_staff: + qs = qs.filter(org=self.request.org) + id = self.request.GET.get("id", None) if id: qs = qs.filter(id=id) @@ -751,7 +755,7 @@ def render_to_response(self, context, **response_kwargs): class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): default_template = "smartmin/delete_confirm.html" - permission = "msgs.broadcast_create" + permission = "msgs.broadcast_update" fields = () submit_button_name = _("Interrupt") success_url = "@msgs.broadcast_list" diff --git a/temba/orgs/views.py b/temba/orgs/views.py index b8ea58b66ee..3c5ed83c4a1 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -138,9 +138,7 @@ def has_permission(self, request, *args, **kwargs): self.args = args self.request = request - org = self.derive_org() - - if self.get_user().is_staff and org: + if self.get_user().is_staff: return True if self.get_user().is_anonymous: diff --git a/temba/settings_common.py b/temba/settings_common.py index b3ab555b332..8173a9de112 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -363,7 +363,11 @@ "flows.flowsession": ("json",), "globals.global": ("unused",), "locations.adminboundary": ("alias", "boundaries", "geometry"), - "msgs.broadcast": ("scheduled", "scheduled_read", "scheduled_delete", "interrupt", "status"), + "msgs.broadcast": ( + "scheduled", + "scheduled_read", + "scheduled_delete", + ), "msgs.msg": ("archive", "export", "label", "menu"), "orgs.export": ("download",), "orgs.org": ( diff --git a/templates/frame.html b/templates/frame.html index 2294ce687aa..43cb2d39acf 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -211,8 +211,6 @@
    - -
    diff --git a/templates/msgs/includes/broadcast.html b/templates/msgs/includes/broadcast.html index 6e745c2094d..93b90e6561f 100644 --- a/templates/msgs/includes/broadcast.html +++ b/templates/msgs/includes/broadcast.html @@ -19,7 +19,7 @@ {% endif %}
    -
    +
    {{ translation.text }} @@ -56,16 +56,6 @@ {% endfor %}
    -
    - {% if broadcast.status == "P" or broadcast.status == "S" or broadcast.status == "Q" %} - - - {% endif %} -
    {% if broadcast.schedule %}
    @@ -99,6 +89,28 @@ {% endif %} {% endif %}
    + {% else %} +
    +
    +
    + {% blocktrans count message_count=broadcast.get_message_count %} + {{message_count}} message + {% plural %} + {{message_count}} messages + {% endblocktrans %} +
    +
    + {% if broadcast.status == "P" or broadcast.status == "S" or broadcast.status == "Q" %} + + + {% endif %} +
    +
    +
    {% endif %}
    {% endwith %} From 9b0c05e61585959e757117e47128ada9ac3f3013 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 02:19:01 +0000 Subject: [PATCH 100/557] Switch to flowstart_list permission for status --- temba/flows/tests.py | 2 +- temba/flows/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 47ee4073f7f..1f68497101c 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -5323,7 +5323,7 @@ def test_status(self): start = FlowStart.create(flow, self.admin, contacts=[contact]) status_url = f"{reverse('flows.flowstart_status')}?id={start.id}&status=P" - self.assertRequestDisallowed(status_url, [None, self.user, self.agent]) + self.assertRequestDisallowed(status_url, [self.agent]) response = self.assertReadFetch(status_url, [self.editor, self.admin]) # status returns json diff --git a/temba/flows/views.py b/temba/flows/views.py index 240f789d7a7..a143e1fcef8 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1882,7 +1882,7 @@ def get_context_data(self, *args, **kwargs): return context class Status(OrgPermsMixin, SmartListView): - permission = "flows.flowstart_read" + permission = "flows.flowstart_list" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) From 4ebe1b319795b248476d9f6356ecb8d2684284b6 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 02:30:23 +0000 Subject: [PATCH 101/557] Update to latest components --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d83d5f7c0cb..67ced481c96 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.0", + "@nyaruka/temba-components": "0.108.1", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index e7231e33e1e..adce1a0b0f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.0": - version "0.108.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.0.tgz#56f76e5b8fa6ca8aa560d66a149384da5f079acc" - integrity sha512-HJnXwmGqpwzF2rsvzBahBwKPU0COKhgA4lB2Ey1uc5XNLyOfkQYbU0HC6Cbgh2onfyAPlMlGRWEQNn78jPZSPA== +"@nyaruka/temba-components@0.108.1": + version "0.108.1" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.1.tgz#8da7afc35a8347691efd58289c2d00b4c8b124c9" + integrity sha512-yx/S7Ut/qoeR48Ck0gpF46tVNPiROuzPI78lFME6JEMjQN89teMP0CjgWWIUrtO+HGph5kDH2urWZPeuzHcqVA== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 851d5998175efef9ebf0179693ad8506df850e97 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 14:12:19 +0000 Subject: [PATCH 102/557] Add org_deindex to mailroom client but don't use it yet --- temba/mailroom/client/client.py | 3 +++ temba/mailroom/client/tests.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/temba/mailroom/client/client.py b/temba/mailroom/client/client.py index fbb15d43ba0..0a10418783c 100644 --- a/temba/mailroom/client/client.py +++ b/temba/mailroom/client/client.py @@ -243,6 +243,9 @@ def msg_send(self, org, user, contact, text: str, attachments: list[str], ticket }, ) + def org_deindex(self, org): + return self._request("org/deindex", {"org_id": org.id}) + def po_export(self, org, flows, language: str): return self._request( "po/export", diff --git a/temba/mailroom/client/tests.py b/temba/mailroom/client/tests.py index 0245b0f7a2d..e81b9ca2f45 100644 --- a/temba/mailroom/client/tests.py +++ b/temba/mailroom/client/tests.py @@ -561,6 +561,19 @@ def test_msg_send(self, mock_post): }, ) + @patch("requests.post") + def test_org_deindex(self, mock_post): + mock_post.return_value = MockJsonResponse(200, {}) + response = self.client.org_deindex(self.org) + + self.assertEqual({}, response) + + mock_post.assert_called_once_with( + "http://localhost:8090/mr/org/deindex", + headers={"User-Agent": "Temba", "Authorization": "Token sesame"}, + json={"org_id": self.org.id}, + ) + def test_po_export(self): flow1 = self.create_flow("Flow 1") flow2 = self.create_flow("Flow 2") From bcf0e37064cf9549735139ff6d85f4809bec0ef0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 16:09:52 +0000 Subject: [PATCH 103/557] Request de-indexing of contacts when hard deleting an org --- temba/orgs/models.py | 3 +++ temba/orgs/tests.py | 5 ++++- temba/tests/mailroom.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 2db315cc1b2..160f5c35060 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1398,6 +1398,9 @@ def delete(self) -> dict: # needs to come after deletion of msgs and broadcasts as those insert new counts delete_in_batches(self.system_labels.all()) + # now that contacts are no longer in the database, we can start de-indexing them from search + mailroom.get_client().org_deindex(self) + # save when we were actually deleted self.modified_on = timezone.now() self.deleted_on = timezone.now() diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index ef2af8363c4..5715727f591 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1,7 +1,7 @@ import io import smtplib from datetime import date, datetime, timedelta, timezone as tzone -from unittest.mock import patch +from unittest.mock import call, patch from urllib.parse import urlencode from zoneinfo import ZoneInfo @@ -1508,6 +1508,9 @@ def test_release_and_delete(self, mr_mocks): self.assertTrue(Archive.storage().exists(f"{self.org2.id}/extra_file.json")) self.assertFalse(Archive.storage().exists(f"{self.org.id}/extra_file.json")) + # check we've initiated search de-indexing for all deleted orgs + self.assertEqual([call(org1_child1), call(org1_child2), call(self.org)], mr_mocks.calls["org_deindex"]) + # we don't actually delete org objects but at this point there should be no related fields preventing that Model.delete(org1_child1) Model.delete(org1_child2) diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 8b0110600c6..8f7e3ea6c3f 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -369,6 +369,10 @@ def msg_send(self, org, user, contact, text: str, attachments: list[str], ticket "modified_on": msg.modified_on.isoformat(), } + @_client_method + def org_deindex(self, org): + return {} + @_client_method def ticket_assign(self, org, user, tickets, assignee): now = timezone.now() From 58b979c9823c779528e38e8902975322ba59e2a1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 11:30:52 -0500 Subject: [PATCH 104/557] Tweak contacts API endpoint docs --- temba/api/v2/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 0fcf47a9a7c..5e7c3f05e61 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -1175,8 +1175,7 @@ class ContactsEndpoint(ListAPIMixin, WriteAPIMixin, DeleteAPIMixin, BaseEndpoint ## Listing Contacts - A **GET** returns the list of contacts for your organization, in the order of last activity date. You can return - only deleted contacts by passing the `deleted=true` parameter to your call. + A **GET** returns the list of contacts for your organization, in the order of last modified. * **uuid** - the UUID of the contact (string), filterable as `uuid`. * **name** - the name of the contact (string). From d6441a80210072dc0af1c120ce22a045c6bfce71 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 20:16:10 +0000 Subject: [PATCH 105/557] Use org middleware instead of view for staff --- temba/flows/views.py | 6 +----- temba/middleware.py | 6 ++++++ temba/msgs/views.py | 6 ++---- temba/orgs/views.py | 4 +++- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/temba/flows/views.py b/temba/flows/views.py index a143e1fcef8..189a3ec28b2 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1881,15 +1881,11 @@ def get_context_data(self, *args, **kwargs): return context - class Status(OrgPermsMixin, SmartListView): + class Status(OrgPermsMixin, OrgFilterMixin, SmartListView): permission = "flows.flowstart_list" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) - - if not self.request.user.is_staff: - qs = qs.filter(org=self.request.org) - id = self.request.GET.get("id", None) if id: qs = qs.filter(id=id) diff --git a/temba/middleware.py b/temba/middleware.py index 5d53e81330a..2626a31e14a 100644 --- a/temba/middleware.py +++ b/temba/middleware.py @@ -32,6 +32,7 @@ class OrgMiddleware: session_key = "org_id" header_name = "X-Temba-Org" + service_header_name = "HTTP_X_TEMBA_SERVICE_ORG" select_related = ("parent",) def __init__(self, get_response=None): @@ -64,6 +65,11 @@ def determine_org(self, request): # check for value in session org_id = request.session.get(self.session_key, None) + + # staff users alternatively can pass a service header + if user.is_staff: + org_id = request.META.get(self.service_header_name, org_id) + if org_id: org = Org.objects.filter(is_active=True, id=org_id).select_related(*self.select_related).first() diff --git a/temba/msgs/views.py b/temba/msgs/views.py index b7209c3c021..c6086a042ec 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -35,6 +35,7 @@ DependencyUsagesModal, MenuMixin, ModalMixin, + OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin, ) @@ -719,15 +720,12 @@ def form_valid(self, form): return self.render_modal_response(form) - class Status(OrgPermsMixin, SmartListView): + class Status(OrgPermsMixin, OrgFilterMixin, SmartListView): permission = "msgs.broadcast_read" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) - if not self.request.user.is_staff: - qs = qs.filter(org=self.request.org) - id = self.request.GET.get("id", None) if id: qs = qs.filter(id=id) diff --git a/temba/orgs/views.py b/temba/orgs/views.py index 3c5ed83c4a1..b8ea58b66ee 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -138,7 +138,9 @@ def has_permission(self, request, *args, **kwargs): self.args = args self.request = request - if self.get_user().is_staff: + org = self.derive_org() + + if self.get_user().is_staff and org: return True if self.get_user().is_anonymous: From e4024557ef6339b8bebda9ad926093041d8699bf Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 20:18:27 +0000 Subject: [PATCH 106/557] Add components with service attributes --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 67ced481c96..af4a750ac5f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.1", + "@nyaruka/temba-components": "0.108.2", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index adce1a0b0f7..f67c9499620 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.1": - version "0.108.1" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.1.tgz#8da7afc35a8347691efd58289c2d00b4c8b124c9" - integrity sha512-yx/S7Ut/qoeR48Ck0gpF46tVNPiROuzPI78lFME6JEMjQN89teMP0CjgWWIUrtO+HGph5kDH2urWZPeuzHcqVA== +"@nyaruka/temba-components@0.108.2": + version "0.108.2" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.2.tgz#8fff157b7414eb476d7f2fe669dd6865ed4e8a33" + integrity sha512-TwXxGLaIuXuHPtK+Dxr/VgWV9x02SvCamk5x0+Gm+VnLPtxZ4MrKRj3e1Z5KUCvae6jbp+kXxc/h+KqM1z6Gjg== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From dec3a7b44cef3b44240d0420cb5f7772f19ebc21 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 20:28:01 +0000 Subject: [PATCH 107/557] Add unit test for service header --- temba/utils/tests.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/temba/utils/tests.py b/temba/utils/tests.py index 05be70d4210..6edad3e95ec 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -14,6 +14,7 @@ from django.urls import reverse from django.utils import timezone +from temba.orgs.models import Org from temba.tests import TembaTest, matchers, override_brand from temba.utils import json, uuid from temba.utils.compose import compose_serialize @@ -292,11 +293,29 @@ def test_task3(foo, bar): class MiddlewareTest(TembaTest): def test_org(self): + + self.other_org = Org.objects.create( + name="Other Org", + timezone=ZoneInfo("Africa/Kigali"), + flow_languages=["eng", "kin"], + created_by=self.admin, + modified_by=self.admin, + ) + self.other_org.initialize() + response = self.client.get(reverse("public.public_index")) self.assertFalse(response.has_header("X-Temba-Org")) self.login(self.customer_support) + # our staff user doesn't have a default org + response = self.client.get(reverse("public.public_index")) + self.assertFalse(response.has_header("X-Temba-Org")) + + # but they can specify an org to service as a header + response = self.client.get(reverse("public.public_index"), headers={"X-Temba-Service-Org": str(self.org.id)}) + self.assertEqual(response["X-Temba-Org"], str(self.org.id)) + response = self.client.get(reverse("public.public_index")) self.assertFalse(response.has_header("X-Temba-Org")) @@ -305,6 +324,12 @@ def test_org(self): response = self.client.get(reverse("public.public_index")) self.assertEqual(response["X-Temba-Org"], str(self.org.id)) + # non-staff can't specify a different org from there own + response = self.client.get( + reverse("public.public_index"), headers={"X-Temba-Service-Org": str(self.other_org.id)} + ) + self.assertNotEqual(response["X-Temba-Org"], str(self.other_org.id)) + def test_redirect(self): self.assertNotRedirect(self.client.get(reverse("public.public_index")), None) From 901707228149c5d112cb5d1f4900bb26ab939328 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 20:46:12 +0000 Subject: [PATCH 108/557] Use headers instead of META --- temba/middleware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/middleware.py b/temba/middleware.py index 2626a31e14a..875f455202b 100644 --- a/temba/middleware.py +++ b/temba/middleware.py @@ -32,7 +32,7 @@ class OrgMiddleware: session_key = "org_id" header_name = "X-Temba-Org" - service_header_name = "HTTP_X_TEMBA_SERVICE_ORG" + service_header_name = "X-Temba-Service-Org" select_related = ("parent",) def __init__(self, get_response=None): @@ -68,7 +68,7 @@ def determine_org(self, request): # staff users alternatively can pass a service header if user.is_staff: - org_id = request.META.get(self.service_header_name, org_id) + org_id = request.headers.get(self.service_header_name, org_id) if org_id: org = Org.objects.filter(is_active=True, id=org_id).select_related(*self.select_related).first() From 9c82514094e8be076ba01e23b6c810149852d366 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 19:54:05 +0000 Subject: [PATCH 109/557] Explicitly de-index contacts when released --- temba/contacts/models.py | 5 ++++- temba/contacts/tests.py | 7 +++++-- temba/flows/tests.py | 3 ++- temba/mailroom/client/client.py | 3 +++ temba/mailroom/client/tests.py | 15 +++++++++++++++ temba/orgs/models.py | 3 ++- temba/orgs/tasks.py | 2 +- temba/tests/mailroom.py | 4 ++++ 8 files changed, 36 insertions(+), 6 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 6a4c9680604..60e5b855b96 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1057,7 +1057,7 @@ def restore(self, user): Contact.bulk_change_status(user, [self], modifiers.Status.ACTIVE) self.refresh_from_db() - def release(self, user, *, immediately=False): + def release(self, user, *, immediately=False, deindex=True): """ Releases this contact. Note that we clear all identifying data but don't hard delete the contact because we need to expose deleted contacts over the API to allow external systems to know that contacts have been deleted. @@ -1092,6 +1092,9 @@ def release(self, user, *, immediately=False): self.modified_by = user self.save(update_fields=("name", "is_active", "fields", "modified_by", "modified_on")) + if deindex: + mailroom.get_client().contact_deindex(self.org, [self]) + # the hard work of removing everything this contact owns can be given to a celery task if immediately: self._full_release() diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 2fd9fd22f24..bfc56691ee3 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -950,7 +950,8 @@ def test_interrupt(self, mr_mocks): other_org_contact.refresh_from_db() self.assertIsNotNone(other_org_contact.current_flow) - def test_delete(self): + @mock_mailroom + def test_delete(self, mr_mocks): contact = self.create_contact("Joe", phone="+593979000111") other_org_contact = self.create_contact("Hans", phone="+593979123456", org=self.org2) @@ -974,6 +975,8 @@ def test_delete(self): contact.refresh_from_db() self.assertFalse(contact.is_active) + self.assertEqual([call(self.org, [contact])], mr_mocks.calls["contact_deindex"]) + # can't delete contact in other org delete_url = reverse("contacts.contact_delete", args=[other_org_contact.id]) response = self.client.post(delete_url, {"id": other_org_contact.id}) @@ -1675,7 +1678,7 @@ def setUp(self): # create an deleted contact self.jim = self.create_contact(name="Jim") - self.jim.release(self.user) + self.jim.release(self.user, deindex=False) # create contact in other org self.other_org_contact = self.create_contact(name="Fred", phone="+250768111222", org=self.org2) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 250b62a8059..1adb9440f40 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -354,7 +354,8 @@ def test_copy_group_split_no_name(self): copy_def["nodes"][0]["router"]["cases"][0]["arguments"], ) - def test_activity(self): + @mock_mailroom + def test_activity(self, mr_mocks): flow = self.get_flow("favorites_v13") flow_nodes = flow.get_definition()["nodes"] color_prompt = flow_nodes[0] diff --git a/temba/mailroom/client/client.py b/temba/mailroom/client/client.py index 0a10418783c..f009777d23a 100644 --- a/temba/mailroom/client/client.py +++ b/temba/mailroom/client/client.py @@ -72,6 +72,9 @@ def contact_create(self, org, user, contact: ContactSpec) -> Contact: return Contact.objects.get(id=resp["contact"]["id"]) + def contact_deindex(self, org, contacts): + return self._request("contact/deindex", {"org_id": org.id, "contact_ids": [c.id for c in contacts]}) + def contact_export(self, org, group, query: str) -> list[int]: resp = self._request("contact/export", {"org_id": org.id, "group_id": group.id, "query": query}) diff --git a/temba/mailroom/client/tests.py b/temba/mailroom/client/tests.py index e81b9ca2f45..dc11cb39a2d 100644 --- a/temba/mailroom/client/tests.py +++ b/temba/mailroom/client/tests.py @@ -136,6 +136,21 @@ def test_contact_create(self, mock_post): }, ) + @patch("requests.post") + def test_contact_deindex(self, mock_post): + ann = self.create_contact("Ann", urns=["tel:+12340000001"]) + bob = self.create_contact("Bob", urns=["tel:+12340000002"]) + mock_post.return_value = MockJsonResponse(200, {"deindexed": 2}) + response = self.client.contact_deindex(self.org, [ann, bob]) + + self.assertEqual({"deindexed": 2}, response) + + mock_post.assert_called_once_with( + "http://localhost:8090/mr/contact/deindex", + headers={"User-Agent": "Temba", "Authorization": "Token sesame"}, + json={"org_id": self.org.id, "contact_ids": [ann.id, bob.id]}, + ) + @patch("requests.post") def test_contact_export(self, mock_post): group = self.create_group("Doctors", contacts=[]) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 160f5c35060..83c66bc7cbd 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1346,7 +1346,8 @@ def delete(self) -> dict: # delete our contacts for contact in self.contacts.all(): - contact.release(user, immediately=True) + # release synchronously and don't deindex as that will happen for the whole org + contact.release(user, immediately=True, deindex=False) contact.delete() counts["contacts"] += 1 diff --git a/temba/orgs/tasks.py b/temba/orgs/tasks.py index 6063357e783..b892b9ebdd1 100644 --- a/temba/orgs/tasks.py +++ b/temba/orgs/tasks.py @@ -109,7 +109,7 @@ def delete_released_orgs(): num_deleted, num_failed = 0, 0 - for org in Org.objects.filter(is_active=False, released_on__lt=week_ago, deleted_on=None): + for org in Org.objects.filter(is_active=False, released_on__lt=week_ago, deleted_on=None).order_by("released_on"): start = timezone.now() try: diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 8f7e3ea6c3f..2949143b686 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -201,6 +201,10 @@ def contact_create(self, org, user, contact: mailroom.ContactSpec): group_uuids=contact.groups, ) + @_client_method + def contact_deindex(self, org, contacts): + return {"deindexed": len(contacts)} + @_client_method def contact_export(self, org, group, query: str) -> list[int]: if self.mocks._contact_export: From f15cb52d3b3e85a404641b90e5d54585b7122830 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 14:18:33 -0700 Subject: [PATCH 110/557] Use 10th anniversary rp logo --- static/brands/rapidpro/less/variables.less | 2 +- static/images/logo-dark.svg | 26 +++++++++++++++++++++- temba/settings.py.dev | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/static/brands/rapidpro/less/variables.less b/static/brands/rapidpro/less/variables.less index 607b84d9e1d..da1eebddeb5 100644 --- a/static/brands/rapidpro/less/variables.less +++ b/static/brands/rapidpro/less/variables.less @@ -1,5 +1,5 @@ // RapidPro blue -@color-primary: #0c6596; +@color-primary: #294E8E; @color-secondary: #f3f3f3; @color-links: @color-primary; diff --git a/static/images/logo-dark.svg b/static/images/logo-dark.svg index c853153f0b9..b5e4dc3b6e9 100644 --- a/static/images/logo-dark.svg +++ b/static/images/logo-dark.svg @@ -1 +1,25 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/temba/settings.py.dev b/temba/settings.py.dev index c8b53ee9ed9..151d69687bf 100644 --- a/temba/settings.py.dev +++ b/temba/settings.py.dev @@ -47,4 +47,4 @@ warnings.filterwarnings( # ----------------------------------------------------------------------------------- # Make our sitestatic URL be our static URL on development # ----------------------------------------------------------------------------------- -STATIC_URL = "/sitestatic/" +STATIC_URL = "/static/" From fe84ed72e51f781c9615cd8ab41cd049cc4a646a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 16:29:42 -0500 Subject: [PATCH 111/557] Update CHANGELOG.md for v9.3.48 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd09441d232..05fe1faddc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v9.3.48 (2024-09-26) +------------------------- + * Use 10th anniversary rp logo + * Explicitly de-index contacts when released + * Request de-indexing of contacts when hard deleting an org + * Switch to flowstart_list permission for status + * Add status and interrupt for broadcasts and starts + v9.3.47 (2024-09-25) ------------------------- * Re-introduce QUEUED status for FlowStarts and Broadcasts diff --git a/pyproject.toml b/pyproject.toml index c64186b0cde..a9e033e39ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.47" +version = "9.3.48" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 6cc286b3894..5459fa806a9 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.47" +__version__ = "9.3.48" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 45810a223754747c8742e308745a703e751ad01c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 21:47:27 +0000 Subject: [PATCH 112/557] Fix deindexing a deleted contact --- temba/contacts/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 60e5b855b96..52be0bffcf3 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1064,6 +1064,10 @@ def release(self, user, *, immediately=False, deindex=True): """ from .tasks import full_release_contact + # do de-indexing first so if it fails for some reason, we don't go through with the delete + if deindex: + mailroom.get_client().contact_deindex(self.org, [self]) + with transaction.atomic(): # prep our urns for deletion so our old path creates a new urn for urn in self.urns.all(): @@ -1092,9 +1096,6 @@ def release(self, user, *, immediately=False, deindex=True): self.modified_by = user self.save(update_fields=("name", "is_active", "fields", "modified_by", "modified_on")) - if deindex: - mailroom.get_client().contact_deindex(self.org, [self]) - # the hard work of removing everything this contact owns can be given to a celery task if immediately: self._full_release() From d23c1b9d93a7b3e6799595992acb55e87cdd6022 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 26 Sep 2024 17:22:50 -0500 Subject: [PATCH 113/557] Update CHANGELOG.md for v9.3.49 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fe1faddc3..85ad5154cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.49 (2024-09-26) +------------------------- + * Tweak deindexing a deleted contact + v9.3.48 (2024-09-26) ------------------------- * Use 10th anniversary rp logo diff --git a/pyproject.toml b/pyproject.toml index a9e033e39ee..92d66863ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.48" +version = "9.3.49" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 5459fa806a9..bee8bae341e 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.48" +__version__ = "9.3.49" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From d31b1e8f15022b9d74eca22d52efbeeba7f4d778 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 17:33:32 -0700 Subject: [PATCH 114/557] Add commas for broadcast message count --- templates/msgs/includes/broadcast.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/msgs/includes/broadcast.html b/templates/msgs/includes/broadcast.html index 93b90e6561f..c057a9a0be9 100644 --- a/templates/msgs/includes/broadcast.html +++ b/templates/msgs/includes/broadcast.html @@ -93,7 +93,7 @@
    - {% blocktrans count message_count=broadcast.get_message_count %} + {% blocktrans with message_count=broadcast.get_message_count|intcomma count counter=broadcast.get_message_count %} {{message_count}} message {% plural %} {{message_count}} messages From 842ee93b71f9cf3172c77f2620a95a0a4342c351 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 17:34:49 -0700 Subject: [PATCH 115/557] Update CHANGELOG.md for v9.3.50 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ad5154cdb..c423917b30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.50 (2024-09-27) +------------------------- + * Add commas for broadcast message count + v9.3.49 (2024-09-26) ------------------------- * Tweak deindexing a deleted contact diff --git a/pyproject.toml b/pyproject.toml index 92d66863ab6..18df10b8b3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.49" +version = "9.3.50" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index bee8bae341e..e4d292a74b4 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.49" +__version__ = "9.3.50" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 504b685035ab19bcd4370df5f38e1b0cf2ffd0c9 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Fri, 27 Sep 2024 01:27:11 +0000 Subject: [PATCH 116/557] Update components with progress formatting tweaks --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index af4a750ac5f..bcb8845de72 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.2", + "@nyaruka/temba-components": "0.108.3", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index f67c9499620..7a7388ac1cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.2": - version "0.108.2" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.2.tgz#8fff157b7414eb476d7f2fe669dd6865ed4e8a33" - integrity sha512-TwXxGLaIuXuHPtK+Dxr/VgWV9x02SvCamk5x0+Gm+VnLPtxZ4MrKRj3e1Z5KUCvae6jbp+kXxc/h+KqM1z6Gjg== +"@nyaruka/temba-components@0.108.3": + version "0.108.3" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.3.tgz#b1d5cd3e9e2a66b553f4c3339095c3cb56ab17d5" + integrity sha512-QapbIrrQ+HO7quLFXmXDHu9LGn2/lf9U2nl4Cxw6zTeAe3cRoX0GA3kYoIZlkup6w2Ilr0cvWGupqulp/2r45Q== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From bc217c9e37e0996b443232ad8584db2bf737df91 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 26 Sep 2024 18:29:05 -0700 Subject: [PATCH 117/557] Update CHANGELOG.md for v9.3.51 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c423917b30f..0eb553e2cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.51 (2024-09-27) +------------------------- + * Update components with progress bar tweaks + v9.3.50 (2024-09-27) ------------------------- * Add commas for broadcast message count diff --git a/pyproject.toml b/pyproject.toml index 18df10b8b3e..d275023b39f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.50" +version = "9.3.51" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index e4d292a74b4..98bcaa0788b 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.50" +__version__ = "9.3.51" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 7967acc81caea6896c66b3545af667c70010de63 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 30 Sep 2024 12:45:40 -0500 Subject: [PATCH 118/557] Add test_errors to mailroom client --- temba/mailroom/client/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/temba/mailroom/client/client.py b/temba/mailroom/client/client.py index f009777d23a..45a73a1aba0 100644 --- a/temba/mailroom/client/client.py +++ b/temba/mailroom/client/client.py @@ -330,6 +330,9 @@ def ticket_reopen(self, org, user, tickets): }, ) + def test_errors(self, log, ret, panic): # pragma: no cover + return self._request("test_errors", {"log": log, "ret": ret, "panic": panic}) + def _request(self, endpoint, payload=None, files=None, post=True, encode_json=False): if logger.isEnabledFor(logging.DEBUG): # pragma: no cover logger.debug("=============== %s request ===============" % endpoint) From c572f3b0fb4ef61185d149324cac9b705813a8e2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 30 Sep 2024 14:40:44 -0500 Subject: [PATCH 119/557] Update CHANGELOG.md for v9.3.52 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb553e2cc8..b60beb3e146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.52 (2024-09-30) +------------------------- + * Add test_errors to mailroom client + v9.3.51 (2024-09-27) ------------------------- * Update components with progress bar tweaks diff --git a/pyproject.toml b/pyproject.toml index d275023b39f..5cbce7cd112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.51" +version = "9.3.52" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 98bcaa0788b..477daddabc8 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.51" +__version__ = "9.3.52" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 5924c26011f4b012f21902cc16eaf6ad6b8cc3c5 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 1 Oct 2024 23:09:26 +0200 Subject: [PATCH 120/557] Fix location aliases to only update in one workspace --- temba/locations/models.py | 4 ++-- temba/locations/tests.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/temba/locations/models.py b/temba/locations/models.py index 1eb3f761e5f..bc93d9935c5 100644 --- a/temba/locations/models.py +++ b/temba/locations/models.py @@ -103,7 +103,7 @@ def _update_child_paths(boundary): def update_aliases(self, org, user, aliases: list): siblings = self.parent.children.all() - self.aliases.all().delete() # delete any existing aliases + self.aliases.filter(org=org).delete() # delete any existing aliases for this workspace for new_alias in aliases: assert new_alias and len(new_alias) < AdminBoundary.MAX_NAME_LEN @@ -111,7 +111,7 @@ def update_aliases(self, org, user, aliases: list): # aliases are only allowed to exist on one boundary with same parent at a time BoundaryAlias.objects.filter(name=new_alias, boundary__in=siblings, org=org).delete() - BoundaryAlias.objects.create(boundary=self, org=org, name=new_alias, created_by=user, modified_by=user) + BoundaryAlias.create(org, user, self, new_alias) def release(self): for child_boundary in AdminBoundary.objects.filter(parent=self): # pragma: no cover diff --git a/temba/locations/tests.py b/temba/locations/tests.py index 21aae184b10..0d94f293647 100644 --- a/temba/locations/tests.py +++ b/temba/locations/tests.py @@ -16,6 +16,37 @@ class LocationTest(TembaTest): + def test_aliases_update(self): + self.setUpLocations() + + # make other workspace with the same locations + self.org2.country = self.country + self.org2.save(update_fields=("country",)) + + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).count(), 1) + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).get().name, "Kigari") + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).count(), 1) + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).get().name, "Chigali") + + self.state1.update_aliases(self.org, self.admin, ["Kigari", "CapitalCity", "MVK"]) + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).count(), 3) + self.assertEqual( + list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).values_list("name", flat=True)), + ["Kigari", "CapitalCity", "MVK"], + ) + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).get().name, "Chigali") + + self.state1.update_aliases(self.org2, self.admin2, ["Chigali", "CapitalCity", "MVK"]) + self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).count(), 3) + self.assertEqual( + list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).values_list("name", flat=True)), + ["Chigali", "CapitalCity", "MVK"], + ) + self.assertEqual( + list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).values_list("name", flat=True)), + ["Kigari", "CapitalCity", "MVK"], + ) + def test_boundaries(self): self.setUpLocations() From 427f7febd28569381c98f6a5ff9bf72470de67ce Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 1 Oct 2024 16:25:51 -0500 Subject: [PATCH 121/557] Update CHANGELOG.md for v9.3.53 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b60beb3e146..de9c74a984f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.53 (2024-10-01) +------------------------- + * Fix location aliases to only update in one workspace + v9.3.52 (2024-09-30) ------------------------- * Add test_errors to mailroom client diff --git a/pyproject.toml b/pyproject.toml index 5cbce7cd112..16e9083027e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.52" +version = "9.3.53" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 477daddabc8..82c16018159 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.52" +__version__ = "9.3.53" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 647c8bec34e4c8e2649f53036f4aab99d421c317 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 2 Oct 2024 13:38:49 +0200 Subject: [PATCH 122/557] Make template sync use consistent components order to avoid breaking flows variables --- temba/templates/types/whatsapp/tests.py | 90 +++++++++++++++++++++++++ temba/templates/types/whatsapp/type.py | 13 +++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/temba/templates/types/whatsapp/tests.py b/temba/templates/types/whatsapp/tests.py index 549a1a372c9..d9fbe8779af 100644 --- a/temba/templates/types/whatsapp/tests.py +++ b/temba/templates/types/whatsapp/tests.py @@ -129,6 +129,96 @@ def test_update_local_wa(self): self.assertEqual([{"type": "text"}, {"type": "text"}, {"type": "text"}, {"type": "text"}], trans.variables) self.assertTrue(trans.is_supported) + # try a template with multiple components with components order changed + trans = self.type.update_local( + channel, + { + "name": "order_template", + "components": [ + {"type": "FOOTER", "text": "Thanks for your patience"}, + { + "type": "BODY", + "text": "Sorry your order {{1}} took longer to deliver than expected.\nWe'll notify you about updates in the next {{2}} days.\n\nDo you have more question?", + "example": {"body_text": [["#123 for shoes", "3"]]}, + }, + { + "type": "BUTTONS", + "buttons": [ + {"type": "QUICK_REPLY", "text": "Yes {{1}}"}, + {"type": "QUICK_REPLY", "text": "No"}, + {"type": "PHONE_NUMBER", "text": "Call center", "phone_number": "+1234"}, + { + "type": "URL", + "text": "Check website", + "url": r"https:\/\/example.com\/?wa_customer={{1}}", + "example": [r"https:\/\/example.com\/?wa_customer=id_123"], + }, + { + "type": "URL", + "text": "Check website", + "url": r"https:\/\/example.com\/help", + "example": [r"https:\/\/example.com\/help"], + }, + ], + }, + {"type": "HEADER", "format": "TEXT", "text": "Your order!"}, + ], + "language": "en", + "status": "APPROVED", + "rejected_reason": "NONE", + "category": "UTILITY", + }, + ) + self.assertEqual("order_template", trans.template.name) + self.assertEqual( + [ + {"name": "header", "type": "header/text", "content": "Your order!", "variables": {}}, + { + "name": "body", + "type": "body/text", + "content": "Sorry your order {{1}} took longer to deliver than expected.\nWe'll notify you about updates in the next {{2}} days.\n\nDo you have more question?", + "variables": {"1": 0, "2": 1}, + }, + { + "name": "footer", + "type": "footer/text", + "content": "Thanks for your patience", + "variables": {}, + }, + { + "name": "button.0", + "type": "button/quick_reply", + "content": "Yes {{1}}", + "variables": {"1": 2}, + }, + {"name": "button.1", "type": "button/quick_reply", "content": "No", "variables": {}}, + { + "name": "button.2", + "type": "button/phone_number", + "content": "+1234", + "display": "Call center", + "variables": {}, + }, + { + "name": "button.3", + "type": "button/url", + "content": r"https:\/\/example.com\/?wa_customer={{1}}", + "display": "Check website", + "variables": {"1": 3}, + }, + { + "name": "button.4", + "type": "button/url", + "content": r"https:\/\/example.com\/help", + "display": "Check website", + "variables": {}, + }, + ], + trans.components, + ) + self.assertEqual([{"type": "text"}, {"type": "text"}, {"type": "text"}, {"type": "text"}], trans.variables) + self.assertTrue(trans.is_supported) + # try a template with non-text header trans = self.type.update_local( channel, diff --git a/temba/templates/types/whatsapp/type.py b/temba/templates/types/whatsapp/type.py index 658be40fc99..f67d1c3d575 100644 --- a/temba/templates/types/whatsapp/type.py +++ b/temba/templates/types/whatsapp/type.py @@ -55,8 +55,17 @@ def add_variables(names: list, typ: str) -> dict: map[name] = len(variables) - 1 return map + raw_dict = dict() for component in raw: - comp_type = component["type"].upper() + if component["type"].upper() not in ["HEADER", "BODY", "FOOTER", "BUTTONS"]: + supported = False + raw_dict[component["type"].upper()] = component + + for comp_type in ["HEADER", "BODY", "FOOTER", "BUTTONS"]: + component = raw_dict.get(comp_type) + if component is None: + continue + comp_text = component.get("text", "") if comp_type == "HEADER": @@ -127,7 +136,5 @@ def add_variables(names: list, typ: str) -> dict: else: supported = False - else: - supported = False return components, variables, supported From 862216bfa8849167b984b513e3e80e32358ae7e2 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 2 Oct 2024 15:36:12 +0200 Subject: [PATCH 123/557] Adjust background flow start preview to include all contacts in other flows --- temba/flows/tests.py | 55 ++++++++++++++++++++++++++++++++++++++++++-- temba/flows/views.py | 3 +++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 24ddb7441ab..340b9d0b1b9 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2292,7 +2292,7 @@ def test_inactive_flow(self): @mock_mailroom def test_preview_start(self, mr_mocks): - flow = self.create_flow("Test") + flow = self.create_flow("Test Flow") self.create_field("age", "Age") self.create_contact("Ann", phone="+16302222222", fields={"age": 40}) self.create_contact("Bob", phone="+16303333333", fields={"age": 33}) @@ -2322,6 +2322,32 @@ def test_preview_start(self, mr_mocks): response.json(), ) + mr_mocks.flow_start_preview( + query='age > 30 AND status = "active" AND history != "Test Flow" AND flow = ""', total=100 + ) + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + + self.login(self.editor) + + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True, "in_a_flow": True}, + }, + content_type="application/json", + ) + self.assertEqual( + { + "query": 'age > 30 AND status = "active" AND history != "Test Flow" AND flow = ""', + "total": 100, + "send_time": 10.0, + "warnings": [], + "blockers": [], + }, + response.json(), + ) + # try with a bad query mr_mocks.exception(mailroom.QueryValidationException("mismatched input at (((", "syntax")) @@ -2391,7 +2417,7 @@ def test_preview_start(self, mr_mocks): # shouldn't be able to since we don't have a call channel self.org.flow_starts.all().delete() - mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Test Flow"', total=100) + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "IVR Test"', total=100) response = self.client.post( preview_url, @@ -2491,6 +2517,31 @@ def test_preview_start(self, mr_mocks): 'To start this flow you need to add a channel to your workspace which will allow you to send messages to your contacts.', ) + flow = self.create_flow("Background Flow", flow_type=Flow.TYPE_BACKGROUND) + mr_mocks.flow_start_preview(query='age > 30 AND status = "active" AND history != "Background Flow"', total=100) + preview_url = reverse("flows.flow_preview_start", args=[flow.id]) + + self.login(self.editor) + + response = self.client.post( + preview_url, + { + "query": "age > 30", + "exclusions": {"non_active": True, "started_previously": True, "in_a_flow": True}, + }, + content_type="application/json", + ) + self.assertEqual( + { + "query": 'age > 30 AND status = "active" AND history != "Background Flow"', + "total": 100, + "send_time": 0.0, + "warnings": [], + "blockers": [], + }, + response.json(), + ) + @mock_mailroom def test_template_warnings(self, mr_mocks): self.login(self.admin) diff --git a/temba/flows/views.py b/temba/flows/views.py index 189a3ec28b2..ceace204db4 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -1589,6 +1589,9 @@ def post(self, request, *args, **kwargs): exclude = mailroom.Exclusions(**payload.get("exclude", {})) flow = self.get_object() + if flow and flow.flow_type == Flow.TYPE_BACKGROUND: + exclude.in_a_flow = False + try: query, total = FlowStart.preview(flow, include=include, exclude=exclude) except mailroom.QueryValidationException as e: From bfdbdaa9de3cbaade163be99f3a2b2f758d15d3f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 2 Oct 2024 10:13:16 -0500 Subject: [PATCH 124/557] Update CHANGELOG.md for v9.3.54 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9c74a984f..c37742eb6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.54 (2024-10-02) +------------------------- + * Adjust background flow start preview to include all contacts in other flows + * Make template sync use consistent components order to avoid breaking flows variables + v9.3.53 (2024-10-01) ------------------------- * Fix location aliases to only update in one workspace diff --git a/pyproject.toml b/pyproject.toml index 16e9083027e..49c786dfd33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.53" +version = "9.3.54" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 82c16018159..d2f24ec575f 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.53" +__version__ = "9.3.54" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 166c5fa4199555add3a2227e3e21f5c9143b0f58 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 2 Oct 2024 20:38:09 +0000 Subject: [PATCH 125/557] Fix topic create and update and tweak list pages for consistency --- temba/contacts/tests.py | 2 +- temba/contacts/views.py | 3 +- temba/globals/tests.py | 2 +- temba/globals/views.py | 4 +- temba/tickets/forms.py | 30 +++++++++++++ temba/tickets/models.py | 2 +- temba/tickets/tests.py | 70 ++++++++++++++++++------------ temba/tickets/views.py | 44 ++++++------------- templates/globals/global_list.html | 4 -- 9 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 temba/tickets/forms.py diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index bfc56691ee3..3c7a62fa4e5 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -3500,7 +3500,7 @@ def test_list(self): list_url, [self.user, self.editor, self.admin], context_objects=[self.age, self.gender, self.state] ) self.assertContentMenu(list_url, self.user, []) - self.assertContentMenu(list_url, self.admin, ["New Field"]) + self.assertContentMenu(list_url, self.admin, ["New"]) def test_create_warnings(self): self.login(self.admin) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index b0659e5f37d..9f2b2601d57 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -1136,9 +1136,10 @@ class List(ContentMenuMixin, SpaMixin, OrgPermsMixin, SmartListView): def build_content_menu(self, menu): if self.has_org_perm("contacts.contactfield_create"): menu.add_modax( - _("New Field"), + _("New"), "new-field", f"{reverse('contacts.contactfield_create')}", + title=_("New Field"), on_submit="handleFieldUpdated()", as_button=True, ) diff --git a/temba/globals/tests.py b/temba/globals/tests.py index 2c937f68d09..90a8defadea 100644 --- a/temba/globals/tests.py +++ b/temba/globals/tests.py @@ -101,7 +101,7 @@ def test_list_and_unused(self): self.assertEqual(list(response.context["object_list"]), [self.global2]) self.assertListFetch(unused_url, [self.user, self.editor, self.admin], context_objects=[self.global2]) - self.assertContentMenu(list_url, self.admin, ["New Global"]) + self.assertContentMenu(list_url, self.admin, ["New"]) @override_settings(ORG_LIMIT_DEFAULTS={"globals": 4}) def test_create(self): diff --git a/temba/globals/views.py b/temba/globals/views.py index badfc70191a..e87961b3fd1 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -112,7 +112,7 @@ class Delete(DependencyDeleteModal): success_url = "@globals.global_list" class List(SpaMixin, ContentMenuMixin, OrgPermsMixin, SmartListView): - title = _("Manage Globals") + title = _("Globals") fields = ("name", "key", "value") search_fields = ("name__icontains", "key__icontains") default_order = ("key",) @@ -122,7 +122,7 @@ class List(SpaMixin, ContentMenuMixin, OrgPermsMixin, SmartListView): def build_content_menu(self, menu): if self.has_org_perm("globals.global_create"): menu.add_modax( - _("New Global"), + _("New"), "new-global", reverse("globals.global_create"), title=_("New Global"), diff --git a/temba/tickets/forms.py b/temba/tickets/forms.py new file mode 100644 index 00000000000..7028b9e4c02 --- /dev/null +++ b/temba/tickets/forms.py @@ -0,0 +1,30 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from .models import Topic + + +class TopicForm(forms.ModelForm): + def __init__(self, org, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + assert not self.instance or not self.instance.is_system, "cannot edit system topic" + + self.org = org + + def clean_name(self): + name = self.cleaned_data["name"] + + # make sure the name isn't already taken + conflicts = self.org.topics.filter(name__iexact=name) + if self.instance: + conflicts = conflicts.exclude(id=self.instance.id) + + if conflicts.exists(): + raise forms.ValidationError(_("Topic with this name already exists.")) + + return name + + class Meta: + model = Topic + fields = ("name",) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 919bd3381e3..cece5931df9 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -50,7 +50,7 @@ def create_default_topic(cls, org): @classmethod def create(cls, org, user, name: str): assert cls.is_valid_name(name), f"'{name}' is not a valid topic name" - assert not org.topics.filter(name__iexact=name).exists() + assert not org.topics.filter(name__iexact=name).exists(), f"topic with name '{name}' already exists" return org.topics.create(name=name, created_by=user, modified_by=user) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index b63863b999a..0a6d7b26d4c 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -244,8 +244,10 @@ def test_create(self): create_url = reverse("tickets.topic_create") self.assertRequestDisallowed(create_url, [None, self.agent, self.user]) + self.assertCreateFetch(create_url, [self.editor, self.admin], form_fields=("name",)) + # try to create with empty name self.assertCreateSubmit( create_url, self.admin, @@ -253,6 +255,22 @@ def test_create(self): form_errors={"name": "This field is required."}, ) + # try to create with name that is already taken + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "general"}, + form_errors={"name": "Topic with this name already exists."}, + ) + + # try to create with name that is too long + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "X" * 65}, + form_errors={"name": "Ensure this value has at most 64 characters (it has 65)."}, + ) + self.assertCreateSubmit( create_url, self.admin, @@ -262,54 +280,52 @@ def test_create(self): ) def test_update(self): - system_topic = Topic.objects.filter(org=self.org, is_system=True).first() - user_topic = Topic.objects.create(org=self.org, name="Hot Topic", created_by=self.admin, modified_by=self.admin) + topic = Topic.create(self.org, self.admin, "Hot Topic") - # can't edit a system topic - update_url = reverse("tickets.topic_update", args=[system_topic.uuid]) - self.assertUpdateSubmit( - update_url, - self.admin, - {"name": "My Topic"}, - form_errors={"name": "Cannot edit system topic"}, - object_unchanged=system_topic, - ) + update_url = reverse("tickets.topic_update", args=[topic.uuid]) - # check permissions self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2]) + self.assertUpdateFetch(update_url, [self.editor, self.admin], form_fields=["name"]) - # names must be unique - update_url = reverse("tickets.topic_update", args=[user_topic.uuid]) + # names must be unique (case-insensitive) self.assertUpdateSubmit( update_url, self.admin, - {"name": "General"}, - form_errors={"name": "Topic already exists, please try another name"}, - object_unchanged=user_topic, + {"name": "general"}, + form_errors={"name": "Topic with this name already exists."}, + object_unchanged=topic, ) - # edit successfully - self.assertUpdateSubmit(update_url, self.admin, {"name": "Boring Tickets"}, success_status=302) + self.assertUpdateSubmit(update_url, self.admin, {"name": "Boring"}, success_status=302) - user_topic.refresh_from_db() - self.assertEqual(user_topic.name, "Boring Tickets") + topic.refresh_from_db() + self.assertEqual(topic.name, "Boring") + + # can't edit a system topic + with self.assertRaises(AssertionError): + self.requestView(reverse("tickets.topic_update", args=[self.org.default_ticket_topic.uuid]), self.admin) def test_delete(self): - system_topic = Topic.objects.filter(org=self.org, is_system=True).first() - user_topic = Topic.objects.create(org=self.org, name="Hot Topic", created_by=self.admin, modified_by=self.admin) + topic1 = Topic.create(self.org, self.admin, "Planes") + topic2 = Topic.create(self.org, self.admin, "Trains") + + delete_url = reverse("tickets.topic_delete", args=[topic1.uuid]) - delete_url = reverse("tickets.topic_delete", args=[user_topic.uuid]) self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2]) response = self.assertDeleteFetch(delete_url, [self.editor, self.admin]) self.assertContains(response, "You are about to delete") # submit to delete it - response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=user_topic, success_status=302) + response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=topic1, success_status=302) + + # other topic unafected + topic2.refresh_from_db() + self.assertTrue(topic2.is_active) - # we should have been redirected to the system topic - self.assertEqual(f"/ticket/{system_topic.uuid}/open/", response.url) + # we should have been redirected to the default topic + self.assertEqual(f"/ticket/{self.org.default_ticket_topic.uuid}/open/", response.url) class TicketCRUDLTest(TembaTest, CRUDLTestMixin): diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 3186e79936d..29709529ab7 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -26,6 +26,7 @@ from temba.utils.uuid import UUID_REGEX from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin +from .forms import TopicForm from .models import ( AllFolder, MineFolder, @@ -43,45 +44,28 @@ class TopicCRUDL(SmartCRUDL): model = Topic actions = ("create", "update", "delete") - slug_field = "uuid" class Create(OrgPermsMixin, ComponentFormMixin, ModalMixin, SmartCreateView): - class TopicForm(forms.ModelForm): - class Meta: - model = Topic - fields = ("name",) - form_class = TopicForm success_url = "hide" - def pre_save(self, obj): - obj = super().pre_save(obj) - obj.org = self.request.org - return obj - - class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): - class Form(forms.ModelForm): - def clean_name(self): - name = self.cleaned_data["name"] - - if self.instance.is_system: - raise forms.ValidationError(_("Cannot edit system topic")) - - # make sure the name isn't already taken - existing = self.instance.org.topics.filter(is_active=True, name__iexact=name).first() - if existing and self.instance != existing: - raise forms.ValidationError(_("Topic already exists, please try another name")) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs - return name - - class Meta: - fields = ("name",) - model = Topic + def save(self, obj): + return Topic.create(self.request.org, self.request.user, obj.name) + class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): + form_class = TopicForm success_url = "hide" slug_url_kwarg = "uuid" - fields = ("name",) - form_class = Form + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" diff --git a/templates/globals/global_list.html b/templates/globals/global_list.html index c15acc3c45f..9bcbe0cecdb 100644 --- a/templates/globals/global_list.html +++ b/templates/globals/global_list.html @@ -115,10 +115,6 @@ {% block extra-style %} {{ block.super }} +{% endblock modal-extra-style %} diff --git a/templates/tickets/shortcut_delete.html b/templates/tickets/shortcut_delete.html new file mode 100644 index 00000000000..d6a3c671c0b --- /dev/null +++ b/templates/tickets/shortcut_delete.html @@ -0,0 +1,8 @@ +{% extends "includes/modax.html" %} +{% load i18n %} + +{% block fields %} + {% blocktrans trimmed %} + You are about to delete the shortcut {{ object }}. There is no way to undo this. Are you sure? + {% endblocktrans %} +{% endblock fields %} diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html new file mode 100644 index 00000000000..a7a7b5cde55 --- /dev/null +++ b/templates/tickets/shortcut_list.html @@ -0,0 +1,69 @@ +{% extends "smartmin/list.html" %} +{% load smartmin temba i18n humanize %} + +{% block content %} +
    + {% blocktrans trimmed %} + These are canned responses that agents can use to quickly reply to tickets. + {% endblocktrans %} +
    + {% block pre-table %} + + + + + {% endblock pre-table %} +
    {% include "includes/short_pagination.html" %}
    +
    +
    - - -
    {% include "includes/recipients.html" with groups=broadcast.groups.all contacts=broadcast.contacts.all urns=broadcast.urns %}
    -
    -
    -
    -
    {{ translation.text }}
    -
    -
    - {% if translation.attachments %} -
    - {% for attachment in translation.attachments %} - {% attachment_button attachment %} - {% endfor %} -
    - {% endif %} -
    - {{ broadcast.created_on|timedate }} -
    @@ -66,7 +37,7 @@
    {% trans "No matching messages." %}
    - +
    - - + + +
    + + {% for obj in object_list %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {{ obj.name }} +
    {{ obj.text|truncatechars:100 }}
    +
    + {% if org_perms.tickets.shortcut_delete %} + + {% endif %} +
    {% trans "No shortcuts" %}
    +
    +{% endblock content %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %} From ee604b9fe5132e215faf56dff8ee3493331a6899 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 2 Oct 2024 22:09:45 +0000 Subject: [PATCH 127/557] Update components to get new icon --- package.json | 2 +- templates/tickets/shortcut_list.html | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bcb8845de72..0b0d75161db 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.3", + "@nyaruka/temba-components": "0.108.4", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html index a7a7b5cde55..d168b0b6c23 100644 --- a/templates/tickets/shortcut_list.html +++ b/templates/tickets/shortcut_list.html @@ -36,7 +36,7 @@ {% empty %} - {% trans "No shortcuts" %} + {% trans "No shortcuts" %} {% endfor %} diff --git a/yarn.lock b/yarn.lock index 7a7388ac1cc..ff720533ff2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.3": - version "0.108.3" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.3.tgz#b1d5cd3e9e2a66b553f4c3339095c3cb56ab17d5" - integrity sha512-QapbIrrQ+HO7quLFXmXDHu9LGn2/lf9U2nl4Cxw6zTeAe3cRoX0GA3kYoIZlkup6w2Ilr0cvWGupqulp/2r45Q== +"@nyaruka/temba-components@0.108.4": + version "0.108.4" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.4.tgz#f1eefeca084665f804a3c8a7f8b81f59830dedac" + integrity sha512-WkJC/OBldUEKrKeF15iCeozouXWUSvPXCQMAvdrofLGgzKduUom4t5Vn+xfXrEYUNrTmjknFqaAH663Wno6Dvg== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 48f59acbe93dcfc69e15ee3a8cb68eedeb482d12 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 2 Oct 2024 22:19:52 +0000 Subject: [PATCH 128/557] Add internal API endpoint for fetching shortcuts --- temba/api/internal/serializers.py | 9 +++++++++ temba/api/internal/tests.py | 32 ++++++++++++++++++++++++++++++- temba/api/internal/urls.py | 3 ++- temba/api/internal/views.py | 7 +++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/temba/api/internal/serializers.py b/temba/api/internal/serializers.py index 840cef83650..b518f46fdf4 100644 --- a/temba/api/internal/serializers.py +++ b/temba/api/internal/serializers.py @@ -4,6 +4,7 @@ from temba.locations.models import AdminBoundary from temba.templates.models import Template, TemplateTranslation +from temba.tickets.models import Shortcut class ModelAsJsonSerializer(serializers.BaseSerializer): @@ -17,6 +18,14 @@ class Meta: fields = ("osm_id", "name", "path") +class ShortcutReadSerializer(serializers.ModelSerializer): + modified_on = serializers.DateTimeField(default_timezone=tzone.utc) + + class Meta: + model = Shortcut + fields = ("uuid", "name", "text", "modified_on") + + class TemplateReadSerializer(serializers.ModelSerializer): STATUSES = { TemplateTranslation.STATUS_PENDING: "pending", diff --git a/temba/api/internal/tests.py b/temba/api/internal/tests.py index bfdd9f772b7..4d7047437b4 100644 --- a/temba/api/internal/tests.py +++ b/temba/api/internal/tests.py @@ -6,7 +6,7 @@ from temba.notifications.types import ExportFinishedNotificationType from temba.templates.models import TemplateTranslation from temba.tests import TembaTest, matchers -from temba.tickets.models import TicketExport +from temba.tickets.models import Shortcut, TicketExport NUM_BASE_QUERIES = 4 # number of queries required for any request (internal API is session only) @@ -122,6 +122,36 @@ def test_notifications(self): self.assertEqual(2, self.admin.notifications.filter(is_seen=True).count()) self.assertEqual(1, self.editor.notifications.filter(is_seen=False).count()) + def test_shortcuts(self): + endpoint_url = reverse("api.internal.shortcuts") + ".json" + + self.assertGetNotPermitted(endpoint_url, [None, self.agent]) + self.assertPostNotAllowed(endpoint_url) + self.assertDeleteNotAllowed(endpoint_url) + + shortcut1 = Shortcut.create(self.org, self.admin, "Planes", "Planes are...") + shortcut2 = Shortcut.create(self.org, self.admin, "Trains", "Trains are...") + Shortcut.create(self.org2, self.admin, "Cars", "Other org") + + self.assertGet( + endpoint_url, + [self.admin], + results=[ + { + "uuid": str(shortcut2.uuid), + "name": "Trains", + "text": "Trains are...", + "modified_on": matchers.ISODate(), + }, + { + "uuid": str(shortcut1.uuid), + "name": "Planes", + "text": "Planes are...", + "modified_on": matchers.ISODate(), + }, + ], + ) + def test_templates(self): endpoint_url = reverse("api.internal.templates") + ".json" diff --git a/temba/api/internal/urls.py b/temba/api/internal/urls.py index 4209cdb6947..3d8fd567726 100644 --- a/temba/api/internal/urls.py +++ b/temba/api/internal/urls.py @@ -2,12 +2,13 @@ from django.urls import re_path -from .views import LocationsEndpoint, NotificationsEndpoint, TemplatesEndpoint +from .views import LocationsEndpoint, NotificationsEndpoint, ShortcutsEndpoint, TemplatesEndpoint urlpatterns = [ # ========== endpoints A-Z =========== re_path(r"^locations$", LocationsEndpoint.as_view(), name="api.internal.locations"), re_path(r"^notifications$", NotificationsEndpoint.as_view(), name="api.internal.notifications"), + re_path(r"^shortcuts$", ShortcutsEndpoint.as_view(), name="api.internal.shortcuts"), re_path(r"^templates$", TemplatesEndpoint.as_view(), name="api.internal.templates"), ] diff --git a/temba/api/internal/views.py b/temba/api/internal/views.py index d2c81c9b585..892cd4f6cbe 100644 --- a/temba/api/internal/views.py +++ b/temba/api/internal/views.py @@ -8,6 +8,7 @@ from temba.locations.models import AdminBoundary from temba.notifications.models import Notification from temba.templates.models import Template, TemplateTranslation +from temba.tickets.models import Shortcut from ..models import APIPermission, SSLPermission from ..support import APISessionAuthentication, CreatedOnCursorPagination, ModifiedOnCursorPagination @@ -85,6 +86,12 @@ def delete(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) +class ShortcutsEndpoint(ListAPIMixin, BaseEndpoint): + model = Shortcut + serializer_class = serializers.ShortcutReadSerializer + pagination_class = ModifiedOnCursorPagination + + class TemplatesEndpoint(ListAPIMixin, BaseEndpoint): """ WhatsApp templates with their translations. From dfeb362f2092968581589b4be2fc63186001b7f3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 2 Oct 2024 22:24:46 +0000 Subject: [PATCH 129/557] Coverage --- temba/tickets/tests.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 354767b58cf..eb4754ef118 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -279,6 +279,31 @@ def test_create(self): success_status=302, ) + def test_update(self): + shortcut = Shortcut.create(self.org, self.admin, "Planes", "Planes are...") + Shortcut.create(self.org, self.admin, "Trains", "Trains are...") + + update_url = reverse("tickets.shortcut_update", args=[shortcut.id]) + + self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2]) + + self.assertUpdateFetch(update_url, [self.editor, self.admin], form_fields=["name", "text"]) + + # names must be unique (case-insensitive) + self.assertUpdateSubmit( + update_url, + self.admin, + {"name": "trains", "text": "Trains are..."}, + form_errors={"name": "Shortcut with this name already exists."}, + object_unchanged=shortcut, + ) + + self.assertUpdateSubmit(update_url, self.admin, {"name": "Cars", "text": "Cars are..."}, success_status=302) + + shortcut.refresh_from_db() + self.assertEqual(shortcut.name, "Cars") + self.assertEqual(shortcut.text, "Cars are...") + def test_delete(self): shortcut1 = Shortcut.create(self.org, self.admin, "Planes", "Planes are...") shortcut2 = Shortcut.create(self.org, self.admin, "Trains", "Trains are...") From 467422f385c13b7f2d5fd7aef885784bf9bde13f Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Thu, 3 Oct 2024 11:45:12 +0200 Subject: [PATCH 130/557] Add pagination to flow starts and webhook logs pages --- templates/flows/flowstart_list.html | 1 + templates/request_logs/httplog_webhooks.html | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/flows/flowstart_list.html b/templates/flows/flowstart_list.html index e823b1757ee..0c3a8031ee8 100644 --- a/templates/flows/flowstart_list.html +++ b/templates/flows/flowstart_list.html @@ -10,6 +10,7 @@ {% endblock extra-style %} {% block content %} +
    {% include "includes/short_pagination.html" %}
    diff --git a/templates/request_logs/httplog_webhooks.html b/templates/request_logs/httplog_webhooks.html index e821884c754..87243c2ae35 100644 --- a/templates/request_logs/httplog_webhooks.html +++ b/templates/request_logs/httplog_webhooks.html @@ -2,6 +2,7 @@ {% load i18n temba humanize %} {% block content %} +
    {% include "includes/short_pagination.html" %}
    From 87b73dfdf18793adcd0347f9bbd3f0e2eff4f4da Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 14:34:06 +0000 Subject: [PATCH 131/557] Temporarily hide menu item for shortcuts --- temba/tickets/tests.py | 10 +++++++++- temba/tickets/views.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index eb4754ef118..05cb0dc66cd 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -516,7 +516,15 @@ def test_menu(self): self.assertPageMenu( menu_url, self.admin, - ["My Tickets (2)", "Unassigned (1)", "All (3)", "Shortcuts (0)", "Export", "New Topic", "General (3)"], + [ + "My Tickets (2)", + "Unassigned (1)", + "All (3)", + # "Shortcuts (0)", + "Export", + "New Topic", + "General (3)", + ], ) self.assertPageMenu(menu_url, self.agent, ["My Tickets (0)", "Unassigned (1)", "All (3)", "General (3)"]) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 2154069ae31..02992a6d767 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -309,15 +309,15 @@ def derive_menu(self): ) menu.append(self.create_divider()) - menu.append( - self.create_menu_item( - menu_id="shortcuts", - name=_("Shortcuts"), - icon="shortcut", - count=org.shortcuts.filter(is_active=True).count(), - href="tickets.shortcut_list", - ) - ) + # menu.append( + # self.create_menu_item( + # menu_id="shortcuts", + # name=_("Shortcuts"), + # icon="shortcut", + # count=org.shortcuts.filter(is_active=True).count(), + # href="tickets.shortcut_list", + # ) + # ) menu.append(self.create_modax_button(_("Export"), "tickets.ticket_export", icon="export")) menu.append( self.create_modax_button(_("New Topic"), "tickets.topic_create", icon="add", on_submit="refreshMenu()") From 0156ed3b6a92165d96e4880dc72d9e07674091bd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 15:06:21 +0000 Subject: [PATCH 132/557] Add query check to test --- temba/api/internal/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temba/api/internal/tests.py b/temba/api/internal/tests.py index 4d7047437b4..4738ccaf9c4 100644 --- a/temba/api/internal/tests.py +++ b/temba/api/internal/tests.py @@ -150,6 +150,7 @@ def test_shortcuts(self): "modified_on": matchers.ISODate(), }, ], + num_queries=NUM_BASE_QUERIES + 1, ) def test_templates(self): From c88a4f6f8e09ebd52a99e522147dcbb88d31fc08 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 15:22:30 +0000 Subject: [PATCH 133/557] Tweak intermittently failing test --- temba/orgs/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 5715727f591..632af5e630f 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -12,7 +12,7 @@ from django.contrib.auth.models import Group from django.core import mail from django.core.files.storage import default_storage -from django.db.models import Model +from django.db.models import F, Model from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone @@ -1492,7 +1492,7 @@ def test_release_and_delete(self, mr_mocks): self.assertOrgActive(self.org2, org2_content) # make it look like released orgs were released over a week ago - Org.objects.exclude(released_on=None).update(released_on=timezone.now() - timedelta(days=8)) + Org.objects.exclude(released_on=None).update(released_on=F("released_on") - timedelta(days=8)) delete_released_orgs() From 7082dba231be6417136cce7ed17b52a1303d91dc Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 10:41:53 -0500 Subject: [PATCH 134/557] Update CHANGELOG.md for v9.3.55 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c37742eb6d3..67410e73d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v9.3.55 (2024-10-03) +------------------------- + * Temporarily hide menu item for shortcuts + * Add pagination to flow starts and webhook logs pages + * Add internal API endpoint for fetching shortcuts + * Add model and CRUDL views for ticket shortcuts + * Fix topic create and update and tweak list pages for consistency + v9.3.54 (2024-10-02) ------------------------- * Adjust background flow start preview to include all contacts in other flows diff --git a/pyproject.toml b/pyproject.toml index 49c786dfd33..017e9b2d893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.54" +version = "9.3.55" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index d2f24ec575f..c108ae6cb08 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.54" +__version__ = "9.3.55" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From ee5b0a09a3903e2d035f845e624c3d0d09d5575d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 16:24:14 +0000 Subject: [PATCH 135/557] Move org dependent view mixins into mixins moduley --- temba/airtime/views.py | 2 +- temba/api/v2/views.py | 2 +- temba/archives/views.py | 2 +- temba/campaigns/views.py | 3 +- temba/channels/types/plivo/views.py | 2 +- temba/channels/types/twilio/views.py | 2 +- temba/channels/types/vonage/views.py | 2 +- temba/channels/types/whatsapp/views.py | 3 +- temba/channels/views.py | 3 +- temba/classifiers/views.py | 3 +- temba/contacts/views.py | 11 +-- temba/dashboard/views.py | 2 +- temba/flows/views.py | 11 +-- temba/globals/views.py | 3 +- temba/locations/views.py | 4 +- temba/msgs/views.py | 12 +-- temba/notifications/views.py | 2 +- temba/orgs/mixins.py | 108 +++++++++++++++++++++++++ temba/orgs/views.py | 107 +----------------------- temba/request_logs/views.py | 2 +- temba/templates/views.py | 3 +- temba/tickets/views.py | 3 +- temba/triggers/views.py | 3 +- temba/utils/whatsapp/views.py | 2 +- 24 files changed, 144 insertions(+), 153 deletions(-) create mode 100644 temba/orgs/mixins.py diff --git a/temba/airtime/views.py b/temba/airtime/views.py index f3e69a23529..2fd0eb8c6a5 100644 --- a/temba/airtime/views.py +++ b/temba/airtime/views.py @@ -6,7 +6,7 @@ from temba.airtime.models import AirtimeTransfer from temba.contacts.models import URN, ContactURN -from temba.orgs.views import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.request_logs.models import HTTPLog from temba.utils.views import SpaMixin diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 5e7c3f05e61..e9a2ab45572 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -21,8 +21,8 @@ from temba.globals.models import Global from temba.locations.models import AdminBoundary, BoundaryAlias from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, OptIn, SystemLabel +from temba.orgs.mixins import OrgPermsMixin from temba.orgs.models import OrgMembership, User -from temba.orgs.views import OrgPermsMixin from temba.tickets.models import Ticket, TicketCount, Topic from temba.utils import str_to_bool from temba.utils.uuid import is_uuid diff --git a/temba/archives/views.py b/temba/archives/views.py index 99f2c246915..cdb2c7cb25c 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -4,7 +4,7 @@ from django.http import HttpResponseRedirect -from temba.orgs.views import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.views import SpaMixin from .models import Archive diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index d7b5472b9ca..61548e9483d 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -19,7 +19,8 @@ from temba.contacts.models import ContactField, ContactGroup from temba.flows.models import Flow from temba.msgs.models import Msg -from temba.orgs.views import MenuMixin, ModalMixin, OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import MenuMixin, ModalMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin diff --git a/temba/channels/types/plivo/views.py b/temba/channels/types/plivo/views.py index 84a1b51a06a..f5ba611fd77 100644 --- a/temba/channels/types/plivo/views.py +++ b/temba/channels/types/plivo/views.py @@ -13,7 +13,7 @@ from temba.channels.models import Channel from temba.channels.views import BaseClaimNumberMixin, ChannelTypeMixin, ClaimViewMixin -from temba.orgs.views import OrgPermsMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import SelectWidget from temba.utils.http import http_headers diff --git a/temba/channels/types/twilio/views.py b/temba/channels/types/twilio/views.py index 0fe1493be18..beacd6857b2 100644 --- a/temba/channels/types/twilio/views.py +++ b/temba/channels/types/twilio/views.py @@ -13,7 +13,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import OrgPermsMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import InputWidget, SelectWidget from temba.utils.timezones import timezone_to_country_code diff --git a/temba/channels/types/vonage/views.py b/temba/channels/types/vonage/views.py index 1b6bbe568fd..c44771588fd 100644 --- a/temba/channels/types/vonage/views.py +++ b/temba/channels/types/vonage/views.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import OrgPermsMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import InputWidget, SelectWidget from temba.utils.models import generate_uuid diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 5a641981eea..199ddb05ff7 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -10,7 +10,8 @@ from django.utils.translation import gettext_lazy as _ from temba.channels.views import ChannelTypeMixin -from temba.orgs.views import ModalMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import ModalMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate from temba.utils.views import ContentMenuMixin diff --git a/temba/channels/views.py b/temba/channels/views.py index 10bc5b49249..e4710f40f33 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -35,7 +35,8 @@ from temba.ivr.models import Call from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import DependencyDeleteModal, ModalMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import DependencyDeleteModal, ModalMixin from temba.utils import countries from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 63ad01c6b37..2f178b9f65d 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -5,7 +5,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import DependencyDeleteModal, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import DependencyDeleteModal from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin from .models import Classifier diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 9f2b2601d57..39090c59b10 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -33,16 +33,9 @@ from temba.channels.models import Channel from temba.mailroom.events import Event from temba.notifications.views import NotificationTargetMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import User -from temba.orgs.views import ( - BaseExportView, - DependencyDeleteModal, - DependencyUsagesModal, - MenuMixin, - ModalMixin, - OrgObjPermsMixin, - OrgPermsMixin, -) +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, MenuMixin, ModalMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime diff --git a/temba/dashboard/views.py b/temba/dashboard/views.py index 80eae185a5a..f374073fc3a 100644 --- a/temba/dashboard/views.py +++ b/temba/dashboard/views.py @@ -9,8 +9,8 @@ from django.utils.translation import gettext_lazy as _ from temba.channels.models import Channel, ChannelCount +from temba.orgs.mixins import OrgPermsMixin from temba.orgs.models import Org -from temba.orgs.views import OrgPermsMixin from temba.utils.views import SpaMixin flattened_colors = [ diff --git a/temba/flows/views.py b/temba/flows/views.py index ceace204db4..1845a7b7b1d 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -34,16 +34,9 @@ from temba.flows.models import Flow, FlowRevision, FlowRun, FlowSession, FlowStart from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call +from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import ( - BaseExportView, - DependencyDeleteModal, - MenuMixin, - ModalMixin, - OrgFilterMixin, - OrgObjPermsMixin, - OrgPermsMixin, -) +from temba.orgs.views import BaseExportView, DependencyDeleteModal, MenuMixin, ModalMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit from temba.utils.fields import ( diff --git a/temba/globals/views.py b/temba/globals/views.py index e87961b3fd1..b6593735dca 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -5,7 +5,8 @@ from django import forms from django.urls import reverse -from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal, ModalMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal, ModalMixin from temba.utils.fields import InputWidget from temba.utils.views import ContentMenuMixin, SpaMixin diff --git a/temba/locations/views.py b/temba/locations/views.py index 9ce123a07a3..1e82ad5a5bc 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -8,9 +8,9 @@ from django.views.decorators.csrf import csrf_exempt from temba.locations.models import AdminBoundary, BoundaryAlias -from temba.orgs.views import OrgPermsMixin, SpaMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils import json -from temba.utils.views import ContentMenuMixin +from temba.utils.views import ContentMenuMixin, SpaMixin class BoundaryCRUDL(SmartCRUDL): diff --git a/temba/msgs/views.py b/temba/msgs/views.py index c6086a042ec..08342dfe948 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -28,17 +28,9 @@ from temba import mailroom from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions +from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import Org -from temba.orgs.views import ( - BaseExportView, - DependencyDeleteModal, - DependencyUsagesModal, - MenuMixin, - ModalMixin, - OrgFilterMixin, - OrgObjPermsMixin, - OrgPermsMixin, -) +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, MenuMixin, ModalMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation from temba.utils import json, languages diff --git a/temba/notifications/views.py b/temba/notifications/views.py index 943cad85245..2358005115b 100644 --- a/temba/notifications/views.py +++ b/temba/notifications/views.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import OrgPermsMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils.views import SpaMixin from .mixins import NotificationTargetMixin diff --git a/temba/orgs/mixins.py b/temba/orgs/mixins.py new file mode 100644 index 00000000000..d87e5e7697f --- /dev/null +++ b/temba/orgs/mixins.py @@ -0,0 +1,108 @@ +from urllib.parse import quote_plus + +from django.http import HttpResponseRedirect +from django.urls import reverse + + +class OrgPermsMixin: + """ + Get the organization and the user within the inheriting view so that it be come easy to decide + whether this user has a certain permission for that particular organization to perform the view's actions + """ + + def get_user(self): + return self.request.user + + def derive_org(self): + return self.request.org + + def has_org_perm(self, permission): + org = self.derive_org() + if org: + return self.get_user().has_org_perm(org, permission) + return False + + def has_permission(self, request, *args, **kwargs): + """ + Figures out if the current user has permissions for this view. + """ + self.kwargs = kwargs + self.args = args + self.request = request + + org = self.derive_org() + + if self.get_user().is_staff and org: + return True + + if self.get_user().is_anonymous: + return False + + if self.get_user().has_perm(self.permission): # pragma: needs cover + return True + + return self.has_org_perm(self.permission) + + def dispatch(self, request, *args, **kwargs): + # non admin authenticated users without orgs get the org chooser + user = self.get_user() + if user.is_authenticated and not user.is_staff: + if not self.derive_org(): + return HttpResponseRedirect(reverse("orgs.org_choose")) + + return super().dispatch(request, *args, **kwargs) + + +class OrgObjPermsMixin(OrgPermsMixin): + def get_object_org(self): + return self.get_object().org + + def has_org_perm(self, codename): + has_org_perm = super().has_org_perm(codename) + if has_org_perm: + return self.request.org == self.get_object_org() + + return False + + def has_permission(self, request, *args, **kwargs): + user = self.request.user + if user.is_staff: + return True + + has_perm = super().has_permission(request, *args, **kwargs) + if has_perm: + return self.request.org == self.get_object_org() + + def pre_process(self, request, *args, **kwargs): + org = self.get_object_org() + if request.user.is_staff and self.request.org != org: + return HttpResponseRedirect( + f"{reverse('orgs.org_service')}?next={quote_plus(request.path)}&other_org={org.id}" + ) + + +class OrgFilterMixin: + """ + Simple mixin to filter a view's queryset by the request org + """ + + def derive_queryset(self, *args, **kwargs): + queryset = super().derive_queryset(*args, **kwargs) + + if not self.request.user.is_authenticated: + return queryset.none() # pragma: no cover + else: + return queryset.filter(org=self.request.org) + + +class InferOrgMixin: + """ + Mixin for view whose object is the current org + """ + + @classmethod + def derive_url_pattern(cls, path, action): + return r"^%s/%s/$" % (path, action) + + def get_object(self, *args, **kwargs): + return self.request.org diff --git a/temba/orgs/views.py b/temba/orgs/views.py index b8ea58b66ee..0322a2c46dd 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -1,6 +1,6 @@ from collections import OrderedDict from datetime import timedelta -from urllib.parse import quote, quote_plus +from urllib.parse import quote import iso8601 import pyotp @@ -74,6 +74,7 @@ ) from .forms import SignupForm, SMTPForm +from .mixins import InferOrgMixin, OrgObjPermsMixin, OrgPermsMixin from .models import ( BackupToken, DefinitionExport, @@ -112,97 +113,6 @@ def check_login(request): return HttpResponseRedirect(settings.LOGIN_URL) -class OrgPermsMixin: - """ - Get the organization and the user within the inheriting view so that it be come easy to decide - whether this user has a certain permission for that particular organization to perform the view's actions - """ - - def get_user(self): - return self.request.user - - def derive_org(self): - return self.request.org - - def has_org_perm(self, permission): - org = self.derive_org() - if org: - return self.get_user().has_org_perm(org, permission) - return False - - def has_permission(self, request, *args, **kwargs): - """ - Figures out if the current user has permissions for this view. - """ - self.kwargs = kwargs - self.args = args - self.request = request - - org = self.derive_org() - - if self.get_user().is_staff and org: - return True - - if self.get_user().is_anonymous: - return False - - if self.get_user().has_perm(self.permission): # pragma: needs cover - return True - - return self.has_org_perm(self.permission) - - def dispatch(self, request, *args, **kwargs): - # non admin authenticated users without orgs get the org chooser - user = self.get_user() - if user.is_authenticated and not user.is_staff: - if not self.derive_org(): - return HttpResponseRedirect(reverse("orgs.org_choose")) - - return super().dispatch(request, *args, **kwargs) - - -class OrgFilterMixin: - """ - Simple mixin to filter a view's queryset by the request org - """ - - def derive_queryset(self, *args, **kwargs): - queryset = super().derive_queryset(*args, **kwargs) - - if not self.request.user.is_authenticated: - return queryset.none() # pragma: no cover - else: - return queryset.filter(org=self.request.org) - - -class OrgObjPermsMixin(OrgPermsMixin): - def get_object_org(self): - return self.get_object().org - - def has_org_perm(self, codename): - has_org_perm = super().has_org_perm(codename) - if has_org_perm: - return self.request.org == self.get_object_org() - - return False - - def has_permission(self, request, *args, **kwargs): - user = self.request.user - if user.is_staff: - return True - - has_perm = super().has_permission(request, *args, **kwargs) - if has_perm: - return self.request.org == self.get_object_org() - - def pre_process(self, request, *args, **kwargs): - org = self.get_object_org() - if request.user.is_staff and self.request.org != org: - return HttpResponseRedirect( - f"{reverse('orgs.org_service')}?next={quote_plus(request.path)}&other_org={org.id}" - ) - - class ModalMixin(SmartFormView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -550,19 +460,6 @@ def form_valid(self, form): return super().form_valid(form) -class InferOrgMixin: - """ - Mixin for view whose object is the current org - """ - - @classmethod - def derive_url_pattern(cls, path, action): - return r"^%s/%s/$" % (path, action) - - def get_object(self, *args, **kwargs): - return self.request.org - - class InferUserMixin: """ Mixin for view whose object is the current user diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 0e9c1a2785c..1c19f013bc0 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -7,7 +7,7 @@ from temba.channels.models import Channel from temba.classifiers.models import Classifier -from temba.orgs.views import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import str_to_bool from temba.utils.views import ContentMenuMixin, SpaMixin diff --git a/temba/templates/views.py b/temba/templates/views.py index 523c8e7599f..1cf1aa235b6 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -1,6 +1,7 @@ from smartmin.views import SmartCRUDL, SmartListView, SmartReadView -from temba.orgs.views import DependencyUsagesModal, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import DependencyUsagesModal from temba.utils.views import SpaMixin from .models import Template, TemplateTranslation diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 02992a6d767..72255497502 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -20,7 +20,8 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import BaseExportView, MenuMixin, ModalMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import BaseExportView, MenuMixin, ModalMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 120b0eb8f23..393ee0169ec 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -15,7 +15,8 @@ from temba.flows.models import Flow from temba.formax import FormaxMixin from temba.msgs.views import ModalMixin -from temba.orgs.views import MenuMixin, OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import MenuMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, SpaMixin diff --git a/temba/utils/whatsapp/views.py b/temba/utils/whatsapp/views.py index 01c35a91704..36f43288dba 100644 --- a/temba/utils/whatsapp/views.py +++ b/temba/utils/whatsapp/views.py @@ -4,7 +4,7 @@ from temba.channels.models import Channel from temba.channels.views import ChannelTypeMixin -from temba.orgs.views import OrgPermsMixin +from temba.orgs.mixins import OrgPermsMixin from temba.utils.views import PostOnlyMixin from .tasks import refresh_whatsapp_contacts From fb52975a4a8310a8e91f8ec5601dba3fbbcd7fe3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 16:49:38 +0000 Subject: [PATCH 136/557] Convert MenuMixin into a concrete base class --- temba/campaigns/views.py | 14 +++----------- temba/contacts/views.py | 37 ++++--------------------------------- temba/flows/views.py | 4 ++-- temba/msgs/views.py | 14 +++----------- temba/orgs/views.py | 8 ++++++-- temba/tickets/views.py | 4 ++-- temba/triggers/views.py | 4 ++-- 7 files changed, 22 insertions(+), 63 deletions(-) diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 61548e9483d..d4d049d59bc 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -1,12 +1,4 @@ -from smartmin.views import ( - SmartCreateView, - SmartCRUDL, - SmartDeleteView, - SmartListView, - SmartReadView, - SmartTemplateView, - SmartUpdateView, -) +from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView from django import forms from django.contrib import messages @@ -20,7 +12,7 @@ from temba.flows.models import Flow from temba.msgs.models import Msg from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import MenuMixin, ModalMixin +from temba.orgs.views import BaseMenuView, ModalMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin @@ -53,7 +45,7 @@ class CampaignCRUDL(SmartCRUDL): model = Campaign actions = ("create", "read", "update", "list", "archived", "archive", "activate", "menu") - class Menu(MenuMixin, SmartTemplateView): + class Menu(BaseMenuView): def derive_menu(self): org = self.request.org diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 39090c59b10..c804a8b6221 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -4,15 +4,7 @@ from urllib.parse import quote_plus import iso8601 -from smartmin.views import ( - SmartCreateView, - SmartCRUDL, - SmartListView, - SmartReadView, - SmartTemplateView, - SmartUpdateView, - SmartView, -) +from smartmin.views import SmartCreateView, SmartCRUDL, SmartListView, SmartReadView, SmartUpdateView, SmartView from django import forms from django.conf import settings @@ -35,7 +27,7 @@ from temba.notifications.views import NotificationTargetMixin from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import User -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, MenuMixin, ModalMixin +from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime @@ -197,7 +189,7 @@ class ContactCRUDL(SmartCRUDL): "history", ) - class Menu(MenuMixin, OrgPermsMixin, SmartTemplateView): + class Menu(BaseMenuView): def render_to_response(self, context, **response_kwargs): org = self.request.org counts = Contact.get_status_counts(org) @@ -856,28 +848,7 @@ def save(self, obj): class ContactGroupCRUDL(SmartCRUDL): model = ContactGroup - actions = ("create", "update", "usages", "delete", "menu") - - class Menu(MenuMixin, OrgPermsMixin, SmartTemplateView): # pragma: no cover - def derive_menu(self): - org = self.request.org - - # order groups with smart (group_type=Q) before manual (group_type=M) - all_groups = ContactGroup.get_groups(org).order_by("-group_type", Upper("name")) - group_counts = ContactGroupCount.get_totals(all_groups) - - menu = [] - for g in all_groups: - menu.append( - self.create_menu_item( - menu_id=g.uuid, - name=g.name, - icon="loader" if g.status != ContactGroup.STATUS_READY else "atom" if g.query else "", - count=group_counts[g], - href=reverse("contacts.contact_filter", args=[g.uuid]), - ) - ) - return menu + actions = ("create", "update", "usages", "delete") class Create(ComponentFormMixin, ModalMixin, OrgPermsMixin, SmartCreateView): form_class = ContactGroupForm diff --git a/temba/flows/views.py b/temba/flows/views.py index 1845a7b7b1d..442df215132 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -36,7 +36,7 @@ from temba.ivr.models import Call from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal, MenuMixin, ModalMixin +from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, ModalMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit from temba.utils.fields import ( @@ -201,7 +201,7 @@ def get_queryset(self): initial_queryset = super().get_queryset() return initial_queryset.filter(is_active=True) - class Menu(MenuMixin, SmartTemplateView): + class Menu(BaseMenuView): @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/((?P[A-z]+)/)?$" % (path, action) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 08342dfe948..f8e6dee7af2 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -5,15 +5,7 @@ from urllib.parse import quote_plus import magic -from smartmin.views import ( - SmartCreateView, - SmartCRUDL, - SmartDeleteView, - SmartListView, - SmartReadView, - SmartTemplateView, - SmartUpdateView, -) +from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView from django import forms from django.conf import settings @@ -30,7 +22,7 @@ from temba.mailroom.client.types import Exclusions from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, MenuMixin, ModalMixin +from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation from temba.utils import json, languages @@ -760,7 +752,7 @@ class MsgCRUDL(SmartCRUDL): model = Msg actions = ("inbox", "flow", "archived", "menu", "outbox", "sent", "failed", "filter", "export", "legacy_inbox") - class Menu(MenuMixin, OrgPermsMixin, SmartTemplateView): # pragma: no cover + class Menu(BaseMenuView): # pragma: no cover def derive_menu(self): org = self.request.org counts = SystemLabel.get_counts(org) diff --git a/temba/orgs/views.py b/temba/orgs/views.py index 0322a2c46dd..2bb0d89b996 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -1069,7 +1069,11 @@ def form_valid(self, form): return super().form_valid(form) -class MenuMixin(OrgPermsMixin): +class BaseMenuView(OrgPermsMixin, SmartTemplateView): + """ + Base view for the section menu views + """ + def create_divider(self): return {"type": "divider"} @@ -1226,7 +1230,7 @@ class OrgCRUDL(SmartCRUDL): model = Org - class Menu(MenuMixin, InferOrgMixin, SmartTemplateView): + class Menu(BaseMenuView): @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/((?P[A-z]+)/)?$" % (path, action) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 72255497502..43bc1aadbd4 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -21,7 +21,7 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import BaseExportView, MenuMixin, ModalMixin +from temba.orgs.views import BaseExportView, BaseMenuView, ModalMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget @@ -287,7 +287,7 @@ def build_content_menu(self, menu): def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).none() - class Menu(MenuMixin, OrgPermsMixin, SmartTemplateView): + class Menu(BaseMenuView): def derive_menu(self): org = self.request.org user = self.request.user diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 393ee0169ec..4cdcffccb7c 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -16,7 +16,7 @@ from temba.formax import FormaxMixin from temba.msgs.views import ModalMixin from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import MenuMixin +from temba.orgs.views import BaseMenuView from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, SpaMixin @@ -196,7 +196,7 @@ class TriggerCRUDL(SmartCRUDL): "folder", ) - class Menu(MenuMixin, SmartTemplateView): + class Menu(BaseMenuView): @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/((?P[A-z]+)/)?$" % (path, action) From 7b31969069112aa46076e3ebaf374ebae2d077f8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 13:11:20 -0500 Subject: [PATCH 137/557] Update CHANGELOG.md for v9.3.56 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67410e73d7d..c98f861fb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.56 (2024-10-03) +------------------------- + * Cleanup some view mixins + v9.3.55 (2024-10-03) ------------------------- * Temporarily hide menu item for shortcuts diff --git a/pyproject.toml b/pyproject.toml index 017e9b2d893..c5b8fdf36df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.55" +version = "9.3.56" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index c108ae6cb08..f3a535fbe12 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.55" +__version__ = "9.3.56" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From e1a133af6be8b864a08fa9315f3b4042ed243ab7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 19:00:49 +0000 Subject: [PATCH 138/557] Add base classes for org level views --- temba/campaigns/tests.py | 2 +- temba/campaigns/views.py | 13 ++-- temba/contacts/views.py | 3 +- temba/flows/views.py | 11 ++-- temba/msgs/tests.py | 4 +- temba/msgs/views.py | 9 +-- temba/orgs/mixins.py | 14 ----- temba/orgs/views.py | 106 +-------------------------------- temba/orgs/views_base.py | 125 +++++++++++++++++++++++++++++++++++++++ temba/tickets/views.py | 3 +- temba/triggers/views.py | 10 ++-- 11 files changed, 156 insertions(+), 144 deletions(-) create mode 100644 temba/orgs/views_base.py diff --git a/temba/campaigns/tests.py b/temba/campaigns/tests.py index 0f13426a3f7..a0c93f19d37 100644 --- a/temba/campaigns/tests.py +++ b/temba/campaigns/tests.py @@ -1229,7 +1229,7 @@ def test_archive_and_activate(self): # can't archive campaign from other org response = self.client.post(reverse("campaigns.campaign_archive", args=[other_org_campaign.id])) - self.assertEqual(404, response.status_code) + self.assertEqual(302, response.status_code) # check object is unchanged other_org_campaign.refresh_from_db() diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index d4d049d59bc..3003cc1f872 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -1,4 +1,4 @@ -from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView +from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartReadView, SmartUpdateView from django import forms from django.contrib import messages @@ -11,8 +11,9 @@ from temba.contacts.models import ContactField, ContactGroup from temba.flows.models import Flow from temba.msgs.models import Msg -from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import BaseMenuView, ModalMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views import ModalMixin +from temba.orgs.views_base import BaseListView, BaseMenuView from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin @@ -161,7 +162,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class BaseList(SpaMixin, ContentMenuMixin, OrgFilterMixin, OrgPermsMixin, BulkActionMixin, SmartListView): + class BaseList(SpaMixin, ContentMenuMixin, BulkActionMixin, BaseListView): fields = ("name", "group") default_template = "campaigns/campaign_list.html" default_order = ("-modified_on",) @@ -205,7 +206,7 @@ def get_queryset(self, *args, **kwargs): qs = qs.filter(is_active=True, is_archived=True) return qs - class Archive(OrgFilterMixin, OrgPermsMixin, SmartUpdateView): + class Archive(OrgObjPermsMixin, SmartUpdateView): fields = () success_url = "uuid@campaigns.campaign_read" success_message = _("Campaign archived") @@ -214,7 +215,7 @@ def save(self, obj): obj.apply_action_archive(self.request.user, Campaign.objects.filter(id=obj.id)) return obj - class Activate(OrgFilterMixin, OrgPermsMixin, SmartUpdateView): + class Activate(OrgObjPermsMixin, SmartUpdateView): fields = () success_url = "uuid@campaigns.campaign_read" success_message = _("Campaign activated") diff --git a/temba/contacts/views.py b/temba/contacts/views.py index c804a8b6221..201fd9c3091 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -27,7 +27,8 @@ from temba.notifications.views import NotificationTargetMixin from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import User -from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views_base import BaseMenuView from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime diff --git a/temba/flows/views.py b/temba/flows/views.py index 442df215132..71b63488190 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -34,9 +34,10 @@ from temba.flows.models import Flow, FlowRevision, FlowRun, FlowSession, FlowStart from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call -from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal, ModalMixin +from temba.orgs.views_base import BaseListView, BaseMenuView from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit from temba.utils.fields import ( @@ -665,7 +666,7 @@ def update_triggers(self, flow, user, new_keywords: list): match_type=Trigger.MATCH_FIRST_WORD, ) - class BaseList(SpaMixin, OrgFilterMixin, OrgPermsMixin, BulkActionMixin, ContentMenuMixin, SmartListView): + class BaseList(SpaMixin, BulkActionMixin, ContentMenuMixin, BaseListView): permission = "flows.flow_list" title = _("Flows") refresh = 10000 @@ -1846,7 +1847,7 @@ class FlowStartCRUDL(SmartCRUDL): model = FlowStart actions = ("list", "interrupt", "status") - class List(SpaMixin, OrgFilterMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, BaseListView): title = _("Flow Starts") ordering = ("-created_on",) select_related = ("flow", "created_by") @@ -1877,7 +1878,7 @@ def get_context_data(self, *args, **kwargs): return context - class Status(OrgPermsMixin, OrgFilterMixin, SmartListView): + class Status(BaseListView): permission = "flows.flowstart_list" def derive_queryset(self, **kwargs): diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index bbdd17dd24b..6760a4e8178 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -2612,8 +2612,8 @@ def test_status(self): ) status_url = f"{reverse('msgs.broadcast_status')}?id={broadcast.id}&status=P" - self.assertRequestDisallowed(status_url, [None, self.user, self.agent]) - response = self.assertReadFetch(status_url, [self.editor, self.admin]) + self.assertRequestDisallowed(status_url, [None, self.agent]) + response = self.assertReadFetch(status_url, [self.user, self.editor, self.admin]) # status returns json self.assertEqual("Pending", response.json()["results"][0]["status"]) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index f8e6dee7af2..79282d55015 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -20,9 +20,10 @@ from temba import mailroom from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions -from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import Org -from temba.orgs.views import BaseExportView, BaseMenuView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views_base import BaseListView, BaseMenuView from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation from temba.utils import json, languages @@ -704,8 +705,8 @@ def form_valid(self, form): return self.render_modal_response(form) - class Status(OrgPermsMixin, OrgFilterMixin, SmartListView): - permission = "msgs.broadcast_read" + class Status(BaseListView): + permission = "msgs.broadcast_list" def derive_queryset(self, **kwargs): qs = super().derive_queryset(**kwargs) diff --git a/temba/orgs/mixins.py b/temba/orgs/mixins.py index d87e5e7697f..2d45f104da4 100644 --- a/temba/orgs/mixins.py +++ b/temba/orgs/mixins.py @@ -81,20 +81,6 @@ def pre_process(self, request, *args, **kwargs): ) -class OrgFilterMixin: - """ - Simple mixin to filter a view's queryset by the request org - """ - - def derive_queryset(self, *args, **kwargs): - queryset = super().derive_queryset(*args, **kwargs) - - if not self.request.user.is_authenticated: - return queryset.none() # pragma: no cover - else: - return queryset.filter(org=self.request.org) - - class InferOrgMixin: """ Mixin for view whose object is the current org diff --git a/temba/orgs/views.py b/temba/orgs/views.py index 2bb0d89b996..064d6865b0a 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views.py @@ -38,7 +38,6 @@ from django.utils import timezone from django.utils.encoding import DjangoUnicodeDecodeError, force_str from django.utils.functional import cached_property -from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt @@ -87,6 +86,7 @@ User, UserSettings, ) +from .views_base import BaseMenuView # session key for storing a two-factor enabled user's id once we've checked their password TWO_FACTOR_USER_SESSION_KEY = "_two_factor_user_id" @@ -1069,110 +1069,6 @@ def form_valid(self, form): return super().form_valid(form) -class BaseMenuView(OrgPermsMixin, SmartTemplateView): - """ - Base view for the section menu views - """ - - def create_divider(self): - return {"type": "divider"} - - def create_space(self): # pragma: no cover - return {"type": "space"} - - def create_section(self, name, items=()): # pragma: no cover - return {"id": slugify(name), "name": name, "type": "section", "items": items} - - def create_list(self, name, href, type): - return {"id": name, "href": href, "type": type} - - def create_modax_button(self, name, href, icon=None, on_submit=None): # pragma: no cover - menu_item = {"id": slugify(name), "name": name, "type": "modax-button"} - if href: - if href[0] == "/": # pragma: no cover - menu_item["href"] = href - elif self.has_org_perm(href): - menu_item["href"] = reverse(href) - - if on_submit: - menu_item["on_submit"] = on_submit - - if icon: # pragma: no cover - menu_item["icon"] = icon - - if "href" not in menu_item: # pragma: no cover - return None - - return menu_item - - def create_menu_item( - self, - menu_id=None, - name=None, - icon=None, - avatar=None, - endpoint=None, - href=None, - count=None, - perm=None, - items=[], - inline=False, - bottom=False, - popup=False, - event=None, - posterize=False, - bubble=None, - mobile=False, - ): - if perm and not self.has_org_perm(perm): # pragma: no cover - return - - menu_item = {"name": name, "inline": inline} - menu_item["id"] = menu_id if menu_id else slugify(name) - menu_item["bottom"] = bottom - menu_item["popup"] = popup - menu_item["avatar"] = avatar - menu_item["posterize"] = posterize - menu_item["event"] = event - menu_item["mobile"] = mobile - - if bubble: - menu_item["bubble"] = bubble - - if icon: - menu_item["icon"] = icon - - if count is not None: - menu_item["count"] = count - - if endpoint: - if endpoint[0] == "/": # pragma: no cover - menu_item["endpoint"] = endpoint - elif perm or self.has_org_perm(endpoint): - menu_item["endpoint"] = reverse(endpoint) - - if href: - if href[0] == "/": - menu_item["href"] = href - elif perm or self.has_org_perm(href): - menu_item["href"] = reverse(href) - - if items: # pragma: no cover - menu_item["items"] = [item for item in items if item is not None] - - # only include the menu item if we have somewhere to go - if "href" not in menu_item and "endpoint" not in menu_item and not inline and not popup and not event: - return None - - return menu_item - - def get_menu(self): - return [item for item in self.derive_menu() if item is not None] - - def render_to_response(self, context, **response_kwargs): - return JsonResponse({"results": self.get_menu()}) - - class InvitationMixin: @cached_property def invitation(self, **kwargs): diff --git a/temba/orgs/views_base.py b/temba/orgs/views_base.py new file mode 100644 index 00000000000..e9b1ea63676 --- /dev/null +++ b/temba/orgs/views_base.py @@ -0,0 +1,125 @@ +from smartmin.views import SmartListView, SmartTemplateView + +from django.http import JsonResponse +from django.urls import reverse +from django.utils.text import slugify + +from .mixins import OrgPermsMixin + + +class BaseListView(OrgPermsMixin, SmartListView): + """ + Base list view for objects that belong to the current org + """ + + def derive_queryset(self, *args, **kwargs): + queryset = super().derive_queryset(*args, **kwargs) + + if not self.request.user.is_authenticated: + return queryset.none() # pragma: no cover + else: + return queryset.filter(org=self.request.org) + + +class BaseMenuView(OrgPermsMixin, SmartTemplateView): + """ + Base view for the section menus + """ + + def create_divider(self): + return {"type": "divider"} + + def create_space(self): # pragma: no cover + return {"type": "space"} + + def create_section(self, name, items=()): # pragma: no cover + return {"id": slugify(name), "name": name, "type": "section", "items": items} + + def create_list(self, name, href, type): + return {"id": name, "href": href, "type": type} + + def create_modax_button(self, name, href, icon=None, on_submit=None): # pragma: no cover + menu_item = {"id": slugify(name), "name": name, "type": "modax-button"} + if href: + if href[0] == "/": # pragma: no cover + menu_item["href"] = href + elif self.has_org_perm(href): + menu_item["href"] = reverse(href) + + if on_submit: + menu_item["on_submit"] = on_submit + + if icon: # pragma: no cover + menu_item["icon"] = icon + + if "href" not in menu_item: # pragma: no cover + return None + + return menu_item + + def create_menu_item( + self, + menu_id=None, + name=None, + icon=None, + avatar=None, + endpoint=None, + href=None, + count=None, + perm=None, + items=[], + inline=False, + bottom=False, + popup=False, + event=None, + posterize=False, + bubble=None, + mobile=False, + ): + if perm and not self.has_org_perm(perm): # pragma: no cover + return + + menu_item = {"name": name, "inline": inline} + menu_item["id"] = menu_id if menu_id else slugify(name) + menu_item["bottom"] = bottom + menu_item["popup"] = popup + menu_item["avatar"] = avatar + menu_item["posterize"] = posterize + menu_item["event"] = event + menu_item["mobile"] = mobile + + if bubble: + menu_item["bubble"] = bubble + + if icon: + menu_item["icon"] = icon + + if count is not None: + menu_item["count"] = count + + if endpoint: + if endpoint[0] == "/": # pragma: no cover + menu_item["endpoint"] = endpoint + elif perm or self.has_org_perm(endpoint): + menu_item["endpoint"] = reverse(endpoint) + + if href: + if href[0] == "/": + menu_item["href"] = href + elif perm or self.has_org_perm(href): + menu_item["href"] = reverse(href) + + if items: # pragma: no cover + menu_item["items"] = [item for item in items if item is not None] + + # only include the menu item if we have somewhere to go + if "href" not in menu_item and "endpoint" not in menu_item and not inline and not popup and not event: + return None + + return menu_item + + def get_menu(self): + return [item for item in self.derive_menu() if item is not None] + + def render_to_response(self, context, **response_kwargs): + return JsonResponse({"results": self.get_menu()}) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 43bc1aadbd4..c8fa04f1a9b 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -21,7 +21,8 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import BaseExportView, BaseMenuView, ModalMixin +from temba.orgs.views import BaseExportView, ModalMixin +from temba.orgs.views_base import BaseMenuView from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 4cdcffccb7c..e1c6454e012 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -1,6 +1,6 @@ from enum import Enum -from smartmin.views import SmartCreateView, SmartCRUDL, SmartListView, SmartTemplateView, SmartUpdateView +from smartmin.views import SmartCreateView, SmartCRUDL, SmartTemplateView, SmartUpdateView from django import forms from django.db.models.functions import Upper @@ -15,8 +15,8 @@ from temba.flows.models import Flow from temba.formax import FormaxMixin from temba.msgs.views import ModalMixin -from temba.orgs.mixins import OrgFilterMixin, OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views import BaseMenuView +from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views_base import BaseListView, BaseMenuView from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, SpaMixin @@ -243,7 +243,7 @@ def derive_menu(self): return menu - class Create(SpaMixin, FormaxMixin, OrgFilterMixin, OrgPermsMixin, SmartTemplateView): + class Create(SpaMixin, FormaxMixin, OrgPermsMixin, SmartTemplateView): title = _("New Trigger") menu_path = "/trigger/new-trigger" @@ -425,7 +425,7 @@ def form_valid(self, form): response["REDIRECT"] = self.get_success_url() return response - class BaseList(SpaMixin, OrgFilterMixin, OrgPermsMixin, BulkActionMixin, SmartListView): + class BaseList(SpaMixin, BulkActionMixin, BaseListView): """ Base class for list views """ From d1b8144944a825aaaeb34fd6dacca3015e48c171 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 19:37:29 +0000 Subject: [PATCH 139/557] Reorg all view stuff in the orgs app into a views package --- temba/airtime/views.py | 2 +- temba/api/v2/views.py | 2 +- temba/archives/views.py | 2 +- temba/campaigns/views.py | 4 ++-- temba/channels/types/plivo/views.py | 2 +- temba/channels/types/twilio/views.py | 2 +- temba/channels/types/vonage/views.py | 2 +- temba/channels/types/whatsapp/views.py | 2 +- temba/channels/views.py | 2 +- temba/classifiers/views.py | 2 +- temba/contacts/views.py | 4 ++-- temba/dashboard/views.py | 2 +- temba/flows/views.py | 4 ++-- temba/globals/views.py | 2 +- temba/locations/views.py | 2 +- temba/msgs/views.py | 4 ++-- temba/notifications/views.py | 2 +- temba/orgs/views/__init__.py | 1 + temba/orgs/{views_base.py => views/base.py} | 0 temba/orgs/{ => views}/forms.py | 2 +- temba/orgs/{ => views}/mixins.py | 0 temba/orgs/{ => views}/views.py | 8 ++++---- temba/request_logs/views.py | 2 +- temba/templates/views.py | 2 +- temba/tickets/views.py | 4 ++-- temba/triggers/views.py | 4 ++-- temba/utils/whatsapp/views.py | 2 +- 27 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 temba/orgs/views/__init__.py rename temba/orgs/{views_base.py => views/base.py} (100%) rename temba/orgs/{ => views}/forms.py (99%) rename temba/orgs/{ => views}/mixins.py (100%) rename temba/orgs/{ => views}/views.py (99%) diff --git a/temba/airtime/views.py b/temba/airtime/views.py index 2fd0eb8c6a5..fcceaf0996f 100644 --- a/temba/airtime/views.py +++ b/temba/airtime/views.py @@ -6,7 +6,7 @@ from temba.airtime.models import AirtimeTransfer from temba.contacts.models import URN, ContactURN -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.request_logs.models import HTTPLog from temba.utils.views import SpaMixin diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index e9a2ab45572..6f83ac54b56 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -21,8 +21,8 @@ from temba.globals.models import Global from temba.locations.models import AdminBoundary, BoundaryAlias from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, OptIn, SystemLabel -from temba.orgs.mixins import OrgPermsMixin from temba.orgs.models import OrgMembership, User +from temba.orgs.views.mixins import OrgPermsMixin from temba.tickets.models import Ticket, TicketCount, Topic from temba.utils import str_to_bool from temba.utils.uuid import is_uuid diff --git a/temba/archives/views.py b/temba/archives/views.py index cdb2c7cb25c..a4fb9adb285 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -4,7 +4,7 @@ from django.http import HttpResponseRedirect -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.views import SpaMixin from .models import Archive diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 3003cc1f872..6577293c720 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -11,9 +11,9 @@ from temba.contacts.models import ContactField, ContactGroup from temba.flows.models import Flow from temba.msgs.models import Msg -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import ModalMixin -from temba.orgs.views_base import BaseListView, BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin diff --git a/temba/channels/types/plivo/views.py b/temba/channels/types/plivo/views.py index f5ba611fd77..4c8cc70334e 100644 --- a/temba/channels/types/plivo/views.py +++ b/temba/channels/types/plivo/views.py @@ -13,7 +13,7 @@ from temba.channels.models import Channel from temba.channels.views import BaseClaimNumberMixin, ChannelTypeMixin, ClaimViewMixin -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import SelectWidget from temba.utils.http import http_headers diff --git a/temba/channels/types/twilio/views.py b/temba/channels/types/twilio/views.py index beacd6857b2..1916ea3b2d9 100644 --- a/temba/channels/types/twilio/views.py +++ b/temba/channels/types/twilio/views.py @@ -13,7 +13,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import InputWidget, SelectWidget from temba.utils.timezones import timezone_to_country_code diff --git a/temba/channels/types/vonage/views.py b/temba/channels/types/vonage/views.py index c44771588fd..8ae2e5504a1 100644 --- a/temba/channels/types/vonage/views.py +++ b/temba/channels/types/vonage/views.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import countries from temba.utils.fields import InputWidget, SelectWidget from temba.utils.models import generate_uuid diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 199ddb05ff7..95c3e76aa95 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -10,8 +10,8 @@ from django.utils.translation import gettext_lazy as _ from temba.channels.views import ChannelTypeMixin -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import ModalMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate from temba.utils.views import ContentMenuMixin diff --git a/temba/channels/views.py b/temba/channels/views.py index e4710f40f33..bbb4e9e561c 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -35,8 +35,8 @@ from temba.ivr.models import Call from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import DependencyDeleteModal, ModalMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import countries from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 2f178b9f65d..3adf0fa9903 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -5,8 +5,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import DependencyDeleteModal +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin from .models import Classifier diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 201fd9c3091..64e2d45e1dc 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -25,10 +25,10 @@ from temba.channels.models import Channel from temba.mailroom.events import Event from temba.notifications.views import NotificationTargetMixin -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import User from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin -from temba.orgs.views_base import BaseMenuView +from temba.orgs.views.base import BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime diff --git a/temba/dashboard/views.py b/temba/dashboard/views.py index f374073fc3a..980e774ba04 100644 --- a/temba/dashboard/views.py +++ b/temba/dashboard/views.py @@ -9,8 +9,8 @@ from django.utils.translation import gettext_lazy as _ from temba.channels.models import Channel, ChannelCount -from temba.orgs.mixins import OrgPermsMixin from temba.orgs.models import Org +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils.views import SpaMixin flattened_colors = [ diff --git a/temba/flows/views.py b/temba/flows/views.py index 71b63488190..8dbe3030e09 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -34,10 +34,10 @@ from temba.flows.models import Flow, FlowRevision, FlowRun, FlowSession, FlowStart from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import IntegrationType, Org from temba.orgs.views import BaseExportView, DependencyDeleteModal, ModalMixin -from temba.orgs.views_base import BaseListView, BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit from temba.utils.fields import ( diff --git a/temba/globals/views.py b/temba/globals/views.py index b6593735dca..1c8f81f7567 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -5,8 +5,8 @@ from django import forms from django.urls import reverse -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.views import ContentMenuMixin, SpaMixin diff --git a/temba/locations/views.py b/temba/locations/views.py index 1e82ad5a5bc..4c7615eaebc 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from temba.locations.models import AdminBoundary, BoundaryAlias -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import json from temba.utils.views import ContentMenuMixin, SpaMixin diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 79282d55015..52faf827845 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -20,10 +20,10 @@ from temba import mailroom from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.models import Org from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin -from temba.orgs.views_base import BaseListView, BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation from temba.utils import json, languages diff --git a/temba/notifications/views.py b/temba/notifications/views.py index 2358005115b..fa93c64a6f7 100644 --- a/temba/notifications/views.py +++ b/temba/notifications/views.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils.views import SpaMixin from .mixins import NotificationTargetMixin diff --git a/temba/orgs/views/__init__.py b/temba/orgs/views/__init__.py new file mode 100644 index 00000000000..360d6f84458 --- /dev/null +++ b/temba/orgs/views/__init__.py @@ -0,0 +1 @@ +from .views import * # noqa diff --git a/temba/orgs/views_base.py b/temba/orgs/views/base.py similarity index 100% rename from temba/orgs/views_base.py rename to temba/orgs/views/base.py diff --git a/temba/orgs/forms.py b/temba/orgs/views/forms.py similarity index 99% rename from temba/orgs/forms.py rename to temba/orgs/views/forms.py index 10f35c9d601..1f07abc0ee4 100644 --- a/temba/orgs/forms.py +++ b/temba/orgs/views/forms.py @@ -10,7 +10,7 @@ from temba.utils.fields import InputWidget from temba.utils.timezones import TimeZoneFormField -from .models import Org, User +from ..models import Org, User class SignupForm(forms.ModelForm): diff --git a/temba/orgs/mixins.py b/temba/orgs/views/mixins.py similarity index 100% rename from temba/orgs/mixins.py rename to temba/orgs/views/mixins.py diff --git a/temba/orgs/views.py b/temba/orgs/views/views.py similarity index 99% rename from temba/orgs/views.py rename to temba/orgs/views/views.py index 064d6865b0a..aaac9fc3967 100644 --- a/temba/orgs/views.py +++ b/temba/orgs/views/views.py @@ -72,9 +72,7 @@ StaffOnlyMixin, ) -from .forms import SignupForm, SMTPForm -from .mixins import InferOrgMixin, OrgObjPermsMixin, OrgPermsMixin -from .models import ( +from ..models import ( BackupToken, DefinitionExport, Export, @@ -86,7 +84,9 @@ User, UserSettings, ) -from .views_base import BaseMenuView +from .base import BaseMenuView +from .forms import SignupForm, SMTPForm +from .mixins import InferOrgMixin, OrgObjPermsMixin, OrgPermsMixin # session key for storing a two-factor enabled user's id once we've checked their password TWO_FACTOR_USER_SESSION_KEY = "_two_factor_user_id" diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 1c19f013bc0..59d0d9ce310 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -7,7 +7,7 @@ from temba.channels.models import Channel from temba.classifiers.models import Classifier -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import str_to_bool from temba.utils.views import ContentMenuMixin, SpaMixin diff --git a/temba/templates/views.py b/temba/templates/views.py index 1cf1aa235b6..e8a92728b6a 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -1,7 +1,7 @@ from smartmin.views import SmartCRUDL, SmartListView, SmartReadView -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import DependencyUsagesModal +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.views import SpaMixin from .models import Template, TemplateTranslation diff --git a/temba/tickets/views.py b/temba/tickets/views.py index c8fa04f1a9b..ece526d474d 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -20,9 +20,9 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.orgs.views import BaseExportView, ModalMixin -from temba.orgs.views_base import BaseMenuView +from temba.orgs.views.base import BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget diff --git a/temba/triggers/views.py b/temba/triggers/views.py index e1c6454e012..b14fbbbd620 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -15,8 +15,8 @@ from temba.flows.models import Flow from temba.formax import FormaxMixin from temba.msgs.views import ModalMixin -from temba.orgs.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.orgs.views_base import BaseListView, BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, SpaMixin diff --git a/temba/utils/whatsapp/views.py b/temba/utils/whatsapp/views.py index 36f43288dba..bae94373d46 100644 --- a/temba/utils/whatsapp/views.py +++ b/temba/utils/whatsapp/views.py @@ -4,7 +4,7 @@ from temba.channels.models import Channel from temba.channels.views import ChannelTypeMixin -from temba.orgs.mixins import OrgPermsMixin +from temba.orgs.views.mixins import OrgPermsMixin from temba.utils.views import PostOnlyMixin from .tasks import refresh_whatsapp_contacts From faff68651fd89b90ee763315ce60a0bc19d11e20 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 3 Oct 2024 22:10:41 +0000 Subject: [PATCH 140/557] Use BaseListView where possible --- temba/airtime/views.py | 10 ++++------ temba/archives/views.py | 15 +++++++-------- temba/contacts/views.py | 8 ++++---- temba/globals/views.py | 5 +++-- temba/msgs/views.py | 10 +++++----- temba/orgs/views/base.py | 4 ++-- temba/request_logs/views.py | 9 +++++---- temba/templates/views.py | 7 ++++--- temba/tickets/views.py | 8 ++++---- 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/temba/airtime/views.py b/temba/airtime/views.py index fcceaf0996f..dfaef571f8c 100644 --- a/temba/airtime/views.py +++ b/temba/airtime/views.py @@ -1,4 +1,4 @@ -from smartmin.views import SmartCRUDL, SmartListView, SmartReadView +from smartmin.views import SmartCRUDL, SmartReadView from django.db.models import Prefetch from django.urls import reverse @@ -6,7 +6,8 @@ from temba.airtime.models import AirtimeTransfer from temba.contacts.models import URN, ContactURN -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.base import BaseListView +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.request_logs.models import HTTPLog from temba.utils.views import SpaMixin @@ -15,7 +16,7 @@ class AirtimeCRUDL(SmartCRUDL): model = AirtimeTransfer actions = ("list", "read") - class List(SpaMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, BaseListView): menu_path = "/settings/workspace" title = _("Recent Airtime Transfers") fields = ("status", "contact", "recipient", "currency", "actual_amount", "created_on") @@ -39,9 +40,6 @@ def lookup_field_link(self, context, field, obj): return super().lookup_field_link(context, field, obj) - def derive_queryset(self, **kwargs): - return AirtimeTransfer.objects.filter(org=self.derive_org()) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["org"] = self.derive_org() diff --git a/temba/archives/views.py b/temba/archives/views.py index a4fb9adb285..372113b3da2 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -1,10 +1,11 @@ from gettext import gettext as _ -from smartmin.views import SmartCRUDL, SmartListView, SmartReadView +from smartmin.views import SmartCRUDL, SmartReadView from django.http import HttpResponseRedirect -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.base import BaseListView +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.views import SpaMixin from .models import Archive @@ -15,19 +16,17 @@ class ArchiveCRUDL(SmartCRUDL): actions = ("read", "run", "message") permissions = True - class BaseList(SpaMixin, OrgPermsMixin, SmartListView): + class BaseList(SpaMixin, BaseListView): title = _("Archive") fields = ("url", "start_date", "period", "record_count", "size") default_order = ("-start_date", "-period", "archive_type") paginate_by = 250 - def get_queryset(self, **kwargs): - queryset = super().get_queryset(**kwargs) + def derive_queryset(self, **kwargs): + queryset = super().derive_queryset(**kwargs) # filter by our archive type - return queryset.filter(org=self.request.org, archive_type=self.get_archive_type()).exclude( - rollup_id__isnull=False - ) + return queryset.filter(archive_type=self.get_archive_type()).exclude(rollup_id__isnull=False) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 64e2d45e1dc..00cf579b2f5 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -27,7 +27,7 @@ from temba.notifications.views import NotificationTargetMixin from temba.orgs.models import User from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin -from temba.orgs.views.base import BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit @@ -1093,7 +1093,7 @@ def post(self, request, *args, **kwargs): return HttpResponse(json.dumps(payload), status=400, content_type="application/json") - class List(ContentMenuMixin, SpaMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, ContentMenuMixin, BaseListView): menu_path = "/contact/fields" title = _("Fields") default_order = "name" @@ -1109,8 +1109,8 @@ def build_content_menu(self, menu): as_button=True, ) - def get_queryset(self, **kwargs): - return super().get_queryset(**kwargs).filter(org=self.request.org, is_active=True, is_system=False) + def derive_queryset(self, **kwargs): + return super().derive_queryset(**kwargs).filter(is_active=True, is_system=False) class Usages(FieldLookupMixin, DependencyUsagesModal): permission = "contacts.contactfield_read" diff --git a/temba/globals/views.py b/temba/globals/views.py index 1c8f81f7567..cc447096ca0 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -1,11 +1,12 @@ from gettext import gettext as _ -from smartmin.views import SmartCreateView, SmartCRUDL, SmartListView, SmartUpdateView +from smartmin.views import SmartCreateView, SmartCRUDL, SmartUpdateView from django import forms from django.urls import reverse from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.views import ContentMenuMixin, SpaMixin @@ -112,7 +113,7 @@ class Delete(DependencyDeleteModal): cancel_url = "@globals.global_list" success_url = "@globals.global_list" - class List(SpaMixin, ContentMenuMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, ContentMenuMixin, BaseListView): title = _("Globals") fields = ("name", "key", "value") search_fields = ("name__icontains", "key__icontains") diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 52faf827845..d7b2fdaec7b 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus import magic -from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView +from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartReadView, SmartUpdateView from django import forms from django.conf import settings @@ -43,7 +43,7 @@ from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel -class SystemLabelView(SpaMixin, OrgPermsMixin, SmartListView): +class SystemLabelView(SpaMixin, BaseListView): """ Base class for views backed by a system label or message label queryset """ @@ -1174,9 +1174,9 @@ def post(self, request, *args, **kwargs): } ) - class List(StaffOnlyMixin, OrgPermsMixin, SmartListView): + class List(StaffOnlyMixin, BaseListView): fields = ("url", "content_type", "size", "created_by", "created_on") default_order = ("-created_on",) - def get_queryset(self, **kwargs): - return super().get_queryset(**kwargs).filter(org=self.request.org, original=None) + def derive_queryset(self, **kwargs): + return super().derive_queryset(**kwargs).filter(original=None) diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index e9b1ea63676..45f415048dc 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -12,8 +12,8 @@ class BaseListView(OrgPermsMixin, SmartListView): Base list view for objects that belong to the current org """ - def derive_queryset(self, *args, **kwargs): - queryset = super().derive_queryset(*args, **kwargs) + def derive_queryset(self, **kwargs): + queryset = super().derive_queryset(**kwargs) if not self.request.user.is_authenticated: return queryset.none() # pragma: no cover diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 59d0d9ce310..e65c2fd9326 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -7,7 +7,8 @@ from temba.channels.models import Channel from temba.classifiers.models import Classifier -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.base import BaseListView +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils import str_to_bool from temba.utils.views import ContentMenuMixin, SpaMixin @@ -57,7 +58,7 @@ class HTTPLogCRUDL(SmartCRUDL): model = HTTPLog actions = ("webhooks", "channel", "classifier", "read") - class Webhooks(SpaMixin, ContentMenuMixin, OrgPermsMixin, SmartListView): + class Webhooks(SpaMixin, ContentMenuMixin, BaseListView): default_order = ("-created_on",) select_related = ("flow",) fields = ("flow", "url", "status_code", "request_time", "created_on") @@ -68,8 +69,8 @@ def derive_title(self): return _("Failed Webhooks") return _("Webhooks") - def get_queryset(self, **kwargs): - qs = super().get_queryset(**kwargs).filter(org=self.request.org, flow__isnull=False) + def derive_queryset(self, **kwargs): + qs = super().derive_queryset(**kwargs).filter(flow__isnull=False) if str_to_bool(self.request.GET.get("error")): qs = qs.filter(is_error=True) return qs diff --git a/temba/templates/views.py b/temba/templates/views.py index e8a92728b6a..8654e57437d 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -1,7 +1,8 @@ -from smartmin.views import SmartCRUDL, SmartListView, SmartReadView +from smartmin.views import SmartCRUDL, SmartReadView from temba.orgs.views import DependencyUsagesModal -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.base import BaseListView +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.views import SpaMixin from .models import Template, TemplateTranslation @@ -11,7 +12,7 @@ class TemplateCRUDL(SmartCRUDL): model = Template actions = ("list", "read", "usages") - class List(SpaMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, BaseListView): default_order = ("-created_on",) def derive_menu_path(self): diff --git a/temba/tickets/views.py b/temba/tickets/views.py index ece526d474d..4a7c88a5cff 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -21,7 +21,7 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin from temba.orgs.views import BaseExportView, ModalMixin -from temba.orgs.views.base import BaseMenuView +from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook @@ -82,11 +82,11 @@ def post(self, request, *args, **kwargs): redirect_url = self.get_redirect_url() return HttpResponseRedirect(redirect_url) - class List(SpaMixin, ContentMenuMixin, OrgPermsMixin, SmartListView): + class List(SpaMixin, ContentMenuMixin, BaseListView): menu_path = "/ticket/shortcuts" - def get_queryset(self, **kwargs): - return super().get_queryset(**kwargs).filter(org=self.request.org, is_active=True).order_by(Lower("name")) + def derive_queryset(self, **kwargs): + return super().derive_queryset(**kwargs).filter(is_active=True).order_by(Lower("name")) def build_content_menu(self, menu): if self.has_org_perm("tickets.shortcut_create"): From dfbdb45af9459ed62671a6339a66f0b5a440b202 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 14:44:38 +0000 Subject: [PATCH 141/557] Reorganize non-org aware base views and mixins --- temba/api/views.py | 2 +- temba/apks/views.py | 2 +- temba/campaigns/views.py | 5 +- temba/channels/types/twitter/views.py | 2 +- temba/channels/types/whatsapp/views.py | 2 +- temba/channels/views.py | 3 +- temba/classifiers/views.py | 3 +- temba/contacts/views.py | 5 +- temba/flows/views.py | 5 +- temba/globals/views.py | 3 +- temba/locations/views.py | 3 +- temba/msgs/views.py | 7 +- temba/orgs/views/mixins.py | 122 ++++++- temba/orgs/views/views.py | 5 +- temba/public/views.py | 3 +- temba/request_logs/views.py | 3 +- temba/tickets/views.py | 3 +- temba/triggers/views.py | 5 +- temba/utils/views.py | 486 ------------------------- temba/utils/views/__init__.py | 1 + temba/utils/views/mixins.py | 162 +++++++++ temba/utils/views/views.py | 180 +++++++++ temba/utils/{ => views}/wizard.py | 0 temba/utils/whatsapp/views.py | 2 +- 24 files changed, 501 insertions(+), 513 deletions(-) delete mode 100644 temba/utils/views.py create mode 100644 temba/utils/views/__init__.py create mode 100644 temba/utils/views/mixins.py create mode 100644 temba/utils/views/views.py rename temba/utils/{ => views}/wizard.py (100%) diff --git a/temba/api/views.py b/temba/api/views.py index 411dfa9b58d..effe594a491 100644 --- a/temba/api/views.py +++ b/temba/api/views.py @@ -15,7 +15,7 @@ from temba.contacts.models import URN from temba.orgs.views import ModalMixin, OrgObjPermsMixin from temba.utils.models import TembaModel -from temba.utils.views import NonAtomicMixin +from temba.utils.views.mixins import NonAtomicMixin from .models import APIToken, BulkActionFailure diff --git a/temba/apks/views.py b/temba/apks/views.py index 316c2b6a777..c04fb52d8b1 100644 --- a/temba/apks/views.py +++ b/temba/apks/views.py @@ -1,6 +1,6 @@ from smartmin.views import SmartCreateView, SmartCRUDL, SmartListView, SmartUpdateView -from temba.utils.views import StaffOnlyMixin +from temba.utils.views.mixins import StaffOnlyMixin from .models import Apk diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 6577293c720..1cb5c4cbdfc 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -13,10 +13,11 @@ from temba.msgs.models import Msg from temba.orgs.views import ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField -from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import Campaign, CampaignEvent diff --git a/temba/channels/types/twitter/views.py b/temba/channels/types/twitter/views.py index 2c09fdfb835..ef114c1aa85 100644 --- a/temba/channels/types/twitter/views.py +++ b/temba/channels/types/twitter/views.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from temba.utils.views import NonAtomicMixin +from temba.utils.views.mixins import NonAtomicMixin from ...models import Channel from ...views import ClaimViewMixin, UpdateChannelForm diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 95c3e76aa95..91cfc1efc08 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -14,7 +14,7 @@ from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate -from temba.utils.views import ContentMenuMixin +from temba.utils.views.mixins import ContentMenuMixin from ...models import Channel from ...views import ClaimViewMixin diff --git a/temba/channels/views.py b/temba/channels/views.py index bbb4e9e561c..4195c958f9c 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -41,7 +41,8 @@ from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import Channel, ChannelCount, ChannelLog diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 3adf0fa9903..ecce2f61be1 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -7,7 +7,8 @@ from temba.orgs.views import DependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import Classifier diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 00cf579b2f5..0d7f1c861f3 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -28,14 +28,15 @@ from temba.orgs.models import User from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, NonAtomicMixin, SpaMixin +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport diff --git a/temba/flows/views.py b/temba/flows/views.py index 8dbe3030e09..0ab9c4a838e 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -37,7 +37,7 @@ from temba.orgs.models import IntegrationType, Org from temba.orgs.views import BaseExportView, DependencyDeleteModal, ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit from temba.utils.fields import ( @@ -49,7 +49,8 @@ TembaChoiceField, ) from temba.utils.text import slugify_with -from temba.utils.views import BulkActionMixin, ContentMenuMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin, StaffOnlyMixin from .models import FlowLabel, FlowStartCount, FlowUserConflictException, FlowVersionConflictException, ResultsExport diff --git a/temba/globals/views.py b/temba/globals/views.py index cc447096ca0..d03d1f8ab1b 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -9,7 +9,8 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views import ContentMenuMixin, SpaMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import Global diff --git a/temba/locations/views.py b/temba/locations/views.py index 4c7615eaebc..1a8d64b7705 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -10,7 +10,8 @@ from temba.locations.models import AdminBoundary, BoundaryAlias from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import json -from temba.utils.views import ContentMenuMixin, SpaMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin class BoundaryCRUDL(SmartCRUDL): diff --git a/temba/msgs/views.py b/temba/msgs/views.py index d7b2fdaec7b..b595b9da13a 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -23,7 +23,7 @@ from temba.orgs.models import Org from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation from temba.utils import json, languages @@ -37,8 +37,9 @@ SelectWidget, ) from temba.utils.models import patch_queryset_count -from temba.utils.views import BulkActionMixin, ContentMenuMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, StaffOnlyMixin -from temba.utils.wizard import SmartWizardUpdateView, SmartWizardView +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, PostOnlyMixin, StaffOnlyMixin +from temba.utils.views.wizard import SmartWizardUpdateView, SmartWizardView from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel diff --git a/temba/orgs/views/mixins.py b/temba/orgs/views/mixins.py index 2d45f104da4..86f03036101 100644 --- a/temba/orgs/views/mixins.py +++ b/temba/orgs/views/mixins.py @@ -1,7 +1,13 @@ +import logging from urllib.parse import quote_plus -from django.http import HttpResponseRedirect +from django import forms +from django.contrib import messages +from django.http import HttpResponseForbidden, HttpResponseRedirect from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +logger = logging.getLogger(__name__) class OrgPermsMixin: @@ -92,3 +98,117 @@ def derive_url_pattern(cls, path, action): def get_object(self, *args, **kwargs): return self.request.org + + +class BulkActionMixin: + """ + Mixin for list views which have bulk actions + """ + + bulk_actions = () + bulk_action_permissions = {} + + class Form(forms.Form): + def __init__(self, actions, queryset, label_queryset, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["action"] = forms.ChoiceField(choices=[(a, a) for a in actions], required=True) + self.fields["objects"] = forms.ModelMultipleChoiceField(queryset=queryset, required=False) + self.fields["all"] = forms.BooleanField(required=False) + self.fields["add"] = forms.BooleanField(required=False) + + if label_queryset: + self.fields["label"] = forms.ModelChoiceField(label_queryset, required=False) + + def clean(self): + cleaned_data = super().clean() + action = cleaned_data.get("action") + label = cleaned_data.get("label") + if action in ("label", "unlabel") and not label: + raise forms.ValidationError("Must specify a label") + + # TODO update frontend to send back unlabel actions + if action == "label" and self.data.get("add", "").lower() == "false": + cleaned_data["action"] = "unlabel" + + class Meta: + fields = ("action", "objects") + + def post(self, request, *args, **kwargs): + """ + Handles a POSTed action form and returns the default GET response + """ + user = self.request.user + org = self.request.org + form = BulkActionMixin.Form( + self.get_bulk_actions(), self.get_queryset(), self.get_bulk_action_labels(), data=self.request.POST + ) + + if form.is_valid(): + action = form.cleaned_data["action"] + objects = form.cleaned_data["objects"] + all_objects = form.cleaned_data["all"] + label = form.cleaned_data.get("label") + + if all_objects: + objects = self.get_queryset() + else: + objects_ids = [o.id for o in objects] + self.kwargs["bulk_action_ids"] = objects_ids # include in kwargs so is accessible in get call below + + # convert objects queryset to one based only on org + ids + objects = self.model._default_manager.filter(org=org, id__in=objects_ids) + + # check we have the required permission for this action + permission = self.get_bulk_action_permission(action) + if not user.has_perm(permission) and not user.has_org_perm(org, permission): + return HttpResponseForbidden() + + try: + self.apply_bulk_action(user, action, objects, label) + except forms.ValidationError as e: + for e in e.messages: + messages.info(request, e) + except Exception: + messages.error(request, _("An error occurred while making your changes. Please try again.")) + logger.exception(f"error applying '{action}' to {self.model.__name__} objects") + + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["actions"] = self.get_bulk_actions() + return context + + def get_bulk_actions(self): + """ + Gets the allowed bulk actions for this view + """ + return self.bulk_actions + + def get_bulk_action_permission(self, action): + """ + Gets the required permission for the given action (defaults to the update permission for the model class) + """ + default = f"{self.model._meta.app_label}.{self.model.__name__.lower()}_update" + + return self.bulk_action_permissions.get(action, default) + + def get_bulk_action_labels(self): + """ + Views can override this to provide a set of labels for label/unlabel actions + """ + return None + + def apply_bulk_action(self, user, action, objects, label): + """ + Applies the given action to the given objects. If this method throws a validation error, that will become the + error message sent back to the user. + """ + func_name = f"apply_action_{action}" + model_func = getattr(self.model, func_name) + assert model_func, f"{self.model.__name__} has no method called {func_name}" + + args = [label] if label else [] + + model_func(user, objects, *args) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index aaac9fc3967..b7bbe655e50 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -61,14 +61,13 @@ ) from temba.utils.text import generate_secret from temba.utils.timezones import TimeZoneFormField -from temba.utils.views import ( - ComponentFormMixin, +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ( ContentMenuMixin, NonAtomicMixin, NoNavMixin, PostOnlyMixin, RequireRecentAuthMixin, - SpaMixin, StaffOnlyMixin, ) diff --git a/temba/public/views.py b/temba/public/views.py index f238b3b8d67..b527688b0fa 100644 --- a/temba/public/views.py +++ b/temba/public/views.py @@ -13,7 +13,8 @@ from temba.public.models import Lead, Video from temba.utils import analytics, get_anonymous_user, json from temba.utils.text import generate_secret -from temba.utils.views import NoNavMixin, SpaMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import NoNavMixin class IndexView(NoNavMixin, SmartTemplateView): diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index e65c2fd9326..15cb02dd73f 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -10,7 +10,8 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils import str_to_bool -from temba.utils.views import ContentMenuMixin, SpaMixin +from temba.utils.views import SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import HTTPLog diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 4a7c88a5cff..0aceecf36b7 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -27,7 +27,8 @@ from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .forms import ShortcutForm, TopicForm from .models import ( diff --git a/temba/triggers/views.py b/temba/triggers/views.py index b14fbbbd620..4b10dd1ae53 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -16,10 +16,11 @@ from temba.formax import FormaxMixin from temba.msgs.views import ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views import BulkActionMixin, ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views.mixins import ContentMenuMixin from .models import Trigger diff --git a/temba/utils/views.py b/temba/utils/views.py deleted file mode 100644 index 91c40aa5611..00000000000 --- a/temba/utils/views.py +++ /dev/null @@ -1,486 +0,0 @@ -import logging -from urllib.parse import quote, urlencode - -import requests - -from django import forms -from django.conf import settings -from django.contrib import messages -from django.db import transaction -from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse -from django.urls import reverse -from django.utils import timezone -from django.utils.functional import cached_property -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.decorators.csrf import csrf_exempt - -from temba import __version__ as temba_version -from temba.utils import json -from temba.utils.fields import CheckboxWidget, DateWidget, InputWidget, SelectMultipleWidget, SelectWidget - -logger = logging.getLogger(__name__) - -TEMBA_MENU_SELECTION = "temba_menu_selection" -TEMBA_CONTENT_ONLY = "x-temba-content-only" -TEMBA_VERSION = "x-temba-version" - - -class NoNavMixin(View): - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["base_template"] = "no_nav.html" - return context - - -class SpaMixin(View): - """ - Uses SPA base template if the header is set appropriately - """ - - @cached_property - def spa_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_PATH", "").split("/") if s) - - @cached_property - def spa_referrer_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) - - def is_content_only(self): - return "HTTP_TEMBA_SPA" in self.request.META - - def get_template_names(self): - templates = super().get_template_names() - spa_templates = [] - - for template in templates: - original = template.split(".") - if len(original) == 2: - spa_template = original[0] + "_spa." + original[1] - if spa_template: - spa_templates.append(spa_template) - - return spa_templates + templates - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["temba_version"] = temba_version - - if self.request.org: - context["active_org"] = self.request.org - - if self.is_content_only(): - context["base_template"] = "spa.html" - else: - context["base_template"] = "frame.html" - - context["is_spa"] = True - context["is_content_only"] = self.is_content_only() - 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) - dev_host = getattr(settings, "EDITOR_DEV_HOST", "localhost") - 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(f"http://{dev_host}: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["flow_editor_scripts"] = scripts - context["flow_editor_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) - response.headers[TEMBA_VERSION] = temba_version - response.headers[TEMBA_MENU_SELECTION] = context[TEMBA_MENU_SELECTION] - response.headers[TEMBA_CONTENT_ONLY] = 1 if self.is_content_only() else 0 - return response - - -class ComponentFormMixin(View): - """ - Mixin to replace form field controls with component based widgets - """ - - def customize_form_field(self, name, field): - attrs = field.widget.attrs if field.widget.attrs else {} - - # don't replace the widget if it is already one of us - if isinstance( - field.widget, - (forms.widgets.HiddenInput, CheckboxWidget, InputWidget, SelectWidget, SelectMultipleWidget, DateWidget), - ): - return field - - if isinstance(field.widget, (forms.widgets.Textarea,)): - attrs["textarea"] = True - field.widget = InputWidget(attrs=attrs) - elif isinstance(field.widget, (forms.widgets.PasswordInput,)): # pragma: needs cover - attrs["password"] = True - field.widget = InputWidget(attrs=attrs) - elif isinstance( - field.widget, - (forms.widgets.TextInput, forms.widgets.EmailInput, forms.widgets.URLInput, forms.widgets.NumberInput), - ): - field.widget = InputWidget(attrs=attrs) - elif isinstance(field.widget, (forms.widgets.Select,)): - if isinstance(field, (forms.models.ModelMultipleChoiceField,)): - field.widget = SelectMultipleWidget(attrs) # pragma: needs cover - else: - field.widget = SelectWidget(attrs) - - field.widget.choices = field.choices - elif isinstance(field.widget, (forms.widgets.CheckboxInput,)): - field.widget = CheckboxWidget(attrs) - - return field - - -class StaffOnlyMixin: - """ - Views that only staff should be able to access - """ - - def has_permission(self, request, *args, **kwargs): - return self.request.user.is_staff - - -class PostOnlyMixin(View): - """ - Utility mixin to make a class based view be POST only - """ - - def get(self, *args, **kwargs): - return HttpResponse("Method Not Allowed", status=405) - - -class NonAtomicMixin(View): - """ - Utility mixin to disable automatic transaction wrapping of a class based view - """ - - @transaction.non_atomic_requests - def dispatch(self, request, *args, **kwargs): - return super().dispatch(request, *args, **kwargs) - - -class BulkActionMixin: - """ - Mixin for list views which have bulk actions - """ - - bulk_actions = () - bulk_action_permissions = {} - - class Form(forms.Form): - def __init__(self, actions, queryset, label_queryset, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["action"] = forms.ChoiceField(choices=[(a, a) for a in actions], required=True) - self.fields["objects"] = forms.ModelMultipleChoiceField(queryset=queryset, required=False) - self.fields["all"] = forms.BooleanField(required=False) - self.fields["add"] = forms.BooleanField(required=False) - - if label_queryset: - self.fields["label"] = forms.ModelChoiceField(label_queryset, required=False) - - def clean(self): - cleaned_data = super().clean() - action = cleaned_data.get("action") - label = cleaned_data.get("label") - if action in ("label", "unlabel") and not label: - raise forms.ValidationError("Must specify a label") - - # TODO update frontend to send back unlabel actions - if action == "label" and self.data.get("add", "").lower() == "false": - cleaned_data["action"] = "unlabel" - - class Meta: - fields = ("action", "objects") - - def post(self, request, *args, **kwargs): - """ - Handles a POSTed action form and returns the default GET response - """ - user = self.request.user - org = self.request.org - form = BulkActionMixin.Form( - self.get_bulk_actions(), self.get_queryset(), self.get_bulk_action_labels(), data=self.request.POST - ) - - if form.is_valid(): - action = form.cleaned_data["action"] - objects = form.cleaned_data["objects"] - all_objects = form.cleaned_data["all"] - label = form.cleaned_data.get("label") - - if all_objects: - objects = self.get_queryset() - else: - objects_ids = [o.id for o in objects] - self.kwargs["bulk_action_ids"] = objects_ids # include in kwargs so is accessible in get call below - - # convert objects queryset to one based only on org + ids - objects = self.model._default_manager.filter(org=org, id__in=objects_ids) - - # check we have the required permission for this action - permission = self.get_bulk_action_permission(action) - if not user.has_perm(permission) and not user.has_org_perm(org, permission): - return HttpResponseForbidden() - - try: - self.apply_bulk_action(user, action, objects, label) - except forms.ValidationError as e: - for e in e.messages: - messages.info(request, e) - except Exception: - messages.error(request, _("An error occurred while making your changes. Please try again.")) - logger.exception(f"error applying '{action}' to {self.model.__name__} objects") - - return self.get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["actions"] = self.get_bulk_actions() - return context - - def get_bulk_actions(self): - """ - Gets the allowed bulk actions for this view - """ - return self.bulk_actions - - def get_bulk_action_permission(self, action): - """ - Gets the required permission for the given action (defaults to the update permission for the model class) - """ - default = f"{self.model._meta.app_label}.{self.model.__name__.lower()}_update" - - return self.bulk_action_permissions.get(action, default) - - def get_bulk_action_labels(self): - """ - Views can override this to provide a set of labels for label/unlabel actions - """ - return None - - def apply_bulk_action(self, user, action, objects, label): - """ - Applies the given action to the given objects. If this method throws a validation error, that will become the - error message sent back to the user. - """ - func_name = f"apply_action_{action}" - model_func = getattr(self.model, func_name) - assert model_func, f"{self.model.__name__} has no method called {func_name}" - - args = [label] if label else [] - - model_func(user, objects, *args) - - -class RequireRecentAuthMixin: - """ - Mixin that redirects the user to a authentication page if they haven't authenticated recently. - """ - - recent_auth_seconds = 10 * 60 - recent_auth_includes_formax = False - - def pre_process(self, request, *args, **kwargs): - is_formax = "HTTP_X_FORMAX" in request.META - if not is_formax or self.recent_auth_includes_formax: - last_auth_on = request.user.settings.last_auth_on - if not last_auth_on or (timezone.now() - last_auth_on).total_seconds() > self.recent_auth_seconds: - return HttpResponseRedirect(reverse("users.confirm_access") + f"?next={quote(request.path)}") - - return super().pre_process(request, *args, **kwargs) - - -class ExternalURLHandler(View): - """ - It's useful to register Courier and Mailroom URLs in RapidPro so they can be used in templates, and if they are hit - here, we can provide the user with a error message about - """ - - service = None - - @csrf_exempt - def dispatch(self, request, *args, **kwargs): - logger.error(f"URL intended for {self.service} reached RapidPro", extra={"URL": request.get_full_path()}) - return HttpResponse(f"this URL should be mapped to a {self.service} instance", status=404) - - -class CourierURLHandler(ExternalURLHandler): - service = "Courier" - - -class MailroomURLHandler(ExternalURLHandler): - service = "Mailroom" - - -class ContentMenu: - """ - Utility for building content menus - """ - - def __init__(self): - self.groups = [[]] - - def new_group(self): - self.groups.append([]) - - def add_link(self, label: str, url: str, as_button: bool = False): - self.groups[-1].append({"type": "link", "label": label, "url": url, "as_button": as_button}) - - def add_js(self, id: str, label: str, as_button: bool = False): - self.groups[-1].append( - { - "id": id, - "type": "js", - "label": label, - "as_button": as_button, - } - ) - - def add_url_post(self, label: str, url: str, as_button: bool = False): - self.groups[-1].append({"type": "url_post", "label": label, "url": url, "as_button": as_button}) - - def add_modax( - self, - label: str, - modal_id: str, - url: str, - *, - title: str = None, - on_submit: str = None, - on_redirect: str = None, - primary: bool = False, - as_button: bool = False, - disabled: bool = False, - ): - self.groups[-1].append( - { - "type": "modax", - "label": label, - "url": url, - "modal_id": modal_id, - "title": title or label, - "on_submit": on_submit, - "on_redirect": on_redirect, - "primary": primary, - "as_button": as_button, - "disabled": disabled, - } - ) - - def as_items(self): - """ - Reduce groups to a flat list of items separated by dividers. - """ - items = [] - for group in self.groups: - if not group: - continue - if items: - items.append({"type": "divider"}) - items.extend(group) - return items - - -class ContentMenuMixin: - """ - Mixin for views that have a content menu (hamburger icon with dropdown items) - - TODO: use component to read menu as JSON and then can stop putting menu (in legacy gear-links format) in context - """ - - # renderers to convert menu items to the legacy "gear-links" format - gear_link_renderers = { - "link": lambda i: {"title": i["label"], "href": i["url"], "as_button": i["as_button"]}, - "js": lambda i: { - "id": i["id"], - "title": i["label"], - "on_click": i["on_click"], - "js_class": i["link_class"], - "href": "#", - "as_button": i["as_button"], - }, - "url_post": lambda i: { - "title": i["label"], - "href": i["url"], - "js_class": "posterize", - "as_button": i["as_button"], - }, - "modax": lambda i: { - "id": i["modal_id"], - "title": i["label"], - "modax": i["title"], - "href": i["url"], - "on_submit": i["on_submit"], - "style": "button-primary" if i["primary"] else "", - "as_button": i["as_button"], - "disabled": i["disabled"], - }, - "divider": lambda i: {"divider": True}, - } - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # does the page have a content menu? - context["has_content_menu"] = len(self._get_content_menu()) > 0 - - # does the page have a search query? - if "search" in self.request.GET: - context["has_search_query"] = urlencode({"search": self.request.GET["search"]}) - - return context - - def _get_content_menu(self): - menu = ContentMenu() - self.build_content_menu(menu) - return menu.as_items() - - def build_content_menu(self, menu: ContentMenu): # pragma: no cover - pass - - def get(self, request, *args, **kwargs): - if "HTTP_TEMBA_CONTENT_MENU" in self.request.META: - return JsonResponse({"items": self._get_content_menu()}) - - return super().get(request, *args, **kwargs) diff --git a/temba/utils/views/__init__.py b/temba/utils/views/__init__.py new file mode 100644 index 00000000000..360d6f84458 --- /dev/null +++ b/temba/utils/views/__init__.py @@ -0,0 +1 @@ +from .views import * # noqa diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py new file mode 100644 index 00000000000..0391d6b4dd2 --- /dev/null +++ b/temba/utils/views/mixins.py @@ -0,0 +1,162 @@ +import logging +from urllib.parse import quote, urlencode + +from django.db import transaction +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.urls import reverse +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class NoNavMixin: + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["base_template"] = "no_nav.html" + return context + + +class NonAtomicMixin: + """ + Utility mixin to disable automatic transaction wrapping of a class based view + """ + + @transaction.non_atomic_requests + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + + +class PostOnlyMixin: + """ + Utility mixin to make a class based view be POST only + """ + + def get(self, *args, **kwargs): + return HttpResponse("Method Not Allowed", status=405) + + +class RequireRecentAuthMixin: + """ + Mixin that redirects the user to a authentication page if they haven't authenticated recently. + """ + + recent_auth_seconds = 10 * 60 + recent_auth_includes_formax = False + + def pre_process(self, request, *args, **kwargs): + is_formax = "HTTP_X_FORMAX" in request.META + if not is_formax or self.recent_auth_includes_formax: + last_auth_on = request.user.settings.last_auth_on + if not last_auth_on or (timezone.now() - last_auth_on).total_seconds() > self.recent_auth_seconds: + return HttpResponseRedirect(reverse("users.confirm_access") + f"?next={quote(request.path)}") + + return super().pre_process(request, *args, **kwargs) + + +class StaffOnlyMixin: + """ + Views that only staff should be able to access + """ + + def has_permission(self, request, *args, **kwargs): + return self.request.user.is_staff + + +class ContentMenuMixin: + """ + Mixin for views that have a content menu (hamburger icon with dropdown items) + """ + + class Menu: + """ + Utility for building content menus + """ + + def __init__(self): + self.groups = [[]] + + def new_group(self): + self.groups.append([]) + + def add_link(self, label: str, url: str, as_button: bool = False): + self.groups[-1].append({"type": "link", "label": label, "url": url, "as_button": as_button}) + + def add_js(self, id: str, label: str, as_button: bool = False): + self.groups[-1].append( + { + "id": id, + "type": "js", + "label": label, + "as_button": as_button, + } + ) + + def add_url_post(self, label: str, url: str, as_button: bool = False): + self.groups[-1].append({"type": "url_post", "label": label, "url": url, "as_button": as_button}) + + def add_modax( + self, + label: str, + modal_id: str, + url: str, + *, + title: str = None, + on_submit: str = None, + on_redirect: str = None, + primary: bool = False, + as_button: bool = False, + disabled: bool = False, + ): + self.groups[-1].append( + { + "type": "modax", + "label": label, + "url": url, + "modal_id": modal_id, + "title": title or label, + "on_submit": on_submit, + "on_redirect": on_redirect, + "primary": primary, + "as_button": as_button, + "disabled": disabled, + } + ) + + def as_items(self): + """ + Reduce groups to a flat list of items separated by dividers. + """ + items = [] + for group in self.groups: + if not group: + continue + if items: + items.append({"type": "divider"}) + items.extend(group) + return items + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # does the page have a content menu? + context["has_content_menu"] = len(self._get_content_menu()) > 0 + + # does the page have a search query? + if "search" in self.request.GET: + context["has_search_query"] = urlencode({"search": self.request.GET["search"]}) + + return context + + def _get_content_menu(self): + menu = self.Menu() + self.build_content_menu(menu) + return menu.as_items() + + def build_content_menu(self, menu: Menu): # pragma: no cover + pass + + def get(self, request, *args, **kwargs): + if "HTTP_TEMBA_CONTENT_MENU" in self.request.META: + return JsonResponse({"items": self._get_content_menu()}) + + return super().get(request, *args, **kwargs) diff --git a/temba/utils/views/views.py b/temba/utils/views/views.py new file mode 100644 index 00000000000..e22f4ea4e8b --- /dev/null +++ b/temba/utils/views/views.py @@ -0,0 +1,180 @@ +import logging + +import requests + +from django import forms +from django.conf import settings +from django.http import HttpResponse +from django.utils.functional import cached_property +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from temba import __version__ as temba_version +from temba.utils import json +from temba.utils.fields import CheckboxWidget, DateWidget, InputWidget, SelectMultipleWidget, SelectWidget + +logger = logging.getLogger(__name__) + +TEMBA_MENU_SELECTION = "temba_menu_selection" +TEMBA_CONTENT_ONLY = "x-temba-content-only" +TEMBA_VERSION = "x-temba-version" + + +class SpaMixin(View): + """ + Uses SPA base template if the header is set appropriately + """ + + @cached_property + def spa_path(self) -> tuple: + return tuple(s for s in self.request.META.get("HTTP_TEMBA_PATH", "").split("/") if s) + + @cached_property + def spa_referrer_path(self) -> tuple: + return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) + + def is_content_only(self): + return "HTTP_TEMBA_SPA" in self.request.META + + def get_template_names(self): + templates = super().get_template_names() + spa_templates = [] + + for template in templates: + original = template.split(".") + if len(original) == 2: + spa_template = original[0] + "_spa." + original[1] + if spa_template: + spa_templates.append(spa_template) + + return spa_templates + templates + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["temba_version"] = temba_version + + if self.request.org: + context["active_org"] = self.request.org + + if self.is_content_only(): + context["base_template"] = "spa.html" + else: + context["base_template"] = "frame.html" + + context["is_spa"] = True + context["is_content_only"] = self.is_content_only() + 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) + dev_host = getattr(settings, "EDITOR_DEV_HOST", "localhost") + 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(f"http://{dev_host}: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["flow_editor_scripts"] = scripts + context["flow_editor_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) + response.headers[TEMBA_VERSION] = temba_version + response.headers[TEMBA_MENU_SELECTION] = context[TEMBA_MENU_SELECTION] + response.headers[TEMBA_CONTENT_ONLY] = 1 if self.is_content_only() else 0 + return response + + +class ComponentFormMixin(View): + """ + Mixin to replace form field controls with component based widgets + """ + + def customize_form_field(self, name, field): + attrs = field.widget.attrs if field.widget.attrs else {} + + # don't replace the widget if it is already one of us + if isinstance( + field.widget, + (forms.widgets.HiddenInput, CheckboxWidget, InputWidget, SelectWidget, SelectMultipleWidget, DateWidget), + ): + return field + + if isinstance(field.widget, (forms.widgets.Textarea,)): + attrs["textarea"] = True + field.widget = InputWidget(attrs=attrs) + elif isinstance(field.widget, (forms.widgets.PasswordInput,)): # pragma: needs cover + attrs["password"] = True + field.widget = InputWidget(attrs=attrs) + elif isinstance( + field.widget, + (forms.widgets.TextInput, forms.widgets.EmailInput, forms.widgets.URLInput, forms.widgets.NumberInput), + ): + field.widget = InputWidget(attrs=attrs) + elif isinstance(field.widget, (forms.widgets.Select,)): + if isinstance(field, (forms.models.ModelMultipleChoiceField,)): + field.widget = SelectMultipleWidget(attrs) # pragma: needs cover + else: + field.widget = SelectWidget(attrs) + + field.widget.choices = field.choices + elif isinstance(field.widget, (forms.widgets.CheckboxInput,)): + field.widget = CheckboxWidget(attrs) + + return field + + +class ExternalURLHandler(View): + """ + It's useful to register Courier and Mailroom URLs in RapidPro so they can be used in templates, and if they are hit + here, we can provide the user with a error message about + """ + + service = None + + @csrf_exempt + def dispatch(self, request, *args, **kwargs): + logger.error(f"URL intended for {self.service} reached RapidPro", extra={"URL": request.get_full_path()}) + return HttpResponse(f"this URL should be mapped to a {self.service} instance", status=404) + + +class CourierURLHandler(ExternalURLHandler): + service = "Courier" + + +class MailroomURLHandler(ExternalURLHandler): + service = "Mailroom" diff --git a/temba/utils/wizard.py b/temba/utils/views/wizard.py similarity index 100% rename from temba/utils/wizard.py rename to temba/utils/views/wizard.py diff --git a/temba/utils/whatsapp/views.py b/temba/utils/whatsapp/views.py index bae94373d46..73a37c4d972 100644 --- a/temba/utils/whatsapp/views.py +++ b/temba/utils/whatsapp/views.py @@ -5,7 +5,7 @@ from temba.channels.models import Channel from temba.channels.views import ChannelTypeMixin from temba.orgs.views.mixins import OrgPermsMixin -from temba.utils.views import PostOnlyMixin +from temba.utils.views.mixins import PostOnlyMixin from .tasks import refresh_whatsapp_contacts From 1dc58931cb716457ede261fd14dae9b4da635e94 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 15:31:10 +0000 Subject: [PATCH 142/557] Move SpaMixin as well --- temba/airtime/views.py | 2 +- temba/archives/views.py | 2 +- temba/campaigns/tests.py | 2 +- temba/campaigns/views.py | 3 +- temba/channels/tests.py | 2 +- temba/channels/types/whatsapp/tests.py | 2 +- temba/channels/views.py | 4 +- temba/classifiers/tests.py | 2 +- temba/classifiers/views.py | 4 +- temba/contacts/tests.py | 2 +- temba/contacts/views.py | 4 +- temba/dashboard/views.py | 2 +- temba/flows/tests.py | 2 +- temba/flows/views.py | 3 +- temba/globals/views.py | 3 +- temba/locations/views.py | 3 +- temba/msgs/tests.py | 2 +- temba/msgs/views.py | 3 +- temba/notifications/views.py | 2 +- temba/orgs/tests.py | 2 +- temba/orgs/views/views.py | 3 +- temba/public/views.py | 3 +- temba/request_logs/tests.py | 2 +- temba/request_logs/views.py | 3 +- temba/templates/views.py | 2 +- temba/tickets/views.py | 4 +- temba/triggers/tests.py | 2 +- temba/triggers/views.py | 4 +- temba/utils/views/mixins.py | 110 +++++++++++++++++++++++++ temba/utils/views/views.py | 109 ------------------------ 30 files changed, 144 insertions(+), 149 deletions(-) diff --git a/temba/airtime/views.py b/temba/airtime/views.py index dfaef571f8c..3c93a31cdea 100644 --- a/temba/airtime/views.py +++ b/temba/airtime/views.py @@ -9,7 +9,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin from temba.request_logs.models import HTTPLog -from temba.utils.views import SpaMixin +from temba.utils.views.mixins import SpaMixin class AirtimeCRUDL(SmartCRUDL): diff --git a/temba/archives/views.py b/temba/archives/views.py index 372113b3da2..4f71042cd14 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -6,7 +6,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin -from temba.utils.views import SpaMixin +from temba.utils.views.mixins import SpaMixin from .models import Archive diff --git a/temba/campaigns/tests.py b/temba/campaigns/tests.py index a0c93f19d37..62a563af148 100644 --- a/temba/campaigns/tests.py +++ b/temba/campaigns/tests.py @@ -14,7 +14,7 @@ from temba.msgs.models import Msg from temba.orgs.models import DefinitionExport, Org from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import Campaign, CampaignEvent, EventFire from .tasks import trim_event_fires diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 1cb5c4cbdfc..e79e151379d 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -16,8 +16,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import Campaign, CampaignEvent diff --git a/temba/channels/tests.py b/temba/channels/tests.py index d0bc74a7a86..16274dc24f0 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -30,7 +30,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 temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import Channel, ChannelCount, ChannelEvent, ChannelLog, SyncEvent from .tasks import ( diff --git a/temba/channels/types/whatsapp/tests.py b/temba/channels/types/whatsapp/tests.py index efcec116c66..21da8e1329e 100644 --- a/temba/channels/types/whatsapp/tests.py +++ b/temba/channels/types/whatsapp/tests.py @@ -8,7 +8,7 @@ from temba.request_logs.models import HTTPLog from temba.tests import MockJsonResponse, MockResponse, TembaTest -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from ...models import Channel from .type import WhatsAppType diff --git a/temba/channels/views.py b/temba/channels/views.py index 4195c958f9c..19a0d9a229c 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -41,8 +41,8 @@ from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views import ComponentFormMixin, SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views import ComponentFormMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import Channel, ChannelCount, ChannelLog diff --git a/temba/classifiers/tests.py b/temba/classifiers/tests.py index e8245e819c8..4df27b9b1f6 100644 --- a/temba/classifiers/tests.py +++ b/temba/classifiers/tests.py @@ -5,7 +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 temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import Classifier from .types.luis import LuisType diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index ecce2f61be1..0d8b92126ea 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -7,8 +7,8 @@ from temba.orgs.views import DependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.utils.views import ComponentFormMixin, SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views import ComponentFormMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import Classifier diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 3c7a62fa4e5..d9e9a9e59ea 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -35,7 +35,7 @@ from temba.triggers.models import Trigger from temba.utils import json, s3 from temba.utils.dates import datetime_to_timestamp -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import ( URN, diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 0d7f1c861f3..d15baaa538e 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -35,8 +35,8 @@ from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views import ComponentFormMixin, SpaMixin -from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin +from temba.utils.views import ComponentFormMixin +from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, SpaMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport diff --git a/temba/dashboard/views.py b/temba/dashboard/views.py index 980e774ba04..9663fc0f38f 100644 --- a/temba/dashboard/views.py +++ b/temba/dashboard/views.py @@ -11,7 +11,7 @@ from temba.channels.models import Channel, ChannelCount from temba.orgs.models import Org from temba.orgs.views.mixins import OrgPermsMixin -from temba.utils.views import SpaMixin +from temba.utils.views.mixins import SpaMixin flattened_colors = [ "#335c81", diff --git a/temba/flows/tests.py b/temba/flows/tests.py index 340b9d0b1b9..ecd5bc1661d 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -29,7 +29,7 @@ from temba.triggers.models import Trigger from temba.utils import json, s3 from temba.utils.uuid import uuid4 -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .checks import mailroom_url from .models import ( diff --git a/temba/flows/views.py b/temba/flows/views.py index 0ab9c4a838e..38c25240fdb 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -49,8 +49,7 @@ TembaChoiceField, ) from temba.utils.text import slugify_with -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin, StaffOnlyMixin from .models import FlowLabel, FlowStartCount, FlowUserConflictException, FlowVersionConflictException, ResultsExport diff --git a/temba/globals/views.py b/temba/globals/views.py index d03d1f8ab1b..a04d3b162a4 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -9,8 +9,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import Global diff --git a/temba/locations/views.py b/temba/locations/views.py index 1a8d64b7705..dd7ef6efc5e 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -10,8 +10,7 @@ from temba.locations.models import AdminBoundary, BoundaryAlias from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import json -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin class BoundaryCRUDL(SmartCRUDL): diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 6760a4e8178..3eef86bf2ff 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -36,7 +36,7 @@ from temba.utils import s3 from temba.utils.compose import compose_deserialize_attachments, compose_serialize from temba.utils.fields import ContactSearchWidget -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .tasks import fail_old_android_messages, squash_msg_counts diff --git a/temba/msgs/views.py b/temba/msgs/views.py index b595b9da13a..c8cf7031713 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -37,8 +37,7 @@ SelectWidget, ) from temba.utils.models import patch_queryset_count -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, PostOnlyMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, StaffOnlyMixin from temba.utils.views.wizard import SmartWizardUpdateView, SmartWizardView from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel diff --git a/temba/notifications/views.py b/temba/notifications/views.py index fa93c64a6f7..21482a4052d 100644 --- a/temba/notifications/views.py +++ b/temba/notifications/views.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from temba.orgs.views.mixins import OrgPermsMixin -from temba.utils.views import SpaMixin +from temba.utils.views.mixins import SpaMixin from .mixins import NotificationTargetMixin from .models import Incident diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 632af5e630f..6a5edb2765b 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -48,7 +48,7 @@ from temba.triggers.models import Trigger from temba.utils import json, languages from temba.utils.uuid import uuid4 -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .context_processors import RolePermsWrapper from .models import ( diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index b7bbe655e50..58380b48937 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -61,13 +61,14 @@ ) from temba.utils.text import generate_secret from temba.utils.timezones import TimeZoneFormField -from temba.utils.views import ComponentFormMixin, SpaMixin +from temba.utils.views import ComponentFormMixin from temba.utils.views.mixins import ( ContentMenuMixin, NonAtomicMixin, NoNavMixin, PostOnlyMixin, RequireRecentAuthMixin, + SpaMixin, StaffOnlyMixin, ) diff --git a/temba/public/views.py b/temba/public/views.py index b527688b0fa..2245fca4bce 100644 --- a/temba/public/views.py +++ b/temba/public/views.py @@ -13,8 +13,7 @@ from temba.public.models import Lead, Video from temba.utils import analytics, get_anonymous_user, json from temba.utils.text import generate_secret -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import NoNavMixin +from temba.utils.views.mixins import NoNavMixin, SpaMixin class IndexView(NoNavMixin, SmartTemplateView): diff --git a/temba/request_logs/tests.py b/temba/request_logs/tests.py index 743f5c4add5..27304a8554a 100644 --- a/temba/request_logs/tests.py +++ b/temba/request_logs/tests.py @@ -9,7 +9,7 @@ from temba.classifiers.models import Classifier from temba.classifiers.types.wit import WitType from temba.tests import CRUDLTestMixin, TembaTest -from temba.utils.views import TEMBA_MENU_SELECTION +from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import HTTPLog from .tasks import trim_http_logs diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 15cb02dd73f..8efd962f59f 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -10,8 +10,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils import str_to_bool -from temba.utils.views import SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import HTTPLog diff --git a/temba/templates/views.py b/temba/templates/views.py index 8654e57437d..68a6f5d5a7f 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -3,7 +3,7 @@ from temba.orgs.views import DependencyUsagesModal from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin -from temba.utils.views import SpaMixin +from temba.utils.views.mixins import SpaMixin from .models import Template, TemplateTranslation diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 0aceecf36b7..70b6b197c03 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -27,8 +27,8 @@ from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views import ComponentFormMixin, SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views import ComponentFormMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .forms import ShortcutForm, TopicForm from .models import ( diff --git a/temba/triggers/tests.py b/temba/triggers/tests.py index 8e80f88d7f0..a353a33412f 100644 --- a/temba/triggers/tests.py +++ b/temba/triggers/tests.py @@ -12,7 +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 temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import Trigger from .types import KeywordTriggerType diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 4b10dd1ae53..d8805f4962c 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -19,8 +19,8 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views import ComponentFormMixin, SpaMixin -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views import ComponentFormMixin +from temba.utils.views.mixins import ContentMenuMixin, SpaMixin from .models import Trigger diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 0391d6b4dd2..c5c7d9c637b 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -1,13 +1,24 @@ import logging from urllib.parse import quote, urlencode +import requests + +from django.conf import settings from django.db import transaction from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse from django.utils import timezone +from django.utils.functional import cached_property + +from temba import __version__ as temba_version +from temba.utils import json logger = logging.getLogger(__name__) +TEMBA_MENU_SELECTION = "temba_menu_selection" +TEMBA_CONTENT_ONLY = "x-temba-content-only" +TEMBA_VERSION = "x-temba-version" + class NoNavMixin: def get_context_data(self, **kwargs): @@ -160,3 +171,102 @@ def get(self, request, *args, **kwargs): return JsonResponse({"items": self._get_content_menu()}) return super().get(request, *args, **kwargs) + + +class SpaMixin: + """ + Uses SPA base template if the header is set appropriately + """ + + @cached_property + def spa_path(self) -> tuple: + return tuple(s for s in self.request.META.get("HTTP_TEMBA_PATH", "").split("/") if s) + + @cached_property + def spa_referrer_path(self) -> tuple: + return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) + + def is_content_only(self): + return "HTTP_TEMBA_SPA" in self.request.META + + def get_template_names(self): + templates = super().get_template_names() + spa_templates = [] + + for template in templates: + original = template.split(".") + if len(original) == 2: + spa_template = original[0] + "_spa." + original[1] + if spa_template: + spa_templates.append(spa_template) + + return spa_templates + templates + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["temba_version"] = temba_version + + if self.request.org: + context["active_org"] = self.request.org + + if self.is_content_only(): + context["base_template"] = "spa.html" + else: + context["base_template"] = "frame.html" + + context["is_spa"] = True + context["is_content_only"] = self.is_content_only() + 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) + dev_host = getattr(settings, "EDITOR_DEV_HOST", "localhost") + 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(f"http://{dev_host}: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["flow_editor_scripts"] = scripts + context["flow_editor_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) + response.headers[TEMBA_VERSION] = temba_version + response.headers[TEMBA_MENU_SELECTION] = context[TEMBA_MENU_SELECTION] + response.headers[TEMBA_CONTENT_ONLY] = 1 if self.is_content_only() else 0 + return response diff --git a/temba/utils/views/views.py b/temba/utils/views/views.py index e22f4ea4e8b..ec5b5400b2a 100644 --- a/temba/utils/views/views.py +++ b/temba/utils/views/views.py @@ -1,123 +1,14 @@ import logging -import requests - from django import forms -from django.conf import settings from django.http import HttpResponse -from django.utils.functional import cached_property from django.views import View from django.views.decorators.csrf import csrf_exempt -from temba import __version__ as temba_version -from temba.utils import json from temba.utils.fields import CheckboxWidget, DateWidget, InputWidget, SelectMultipleWidget, SelectWidget logger = logging.getLogger(__name__) -TEMBA_MENU_SELECTION = "temba_menu_selection" -TEMBA_CONTENT_ONLY = "x-temba-content-only" -TEMBA_VERSION = "x-temba-version" - - -class SpaMixin(View): - """ - Uses SPA base template if the header is set appropriately - """ - - @cached_property - def spa_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_PATH", "").split("/") if s) - - @cached_property - def spa_referrer_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) - - def is_content_only(self): - return "HTTP_TEMBA_SPA" in self.request.META - - def get_template_names(self): - templates = super().get_template_names() - spa_templates = [] - - for template in templates: - original = template.split(".") - if len(original) == 2: - spa_template = original[0] + "_spa." + original[1] - if spa_template: - spa_templates.append(spa_template) - - return spa_templates + templates - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["temba_version"] = temba_version - - if self.request.org: - context["active_org"] = self.request.org - - if self.is_content_only(): - context["base_template"] = "spa.html" - else: - context["base_template"] = "frame.html" - - context["is_spa"] = True - context["is_content_only"] = self.is_content_only() - 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) - dev_host = getattr(settings, "EDITOR_DEV_HOST", "localhost") - 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(f"http://{dev_host}: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["flow_editor_scripts"] = scripts - context["flow_editor_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) - response.headers[TEMBA_VERSION] = temba_version - response.headers[TEMBA_MENU_SELECTION] = context[TEMBA_MENU_SELECTION] - response.headers[TEMBA_CONTENT_ONLY] = 1 if self.is_content_only() else 0 - return response - class ComponentFormMixin(View): """ From 4a4977422464789379f14558e9be3531f85b7b11 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 15:49:44 +0000 Subject: [PATCH 143/557] Move ComponentFormsMixin as well --- temba/channels/views.py | 3 +-- temba/classifiers/views.py | 3 +-- temba/contacts/views.py | 3 +-- temba/orgs/views/views.py | 2 +- temba/tickets/views.py | 3 +-- temba/triggers/views.py | 3 +-- temba/utils/views/mixins.py | 41 ++++++++++++++++++++++++++++++++++++ temba/utils/views/views.py | 42 ------------------------------------- 8 files changed, 47 insertions(+), 53 deletions(-) diff --git a/temba/channels/views.py b/temba/channels/views.py index 19a0d9a229c..4fc268dd91c 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -41,8 +41,7 @@ from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views import ComponentFormMixin -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin from .models import Channel, ChannelCount, ChannelLog diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 0d8b92126ea..f36a2371482 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -7,8 +7,7 @@ from temba.orgs.views import DependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.utils.views import ComponentFormMixin -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin from .models import Classifier diff --git a/temba/contacts/views.py b/temba/contacts/views.py index d15baaa538e..96808e673a6 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -35,8 +35,7 @@ from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views import ComponentFormMixin -from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, NonAtomicMixin, SpaMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 58380b48937..06d47dec797 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -61,8 +61,8 @@ ) from temba.utils.text import generate_secret from temba.utils.timezones import TimeZoneFormField -from temba.utils.views import ComponentFormMixin from temba.utils.views.mixins import ( + ComponentFormMixin, ContentMenuMixin, NonAtomicMixin, NoNavMixin, diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 70b6b197c03..924ccb8280f 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -27,8 +27,7 @@ from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views import ComponentFormMixin -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin from .forms import ShortcutForm, TopicForm from .models import ( diff --git a/temba/triggers/views.py b/temba/triggers/views.py index d8805f4962c..84babcf55f4 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -19,8 +19,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views import ComponentFormMixin -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin from .models import Trigger diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index c5c7d9c637b..87fdf750f10 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -3,6 +3,7 @@ import requests +from django import forms from django.conf import settings from django.db import transaction from django.http import HttpResponse, HttpResponseRedirect, JsonResponse @@ -12,6 +13,7 @@ from temba import __version__ as temba_version from temba.utils import json +from temba.utils.fields import CheckboxWidget, DateWidget, InputWidget, SelectMultipleWidget, SelectWidget logger = logging.getLogger(__name__) @@ -73,6 +75,45 @@ def has_permission(self, request, *args, **kwargs): return self.request.user.is_staff +class ComponentFormMixin: + """ + Mixin to replace form field controls with component based widgets + """ + + def customize_form_field(self, name, field): + attrs = field.widget.attrs if field.widget.attrs else {} + + # don't replace the widget if it is already one of us + if isinstance( + field.widget, + (forms.widgets.HiddenInput, CheckboxWidget, InputWidget, SelectWidget, SelectMultipleWidget, DateWidget), + ): + return field + + if isinstance(field.widget, (forms.widgets.Textarea,)): + attrs["textarea"] = True + field.widget = InputWidget(attrs=attrs) + elif isinstance(field.widget, (forms.widgets.PasswordInput,)): # pragma: needs cover + attrs["password"] = True + field.widget = InputWidget(attrs=attrs) + elif isinstance( + field.widget, + (forms.widgets.TextInput, forms.widgets.EmailInput, forms.widgets.URLInput, forms.widgets.NumberInput), + ): + field.widget = InputWidget(attrs=attrs) + elif isinstance(field.widget, (forms.widgets.Select,)): + if isinstance(field, (forms.models.ModelMultipleChoiceField,)): + field.widget = SelectMultipleWidget(attrs) # pragma: needs cover + else: + field.widget = SelectWidget(attrs) + + field.widget.choices = field.choices + elif isinstance(field.widget, (forms.widgets.CheckboxInput,)): + field.widget = CheckboxWidget(attrs) + + return field + + class ContentMenuMixin: """ Mixin for views that have a content menu (hamburger icon with dropdown items) diff --git a/temba/utils/views/views.py b/temba/utils/views/views.py index ec5b5400b2a..adc994a1253 100644 --- a/temba/utils/views/views.py +++ b/temba/utils/views/views.py @@ -1,54 +1,12 @@ import logging -from django import forms from django.http import HttpResponse from django.views import View from django.views.decorators.csrf import csrf_exempt -from temba.utils.fields import CheckboxWidget, DateWidget, InputWidget, SelectMultipleWidget, SelectWidget - logger = logging.getLogger(__name__) -class ComponentFormMixin(View): - """ - Mixin to replace form field controls with component based widgets - """ - - def customize_form_field(self, name, field): - attrs = field.widget.attrs if field.widget.attrs else {} - - # don't replace the widget if it is already one of us - if isinstance( - field.widget, - (forms.widgets.HiddenInput, CheckboxWidget, InputWidget, SelectWidget, SelectMultipleWidget, DateWidget), - ): - return field - - if isinstance(field.widget, (forms.widgets.Textarea,)): - attrs["textarea"] = True - field.widget = InputWidget(attrs=attrs) - elif isinstance(field.widget, (forms.widgets.PasswordInput,)): # pragma: needs cover - attrs["password"] = True - field.widget = InputWidget(attrs=attrs) - elif isinstance( - field.widget, - (forms.widgets.TextInput, forms.widgets.EmailInput, forms.widgets.URLInput, forms.widgets.NumberInput), - ): - field.widget = InputWidget(attrs=attrs) - elif isinstance(field.widget, (forms.widgets.Select,)): - if isinstance(field, (forms.models.ModelMultipleChoiceField,)): - field.widget = SelectMultipleWidget(attrs) # pragma: needs cover - else: - field.widget = SelectWidget(attrs) - - field.widget.choices = field.choices - elif isinstance(field.widget, (forms.widgets.CheckboxInput,)): - field.widget = CheckboxWidget(attrs) - - return field - - class ExternalURLHandler(View): """ It's useful to register Courier and Mailroom URLs in RapidPro so they can be used in templates, and if they are hit From 6e931378c1d8035191bdc8728ec79574602979ca Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 16:05:35 +0000 Subject: [PATCH 144/557] ContentMenu > ContextMenu --- temba/campaigns/views.py | 8 ++++---- temba/channels/types/whatsapp/views.py | 6 +++--- temba/channels/views.py | 4 ++-- temba/classifiers/views.py | 4 ++-- temba/contacts/views.py | 16 ++++++++-------- temba/flows/views.py | 8 ++++---- temba/globals/views.py | 4 ++-- temba/locations/views.py | 4 ++-- temba/msgs/views.py | 6 +++--- temba/orgs/views/views.py | 18 +++++++++--------- temba/request_logs/views.py | 6 +++--- temba/tickets/views.py | 8 ++++---- temba/triggers/views.py | 4 ++-- temba/utils/views/mixins.py | 6 +++--- 14 files changed, 51 insertions(+), 51 deletions(-) diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index e79e151379d..696fa361677 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -16,7 +16,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, SpaMixin from .models import Campaign, CampaignEvent @@ -108,7 +108,7 @@ def form_valid(self, form): return self.render_modal_response(form) - class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" menu_path = "/campaign/active" @@ -162,7 +162,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class BaseList(SpaMixin, ContentMenuMixin, BulkActionMixin, BaseListView): + class BaseList(SpaMixin, ContextMenuMixin, BulkActionMixin, BaseListView): fields = ("name", "group") default_template = "campaigns/campaign_list.html" default_order = ("-modified_on",) @@ -475,7 +475,7 @@ class CampaignEventCRUDL(SmartCRUDL): "This is a background flow. When it triggers, it will run it for all contacts without interruption." ) - class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/(?P[0-9a-f-]+)/(?P\d+)/$" % (path, action) diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 91cfc1efc08..431d736f61e 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -14,7 +14,7 @@ from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate -from temba.utils.views.mixins import ContentMenuMixin +from temba.utils.views.mixins import ContextMenuMixin from ...models import Channel from ...views import ClaimViewMixin @@ -220,7 +220,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({}) -class RequestCode(ChannelTypeMixin, ModalMixin, ContentMenuMixin, OrgObjPermsMixin, SmartModelActionView): +class RequestCode(ChannelTypeMixin, ModalMixin, ContextMenuMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): pass @@ -284,7 +284,7 @@ def execute_action(self): ) -class VerifyCode(ChannelTypeMixin, ModalMixin, ContentMenuMixin, OrgObjPermsMixin, SmartModelActionView): +class VerifyCode(ChannelTypeMixin, ModalMixin, ContextMenuMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): code = forms.CharField( min_length=6, required=True, help_text=_("The 6-digits number verification code"), widget=InputWidget() diff --git a/temba/channels/views.py b/temba/channels/views.py index 4fc268dd91c..90627ffbfbc 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -41,7 +41,7 @@ from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin from .models import Channel, ChannelCount, ChannelLog @@ -473,7 +473,7 @@ class ChannelCRUDL(SmartCRUDL): "facebook_whitelist", ) - class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, NotificationTargetMixin, SmartReadView): + class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, NotificationTargetMixin, SmartReadView): slug_url_kwarg = "uuid" exclude = ("id", "is_active", "created_by", "modified_by", "modified_on") diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index f36a2371482..54e30637f1f 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -7,7 +7,7 @@ from temba.orgs.views import DependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin -from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin from .models import Classifier @@ -48,7 +48,7 @@ class Delete(DependencyDeleteModal): success_url = "@orgs.org_workspace" success_message = _("Your classifier has been deleted.") - class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" exclude = ("id", "is_active", "created_by", "modified_by", "modified_on") diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 96808e673a6..53f0ae5aec9 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -35,7 +35,7 @@ from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, NonAtomicMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, NonAtomicMixin, SpaMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport @@ -313,7 +313,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({"results": results, "more": False, "total": len(results), "err": "nil"}) - class Read(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" fields = ("name",) select_related = ("current_flow",) @@ -499,7 +499,7 @@ def get(self, request, *args, **kwargs): } return JsonResponse(summary) - class List(ContentMenuMixin, ContactListView): + class List(ContextMenuMixin, ContactListView): title = _("Active") system_group = ContactGroup.TYPE_DB_ACTIVE menu_path = "/contact/active" @@ -552,7 +552,7 @@ def get_context_data(self, *args, **kwargs): context["contact_fields"] = ContactField.get_fields(org).order_by("-show_in_table", "-priority", "id")[0:6] return context - class Blocked(ContentMenuMixin, ContactListView): + class Blocked(ContextMenuMixin, ContactListView): title = _("Blocked") system_group = ContactGroup.TYPE_DB_BLOCKED @@ -568,7 +568,7 @@ def get_context_data(self, *args, **kwargs): context["reply_disabled"] = True return context - class Stopped(ContentMenuMixin, ContactListView): + class Stopped(ContextMenuMixin, ContactListView): title = _("Stopped") template_name = "contacts/contact_stopped.html" system_group = ContactGroup.TYPE_DB_STOPPED @@ -585,7 +585,7 @@ def get_context_data(self, *args, **kwargs): context["reply_disabled"] = True return context - class Archived(ContentMenuMixin, ContactListView): + class Archived(ContextMenuMixin, ContactListView): title = _("Archived") template_name = "contacts/contact_archived.html" system_group = ContactGroup.TYPE_DB_ARCHIVED @@ -611,7 +611,7 @@ def build_content_menu(self, menu): if self.has_org_perm("contacts.contact_delete"): menu.add_js("contacts_delete_all", _("Delete All")) - class Filter(OrgObjPermsMixin, ContentMenuMixin, ContactListView): + class Filter(OrgObjPermsMixin, ContextMenuMixin, ContactListView): template_name = "contacts/contact_filter.html" def build_content_menu(self, menu): @@ -1093,7 +1093,7 @@ def post(self, request, *args, **kwargs): return HttpResponse(json.dumps(payload), status=400, content_type="application/json") - class List(SpaMixin, ContentMenuMixin, BaseListView): + class List(SpaMixin, ContextMenuMixin, BaseListView): menu_path = "/contact/fields" title = _("Fields") default_order = "name" diff --git a/temba/flows/views.py b/temba/flows/views.py index 38c25240fdb..7944c59465c 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -49,7 +49,7 @@ TembaChoiceField, ) from temba.utils.text import slugify_with -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContextMenuMixin, SpaMixin, StaffOnlyMixin from .models import FlowLabel, FlowStartCount, FlowUserConflictException, FlowVersionConflictException, ResultsExport @@ -666,7 +666,7 @@ def update_triggers(self, flow, user, new_keywords: list): match_type=Trigger.MATCH_FIRST_WORD, ) - class BaseList(SpaMixin, BulkActionMixin, ContentMenuMixin, BaseListView): + class BaseList(SpaMixin, BulkActionMixin, ContextMenuMixin, BaseListView): permission = "flows.flow_list" title = _("Flows") refresh = 10000 @@ -849,7 +849,7 @@ def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) return qs.filter(org=self.request.org, labels=self.label, is_archived=False).order_by("-created_on") - class Editor(SpaMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Editor(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" def derive_menu_path(self): @@ -1365,7 +1365,7 @@ class CategoryCounts(AllowOnlyActiveFlowMixin, OrgObjPermsMixin, SmartReadView): def render_to_response(self, context, **response_kwargs): return JsonResponse({"counts": self.get_object().get_category_counts()}) - class Results(SpaMixin, AllowOnlyActiveFlowMixin, OrgObjPermsMixin, ContentMenuMixin, SmartReadView): + class Results(SpaMixin, AllowOnlyActiveFlowMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" def build_content_menu(self, menu): diff --git a/temba/globals/views.py b/temba/globals/views.py index a04d3b162a4..852fcae3fa9 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -9,7 +9,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, SpaMixin from .models import Global @@ -113,7 +113,7 @@ class Delete(DependencyDeleteModal): cancel_url = "@globals.global_list" success_url = "@globals.global_list" - class List(SpaMixin, ContentMenuMixin, BaseListView): + class List(SpaMixin, ContextMenuMixin, BaseListView): title = _("Globals") fields = ("name", "key", "value") search_fields = ("name__icontains", "key__icontains") diff --git a/temba/locations/views.py b/temba/locations/views.py index dd7ef6efc5e..5ef64e3fd5e 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -10,14 +10,14 @@ from temba.locations.models import AdminBoundary, BoundaryAlias from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import json -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, SpaMixin class BoundaryCRUDL(SmartCRUDL): actions = ("alias", "geometry", "boundaries") model = AdminBoundary - class Alias(SpaMixin, OrgPermsMixin, ContentMenuMixin, SmartReadView): + class Alias(SpaMixin, OrgPermsMixin, ContextMenuMixin, SmartReadView): menu_path = "/settings/workspace" @classmethod diff --git a/temba/msgs/views.py b/temba/msgs/views.py index c8cf7031713..5c704286c55 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -37,7 +37,7 @@ SelectWidget, ) from temba.utils.models import patch_queryset_count -from temba.utils.views.mixins import ContentMenuMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContextMenuMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, StaffOnlyMixin from temba.utils.views.wizard import SmartWizardUpdateView, SmartWizardView from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel @@ -78,7 +78,7 @@ def get_context_data(self, **kwargs): return context -class MsgListView(ContentMenuMixin, BulkActionMixin, SystemLabelView): +class MsgListView(ContextMenuMixin, BulkActionMixin, SystemLabelView): """ Base class for message list views with message folders and labels listed by the side """ @@ -559,7 +559,7 @@ def done(self, form_list, form_dict, **kwargs): return HttpResponseRedirect(self.get_success_url()) - class ScheduledRead(SpaMixin, ContentMenuMixin, OrgObjPermsMixin, SmartReadView): + class ScheduledRead(SpaMixin, ContextMenuMixin, OrgObjPermsMixin, SmartReadView): title = _("Broadcast") menu_path = "/msg/broadcasts" diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 06d47dec797..9ae0ad263cd 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -63,7 +63,7 @@ from temba.utils.timezones import TimeZoneFormField from temba.utils.views.mixins import ( ComponentFormMixin, - ContentMenuMixin, + ContextMenuMixin, NonAtomicMixin, NoNavMixin, PostOnlyMixin, @@ -492,7 +492,7 @@ class UserCRUDL(SmartCRUDL): "send_verification_email", ) - class Read(StaffOnlyMixin, ContentMenuMixin, SpaMixin, SmartReadView): + class Read(StaffOnlyMixin, ContextMenuMixin, SpaMixin, SmartReadView): fields = ("email", "date_joined") menu_path = "/staff/users/all" @@ -541,7 +541,7 @@ def get_context_data(self, **kwargs): context["filters"] = self.filters return context - class Update(StaffOnlyMixin, SpaMixin, ModalMixin, ComponentFormMixin, ContentMenuMixin, SmartUpdateView): + class Update(StaffOnlyMixin, SpaMixin, ModalMixin, ComponentFormMixin, ContextMenuMixin, SmartUpdateView): class Form(UserUpdateForm): groups = forms.ModelMultipleChoiceField( widget=SelectMultipleWidget( @@ -1038,7 +1038,7 @@ def get_context_data(self, **kwargs): def derive_formax_sections(self, formax, context): formax.add_section("profile", reverse("orgs.user_edit"), icon="user") - class Tokens(SpaMixin, InferUserMixin, ContentMenuMixin, OrgPermsMixin, SmartUpdateView): + class Tokens(SpaMixin, InferUserMixin, ContextMenuMixin, OrgPermsMixin, SmartUpdateView): class Form(forms.ModelForm): new = forms.BooleanField(required=False) @@ -1580,7 +1580,7 @@ def extract_from(smtp_url: str) -> str: context["from_email_custom"] = from_email_custom return context - class Read(StaffOnlyMixin, SpaMixin, ContentMenuMixin, SmartReadView): + class Read(StaffOnlyMixin, SpaMixin, ContextMenuMixin, SmartReadView): def build_content_menu(self, menu): obj = self.get_object() if not obj.is_active: @@ -1806,7 +1806,7 @@ def post(self, request, *args, **kwargs): self.object.release(request.user) return self.render_modal_response() - class ManageAccounts(SpaMixin, InferOrgMixin, ContentMenuMixin, OrgPermsMixin, SmartUpdateView): + class ManageAccounts(SpaMixin, InferOrgMixin, ContextMenuMixin, OrgPermsMixin, SmartUpdateView): class AccountsForm(forms.ModelForm): def __init__(self, org, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2015,7 +2015,7 @@ def form_invalid(self, form): switch_to_org(self.request, None) return HttpResponseRedirect(reverse("orgs.org_manage")) - class SubOrgs(SpaMixin, ContentMenuMixin, OrgPermsMixin, InferOrgMixin, SmartListView): + class SubOrgs(SpaMixin, ContextMenuMixin, OrgPermsMixin, InferOrgMixin, SmartListView): title = _("Workspaces") menu_path = "/settings/workspaces" @@ -2483,7 +2483,7 @@ def get_context_data(self, **kwargs): context["prometheus_url"] = f"https://{org.branding['domain']}/mr/org/{org.uuid}/metrics" return context - class Workspace(SpaMixin, FormaxMixin, ContentMenuMixin, InferOrgMixin, OrgPermsMixin, SmartReadView): + class Workspace(SpaMixin, FormaxMixin, ContextMenuMixin, InferOrgMixin, OrgPermsMixin, SmartReadView): title = _("Workspace") menu_path = "/settings/workspace" @@ -2804,7 +2804,7 @@ class ExportCRUDL(SmartCRUDL): model = Export actions = ("download",) - class Download(SpaMixin, ContentMenuMixin, NotificationTargetMixin, OrgObjPermsMixin, SmartReadView): + class Download(SpaMixin, ContextMenuMixin, NotificationTargetMixin, OrgObjPermsMixin, SmartReadView): slug_url_kwarg = "uuid" menu_path = "/settings/workspace" title = _("Export") diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 8efd962f59f..fbbeb6268f5 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -10,7 +10,7 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils import str_to_bool -from temba.utils.views.mixins import ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, SpaMixin from .models import HTTPLog @@ -58,7 +58,7 @@ class HTTPLogCRUDL(SmartCRUDL): model = HTTPLog actions = ("webhooks", "channel", "classifier", "read") - class Webhooks(SpaMixin, ContentMenuMixin, BaseListView): + class Webhooks(SpaMixin, ContextMenuMixin, BaseListView): default_order = ("-created_on",) select_related = ("flow",) fields = ("flow", "url", "status_code", "request_time", "created_on") @@ -81,7 +81,7 @@ def build_content_menu(self, menu): else: menu.add_link(_("Errors"), f'{reverse("request_logs.httplog_webhooks")}?error=1') - class Channel(ContentMenuMixin, BaseObjLogsView): + class Channel(ContextMenuMixin, BaseObjLogsView): source_field = "channel" source_url = "uuid@channels.channel_read" title = _("Template Fetch Logs") diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 924ccb8280f..aa10696c5e6 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -27,7 +27,7 @@ from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin from .forms import ShortcutForm, TopicForm from .models import ( @@ -82,7 +82,7 @@ def post(self, request, *args, **kwargs): redirect_url = self.get_redirect_url() return HttpResponseRedirect(redirect_url) - class List(SpaMixin, ContentMenuMixin, BaseListView): + class List(SpaMixin, ContextMenuMixin, BaseListView): menu_path = "/ticket/shortcuts" def derive_queryset(self, **kwargs): @@ -170,7 +170,7 @@ class Meta: slug_url_kwarg = "uuid" success_url = "hide" - class List(SpaMixin, ContentMenuMixin, OrgPermsMixin, NotificationTargetMixin, SmartListView): + class List(SpaMixin, ContextMenuMixin, OrgPermsMixin, NotificationTargetMixin, SmartListView): """ A placeholder view for the ticket handling frontend components which fetch tickets from the endpoint below """ @@ -341,7 +341,7 @@ def derive_menu(self): return menu - class Folder(ContentMenuMixin, OrgPermsMixin, SmartTemplateView): + class Folder(ContextMenuMixin, OrgPermsMixin, SmartTemplateView): permission = "tickets.ticket_list" paginate_by = 25 diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 84babcf55f4..389dc8377ba 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -19,7 +19,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views.mixins import ComponentFormMixin, ContentMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin from .models import Trigger @@ -464,7 +464,7 @@ def pre_process(self, request, *args, **kwargs): def get_queryset(self, *args, **kwargs): return super().get_queryset(*args, **kwargs).filter(is_archived=False) - class Archived(ContentMenuMixin, BaseList): + class Archived(ContextMenuMixin, BaseList): """ Archived triggers of all types """ diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 87fdf750f10..f64d336b04e 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -114,14 +114,14 @@ def customize_form_field(self, name, field): return field -class ContentMenuMixin: +class ContextMenuMixin: """ - Mixin for views that have a content menu (hamburger icon with dropdown items) + Mixin for views that have a context menu (hamburger icon with dropdown items) """ class Menu: """ - Utility for building content menus + Utility for building the menus """ def __init__(self): From ddfcda9fac19e322792489fd7946e5516d1f6274 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 16:31:36 +0000 Subject: [PATCH 145/557] Move ModalMixin to temba.utils.views.mixins --- temba/api/views.py | 4 +- temba/campaigns/views.py | 3 +- temba/channels/types/facebookapp/views.py | 3 +- temba/channels/types/instagram/views.py | 3 +- temba/channels/types/whatsapp/views.py | 3 +- temba/channels/views.py | 4 +- temba/contacts/views.py | 4 +- temba/flows/views.py | 4 +- temba/globals/views.py | 4 +- temba/msgs/views.py | 11 +++- temba/orgs/views/views.py | 58 +-------------------- temba/tickets/views.py | 4 +- temba/triggers/views.py | 3 +- temba/utils/views/mixins.py | 63 ++++++++++++++++++++++- 14 files changed, 91 insertions(+), 80 deletions(-) diff --git a/temba/api/views.py b/temba/api/views.py index effe594a491..adb454f719a 100644 --- a/temba/api/views.py +++ b/temba/api/views.py @@ -13,9 +13,9 @@ from temba import mailroom from temba.api.support import InvalidQueryError from temba.contacts.models import URN -from temba.orgs.views import ModalMixin, OrgObjPermsMixin +from temba.orgs.views import OrgObjPermsMixin from temba.utils.models import TembaModel -from temba.utils.views.mixins import NonAtomicMixin +from temba.utils.views.mixins import ModalMixin, NonAtomicMixin from .models import APIToken, BulkActionFailure diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 696fa361677..ef0984e1fa9 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -11,12 +11,11 @@ from temba.contacts.models import ContactField, ContactGroup from temba.flows.models import Flow from temba.msgs.models import Msg -from temba.orgs.views import ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField -from temba.utils.views.mixins import ContextMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin from .models import Campaign, CampaignEvent diff --git a/temba/channels/types/facebookapp/views.py b/temba/channels/types/facebookapp/views.py index 5c0097c9f2e..54a3abe9180 100644 --- a/temba/channels/types/facebookapp/views.py +++ b/temba/channels/types/facebookapp/views.py @@ -6,8 +6,9 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import ModalMixin, OrgObjPermsMixin +from temba.orgs.views import OrgObjPermsMixin from temba.utils.text import truncate +from temba.utils.views.mixins import ModalMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin diff --git a/temba/channels/types/instagram/views.py b/temba/channels/types/instagram/views.py index 35f3d31b9a2..e70a00b6f18 100644 --- a/temba/channels/types/instagram/views.py +++ b/temba/channels/types/instagram/views.py @@ -8,8 +8,9 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import ModalMixin, OrgObjPermsMixin +from temba.orgs.views import OrgObjPermsMixin from temba.utils.text import truncate +from temba.utils.views.mixins import ModalMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 431d736f61e..8ba43c0d6fc 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -10,11 +10,10 @@ from django.utils.translation import gettext_lazy as _ from temba.channels.views import ChannelTypeMixin -from temba.orgs.views import ModalMixin from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate -from temba.utils.views.mixins import ContextMenuMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalMixin from ...models import Channel from ...views import ClaimViewMixin diff --git a/temba/channels/views.py b/temba/channels/views.py index 90627ffbfbc..55ec7f9ca59 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -35,13 +35,13 @@ from temba.ivr.models import Call from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import DependencyDeleteModal, ModalMixin +from temba.orgs.views import DependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import countries from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin from .models import Channel, ChannelCount, ChannelLog diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 53f0ae5aec9..4e09306a020 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -26,7 +26,7 @@ from temba.mailroom.events import Event from temba.notifications.views import NotificationTargetMixin from temba.orgs.models import User -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic @@ -35,7 +35,7 @@ from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, NonAtomicMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, NonAtomicMixin, SpaMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport diff --git a/temba/flows/views.py b/temba/flows/views.py index 7944c59465c..520688b516e 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -35,7 +35,7 @@ from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.triggers.models import Trigger @@ -49,7 +49,7 @@ TembaChoiceField, ) from temba.utils.text import slugify_with -from temba.utils.views.mixins import ContextMenuMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin, StaffOnlyMixin from .models import FlowLabel, FlowStartCount, FlowUserConflictException, FlowVersionConflictException, ResultsExport diff --git a/temba/globals/views.py b/temba/globals/views.py index 852fcae3fa9..d5165a774c4 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -5,11 +5,11 @@ from django import forms from django.urls import reverse -from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views.mixins import ContextMenuMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin from .models import Global diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 5c704286c55..5596a8db504 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -21,7 +21,7 @@ from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions from temba.orgs.models import Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal, ModalMixin +from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.views import ScheduleFormMixin @@ -37,7 +37,14 @@ SelectWidget, ) from temba.utils.models import patch_queryset_count -from temba.utils.views.mixins import ContextMenuMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views.mixins import ( + ContextMenuMixin, + ModalMixin, + NonAtomicMixin, + PostOnlyMixin, + SpaMixin, + StaffOnlyMixin, +) from temba.utils.views.wizard import SmartWizardUpdateView, SmartWizardView from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 9ae0ad263cd..152ca6b3058 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -14,8 +14,6 @@ SmartDeleteView, SmartFormView, SmartListView, - SmartModelActionView, - SmartModelFormView, SmartReadView, SmartTemplateView, SmartUpdateView, @@ -29,7 +27,6 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView as AuthLoginView from django.core.exceptions import ValidationError -from django.db import IntegrityError from django.db.models.functions import Lower from django.forms import ModelChoiceField from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse @@ -64,6 +61,7 @@ from temba.utils.views.mixins import ( ComponentFormMixin, ContextMenuMixin, + ModalMixin, NonAtomicMixin, NoNavMixin, PostOnlyMixin, @@ -113,60 +111,6 @@ def check_login(request): return HttpResponseRedirect(settings.LOGIN_URL) -class ModalMixin(SmartFormView): - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - if "HTTP_X_PJAX" in self.request.META and "HTTP_X_FORMAX" not in self.request.META: # pragma: no cover - context["base_template"] = "smartmin/modal.html" - context["is_modal"] = True - if "success_url" in kwargs: # pragma: no cover - context["success_url"] = kwargs["success_url"] - - pairs = [quote(k) + "=" + quote(v) for k, v in self.request.GET.items() if k != "_"] - context["action_url"] = self.request.path + "?" + ("&".join(pairs)) - - return context - - def render_modal_response(self, form=None): - success_url = self.get_success_url() - response = self.render_to_response( - self.get_context_data( - form=form, - success_url=self.get_success_url(), - success_script=getattr(self, "success_script", None), - ) - ) - - response["Temba-Success"] = success_url - return response - - def form_valid(self, form): - if isinstance(form, forms.ModelForm): - self.object = form.save(commit=False) - - try: - if isinstance(self, SmartModelFormView): - self.object = self.pre_save(self.object) - self.save(self.object) - self.object = self.post_save(self.object) - - elif isinstance(self, SmartModelActionView): - self.execute_action() - - messages.success(self.request, self.derive_success_message()) - - if "HTTP_X_PJAX" not in self.request.META: - return HttpResponseRedirect(self.get_success_url()) - else: # pragma: no cover - return self.render_modal_response(form) - - except (IntegrityError, ValueError, ValidationError) as e: - message = getattr(e, "message", str(e).capitalize()) - self.form.add_error(None, message) - return self.render_to_response(self.get_context_data(form=form)) - - class IntegrationViewMixin(OrgPermsMixin): permission = "orgs.org_manage_integrations" integration_type = None diff --git a/temba/tickets/views.py b/temba/tickets/views.py index aa10696c5e6..bbea8038d57 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -20,14 +20,14 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import BaseExportView, ModalMixin +from temba.orgs.views import BaseExportView from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin from .forms import ShortcutForm, TopicForm from .models import ( diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 389dc8377ba..5b7986b32f3 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -14,12 +14,11 @@ from temba.contacts.omnibox import omnibox_serialize from temba.flows.models import Flow from temba.formax import FormaxMixin -from temba.msgs.views import ModalMixin from temba.orgs.views.base import BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin from .models import Trigger diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index f64d336b04e..23e47dcdd54 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -2,10 +2,13 @@ from urllib.parse import quote, urlencode import requests +from smartmin.views import SmartModelActionView, SmartModelFormView, SmartFormView from django import forms from django.conf import settings -from django.db import transaction +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse from django.utils import timezone @@ -214,6 +217,64 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) +class ModalMixin(SmartFormView): + """ + TODO rework this to be an actual mixin + """ + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if "HTTP_X_PJAX" in self.request.META and "HTTP_X_FORMAX" not in self.request.META: # pragma: no cover + context["base_template"] = "smartmin/modal.html" + context["is_modal"] = True + if "success_url" in kwargs: # pragma: no cover + context["success_url"] = kwargs["success_url"] + + pairs = [quote(k) + "=" + quote(v) for k, v in self.request.GET.items() if k != "_"] + context["action_url"] = self.request.path + "?" + ("&".join(pairs)) + + return context + + def render_modal_response(self, form=None): + success_url = self.get_success_url() + response = self.render_to_response( + self.get_context_data( + form=form, + success_url=self.get_success_url(), + success_script=getattr(self, "success_script", None), + ) + ) + + response["Temba-Success"] = success_url + return response + + def form_valid(self, form): + if isinstance(form, forms.ModelForm): + self.object = form.save(commit=False) + + try: + if isinstance(self, SmartModelFormView): + self.object = self.pre_save(self.object) + self.save(self.object) + self.object = self.post_save(self.object) + + elif isinstance(self, SmartModelActionView): + self.execute_action() + + messages.success(self.request, self.derive_success_message()) + + if "HTTP_X_PJAX" not in self.request.META: + return HttpResponseRedirect(self.get_success_url()) + else: # pragma: no cover + return self.render_modal_response(form) + + except (IntegrityError, ValueError, ValidationError) as e: + message = getattr(e, "message", str(e).capitalize()) + self.form.add_error(None, message) + return self.render_to_response(self.get_context_data(form=form)) + + class SpaMixin: """ Uses SPA base template if the header is set appropriately From 4da9505c79066c8e9a79bcba3feefab9b9fac323 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 16:43:41 +0000 Subject: [PATCH 146/557] Move BaseExportView to temba.orgs.views.base.BaseExportModal --- temba/contacts/views.py | 6 +- temba/flows/views.py | 8 +-- temba/msgs/views.py | 8 +-- temba/orgs/views/base.py | 125 +++++++++++++++++++++++++++++++++++- temba/orgs/views/views.py | 113 -------------------------------- temba/tickets/views.py | 5 +- temba/utils/views/mixins.py | 2 +- 7 files changed, 138 insertions(+), 129 deletions(-) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 4e09306a020..dd0c53b9166 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -26,8 +26,8 @@ from temba.mailroom.events import Event from temba.notifications.views import NotificationTargetMixin from temba.orgs.models import User -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal -from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal +from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit @@ -272,7 +272,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({"results": menu}) - class Export(BaseExportView): + class Export(BaseExportModal): export_type = ContactExport success_url = "@contacts.contact_list" size_limit = 1_000_000 diff --git a/temba/flows/views.py b/temba/flows/views.py index 520688b516e..c14456b032c 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -35,8 +35,8 @@ from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal -from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views import DependencyDeleteModal +from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit @@ -1153,8 +1153,8 @@ def get_context_data(self, *args, **kwargs): def derive_initial(self): return {"language": self.po_info.language_code if self.po_info else ""} - class ExportResults(BaseExportView): - class Form(BaseExportView.Form): + class ExportResults(BaseExportModal): + class Form(BaseExportModal.Form): flows = forms.ModelMultipleChoiceField( Flow.objects.none(), required=True, widget=forms.MultipleHiddenInput() ) diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 5596a8db504..ce1ad98af66 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -21,8 +21,8 @@ from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions from temba.orgs.models import Org -from temba.orgs.views import BaseExportView, DependencyDeleteModal, DependencyUsagesModal -from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal +from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation @@ -868,8 +868,8 @@ def derive_menu(self): return menu - class Export(BaseExportView): - class Form(BaseExportView.Form): + class Export(BaseExportModal): + class Form(BaseExportModal.Form): LABEL_CHOICES = ((0, _("Just this label")), (1, _("All messages"))) SYSTEM_LABEL_CHOICES = ((0, _("Just this folder")), (1, _("All messages"))) diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index 45f415048dc..44e02160431 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -1,8 +1,20 @@ -from smartmin.views import SmartListView, SmartTemplateView +from datetime import timedelta +from smartmin.views import SmartFormView, SmartListView, SmartTemplateView + +from django import forms +from django.contrib import messages +from django.db.models.functions import Lower from django.http import JsonResponse from django.urls import reverse +from django.utils import timezone from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + +from temba.contacts.models import ContactField, ContactGroup +from temba.utils import on_transaction_commit +from temba.utils.fields import SelectMultipleWidget, TembaDateField +from temba.utils.views.mixins import ModalMixin from .mixins import OrgPermsMixin @@ -123,3 +135,114 @@ def get_menu(self): def render_to_response(self, context, **response_kwargs): return JsonResponse({"results": self.get_menu()}) + + +class BaseExportModal(ModalMixin, OrgPermsMixin, SmartFormView): + """ + Base modal view for exports + """ + + class Form(forms.Form): + MAX_FIELDS_COLS = 10 + MAX_GROUPS_COLS = 10 + + start_date = TembaDateField(label=_("Start Date")) + end_date = TembaDateField(label=_("End Date")) + + with_fields = forms.ModelMultipleChoiceField( + ContactField.objects.none(), + required=False, + label=_("Fields"), + widget=SelectMultipleWidget(attrs={"placeholder": _("Optional: Fields to include"), "searchable": True}), + ) + with_groups = forms.ModelMultipleChoiceField( + ContactGroup.objects.none(), + required=False, + label=_("Groups"), + widget=SelectMultipleWidget( + attrs={"placeholder": _("Optional: Group memberships to include"), "searchable": True} + ), + ) + + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.org = org + self.fields["with_fields"].queryset = ContactField.get_fields(org).order_by(Lower("name")) + self.fields["with_groups"].queryset = ContactGroup.get_groups(org=org, ready_only=True).order_by( + Lower("name") + ) + + def clean_with_fields(self): + data = self.cleaned_data["with_fields"] + if data and len(data) > self.MAX_FIELDS_COLS: + raise forms.ValidationError(_(f"You can only include up to {self.MAX_FIELDS_COLS} fields.")) + + return data + + def clean_with_groups(self): + data = self.cleaned_data["with_groups"] + if data and len(data) > self.MAX_GROUPS_COLS: + raise forms.ValidationError(_(f"You can only include up to {self.MAX_GROUPS_COLS} groups.")) + + return data + + def clean(self): + cleaned_data = super().clean() + + start_date = cleaned_data.get("start_date") + end_date = cleaned_data.get("end_date") + + if start_date and start_date > timezone.now().astimezone(self.org.timezone).date(): + raise forms.ValidationError(_("Start date can't be in the future.")) + + if end_date and start_date and end_date < start_date: + raise forms.ValidationError(_("End date can't be before start date.")) + + return cleaned_data + + form_class = Form + submit_button_name = _("Export") + success_message = _("We are preparing your export and you will get a notification when it is complete.") + export_type = None + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs + + def derive_initial(self): + initial = super().derive_initial() + + # default to last 90 days in org timezone + end = timezone.now() + start = end - timedelta(days=90) + + initial["end_date"] = end.date() + initial["start_date"] = start.date() + return initial + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["blocker"] = self.get_blocker() + return context + + def get_blocker(self) -> str: + if self.export_type.has_recent_unfinished(self.request.org): + return "existing-export" + + return "" + + def form_valid(self, form): + if self.get_blocker(): + return self.form_invalid(form) + + user = self.request.user + org = self.request.org + export = self.create_export(org, user, form) + + on_transaction_commit(lambda: export.start()) + + messages.info(self.request, self.success_message) + + return self.render_modal_response(form) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 152ca6b3058..30f46e483dc 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -40,7 +40,6 @@ from temba.api.models import APIToken, Resthook from temba.campaigns.models import Campaign -from temba.contacts.models import ContactField, ContactGroup from temba.flows.models import Flow from temba.formax import FormaxMixin from temba.notifications.mixins import NotificationTargetMixin @@ -54,7 +53,6 @@ InputWidget, SelectMultipleWidget, SelectWidget, - TembaDateField, ) from temba.utils.text import generate_secret from temba.utils.timezones import TimeZoneFormField @@ -2775,114 +2773,3 @@ def get_context_data(self, **kwargs): def get_notification_scope(self) -> tuple[str, str]: return "export:finished", self.get_object().get_notification_scope() - - -class BaseExportView(ModalMixin, OrgPermsMixin, SmartFormView): - """ - Base modal view for exports - """ - - class Form(forms.Form): - MAX_FIELDS_COLS = 10 - MAX_GROUPS_COLS = 10 - - start_date = TembaDateField(label=_("Start Date")) - end_date = TembaDateField(label=_("End Date")) - - with_fields = forms.ModelMultipleChoiceField( - ContactField.objects.none(), - required=False, - label=_("Fields"), - widget=SelectMultipleWidget(attrs={"placeholder": _("Optional: Fields to include"), "searchable": True}), - ) - with_groups = forms.ModelMultipleChoiceField( - ContactGroup.objects.none(), - required=False, - label=_("Groups"), - widget=SelectMultipleWidget( - attrs={"placeholder": _("Optional: Group memberships to include"), "searchable": True} - ), - ) - - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.org = org - self.fields["with_fields"].queryset = ContactField.get_fields(org).order_by(Lower("name")) - self.fields["with_groups"].queryset = ContactGroup.get_groups(org=org, ready_only=True).order_by( - Lower("name") - ) - - def clean_with_fields(self): - data = self.cleaned_data["with_fields"] - if data and len(data) > self.MAX_FIELDS_COLS: - raise forms.ValidationError(_(f"You can only include up to {self.MAX_FIELDS_COLS} fields.")) - - return data - - def clean_with_groups(self): - data = self.cleaned_data["with_groups"] - if data and len(data) > self.MAX_GROUPS_COLS: - raise forms.ValidationError(_(f"You can only include up to {self.MAX_GROUPS_COLS} groups.")) - - return data - - def clean(self): - cleaned_data = super().clean() - - start_date = cleaned_data.get("start_date") - end_date = cleaned_data.get("end_date") - - if start_date and start_date > timezone.now().astimezone(self.org.timezone).date(): - raise forms.ValidationError(_("Start date can't be in the future.")) - - if end_date and start_date and end_date < start_date: - raise forms.ValidationError(_("End date can't be before start date.")) - - return cleaned_data - - form_class = Form - submit_button_name = _("Export") - success_message = _("We are preparing your export and you will get a notification when it is complete.") - export_type = None - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.request.org - return kwargs - - def derive_initial(self): - initial = super().derive_initial() - - # default to last 90 days in org timezone - end = timezone.now() - start = end - timedelta(days=90) - - initial["end_date"] = end.date() - initial["start_date"] = start.date() - return initial - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["blocker"] = self.get_blocker() - return context - - def get_blocker(self) -> str: - if self.export_type.has_recent_unfinished(self.request.org): - return "existing-export" - - return "" - - def form_valid(self, form): - if self.get_blocker(): - return self.form_invalid(form) - - user = self.request.user - org = self.request.org - export = self.create_export(org, user, form) - - on_transaction_commit(lambda: export.start()) - - messages.info(self.request, self.success_message) - - return self.render_modal_response(form) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index bbea8038d57..b5cb022ae2f 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -20,8 +20,7 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import BaseExportView -from temba.orgs.views.base import BaseListView, BaseMenuView +from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook @@ -526,7 +525,7 @@ def render_to_response(self, context, **response_kwargs): return response_from_workbook(workbook, f"ticket-stats-{timezone.now().strftime('%Y-%m-%d')}.xlsx") - class Export(BaseExportView): + class Export(BaseExportModal): export_type = TicketExport success_url = "@tickets.ticket_list" diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 23e47dcdd54..64ee6752172 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -2,7 +2,7 @@ from urllib.parse import quote, urlencode import requests -from smartmin.views import SmartModelActionView, SmartModelFormView, SmartFormView +from smartmin.views import SmartFormView, SmartModelActionView, SmartModelFormView from django import forms from django.conf import settings From 6d46176f833d49c27bbc35f96c0d323a76db42d8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 17:04:42 +0000 Subject: [PATCH 147/557] Re-organize flow dependency modals --- temba/api/views.py | 2 +- temba/channels/views.py | 4 +- temba/classifiers/views.py | 4 +- temba/contacts/views.py | 17 ++++++--- temba/flows/views.py | 5 +-- temba/globals/views.py | 7 ++-- temba/msgs/views.py | 13 +++++-- temba/orgs/views/base.py | 65 +++++++++++++++++++++++++++++-- temba/orgs/views/mixins.py | 19 ++++++++++ temba/orgs/views/views.py | 78 -------------------------------------- temba/templates/views.py | 5 +-- 11 files changed, 113 insertions(+), 106 deletions(-) diff --git a/temba/api/views.py b/temba/api/views.py index adb454f719a..2929c4f0335 100644 --- a/temba/api/views.py +++ b/temba/api/views.py @@ -13,7 +13,7 @@ from temba import mailroom from temba.api.support import InvalidQueryError from temba.contacts.models import URN -from temba.orgs.views import OrgObjPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.models import TembaModel from temba.utils.views.mixins import ModalMixin, NonAtomicMixin diff --git a/temba/channels/views.py b/temba/channels/views.py index 55ec7f9ca59..f0c8190498f 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -35,7 +35,7 @@ from temba.ivr.models import Call from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views import DependencyDeleteModal +from temba.orgs.views.base import BaseDependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils import countries from temba.utils.fields import SelectWidget @@ -699,7 +699,7 @@ def execute_action(self): default_error = dict(message=_("An error occured contacting the Facebook API")) raise ValidationError(response_json.get("error", default_error)["message"]) - class Delete(DependencyDeleteModal, SpaMixin): + class Delete(BaseDependencyDeleteModal): cancel_url = "uuid@channels.channel_read" success_url = "@orgs.org_workspace" success_message = _("Your channel has been removed.") diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 54e30637f1f..379442eaaa7 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import DependencyDeleteModal +from temba.orgs.views.base import BaseDependencyDeleteModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, SpaMixin @@ -43,7 +43,7 @@ class ClassifierCRUDL(SmartCRUDL): model = Classifier actions = ("read", "connect", "delete", "sync") - class Delete(DependencyDeleteModal): + class Delete(BaseDependencyDeleteModal): cancel_url = "uuid@classifiers.classifier_read" success_url = "@orgs.org_workspace" success_message = _("Your classifier has been deleted.") diff --git a/temba/contacts/views.py b/temba/contacts/views.py index dd0c53b9166..e0e26a64f76 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -26,8 +26,13 @@ from temba.mailroom.events import Event from temba.notifications.views import NotificationTargetMixin from temba.orgs.models import User -from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal -from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView +from temba.orgs.views.base import ( + BaseDependencyDeleteModal, + BaseExportModal, + BaseListView, + BaseMenuView, + BaseUsagesModal, +) from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import json, on_transaction_commit @@ -913,10 +918,10 @@ def post_save(self, obj): obj.update_query(obj.query) return obj - class Usages(DependencyUsagesModal): + class Usages(BaseUsagesModal): permission = "contacts.contactgroup_read" - class Delete(DependencyDeleteModal): + class Delete(BaseDependencyDeleteModal): cancel_url = "uuid@contacts.contact_filter" success_url = "@contacts.contact_list" @@ -1072,7 +1077,7 @@ def form_valid(self, form): super().form_valid(form) return self.render_modal_response(form) - class Delete(FieldLookupMixin, DependencyDeleteModal): + class Delete(FieldLookupMixin, BaseDependencyDeleteModal): cancel_url = "@contacts.contactfield_list" success_url = "hide" @@ -1112,7 +1117,7 @@ def build_content_menu(self, menu): def derive_queryset(self, **kwargs): return super().derive_queryset(**kwargs).filter(is_active=True, is_system=False) - class Usages(FieldLookupMixin, DependencyUsagesModal): + class Usages(FieldLookupMixin, BaseUsagesModal): permission = "contacts.contactfield_read" queryset = ContactField.user_fields diff --git a/temba/flows/views.py b/temba/flows/views.py index c14456b032c..7e5c7ca58d0 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -35,8 +35,7 @@ from temba.flows.tasks import update_session_wait_expires from temba.ivr.models import Call from temba.orgs.models import IntegrationType, Org -from temba.orgs.views import DependencyDeleteModal -from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView +from temba.orgs.views.base import BaseDependencyDeleteModal, BaseExportModal, BaseListView, BaseMenuView from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.triggers.models import Trigger from temba.utils import analytics, gettext, json, languages, on_transaction_commit @@ -482,7 +481,7 @@ def post_save(self, obj): return obj - class Delete(DependencyDeleteModal): + class Delete(BaseDependencyDeleteModal): cancel_url = "uuid@flows.flow_editor" success_url = "@flows.flow_list" diff --git a/temba/globals/views.py b/temba/globals/views.py index d5165a774c4..cddef79c059 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -5,8 +5,7 @@ from django import forms from django.urls import reverse -from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal -from temba.orgs.views.base import BaseListView +from temba.orgs.views.base import BaseDependencyDeleteModal, BaseListView, BaseUsagesModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin @@ -109,7 +108,7 @@ def get_form_kwargs(self): kwargs["org"] = self.derive_org() return kwargs - class Delete(DependencyDeleteModal): + class Delete(BaseDependencyDeleteModal): cancel_url = "@globals.global_list" success_url = "@globals.global_list" @@ -154,5 +153,5 @@ class Unused(List): def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).filter(usage_count=0) - class Usages(DependencyUsagesModal): + class Usages(BaseUsagesModal): permission = "globals.global_read" diff --git a/temba/msgs/views.py b/temba/msgs/views.py index ce1ad98af66..e3034ed8ed5 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -21,8 +21,13 @@ from temba.archives.models import Archive from temba.mailroom.client.types import Exclusions from temba.orgs.models import Org -from temba.orgs.views import DependencyDeleteModal, DependencyUsagesModal -from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView +from temba.orgs.views.base import ( + BaseDependencyDeleteModal, + BaseExportModal, + BaseListView, + BaseMenuView, + BaseUsagesModal, +) from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.views import ScheduleFormMixin from temba.templates.models import Template, TemplateTranslation @@ -1132,10 +1137,10 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class Usages(DependencyUsagesModal): + class Usages(BaseUsagesModal): permission = "msgs.label_read" - class Delete(DependencyDeleteModal): + class Delete(BaseDependencyDeleteModal): cancel_url = "@msgs.msg_inbox" success_url = "@msgs.msg_inbox" success_message = _("Your label has been deleted.") diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index 44e02160431..c48c99121ab 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -1,11 +1,11 @@ from datetime import timedelta -from smartmin.views import SmartFormView, SmartListView, SmartTemplateView +from smartmin.views import SmartDeleteView, SmartFormView, SmartListView, SmartReadView, SmartTemplateView from django import forms from django.contrib import messages from django.db.models.functions import Lower -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils import timezone from django.utils.text import slugify @@ -16,7 +16,7 @@ from temba.utils.fields import SelectMultipleWidget, TembaDateField from temba.utils.views.mixins import ModalMixin -from .mixins import OrgPermsMixin +from .mixins import DependencyMixin, OrgObjPermsMixin, OrgPermsMixin class BaseListView(OrgPermsMixin, SmartListView): @@ -246,3 +246,62 @@ def form_valid(self, form): messages.info(self.request, self.success_message) return self.render_modal_response(form) + + +class BaseUsagesModal(DependencyMixin, OrgObjPermsMixin, SmartReadView): + """ + Base view for usage modals of flow dependencies + """ + + slug_url_kwarg = "uuid" + template_name = "orgs/dependency_usages_modal.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["dependents"] = self.get_dependents(self.object) + return context + + +class BaseDependencyDeleteModal(DependencyMixin, ModalMixin, OrgObjPermsMixin, SmartDeleteView): + """ + Base view for delete modals of flow dependencies + """ + + slug_url_kwarg = "uuid" + fields = ("uuid",) + submit_button_name = _("Delete") + template_name = "orgs/dependency_delete_modal.html" + + # warnings for soft dependencies + type_warnings = { + "flow": _("these may not work as expected"), # always soft + "campaign_event": _("these will be removed"), # soft for fields and flows + "trigger": _("these will be removed"), # soft for flows + } + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # get dependents and sort by soft vs hard + all_dependents = self.get_dependents(self.object) + soft_dependents = {} + hard_dependents = {} + for type_key, type_qs in all_dependents.items(): + if type_key in self.object.soft_dependent_types: + soft_dependents[type_key] = type_qs + else: + hard_dependents[type_key] = type_qs + + context["soft_dependents"] = soft_dependents + context["hard_dependents"] = hard_dependents + context["type_warnings"] = self.type_warnings + return context + + def post(self, request, *args, **kwargs): + obj = self.get_object() + obj.release(request.user) + + messages.info(request, self.derive_success_message()) + response = HttpResponse() + response["Temba-Success"] = self.get_success_url() + return response diff --git a/temba/orgs/views/mixins.py b/temba/orgs/views/mixins.py index 86f03036101..bb19ec468df 100644 --- a/temba/orgs/views/mixins.py +++ b/temba/orgs/views/mixins.py @@ -212,3 +212,22 @@ def apply_bulk_action(self, user, action, objects, label): args = [label] if label else [] model_func(user, objects, *args) + + +class DependencyMixin: + dependent_order = {"campaign_event": ("relative_to__name",), "trigger": ("trigger_type", "created_on")} + dependent_select_related = {"campaign_event": ("campaign", "relative_to")} + + def get_dependents(self, obj) -> dict: + dependents = {} + for type_key, type_qs in obj.get_dependents().items(): + # only include dependency types which we have at least one dependent of + if type_qs.exists(): + type_qs = type_qs.order_by(*self.dependent_order.get(type_key, ("name",))) + + type_select_related = self.dependent_select_related.get(type_key, ()) + if type_select_related: + type_qs = type_qs.select_related(*type_select_related) + + dependents[type_key] = type_qs + return dependents diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 30f46e483dc..8305820471b 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -145,84 +145,6 @@ def form_valid(self, form): return response -class DependencyModalMixin(OrgObjPermsMixin): - dependent_order = {"campaign_event": ("relative_to__name",), "trigger": ("trigger_type", "created_on")} - dependent_select_related = {"campaign_event": ("campaign", "relative_to")} - - def get_dependents(self, obj) -> dict: - dependents = {} - for type_key, type_qs in obj.get_dependents().items(): - # only include dependency types which we have at least one dependent of - if type_qs.exists(): - type_qs = type_qs.order_by(*self.dependent_order.get(type_key, ("name",))) - - type_select_related = self.dependent_select_related.get(type_key, ()) - if type_select_related: - type_qs = type_qs.select_related(*type_select_related) - - dependents[type_key] = type_qs - return dependents - - -class DependencyUsagesModal(DependencyModalMixin, SmartReadView): - """ - Base view for usage modals of flow dependencies - """ - - slug_url_kwarg = "uuid" - template_name = "orgs/dependency_usages_modal.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["dependents"] = self.get_dependents(self.object) - return context - - -class DependencyDeleteModal(DependencyModalMixin, ModalMixin, SmartDeleteView): - """ - Base view for delete modals of flow dependencies - """ - - slug_url_kwarg = "uuid" - fields = ("uuid",) - submit_button_name = _("Delete") - template_name = "orgs/dependency_delete_modal.html" - - # warnings for soft dependencies - type_warnings = { - "flow": _("these may not work as expected"), # always soft - "campaign_event": _("these will be removed"), # soft for fields and flows - "trigger": _("these will be removed"), # soft for flows - } - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # get dependents and sort by soft vs hard - all_dependents = self.get_dependents(self.object) - soft_dependents = {} - hard_dependents = {} - for type_key, type_qs in all_dependents.items(): - if type_key in self.object.soft_dependent_types: - soft_dependents[type_key] = type_qs - else: - hard_dependents[type_key] = type_qs - - context["soft_dependents"] = soft_dependents - context["hard_dependents"] = hard_dependents - context["type_warnings"] = self.type_warnings - return context - - def post(self, request, *args, **kwargs): - obj = self.get_object() - obj.release(request.user) - - messages.info(request, self.derive_success_message()) - response = HttpResponse() - response["Temba-Success"] = self.get_success_url() - return response - - class LoginView(Login): """ Overrides the smartmin login view to redirect users with 2FA enabled to a second verification view. diff --git a/temba/templates/views.py b/temba/templates/views.py index 68a6f5d5a7f..4437d3283b8 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -1,7 +1,6 @@ from smartmin.views import SmartCRUDL, SmartReadView -from temba.orgs.views import DependencyUsagesModal -from temba.orgs.views.base import BaseListView +from temba.orgs.views.base import BaseListView, BaseUsagesModal from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.views.mixins import SpaMixin @@ -52,5 +51,5 @@ def get_context_data(self, **kwargs): context["status_icons"] = self.status_icons return context - class Usages(DependencyUsagesModal): + class Usages(BaseUsagesModal): permission = "templates.template_read" From fd5b5b7df31c1255c26f9e872b71ecdc5d63dec7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 13:14:08 -0500 Subject: [PATCH 148/557] Update CHANGELOG.md for v9.3.57 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98f861fb78..78c0ea6f96b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.57 (2024-10-04) +------------------------- + * More view refactoring + v9.3.56 (2024-10-03) ------------------------- * Cleanup some view mixins diff --git a/pyproject.toml b/pyproject.toml index c5b8fdf36df..7676a57fbb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.56" +version = "9.3.57" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index f3a535fbe12..f1ee191b625 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.56" +__version__ = "9.3.57" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From f4ee7024551cd641c77aae62788b5d4cdc823f6a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 18:33:49 +0000 Subject: [PATCH 149/557] Tweak imports --- temba/channels/types/facebookapp/views.py | 2 +- temba/channels/types/instagram/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/channels/types/facebookapp/views.py b/temba/channels/types/facebookapp/views.py index 54a3abe9180..72429e2dff5 100644 --- a/temba/channels/types/facebookapp/views.py +++ b/temba/channels/types/facebookapp/views.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import OrgObjPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate from temba.utils.views.mixins import ModalMixin diff --git a/temba/channels/types/instagram/views.py b/temba/channels/types/instagram/views.py index e70a00b6f18..8ab0ad617a3 100644 --- a/temba/channels/types/instagram/views.py +++ b/temba/channels/types/instagram/views.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views import OrgObjPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate from temba.utils.views.mixins import ModalMixin From ce39a7f637ded02c54ac532e7f4c9e9d42a50feb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 4 Oct 2024 19:12:54 +0000 Subject: [PATCH 150/557] Rename ModalMixin to ModalFormMixin and make it an actual mixin --- temba/api/views.py | 4 ++-- temba/campaigns/views.py | 12 ++++++------ temba/channels/types/facebookapp/views.py | 4 ++-- temba/channels/types/instagram/views.py | 4 ++-- temba/channels/types/whatsapp/views.py | 15 ++------------- temba/channels/views.py | 6 +++--- temba/contacts/views.py | 18 +++++++++--------- temba/flows/views.py | 21 +++++++++++---------- temba/globals/views.py | 6 +++--- temba/msgs/views.py | 12 ++++++------ temba/orgs/views/base.py | 6 +++--- temba/orgs/views/views.py | 16 ++++++++-------- temba/tickets/views.py | 18 +++++++++--------- temba/triggers/views.py | 4 ++-- temba/utils/views/mixins.py | 2 +- 15 files changed, 69 insertions(+), 79 deletions(-) diff --git a/temba/api/views.py b/temba/api/views.py index 2929c4f0335..b7fc5d19097 100644 --- a/temba/api/views.py +++ b/temba/api/views.py @@ -15,7 +15,7 @@ from temba.contacts.models import URN from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.models import TembaModel -from temba.utils.views.mixins import ModalMixin, NonAtomicMixin +from temba.utils.views.mixins import ModalFormMixin, NonAtomicMixin from .models import APIToken, BulkActionFailure @@ -280,7 +280,7 @@ class APITokenCRUDL(SmartCRUDL): model = APIToken actions = ("delete",) - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): slug_url_kwarg = "key" fields = ("key",) cancel_url = "@orgs.user_tokens" diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index ef0984e1fa9..0e5929db9a5 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -15,7 +15,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.utils import languages from temba.utils.fields import CompletionTextarea, InputWidget, SelectWidget, TembaChoiceField -from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalFormMixin, SpaMixin from .models import Campaign, CampaignEvent @@ -72,7 +72,7 @@ def derive_menu(self): return menu - class Update(OrgObjPermsMixin, ModalMixin, SmartUpdateView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): fields = ("name", "group") form_class = CampaignForm @@ -146,7 +146,7 @@ def build_content_menu(self, menu): if self.has_org_perm("campaigns.campaign_archive"): menu.add_url_post(_("Archive"), reverse("campaigns.campaign_archive", args=[obj.id])) - class Create(OrgPermsMixin, ModalMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): fields = ("name", "group") form_class = CampaignForm success_url = "uuid@campaigns.campaign_read" @@ -531,7 +531,7 @@ def build_content_menu(self, menu): title=_("Delete Event"), ) - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") fields = ("uuid",) @@ -552,7 +552,7 @@ def get_redirect_url(self): def get_cancel_url(self): # pragma: needs cover return reverse("campaigns.campaign_read", args=[self.object.campaign.uuid]) - class Update(OrgObjPermsMixin, ModalMixin, SmartUpdateView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = CampaignEventForm default_fields = [ "event_type", @@ -651,7 +651,7 @@ def pre_save(self, obj): def get_success_url(self): return reverse("campaigns.campaignevent_read", args=[self.object.campaign.uuid, self.object.pk]) - class Create(OrgPermsMixin, ModalMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): default_fields = [ "event_type", "flow_to_start", diff --git a/temba/channels/types/facebookapp/views.py b/temba/channels/types/facebookapp/views.py index 72429e2dff5..2bfbe6b921e 100644 --- a/temba/channels/types/facebookapp/views.py +++ b/temba/channels/types/facebookapp/views.py @@ -8,7 +8,7 @@ from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate -from temba.utils.views.mixins import ModalMixin +from temba.utils.views.mixins import ModalFormMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin @@ -137,7 +137,7 @@ def form_valid(self, form): return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, ModalMixin, OrgObjPermsMixin, SmartModelActionView): +class RefreshToken(ChannelTypeMixin, ModalFormMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) fb_user_id = forms.CharField( diff --git a/temba/channels/types/instagram/views.py b/temba/channels/types/instagram/views.py index 8ab0ad617a3..61820e47892 100644 --- a/temba/channels/types/instagram/views.py +++ b/temba/channels/types/instagram/views.py @@ -10,7 +10,7 @@ from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate -from temba.utils.views.mixins import ModalMixin +from temba.utils.views.mixins import ModalFormMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin @@ -168,7 +168,7 @@ def form_valid(self, form): return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, ModalMixin, OrgObjPermsMixin, SmartModelActionView): +class RefreshToken(ChannelTypeMixin, ModalFormMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) fb_user_id = forms.CharField( diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 8ba43c0d6fc..15a7c6dee32 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -13,7 +13,6 @@ from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget from temba.utils.text import truncate -from temba.utils.views.mixins import ContextMenuMixin, ModalMixin from ...models import Channel from ...views import ClaimViewMixin @@ -219,7 +218,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({}) -class RequestCode(ChannelTypeMixin, ModalMixin, ContextMenuMixin, OrgObjPermsMixin, SmartModelActionView): +class RequestCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): pass @@ -240,11 +239,6 @@ def get_success_url(self): def derive_menu_path(self): return f"/settings/channels/{self.get_object().uuid}" - def build_content_menu(self, menu): - obj = self.get_object() - - menu.add_link(_("Channel"), reverse("channels.channel_read", args=[obj.uuid])) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) phone_number_url = f"https://graph.facebook.com/v18.0/{self.object.address}" @@ -283,7 +277,7 @@ def execute_action(self): ) -class VerifyCode(ChannelTypeMixin, ModalMixin, ContextMenuMixin, OrgObjPermsMixin, SmartModelActionView): +class VerifyCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView): class Form(forms.Form): code = forms.CharField( min_length=6, required=True, help_text=_("The 6-digits number verification code"), widget=InputWidget() @@ -298,11 +292,6 @@ class Form(forms.Form): title = _("Verify Number") submit_button_name = _("Verify Number") - def build_content_menu(self, menu): - obj = self.get_object() - - menu.add_link(_("Channel"), reverse("channels.channel_read", args=[obj.uuid])) - def get_queryset(self): return Channel.objects.filter(is_active=True, org=self.request.org, channel_type=self.channel_type.code) diff --git a/temba/channels/views.py b/temba/channels/views.py index f0c8190498f..f840d153a05 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -41,7 +41,7 @@ from temba.utils.fields import SelectWidget from temba.utils.json import EpochEncoder from temba.utils.models import patch_queryset_count -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin from .models import Channel, ChannelCount, ChannelLog @@ -663,7 +663,7 @@ def render_to_response(self, context, **response_kwargs): encoder=EpochEncoder, ) - class FacebookWhitelist(ComponentFormMixin, ModalMixin, OrgObjPermsMixin, SmartModelActionView): + class FacebookWhitelist(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartModelActionView): class DomainForm(forms.Form): whitelisted_domain = forms.URLField( required=True, @@ -733,7 +733,7 @@ def post(self, request, *args, **kwargs): response["Temba-Success"] = self.get_success_url() return response - class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): def derive_title(self): return _("%s Channel") % self.object.type.name diff --git a/temba/contacts/views.py b/temba/contacts/views.py index e0e26a64f76..739ee3e379f 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -40,7 +40,7 @@ from temba.utils.fields import CheckboxWidget, InputWidget, SelectWidget, TembaChoiceField from temba.utils.models import patch_queryset_count from temba.utils.models.es import IDSliceQuerySet -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, NonAtomicMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, NonAtomicMixin, SpaMixin from .forms import ContactGroupForm, CreateContactForm, UpdateContactForm from .models import URN, Contact, ContactExport, ContactField, ContactGroup, ContactGroupCount, ContactImport @@ -671,7 +671,7 @@ def derive_group(self): except ContactGroup.DoesNotExist: raise Http404("Group not found") - class Create(NonAtomicMixin, ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(NonAtomicMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = CreateContactForm submit_button_name = _("Create") @@ -703,7 +703,7 @@ def form_valid(self, form): return self.render_modal_response(form) - class Update(SpaMixin, ComponentFormMixin, NonAtomicMixin, ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ComponentFormMixin, NonAtomicMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = UpdateContactForm success_url = "hide" @@ -781,7 +781,7 @@ def form_valid(self, form): return self.render_modal_response(form) - class OpenTicket(ComponentFormMixin, ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class OpenTicket(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): """ Opens a new ticket for this contact. """ @@ -838,7 +838,7 @@ def save(self, obj): obj.interrupt(self.request.user) return obj - class Delete(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): """ Delete this contact (can't be undone) """ @@ -856,7 +856,7 @@ class ContactGroupCRUDL(SmartCRUDL): model = ContactGroup actions = ("create", "update", "usages", "delete") - class Create(ComponentFormMixin, ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = ContactGroupForm fields = ("name", "preselected_contacts", "group_query") success_url = "uuid@contacts.contact_filter" @@ -890,7 +890,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class Update(ComponentFormMixin, ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = ContactGroupForm fields = ("name",) success_url = "uuid@contacts.contact_filter" @@ -1011,7 +1011,7 @@ class ContactFieldCRUDL(SmartCRUDL): model = ContactField actions = ("list", "create", "update", "update_priority", "delete", "usages") - class Create(ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): class Form(ContactFieldForm): def clean(self): super().clean() @@ -1054,7 +1054,7 @@ def form_valid(self, form): ) return self.render_modal_response(form) - class Update(FieldLookupMixin, ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(FieldLookupMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): queryset = ContactField.objects.filter(is_system=False) form_class = ContactFieldForm submit_button_name = _("Update") diff --git a/temba/flows/views.py b/temba/flows/views.py index 7e5c7ca58d0..82061adaab8 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -8,6 +8,7 @@ SmartCreateView, SmartCRUDL, SmartDeleteView, + SmartFormView, SmartListView, SmartReadView, SmartTemplateView, @@ -48,7 +49,7 @@ TembaChoiceField, ) from temba.utils.text import slugify_with -from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin, StaffOnlyMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalFormMixin, SpaMixin, StaffOnlyMixin from .models import FlowLabel, FlowStartCount, FlowUserConflictException, FlowVersionConflictException, ResultsExport @@ -156,7 +157,7 @@ class FlowRunCRUDL(SmartCRUDL): actions = ("delete",) model = FlowRun - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): fields = ("id",) success_message = None @@ -387,7 +388,7 @@ def post(self, request, *args, **kwargs): return JsonResponse({"status": "failure", "description": error, "detail": detail}, status=400) - class Create(ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): class Form(BaseFlowForm): keyword_triggers = forms.CharField( required=False, @@ -494,7 +495,7 @@ def form_valid(self, form): # redirect to the newly created flow return HttpResponseRedirect(reverse("flows.flow_editor", args=[copy.uuid])) - class Update(AllowOnlyActiveFlowMixin, ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(AllowOnlyActiveFlowMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class BaseForm(BaseFlowForm): class Meta: model = Flow @@ -987,7 +988,7 @@ def form_valid(self, form): return HttpResponseRedirect(self.get_success_url()) - class ExportTranslation(OrgObjPermsMixin, ModalMixin, SmartUpdateView): + class ExportTranslation(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class Form(forms.Form): language = forms.ChoiceField( required=False, @@ -1603,7 +1604,7 @@ def post(self, request, *args, **kwargs): } ) - class Start(OrgPermsMixin, ModalMixin): + class Start(ModalFormMixin, OrgPermsMixin, SmartFormView): class Form(forms.ModelForm): flow = TembaChoiceField( queryset=Flow.objects.none(), @@ -1789,7 +1790,7 @@ class FlowLabelCRUDL(SmartCRUDL): model = FlowLabel actions = ("create", "update", "delete") - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): fields = ("uuid",) success_url = "@flows.flow_list" cancel_url = "@flows.flow_list" @@ -1803,7 +1804,7 @@ def post(self, request, *args, **kwargs): self.object.delete() return self.render_modal_response() - class Update(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = FlowLabelForm success_url = "uuid@flows.flow_filter" @@ -1812,7 +1813,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class Create(ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): fields = ("name", "flows") form_class = FlowLabelForm submit_button_name = _("Create") @@ -1914,7 +1915,7 @@ def render_to_response(self, context, **response_kwargs): ) return JsonResponse({"results": results}) - class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Interrupt(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): default_template = "smartmin/delete_confirm.html" permission = "flows.flowstart_update" fields = () diff --git a/temba/globals/views.py b/temba/globals/views.py index cddef79c059..3413c38cdc5 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -8,7 +8,7 @@ from temba.orgs.views.base import BaseDependencyDeleteModal, BaseListView, BaseUsagesModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views.mixins import ContextMenuMixin, ModalMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalFormMixin, SpaMixin from .models import Global @@ -79,7 +79,7 @@ class GlobalCRUDL(SmartCRUDL): model = Global actions = ("create", "update", "delete", "list", "unused", "usages") - class Create(ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = CreateGlobalForm submit_button_name = _("Create") @@ -99,7 +99,7 @@ def form_valid(self, form): return self.render_modal_response(form) - class Update(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = UpdateGlobalForm submit_button_name = _("Update") diff --git a/temba/msgs/views.py b/temba/msgs/views.py index e3034ed8ed5..db7596ec88f 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -44,7 +44,7 @@ from temba.utils.models import patch_queryset_count from temba.utils.views.mixins import ( ContextMenuMixin, - ModalMixin, + ModalFormMixin, NonAtomicMixin, PostOnlyMixin, SpaMixin, @@ -602,7 +602,7 @@ def build_content_menu(self, menu): title=_("Delete Broadcast"), ) - class ScheduledDelete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class ScheduledDelete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "broadcast_scheduled_delete.html" cancel_url = "id@msgs.broadcast_scheduled_read" success_url = "@msgs.broadcast_scheduled" @@ -664,7 +664,7 @@ def post(self, request, *args, **kwargs): } ) - class ToNode(NonAtomicMixin, ModalMixin, OrgPermsMixin, SmartCreateView): + class ToNode(NonAtomicMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): class Form(forms.ModelForm): text = forms.CharField( widget=CompletionTextarea( @@ -748,7 +748,7 @@ def render_to_response(self, context, **response_kwargs): ) return JsonResponse({"results": results}) - class Interrupt(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Interrupt(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): default_template = "smartmin/delete_confirm.html" permission = "msgs.broadcast_update" fields = () @@ -1103,7 +1103,7 @@ class LabelCRUDL(SmartCRUDL): model = Label actions = ("create", "update", "usages", "delete") - class Create(ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): fields = ("name", "messages") success_url = "uuid@msgs.msg_filter" form_class = LabelForm @@ -1127,7 +1127,7 @@ def post_save(self, obj, *args, **kwargs): return obj - class Update(ModalMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = LabelForm success_url = "uuid@msgs.msg_filter" title = _("Update Label") diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index c48c99121ab..8848eb332b7 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -14,7 +14,7 @@ from temba.contacts.models import ContactField, ContactGroup from temba.utils import on_transaction_commit from temba.utils.fields import SelectMultipleWidget, TembaDateField -from temba.utils.views.mixins import ModalMixin +from temba.utils.views.mixins import ModalFormMixin from .mixins import DependencyMixin, OrgObjPermsMixin, OrgPermsMixin @@ -137,7 +137,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({"results": self.get_menu()}) -class BaseExportModal(ModalMixin, OrgPermsMixin, SmartFormView): +class BaseExportModal(ModalFormMixin, OrgPermsMixin, SmartFormView): """ Base modal view for exports """ @@ -262,7 +262,7 @@ def get_context_data(self, **kwargs): return context -class BaseDependencyDeleteModal(DependencyMixin, ModalMixin, OrgObjPermsMixin, SmartDeleteView): +class BaseDependencyDeleteModal(DependencyMixin, ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): """ Base view for delete modals of flow dependencies """ diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 8305820471b..5421a457fce 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -59,7 +59,7 @@ from temba.utils.views.mixins import ( ComponentFormMixin, ContextMenuMixin, - ModalMixin, + ModalFormMixin, NonAtomicMixin, NoNavMixin, PostOnlyMixin, @@ -405,7 +405,7 @@ def get_context_data(self, **kwargs): context["filters"] = self.filters return context - class Update(StaffOnlyMixin, SpaMixin, ModalMixin, ComponentFormMixin, ContextMenuMixin, SmartUpdateView): + class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, ContextMenuMixin, SmartUpdateView): class Form(UserUpdateForm): groups = forms.ModelMultipleChoiceField( widget=SelectMultipleWidget( @@ -444,7 +444,7 @@ def post_save(self, obj): return obj - class Delete(StaffOnlyMixin, SpaMixin, ModalMixin, SmartDeleteView): + class Delete(StaffOnlyMixin, ModalFormMixin, SmartDeleteView): fields = ("id",) permission = "orgs.user_update" submit_button_name = _("Delete") @@ -1561,7 +1561,7 @@ def lookup_field_link(self, context, field, obj): return reverse("orgs.user_update", args=[owner.pk]) return super().lookup_field_link(context, field, obj) - class Update(StaffOnlyMixin, SpaMixin, ModalMixin, ComponentFormMixin, SmartUpdateView): + class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, SmartUpdateView): ACTION_FLAG = "flag" ACTION_UNFLAG = "unflag" ACTION_SUSPEND = "suspend" @@ -1647,7 +1647,7 @@ def pre_save(self, obj): obj.limits = cleaned_data["limits"] return obj - class DeleteChild(SpaMixin, OrgObjPermsMixin, ModalMixin, SmartDeleteView): + class DeleteChild(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): cancel_url = "@orgs.org_sub_orgs" success_url = "@orgs.org_sub_orgs" fields = ("id",) @@ -1907,7 +1907,7 @@ def get_context_data(self, **kwargs): return context - class Create(NonAtomicMixin, SpaMixin, OrgPermsMixin, ModalMixin, InferOrgMixin, SmartCreateView): + class Create(NonAtomicMixin, ModalFormMixin, InferOrgMixin, OrgPermsMixin, SmartCreateView): class Form(forms.ModelForm): TYPE_CHILD = "child" TYPE_NEW = "new" @@ -2389,7 +2389,7 @@ class Meta: def derive_exclude(self): return ["language"] if len(settings.LANGUAGES) == 1 else [] - class EditSubOrg(SpaMixin, ModalMixin, Edit): + class EditSubOrg(SpaMixin, ModalFormMixin, Edit): success_url = "@orgs.org_sub_orgs" def get_success_url(self): @@ -2538,7 +2538,7 @@ class InvitationCRUDL(SmartCRUDL): model = Invitation actions = ("create",) - class Create(SpaMixin, ModalMixin, OrgPermsMixin, SmartCreateView): + class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView): class Form(forms.ModelForm): ROLE_CHOICES = [(r.code, r.display) for r in (OrgRole.AGENT, OrgRole.EDITOR, OrgRole.ADMINISTRATOR)] diff --git a/temba/tickets/views.py b/temba/tickets/views.py index b5cb022ae2f..80ecfb7960d 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -26,7 +26,7 @@ from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget from temba.utils.uuid import UUID_REGEX -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin from .forms import ShortcutForm, TopicForm from .models import ( @@ -48,7 +48,7 @@ class ShortcutCRUDL(SmartCRUDL): model = Shortcut actions = ("create", "update", "delete", "list") - class Create(OrgPermsMixin, ComponentFormMixin, ModalMixin, SmartCreateView): + class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = ShortcutForm success_url = "@tickets.shortcut_list" @@ -60,7 +60,7 @@ def get_form_kwargs(self): def save(self, obj): return Shortcut.create(self.request.org, self.request.user, obj.name, obj.text) - class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = ShortcutForm success_url = "@tickets.shortcut_list" @@ -69,7 +69,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") fields = ("id",) @@ -102,7 +102,7 @@ class TopicCRUDL(SmartCRUDL): model = Topic actions = ("create", "update", "delete") - class Create(OrgPermsMixin, ComponentFormMixin, ModalMixin, SmartCreateView): + class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = TopicForm success_url = "hide" @@ -114,7 +114,7 @@ def get_form_kwargs(self): def save(self, obj): return Topic.create(self.request.org, self.request.user, obj.name) - class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = TopicForm success_url = "hide" slug_url_kwarg = "uuid" @@ -124,7 +124,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class Delete(ModalMixin, OrgObjPermsMixin, SmartDeleteView): + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") slug_url_kwarg = "uuid" @@ -151,7 +151,7 @@ class TicketCRUDL(SmartCRUDL): model = Ticket actions = ("list", "update", "folder", "note", "menu", "export_stats", "export") - class Update(OrgObjPermsMixin, ComponentFormMixin, ModalMixin, SmartUpdateView): + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class Form(forms.ModelForm): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -489,7 +489,7 @@ def as_json(t): return JsonResponse(results) - class Note(ModalMixin, ComponentFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Note(ModalFormMixin, ComponentFormMixin, OrgObjPermsMixin, SmartUpdateView): """ Creates a note for this contact """ diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 5b7986b32f3..4117bf2bbe8 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -18,7 +18,7 @@ from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin from temba.schedules.models import Schedule from temba.utils.fields import SelectMultipleWidget, SelectWidget, TembaChoiceField, TembaMultipleChoiceField -from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalMixin, SpaMixin +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin from .models import Trigger @@ -370,7 +370,7 @@ class CreateOptIn(BaseCreate): class CreateOptOut(BaseCreate): trigger_type = Trigger.TYPE_OPT_OUT - class Update(ModalMixin, ComponentFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(ModalFormMixin, ComponentFormMixin, OrgObjPermsMixin, SmartUpdateView): def get_form_class(self): return self.object.type.form diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 64ee6752172..31637f1130c 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -217,7 +217,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) -class ModalMixin(SmartFormView): +class ModalFormMixin(SmartFormView): """ TODO rework this to be an actual mixin """ From 2820bd831a55aecd104754485b85ac3d9abb9a94 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 7 Oct 2024 17:03:15 +0200 Subject: [PATCH 151/557] Remove unused modal mixin on non modal views --- temba/channels/types/facebookapp/views.py | 6 ++++-- temba/channels/types/instagram/views.py | 6 ++++-- temba/channels/types/whatsapp/views.py | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/temba/channels/types/facebookapp/views.py b/temba/channels/types/facebookapp/views.py index 2bfbe6b921e..30628074bcb 100644 --- a/temba/channels/types/facebookapp/views.py +++ b/temba/channels/types/facebookapp/views.py @@ -8,7 +8,6 @@ from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate -from temba.utils.views.mixins import ModalFormMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin @@ -137,7 +136,7 @@ def form_valid(self, form): return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, ModalFormMixin, OrgObjPermsMixin, SmartModelActionView): +class RefreshToken(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): class Form(forms.Form): user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) fb_user_id = forms.CharField( @@ -153,6 +152,9 @@ class Form(forms.Form): title = _("Reconnect Facebook Page") menu_path = "/settings/workspace" + def derive_menu_path(self): + return f"/settings/channels/{self.get_object().uuid}" + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["refresh_url"] = reverse("channels.types.facebookapp.refresh_token", args=(self.object.uuid,)) diff --git a/temba/channels/types/instagram/views.py b/temba/channels/types/instagram/views.py index 61820e47892..0d943692cc2 100644 --- a/temba/channels/types/instagram/views.py +++ b/temba/channels/types/instagram/views.py @@ -10,7 +10,6 @@ from temba.orgs.views.mixins import OrgObjPermsMixin from temba.utils.text import truncate -from temba.utils.views.mixins import ModalFormMixin from ...models import Channel from ...views import ChannelTypeMixin, ClaimViewMixin @@ -168,7 +167,7 @@ def form_valid(self, form): return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, ModalFormMixin, OrgObjPermsMixin, SmartModelActionView): +class RefreshToken(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): class Form(forms.Form): user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) fb_user_id = forms.CharField( @@ -184,6 +183,9 @@ class Form(forms.Form): template_name = "channels/types/instagram/refresh_token.html" title = _("Reconnect Instagram Business Account") + def derive_menu_path(self): + return f"/settings/channels/{self.get_object().uuid}" + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["refresh_url"] = reverse("channels.types.instagram.refresh_token", args=(self.object.uuid,)) diff --git a/temba/channels/types/whatsapp/views.py b/temba/channels/types/whatsapp/views.py index 15a7c6dee32..f4ee8141172 100644 --- a/temba/channels/types/whatsapp/views.py +++ b/temba/channels/types/whatsapp/views.py @@ -218,7 +218,7 @@ def render_to_response(self, context, **response_kwargs): return JsonResponse({}) -class RequestCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView): +class RequestCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): class Form(forms.Form): pass @@ -277,7 +277,7 @@ def execute_action(self): ) -class VerifyCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView): +class VerifyCode(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): class Form(forms.Form): code = forms.CharField( min_length=6, required=True, help_text=_("The 6-digits number verification code"), widget=InputWidget() From f00d83da137b3e628c66f699aea64f27eb46c9c4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 7 Oct 2024 18:14:42 +0000 Subject: [PATCH 152/557] Add BaseCreateModal and BaseUpdateModal --- temba/airtime/views.py | 2 +- temba/archives/views.py | 3 +- temba/campaigns/views.py | 2 +- temba/contacts/views.py | 2 +- temba/flows/views.py | 6 ++-- temba/globals/views.py | 4 +-- temba/msgs/views.py | 6 ++-- temba/orgs/views/base.py | 63 ++++++++++++++++++++++++++++++++----- temba/request_logs/views.py | 2 +- temba/templates/views.py | 2 +- temba/tickets/views.py | 58 +++++++++------------------------- temba/triggers/views.py | 2 +- 12 files changed, 86 insertions(+), 66 deletions(-) diff --git a/temba/airtime/views.py b/temba/airtime/views.py index 3c93a31cdea..b7ca1364fe0 100644 --- a/temba/airtime/views.py +++ b/temba/airtime/views.py @@ -16,7 +16,7 @@ class AirtimeCRUDL(SmartCRUDL): model = AirtimeTransfer actions = ("list", "read") - class List(SpaMixin, BaseListView): + class List(BaseListView): menu_path = "/settings/workspace" title = _("Recent Airtime Transfers") fields = ("status", "contact", "recipient", "currency", "actual_amount", "created_on") diff --git a/temba/archives/views.py b/temba/archives/views.py index 4f71042cd14..2aea2a100c5 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -6,7 +6,6 @@ from temba.orgs.views.base import BaseListView from temba.orgs.views.mixins import OrgObjPermsMixin -from temba.utils.views.mixins import SpaMixin from .models import Archive @@ -16,7 +15,7 @@ class ArchiveCRUDL(SmartCRUDL): actions = ("read", "run", "message") permissions = True - class BaseList(SpaMixin, BaseListView): + class BaseList(BaseListView): title = _("Archive") fields = ("url", "start_date", "period", "record_count", "size") default_order = ("-start_date", "-period", "archive_type") diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index 0e5929db9a5..ec20f4d6f11 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -161,7 +161,7 @@ def get_form_kwargs(self): kwargs["org"] = self.request.org return kwargs - class BaseList(SpaMixin, ContextMenuMixin, BulkActionMixin, BaseListView): + class BaseList(ContextMenuMixin, BulkActionMixin, BaseListView): fields = ("name", "group") default_template = "campaigns/campaign_list.html" default_order = ("-modified_on",) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 739ee3e379f..5c9c62e951a 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -1098,7 +1098,7 @@ def post(self, request, *args, **kwargs): return HttpResponse(json.dumps(payload), status=400, content_type="application/json") - class List(SpaMixin, ContextMenuMixin, BaseListView): + class List(ContextMenuMixin, BaseListView): menu_path = "/contact/fields" title = _("Fields") default_order = "name" diff --git a/temba/flows/views.py b/temba/flows/views.py index 82061adaab8..f8c2eb82e78 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -666,7 +666,7 @@ def update_triggers(self, flow, user, new_keywords: list): match_type=Trigger.MATCH_FIRST_WORD, ) - class BaseList(SpaMixin, BulkActionMixin, ContextMenuMixin, BaseListView): + class BaseList(BulkActionMixin, ContextMenuMixin, BaseListView): permission = "flows.flow_list" title = _("Flows") refresh = 10000 @@ -1847,7 +1847,7 @@ class FlowStartCRUDL(SmartCRUDL): model = FlowStart actions = ("list", "interrupt", "status") - class List(SpaMixin, BaseListView): + class List(BaseListView): title = _("Flow Starts") ordering = ("-created_on",) select_related = ("flow", "created_by") @@ -1878,7 +1878,7 @@ def get_context_data(self, *args, **kwargs): return context - class Status(BaseListView): + class Status(OrgPermsMixin, SmartListView): permission = "flows.flowstart_list" def derive_queryset(self, **kwargs): diff --git a/temba/globals/views.py b/temba/globals/views.py index 3413c38cdc5..479522591f9 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -8,7 +8,7 @@ from temba.orgs.views.base import BaseDependencyDeleteModal, BaseListView, BaseUsagesModal from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.fields import InputWidget -from temba.utils.views.mixins import ContextMenuMixin, ModalFormMixin, SpaMixin +from temba.utils.views.mixins import ContextMenuMixin, ModalFormMixin from .models import Global @@ -112,7 +112,7 @@ class Delete(BaseDependencyDeleteModal): cancel_url = "@globals.global_list" success_url = "@globals.global_list" - class List(SpaMixin, ContextMenuMixin, BaseListView): + class List(ContextMenuMixin, BaseListView): title = _("Globals") fields = ("name", "key", "value") search_fields = ("name__icontains", "key__icontains") diff --git a/temba/msgs/views.py b/temba/msgs/views.py index db7596ec88f..5f60eaf02b0 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus import magic -from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartReadView, SmartUpdateView +from smartmin.views import SmartCreateView, SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView from django import forms from django.conf import settings @@ -55,7 +55,7 @@ from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel -class SystemLabelView(SpaMixin, BaseListView): +class SystemLabelView(BaseListView): """ Base class for views backed by a system label or message label queryset """ @@ -717,7 +717,7 @@ def form_valid(self, form): return self.render_modal_response(form) - class Status(BaseListView): + class Status(OrgPermsMixin, SmartListView): permission = "msgs.broadcast_list" def derive_queryset(self, **kwargs): diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index 8848eb332b7..e6121e060f4 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -1,11 +1,19 @@ from datetime import timedelta -from smartmin.views import SmartDeleteView, SmartFormView, SmartListView, SmartReadView, SmartTemplateView +from smartmin.views import ( + SmartCreateView, + SmartDeleteView, + SmartFormView, + SmartListView, + SmartReadView, + SmartTemplateView, + SmartUpdateView, +) from django import forms from django.contrib import messages from django.db.models.functions import Lower -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse from django.utils import timezone from django.utils.text import slugify @@ -14,23 +22,64 @@ from temba.contacts.models import ContactField, ContactGroup from temba.utils import on_transaction_commit from temba.utils.fields import SelectMultipleWidget, TembaDateField -from temba.utils.views.mixins import ModalFormMixin +from temba.utils.views.mixins import ComponentFormMixin, ModalFormMixin, SpaMixin from .mixins import DependencyMixin, OrgObjPermsMixin, OrgPermsMixin -class BaseListView(OrgPermsMixin, SmartListView): +class BaseCreateModal(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): + """ + Base create modal view + """ + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs + + def pre_save(self, obj): + obj = super().pre_save(obj) + obj.org = self.request.org + return obj + + +class BaseUpdateModal(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + """ + Base update modal view + """ + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs + + def derive_queryset(self, **kwargs): + return super().derive_queryset(**kwargs).filter(org=self.request.org, is_active=True) + + +class BaseDeleteModal(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): + default_template = "smartmin/delete_confirm.html" + submit_button_name = _("Delete") + fields = ("id",) + + def post(self, request, *args, **kwargs): + self.get_object().release(self.request.user) + redirect_url = self.get_redirect_url() + return HttpResponseRedirect(redirect_url) + + +class BaseListView(SpaMixin, OrgPermsMixin, SmartListView): """ Base list view for objects that belong to the current org """ def derive_queryset(self, **kwargs): - queryset = super().derive_queryset(**kwargs) + qs = super().derive_queryset(**kwargs) if not self.request.user.is_authenticated: - return queryset.none() # pragma: no cover + return qs.none() # pragma: no cover else: - return queryset.filter(org=self.request.org) + return qs.filter(org=self.request.org) class BaseMenuView(OrgPermsMixin, SmartTemplateView): diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index fbbeb6268f5..5e020ad992a 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -58,7 +58,7 @@ class HTTPLogCRUDL(SmartCRUDL): model = HTTPLog actions = ("webhooks", "channel", "classifier", "read") - class Webhooks(SpaMixin, ContextMenuMixin, BaseListView): + class Webhooks(ContextMenuMixin, BaseListView): default_order = ("-created_on",) select_related = ("flow",) fields = ("flow", "url", "status_code", "request_time", "created_on") diff --git a/temba/templates/views.py b/temba/templates/views.py index 4437d3283b8..76852edaf7b 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -11,7 +11,7 @@ class TemplateCRUDL(SmartCRUDL): model = Template actions = ("list", "read", "usages") - class List(SpaMixin, BaseListView): + class List(BaseListView): default_order = ("-created_on",) def derive_menu_path(self): diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 80ecfb7960d..4c3d1ff44f8 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -1,13 +1,6 @@ from datetime import timedelta -from smartmin.views import ( - SmartCreateView, - SmartCRUDL, - SmartDeleteView, - SmartListView, - SmartTemplateView, - SmartUpdateView, -) +from smartmin.views import SmartCRUDL, SmartDeleteView, SmartListView, SmartTemplateView, SmartUpdateView from django import forms from django.db.models.aggregates import Max @@ -20,7 +13,14 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin -from temba.orgs.views.base import BaseExportModal, BaseListView, BaseMenuView +from temba.orgs.views.base import ( + BaseCreateModal, + BaseDeleteModal, + BaseExportModal, + BaseListView, + BaseMenuView, + BaseUpdateModal, +) from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook @@ -48,40 +48,22 @@ class ShortcutCRUDL(SmartCRUDL): model = Shortcut actions = ("create", "update", "delete", "list") - class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): + class Create(BaseCreateModal): form_class = ShortcutForm success_url = "@tickets.shortcut_list" - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.request.org - return kwargs - def save(self, obj): return Shortcut.create(self.request.org, self.request.user, obj.name, obj.text) - class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(BaseUpdateModal): form_class = ShortcutForm success_url = "@tickets.shortcut_list" - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.request.org - return kwargs - - class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): - default_template = "smartmin/delete_confirm.html" - submit_button_name = _("Delete") - fields = ("id",) + class Delete(BaseDeleteModal): cancel_url = "@tickets.shortcut_list" redirect_url = "@tickets.shortcut_list" - def post(self, request, *args, **kwargs): - self.get_object().release(self.request.user) - redirect_url = self.get_redirect_url() - return HttpResponseRedirect(redirect_url) - - class List(SpaMixin, ContextMenuMixin, BaseListView): + class List(ContextMenuMixin, BaseListView): menu_path = "/ticket/shortcuts" def derive_queryset(self, **kwargs): @@ -102,28 +84,18 @@ class TopicCRUDL(SmartCRUDL): model = Topic actions = ("create", "update", "delete") - class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): + class Create(BaseCreateModal): form_class = TopicForm success_url = "hide" - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.request.org - return kwargs - def save(self, obj): return Topic.create(self.request.org, self.request.user, obj.name) - class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Update(BaseUpdateModal): form_class = TopicForm success_url = "hide" slug_url_kwarg = "uuid" - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.request.org - return kwargs - class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 4117bf2bbe8..6ee60ac8ee9 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -424,7 +424,7 @@ def form_valid(self, form): response["REDIRECT"] = self.get_success_url() return response - class BaseList(SpaMixin, BulkActionMixin, BaseListView): + class BaseList(BulkActionMixin, BaseListView): """ Base class for list views """ From 46c69f96e97a1baa43afdc1856c58475f1764dde Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 7 Oct 2024 18:39:01 +0000 Subject: [PATCH 153/557] Finsih renaming content menus to context menus --- temba/campaigns/views.py | 6 +++--- temba/channels/views.py | 2 +- temba/classifiers/views.py | 2 +- temba/contacts/views.py | 14 +++++++------- temba/flows/views.py | 8 ++++---- temba/globals/views.py | 2 +- temba/msgs/views.py | 10 +++++----- temba/orgs/views/views.py | 14 +++++++------- temba/request_logs/views.py | 2 +- temba/tickets/views.py | 6 +++--- temba/triggers/views.py | 2 +- temba/utils/views/mixins.py | 10 +++++----- templates/frame.html | 2 +- templates/spa.html | 2 +- 14 files changed, 41 insertions(+), 41 deletions(-) diff --git a/temba/campaigns/views.py b/temba/campaigns/views.py index ec20f4d6f11..88adf8cc2fc 100644 --- a/temba/campaigns/views.py +++ b/temba/campaigns/views.py @@ -114,7 +114,7 @@ class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): def derive_title(self): return self.object.name - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if obj.is_archived: @@ -184,7 +184,7 @@ def get_queryset(self, *args, **kwargs): qs = qs.filter(is_active=True, is_archived=False) return qs - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("campaigns.campaign_create"): menu.add_modax( _("New Campaign"), @@ -512,7 +512,7 @@ def get_context_data(self, **kwargs): return context - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if self.has_org_perm("campaigns.campaignevent_update") and not obj.campaign.is_archived: diff --git a/temba/channels/views.py b/temba/channels/views.py index f840d153a05..9194bc875f8 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -486,7 +486,7 @@ def get_queryset(self): def get_notification_scope(self) -> tuple: return "incident:started", str(self.object.id) - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() for item in obj.type.menu_items: diff --git a/temba/classifiers/views.py b/temba/classifiers/views.py index 379442eaaa7..1c5aa1c5644 100644 --- a/temba/classifiers/views.py +++ b/temba/classifiers/views.py @@ -55,7 +55,7 @@ class Read(SpaMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): def derive_menu_path(self): return f"/settings/classifiers/{self.get_object().uuid}" - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() menu.add_link(_("Log"), reverse("request_logs.httplog_classifier", args=[obj.uuid])) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 5c9c62e951a..97f2f8c2af9 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -332,7 +332,7 @@ def derive_title(self): def get_queryset(self): return Contact.objects.filter(is_active=True) - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if self.has_org_perm("contacts.contact_update"): @@ -517,7 +517,7 @@ def get_bulk_actions(self): actions += ("start-flow",) return actions - def build_content_menu(self, menu): + def build_context_menu(self, menu): search = self.request.GET.get("search") # define save search conditions @@ -564,7 +564,7 @@ class Blocked(ContextMenuMixin, ContactListView): def get_bulk_actions(self): return ("restore", "archive") if self.has_org_perm("contacts.contact_update") else () - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("contacts.contact_export"): menu.add_modax(_("Export"), "export-contacts", self.derive_export_url(), title=_("Export Contacts")) @@ -581,7 +581,7 @@ class Stopped(ContextMenuMixin, ContactListView): def get_bulk_actions(self): return ("restore", "archive") if self.has_org_perm("contacts.contact_update") else () - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("contacts.contact_export"): menu.add_modax(_("Export"), "export-contacts", self.derive_export_url(), title=_("Export Contacts")) @@ -609,7 +609,7 @@ def get_context_data(self, *args, **kwargs): context["reply_disabled"] = True return context - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("contacts.contact_export"): menu.add_modax(_("Export"), "export-contacts", self.derive_export_url(), title=_("Export Contacts")) @@ -619,7 +619,7 @@ def build_content_menu(self, menu): class Filter(OrgObjPermsMixin, ContextMenuMixin, ContactListView): template_name = "contacts/contact_filter.html" - def build_content_menu(self, menu): + def build_context_menu(self, menu): if not self.group.is_system and self.has_org_perm("contacts.contactgroup_update"): menu.add_modax(_("Edit"), "edit-group", reverse("contacts.contactgroup_update", args=[self.group.id])) @@ -1103,7 +1103,7 @@ class List(ContextMenuMixin, BaseListView): title = _("Fields") default_order = "name" - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("contacts.contactfield_create"): menu.add_modax( _("New"), diff --git a/temba/flows/views.py b/temba/flows/views.py index f8c2eb82e78..b0cea1b49be 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -757,7 +757,7 @@ def get_folders(self): ), ] - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("flows.flow_create"): menu.add_modax( _("New Flow"), @@ -808,7 +808,7 @@ class Filter(BaseList, OrgObjPermsMixin): def derive_menu_path(self): return f"/flow/labels/{self.label.uuid}" - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("flows.flow_update"): menu.add_modax( _("Edit"), @@ -901,7 +901,7 @@ def get_features(self, org) -> list: return features - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if obj.flow_type != Flow.TYPE_SURVEY and self.has_org_perm("flows.flow_start") and not obj.is_archived: @@ -1368,7 +1368,7 @@ def render_to_response(self, context, **response_kwargs): class Results(SpaMixin, AllowOnlyActiveFlowMixin, OrgObjPermsMixin, ContextMenuMixin, SmartReadView): slug_url_kwarg = "uuid" - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if self.has_org_perm("flows.flow_results"): diff --git a/temba/globals/views.py b/temba/globals/views.py index 479522591f9..cd52393fde9 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -120,7 +120,7 @@ class List(ContextMenuMixin, BaseListView): paginate_by = 250 menu_path = "/flow/globals" - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("globals.global_create"): menu.add_modax( _("New"), diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 5f60eaf02b0..252bda9da7c 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -139,7 +139,7 @@ def get_context_data(self, **kwargs): return context - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("msgs.broadcast_create"): menu.add_modax( _("Send"), "send-message", reverse("msgs.broadcast_create"), title=_("New Broadcast"), as_button=True @@ -340,7 +340,7 @@ def get_queryset(self, **kwargs): .prefetch_related("groups", "contacts") ) - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("msgs.broadcast_create"): menu.add_modax( _("Send"), @@ -361,7 +361,7 @@ class Scheduled(MsgListView): "-created_on", ) - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("msgs.broadcast_create"): menu.add_modax( _("Send"), @@ -583,7 +583,7 @@ def get_context_data(self, **kwargs): context["send_history"] = self.get_object().children.order_by("-created_on") return context - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if self.has_org_perm("msgs.broadcast_update") and obj.schedule.next_fire: @@ -1013,7 +1013,7 @@ def derive_menu_path(self): def derive_title(self, *args, **kwargs): return self.label.name - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("msgs.msg_update"): menu.add_modax( _("Edit"), diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 5421a457fce..fd80baff1f2 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -360,7 +360,7 @@ class Read(StaffOnlyMixin, ContextMenuMixin, SpaMixin, SmartReadView): fields = ("email", "date_joined") menu_path = "/staff/users/all" - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() menu.add_modax( _("Edit"), @@ -916,7 +916,7 @@ class Meta: success_url = "@orgs.user_tokens" token_limit = 3 - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit: menu.add_url_post(_("New Token"), reverse("orgs.user_tokens") + "?new=1", as_button=True) @@ -1445,7 +1445,7 @@ def extract_from(smtp_url: str) -> str: return context class Read(StaffOnlyMixin, SpaMixin, ContextMenuMixin, SmartReadView): - def build_content_menu(self, menu): + def build_context_menu(self, menu): obj = self.get_object() if not obj.is_active: return @@ -1781,7 +1781,7 @@ def pre_process(self, request, *args, **kwargs): if Org.FEATURE_USERS not in request.org.features: return HttpResponseRedirect(reverse("orgs.org_workspace")) - def build_content_menu(self, menu): + def build_context_menu(self, menu): menu.add_modax(_("Invite"), "invite-create", reverse("orgs.invitation_create"), as_button=True) def get_form_kwargs(self): @@ -1826,7 +1826,7 @@ class ManageAccountsSubOrg(ManageAccounts): def pre_process(self, request, *args, **kwargs): pass - def build_content_menu(self, menu): + def build_context_menu(self, menu): menu.add_modax( _("Invite"), "invite-create", @@ -1883,7 +1883,7 @@ class SubOrgs(SpaMixin, ContextMenuMixin, OrgPermsMixin, InferOrgMixin, SmartLis title = _("Workspaces") menu_path = "/settings/workspaces" - def build_content_menu(self, menu): + def build_context_menu(self, menu): org = self.get_object() enabled = Org.FEATURE_CHILD_ORGS in org.features or Org.FEATURE_NEW_ORGS in org.features @@ -2681,7 +2681,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) - def build_content_menu(self, menu): + def build_context_menu(self, menu): menu.add_js("export_download", _("Download"), as_button=True) def get_template_names(self): diff --git a/temba/request_logs/views.py b/temba/request_logs/views.py index 5e020ad992a..d29d447dac5 100644 --- a/temba/request_logs/views.py +++ b/temba/request_logs/views.py @@ -75,7 +75,7 @@ def derive_queryset(self, **kwargs): qs = qs.filter(is_error=True) return qs - def build_content_menu(self, menu): + def build_context_menu(self, menu): if str_to_bool(self.request.GET.get("error")): menu.add_link(_("All logs"), reverse("request_logs.httplog_webhooks")) else: diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 4c3d1ff44f8..26d35dbe0f0 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -69,7 +69,7 @@ class List(ContextMenuMixin, BaseListView): def derive_queryset(self, **kwargs): return super().derive_queryset(**kwargs).filter(is_active=True).order_by(Lower("name")) - def build_content_menu(self, menu): + def build_context_menu(self, menu): if self.has_org_perm("tickets.shortcut_create"): menu.add_modax( _("New"), @@ -212,7 +212,7 @@ def get_context_data(self, **kwargs): return context - def build_content_menu(self, menu): + def build_context_menu(self, menu): # we only support dynamic content menus if "HTTP_TEMBA_CONTENT_MENU" not in self.request.META: return @@ -328,7 +328,7 @@ def folder(self): raise Http404() return folder - def build_content_menu(self, menu): + def build_context_menu(self, menu): # we only support dynamic content menus if "HTTP_TEMBA_CONTENT_MENU" not in self.request.META: return diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 6ee60ac8ee9..6b1c94ae60b 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -472,7 +472,7 @@ class Archived(ContextMenuMixin, BaseList): title = _("Archived") menu_path = "/trigger/archived" - def build_content_menu(self, menu): + def build_context_menu(self, menu): menu.add_js("triggers_delete_all", _("Delete All")) def get_queryset(self, *args, **kwargs): diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 31637f1130c..3b6e9268ebc 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -194,7 +194,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # does the page have a content menu? - context["has_content_menu"] = len(self._get_content_menu()) > 0 + context["has_context_menu"] = len(self._get_context_menu()) > 0 # does the page have a search query? if "search" in self.request.GET: @@ -202,17 +202,17 @@ def get_context_data(self, **kwargs): return context - def _get_content_menu(self): + def _get_context_menu(self): menu = self.Menu() - self.build_content_menu(menu) + self.build_context_menu(menu) return menu.as_items() - def build_content_menu(self, menu: Menu): # pragma: no cover + def build_context_menu(self, menu: Menu): # pragma: no cover pass def get(self, request, *args, **kwargs): if "HTTP_TEMBA_CONTENT_MENU" in self.request.META: - return JsonResponse({"items": self._get_content_menu()}) + return JsonResponse({"items": self._get_context_menu()}) return super().get(request, *args, **kwargs) diff --git a/templates/frame.html b/templates/frame.html index 43cb2d39acf..da580d5ac63 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -240,7 +240,7 @@
    - {% if has_content_menu %} + {% if has_context_menu %} {% include "spa_page_menu.html" %} {% endif %} diff --git a/templates/spa.html b/templates/spa.html index 5bf74550127..d2f61b65cb5 100644 --- a/templates/spa.html +++ b/templates/spa.html @@ -33,7 +33,7 @@
    - {% if has_content_menu %} + {% if has_context_menu %} {% include "spa_page_menu.html" %} {% endif %} From 4a83697902100bd6bb6a95b6d91fb2f3cde884ad Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 7 Oct 2024 20:18:56 +0000 Subject: [PATCH 154/557] Fix BaseDeleteModal to not use ModalFormMixin --- temba/orgs/views/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index e6121e060f4..891ff9f8135 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -57,15 +57,15 @@ def derive_queryset(self, **kwargs): return super().derive_queryset(**kwargs).filter(org=self.request.org, is_active=True) -class BaseDeleteModal(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): +class BaseDeleteModal(OrgObjPermsMixin, SmartDeleteView): default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") fields = ("id",) def post(self, request, *args, **kwargs): self.get_object().release(self.request.user) - redirect_url = self.get_redirect_url() - return HttpResponseRedirect(redirect_url) + + return HttpResponseRedirect(self.get_redirect_url()) class BaseListView(SpaMixin, OrgPermsMixin, SmartListView): From 6572264d6d27652f5fbf21d9f7d94239a9eb5217 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Mon, 7 Oct 2024 14:14:48 -0700 Subject: [PATCH 155/557] Normal menu navigation for tickets --- static/js/frame.js | 6 ------ temba/tickets/views.py | 20 +++++++++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/static/js/frame.js b/static/js/frame.js index f787fd2c52d..828d7b2dcef 100644 --- a/static/js/frame.js +++ b/static/js/frame.js @@ -376,12 +376,6 @@ function handleMenuClicked(event) { return; } - if (!item.popup && selection.length > 1 && selection[0] == 'ticket') { - if (window.handleTicketsMenuChanged) { - handleTicketsMenuChanged(item); - } - } - // posterize if called for if (item.href && item.posterize) { posterize(item.href); diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 43bc1aadbd4..562ddfc19d7 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -306,19 +306,20 @@ def derive_menu(self): "name": folder.name, "icon": folder.icon, "count": counts[folder.id], + "href": f"/ticket/{folder.id}/open/", } ) menu.append(self.create_divider()) - # menu.append( - # self.create_menu_item( - # menu_id="shortcuts", - # name=_("Shortcuts"), - # icon="shortcut", - # count=org.shortcuts.filter(is_active=True).count(), - # href="tickets.shortcut_list", - # ) - # ) + menu.append( + self.create_menu_item( + menu_id="shortcuts", + name=_("Shortcuts"), + icon="shortcut", + count=org.shortcuts.filter(is_active=True).count(), + href="tickets.shortcut_list", + ) + ) menu.append(self.create_modax_button(_("Export"), "tickets.ticket_export", icon="export")) menu.append( self.create_modax_button(_("New Topic"), "tickets.topic_create", icon="add", on_submit="refreshMenu()") @@ -335,6 +336,7 @@ def derive_menu(self): "name": topic.name, "icon": "topic", "count": counts[topic], + "href": f"/ticket/{topic.uuid}/open/", } ) From 67800ec885393eec57daef3701db1faf43340b1b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 7 Oct 2024 22:31:30 +0000 Subject: [PATCH 156/557] Add new notification type for when an invitation to join a workspace is accepted --- .../0024_notification_data_and_more.py | 23 +++++++++++++ temba/notifications/models.py | 3 +- temba/notifications/tests.py | 34 +++++++++++++++++-- temba/notifications/types/__init__.py | 2 ++ temba/notifications/types/builtin.py | 33 ++++++++++++++++++ .../0152_alter_invitation_user_group.py | 22 ++++++++++++ temba/orgs/models.py | 11 +++++- temba/orgs/tests.py | 4 ++- temba/orgs/views/views.py | 4 +-- .../email/invitation_accepted.html | 10 ++++++ .../email/invitation_accepted.txt | 6 ++++ 11 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 temba/notifications/migrations/0024_notification_data_and_more.py create mode 100644 temba/orgs/migrations/0152_alter_invitation_user_group.py create mode 100644 templates/notifications/email/invitation_accepted.html create mode 100644 templates/notifications/email/invitation_accepted.txt diff --git a/temba/notifications/migrations/0024_notification_data_and_more.py b/temba/notifications/migrations/0024_notification_data_and_more.py new file mode 100644 index 00000000000..fa44fbc2e09 --- /dev/null +++ b/temba/notifications/migrations/0024_notification_data_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1 on 2024-10-07 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0023_notification_email_address"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="data", + field=models.JSONField(default=dict, null=True), + ), + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField(max_length=32), + ), + ] diff --git a/temba/notifications/models.py b/temba/notifications/models.py index d644970eaf2..6ce84985c89 100644 --- a/temba/notifications/models.py +++ b/temba/notifications/models.py @@ -165,7 +165,7 @@ class Notification(models.Model): id = models.BigAutoField(primary_key=True) org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="notifications") - notification_type = models.CharField(max_length=16) + notification_type = models.CharField(max_length=32) medium = models.CharField(max_length=2) # The scope is what we maintain uniqueness of unseen notifications for within an org. For some notification types, @@ -182,6 +182,7 @@ class Notification(models.Model): export = models.ForeignKey(Export, null=True, on_delete=models.PROTECT, related_name="notifications") contact_import = models.ForeignKey(ContactImport, null=True, on_delete=models.PROTECT, related_name="notifications") incident = models.ForeignKey(Incident, null=True, on_delete=models.PROTECT, related_name="notifications") + data = models.JSONField(null=True, default=dict) @classmethod def create_all(cls, org, notification_type: str, *, scope: str, users, medium: str = MEDIUM_UI, **kwargs): diff --git a/temba/notifications/tests.py b/temba/notifications/tests.py index 47f6f15dc2f..a6effad8d65 100644 --- a/temba/notifications/tests.py +++ b/temba/notifications/tests.py @@ -7,14 +7,19 @@ from temba.contacts.models import ContactExport, ContactImport from temba.flows.models import ResultsExport from temba.msgs.models import MessageExport -from temba.orgs.models import OrgRole +from temba.orgs.models import Invitation, OrgRole from temba.tests import CRUDLTestMixin, TembaTest, matchers from temba.tickets.models import TicketExport from .incidents.builtin import ChannelTemplatesFailedIncidentType, OrgFlaggedIncidentType from .models import Incident, Notification from .tasks import send_notification_emails, squash_notification_counts, trim_notifications -from .types.builtin import ExportFinishedNotificationType, UserEmailNotificationType, UserPasswordNotificationType +from .types.builtin import ( + ExportFinishedNotificationType, + InvitationAcceptedNotificationType, + UserEmailNotificationType, + UserPasswordNotificationType, +) class IncidentTest(TembaTest): @@ -499,6 +504,31 @@ def test_user_password(self): self.assertEqual(["editor@nyaruka.com"], mail.outbox[0].recipients()) self.assertIn("Your password has been changed.", mail.outbox[0].body) + def test_invitation_accepted(self): + invitation = Invitation.objects.create( + org=self.org, email="bob@nyaruka.com", created_by=self.admin, modified_by=self.admin + ) + + InvitationAcceptedNotificationType.create(invitation) + + self.assert_notifications( + expected_json={ + "type": "invitation:accepted", + "created_on": matchers.ISODate(), + "target_url": None, + "is_seen": True, + }, + expected_users={self.admin}, + email=True, + ) + + send_notification_emails() + + self.assertEqual(1, len(mail.outbox)) + self.assertEqual("[Nyaruka] New user joined your workspace", mail.outbox[0].subject) + self.assertEqual(["admin@nyaruka.com"], mail.outbox[0].recipients()) # previous address + self.assertIn("User bob@nyaruka.com accepted an invitation to join your workspace.", mail.outbox[0].body) + def test_get_unseen_count(self): imp = ContactImport.objects.create( org=self.org, mappings={}, num_records=5, created_by=self.editor, modified_by=self.editor diff --git a/temba/notifications/types/__init__.py b/temba/notifications/types/__init__.py index d80c685fc4e..47f23478e67 100644 --- a/temba/notifications/types/__init__.py +++ b/temba/notifications/types/__init__.py @@ -2,6 +2,7 @@ ExportFinishedNotificationType, ImportFinishedNotificationType, IncidentStartedNotificationType, + InvitationAcceptedNotificationType, TicketActivityNotificationType, TicketsOpenedNotificationType, UserEmailNotificationType, @@ -25,6 +26,7 @@ def register_notification_type(typ): register_notification_type(ExportFinishedNotificationType()) register_notification_type(ImportFinishedNotificationType()) register_notification_type(IncidentStartedNotificationType()) +register_notification_type(InvitationAcceptedNotificationType()) register_notification_type(TicketsOpenedNotificationType()) register_notification_type(TicketActivityNotificationType()) register_notification_type(UserEmailNotificationType()) diff --git a/temba/notifications/types/builtin.py b/temba/notifications/types/builtin.py index 96cd5a42f0f..70b3eb1ca64 100644 --- a/temba/notifications/types/builtin.py +++ b/temba/notifications/types/builtin.py @@ -176,3 +176,36 @@ def get_email_subject(self, notification) -> str: def get_email_template(self, notification) -> str: return "notifications/email/user_password" + + +class InvitationAcceptedNotificationType(NotificationType): + """ + Notification that a user accepted an invitation to join the workspace. + """ + + slug = "invitation:accepted" + + @classmethod + def create(cls, invitation): + """ + Creates a user joined notification for all admins in the workspace. + """ + + Notification.create_all( + invitation.org, + cls.slug, + scope=str(invitation.id), + users=invitation.org.get_admins(), + medium=Notification.MEDIUM_EMAIL, + email_status=Notification.EMAIL_STATUS_PENDING, + data={"email": invitation.email}, + ) + + def get_target_url(self, notification) -> str: + pass + + def get_email_subject(self, notification) -> str: + return _("New user joined your workspace") + + def get_email_template(self, notification) -> str: + return "notifications/email/invitation_accepted" diff --git a/temba/orgs/migrations/0152_alter_invitation_user_group.py b/temba/orgs/migrations/0152_alter_invitation_user_group.py new file mode 100644 index 00000000000..1a69ec578c4 --- /dev/null +++ b/temba/orgs/migrations/0152_alter_invitation_user_group.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1 on 2024-10-07 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("orgs", "0151_alter_usersettings_avatar"), + ] + + operations = [ + migrations.AlterField( + model_name="invitation", + name="user_group", + field=models.CharField( + choices=[("A", "Administrator"), ("E", "Editor"), ("V", "Viewer"), ("T", "Agent")], + default="E", + max_length=1, + ), + ), + ] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 83c66bc7cbd..d371863edab 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1505,7 +1505,7 @@ class Invitation(SmartModel): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="invitations") email = models.EmailField() secret = models.CharField(max_length=64, unique=True) - user_group = models.CharField(max_length=1, choices=ROLE_CHOICES, default=OrgRole.VIEWER.code) + user_group = models.CharField(max_length=1, choices=ROLE_CHOICES, default=OrgRole.EDITOR.code) def save(self, *args, **kwargs): if not self.secret: @@ -1526,6 +1526,15 @@ def send(self): {"org": self.org, "invitation": self}, ) + def accept(self, user): + from temba.notifications.types.builtin import InvitationAcceptedNotificationType + + self.org.add_user(user, self.role) + + InvitationAcceptedNotificationType.create(self) + + self.release() + def release(self): self.is_active = False self.modified_on = timezone.now() diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 6a5edb2765b..db2af0d00cf 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -136,8 +136,10 @@ def test_model(self): self.assertEqual("RapidPro Invitation", mail.outbox[0].subject) self.assertIn(f"https://app.rapidpro.io/org/join/{invitation.secret}/", mail.outbox[0].body) - invitation.release() + user = User.create("invitededitor@nyaruka.com", "Bob", "", "Qwerty123", "en-US") + invitation.accept(user) + self.assertEqual(1, self.admin.notifications.count()) self.assertFalse(invitation.is_active) def test_expire_task(self): diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index fd80baff1f2..b0f74457818 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -2145,9 +2145,7 @@ def pre_process(self, request, *args, **kwargs): return None def save(self, obj): - obj.add_user(self.request.user, self.invitation.role) - - self.invitation.release() + self.invitation.accept(self.request.user) switch_to_org(self.request, obj) diff --git a/templates/notifications/email/invitation_accepted.html b/templates/notifications/email/invitation_accepted.html new file mode 100644 index 00000000000..c3b78cb84fd --- /dev/null +++ b/templates/notifications/email/invitation_accepted.html @@ -0,0 +1,10 @@ +{% extends "notifications/email/base.html" %} +{% load i18n %} + +{% block notification-body %} +

    + {% blocktrans with email=notification.data.email %} + User {{ email }} accepted an invitation to join your workspace. + {% endblocktrans %} +

    +{% endblock notification-body %} diff --git a/templates/notifications/email/invitation_accepted.txt b/templates/notifications/email/invitation_accepted.txt new file mode 100644 index 00000000000..42a3bf4bf87 --- /dev/null +++ b/templates/notifications/email/invitation_accepted.txt @@ -0,0 +1,6 @@ +{% extends "notifications/email/base.html" %} +{% load i18n %} + +{% block notification-body %} +{% blocktrans with email=notification.data.email %}User {{ email }} accepted an invitation to join your workspace.{% endblocktrans %} +{% endblock notification-body %} From 86e036012315c63cb8b9e39bfb83d8691d597088 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 8 Oct 2024 14:10:43 +0200 Subject: [PATCH 157/557] Use mailroom to trigger android channel sync --- temba/channels/android/sync.py | 79 --------------------------- temba/channels/models.py | 11 +--- temba/channels/tasks.py | 7 --- temba/channels/types/android/tests.py | 5 +- temba/mailroom/client/client.py | 3 + temba/mailroom/client/tests.py | 17 ++++++ temba/mailroom/tests.py | 4 +- temba/orgs/tests.py | 6 +- temba/settings_common.py | 4 -- temba/tests/mailroom.py | 3 + temba/triggers/tests.py | 4 +- 11 files changed, 39 insertions(+), 104 deletions(-) diff --git a/temba/channels/android/sync.py b/temba/channels/android/sync.py index fa16eb26d16..36d02c950d6 100644 --- a/temba/channels/android/sync.py +++ b/temba/channels/android/sync.py @@ -1,16 +1,7 @@ -import time from datetime import datetime, timezone as tzone -import google.auth.transport.requests -import requests -from google.oauth2 import service_account - -from django.conf import settings - from temba.msgs.models import Msg -from ..models import Channel - def get_sync_commands(msgs): """ @@ -53,76 +44,6 @@ def get_channel_commands(channel, commands, sync_event=None): return commands -def _get_access_token(): # pragma: no cover - """ - Retrieve a valid access token that can be used to authorize requests. - """ - credentials = service_account.Credentials.from_service_account_file( - settings.ANDROID_CREDENTIALS_FILE, scopes=["https://www.googleapis.com/auth/firebase.messaging"] - ) - request = google.auth.transport.requests.Request() - credentials.refresh(request) - return credentials.token - - -def validate_registration_info(registration_id): # pragma: no cover - valid_registration_ids = [] - - backoffs = [1, 3, 6] - while backoffs: - resp = requests.get( - f"https://iid.googleapis.com/iid/info/{registration_id}", - params={"details": "true"}, - headers={ - "Authorization": "Bearer " + _get_access_token(), - "access_token_auth": "true", - "Content-Type": "application/json", - }, - ) - - if resp.status_code == 200: - valid_registration_ids.append(registration_id) - break - else: - time.sleep(backoffs[0]) - backoffs = backoffs[1:] - - return valid_registration_ids - - -def sync_channel_fcm(registration_id, channel=None): # pragma: no cover - fcm_failed = False - try: - resp = requests.post( - f"https://fcm.googleapis.com/v1/projects/{settings.ANDROID_FCM_PROJECT_ID}/messages:send", - json={"message": {"token": registration_id, "data": {"msg": "sync"}}}, - headers={ - "Authorization": "Bearer " + _get_access_token(), - "Content-Type": "application/json", - }, - ) - - success = 0 - if resp.status_code == 200: - resp_json = resp.json() - success = resp_json.get("success", 0) - message_id = resp_json.get("message_id", None) - if message_id: - success = 1 - if not success: - fcm_failed = True - except requests.RequestException: - fcm_failed = True - - if fcm_failed: - valid_registration_ids = validate_registration_info(registration_id) - - if registration_id not in valid_registration_ids: - # this fcm id is invalid now, clear it out - channel.config.pop(Channel.CONFIG_FCM_ID, None) - channel.save(update_fields=["config"]) - - def update_message(msg, cmd): """ Updates a message according to the provided client command diff --git a/temba/channels/models.py b/temba/channels/models.py index 90e9c657457..01dc56cfa25 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -11,7 +11,6 @@ from smartmin.models import SmartModel from twilio.base.exceptions import TwilioRestException -from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import Q, Sum @@ -23,6 +22,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from temba import mailroom from temba.orgs.models import DependencyMixin, Org from temba.utils import analytics, dynamo, get_anonymous_user, on_transaction_commit, redact from temba.utils.models import ( @@ -674,14 +674,7 @@ def trigger_sync(self): # pragma: no cover """ assert self.is_android, "can only trigger syncs on Android channels" - - from .tasks import sync_channel_fcm_task - - # androids sync via FCM - fcm_id = self.config.get(Channel.CONFIG_FCM_ID) - - if fcm_id and settings.ANDROID_FCM_PROJECT_ID and settings.ANDROID_CREDENTIALS_FILE: - on_transaction_commit(lambda: sync_channel_fcm_task.delay(fcm_id, channel_id=self.id)) + mailroom.get_client().android_sync(self) def get_count(self, count_types, since=None): qs = ChannelCount.objects.filter(channel=self, count_type__in=count_types) diff --git a/temba/channels/tasks.py b/temba/channels/tasks.py index af29b521291..d8ceaea7aee 100644 --- a/temba/channels/tasks.py +++ b/temba/channels/tasks.py @@ -13,19 +13,12 @@ from temba.utils.crons import cron_task from temba.utils.models import delete_in_batches -from .android import sync from .models import Channel, ChannelCount, ChannelEvent, ChannelLog, SyncEvent from .types.android import AndroidType logger = logging.getLogger(__name__) -@shared_task -def sync_channel_fcm_task(cloud_registration_id, channel_id=None): # pragma: no cover - channel = Channel.objects.filter(id=channel_id).first() - sync.sync_channel_fcm(cloud_registration_id, channel) - - @cron_task() def check_android_channels(): from temba.notifications.incidents.builtin import ChannelDisconnectedIncidentType diff --git a/temba/channels/types/android/tests.py b/temba/channels/types/android/tests.py index 9e0c56ad121..eb28d3b28cb 100644 --- a/temba/channels/types/android/tests.py +++ b/temba/channels/types/android/tests.py @@ -5,13 +5,16 @@ from temba.contacts.models import URN from temba.orgs.models import Org from temba.tests import CRUDLTestMixin, TembaTest +from temba.tests.mailroom import mock_mailroom from temba.utils import get_anonymous_user from ...models import Channel class AndroidTypeTest(TembaTest, CRUDLTestMixin): - def test_claim(self): + + @mock_mailroom + def test_claim(self, mr_mocks): # remove our explicit country so it needs to be derived from channels self.org.country = None self.org.timezone = "UTC" diff --git a/temba/mailroom/client/client.py b/temba/mailroom/client/client.py index 45a73a1aba0..6341611380a 100644 --- a/temba/mailroom/client/client.py +++ b/temba/mailroom/client/client.py @@ -67,6 +67,9 @@ def android_message(self, org, channel, phone: str, text: str, received_on): }, ) + def android_sync(self, channel): + return self._request("android/sync", {"channel_id": channel.id}) + def contact_create(self, org, user, contact: ContactSpec) -> Contact: resp = self._request("contact/create", {"org_id": org.id, "user_id": user.id, "contact": asdict(contact)}) diff --git a/temba/mailroom/client/tests.py b/temba/mailroom/client/tests.py index dc11cb39a2d..6f0c95e3092 100644 --- a/temba/mailroom/client/tests.py +++ b/temba/mailroom/client/tests.py @@ -80,6 +80,23 @@ def test_android_message(self, mock_post): }, ) + @patch("requests.post") + def test_android_sync(self, mock_post): + mock_post.return_value = MockJsonResponse(200, {"id": 12345}) + response = self.client.android_sync( + channel=self.channel, + ) + + self.assertEqual({"id": 12345}, response) + + mock_post.assert_called_once_with( + "http://localhost:8090/mr/android/sync", + headers={"User-Agent": "Temba", "Authorization": "Token sesame"}, + json={ + "channel_id": self.channel.id, + }, + ) + @patch("requests.post") def test_contact_create(self, mock_post): ann = self.create_contact("Ann", urns=["tel:+12340000001"]) diff --git a/temba/mailroom/tests.py b/temba/mailroom/tests.py index 1bd72b987ac..49dae8faf21 100644 --- a/temba/mailroom/tests.py +++ b/temba/mailroom/tests.py @@ -1,4 +1,5 @@ from datetime import timedelta +from unittest.mock import patch from django_redis import get_redis_connection @@ -71,7 +72,8 @@ def test_queue_contact_import_batch(self): }, ) - def test_queue_interrupt_channel(self): + @patch("temba.channels.models.Channel.trigger_sync") + def test_queue_interrupt_channel(self, mock_trigger_sync): self.channel.release(self.admin) self.assert_org_queued(self.org) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 6a5edb2765b..63fa87d9d83 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -668,7 +668,8 @@ def test_ui_management(self): self.editor.refresh_from_db() self.assertFalse(self.editor.is_active) - def test_release(self): + @mock_mailroom + def test_release(self, mr_mocks): token = APIToken.create(self.org, self.admin) # admin doesn't "own" any orgs @@ -3381,7 +3382,8 @@ def test_update(self): self.assertEqual("", self.editor.last_name) self.assertEqual({alphas}, set(self.editor.groups.all())) - def test_delete(self): + @mock_mailroom + def test_delete(self, mr_mocks): delete_url = reverse("orgs.user_delete", args=[self.editor.id]) # this is a customer support only view diff --git a/temba/settings_common.py b/temba/settings_common.py index 5c7d5638d38..bae2d857ffd 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -1006,7 +1006,3 @@ # # You need to change these to real addresses to work with these. IP_ADDRESSES = ("172.16.10.10", "162.16.10.20") - -# Android clients FCM config -ANDROID_FCM_PROJECT_ID = os.environ.get("ANDROID_FCM_PROJECT_ID", "") -ANDROID_CREDENTIALS_FILE = os.environ.get("ANDROID_CREDENTIALS_FILE", "") diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 2949143b686..e11029071ef 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -187,6 +187,9 @@ def android_message(self, org, channel, phone: str, text: str, received_on): ) return {"id": msg.id, "duplicate": False} + def android_sync(self, channel): + return {"id": channel.id} + @_client_method def contact_create(self, org, user, contact: mailroom.ContactSpec): status = {v: k for k, v in Contact.ENGINE_STATUSES.items()}[contact.status] diff --git a/temba/triggers/tests.py b/temba/triggers/tests.py index a353a33412f..ada5bbba91b 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.tests.mailroom import mock_mailroom from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .models import Trigger @@ -567,7 +568,8 @@ def test_menu(self): # the archived trigger not counted self.assertPageMenu(menu_url, self.user, ["Active (1)", "Archived (1)", "Messages (1)"]) - def test_create(self): + @mock_mailroom + def test_create(self, mr_mocks): create_url = reverse("triggers.trigger_create") create_new_convo_url = reverse("triggers.trigger_create_new_conversation") create_inbound_call_url = reverse("triggers.trigger_create_inbound_call") From 70008329096b774f92b542ebd415c55edbda6208 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 09:33:01 -0500 Subject: [PATCH 158/557] Update CHANGELOG.md for v9.3.58 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c0ea6f96b..a3d6e179541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.58 (2024-10-08) +------------------------- + * Use mailroom to trigger android channel sync + * Add new notification type for when an invitation to join a workspace is accepted + * More refactoring of modal views + v9.3.57 (2024-10-04) ------------------------- * More view refactoring diff --git a/pyproject.toml b/pyproject.toml index 7676a57fbb8..c881400b62a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.57" +version = "9.3.58" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index f1ee191b625..e9d55aad0e6 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.57" +__version__ = "9.3.58" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From bf226096caef53eb6ddf4f1769b8f4c38e0ea531 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 15:09:44 +0000 Subject: [PATCH 159/557] Fix not creating invitation accepted notifications in case of new user signup --- temba/orgs/tests.py | 6 ++++++ temba/orgs/views/views.py | 4 +--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 047c4da1c45..52b7bb5772d 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2169,6 +2169,9 @@ def test_join_signup(self): invitation.refresh_from_db() self.assertFalse(invitation.is_active) + self.assertEqual(1, self.admin.notifications.filter(notification_type="invitation:accepted").count()) + self.assertEqual(2, self.org.get_users(roles=[OrgRole.EDITOR]).count()) + def test_join_accept(self): # only authenticated users can access page response = self.client.get(reverse("orgs.org_join_accept", args=["invalid"])) @@ -2211,6 +2214,9 @@ def test_join_accept(self): invitation.refresh_from_db() self.assertFalse(invitation.is_active) + self.assertEqual(1, self.admin.notifications.filter(notification_type="invitation:accepted").count()) + self.assertEqual(2, self.org.get_users(roles=[OrgRole.EDITOR]).count()) + def test_org_grant(self): grant_url = reverse("orgs.org_grant") response = self.client.get(grant_url) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index b0f74457818..c3706ef9ea7 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -2110,9 +2110,7 @@ def save(self, obj): user = authenticate(username=user.username, password=self.form.cleaned_data["password"]) login(self.request, user) - obj.add_user(user, self.invitation.role) - - self.invitation.release() + self.invitation.accept(user) class JoinAccept(NoNavMixin, InvitationMixin, SmartUpdateView): """ From 849db40bc29cc5c07f83cb91c21b07f8e92eedb2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 10:23:49 -0500 Subject: [PATCH 160/557] Update CHANGELOG.md for v9.3.59 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d6e179541..ee511e8550c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.59 (2024-10-08) +------------------------- + * Fix not creating invitation accepted notifications in case of new user signup + v9.3.58 (2024-10-08) ------------------------- * Use mailroom to trigger android channel sync diff --git a/pyproject.toml b/pyproject.toml index c881400b62a..cc5c8855bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.58" +version = "9.3.59" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index e9d55aad0e6..b9664096106 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.58" +__version__ = "9.3.59" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From d6925756f2667b4959224925c5a01bb4bc427e1f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 11:35:57 -0500 Subject: [PATCH 161/557] Improve invitation emails --- templates/orgs/email/invitation_email.html | 13 ++++++++----- templates/orgs/email/invitation_email.txt | 9 +++++---- templates/orgs/email/smtp_test.html | 12 ++++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/templates/orgs/email/invitation_email.html b/templates/orgs/email/invitation_email.html index 915d842333a..45467568707 100644 --- a/templates/orgs/email/invitation_email.html +++ b/templates/orgs/email/invitation_email.html @@ -1,12 +1,15 @@ {% extends "email_base.html" %} -{% load tz i18n %} +{% load i18n %} {% block body %}

    - {% blocktrans trimmed with org=org.name domain=branding.domain brand=branding.name secret=invitation.secret %} - You've been invited to join {{ brand }} as a member of {{ org }}. -
    - To accept the invitation, click here. + {% blocktrans trimmed with org=org.name %} + You've been invited to join the {{ org }} workspace. + {% endblocktrans %} +

    +

    + {% blocktrans trimmed with domain=branding.domain secret=invitation.secret %} + Click here to accept the invitation. {% endblocktrans %}

    {% endblock body %} diff --git a/templates/orgs/email/invitation_email.txt b/templates/orgs/email/invitation_email.txt index f63709270f9..942490ca029 100644 --- a/templates/orgs/email/invitation_email.txt +++ b/templates/orgs/email/invitation_email.txt @@ -1,7 +1,8 @@ {% load i18n %} -{% blocktrans trimmed with org=org.name brand=branding.name %} -You've been invited to join {{ org }} on {{ brand }} +{% blocktrans trimmed with org=org.name %} +You've been invited to join the {{ org }} workspace. +{% endblocktrans %} +{% blocktrans trimmed with domain=branding.domain secret=invitation.secret %} +Go to https://{{ domain }}/org/join/{{ secret }}/ to accept the invitation. {% endblocktrans %} - -{% trans "Click this link to join" %}: https://{{ branding.domain }}/org/join/{{ invitation.secret }}/ diff --git a/templates/orgs/email/smtp_test.html b/templates/orgs/email/smtp_test.html index 6fabe5f4f5a..4fabb6efccd 100644 --- a/templates/orgs/email/smtp_test.html +++ b/templates/orgs/email/smtp_test.html @@ -1,10 +1,10 @@ {% extends "email_base.html" %} -{% load tz i18n %} +{% load i18n %} {% block body %} -

    - {% blocktrans trimmed %} - This email is a test to confirm the custom SMTP server configuration added to your account. - {% endblocktrans %} -

    +

    + {% blocktrans trimmed %} + This email is a test to confirm the custom SMTP server configuration added to your account. + {% endblocktrans %} +

    {% endblock body %} From 14a93c050a3908248268e3e0df0bbf68f2c0e137 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 11:49:46 -0500 Subject: [PATCH 162/557] Update CHANGELOG.md for v9.3.60 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee511e8550c..3b8f25090be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.60 (2024-10-08) +------------------------- + * Improve invitation emails + v9.3.59 (2024-10-08) ------------------------- * Fix not creating invitation accepted notifications in case of new user signup diff --git a/pyproject.toml b/pyproject.toml index cc5c8855bd5..50d4af0d746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.59" +version = "9.3.60" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index b9664096106..771a070c817 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.59" +__version__ = "9.3.60" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 0593057e9fad8a4b2cae213e69f04119356f096c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 18:20:40 +0000 Subject: [PATCH 163/557] Move staff only user views to new staff app --- temba/orgs/tests.py | 155 --------------------- temba/orgs/views/views.py | 125 +---------------- temba/settings_common.py | 1 + temba/staff/__init__.py | 0 temba/staff/tests.py | 126 +++++++++++++++++ temba/staff/urls.py | 3 + temba/staff/views.py | 126 +++++++++++++++++ temba/urls.py | 1 + templates/orgs/org_read.html | 2 +- templates/{orgs => staff}/user_delete.html | 0 templates/{orgs => staff}/user_list.html | 0 templates/{orgs => staff}/user_read.html | 0 12 files changed, 264 insertions(+), 275 deletions(-) create mode 100644 temba/staff/__init__.py create mode 100644 temba/staff/tests.py create mode 100644 temba/staff/urls.py create mode 100644 temba/staff/views.py rename templates/{orgs => staff}/user_delete.html (100%) rename templates/{orgs => staff}/user_list.html (100%) rename templates/{orgs => staff}/user_read.html (100%) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 52b7bb5772d..f014eaf2dc2 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -633,43 +633,6 @@ def test_confirm_access(self): response = self.client.post(confirm_url, {"password": "Qwerty123"}) self.assertRedirect(response, "/msg/") - def test_ui_permissions(self): - # non-logged in users can't go here - response = self.client.get(reverse("orgs.user_list")) - self.assertRedirect(response, "/users/login/") - response = self.client.post(reverse("orgs.user_delete", args=(self.editor.pk,)), dict(delete=True)) - self.assertRedirect(response, "/users/login/") - - # either can admins - self.login(self.admin) - response = self.client.get(reverse("orgs.user_list")) - self.assertRedirect(response, "/users/login/") - response = self.client.post(reverse("orgs.user_delete", args=(self.editor.pk,)), dict(delete=True)) - self.assertRedirect(response, "/users/login/") - - self.editor.refresh_from_db() - self.assertTrue(self.editor.is_active) - - def test_ui_management(self): - # only customer support gets in on this sweet action - self.login(self.customer_support) - - # one of our users should belong to a bunch of orgs - for i in range(5): - org = Org.objects.create( - name=f"Org {i}", timezone=ZoneInfo("Africa/Kigali"), created_by=self.user, modified_by=self.user - ) - org.add_user(self.admin, OrgRole.ADMINISTRATOR) - - response = self.client.get(reverse("orgs.user_list")) - self.assertEqual(200, response.status_code) - - response = self.client.post(reverse("orgs.user_delete", args=(self.editor.pk,)), {"delete": True}) - self.assertEqual(reverse("orgs.user_list"), response["Temba-Success"]) - - self.editor.refresh_from_db() - self.assertFalse(self.editor.is_active) - @mock_mailroom def test_release(self, mr_mocks): token = APIToken.create(self.org, self.admin) @@ -3301,124 +3264,6 @@ def test_languages(self): class UserCRUDLTest(TembaTest, CRUDLTestMixin): - def test_list(self): - list_url = reverse("orgs.user_list") - - self.assertStaffOnly(list_url) - - response = self.requestView(list_url, self.customer_support) - self.assertEqual(8, 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"])) - - response = self.requestView(list_url + "?filter=staff", self.customer_support) - self.assertEqual({self.customer_support, self.superuser}, set(response.context["object_list"])) - - response = self.requestView(list_url + "?search=admin@nyaruka.com", self.customer_support) - self.assertEqual({self.admin}, set(response.context["object_list"])) - - response = self.requestView(list_url + "?search=admin@nyaruka.com", self.customer_support) - self.assertEqual({self.admin}, set(response.context["object_list"])) - - response = self.requestView(list_url + "?search=Andy", self.customer_support) - self.assertEqual({self.admin}, set(response.context["object_list"])) - - def test_read(self): - read_url = reverse("orgs.user_read", args=[self.editor.id]) - - # this is a customer support only view - self.assertStaffOnly(read_url) - - response = self.requestView(read_url, self.customer_support) - self.assertEqual(200, response.status_code) - - def test_update(self): - update_url = reverse("orgs.user_update", args=[self.editor.id]) - - # this is a customer support only view - self.assertStaffOnly(update_url) - - response = self.requestView(update_url, self.customer_support) - self.assertEqual(200, response.status_code) - - alphas = Group.objects.get(name="Alpha") - betas = Group.objects.get(name="Beta") - current_password = self.editor.password - - # submit without new password - response = self.requestView( - update_url, - self.customer_support, - post_data={ - "email": "eddy@nyaruka.com", - "first_name": "Edward", - "last_name": "", - "groups": [alphas.id, betas.id], - }, - ) - self.assertEqual(302, response.status_code) - - self.editor.refresh_from_db() - self.assertEqual("eddy@nyaruka.com", self.editor.email) - self.assertEqual("eddy@nyaruka.com", self.editor.username) # should match email - self.assertEqual(current_password, self.editor.password) - self.assertEqual("Edward", self.editor.first_name) - self.assertEqual("", self.editor.last_name) - self.assertEqual({alphas, betas}, set(self.editor.groups.all())) - - # submit with new password and one less group - response = self.requestView( - update_url, - self.customer_support, - post_data={ - "email": "eddy@nyaruka.com", - "new_password": "Asdf1234", - "first_name": "Edward", - "last_name": "", - "groups": [alphas.id], - }, - ) - self.assertEqual(302, response.status_code) - - self.editor.refresh_from_db() - self.assertEqual("eddy@nyaruka.com", self.editor.email) - self.assertEqual("eddy@nyaruka.com", self.editor.username) - self.assertNotEqual(current_password, self.editor.password) - self.assertEqual("Edward", self.editor.first_name) - self.assertEqual("", self.editor.last_name) - self.assertEqual({alphas}, set(self.editor.groups.all())) - - @mock_mailroom - def test_delete(self, mr_mocks): - delete_url = reverse("orgs.user_delete", args=[self.editor.id]) - - # this is a customer support only view - self.assertStaffOnly(delete_url) - - response = self.requestView(delete_url, self.customer_support) - self.assertEqual(200, response.status_code) - self.assertNotContains(response, "Nyaruka") # editor doesn't own this org - - # make editor the owner of the org - OrgMembership.objects.filter(org=self.org, role_code=OrgRole.ADMINISTRATOR.code).delete() - OrgMembership.objects.filter(org=self.org, role_code=OrgRole.VIEWER.code).delete() - OrgMembership.objects.filter(org=self.org, role_code=OrgRole.AGENT.code).delete() - - response = self.requestView(delete_url, self.customer_support) - self.assertEqual(200, response.status_code) - self.assertContains(response, "Nyaruka") - - response = self.requestView(delete_url, self.customer_support, post_data={}) - self.assertEqual(reverse("orgs.user_list"), response["Temba-Success"]) - - self.editor.refresh_from_db() - self.assertFalse(self.editor.is_active) - - self.org.refresh_from_db() - self.assertFalse(self.org.is_active) - def test_account(self): self.login(self.agent) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index c3706ef9ea7..4dfa6b568b9 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -6,8 +6,8 @@ import pyotp from django_redis import get_redis_connection from packaging.version import Version -from smartmin.users.models import FailedLogin, PasswordHistory, RecoveryToken -from smartmin.users.views import Login, UserUpdateForm +from smartmin.users.models import FailedLogin, RecoveryToken +from smartmin.users.views import Login from smartmin.views import ( SmartCreateView, SmartCRUDL, @@ -23,13 +23,12 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, login, logout, update_session_auth_hash -from django.contrib.auth.models import Group from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView as AuthLoginView from django.core.exceptions import ValidationError from django.db.models.functions import Lower from django.forms import ModelChoiceField -from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse +from django.http import Http404, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, resolve_url from django.urls import reverse, reverse_lazy from django.utils import timezone @@ -44,7 +43,7 @@ from temba.formax import FormaxMixin from temba.notifications.mixins import NotificationTargetMixin from temba.orgs.tasks import send_user_verification_email -from temba.utils import analytics, get_anonymous_user, json, languages, on_transaction_commit, str_to_bool +from temba.utils import analytics, json, languages, on_transaction_commit, str_to_bool from temba.utils.email import EmailSender, parse_smtp_url from temba.utils.fields import ( ArbitraryJsonChoiceField, @@ -340,11 +339,7 @@ def get_object(self, *args, **kwargs): class UserCRUDL(SmartCRUDL): model = User actions = ( - "list", - "update", "edit", - "delete", - "read", "forget", "recover", "two_factor_enable", @@ -356,114 +351,6 @@ class UserCRUDL(SmartCRUDL): "send_verification_email", ) - class Read(StaffOnlyMixin, ContextMenuMixin, SpaMixin, SmartReadView): - fields = ("email", "date_joined") - menu_path = "/staff/users/all" - - def build_context_menu(self, menu): - obj = self.get_object() - menu.add_modax( - _("Edit"), - "user-update", - reverse("orgs.user_update", args=[obj.id]), - title=_("Edit User"), - as_button=True, - ) - - menu.add_modax( - _("Delete"), - "user-delete", - reverse("orgs.user_delete", args=[obj.id]), - title=_("Delete User"), - ) - - class List(StaffOnlyMixin, SpaMixin, SmartListView): - fields = ("email", "name", "date_joined") - ordering = ("-date_joined",) - 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) - - def derive_queryset(self, **kwargs): - qs = super().derive_queryset(**kwargs).filter(is_active=True).exclude(id=get_anonymous_user().id) - obj_filter = self.request.GET.get("filter") - if obj_filter == "beta": - qs = qs.filter(groups__name="Beta") - elif obj_filter == "staff": - qs = qs.filter(is_staff=True) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["filter"] = self.request.GET.get("filter", "all") - context["filters"] = self.filters - return context - - class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, ContextMenuMixin, SmartUpdateView): - class Form(UserUpdateForm): - groups = forms.ModelMultipleChoiceField( - widget=SelectMultipleWidget( - attrs={"placeholder": _("Optional: Select permissions groups."), "searchable": True} - ), - queryset=Group.objects.all(), - required=False, - ) - - class Meta: - model = User - fields = ("email", "new_password", "first_name", "last_name", "groups") - help_texts = {"new_password": _("You can reset the user's password by entering a new password here")} - - form_class = Form - success_message = "User updated successfully." - title = "Update User" - - def pre_save(self, obj): - obj.username = obj.email - return obj - - def post_save(self, obj): - """ - Make sure our groups are up-to-date - """ - if "groups" in self.form.cleaned_data: - obj.groups.clear() - for group in self.form.cleaned_data["groups"]: - obj.groups.add(group) - - # if a new password was set, reset our failed logins - if "new_password" in self.form.cleaned_data and self.form.cleaned_data["new_password"]: - FailedLogin.objects.filter(username__iexact=self.object.username).delete() - PasswordHistory.objects.create(user=obj, password=obj.password) - - return obj - - class Delete(StaffOnlyMixin, ModalFormMixin, SmartDeleteView): - fields = ("id",) - permission = "orgs.user_update" - submit_button_name = _("Delete") - cancel_url = "@orgs.user_list" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["owned_orgs"] = self.get_object().get_owned_orgs() - return context - - def post(self, request, *args, **kwargs): - user = self.get_object() - user.release(self.request.user) - - messages.info(request, self.derive_success_message()) - response = HttpResponse() - response["Temba-Success"] = reverse("orgs.user_list") - return response - class Forget(SmartFormView): class Form(forms.Form): email = forms.EmailField( @@ -1146,7 +1033,7 @@ def derive_menu(self): menu_id="users", name=_("Users"), icon="users", - href=reverse("orgs.user_list"), + href=reverse("staff.user_list"), ), ] @@ -1558,7 +1445,7 @@ def get_context_data(self, **kwargs): def lookup_field_link(self, context, field, obj): if field == "owner": owner = obj.get_owner() - return reverse("orgs.user_update", args=[owner.pk]) + return reverse("staff.user_update", args=[owner.pk]) return super().lookup_field_link(context, field, obj) class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, SmartUpdateView): diff --git a/temba/settings_common.py b/temba/settings_common.py index bae2d857ffd..85b40dc56db 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -279,6 +279,7 @@ "temba.locations", "temba.airtime", "temba.sql", + "temba.staff", ) # don't let smartmin auto create django messages for create and update submissions diff --git a/temba/staff/__init__.py b/temba/staff/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/temba/staff/tests.py b/temba/staff/tests.py new file mode 100644 index 00000000000..40b030b5e4e --- /dev/null +++ b/temba/staff/tests.py @@ -0,0 +1,126 @@ +from django.contrib.auth.models import Group +from django.urls import reverse + +from temba.orgs.models import OrgMembership, OrgRole +from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom +from temba.utils.views.mixins import TEMBA_MENU_SELECTION + + +class UserCRUDLTest(TembaTest, CRUDLTestMixin): + def test_list(self): + list_url = reverse("staff.user_list") + + self.assertStaffOnly(list_url) + + response = self.requestView(list_url, self.customer_support) + self.assertEqual(8, 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"])) + + response = self.requestView(list_url + "?filter=staff", self.customer_support) + self.assertEqual({self.customer_support, self.superuser}, set(response.context["object_list"])) + + response = self.requestView(list_url + "?search=admin@nyaruka.com", self.customer_support) + self.assertEqual({self.admin}, set(response.context["object_list"])) + + response = self.requestView(list_url + "?search=admin@nyaruka.com", self.customer_support) + self.assertEqual({self.admin}, set(response.context["object_list"])) + + response = self.requestView(list_url + "?search=Andy", self.customer_support) + self.assertEqual({self.admin}, set(response.context["object_list"])) + + def test_read(self): + read_url = reverse("staff.user_read", args=[self.editor.id]) + + # this is a customer support only view + self.assertStaffOnly(read_url) + + response = self.requestView(read_url, self.customer_support) + self.assertEqual(200, response.status_code) + + def test_update(self): + update_url = reverse("staff.user_update", args=[self.editor.id]) + + # this is a customer support only view + self.assertStaffOnly(update_url) + + response = self.requestView(update_url, self.customer_support) + self.assertEqual(200, response.status_code) + + alphas = Group.objects.get(name="Alpha") + betas = Group.objects.get(name="Beta") + current_password = self.editor.password + + # submit without new password + response = self.requestView( + update_url, + self.customer_support, + post_data={ + "email": "eddy@nyaruka.com", + "first_name": "Edward", + "last_name": "", + "groups": [alphas.id, betas.id], + }, + ) + self.assertEqual(302, response.status_code) + + self.editor.refresh_from_db() + self.assertEqual("eddy@nyaruka.com", self.editor.email) + self.assertEqual("eddy@nyaruka.com", self.editor.username) # should match email + self.assertEqual(current_password, self.editor.password) + self.assertEqual("Edward", self.editor.first_name) + self.assertEqual("", self.editor.last_name) + self.assertEqual({alphas, betas}, set(self.editor.groups.all())) + + # submit with new password and one less group + response = self.requestView( + update_url, + self.customer_support, + post_data={ + "email": "eddy@nyaruka.com", + "new_password": "Asdf1234", + "first_name": "Edward", + "last_name": "", + "groups": [alphas.id], + }, + ) + self.assertEqual(302, response.status_code) + + self.editor.refresh_from_db() + self.assertEqual("eddy@nyaruka.com", self.editor.email) + self.assertEqual("eddy@nyaruka.com", self.editor.username) + self.assertNotEqual(current_password, self.editor.password) + self.assertEqual("Edward", self.editor.first_name) + self.assertEqual("", self.editor.last_name) + self.assertEqual({alphas}, set(self.editor.groups.all())) + + @mock_mailroom + def test_delete(self, mr_mocks): + delete_url = reverse("staff.user_delete", args=[self.editor.id]) + + # this is a customer support only view + self.assertStaffOnly(delete_url) + + response = self.requestView(delete_url, self.customer_support) + self.assertEqual(200, response.status_code) + self.assertNotContains(response, "Nyaruka") # editor doesn't own this org + + # make editor the owner of the org + OrgMembership.objects.filter(org=self.org, role_code=OrgRole.ADMINISTRATOR.code).delete() + OrgMembership.objects.filter(org=self.org, role_code=OrgRole.VIEWER.code).delete() + OrgMembership.objects.filter(org=self.org, role_code=OrgRole.AGENT.code).delete() + + response = self.requestView(delete_url, self.customer_support) + self.assertEqual(200, response.status_code) + self.assertContains(response, "Nyaruka") + + response = self.requestView(delete_url, self.customer_support, post_data={}) + self.assertEqual(reverse("staff.user_list"), response["Temba-Success"]) + + self.editor.refresh_from_db() + self.assertFalse(self.editor.is_active) + + self.org.refresh_from_db() + self.assertFalse(self.org.is_active) diff --git a/temba/staff/urls.py b/temba/staff/urls.py new file mode 100644 index 00000000000..e3bccf9e60d --- /dev/null +++ b/temba/staff/urls.py @@ -0,0 +1,3 @@ +from .views import UserCRUDL + +urlpatterns = UserCRUDL().as_urlpatterns() diff --git a/temba/staff/views.py b/temba/staff/views.py new file mode 100644 index 00000000000..539a2797faa --- /dev/null +++ b/temba/staff/views.py @@ -0,0 +1,126 @@ +from smartmin.users.models import FailedLogin, PasswordHistory +from smartmin.users.views import UserUpdateForm +from smartmin.views import SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView + +from django import forms +from django.contrib import messages +from django.contrib.auth.models import Group +from django.http import HttpResponse +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt + +from temba.orgs.models import User +from temba.utils import get_anonymous_user +from temba.utils.fields import SelectMultipleWidget +from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin, StaffOnlyMixin + + +class UserCRUDL(SmartCRUDL): + model = User + actions = ("read", "update", "delete", "list") + + class Read(StaffOnlyMixin, ContextMenuMixin, SpaMixin, SmartReadView): + fields = ("email", "date_joined") + menu_path = "/staff/users/all" + + def build_context_menu(self, menu): + obj = self.get_object() + menu.add_modax( + _("Edit"), + "user-update", + reverse("staff.user_update", args=[obj.id]), + title=_("Edit User"), + as_button=True, + ) + + menu.add_modax( + _("Delete"), "user-delete", reverse("staff.user_delete", args=[obj.id]), title=_("Delete User") + ) + + class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, ContextMenuMixin, SmartUpdateView): + class Form(UserUpdateForm): + groups = forms.ModelMultipleChoiceField( + widget=SelectMultipleWidget( + attrs={"placeholder": _("Optional: Select permissions groups."), "searchable": True} + ), + queryset=Group.objects.all(), + required=False, + ) + + class Meta: + model = User + fields = ("email", "new_password", "first_name", "last_name", "groups") + help_texts = {"new_password": _("You can reset the user's password by entering a new password here")} + + form_class = Form + success_message = "User updated successfully." + title = "Update User" + + def pre_save(self, obj): + obj.username = obj.email + return obj + + def post_save(self, obj): + """ + Make sure our groups are up-to-date + """ + if "groups" in self.form.cleaned_data: + obj.groups.clear() + for group in self.form.cleaned_data["groups"]: + obj.groups.add(group) + + # if a new password was set, reset our failed logins + if "new_password" in self.form.cleaned_data and self.form.cleaned_data["new_password"]: + FailedLogin.objects.filter(username__iexact=self.object.username).delete() + PasswordHistory.objects.create(user=obj, password=obj.password) + + return obj + + class Delete(StaffOnlyMixin, ModalFormMixin, SmartDeleteView): + fields = ("id",) + permission = "staff.user_update" + submit_button_name = _("Delete") + cancel_url = "@staff.user_list" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["owned_orgs"] = self.get_object().get_owned_orgs() + return context + + def post(self, request, *args, **kwargs): + user = self.get_object() + user.release(self.request.user) + + messages.info(request, self.derive_success_message()) + response = HttpResponse() + response["Temba-Success"] = reverse("staff.user_list") + return response + + class List(StaffOnlyMixin, SpaMixin, SmartListView): + fields = ("email", "name", "date_joined") + ordering = ("-date_joined",) + 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) + + def derive_queryset(self, **kwargs): + qs = super().derive_queryset(**kwargs).filter(is_active=True).exclude(id=get_anonymous_user().id) + obj_filter = self.request.GET.get("filter") + if obj_filter == "beta": + qs = qs.filter(groups__name="Beta") + elif obj_filter == "staff": + qs = qs.filter(is_staff=True) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["filter"] = self.request.GET.get("filter", "all") + context["filters"] = self.filters + return context diff --git a/temba/urls.py b/temba/urls.py index 7509e49bea2..7a2ad15b3c1 100644 --- a/temba/urls.py +++ b/temba/urls.py @@ -37,6 +37,7 @@ re_path(r"^", include("temba.tickets.urls")), re_path(r"^", include("temba.triggers.urls")), re_path(r"^", include("temba.orgs.urls")), + re_path(r"^staff/", include("temba.staff.urls")), re_path(r"^relayers/relayer/sync/(\d+)/$", sync, {}, "sync"), re_path(r"^relayers/relayer/register/$", register, {}, "register"), re_path(r"^imports/", include("smartmin.csv_imports.urls")), diff --git a/templates/orgs/org_read.html b/templates/orgs/org_read.html index 6fa2eb3fcbe..001975b8ca6 100644 --- a/templates/orgs/org_read.html +++ b/templates/orgs/org_read.html @@ -37,7 +37,7 @@
    {% for user in user_role.users %} {% endfor %}
    diff --git a/templates/orgs/user_delete.html b/templates/staff/user_delete.html similarity index 100% rename from templates/orgs/user_delete.html rename to templates/staff/user_delete.html diff --git a/templates/orgs/user_list.html b/templates/staff/user_list.html similarity index 100% rename from templates/orgs/user_list.html rename to templates/staff/user_list.html diff --git a/templates/orgs/user_read.html b/templates/staff/user_read.html similarity index 100% rename from templates/orgs/user_read.html rename to templates/staff/user_read.html From d3dc1fad8af725ad6e06261415444643f5d9385f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 19:11:18 +0000 Subject: [PATCH 164/557] Move org read, update and list to staff app --- temba/orgs/tests.py | 138 +---------- temba/orgs/views/views.py | 218 +----------------- temba/staff/tests.py | 133 ++++++++++- temba/staff/urls.py | 5 +- temba/staff/views.py | 215 ++++++++++++++++- .../org_manage.html => staff/org_list.html} | 0 templates/{orgs => staff}/org_read.html | 0 templates/{orgs => staff}/org_update.html | 0 templates/staff/user_read.html | 2 +- 9 files changed, 358 insertions(+), 353 deletions(-) rename templates/{orgs/org_manage.html => staff/org_list.html} (100%) rename templates/{orgs => staff}/org_read.html (100%) rename templates/{orgs => staff}/org_update.html (100%) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index f014eaf2dc2..470cf22f8a8 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -48,7 +48,6 @@ from temba.triggers.models import Trigger from temba.utils import json, languages from temba.utils.uuid import uuid4 -from temba.utils.views.mixins import TEMBA_MENU_SELECTION from .context_processors import RolePermsWrapper from .models import ( @@ -1822,34 +1821,6 @@ def test_menu(self): menu = self.client.get(menu_url).json()["results"] self.assertEqual("tomato", menu[8]["bubble"]) - def test_read(self): - read_url = reverse("orgs.org_read", args=[self.org.id]) - - # make our second org a child - self.org2.parent = self.org - self.org2.save() - - response = self.assertStaffOnly(read_url) - - # we should have a child in our context - self.assertEqual(1, len(response.context["children"])) - - # we should have options to flag and suspend - self.assertContentMenu(read_url, self.customer_support, ["Edit", "Flag", "Suspend", "Verify", "-", "Service"]) - - # flag and content menu option should be inverted - self.org.flag() - self.org.suspend() - - self.assertContentMenu( - read_url, self.customer_support, ["Edit", "Unflag", "Unsuspend", "Verify", "-", "Service"] - ) - - # no menu for inactive orgs - self.org.is_active = False - self.org.save() - self.assertContentMenu(read_url, self.customer_support, []) - def test_workspace(self): workspace_url = reverse("orgs.org_workspace") @@ -2832,7 +2803,7 @@ def test_start(self): self.assertRedirect(self.requestView(start_url, self.agent), "/ticket/") # now try as customer support - self.assertRedirect(self.requestView(start_url, self.customer_support), "/org/manage/") + self.assertRedirect(self.requestView(start_url, self.customer_support), "/staff/org/") # if org isn't set, we redirect instead to choose view self.client.logout() @@ -2867,7 +2838,7 @@ def test_choose(self): self.assertContains(response, "No workspaces for this account, please contact your administrator.") # unless they are staff - self.assertRedirect(self.requestView(choose_url, self.customer_support), "/org/manage/") + self.assertRedirect(self.requestView(choose_url, self.customer_support), "/staff/org/") # turn editor into a multi-org user self.org2.add_user(self.editor, OrgRole.EDITOR) @@ -2958,107 +2929,6 @@ def test_delete_child(self): child.refresh_from_db() self.assertFalse(child.is_active) - def test_administration(self): - self.setUpLocations() - - manage_url = reverse("orgs.org_manage") - update_url = reverse("orgs.org_update", args=[self.org.id]) - - self.assertStaffOnly(manage_url) - self.assertStaffOnly(update_url) - - 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]) - assertOrgFilter("?filter=all", [self.org2, self.org]) - assertOrgFilter("?filter=xxxx", [self.org2, self.org]) - assertOrgFilter("?filter=flagged", []) - assertOrgFilter("?filter=anon", []) - assertOrgFilter("?filter=suspended", []) - assertOrgFilter("?filter=verified", []) - - self.org.flag() - - assertOrgFilter("?filter=flagged", [self.org]) - - self.org2.verify() - - assertOrgFilter("?filter=verified", [self.org2]) - - # and can go to our org - response = self.client.get(update_url) - self.assertEqual(200, response.status_code) - self.assertEqual( - [ - "name", - "features", - "is_anon", - "channels_limit", - "fields_limit", - "globals_limit", - "groups_limit", - "labels_limit", - "teams_limit", - "topics_limit", - "loc", - ], - list(response.context["form"].fields.keys()), - ) - - # make some changes to our org - response = self.client.post( - update_url, - { - "name": "Temba II", - "features": ["new_orgs"], - "is_anon": False, - "channels_limit": 20, - "fields_limit": 300, - "globals_limit": "", - "groups_limit": 400, - "labels_limit": "", - "teams_limit": "", - "topics_limit": "", - }, - ) - self.assertEqual(302, response.status_code) - - self.org.refresh_from_db() - self.assertEqual("Temba II", self.org.name) - self.assertEqual(["new_orgs"], self.org.features) - self.assertEqual(self.org.get_limit(Org.LIMIT_FIELDS), 300) - self.assertEqual(self.org.get_limit(Org.LIMIT_GLOBALS), 250) # uses default - self.assertEqual(self.org.get_limit(Org.LIMIT_GROUPS), 400) - self.assertEqual(self.org.get_limit(Org.LIMIT_CHANNELS), 20) - - # flag org - self.client.post(update_url, {"action": "flag"}) - self.org.refresh_from_db() - self.assertTrue(self.org.is_flagged) - - # unflag org - self.client.post(update_url, {"action": "unflag"}) - self.org.refresh_from_db() - self.assertFalse(self.org.is_flagged) - - # suspend org - self.client.post(update_url, {"action": "suspend"}) - self.org.refresh_from_db() - self.assertTrue(self.org.is_suspended) - - # unsuspend org - self.client.post(update_url, {"action": "unsuspend"}) - self.org.refresh_from_db() - self.assertFalse(self.org.is_suspended) - - # verify - self.client.post(update_url, {"action": "verify"}) - self.org.refresh_from_db() - self.assertTrue(self.org.is_verified) - def test_urn_schemes(self): # remove existing channels Channel.objects.all().update(is_active=False, org=None) @@ -3136,7 +3006,7 @@ def test_service(self, mr_mocks): # posting invalid org just redirects back to manage page response = self.client.post(service_url, {"other_org": 325253256}) - self.assertRedirect(response, "/org/manage/") + self.assertRedirect(response, "/staff/org/") # then service our org response = self.client.get(service_url, {"other_org": self.org.id}) @@ -3170,7 +3040,7 @@ def test_service(self, mr_mocks): # stop servicing response = self.client.post(service_url, {}) - self.assertRedirect(response, "/org/manage/") + self.assertRedirect(response, "/staff/org/") self.assertIsNone(self.client.session["org_id"]) self.assertFalse(self.client.session["servicing"]) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 4dfa6b568b9..d45335b8fc3 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -35,7 +35,6 @@ from django.utils.encoding import DjangoUnicodeDecodeError, force_str from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt from temba.api.models import APIToken, Resthook from temba.campaigns.models import Campaign @@ -846,10 +845,10 @@ def get_context_data(self, **kwargs): class OrgCRUDL(SmartCRUDL): + model = Org actions = ( "signup", "start", - "read", "edit", "edit_sub_org", "join", @@ -860,9 +859,7 @@ class OrgCRUDL(SmartCRUDL): "delete_child", "manage_accounts", "manage_accounts_sub_org", - "manage", "menu", - "update", "country", "languages", "sub_orgs", @@ -875,8 +872,6 @@ class OrgCRUDL(SmartCRUDL): "workspace", ) - model = Org - class Menu(BaseMenuView): @classmethod def derive_url_pattern(cls, path, action): @@ -1027,7 +1022,7 @@ def derive_menu(self): menu_id="workspaces", name=_("Workspaces"), icon="workspace", - href=reverse("orgs.org_manage"), + href=reverse("staff.org_list"), ), self.create_menu_item( menu_id="users", @@ -1331,209 +1326,6 @@ def extract_from(smtp_url: str) -> str: context["from_email_custom"] = from_email_custom return context - class Read(StaffOnlyMixin, SpaMixin, ContextMenuMixin, SmartReadView): - def build_context_menu(self, menu): - obj = self.get_object() - if not obj.is_active: - return - - menu.add_modax( - _("Edit"), - "update-workspace", - reverse("orgs.org_update", args=[obj.id]), - title=_("Edit Workspace"), - as_button=True, - on_submit="handleWorkspaceUpdated()", - ) - - if not obj.is_flagged: - menu.add_url_post(_("Flag"), f"{reverse('orgs.org_update', args=[obj.id])}?action=flag") - else: - menu.add_url_post(_("Unflag"), f"{reverse('orgs.org_update', args=[obj.id])}?action=unflag") - - if not obj.is_child: - if not obj.is_suspended: - menu.add_url_post(_("Suspend"), f"{reverse('orgs.org_update', args=[obj.id])}?action=suspend") - else: - menu.add_url_post(_("Unsuspend"), f"{reverse('orgs.org_update', args=[obj.id])}?action=unsuspend") - - if not obj.is_verified: - menu.add_url_post(_("Verify"), f"{reverse('orgs.org_update', args=[obj.id])}?action=verify") - - menu.new_group() - menu.add_url_post( - _("Service"), - f'{reverse("orgs.org_service")}?other_org={obj.id}&next={reverse("msgs.msg_inbox", args=[])}', - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - org = self.get_object() - - users_roles = [] - for role in OrgRole: - role_users = list(org.get_users(roles=[role]).values("id", "email")) - if role_users: - users_roles.append(dict(role_display=role.display_plural, users=role_users)) - - context["users_roles"] = users_roles - context["children"] = Org.objects.filter(parent=org, is_active=True).order_by("-created_on", "name") - return context - - class Manage(StaffOnlyMixin, SpaMixin, SmartListView): - fields = ("name", "owner", "timezone", "created_on") - default_order = ("-created_on",) - search_fields = ("name__icontains", "created_by__email__iexact", "config__icontains") - link_fields = ("name", "owner") - filters = ( - ("all", _("All"), dict(), ("-created_on",)), - ("anon", _("Anonymous"), dict(is_anon=True, is_suspended=False), None), - ("flagged", _("Flagged"), dict(is_flagged=True, is_suspended=False), None), - ("suspended", _("Suspended"), dict(is_suspended=True), None), - ("verified", _("Verified"), dict(config__verified=True, is_suspended=False), None), - ) - - @csrf_exempt - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) - - def get_filter(self): - obj_filter = self.request.GET.get("filter", "all") - for filter in self.filters: - if filter[0] == obj_filter: - return filter - - def derive_title(self): - filter = self.get_filter() - if filter: - return filter[1] - return super().derive_title() - - 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})" - - def derive_queryset(self, **kwargs): - qs = super().derive_queryset(**kwargs).filter(is_active=True) - filter = self.get_filter() - if filter: - _, _, filter_kwargs, ordering = filter - qs = qs.filter(**filter_kwargs) - if ordering: - qs = qs.order_by(*ordering) - else: - qs = qs.order_by(*self.default_order) - else: - qs = qs.filter(is_suspended=False).order_by(*self.default_order) - - return qs - - def derive_ordering(self): - # we do this in derive queryset for simplicity - return None - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["filter"] = self.request.GET.get("filter", "all") - context["filters"] = self.filters - return context - - def lookup_field_link(self, context, field, obj): - if field == "owner": - owner = obj.get_owner() - return reverse("staff.user_update", args=[owner.pk]) - return super().lookup_field_link(context, field, obj) - - class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, SmartUpdateView): - ACTION_FLAG = "flag" - ACTION_UNFLAG = "unflag" - ACTION_SUSPEND = "suspend" - ACTION_UNSUSPEND = "unsuspend" - ACTION_VERIFY = "verify" - - class Form(forms.ModelForm): - features = forms.MultipleChoiceField( - choices=Org.FEATURES_CHOICES, widget=SelectMultipleWidget(), required=False - ) - - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.limits_rows = [] - self.add_limits_fields(org) - - def clean(self): - super().clean() - - limits = dict() - for row in self.limits_rows: - if self.cleaned_data.get(row["limit_field_key"]): - limits[row["limit_type"]] = self.cleaned_data.get(row["limit_field_key"]) - - self.cleaned_data["limits"] = limits - - return self.cleaned_data - - def add_limits_fields(self, org: Org): - for limit_type in settings.ORG_LIMIT_DEFAULTS.keys(): - field = forms.IntegerField( - label=limit_type.capitalize(), - required=False, - initial=org.limits.get(limit_type), - widget=forms.TextInput(attrs={"placeholder": _("Limit")}), - ) - field_key = f"{limit_type}_limit" - - self.fields.update(OrderedDict([(field_key, field)])) - self.limits_rows.append({"limit_type": limit_type, "limit_field_key": field_key}) - - class Meta: - model = Org - fields = ("name", "features", "is_anon") - - form_class = Form - success_url = "hide" - - def derive_title(self): - return None - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.get_object() - return kwargs - - def post(self, request, *args, **kwargs): - if "action" in request.POST: - action = request.POST["action"] - obj = self.get_object() - - if action == self.ACTION_FLAG: - obj.flag() - elif action == self.ACTION_UNFLAG: - obj.unflag() - elif action == self.ACTION_SUSPEND: - obj.suspend() - elif action == self.ACTION_UNSUSPEND: - obj.unsuspend() - elif action == self.ACTION_VERIFY: - obj.verify() - - return HttpResponseRedirect(reverse("orgs.org_read", args=[obj.id])) - - return super().post(request, *args, **kwargs) - - def pre_save(self, obj): - obj = super().pre_save(obj) - - cleaned_data = self.form.cleaned_data - - obj.limits = cleaned_data["limits"] - return obj - class DeleteChild(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): cancel_url = "@orgs.org_sub_orgs" success_url = "@orgs.org_sub_orgs" @@ -1764,7 +1556,7 @@ def form_valid(self, form): # invalid form login 'logs out' the user from the org and takes them to the org manage page def form_invalid(self, form): switch_to_org(self.request, None) - return HttpResponseRedirect(reverse("orgs.org_manage")) + return HttpResponseRedirect(reverse("staff.org_list")) class SubOrgs(SpaMixin, ContextMenuMixin, OrgPermsMixin, InferOrgMixin, SmartListView): title = _("Workspaces") @@ -1878,7 +1670,7 @@ def pre_process(self, request, *args, **kwargs): if not org: if user.is_staff: - return HttpResponseRedirect(reverse("orgs.org_manage")) + return HttpResponseRedirect(reverse("staff.org_list")) return HttpResponseRedirect(reverse("orgs.org_choose")) @@ -1911,7 +1703,7 @@ def pre_process(self, request, *args, **kwargs): elif user_orgs.count() == 0: if user.is_staff: - return HttpResponseRedirect(reverse("orgs.org_manage")) + return HttpResponseRedirect(reverse("staff.org_list")) # for regular users, if there's no orgs, log them out with a message messages.info(request, _("No workspaces for this account, please contact your administrator.")) diff --git a/temba/staff/tests.py b/temba/staff/tests.py index 40b030b5e4e..ede3947255b 100644 --- a/temba/staff/tests.py +++ b/temba/staff/tests.py @@ -1,11 +1,142 @@ from django.contrib.auth.models import Group from django.urls import reverse -from temba.orgs.models import OrgMembership, OrgRole +from temba.orgs.models import Org, OrgMembership, OrgRole from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom from temba.utils.views.mixins import TEMBA_MENU_SELECTION +class OrgCRUDLTest(TembaTest, CRUDLTestMixin): + def test_read(self): + read_url = reverse("staff.org_read", args=[self.org.id]) + + # make our second org a child + self.org2.parent = self.org + self.org2.save() + + response = self.assertStaffOnly(read_url) + + # we should have a child in our context + self.assertEqual(1, len(response.context["children"])) + + # we should have options to flag and suspend + self.assertContentMenu(read_url, self.customer_support, ["Edit", "Flag", "Suspend", "Verify", "-", "Service"]) + + # flag and content menu option should be inverted + self.org.flag() + self.org.suspend() + + self.assertContentMenu( + read_url, self.customer_support, ["Edit", "Unflag", "Unsuspend", "Verify", "-", "Service"] + ) + + # no menu for inactive orgs + self.org.is_active = False + self.org.save() + self.assertContentMenu(read_url, self.customer_support, []) + + def test_list_and_update(self): + self.setUpLocations() + + manage_url = reverse("staff.org_list") + update_url = reverse("staff.org_update", args=[self.org.id]) + + self.assertStaffOnly(manage_url) + self.assertStaffOnly(update_url) + + 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]) + assertOrgFilter("?filter=all", [self.org2, self.org]) + assertOrgFilter("?filter=xxxx", [self.org2, self.org]) + assertOrgFilter("?filter=flagged", []) + assertOrgFilter("?filter=anon", []) + assertOrgFilter("?filter=suspended", []) + assertOrgFilter("?filter=verified", []) + + self.org.flag() + + assertOrgFilter("?filter=flagged", [self.org]) + + self.org2.verify() + + assertOrgFilter("?filter=verified", [self.org2]) + + # and can go to our org + response = self.client.get(update_url) + self.assertEqual(200, response.status_code) + self.assertEqual( + [ + "name", + "features", + "is_anon", + "channels_limit", + "fields_limit", + "globals_limit", + "groups_limit", + "labels_limit", + "teams_limit", + "topics_limit", + "loc", + ], + list(response.context["form"].fields.keys()), + ) + + # make some changes to our org + response = self.client.post( + update_url, + { + "name": "Temba II", + "features": ["new_orgs"], + "is_anon": False, + "channels_limit": 20, + "fields_limit": 300, + "globals_limit": "", + "groups_limit": 400, + "labels_limit": "", + "teams_limit": "", + "topics_limit": "", + }, + ) + self.assertEqual(302, response.status_code) + + self.org.refresh_from_db() + self.assertEqual("Temba II", self.org.name) + self.assertEqual(["new_orgs"], self.org.features) + self.assertEqual(self.org.get_limit(Org.LIMIT_FIELDS), 300) + self.assertEqual(self.org.get_limit(Org.LIMIT_GLOBALS), 250) # uses default + self.assertEqual(self.org.get_limit(Org.LIMIT_GROUPS), 400) + self.assertEqual(self.org.get_limit(Org.LIMIT_CHANNELS), 20) + + # flag org + self.client.post(update_url, {"action": "flag"}) + self.org.refresh_from_db() + self.assertTrue(self.org.is_flagged) + + # unflag org + self.client.post(update_url, {"action": "unflag"}) + self.org.refresh_from_db() + self.assertFalse(self.org.is_flagged) + + # suspend org + self.client.post(update_url, {"action": "suspend"}) + self.org.refresh_from_db() + self.assertTrue(self.org.is_suspended) + + # unsuspend org + self.client.post(update_url, {"action": "unsuspend"}) + self.org.refresh_from_db() + self.assertFalse(self.org.is_suspended) + + # verify + self.client.post(update_url, {"action": "verify"}) + self.org.refresh_from_db() + self.assertTrue(self.org.is_verified) + + class UserCRUDLTest(TembaTest, CRUDLTestMixin): def test_list(self): list_url = reverse("staff.user_list") diff --git a/temba/staff/urls.py b/temba/staff/urls.py index e3bccf9e60d..0edc8576959 100644 --- a/temba/staff/urls.py +++ b/temba/staff/urls.py @@ -1,3 +1,4 @@ -from .views import UserCRUDL +from .views import OrgCRUDL, UserCRUDL -urlpatterns = UserCRUDL().as_urlpatterns() +urlpatterns = OrgCRUDL().as_urlpatterns() +urlpatterns += UserCRUDL().as_urlpatterns() diff --git a/temba/staff/views.py b/temba/staff/views.py index 539a2797faa..a51b04f4f19 100644 --- a/temba/staff/views.py +++ b/temba/staff/views.py @@ -1,21 +1,232 @@ +from collections import OrderedDict + from smartmin.users.models import FailedLogin, PasswordHistory from smartmin.users.views import UserUpdateForm from smartmin.views import SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView from django import forms +from django.conf import settings from django.contrib import messages from django.contrib.auth.models import Group -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt -from temba.orgs.models import User +from temba.orgs.models import Org, OrgRole, User from temba.utils import get_anonymous_user from temba.utils.fields import SelectMultipleWidget from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin, StaffOnlyMixin +class OrgCRUDL(SmartCRUDL): + model = Org + actions = ("read", "update", "list") + + class Read(StaffOnlyMixin, SpaMixin, ContextMenuMixin, SmartReadView): + def build_context_menu(self, menu): + obj = self.get_object() + if not obj.is_active: + return + + menu.add_modax( + _("Edit"), + "update-workspace", + reverse("staff.org_update", args=[obj.id]), + title=_("Edit Workspace"), + as_button=True, + on_submit="handleWorkspaceUpdated()", + ) + + if not obj.is_flagged: + menu.add_url_post(_("Flag"), f"{reverse('staff.org_update', args=[obj.id])}?action=flag") + else: + menu.add_url_post(_("Unflag"), f"{reverse('staff.org_update', args=[obj.id])}?action=unflag") + + if not obj.is_child: + if not obj.is_suspended: + menu.add_url_post(_("Suspend"), f"{reverse('staff.org_update', args=[obj.id])}?action=suspend") + else: + menu.add_url_post(_("Unsuspend"), f"{reverse('staff.org_update', args=[obj.id])}?action=unsuspend") + + if not obj.is_verified: + menu.add_url_post(_("Verify"), f"{reverse('staff.org_update', args=[obj.id])}?action=verify") + + menu.new_group() + menu.add_url_post( + _("Service"), + f'{reverse("orgs.org_service")}?other_org={obj.id}&next={reverse("msgs.msg_inbox", args=[])}', + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + org = self.get_object() + + users_roles = [] + for role in OrgRole: + role_users = list(org.get_users(roles=[role]).values("id", "email")) + if role_users: + users_roles.append(dict(role_display=role.display_plural, users=role_users)) + + context["users_roles"] = users_roles + context["children"] = Org.objects.filter(parent=org, is_active=True).order_by("-created_on", "name") + return context + + class List(StaffOnlyMixin, SpaMixin, SmartListView): + fields = ("name", "owner", "timezone", "created_on") + default_order = ("-created_on",) + search_fields = ("name__icontains", "created_by__email__iexact", "config__icontains") + link_fields = ("name", "owner") + filters = ( + ("all", _("All"), dict(), ("-created_on",)), + ("anon", _("Anonymous"), dict(is_anon=True, is_suspended=False), None), + ("flagged", _("Flagged"), dict(is_flagged=True, is_suspended=False), None), + ("suspended", _("Suspended"), dict(is_suspended=True), None), + ("verified", _("Verified"), dict(config__verified=True, is_suspended=False), None), + ) + + @csrf_exempt + def dispatch(self, *args, **kwargs): + return super().dispatch(*args, **kwargs) + + def get_filter(self): + obj_filter = self.request.GET.get("filter", "all") + for filter in self.filters: + if filter[0] == obj_filter: + return filter + + def derive_title(self): + filter = self.get_filter() + if filter: + return filter[1] + return super().derive_title() + + 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})" + + def derive_queryset(self, **kwargs): + qs = super().derive_queryset(**kwargs).filter(is_active=True) + filter = self.get_filter() + if filter: + _, _, filter_kwargs, ordering = filter + qs = qs.filter(**filter_kwargs) + if ordering: + qs = qs.order_by(*ordering) + else: + qs = qs.order_by(*self.default_order) + else: + qs = qs.filter(is_suspended=False).order_by(*self.default_order) + + return qs + + def derive_ordering(self): + # we do this in derive queryset for simplicity + return None + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["filter"] = self.request.GET.get("filter", "all") + context["filters"] = self.filters + return context + + def lookup_field_link(self, context, field, obj): + if field == "owner": + owner = obj.get_owner() + return reverse("staff.user_update", args=[owner.pk]) + return super().lookup_field_link(context, field, obj) + + class Update(StaffOnlyMixin, ModalFormMixin, ComponentFormMixin, SmartUpdateView): + ACTION_FLAG = "flag" + ACTION_UNFLAG = "unflag" + ACTION_SUSPEND = "suspend" + ACTION_UNSUSPEND = "unsuspend" + ACTION_VERIFY = "verify" + + class Form(forms.ModelForm): + features = forms.MultipleChoiceField( + choices=Org.FEATURES_CHOICES, widget=SelectMultipleWidget(), required=False + ) + + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.limits_rows = [] + self.add_limits_fields(org) + + def clean(self): + super().clean() + + limits = dict() + for row in self.limits_rows: + if self.cleaned_data.get(row["limit_field_key"]): + limits[row["limit_type"]] = self.cleaned_data.get(row["limit_field_key"]) + + self.cleaned_data["limits"] = limits + + return self.cleaned_data + + def add_limits_fields(self, org: Org): + for limit_type in settings.ORG_LIMIT_DEFAULTS.keys(): + field = forms.IntegerField( + label=limit_type.capitalize(), + required=False, + initial=org.limits.get(limit_type), + widget=forms.TextInput(attrs={"placeholder": _("Limit")}), + ) + field_key = f"{limit_type}_limit" + + self.fields.update(OrderedDict([(field_key, field)])) + self.limits_rows.append({"limit_type": limit_type, "limit_field_key": field_key}) + + class Meta: + model = Org + fields = ("name", "features", "is_anon") + + form_class = Form + success_url = "hide" + + def derive_title(self): + return None + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.get_object() + return kwargs + + def post(self, request, *args, **kwargs): + if "action" in request.POST: + action = request.POST["action"] + obj = self.get_object() + + if action == self.ACTION_FLAG: + obj.flag() + elif action == self.ACTION_UNFLAG: + obj.unflag() + elif action == self.ACTION_SUSPEND: + obj.suspend() + elif action == self.ACTION_UNSUSPEND: + obj.unsuspend() + elif action == self.ACTION_VERIFY: + obj.verify() + + return HttpResponseRedirect(reverse("staff.org_read", args=[obj.id])) + + return super().post(request, *args, **kwargs) + + def pre_save(self, obj): + obj = super().pre_save(obj) + + cleaned_data = self.form.cleaned_data + + obj.limits = cleaned_data["limits"] + return obj + + class UserCRUDL(SmartCRUDL): model = User actions = ("read", "update", "delete", "list") diff --git a/templates/orgs/org_manage.html b/templates/staff/org_list.html similarity index 100% rename from templates/orgs/org_manage.html rename to templates/staff/org_list.html diff --git a/templates/orgs/org_read.html b/templates/staff/org_read.html similarity index 100% rename from templates/orgs/org_read.html rename to templates/staff/org_read.html diff --git a/templates/orgs/org_update.html b/templates/staff/org_update.html similarity index 100% rename from templates/orgs/org_update.html rename to templates/staff/org_update.html diff --git a/templates/staff/user_read.html b/templates/staff/user_read.html index 988714b5b1f..2787587bf2d 100644 --- a/templates/staff/user_read.html +++ b/templates/staff/user_read.html @@ -9,7 +9,7 @@
    {% for org in object.get_orgs %} {% endfor %}
    From 6dda747a378d4cc0c3268dcb643ac290635b5474 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 8 Oct 2024 14:37:24 -0500 Subject: [PATCH 165/557] Update CHANGELOG.md for v9.3.61 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8f25090be..49925d194de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.61 (2024-10-08) +------------------------- + * Move staff only rg and user views to new staff app + v9.3.60 (2024-10-08) ------------------------- * Improve invitation emails diff --git a/pyproject.toml b/pyproject.toml index 50d4af0d746..c81aa4494ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.60" +version = "9.3.61" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 771a070c817..a5de61c5616 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.60" +__version__ = "9.3.61" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From f5012fcb621baa19af4177f0c2ada18343e002d3 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 8 Oct 2024 20:51:22 +0000 Subject: [PATCH 166/557] Fix double character rendering on autogrow inputs --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0b0d75161db..4ce4ba0d89e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.4", + "@nyaruka/temba-components": "0.108.6", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index ff720533ff2..942b77ff968 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.4": - version "0.108.4" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.4.tgz#f1eefeca084665f804a3c8a7f8b81f59830dedac" - integrity sha512-WkJC/OBldUEKrKeF15iCeozouXWUSvPXCQMAvdrofLGgzKduUom4t5Vn+xfXrEYUNrTmjknFqaAH663Wno6Dvg== +"@nyaruka/temba-components@0.108.6": + version "0.108.6" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.6.tgz#484890d3a1a77c16bec8726b79802b69a55de6dd" + integrity sha512-gu1qAewnTAcaT8ZFKSU/lGIHpknT58uKrpIVljbMChhBHDvJiLA4OB2jhn/zF/tCOtDc7BuRMLi0vXLS5LJKwQ== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From b850087477360fea97da834ad1c326af75bcf31f Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Tue, 8 Oct 2024 14:05:09 -0700 Subject: [PATCH 167/557] Update CHANGELOG.md for v9.3.62 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49925d194de..050028cd3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.62 (2024-10-08) +------------------------- + * Fix double character rendering on autogrow inputs + v9.3.61 (2024-10-08) ------------------------- * Move staff only rg and user views to new staff app diff --git a/pyproject.toml b/pyproject.toml index c81aa4494ad..675b487f866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.61" +version = "9.3.62" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a5de61c5616..e10872d9e30 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.61" +__version__ = "9.3.62" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 725d08ace172ea9873bf99b263c31b24d1e46633 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 03:05:54 +0000 Subject: [PATCH 168/557] Bump django from 5.1 to 5.1.1 Bumps [django](https://github.com/django/django) from 5.1 to 5.1.1. - [Commits](https://github.com/django/django/compare/5.1...5.1.1) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index f6a398edd36..7a230c98576 100644 --- a/poetry.lock +++ b/poetry.lock @@ -616,13 +616,13 @@ files = [ [[package]] name = "django" -version = "5.1" +version = "5.1.1" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" files = [ - {file = "Django-5.1-py3-none-any.whl", hash = "sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557"}, - {file = "Django-5.1.tar.gz", hash = "sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d"}, + {file = "Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f"}, + {file = "Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2"}, ] [package.dependencies] From e5e6d8e228f505dc756dd990d9ee77cf51298a56 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 17:02:18 +0000 Subject: [PATCH 169/557] WIP --- temba/orgs/views/views.py | 77 ++++++++++++++++++++++ temba/settings_common.py | 6 +- templates/orgs/user_list.html | 88 ++++++++++++++++++++++++++ templates/orgs/user_remove.html | 8 +++ templates/tickets/shortcut_delete.html | 2 +- 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 templates/orgs/user_list.html create mode 100644 templates/orgs/user_remove.html diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index d45335b8fc3..985f5e9fc7b 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -338,6 +338,9 @@ def get_object(self, *args, **kwargs): class UserCRUDL(SmartCRUDL): model = User actions = ( + "list", + "update", + "remove", "edit", "forget", "recover", @@ -350,6 +353,80 @@ class UserCRUDL(SmartCRUDL): "send_verification_email", ) + class List(SpaMixin, ContextMenuMixin, OrgPermsMixin, SmartListView): + title = _("Users") + menu_path = "/settings/users" + search_fields = ("email__icontains", "first_name__icontains", "last_name__icontains") + + def pre_process(self, request, *args, **kwargs): + if Org.FEATURE_USERS not in request.org.features: + return HttpResponseRedirect(reverse("orgs.org_workspace")) + + def build_context_menu(self, menu): + menu.add_modax(_("Invite"), "invite-create", reverse("orgs.invitation_create"), as_button=True) + + def derive_queryset(self, **kwargs): + return ( + super() + .derive_queryset(**kwargs) + .filter(id__in=self.request.org.get_users().values_list("id", flat=True)) + .order_by(Lower("email")) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + has_viewers = False + for user in context["object_list"]: + user.role = self.request.org.get_user_role(user) + if user.role == OrgRole.VIEWER: + has_viewers = True + + context["has_viewers"] = has_viewers + return context + + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Form(forms.ModelForm): + role = forms.ChoiceField( + choices=[(r.code, r.display) for r in (OrgRole.ADMINISTRATOR, OrgRole.EDITOR, OrgRole.AGENT)], + required=True, + label=" ", + widget=SelectWidget(), + ) + + class Meta: + model = User + fields = ("role",) + + form_class = Form + + def get_object_org(self): + return self.request.org + + def derive_initial(self): + # viewers default to editors + role = self.request.org.get_user_role(self.object) + return {"role": OrgRole.EDITOR.code if role == OrgRole.VIEWER else role.code} + + def save(self, obj): + role = OrgRole.from_code(self.form.cleaned_data["role"]) + self.request.org.add_user(obj, role) + return obj + + class Remove(OrgObjPermsMixin, SmartDeleteView): + permission = "orgs.user_update" + fields = ("id",) + cancel_url = "@orgs.user_list" + redirect_url = "@orgs.user_list" + + def get_object_org(self): + return self.request.org + + def post(self, request, *args, **kwargs): + self.request.org.remove_user(self.get_object()) + + return HttpResponseRedirect(self.get_redirect_url()) + class Forget(SmartFormView): class Form(forms.Form): email = forms.EmailField( diff --git a/temba/settings_common.py b/temba/settings_common.py index 85b40dc56db..03a2c8519c3 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -508,6 +508,7 @@ "orgs.orgimport.*", "orgs.user_list", "orgs.user_tokens", + "orgs.user_update", "request_logs.httplog_list", "request_logs.httplog_read", "request_logs.httplog_webhooks", @@ -589,7 +590,6 @@ "orgs.org_resthooks", "orgs.org_workspace", "orgs.orgimport.*", - "orgs.user_list", "orgs.user_tokens", "request_logs.httplog_webhooks", "templates.template_list", @@ -656,7 +656,6 @@ "orgs.org_menu", "orgs.org_read", "orgs.org_workspace", - "orgs.user_list", "templates.template_list", "templates.template_read", "tickets.ticket_export", @@ -684,6 +683,7 @@ # extra permissions that only apply to API requests (wildcard notation not supported here) API_PERMISSIONS = { + "Editors": ("orgs.user_list",), "Agents": ( "contacts.contact_create", "contacts.contact_list", @@ -695,7 +695,7 @@ "msgs.msg_create", "orgs.org_read", "orgs.user_list", - ) + ), } # ----------------------------------------------------------------------------------- diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html new file mode 100644 index 00000000000..88160c7cae7 --- /dev/null +++ b/templates/orgs/user_list.html @@ -0,0 +1,88 @@ +{% extends "smartmin/list.html" %} +{% load smartmin temba i18n %} + +{% block content %} + {% if has_viewers %} + + {% blocktrans trimmed with cutoff="2024-12-31"|day %} + The Viewer role for users is being removed. Please update any users with that role or remove from your + workspace. After {{ cutoff }} these users will no longer be able to access the workspace. + {% endblocktrans %} + + {% endif %} +
    +
    +
    +
    + + + + +
    +
    +
    + {% block pre-table %} + + + + + {% endblock pre-table %} +
    {% include "includes/short_pagination.html" %}
    +
    +
    + + + + + + + + + + {% for obj in object_list %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Email" %}{% trans "Name" %}{% trans "Role" %}
    {{ obj.email }}{{ obj.name }}{{ obj.role.display }} + +
    {% trans "No users" %}
    +
    +{% endblock content %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %} diff --git a/templates/orgs/user_remove.html b/templates/orgs/user_remove.html new file mode 100644 index 00000000000..8073df7f1de --- /dev/null +++ b/templates/orgs/user_remove.html @@ -0,0 +1,8 @@ +{% extends "includes/modax.html" %} +{% load i18n %} + +{% block fields %} + {% blocktrans trimmed %} + You are about to remove the user {{ object }} from your workspace. Are you sure? + {% endblocktrans %} +{% endblock fields %} diff --git a/templates/tickets/shortcut_delete.html b/templates/tickets/shortcut_delete.html index d6a3c671c0b..9235795ab6f 100644 --- a/templates/tickets/shortcut_delete.html +++ b/templates/tickets/shortcut_delete.html @@ -3,6 +3,6 @@ {% block fields %} {% blocktrans trimmed %} - You are about to delete the shortcut {{ object }}. There is no way to undo this. Are you sure? + You are about to delete the shortcut {{ object }}. There is no way to undo this. Are you sure? {% endblocktrans %} {% endblock fields %} From 53416a94e48c7a9da98d0f606a37bb1dd004d545 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 17:42:24 +0000 Subject: [PATCH 170/557] Create status groups with invalid names to avoid conflicts with real group names --- temba/api/v2/tests.py | 3 --- temba/contacts/models.py | 8 ++++---- temba/contacts/tests.py | 6 +----- temba/orgs/tests.py | 2 +- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index db317816c8c..d8c19bb4952 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -3686,9 +3686,6 @@ def test_groups(self, mr_mocks): # try to create another group with same name self.assertPost(endpoint_url, self.admin, {"name": "reporters"}, errors={"name": "This field must be unique."}) - # try to create another group with same name as a system group.. - self.assertPost(endpoint_url, self.admin, {"name": "blocked"}, errors={"name": "This field must be unique."}) - # it's fine if a group in another org has that name self.assertPost(endpoint_url, self.admin, {"name": "Spammers"}, status=201) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 52be0bffcf3..f8d6d69299d 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1430,7 +1430,7 @@ def create_system_groups(cls, org): assert not org.groups.filter(is_system=True).exists(), "org already has system groups" org.groups.create( - name="Active", + name="\\Active", # to avoid name collisions with real groups group_type=ContactGroup.TYPE_DB_ACTIVE, is_system=True, status=cls.STATUS_READY, @@ -1438,7 +1438,7 @@ def create_system_groups(cls, org): modified_by=org.modified_by, ) org.groups.create( - name="Blocked", + name="\\Blocked", group_type=ContactGroup.TYPE_DB_BLOCKED, is_system=True, status=cls.STATUS_READY, @@ -1446,7 +1446,7 @@ def create_system_groups(cls, org): modified_by=org.modified_by, ) org.groups.create( - name="Stopped", + name="\\Stopped", group_type=ContactGroup.TYPE_DB_STOPPED, is_system=True, status=cls.STATUS_READY, @@ -1454,7 +1454,7 @@ def create_system_groups(cls, org): modified_by=org.modified_by, ) org.groups.create( - name="Archived", + name="\\Archived", group_type=ContactGroup.TYPE_DB_ARCHIVED, is_system=True, status=cls.STATUS_READY, diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index d9e9a9e59ea..204fbc72351 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -1349,10 +1349,6 @@ def test_create(self, mr_mocks): response = self.client.post(url, {"name": "Customers"}) self.assertFormError(response.context["form"], "name", "Already used by another group.") - # try to create with name that's already taken by a system group - response = self.client.post(url, {"name": "blocked"}) - self.assertFormError(response.context["form"], "name", "Already used by another group.") - # create with valid name (that will be trimmed) response = self.client.post(url, {"name": "first "}) self.assertNoFormErrors(response) @@ -5017,7 +5013,7 @@ def assertReimport(export): contact5 = self.create_contact("George", urns=["tel:+1234567777"], status=Contact.STATUS_STOPPED) # export a specified status group of contacts (Stopped) - sheets, export = self._export(self.org.groups.get(name="Stopped"), with_groups=[group1]) + sheets, export = self._export(self.org.groups.get(group_type="S"), with_groups=[group1]) self.assertExcelSheet( sheets[0], [ diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 470cf22f8a8..7ae341ba596 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2477,7 +2477,7 @@ def test_signup(self): sample_flows = set(org.flows.values_list("name", flat=True)) self.assertEqual({"created_on", "last_seen_on"}, system_fields) - self.assertEqual({"Active", "Archived", "Blocked", "Stopped", "Open Tickets"}, system_groups) + self.assertEqual({"\\Active", "\\Archived", "\\Blocked", "\\Stopped", "Open Tickets"}, system_groups) self.assertEqual( {"Sample Flow - Order Status Checker", "Sample Flow - Satisfaction Survey", "Sample Flow - Simple Poll"}, sample_flows, From 6b298f500acc191b779052c3c289980d829a1674 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 14:10:16 -0500 Subject: [PATCH 171/557] Update CHANGELOG.md for v9.3.63 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 050028cd3fb..4dbc77cca07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.63 (2024-10-09) +------------------------- + * Create status groups with invalid names to avoid conflicts with real group names + * Bump django from 5.1 to 5.1.1 + v9.3.62 (2024-10-08) ------------------------- * Fix double character rendering on autogrow inputs diff --git a/pyproject.toml b/pyproject.toml index 675b487f866..ccab45c33c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.62" +version = "9.3.63" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index e10872d9e30..0e80557075f 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.62" +__version__ = "9.3.63" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 79fd6924a02a73ca46feed34a59d599164c48872 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 19:22:30 +0000 Subject: [PATCH 172/557] Add test to check shortcut can't be created with invalid name --- temba/tickets/tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 05cb0dc66cd..2e5f55d7197 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -263,6 +263,14 @@ def test_create(self): form_errors={"name": "Shortcut with this name already exists."}, ) + # try to create with name that has invalid characters + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "\\reboot", "text": "x"}, + form_errors={"name": "Cannot contain the character: \\"}, + ) + # try to create with name that is too long self.assertCreateSubmit( create_url, From 223e16237db5e1a08181d02f43f17e3338e11f26 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 19:58:44 +0000 Subject: [PATCH 173/557] Data migration to tweak names of existing status groups --- .../migrations/0189_backfill_proxy_fields.py | 8 +-- .../migrations/0193_fix_status_group_names.py | 32 ++++++++++ temba/contacts/tests.py | 59 ++++++++----------- 3 files changed, 57 insertions(+), 42 deletions(-) create mode 100644 temba/contacts/migrations/0193_fix_status_group_names.py diff --git a/temba/contacts/migrations/0189_backfill_proxy_fields.py b/temba/contacts/migrations/0189_backfill_proxy_fields.py index 92934ccdfc5..8700863b140 100644 --- a/temba/contacts/migrations/0189_backfill_proxy_fields.py +++ b/temba/contacts/migrations/0189_backfill_proxy_fields.py @@ -3,7 +3,7 @@ from django.db import migrations -def backfill_proxy_fields(apps, schema_editor): +def backfill_proxy_fields(apps, schema_editor): # pragma: no cover ContactField = apps.get_model("contacts", "ContactField") # delete all old system fields that weren't usable @@ -17,12 +17,8 @@ def backfill_proxy_fields(apps, schema_editor): ContactField.objects.filter(is_system=True, key__in=("created_on", "last_seen_on")).update(is_proxy=True) -def reverse(apps, schema_editor): - pass - - class Migration(migrations.Migration): dependencies = [("contacts", "0188_contactfield_is_proxy_alter_contactfield_is_system")] - operations = [migrations.RunPython(backfill_proxy_fields, reverse)] + operations = [migrations.RunPython(backfill_proxy_fields, migrations.RunPython.noop)] diff --git a/temba/contacts/migrations/0193_fix_status_group_names.py b/temba/contacts/migrations/0193_fix_status_group_names.py new file mode 100644 index 00000000000..8303f15760f --- /dev/null +++ b/temba/contacts/migrations/0193_fix_status_group_names.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1 on 2024-10-09 19:41 + +from django.db import migrations +from django.db.models import F, Value +from django.db.models.functions import Concat + + +def fix_status_group_names(apps, schema_editor): + ContactGroup = apps.get_model("contacts", "ContactGroup") + num_updated = 0 + + while True: + id_batch = list( + ContactGroup.objects.filter(group_type__in=("A", "B", "S", "V")) + .exclude(name__startswith="\\") + .values_list("id", flat=True)[:1000] + ) + if not id_batch: + break + + ContactGroup.objects.filter(id__in=id_batch).update(name=Concat(Value("\\"), F("name"))) + num_updated += len(id_batch) + + if num_updated: + print(f"Updated {num_updated} status group names") + + +class Migration(migrations.Migration): + + dependencies = [("contacts", "0192_alter_contactnote_text")] + + operations = [migrations.RunPython(fix_status_group_names, migrations.RunPython.noop)] diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 204fbc72351..26aa1c29690 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -5250,44 +5250,31 @@ def assertReimport(export): assertReimport(export) -class BackfillProxyFieldsTest(MigrationTest): +class FixStatusGroupNamesTest(MigrationTest): app = "contacts" - migrate_from = "0188_contactfield_is_proxy_alter_contactfield_is_system" - migrate_to = "0189_backfill_proxy_fields" - - OLD_SYSTEM_FIELDS = [ - {"key": "id", "name": "ID", "value_type": "N"}, - {"key": "name", "name": "Name", "value_type": "T"}, - {"key": "created_on", "name": "Created On", "value_type": "D"}, - {"key": "language", "name": "Language", "value_type": "T"}, - {"key": "last_seen_on", "name": "Last Seen On", "value_type": "D"}, - ] + migrate_from = "0192_alter_contactnote_text" + migrate_to = "0193_fix_status_group_names" def setUpBeforeMigration(self, apps): - # make org 1 look like an org with the old system fields - self.org.fields.all().delete() - - for spec in self.OLD_SYSTEM_FIELDS: - self.org.fields.create( - is_system=True, - key=spec["key"], - name=spec["name"], - value_type=spec["value_type"], - show_in_table=False, - created_by=self.org.created_by, - modified_by=self.org.modified_by, - ) + # make org 1 look like an org with the old system groups + self.org.groups.filter(group_type=ContactGroup.TYPE_DB_ACTIVE).update(name="Active") + self.org.groups.filter(group_type=ContactGroup.TYPE_DB_BLOCKED).update(name="Blocked") + self.org.groups.filter(group_type=ContactGroup.TYPE_DB_STOPPED).update(name="Stopped") + self.org.groups.filter(group_type=ContactGroup.TYPE_DB_ARCHIVED).update(name="Archived") + + self.group1 = self.create_group("Active Contacts", contacts=[]) def test_migration(self): - self.assertEqual( - {"created_on", "last_seen_on"}, set(self.org.fields.filter(is_system=True).values_list("key", flat=True)) - ) - self.assertEqual( - {"created_on", "last_seen_on"}, set(self.org.fields.filter(is_proxy=True).values_list("key", flat=True)) - ) - self.assertEqual( - {"created_on", "last_seen_on"}, set(self.org2.fields.filter(is_system=True).values_list("key", flat=True)) - ) - self.assertEqual( - {"created_on", "last_seen_on"}, set(self.org2.fields.filter(is_proxy=True).values_list("key", flat=True)) - ) + self.assertEqual("\\Active", self.org.groups.get(group_type=ContactGroup.TYPE_DB_ACTIVE).name) + self.assertEqual("\\Blocked", self.org.groups.get(group_type=ContactGroup.TYPE_DB_BLOCKED).name) + self.assertEqual("\\Stopped", self.org.groups.get(group_type=ContactGroup.TYPE_DB_STOPPED).name) + self.assertEqual("\\Archived", self.org.groups.get(group_type=ContactGroup.TYPE_DB_ARCHIVED).name) + + self.assertEqual("\\Active", self.org2.groups.get(group_type=ContactGroup.TYPE_DB_ACTIVE).name) + self.assertEqual("\\Blocked", self.org2.groups.get(group_type=ContactGroup.TYPE_DB_BLOCKED).name) + self.assertEqual("\\Stopped", self.org2.groups.get(group_type=ContactGroup.TYPE_DB_STOPPED).name) + self.assertEqual("\\Archived", self.org2.groups.get(group_type=ContactGroup.TYPE_DB_ARCHIVED).name) + + # check user group unaffected + self.group1.refresh_from_db() + self.assertEqual("Active Contacts", self.group1.name) From 3a3c489977037db79fb1ddc5e45d40c6a607bdce Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 20:36:04 +0000 Subject: [PATCH 174/557] Tweak list view templates for consistency --- templates/campaigns/campaign_list.html | 19 +- templates/contacts/contact_list.html | 256 ++++++++++++------------- templates/flows/flow_list.html | 15 +- templates/globals/global_list.html | 31 +-- templates/msgs/message_box.html | 25 +-- templates/staff/org_list.html | 14 +- templates/staff/user_list.html | 10 +- templates/triggers/trigger_list.html | 9 +- 8 files changed, 162 insertions(+), 217 deletions(-) diff --git a/templates/campaigns/campaign_list.html b/templates/campaigns/campaign_list.html index 50ee64ddc00..01a1611b7be 100644 --- a/templates/campaigns/campaign_list.html +++ b/templates/campaigns/campaign_list.html @@ -23,18 +23,11 @@ {% endblock extra-script %} {% block content %} -
    -
    -
    -
    -
    - - - -
    -
    -
    -
    +
    + + + +
    {% include "includes/short_pagination.html" %}
    @@ -62,7 +55,7 @@ {% empty %} - + {% endfor %} {% block extra-rows %} diff --git a/templates/contacts/contact_list.html b/templates/contacts/contact_list.html index 253cf45900c..b67e51fe352 100644 --- a/templates/contacts/contact_list.html +++ b/templates/contacts/contact_list.html @@ -77,11 +77,7 @@ - {% if search_error %} -
    - {{ search_error }} -
    - {% endif %} + {% if search_error %}
    {{ search_error }}
    {% endif %} {% if org_perms.contacts.contact_delete %}
    {% trans "No matching campaigns" %}{% trans "No campaigns" %}
    - {% if object_list %} - - {% if org_perms.contacts.contact_update %}{% endif %} - - - {% for field in contact_fields %} - {% if field.show_in_table %} - - {% endif %} - {% endfor %} - + {% if org_perms.contacts.contact_update %}{% endif %} + + + {% for field in contact_fields %} + {% if field.show_in_table %} + + {% endif %} + {% endfor %} + - + - - {% for object in contacts %} - - {% if org_perms.contacts.contact_update or org_perms.msgs.broadcast_create %} - - {% endif %} - - + {% for object in contacts %} + + {% if org_perms.contacts.contact_update or org_perms.msgs.broadcast_create %} + - {% for field in contact_fields %} - {% if field.show_in_table %} - + {% endif %} + + + {% for field in contact_fields %} + {% if field.show_in_table %} + + {% endif %} + {% endfor %} + - - - - - {% empty %} - - - {% for field in contact_fields %} - {% if field.show_in_table %}{% endif %} - {% endfor %} - - - {% endfor %} - {% endif %} + + + + + + + {% empty %} + + + + {% endfor %}
    - {% if sort_field == field.key %} - {% if sort_direction == 'desc' %} - -
    - {{ field.name }} - - -
    -
    - {% else %} - -
    - {{ field.name }} - - -
    -
    - {% endif %} - {% else %} - -
    - {{ field.name }} - - -
    -
    - {% endif %} -
    - {% if object_list %} - {% if sort_field == 'last_seen_on' %} +
    + {% if sort_field == field.key %} {% if sort_direction == 'desc' %} - +
    - {% trans "Last Seen On" %} + {{ field.name }}
    {% else %} - +
    - {% trans "Last Seen On" %} + {{ field.name }}
    {% endif %} + {% else %} + +
    + {{ field.name }} + + +
    +
    + {% endif %} +
    + {% if object_list %} + {% if sort_field == 'last_seen_on' %} + {% if sort_direction == 'desc' %} + +
    + {% trans "Last Seen On" %} + + +
    +
    {% else %}
    {% trans "Last Seen On" %} - +
    {% endif %} + {% else %} + +
    + {% trans "Last Seen On" %} + + +
    +
    {% endif %} -
    - {% if object_list %} - {% if sort_field == 'created_on' %} - {% if sort_direction == 'desc' %} - -
    - {% trans "Created On" %} - - -
    -
    - {% else %} - -
    - {% trans "Created On" %} - - -
    -
    - {% endif %} + {% endif %} +
    + {% if object_list %} + {% if sort_field == 'created_on' %} + {% if sort_direction == 'desc' %} + +
    + {% trans "Created On" %} + + +
    +
    {% else %}
    {% trans "Created On" %} - +
    {% endif %} + {% else %} + +
    + {% trans "Created On" %} + + +
    +
    {% endif %} -
    - - - -
    {{ object.name|default:"--" }}
    -
    -
    {{ object|urn_or_anon:user_org }}
    + {% endif %} + +
    + + {% contact_field object field.key %} +
    {{ object.name|default:"--" }}
    +
    +
    {{ object|urn_or_anon:user_org }}
    +
    {% contact_field object field.key %} +
    + {% if object.last_seen_on %} + {{ object.last_seen_on|timedate }} + {% else %} + {{ "--" }} {% endif %} - {% endfor %} -
    -
    - {% if object.last_seen_on %} - {{ object.last_seen_on|timedate }} - {% else %} - {{ "--" }} - {% endif %} -
    -
    -
    {{ object.created_on|timedate }}
    -
    -
    - - {% for group in object.all_groups.all %} - {% if group.group_type == 'U' %} - - {{ group.name }} - - {% endif %} - {% endfor %} - -
    -
    {% trans "No matching contacts." %}
    +
    {{ object.created_on|timedate }}
    +
    +
    + + {% for group in object.all_groups.all %} + {% if group.group_type == 'U' %} + + {{ group.name }} + + {% endif %} + {% endfor %} + +
    +
    {% trans "No contacts" %}
    diff --git a/templates/flows/flow_list.html b/templates/flows/flow_list.html index e75fb7f9aa7..95fe0ae95a4 100644 --- a/templates/flows/flow_list.html +++ b/templates/flows/flow_list.html @@ -15,14 +15,11 @@ {% endif %} {% if org_has_flows %} - {% if view.search_fields %} -
    - - - {% if request.REQUEST.status %}{% endif %} - -
    - {% endif %} +
    + + + +
    {% include "includes/short_pagination.html" %}
    @@ -104,7 +101,7 @@ {% empty %} - + {% endfor %} diff --git a/templates/globals/global_list.html b/templates/globals/global_list.html index 9bcbe0cecdb..78f1b6ec4bd 100644 --- a/templates/globals/global_list.html +++ b/templates/globals/global_list.html @@ -10,22 +10,6 @@ might change later. {% endblocktrans %} -
    -
    -
    -
    - - - - - {% if search_error %} -
    - {{ search_error }} -
    - {% endif %} -
    -
    -
    {% block pre-table %} @@ -34,6 +18,11 @@ {% endblock pre-table %} + + + + +
    {% include "includes/short_pagination.html" %}
    {% trans "No matching flows." %}{% trans "No flows" %}
    @@ -75,15 +64,7 @@ {% empty %} - + {% endfor %} diff --git a/templates/msgs/message_box.html b/templates/msgs/message_box.html index c5fb96766be..24611588c8f 100644 --- a/templates/msgs/message_box.html +++ b/templates/msgs/message_box.html @@ -10,23 +10,17 @@ {% endblock extra-style %} {% block content %} - -
    -
    -
    -
    - - - - -
    -
    -
    + + + + +
    {% include "includes/short_pagination.html" %}
    {% if has_messages %} @@ -107,12 +101,11 @@
    - {% endfor %} - {% if not object_list %} + {% empty %} - + - {% endif %} + {% endfor %}
    - {% if search %} - {% blocktrans trimmed with search=search %} - No globals found for {{ search }} - {% endblocktrans %} - {% else %} - {% trans "No globals" %} - {% endif %} - {% trans "No globals" %}
    {% trans "No messages" %}{% trans "No messages" %}
    {% else %} diff --git a/templates/staff/org_list.html b/templates/staff/org_list.html index 37ce6a30902..2a4f5dec651 100644 --- a/templates/staff/org_list.html +++ b/templates/staff/org_list.html @@ -23,13 +23,9 @@ {% endblock extra-style %} {% block pre-table %} -
    - -
    +
    + + + +
    {% endblock pre-table %} diff --git a/templates/staff/user_list.html b/templates/staff/user_list.html index 4896d9d1a89..8d0d24cc6d1 100644 --- a/templates/staff/user_list.html +++ b/templates/staff/user_list.html @@ -6,12 +6,8 @@ {% endblock page-header %} {% block pre-table %}
    -
    - -
    + + +
    {% endblock pre-table %} diff --git a/templates/triggers/trigger_list.html b/templates/triggers/trigger_list.html index d02da1dcec4..70cdefcd31d 100644 --- a/templates/triggers/trigger_list.html +++ b/templates/triggers/trigger_list.html @@ -30,11 +30,10 @@
    {% trans "Are you sure you want to delete the selected triggers? This cannot be undone." %}
    {% endblock pjax %} -
    - + + - {% if request.REQUEST.status %}{% endif %} - +
    {% include "includes/short_pagination.html" %} @@ -92,7 +91,7 @@ {% empty %} - {% trans "No matching triggers." %} + {% trans "No triggers" %} {% endfor %} From 0984c5fdd216c75357e74b8f0caf76b0525fdd8d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 9 Oct 2024 21:37:14 +0000 Subject: [PATCH 175/557] Fix removing users from workspace --- temba/orgs/views/base.py | 6 +++++- temba/orgs/views/views.py | 12 ++++++++--- .../{user_remove.html => user_delete.html} | 0 templates/orgs/user_list.html | 20 +++++++++---------- 4 files changed, 24 insertions(+), 14 deletions(-) rename templates/orgs/{user_remove.html => user_delete.html} (100%) diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index 891ff9f8135..8b289851daf 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -58,10 +58,14 @@ def derive_queryset(self, **kwargs): class BaseDeleteModal(OrgObjPermsMixin, SmartDeleteView): - default_template = "smartmin/delete_confirm.html" submit_button_name = _("Delete") fields = ("id",) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["submit_button_name"] = self.submit_button_name + return context + def post(self, request, *args, **kwargs): self.get_object().release(self.request.user) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 985f5e9fc7b..eaddd2df104 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -340,7 +340,7 @@ class UserCRUDL(SmartCRUDL): actions = ( "list", "update", - "remove", + "delete", "edit", "forget", "recover", @@ -390,7 +390,7 @@ class Form(forms.ModelForm): role = forms.ChoiceField( choices=[(r.code, r.display) for r in (OrgRole.ADMINISTRATOR, OrgRole.EDITOR, OrgRole.AGENT)], required=True, - label=" ", + label=_("Role"), widget=SelectWidget(), ) @@ -413,15 +413,21 @@ def save(self, obj): self.request.org.add_user(obj, role) return obj - class Remove(OrgObjPermsMixin, SmartDeleteView): + class Delete(OrgObjPermsMixin, SmartDeleteView): permission = "orgs.user_update" fields = ("id",) + submit_button_name = _("Remove") cancel_url = "@orgs.user_list" redirect_url = "@orgs.user_list" def get_object_org(self): return self.request.org + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["submit_button_name"] = self.submit_button_name + return context + def post(self, request, *args, **kwargs): self.request.org.remove_user(self.get_object()) diff --git a/templates/orgs/user_remove.html b/templates/orgs/user_delete.html similarity index 100% rename from templates/orgs/user_remove.html rename to templates/orgs/user_delete.html diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 9ddf7fff091..82f64cca220 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -10,17 +10,17 @@ {% endblocktrans %} {% endif %} -
    - - - -
    {% block pre-table %} - + {% endblock pre-table %} +
    + + + +
    {% include "includes/short_pagination.html" %}
    @@ -40,7 +40,7 @@ - + {% empty %} From 4cb1d68d2c6ddb7464424b26bf8d2435158a2348 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 11:57:08 -0500 Subject: [PATCH 190/557] Update CHANGELOG.md for v9.3.67 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e706de7c69d..8fba1e870d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.67 (2024-10-16) +------------------------- + * New CRUDL views for org users and invitations + v9.3.66 (2024-10-16) ------------------------- * Fix displaying the channel log missing HTTP response diff --git a/pyproject.toml b/pyproject.toml index ccd93f506f1..0814dd8ff08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.66" +version = "9.3.67" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 8c684c0ffac..ccd7445a03c 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.66" +__version__ = "9.3.67" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From a6e26c09ffcef83d826b70e248d39352f5db5e0f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 18:57:14 +0000 Subject: [PATCH 191/557] Tweak user update and delete forms to return 404 for users not in the current org --- temba/orgs/tests.py | 6 ++++++ temba/orgs/views/views.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 5e9e68964bc..86a7d414c54 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2944,6 +2944,9 @@ def test_update(self): self.assertUpdateFetch(update_url, [self.admin], form_fields={"role": "T"}) + # check can't update user not in the current org + self.assertRequestDisallowed(reverse("orgs.user_update", args=[self.admin2.id]), [self.admin]) + # role field for viewers defaults to editor update_url = reverse("orgs.user_update", args=[self.user.id]) @@ -2984,6 +2987,9 @@ def test_delete(self): self.assertRequestDisallowed(delete_url, [None, self.user, self.editor, self.agent]) + # check can't delete user not in the current org + self.assertRequestDisallowed(reverse("orgs.user_delete", args=[self.admin2.id]), [self.admin]) + response = self.assertDeleteFetch(delete_url, [self.admin], as_modal=True) self.assertContains( response, "You are about to remove the user Agnes from your workspace. Are you sure?" diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index c36ec3eceeb..2accd0beacc 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -396,6 +396,9 @@ class Meta: def get_object_org(self): return self.request.org + def get_queryset(self): + return self.request.org.get_users() + def derive_initial(self): # viewers default to editors role = self.request.org.get_user_role(self.object) @@ -426,6 +429,9 @@ class Delete(RequireFeatureMixin, OrgObjPermsMixin, SmartDeleteView): def get_object_org(self): return self.request.org + def get_queryset(self): + return self.request.org.get_users() + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["submit_button_name"] = self.submit_button_name From efa91ae19c4c5296a2aee287d2c0e2573d7a3af3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 14:28:06 -0500 Subject: [PATCH 192/557] Update CHANGELOG.md for v9.3.68 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fba1e870d7..4a49b5bc832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.68 (2024-10-16) +------------------------- + * Tweak user update and delete forms to return 404 for users not in the current org + v9.3.67 (2024-10-16) ------------------------- * New CRUDL views for org users and invitations diff --git a/pyproject.toml b/pyproject.toml index 0814dd8ff08..f1e39697e48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.67" +version = "9.3.68" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index ccd7445a03c..3507f54bbe1 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.67" +__version__ = "9.3.68" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 0ef94e3404255dde52c48b6ecff2094d55e279c0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 19:54:56 +0000 Subject: [PATCH 193/557] Fix how we model team membership so that users can belong to different teams in different workspaces --- ...tion_role_code_invitation_team_and_more.py | 39 +++++++++++++++ temba/orgs/models.py | 47 ++++++++++++------- temba/orgs/views/views.py | 11 +---- temba/tickets/models.py | 6 +-- temba/tickets/tests.py | 22 +++++---- .../management/commands/data/mailroom_db.json | 34 ++++++++++---- .../utils/management/commands/mailroom_db.py | 4 +- 7 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 temba/orgs/migrations/0153_invitation_role_code_invitation_team_and_more.py diff --git a/temba/orgs/migrations/0153_invitation_role_code_invitation_team_and_more.py b/temba/orgs/migrations/0153_invitation_role_code_invitation_team_and_more.py new file mode 100644 index 00000000000..f666520d18f --- /dev/null +++ b/temba/orgs/migrations/0153_invitation_role_code_invitation_team_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1 on 2024-10-16 19:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("orgs", "0152_alter_invitation_user_group"), + ("tickets", "0064_shortcut"), + ] + + operations = [ + migrations.AddField( + model_name="invitation", + name="role_code", + field=models.CharField( + choices=[("A", "Administrator"), ("E", "Editor"), ("T", "Agent")], default="E", max_length=1 + ), + ), + migrations.AddField( + model_name="invitation", + name="team", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="tickets.team"), + ), + migrations.AddField( + model_name="orgmembership", + name="team", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="tickets.team"), + ), + migrations.AlterField( + model_name="invitation", + name="user_group", + field=models.CharField( + choices=[("A", "Administrator"), ("E", "Editor"), ("T", "Agent")], default="E", max_length=1 + ), + ), + ] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 3627d047305..b27924d631a 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -203,13 +203,6 @@ def get_owned_orgs(self): owned_orgs.append(org) return owned_orgs - def set_team(self, team): - """ - Sets the ticketing team for this user - """ - self.settings.team = team - self.settings.save(update_fields=("team",)) - def record_auth(self): """ Records that this user authenticated @@ -327,7 +320,6 @@ class UserSettings(models.Model): STATUS_UNVERIFIED = "U" STATUS_VERIFIED = "V" STATUS_FAILING = "F" - STATUS_CHOICES = ( (STATUS_UNVERIFIED, _("Unverified")), (STATUS_VERIFIED, _("Verified")), @@ -336,7 +328,6 @@ class UserSettings(models.Model): user = models.OneToOneField(User, on_delete=models.PROTECT, related_name="settings") language = models.CharField(max_length=8, choices=settings.LANGUAGES, default=settings.DEFAULT_LANGUAGE) - team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) otp_secret = models.CharField(max_length=16, default=pyotp.random_base32) two_factor_enabled = models.BooleanField(default=False) last_auth_on = models.DateTimeField(null=True) @@ -346,6 +337,9 @@ class UserSettings(models.Model): email_verification_secret = models.CharField(max_length=64, db_index=True) avatar = models.ImageField(upload_to=UploadToIdPathAndRename("avatars/"), storage=public_file_storage, null=True) + # deprecated + team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) + @receiver(post_save, sender=User) def on_user_post_save(sender, instance: User, created: bool, *args, **kwargs): @@ -400,6 +394,10 @@ def permissions(self) -> set: def api_permissions(self) -> set: return set(settings.API_PERMISSIONS.get(self.group_name, ())) + @classmethod + def choices(cls): + return [(r.code, r.display) for r in cls if r != cls.VIEWER] + def has_perm(self, permission: str) -> bool: """ Returns whether this role has the given permission @@ -1062,17 +1060,19 @@ def has_user(self, user: User) -> bool: """ return self.users.filter(id=user.id).exists() - def add_user(self, user: User, role: OrgRole): + def add_user(self, user: User, role: OrgRole, team=None): """ Adds the given user to this org with the given role """ assert role in OrgRole, f"invalid role: {role}" + assert role == OrgRole.AGENT or not team, "only agent users can be assigned to a team" + assert not team or team.org == self, "team must belong to this org" if self.has_user(user): # remove user from any existing roles self.remove_user(user) - self.users.add(user, through_defaults={"role_code": role.code}) + self.users.add(user, through_defaults={"role_code": role.code, "team": team}) self._user_role_cache[user] = role def remove_user(self, user: User): @@ -1436,6 +1436,7 @@ class OrgMembership(models.Model): org = models.ForeignKey(Org, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) role_code = models.CharField(max_length=1) + team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) @property def role(self): @@ -1500,16 +1501,28 @@ class Invitation(SmartModel): An invitation to an e-mail address to join an org as a specific role. """ - ROLE_CHOICES = [(r.code, r.display) for r in OrgRole] - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="invitations") email = models.EmailField() secret = models.CharField(max_length=64, unique=True) - user_group = models.CharField(max_length=1, choices=ROLE_CHOICES, default=OrgRole.EDITOR.code) + role_code = models.CharField(max_length=1, choices=OrgRole.choices(), default=OrgRole.EDITOR.code) + team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) + + # deprecated, use role_code instead + user_group = models.CharField(max_length=1, choices=OrgRole.choices(), default=OrgRole.EDITOR.code) @classmethod - def create(cls, org, user, email: str, role: OrgRole): - return cls.objects.create(org=org, email=email, user_group=role.code, created_by=user, modified_by=user) + def create(cls, org, user, email: str, role: OrgRole, team=None): + assert not team or org == team.org + + return cls.objects.create( + org=org, + email=email, + role_code=role.code, + team=team, + user_group=role.code, + created_by=user, + modified_by=user, + ) def save(self, *args, **kwargs): if not self.secret: @@ -1519,7 +1532,7 @@ def save(self, *args, **kwargs): @property def role(self): - return OrgRole.from_code(self.user_group) + return OrgRole.from_code(self.role_code or self.user_group) def send(self): sender = EmailSender.from_email_type(self.org.branding, "notifications") diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 2accd0beacc..582d2883fc3 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -379,12 +379,7 @@ def get_context_data(self, **kwargs): class Update(RequireFeatureMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class Form(forms.ModelForm): - role = forms.ChoiceField( - choices=[(r.code, r.display) for r in (OrgRole.ADMINISTRATOR, OrgRole.EDITOR, OrgRole.AGENT)], - required=True, - label=_("Role"), - widget=SelectWidget(), - ) + role = forms.ChoiceField(choices=OrgRole.choices(), required=True, label=_("Role"), widget=SelectWidget()) class Meta: model = User @@ -2147,11 +2142,9 @@ def get_context_data(self, **kwargs): class Create(RequireFeatureMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): class Form(forms.ModelForm): - ROLE_CHOICES = [(r.code, r.display) for r in (OrgRole.AGENT, OrgRole.EDITOR, OrgRole.ADMINISTRATOR)] - email = forms.EmailField(widget=InputWidget(attrs={"widget_only": True, "placeholder": _("Email Address")})) role = forms.ChoiceField( - choices=ROLE_CHOICES, initial=OrgRole.EDITOR.code, label=_("Role"), widget=SelectWidget() + choices=OrgRole.choices(), initial=OrgRole.EDITOR.code, label=_("Role"), widget=SelectWidget() ) def __init__(self, org, *args, **kwargs): diff --git a/temba/tickets/models.py b/temba/tickets/models.py index f7279465b52..d949e2288df 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -13,7 +13,7 @@ from temba import mailroom from temba.contacts.models import Contact -from temba.orgs.models import DependencyMixin, Export, ExportType, Org, User, UserSettings +from temba.orgs.models import DependencyMixin, Export, ExportType, Org, OrgMembership, User from temba.utils import chunk_list from temba.utils.dates import date_range from temba.utils.export import MultiSheetExporter @@ -418,11 +418,11 @@ def create(cls, org, user, name: str): return org.teams.create(name=name, created_by=user, modified_by=user) def get_users(self): - return User.objects.filter(settings__team=self) + return self.org.users.filter(orgmembership__team=self) def release(self, user): # remove all users from this team - UserSettings.objects.filter(team=self).update(team=None) + OrgMembership.objects.filter(org=self.org, team=self).update(team=None) self.name = self._deleted_name() self.is_active = False diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 2e5f55d7197..27068a4159e 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -9,7 +9,7 @@ from django.utils import timezone from temba.contacts.models import Contact, ContactField, ContactURN -from temba.orgs.models import Export +from temba.orgs.models import Export, OrgMembership, OrgRole from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -1314,14 +1314,15 @@ def test_release(self): class TeamTest(TembaTest): def test_create(self): team1 = Team.create(self.org, self.admin, "Sales") - self.admin.set_team(team1) - self.agent.set_team(team1) + agent2 = self.create_user("tickets@nyaruka.com") + self.org.add_user(self.agent, OrgRole.AGENT, team1) + self.org.add_user(agent2, OrgRole.AGENT, team1) self.assertEqual("Sales", team1.name) self.assertEqual("Sales", str(team1)) self.assertEqual(f'', repr(team1)) - self.assertEqual({self.admin, self.agent}, set(team1.get_users())) + self.assertEqual({self.agent, agent2}, set(team1.get_users())) # try to create with invalid name with self.assertRaises(AssertionError): @@ -1333,8 +1334,7 @@ def test_create(self): def test_release(self): team1 = Team.create(self.org, self.admin, "Sales") - self.admin.set_team(team1) - self.agent.set_team(team1) + self.org.add_user(self.agent, OrgRole.AGENT, team1) team1.release(self.admin) @@ -1347,8 +1347,8 @@ def test_release(self): class TicketDailyCountTest(TembaTest): def test_model(self): sales = Team.create(self.org, self.admin, "Sales") - self.agent.set_team(sales) - self.editor.set_team(sales) + self.org.add_user(self.agent, OrgRole.AGENT, sales) + self.org.add_user(self.editor, OrgRole.AGENT, sales) self._record_opening(self.org, date(2022, 4, 30)) self._record_opening(self.org, date(2022, 5, 3)) @@ -1443,9 +1443,11 @@ def _record_assignment(self, org, user, d: date): def _record_reply(self, org, user, d: date): TicketDailyCount.objects.create(count_type=TicketDailyCount.TYPE_REPLY, scope=f"o:{org.id}", day=d, count=1) - if user.settings.team: + + team = OrgMembership.objects.get(org=org, user=user).team + if team: TicketDailyCount.objects.create( - count_type=TicketDailyCount.TYPE_REPLY, scope=f"t:{user.settings.team.id}", day=d, count=1 + count_type=TicketDailyCount.TYPE_REPLY, scope=f"t:{team.id}", day=d, count=1 ) TicketDailyCount.objects.create( count_type=TicketDailyCount.TYPE_REPLY, scope=f"o:{org.id}:u:{user.id}", day=d, count=1 diff --git a/temba/utils/management/commands/data/mailroom_db.json b/temba/utils/management/commands/data/mailroom_db.json index b84d518a1a0..8d8347f3242 100644 --- a/temba/utils/management/commands/data/mailroom_db.json +++ b/temba/utils/management/commands/data/mailroom_db.json @@ -13,15 +13,13 @@ "email": "admin1@nyaruka.com", "role": "A", "first_name": "Andy", - "last_name": "Admin", - "team": "Office" + "last_name": "Admin" }, { "email": "editor1@nyaruka.com", "role": "E", "first_name": "Ed", - "last_name": "McEditor", - "team": "Office" + "last_name": "McEditor" }, { "email": "viewer1@nyaruka.com", @@ -349,7 +347,10 @@ "name": "body", "type": "body/text", "content": "Hi {{1}}, are you still experiencing problems with {{2}}?", - "variables": {"1": 0, "2": 1}, + "variables": { + "1": 0, + "2": 1 + }, "params": [ { "type": "text" @@ -360,7 +361,14 @@ ] } ], - "variables": [{"type": "text"}, {"type": "text"}] + "variables": [ + { + "type": "text" + }, + { + "type": "text" + } + ] }, { "channel_uuid": "0f661e8b-ea9d-4bd3-9953-d368340acf91", @@ -374,7 +382,10 @@ "name": "body", "type": "body/text", "content": "Bonjour {{1}}, a tu des problems avec {{2}}?", - "variables": {"1": 0, "2": 1}, + "variables": { + "1": 0, + "2": 1 + }, "params": [ { "type": "text" @@ -385,7 +396,14 @@ ] } ], - "variables": [{"type": "text"}, {"type": "text"}] + "variables": [ + { + "type": "text" + }, + { + "type": "text" + } + ] } ] }, diff --git a/temba/utils/management/commands/mailroom_db.py b/temba/utils/management/commands/mailroom_db.py index 71e8496f367..52b22df0a6d 100644 --- a/temba/utils/management/commands/mailroom_db.py +++ b/temba/utils/management/commands/mailroom_db.py @@ -272,9 +272,7 @@ def create_users(self, spec, org): user = User.objects.create_user( u["email"], u["email"], USER_PASSWORD, first_name=u["first_name"], last_name=u["last_name"] ) - org.add_user(user, OrgRole.from_code(u["role"])) - if u.get("team"): - user.set_team(Team.objects.get(name=u["team"])) + org.add_user(user, OrgRole.from_code(u["role"]), org.teams.get(name=u["team"])) self._log(self.style.SUCCESS("OK") + "\n") From d080b8b4ce734e9639528377244a7f1088221980 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 20:28:24 +0000 Subject: [PATCH 194/557] Fix intermittent test failure --- temba/orgs/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 86a7d414c54..437f1560b60 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1,7 +1,7 @@ import io import smtplib from datetime import date, datetime, timedelta, timezone as tzone -from unittest.mock import call, patch +from unittest.mock import patch from urllib.parse import urlencode from zoneinfo import ZoneInfo @@ -1468,7 +1468,7 @@ def test_release_and_delete(self, mr_mocks): self.assertFalse(Archive.storage().exists(f"{self.org.id}/extra_file.json")) # check we've initiated search de-indexing for all deleted orgs - self.assertEqual([call(org1_child1), call(org1_child2), call(self.org)], mr_mocks.calls["org_deindex"]) + self.assertEqual({org1_child1, org1_child2, self.org}, {c.args[0] for c in mr_mocks.calls["org_deindex"]}) # we don't actually delete org objects but at this point there should be no related fields preventing that Model.delete(org1_child1) From 43e52fa40a23d28cb1115b8559ee68e018a61cdd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 20:41:07 +0000 Subject: [PATCH 195/557] Fix mailroom_db --- temba/orgs/models.py | 2 +- temba/tickets/tests.py | 10 +++++----- temba/utils/management/commands/mailroom_db.py | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index b27924d631a..d5899cf669a 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1060,7 +1060,7 @@ def has_user(self, user: User) -> bool: """ return self.users.filter(id=user.id).exists() - def add_user(self, user: User, role: OrgRole, team=None): + def add_user(self, user: User, role: OrgRole, *, team=None): """ Adds the given user to this org with the given role """ diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 27068a4159e..16f61618bd7 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1315,8 +1315,8 @@ class TeamTest(TembaTest): def test_create(self): team1 = Team.create(self.org, self.admin, "Sales") agent2 = self.create_user("tickets@nyaruka.com") - self.org.add_user(self.agent, OrgRole.AGENT, team1) - self.org.add_user(agent2, OrgRole.AGENT, team1) + self.org.add_user(self.agent, OrgRole.AGENT, team=team1) + self.org.add_user(agent2, OrgRole.AGENT, team=team1) self.assertEqual("Sales", team1.name) self.assertEqual("Sales", str(team1)) @@ -1334,7 +1334,7 @@ def test_create(self): def test_release(self): team1 = Team.create(self.org, self.admin, "Sales") - self.org.add_user(self.agent, OrgRole.AGENT, team1) + self.org.add_user(self.agent, OrgRole.AGENT, team=team1) team1.release(self.admin) @@ -1347,8 +1347,8 @@ def test_release(self): class TicketDailyCountTest(TembaTest): def test_model(self): sales = Team.create(self.org, self.admin, "Sales") - self.org.add_user(self.agent, OrgRole.AGENT, sales) - self.org.add_user(self.editor, OrgRole.AGENT, sales) + self.org.add_user(self.agent, OrgRole.AGENT, team=sales) + self.org.add_user(self.editor, OrgRole.AGENT, team=sales) self._record_opening(self.org, date(2022, 4, 30)) self._record_opening(self.org, date(2022, 5, 3)) diff --git a/temba/utils/management/commands/mailroom_db.py b/temba/utils/management/commands/mailroom_db.py index 52b22df0a6d..4d7f96d66a5 100644 --- a/temba/utils/management/commands/mailroom_db.py +++ b/temba/utils/management/commands/mailroom_db.py @@ -272,7 +272,8 @@ def create_users(self, spec, org): user = User.objects.create_user( u["email"], u["email"], USER_PASSWORD, first_name=u["first_name"], last_name=u["last_name"] ) - org.add_user(user, OrgRole.from_code(u["role"]), org.teams.get(name=u["team"])) + team = org.teams.get(name=u["team"]) if u.get("team") else None + org.add_user(user, OrgRole.from_code(u["role"]), team=team) self._log(self.style.SUCCESS("OK") + "\n") From f63d143b63f1c89aa5babd9d0fde192c55a9bb06 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 16:09:28 -0500 Subject: [PATCH 196/557] Update CHANGELOG.md for v9.3.69 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a49b5bc832..6a9353762b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.69 (2024-10-16) +------------------------- + * Fix how we model team membership so that users can belong to different teams in different workspaces + v9.3.68 (2024-10-16) ------------------------- * Tweak user update and delete forms to return 404 for users not in the current org diff --git a/pyproject.toml b/pyproject.toml index f1e39697e48..09581136896 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.68" +version = "9.3.69" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 3507f54bbe1..a2bfb1c6e38 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.68" +__version__ = "9.3.69" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 272a5c47c534dd9df2f7f0ad5e441cac17b86947 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 22:13:15 +0000 Subject: [PATCH 197/557] Data migration to set Invitation.role_code --- .../0154_backfill_invitation_role.py | 18 ++++++++++++++++++ temba/orgs/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 temba/orgs/migrations/0154_backfill_invitation_role.py diff --git a/temba/orgs/migrations/0154_backfill_invitation_role.py b/temba/orgs/migrations/0154_backfill_invitation_role.py new file mode 100644 index 00000000000..b357eeb0702 --- /dev/null +++ b/temba/orgs/migrations/0154_backfill_invitation_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2024-10-16 22:10 + +from django.db import migrations + + +def backfill_invitation_role(apps, schema_editor): # pragma: no cover + Invitation = apps.get_model("orgs", "Invitation") + + for invitation in Invitation.objects.all(): + invitation.role_code = invitation.user_group + invitation.save(update_fields=("role_code",)) + + +class Migration(migrations.Migration): + + dependencies = [("orgs", "0153_invitation_role_code_invitation_team_and_more")] + + operations = [migrations.RunPython(backfill_invitation_role, migrations.RunPython.noop)] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index d5899cf669a..c64e2582f47 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -314,7 +314,7 @@ class Meta: class UserSettings(models.Model): """ - Custom fields for users + Additional non-org specific fields for users """ STATUS_UNVERIFIED = "U" From 4f1d78e9add9ffd342fe3c5120ff9c0c0f07b247 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 22:21:26 +0000 Subject: [PATCH 198/557] Drop Invitation.user_group and UserSettings.team --- ...0155_remove_invitation_user_group_and_more.py | 13 +++++++++++++ temba/orgs/models.py | 16 ++-------------- temba/orgs/tests.py | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 temba/orgs/migrations/0155_remove_invitation_user_group_and_more.py diff --git a/temba/orgs/migrations/0155_remove_invitation_user_group_and_more.py b/temba/orgs/migrations/0155_remove_invitation_user_group_and_more.py new file mode 100644 index 00000000000..7d89c39a270 --- /dev/null +++ b/temba/orgs/migrations/0155_remove_invitation_user_group_and_more.py @@ -0,0 +1,13 @@ +# Generated by Django 5.1 on 2024-10-16 22:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("orgs", "0154_backfill_invitation_role")] + + operations = [ + migrations.RemoveField(model_name="invitation", name="user_group"), + migrations.RemoveField(model_name="usersettings", name="team"), + ] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index c64e2582f47..ac0c18e2cee 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -337,9 +337,6 @@ class UserSettings(models.Model): email_verification_secret = models.CharField(max_length=64, db_index=True) avatar = models.ImageField(upload_to=UploadToIdPathAndRename("avatars/"), storage=public_file_storage, null=True) - # deprecated - team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) - @receiver(post_save, sender=User) def on_user_post_save(sender, instance: User, created: bool, *args, **kwargs): @@ -1507,21 +1504,12 @@ class Invitation(SmartModel): role_code = models.CharField(max_length=1, choices=OrgRole.choices(), default=OrgRole.EDITOR.code) team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) - # deprecated, use role_code instead - user_group = models.CharField(max_length=1, choices=OrgRole.choices(), default=OrgRole.EDITOR.code) - @classmethod def create(cls, org, user, email: str, role: OrgRole, team=None): assert not team or org == team.org return cls.objects.create( - org=org, - email=email, - role_code=role.code, - team=team, - user_group=role.code, - created_by=user, - modified_by=user, + org=org, email=email, role_code=role.code, team=team, created_by=user, modified_by=user ) def save(self, *args, **kwargs): @@ -1532,7 +1520,7 @@ def save(self, *args, **kwargs): @property def role(self): - return OrgRole.from_code(self.role_code or self.user_group) + return OrgRole.from_code(self.role_code) def send(self): sender = EmailSender.from_email_type(self.org.branding, "notifications") diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 437f1560b60..744a54a697c 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -138,7 +138,7 @@ def test_model(self): def test_expire_task(self): invitation1 = Invitation.objects.create( org=self.org, - user_group="E", + role_code="E", email="neweditor@nyaruka.com", created_by=self.admin, created_on=timezone.now() - timedelta(days=31), @@ -146,7 +146,7 @@ def test_expire_task(self): ) invitation2 = Invitation.objects.create( org=self.org, - user_group="T", + role_code="T", email="newagent@nyaruka.com", created_by=self.admin, created_on=timezone.now() - timedelta(days=29), From f84ddd2905fa80a2a5e6a3c35da78c8384d7601a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 17:25:36 -0500 Subject: [PATCH 199/557] Update CHANGELOG.md for v9.3.70 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9353762b1..12bda7b78a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.70 (2024-10-16) +------------------------- + * Data migration to set Invitation.role_code + v9.3.69 (2024-10-16) ------------------------- * Fix how we model team membership so that users can belong to different teams in different workspaces diff --git a/pyproject.toml b/pyproject.toml index 09581136896..ab21652a517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.69" +version = "9.3.70" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a2bfb1c6e38..d533e4da38e 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.69" +__version__ = "9.3.70" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 0d60d9de5d89d8b492c38baf250039749495a69d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 23:03:48 +0000 Subject: [PATCH 200/557] Fix invitations count on org menu to exclude expired invitations --- temba/orgs/views/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 582d2883fc3..558e928e3de 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -1038,7 +1038,7 @@ def derive_menu(self): name=_("Invitations"), icon="invitations", href="orgs.invitation_list", - count=org.invitations.count(), + count=org.invitations.filter(is_active=True).count(), ) ) From 91481ac6a054665fc3764e8817237c14bf38a533 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 22:57:42 +0000 Subject: [PATCH 201/557] Move org service view to staff app --- temba/orgs/tests.py | 79 +-------------------------------- temba/orgs/views/mixins.py | 2 +- temba/orgs/views/views.py | 34 -------------- temba/staff/tests.py | 70 +++++++++++++++++++++++++++++ temba/staff/views.py | 38 ++++++++++++++-- temba/tests/crudl.py | 2 +- templates/frame.html | 2 +- templates/orgs/org_service.html | 2 +- 8 files changed, 110 insertions(+), 119 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 437f1560b60..9cbad4e67bc 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -24,15 +24,7 @@ from temba.channels.models import Channel, ChannelLog, SyncEvent from temba.classifiers.models import Classifier from temba.classifiers.types.wit import WitType -from temba.contacts.models import ( - URN, - Contact, - ContactExport, - ContactField, - ContactGroup, - ContactImport, - ContactImportBatch, -) +from temba.contacts.models import URN, ContactExport, ContactField, ContactGroup, ContactImport, ContactImportBatch from temba.flows.models import Flow, FlowLabel, FlowRun, FlowSession, FlowStart, FlowStartCount, ResultsExport from temba.globals.models import Global from temba.locations.models import AdminBoundary @@ -2753,75 +2745,6 @@ def test_login_case_not_sensitive(self): self.assertIn("form", response.context) self.assertTrue(response.context["form"].errors) - @mock_mailroom - def test_service(self, mr_mocks): - service_url = reverse("orgs.org_service") - inbox_url = reverse("msgs.msg_inbox") - - # without logging in, try to service our main org - response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) - self.assertLoginRedirect(response) - - response = self.client.post(service_url, {"other_org": self.org.id}) - self.assertLoginRedirect(response) - - # try logging in with a normal user - self.login(self.admin) - - # same thing, no permission - response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) - self.assertLoginRedirect(response) - - response = self.client.post(service_url, {"other_org": self.org.id}) - self.assertLoginRedirect(response) - - # ok, log in as our cs rep - self.login(self.customer_support) - - # getting invalid org, has no service form - response = self.client.get(service_url, {"other_org": 325253256, "next": inbox_url}) - self.assertContains(response, "Invalid org") - - # posting invalid org just redirects back to manage page - response = self.client.post(service_url, {"other_org": 325253256}) - self.assertRedirect(response, "/staff/org/") - - # then service our org - response = self.client.get(service_url, {"other_org": self.org.id}) - self.assertContains(response, "You are about to service the workspace, Nyaruka.") - - # requesting a next page has a slightly different message - response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) - self.assertContains(response, "The page you are requesting belongs to a different workspace, Nyaruka.") - - response = self.client.post(service_url, {"other_org": self.org.id}) - self.assertRedirect(response, "/msg/") - self.assertEqual(self.org.id, self.client.session["org_id"]) - self.assertTrue(self.client.session["servicing"]) - - # specify redirect_url - response = self.client.post(service_url, {"other_org": self.org.id, "next": "/flow/"}) - self.assertRedirect(response, "/flow/") - - # create a new contact - response = self.client.post( - reverse("contacts.contact_create"), data=dict(name="Ben Haggerty", phone="0788123123") - ) - self.assertNoFormErrors(response) - - # make sure that contact's created on is our cs rep - contact = Contact.objects.get(urns__path="+250788123123", org=self.org) - self.assertEqual(self.customer_support, contact.created_by) - - self.assertEqual(self.org.id, self.client.session["org_id"]) - self.assertTrue(self.client.session["servicing"]) - - # stop servicing - response = self.client.post(service_url, {}) - self.assertRedirect(response, "/staff/org/") - self.assertIsNone(self.client.session["org_id"]) - self.assertFalse(self.client.session["servicing"]) - def test_languages(self): settings_url = reverse("orgs.org_workspace") langs_url = reverse("orgs.org_languages") diff --git a/temba/orgs/views/mixins.py b/temba/orgs/views/mixins.py index d25853afa97..54416920568 100644 --- a/temba/orgs/views/mixins.py +++ b/temba/orgs/views/mixins.py @@ -83,7 +83,7 @@ def pre_process(self, request, *args, **kwargs): org = self.get_object_org() if request.user.is_staff and self.request.org != org: return HttpResponseRedirect( - f"{reverse('orgs.org_service')}?next={quote_plus(request.path)}&other_org={org.id}" + f"{reverse('staff.org_service')}?next={quote_plus(request.path)}&other_org={org.id}" ) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 582d2883fc3..b06182f5e9e 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -27,7 +27,6 @@ from django.contrib.auth.views import LoginView as AuthLoginView from django.core.exceptions import ValidationError from django.db.models.functions import Lower -from django.forms import ModelChoiceField from django.http import Http404, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, resolve_url from django.urls import reverse, reverse_lazy @@ -63,7 +62,6 @@ PostOnlyMixin, RequireRecentAuthMixin, SpaMixin, - StaffOnlyMixin, ) from ..models import ( @@ -962,7 +960,6 @@ class OrgCRUDL(SmartCRUDL): "export", "prometheus", "resthooks", - "service", "flow_smtp", "workspace", ) @@ -1450,37 +1447,6 @@ def post(self, request, *args, **kwargs): self.object.release(request.user) return self.render_modal_response() - class Service(StaffOnlyMixin, SmartFormView): - class ServiceForm(forms.Form): - other_org = ModelChoiceField(queryset=Org.objects.all(), widget=forms.HiddenInput()) - next = forms.CharField(widget=forms.HiddenInput(), required=False) - - form_class = ServiceForm - fields = ("other_org", "next") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["other_org"] = Org.objects.filter(id=self.request.GET.get("other_org")).first() - context["next"] = self.request.GET.get("next", "") - return context - - def derive_initial(self): - initial = super().derive_initial() - initial["other_org"] = self.request.GET.get("other_org", "") - initial["next"] = self.request.GET.get("next", "") - return initial - - # valid form means we set our org and redirect to their inbox - def form_valid(self, form): - switch_to_org(self.request, form.cleaned_data["other_org"], servicing=True) - success_url = form.cleaned_data["next"] or reverse("msgs.msg_inbox") - return HttpResponseRedirect(success_url) - - # invalid form login 'logs out' the user from the org and takes them to the org manage page - def form_invalid(self, form): - switch_to_org(self.request, None) - return HttpResponseRedirect(reverse("staff.org_list")) - class SubOrgs(SpaMixin, ContextMenuMixin, OrgPermsMixin, InferOrgMixin, SmartListView): title = _("Workspaces") menu_path = "/settings/workspaces" diff --git a/temba/staff/tests.py b/temba/staff/tests.py index ede3947255b..b2a64fd4931 100644 --- a/temba/staff/tests.py +++ b/temba/staff/tests.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import Group from django.urls import reverse +from temba.contacts.models import Contact from temba.orgs.models import Org, OrgMembership, OrgRole from temba.tests import CRUDLTestMixin, TembaTest, mock_mailroom from temba.utils.views.mixins import TEMBA_MENU_SELECTION @@ -136,6 +137,75 @@ def assertOrgFilter(query: str, expected_orgs: list): self.org.refresh_from_db() self.assertTrue(self.org.is_verified) + @mock_mailroom + def test_service(self, mr_mocks): + service_url = reverse("staff.org_service") + inbox_url = reverse("msgs.msg_inbox") + + # without logging in, try to service our main org + response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) + self.assertLoginRedirect(response) + + response = self.client.post(service_url, {"other_org": self.org.id}) + self.assertLoginRedirect(response) + + # try logging in with a normal user + self.login(self.admin) + + # same thing, no permission + response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) + self.assertLoginRedirect(response) + + response = self.client.post(service_url, {"other_org": self.org.id}) + self.assertLoginRedirect(response) + + # ok, log in as our cs rep + self.login(self.customer_support) + + # getting invalid org, has no service form + response = self.client.get(service_url, {"other_org": 325253256, "next": inbox_url}) + self.assertContains(response, "Invalid org") + + # posting invalid org just redirects back to manage page + response = self.client.post(service_url, {"other_org": 325253256}) + self.assertRedirect(response, "/staff/org/") + + # then service our org + response = self.client.get(service_url, {"other_org": self.org.id}) + self.assertContains(response, "You are about to service the workspace, Nyaruka.") + + # requesting a next page has a slightly different message + response = self.client.get(service_url, {"other_org": self.org.id, "next": inbox_url}) + self.assertContains(response, "The page you are requesting belongs to a different workspace, Nyaruka.") + + response = self.client.post(service_url, {"other_org": self.org.id}) + self.assertRedirect(response, "/msg/") + self.assertEqual(self.org.id, self.client.session["org_id"]) + self.assertTrue(self.client.session["servicing"]) + + # specify redirect_url + response = self.client.post(service_url, {"other_org": self.org.id, "next": "/flow/"}) + self.assertRedirect(response, "/flow/") + + # create a new contact + response = self.client.post( + reverse("contacts.contact_create"), data=dict(name="Ben Haggerty", phone="0788123123") + ) + self.assertNoFormErrors(response) + + # make sure that contact's created on is our cs rep + contact = Contact.objects.get(urns__path="+250788123123", org=self.org) + self.assertEqual(self.customer_support, contact.created_by) + + self.assertEqual(self.org.id, self.client.session["org_id"]) + self.assertTrue(self.client.session["servicing"]) + + # stop servicing + response = self.client.post(service_url, {}) + self.assertRedirect(response, "/staff/org/") + self.assertIsNone(self.client.session["org_id"]) + self.assertFalse(self.client.session["servicing"]) + class UserCRUDLTest(TembaTest, CRUDLTestMixin): def test_list(self): diff --git a/temba/staff/views.py b/temba/staff/views.py index a51b04f4f19..c5f42843d5b 100644 --- a/temba/staff/views.py +++ b/temba/staff/views.py @@ -2,7 +2,7 @@ from smartmin.users.models import FailedLogin, PasswordHistory from smartmin.users.views import UserUpdateForm -from smartmin.views import SmartCRUDL, SmartDeleteView, SmartListView, SmartReadView, SmartUpdateView +from smartmin.views import SmartCRUDL, SmartDeleteView, SmartFormView, SmartListView, SmartReadView, SmartUpdateView from django import forms from django.conf import settings @@ -14,6 +14,7 @@ from django.views.decorators.csrf import csrf_exempt from temba.orgs.models import Org, OrgRole, User +from temba.orgs.views import switch_to_org from temba.utils import get_anonymous_user from temba.utils.fields import SelectMultipleWidget from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin, StaffOnlyMixin @@ -21,7 +22,7 @@ class OrgCRUDL(SmartCRUDL): model = Org - actions = ("read", "update", "list") + actions = ("read", "update", "list", "service") class Read(StaffOnlyMixin, SpaMixin, ContextMenuMixin, SmartReadView): def build_context_menu(self, menu): @@ -55,7 +56,7 @@ def build_context_menu(self, menu): menu.new_group() menu.add_url_post( _("Service"), - f'{reverse("orgs.org_service")}?other_org={obj.id}&next={reverse("msgs.msg_inbox", args=[])}', + f'{reverse("staff.org_service")}?other_org={obj.id}&next={reverse("msgs.msg_inbox", args=[])}', ) def get_context_data(self, **kwargs): @@ -226,6 +227,37 @@ def pre_save(self, obj): obj.limits = cleaned_data["limits"] return obj + class Service(StaffOnlyMixin, SmartFormView): + class ServiceForm(forms.Form): + other_org = forms.ModelChoiceField(queryset=Org.objects.all(), widget=forms.HiddenInput()) + next = forms.CharField(widget=forms.HiddenInput(), required=False) + + form_class = ServiceForm + fields = ("other_org", "next") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["other_org"] = Org.objects.filter(id=self.request.GET.get("other_org")).first() + context["next"] = self.request.GET.get("next", "") + return context + + def derive_initial(self): + initial = super().derive_initial() + initial["other_org"] = self.request.GET.get("other_org", "") + initial["next"] = self.request.GET.get("next", "") + return initial + + # valid form means we set our org and redirect to their inbox + def form_valid(self, form): + switch_to_org(self.request, form.cleaned_data["other_org"], servicing=True) + success_url = form.cleaned_data["next"] or reverse("msgs.msg_inbox") + return HttpResponseRedirect(success_url) + + # invalid form login 'logs out' the user from the org and takes them to the org manage page + def form_invalid(self, form): + switch_to_org(self.request, None) + return HttpResponseRedirect(reverse("staff.org_list")) + class UserCRUDL(SmartCRUDL): model = User diff --git a/temba/tests/crudl.py b/temba/tests/crudl.py index c9b6f763574..77d4bdd66c4 100644 --- a/temba/tests/crudl.py +++ b/temba/tests/crudl.py @@ -384,7 +384,7 @@ def check(self, test_cls, response, msg_prefix): class StaffRedirect(BaseCheck): def check(self, test_cls, response, msg_prefix): - test_cls.assertRedirect(response, reverse("orgs.org_service"), msg=f"{msg_prefix}: expected staff redirect") + test_cls.assertRedirect(response, reverse("staff.org_service"), msg=f"{msg_prefix}: expected staff redirect") class LoginRedirectOr404(BaseCheck): diff --git a/templates/frame.html b/templates/frame.html index da580d5ac63..ccbfcdb2351 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -183,7 +183,7 @@ right:0; bottom:0" class="servicing absolute bg-secondary my-2 mr-20 rounded shadow-xl"> - diff --git a/templates/orgs/org_service.html b/templates/orgs/org_service.html index d290e834089..1f89358375c 100644 --- a/templates/orgs/org_service.html +++ b/templates/orgs/org_service.html @@ -20,7 +20,7 @@ You are about to service the workspace, {{ other_org.name }}. {% endif %} -
    + {% for field in form.fields %} {% render_field field %} {% endfor %} From 72be6633b77d65734551e281007836c92dfc4193 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 19:04:25 -0500 Subject: [PATCH 202/557] Update CHANGELOG.md for v9.3.71 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12bda7b78a5..faf9c28103c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.71 (2024-10-17) +------------------------- + * Fix invitations count on org menu to exclude expired invitations + v9.3.70 (2024-10-16) ------------------------- * Data migration to set Invitation.role_code diff --git a/pyproject.toml b/pyproject.toml index ab21652a517..00b936a9cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.70" +version = "9.3.71" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index d533e4da38e..73b2ef83e66 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.70" +__version__ = "9.3.71" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 40ac05beb5a3b5f2cbb587c3fb3ee2713f6c03cb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 14:13:23 +0000 Subject: [PATCH 203/557] Move template --- templates/{orgs => staff}/org_service.html | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename templates/{orgs => staff}/org_service.html (100%) diff --git a/templates/orgs/org_service.html b/templates/staff/org_service.html similarity index 100% rename from templates/orgs/org_service.html rename to templates/staff/org_service.html From 747759c2a51789ee92b55d50b8b54cc3a708c4af Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 09:38:44 -0500 Subject: [PATCH 204/557] Update CHANGELOG.md for v9.3.72 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faf9c28103c..69601f424a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.72 (2024-10-17) +------------------------- + * Move org service view to staff app + * Drop Invitation.user_group and UserSettings.team + v9.3.71 (2024-10-17) ------------------------- * Fix invitations count on org menu to exclude expired invitations diff --git a/pyproject.toml b/pyproject.toml index 00b936a9cf5..08cc12a683d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.71" +version = "9.3.72" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 73b2ef83e66..fc335daca3d 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.71" +__version__ = "9.3.72" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From d9ef55149b40aa9c0cae8f125d3137cef7c1167b Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Thu, 17 Oct 2024 18:14:08 +0200 Subject: [PATCH 205/557] More clarifications on FC channel claim --- static/css/tailwind.css | 210 ++++++++---------- static/scss/tailwind.scss | 154 ++++++------- templates/channels/types/freshchat/claim.html | 7 +- 3 files changed, 164 insertions(+), 207 deletions(-) diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 12b8e308df1..a4340a0c812 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -563,11 +563,11 @@ video { } .trans-border { - box-shadow: 0 0px 0px 4px rgba(0, 0, 0, .1); + box-shadow: 0 0px 0px 4px rgba(0, 0, 0, 0.1); } .bg-dark-alpha { - background: rgba(0, 0, 0, .2); + background: rgba(0, 0, 0, 0.2); } .text-tertiary { @@ -579,7 +579,7 @@ video { } .trans-lined-box { - box-shadow: 0 0px 0px 4px rgba(0, 0, 0, .1); + box-shadow: 0 0px 0px 4px rgba(0, 0, 0, 0.1); max-height: 0; --tw-bg-opacity: 1; background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); @@ -592,7 +592,7 @@ video { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 200ms + transition-duration: 200ms; } .font-primary { @@ -697,17 +697,13 @@ a:hover { } .button:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-light { @@ -735,17 +731,13 @@ a:hover { } .button-light:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-light:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-light { @@ -757,9 +749,7 @@ a:hover { .button-light:hover { cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-action { @@ -787,17 +777,13 @@ a:hover { } .button-action:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-action:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-action { @@ -809,9 +795,7 @@ a:hover { .button-action:hover { cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-action { @@ -851,17 +835,13 @@ a:hover { } .button-tertiary:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-tertiary:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-tertiary { @@ -895,17 +875,13 @@ a:hover { } .button-secondary:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-secondary:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-secondary { @@ -939,17 +915,13 @@ a:hover { } .button-primary:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-primary:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-primary { @@ -983,17 +955,13 @@ a:hover { } .button-white:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-white:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-white { @@ -1027,17 +995,13 @@ a:hover { } .button-danger:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-danger:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-danger { @@ -1071,17 +1035,13 @@ a:hover { } .button-dark-alpha:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-dark-alpha:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-dark-alpha { @@ -1115,17 +1075,13 @@ a:hover { } .button-sm:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-sm:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } .button-sm { @@ -1148,7 +1104,7 @@ a:hover { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); transform: none; cursor: pointer; - box-shadow: inset 0 0 20px 10px rgba(255, 255, 255, .2); + box-shadow: inset 0 0 20px 10px rgba(255, 255, 255, 0.2); } .lift { @@ -1186,7 +1142,8 @@ a:hover { cursor: pointer; color: rgba(0, 0, 0, .5); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .3); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.3); } .lbl.linked:active { @@ -1219,7 +1176,8 @@ a:hover { cursor: pointer; color: rgba(0, 0, 0, .5); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .3); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.3); } .lbl-primary.linked:active { @@ -1235,7 +1193,8 @@ a:hover { cursor: pointer; color: rgba(255, 255, 255, .9); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } .lbl-primary { @@ -1247,7 +1206,8 @@ a:hover { --tw-text-opacity: 1; color: rgba(255, 255, 255, var(--tw-text-opacity)); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .1); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.1); } .lbl-primary.linked:active { @@ -1294,7 +1254,8 @@ a:hover { cursor: pointer; color: rgba(0, 0, 0, .5); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .3); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.3); } .lbl-group.linked:active { @@ -1310,7 +1271,8 @@ a:hover { cursor: pointer; color: rgba(255, 255, 255, .9); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } .lbl-group { @@ -1319,7 +1281,7 @@ a:hover { } .lbl-group temba-icon { - --icon-color: rgba(0,0,0,.5); + --icon-color: rgba(0, 0, 0, 0.5); margin-right: 3px; } @@ -1331,7 +1293,8 @@ a:hover { cursor: pointer; color: rgba(255, 255, 255, .9); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } .lbl-group.inverted { @@ -1342,11 +1305,12 @@ a:hover { cursor: pointer; color: rgba(255, 255, 255, .9); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } .lbl-group.inverted temba-icon { - --icon-color: rgba(255, 255, 255, .9); + --icon-color: rgba(255, 255, 255, 0.9); } .lbl-secondary { @@ -1374,7 +1338,8 @@ a:hover { cursor: pointer; color: rgba(0, 0, 0, .5); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .3); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.3); } .lbl-secondary.linked:active { @@ -1390,7 +1355,8 @@ a:hover { cursor: pointer; color: rgba(255, 255, 255, .9); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } .lbl-secondary { @@ -1407,7 +1373,8 @@ a:hover { --tw-text-opacity: 1; color: rgba(255, 255, 255, var(--tw-text-opacity)); text-decoration: none; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .1); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.1); } .alert { @@ -1451,7 +1418,7 @@ a:hover { .alert-danger, .alert-error { - background: rgba(255, 181, 181, .17); + background: rgba(255, 181, 181, 0.17); } .max-h-128 { @@ -1470,11 +1437,11 @@ a:hover { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); color: rgba(255, 255, 255, .9); color: rgba(var(--error-rgb, 1)); - background: rgba(255, 181, 181, .17); + background: rgba(255, 181, 181, 0.17); } .alert-text .title { - font-weight: 400 + font-weight: 400; } .cap-label { @@ -1505,41 +1472,41 @@ a:hover { font-size: 0.75rem; text-align: left; text-transform: uppercase; - letter-spacing: 0.05em + letter-spacing: 0.05em; } .page-title { font-size: 1.875rem; --tw-text-opacity: 1; color: rgba(74, 74, 74, var(--tw-text-opacity)); - --icon-color: rgb(77,77,77); + --icon-color: rgb(77, 77, 77); } .page-subtitle { font-size: 1.5rem; --tw-text-opacity: 1; - color: rgba(113, 113, 113, var(--tw-text-opacity)) + color: rgba(113, 113, 113, var(--tw-text-opacity)); } .title { font-size: 1.5rem; margin-bottom: 0.25rem; --tw-text-opacity: 1; - color: rgba(74, 74, 74, var(--tw-text-opacity)) + color: rgba(74, 74, 74, var(--tw-text-opacity)); } .subtitle { font-size: 1.25rem; margin-bottom: 0.25rem; --tw-text-opacity: 1; - color: rgba(74, 74, 74, var(--tw-text-opacity)) + color: rgba(74, 74, 74, var(--tw-text-opacity)); } table.padded td { - padding: 0.75rem + padding: 0.75rem; } -table.list{ +table.list { --tw-bg-opacity: 1; background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); border-radius: 0.5rem; @@ -1562,7 +1529,7 @@ table.list.scrolled { } table.list tr:first-child td { - border-top:none; + border-top: none; } table.list tr.checked td temba-checkbox, table.list tr.checked td:hover temba-checkbox { @@ -1598,8 +1565,8 @@ table.list.toggle thead tr:hover th { table.list.toggle thead tr th:last-child::after { transition: all 200ms ease-in-out; - content: "\e05c"; - color: rgba(0, 0, 0, .3); + content: '\e05c'; + color: rgba(0, 0, 0, 0.3); font-size: 12px; float: right; margin: -3px 0px; @@ -1614,7 +1581,7 @@ table.list.toggle tbody tr td { border: 0px; } -table.list.toggle tbody tr td>* { +table.list.toggle tbody tr td > * { max-height: 0px; } @@ -1634,7 +1601,7 @@ table.list.expanded tbody tr td { padding: 14px; } -table.list.expanded tbody tr td>* { +table.list.expanded tbody tr td > * { max-height: 500px; } @@ -1662,11 +1629,13 @@ table.list.lined td { } table.list tbody tr.warning { - background: repeating-linear-gradient(-55deg, - rgba(207, 127, 127, 0.06), - rgba(207, 127, 127, 0.06) 5px, - rgba(207, 127, 127, 0.08) 5px, - rgba(207, 127, 127, 0.08) 10px); + background: repeating-linear-gradient( + -55deg, + rgba(207, 127, 127, 0.06), + rgba(207, 127, 127, 0.06) 5px, + rgba(207, 127, 127, 0.08) 5px, + rgba(207, 127, 127, 0.08) 10px + ); border-color: rgba(207, 127, 127, 0.35); } @@ -1715,7 +1684,7 @@ table.list.selectable tbody tr td { } table.list.selectable tbody tr.hovered td { - background: rgba(var(--selection-light-rgb), .4); + background: rgba(var(--selection-light-rgb), 0.4); cursor: pointer; } @@ -1724,7 +1693,7 @@ table.list.selectable tbody td:hover temba-checkbox { } table.list.selectable tbody tr.checked td { - background: rgba(var(--selection-light-rgb), .4); + background: rgba(var(--selection-light-rgb), 0.4); border-color: rgba(var(--selection-light-rgb), 1); } @@ -1832,7 +1801,7 @@ table.header { } .lp-frame .right { - flex-grow: 1 + flex-grow: 1; } .action-buttons .linked, .gear-menu .linked { @@ -1879,6 +1848,11 @@ code { color: #2980b9; } +ol.steps, +ul.steps { + line-height: 2; +} + .card { --tw-bg-opacity: 1; background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); @@ -1983,7 +1957,11 @@ code { border-width: 1px; --tw-shadow: 0 0 #0000; box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); - background-color: rgba(0,0,0,.015); + } + +.formax .formax-section.action-summary, + .formax .formax-section.action-link { + background-color: rgba(0, 0, 0, 0.015); } .formax .formax-section.action-summary .formax-summary, .formax .formax-section.action-link .formax-summary { @@ -2035,7 +2013,7 @@ code { .formax .formax-section .formax-container { display: grid; grid-template-rows: min-content 0fr; - transition: grid-template-rows .5s; + transition: grid-template-rows 0.5s; transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275); } @@ -2075,7 +2053,7 @@ code { .formax .formax-section.wide.open .formax-icon .i-container { width: 0px; margin-top: 20px; - margin-left:-25px; + margin-left: -25px; } .formax .formax-section.open { @@ -2092,8 +2070,8 @@ code { } .formax .formax-section.open .formax-icon { - padding:.5em 1em !important; - flex-basis:0 !important; + padding: 0.5em 1em !important; + flex-basis: 0 !important; } .formax .formax-section.open .formax-icon .margin-wrapper { @@ -2116,7 +2094,7 @@ code { .formax .formax-section.open .formax-summary { height: 0px; - padding:0px; + padding: 0px; } .control-group { @@ -2124,7 +2102,7 @@ code { } .text-shadow { - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.10); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .text-shadow-md { @@ -15743,7 +15721,7 @@ code { } .md\:text-shadow { - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.10); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .md\:text-shadow-md { @@ -29205,4 +29183,4 @@ code { .md\:animate-bounce { animation: bounce 1s infinite; } -} \ No newline at end of file +} diff --git a/static/scss/tailwind.scss b/static/scss/tailwind.scss index 899f1f6c3e4..4bb432b4aaa 100644 --- a/static/scss/tailwind.scss +++ b/static/scss/tailwind.scss @@ -1,8 +1,6 @@ @tailwind base; @tailwind components; - - .bg-gradient { background-repeat: no-repeat; background-attachment: fixed; @@ -10,11 +8,11 @@ } .trans-border { - box-shadow: 0 0px 0px 4px rgba(0, 0, 0, .1); + box-shadow: 0 0px 0px 4px rgba(0, 0, 0, 0.1); } .bg-dark-alpha { - background: rgba(0, 0, 0, .2); + background: rgba(0, 0, 0, 0.2); } .text-tertiary { @@ -26,7 +24,7 @@ } .trans-lined-box { - @apply py-4 rounded-lg trans-border absolute bg-white text-primary max-h-0 transition-all duration-200 ease-in-out + @apply py-4 rounded-lg trans-border absolute bg-white text-primary max-h-0 transition-all duration-200 ease-in-out; } .font-primary { @@ -105,20 +103,14 @@ a { margin-top: 1px; &:active { - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } &:hover { text-decoration: none; cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), - + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } - } .button-light { @@ -126,9 +118,7 @@ a { &:hover { cursor: pointer; - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.1), - 0 1px 2px 0 rgba(0, 0, 0, 0.06), + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } } @@ -172,7 +162,7 @@ a { @apply shadow; transform: none; cursor: pointer; - box-shadow: inset 0 0 20px 10px rgba(255, 255, 255, .2); + box-shadow: inset 0 0 20px 10px rgba(255, 255, 255, 0.2); } } @@ -189,7 +179,8 @@ a { &.linked:hover { @apply no-underline cursor-pointer text-dark-alpha-500; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .3); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.3); } &.linked:active { @@ -202,7 +193,8 @@ a { &.linked:hover { @apply no-underline cursor-pointer text-white; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .1); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.1); } &.linked:active { @@ -210,7 +202,6 @@ a { } } - .lbl-icon { @apply text-dark-alpha-300; font-size: 11px; @@ -230,7 +221,7 @@ a { display: inline-flex; align-items: center; temba-icon { - --icon-color: rgba(0,0,0,.5); + --icon-color: rgba(0, 0, 0, 0.5); margin-right: 3px; } } @@ -240,7 +231,8 @@ a { &.linked:hover { @apply no-underline cursor-pointer text-light-alpha-900; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } } @@ -249,11 +241,12 @@ a { &.linked:hover { @apply no-underline cursor-pointer text-light-alpha-900; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(0, 0, 0, .05); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(0, 0, 0, 0.05); } temba-icon { - --icon-color: rgba(255, 255, 255, .9); + --icon-color: rgba(255, 255, 255, 0.9); } } @@ -262,7 +255,8 @@ a { &.linked:hover { @apply no-underline bg-secondary text-white cursor-pointer; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), inset 0 0 20px 20px rgba(255, 255, 255, .1); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06), + inset 0 0 20px 20px rgba(255, 255, 255, 0.1); } } @@ -283,7 +277,7 @@ a { .alert-danger, .alert-error { @apply alert text-error; - background: rgba(255, 181, 181, .17); + background: rgba(255, 181, 181, 0.17); } .max-h-128 { @@ -299,10 +293,10 @@ a { @apply text-light-alpha-900 p-4 rounded shadow; color: rgba(var(--error-rgb, 1)); - background: rgba(255, 181, 181, .17); + background: rgba(255, 181, 181, 0.17); .title { - @apply font-normal + @apply font-normal; } } @@ -315,33 +309,33 @@ a { } .table-header { - @apply border-b border-gray-300 bg-gray-200 text-left font-medium uppercase text-xs tracking-wider + @apply border-b border-gray-300 bg-gray-200 text-left font-medium uppercase text-xs tracking-wider; } .page-title { @apply text-3xl text-gray-700; - --icon-color: rgb(77,77,77); + --icon-color: rgb(77, 77, 77); } .page-subtitle { - @apply text-2xl text-gray-600 + @apply text-2xl text-gray-600; } .title { - @apply text-2xl text-gray-700 mb-1 + @apply text-2xl text-gray-700 mb-1; } .subtitle { - @apply text-xl text-gray-700 mb-1 + @apply text-xl text-gray-700 mb-1; } table.padded { td { - @apply p-3 + @apply p-3; } } -table.list{ +table.list { @apply w-full shadow rounded-lg bg-white overflow-hidden; &.sticky { @@ -359,17 +353,17 @@ table.list{ tr:first-child { td { - border-top:none; + border-top: none; } } tr { &.checked { - td, td:hover { + td, + td:hover { temba-checkbox { --icon-color: #444 !important; } - } } @@ -383,7 +377,6 @@ table.list{ } } - &.light { thead { tr th { @@ -393,7 +386,6 @@ table.list{ } &.toggle { - thead { @apply cursor-pointer select-none; @@ -408,8 +400,8 @@ table.list{ &:last-child { &::after { transition: all 200ms ease-in-out; - content: "\e05c"; - color: rgba(0, 0, 0, .3); + content: '\e05c'; + color: rgba(0, 0, 0, 0.3); font-size: 12px; float: right; margin: -3px 0px; @@ -425,13 +417,12 @@ table.list{ border: 0px; } - tbody tr td>* { + tbody tr td > * { max-height: 0px; } } &.expanded { - &.lined { @apply border-solid border-gray-300 border-l-0 border-r-0; } @@ -448,10 +439,9 @@ table.list{ padding: 14px; } - tbody tr td>* { + tbody tr td > * { max-height: 500px; } - } th { @@ -465,15 +455,16 @@ table.list{ } tbody tr.warning { - background: repeating-linear-gradient(-55deg, - rgba(207, 127, 127, 0.06), - rgba(207, 127, 127, 0.06) 5px, - rgba(207, 127, 127, 0.08) 5px, - rgba(207, 127, 127, 0.08) 10px); + background: repeating-linear-gradient( + -55deg, + rgba(207, 127, 127, 0.06), + rgba(207, 127, 127, 0.06) 5px, + rgba(207, 127, 127, 0.08) 5px, + rgba(207, 127, 127, 0.08) 10px + ); border-color: rgba(207, 127, 127, 0.35); td { - .icon-docs-2, .icon-cloud-upload { @apply text-error; @@ -512,7 +503,6 @@ table.list{ } } - &.tight, &.tight.expanded { tbody { @@ -537,7 +527,6 @@ table.list{ } } - &.selectable { tbody { tr { @@ -548,7 +537,7 @@ table.list{ tr.hovered { td { - background: rgba(var(--selection-light-rgb), .4); + background: rgba(var(--selection-light-rgb), 0.4); @apply cursor-pointer; } } @@ -560,16 +549,11 @@ table.list{ } tr.checked { - - td { - - background: rgba(var(--selection-light-rgb), .4); + background: rgba(var(--selection-light-rgb), 0.4); border-color: rgba(var(--selection-light-rgb), 1); - } } - } } } @@ -611,7 +595,6 @@ table.header { @apply w-64 mr-5; .lp-nav { - &.upper { @apply p-3 pr-4 w-64 mt-2; } @@ -639,7 +622,7 @@ table.header { } .right { - @apply flex-grow + @apply flex-grow; } } @@ -650,7 +633,8 @@ table.header { } } -.warning {} +.warning { +} code { @apply px-2 py-1 rounded-lg text-base; @@ -659,7 +643,11 @@ code { .code { @apply bg-dark-alpha-30 rounded-lg px-1 py-1 leading-tight text-base font-mono inline-block mx-1 whitespace-nowrap; color: #2980b9; +} +ol.steps, +ul.steps { + line-height: 2; } .card { @@ -722,7 +710,6 @@ code { .formax { .formax-section { - margin-left: 0px; margin-right: 0px; @@ -732,10 +719,11 @@ code { box-shadow: var(--widget-box-shadow-focused); } - &.action-summary, &.action-link { + &.action-summary, + &.action-link { @apply border border-gray-400 shadow-none bg-transparent; - background-color: rgba(0,0,0,.015); + background-color: rgba(0, 0, 0, 0.015); .formax-summary { @apply py-6 !important; @@ -771,18 +759,17 @@ code { } .i-container { - width: 30px; - &:before {} + &:before { + } } - } .formax-container { display: grid; grid-template-rows: min-content 0fr; - transition: grid-template-rows .5s; + transition: grid-template-rows 0.5s; transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275); .formax-summary { @@ -806,7 +793,6 @@ code { grid-template-rows: min-content 1fr; } - &.wide.open { margin-left: -115px; margin-right: -115px; @@ -819,11 +805,11 @@ code { .i-container { width: 0px; margin-top: 20px; - margin-left:-25px; + margin-left: -25px; } } } - + &.open { flex-direction: column; margin-left: -30px; @@ -835,15 +821,14 @@ code { } .formax-icon { - padding:.5em 1em !important; - flex-basis:0 !important; - + padding: 0.5em 1em !important; + flex-basis: 0 !important; + .margin-wrapper { margin: 0px !important; } .i-container { - } @apply bg-primary text-white p-12; @@ -855,7 +840,7 @@ code { .formax-summary { height: 0px; - padding:0px; + padding: 0px; } } } @@ -867,7 +852,7 @@ code { @responsive { .text-shadow { - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.10); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .text-shadow-md { @@ -884,7 +869,6 @@ code { } @keyframes bounceFromLeft { - from, 60%, 75%, @@ -917,7 +901,6 @@ code { } @keyframes bounceInUp { - from, 60%, 75%, @@ -970,7 +953,6 @@ code { .disabled { @apply text-gray-300; --icon-color: #ccc; - } .filter { @@ -979,10 +961,6 @@ code { temba-icon { @apply mr-1; } - - } - - -@tailwind utilities; \ No newline at end of file +@tailwind utilities; diff --git a/templates/channels/types/freshchat/claim.html b/templates/channels/types/freshchat/claim.html index 0d7d12a086a..6c37e2354e2 100644 --- a/templates/channels/types/freshchat/claim.html +++ b/templates/channels/types/freshchat/claim.html @@ -19,13 +19,14 @@
  • {% blocktrans trimmed %} - Under Settings again, select Webhooks and copy the Public Key and paste it below. This assures all webhook request from FreshChat will be authenticated. + Under Settings again, select Webhooks or Conversation Webhooks (if you do not see the options, search for that and make sure the feature is enabled) + and then use the Copy button to copy the Public Key (usually RSA Public Key) and paste it below. This assures all webhook request from FreshChat will be authenticated. {% endblocktrans %}
  • {% blocktrans trimmed %} - Lastly, you'll need the UUID of the Agent that {{ name }} will use when it sends FreshChat message. This is available via - the FreshChat API. + Lastly, you'll need the UUID of the Agent that {{ name }} will use when it sends FreshChat message. + This is available via the FreshChat API. {% endblocktrans %}
  • From 25027980a5d25626c42af6d834a9f1f84440aaec Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 16:32:55 +0000 Subject: [PATCH 206/557] Overhaul UI for managing child workspaces --- temba/orgs/tests.py | 76 +++++++--------------- temba/orgs/views/views.py | 76 +++++++++++----------- temba/settings_common.py | 3 +- templates/orgs/org_list.html | 75 ++++++++++++++++++++++ templates/orgs/org_sub_orgs.html | 105 ------------------------------- 5 files changed, 137 insertions(+), 198 deletions(-) create mode 100644 templates/orgs/org_list.html delete mode 100644 templates/orgs/org_sub_orgs.html diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 1c48843a6ec..473402514ae 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1677,11 +1677,11 @@ def test_workspace(self): self.admin, [ "Nyaruka", - "Workspaces (1)", - "Dashboard", "Account", "Resthooks", "Incidents", + "Workspaces (2)", + "Dashboard", "Users (4)", "Invitations (0)", "Export", @@ -2344,29 +2344,15 @@ def test_signup(self): self.assertFalse(User.objects.filter(email="myal@relieves.org")) def test_create_new(self): - children_url = reverse("orgs.org_sub_orgs") create_url = reverse("orgs.org_create") - self.login(self.admin) - - # by default orgs don't have this feature - response = self.client.get(children_url) - self.assertContentMenu(children_url, self.admin, []) - - # trying to access the modal directly should redirect - response = self.client.get(create_url) - self.assertRedirect(response, "/org/workspace/") + # nobody can access if new orgs feature not enabled + response = self.requestView(create_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) self.org.features = [Org.FEATURE_NEW_ORGS] self.org.save(update_fields=("features",)) - response = self.client.get(children_url) - self.assertContentMenu(children_url, self.admin, ["New Workspace"]) - - # give org2 the same feature - self.org2.features = [Org.FEATURE_NEW_ORGS] - self.org2.save(update_fields=("features",)) - # since we can only create new orgs, we don't show type as an option self.assertRequestDisallowed(create_url, [None, self.user, self.editor, self.agent]) self.assertCreateFetch(create_url, [self.admin], form_fields=["name", "timezone"]) @@ -2398,24 +2384,18 @@ def test_create_new(self): self.assertEqual(str(new_org.id), response.headers["X-Temba-Org"]) def test_create_child(self): - children_url = reverse("orgs.org_sub_orgs") + list_url = reverse("orgs.org_list") create_url = reverse("orgs.org_create") - self.login(self.admin) - - # by default orgs don't have the new_orgs or child_orgs feature - response = self.client.get(children_url) - self.assertContentMenu(children_url, self.admin, []) - - # trying to access the modal directly should redirect - response = self.client.get(create_url) - self.assertRedirect(response, "/org/workspace/") + # nobody can access if child orgs feature not enabled + response = self.requestView(create_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) self.org.features = [Org.FEATURE_CHILD_ORGS] self.org.save(update_fields=("features",)) - response = self.client.get(children_url) - self.assertContentMenu(children_url, self.admin, ["New Workspace"]) + response = self.client.get(list_url) + self.assertContentMenu(list_url, self.admin, ["New"]) # give org2 the same feature self.org2.features = [Org.FEATURE_CHILD_ORGS] @@ -2447,7 +2427,7 @@ def test_create_child(self): self.assertEqual(OrgRole.ADMINISTRATOR, child_org.get_user_role(self.admin)) # should have been redirected to child management page - self.assertRedirect(response, "/org/sub_orgs/") + self.assertRedirect(response, "/org/") def test_create_child_or_new(self): create_url = reverse("orgs.org_create") @@ -2491,17 +2471,10 @@ def test_create_child_spa(self): response = self.client.post(create_url, {"name": "Child Org", "timezone": "Africa/Nairobi"}, HTTP_TEMBA_SPA=1) - self.assertRedirect(response, reverse("orgs.org_sub_orgs")) - - def test_child_management(self): - sub_orgs_url = reverse("orgs.org_sub_orgs") - menu_url = reverse("orgs.org_menu") + "settings/" + self.assertRedirect(response, reverse("orgs.org_list")) - self.login(self.admin) - - response = self.client.get(menu_url) - self.assertNotContains(response, "Workspaces") - self.assertNotContains(response, sub_orgs_url) + def test_list(self): + list_url = reverse("orgs.org_list") # enable child orgs and create some child orgs self.org.features = [Org.FEATURE_CHILD_ORGS, Org.FEATURE_USERS] @@ -2509,15 +2482,8 @@ def test_child_management(self): child1 = self.org.create_new(self.admin, "Child Org 1", self.org.timezone, as_child=True) child2 = self.org.create_new(self.admin, "Child Org 2", self.org.timezone, as_child=True) - # now we see the Workspaces menu item - self.login(self.admin, choose_org=self.org) - - response = self.client.get(menu_url) - self.assertContains(response, "Workspaces") - self.assertContains(response, sub_orgs_url) - response = self.assertListFetch( - sub_orgs_url, [self.admin], context_objects=[child1, child2], choose_org=self.org + list_url, [self.admin], context_objects=[self.org, child1, child2], choose_org=self.org ) child1_edit_url = reverse("orgs.org_edit_sub_org") + f"?org={child1.id}" @@ -2528,11 +2494,11 @@ def test_child_management(self): child1_edit_url, {"name": "New Child Name", "timezone": "Africa/Nairobi", "date_format": "Y", "language": "es"}, ) - self.assertEqual(sub_orgs_url, response.url) + self.assertEqual(list_url, response.url) child1.refresh_from_db() self.assertEqual("New Child Name", child1.name) - self.assertEqual("/org/sub_orgs/", response.url) + self.assertEqual("/org/", response.url) # edit our sub org's details in a spa view response = self.client.post( @@ -2541,7 +2507,7 @@ def test_child_management(self): HTTP_TEMBA_SPA=1, ) - self.assertEqual(reverse("orgs.org_sub_orgs"), response.url) + self.assertEqual(list_url, response.url) child1.refresh_from_db() self.assertEqual("Spa Child Name", child1.name) @@ -2692,9 +2658,9 @@ def test_delete_child(self): response = self.client.get(delete_url) self.assertContains(response, "You are about to delete the workspace Child Workspace") - # go through with it, redirects to main workspace page + # go through with it, redirects to workspaces list page response = self.client.post(delete_url) - self.assertEqual(reverse("orgs.org_sub_orgs"), response["Temba-Success"]) + self.assertEqual(reverse("orgs.org_list"), response["Temba-Success"]) child.refresh_from_db() self.assertFalse(child.is_active) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 2052e940e31..67826caa21a 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -26,6 +26,7 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView as AuthLoginView from django.core.exceptions import ValidationError +from django.db.models import Q from django.db.models.functions import Lower from django.http import Http404, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, resolve_url @@ -955,7 +956,7 @@ class OrgCRUDL(SmartCRUDL): "menu", "country", "languages", - "sub_orgs", + "list", "create", "export", "prometheus", @@ -988,24 +989,6 @@ def derive_menu(self): ) ] - if self.has_org_perm("orgs.org_sub_orgs") and Org.FEATURE_CHILD_ORGS in org.features: - children = org.children.filter(is_active=True).count() - item = self.create_menu_item(name=_("Workspaces"), icon="children", href="orgs.org_sub_orgs") - if children: - item["count"] = children - menu.append(item) - - if self.has_org_perm("orgs.org_dashboard") and Org.FEATURE_CHILD_ORGS in org.features: - menu.append( - self.create_menu_item( - menu_id="dashboard", - name=_("Dashboard"), - icon="dashboard", - href="dashboard.dashboard_home", - perm="orgs.org_dashboard", - ) - ) - if self.request.user.is_authenticated: menu.append( self.create_menu_item( @@ -1023,6 +1006,26 @@ def derive_menu(self): self.create_menu_item(name=_("Incidents"), icon="incidents", href="notifications.incident_list") ) + if Org.FEATURE_CHILD_ORGS in org.features and self.has_org_perm("orgs.org_list"): + menu.append(self.create_divider()) + menu.append( + self.create_menu_item( + name=_("Workspaces"), + icon="children", + href="orgs.org_list", + count=org.children.filter(is_active=True).count() + 1, + ) + ) + menu.append( + self.create_menu_item( + menu_id="dashboard", + name=_("Dashboard"), + icon="dashboard", + href="dashboard.dashboard_home", + perm="orgs.org_dashboard", + ) + ) + if Org.FEATURE_USERS in org.features and self.has_org_perm("orgs.user_list"): menu.append(self.create_divider()) menu.append( @@ -1425,8 +1428,8 @@ def extract_from(smtp_url: str) -> str: return context class DeleteChild(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): - cancel_url = "@orgs.org_sub_orgs" - success_url = "@orgs.org_sub_orgs" + cancel_url = "@orgs.org_list" + success_url = "@orgs.org_list" fields = ("id",) submit_button_name = _("Delete") @@ -1447,25 +1450,26 @@ def post(self, request, *args, **kwargs): self.object.release(request.user) return self.render_modal_response() - class SubOrgs(SpaMixin, ContextMenuMixin, OrgPermsMixin, InferOrgMixin, SmartListView): + class List(SpaMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, SmartListView): + require_feature = Org.FEATURE_CHILD_ORGS title = _("Workspaces") menu_path = "/settings/workspaces" + search_fields = ("name__icontains",) def build_context_menu(self, menu): - org = self.get_object() - - enabled = Org.FEATURE_CHILD_ORGS in org.features or Org.FEATURE_NEW_ORGS in org.features - if self.has_org_perm("orgs.org_create") and enabled: - menu.add_modax(_("New Workspace"), "new_workspace", reverse("orgs.org_create")) + if self.has_org_perm("orgs.org_create"): + menu.add_modax(_("New"), "new_workspace", reverse("orgs.org_create"), as_button=True) def derive_queryset(self, **kwargs): - queryset = super().derive_queryset(**kwargs) - - # all our children - org = self.get_object() - ids = [child.id for child in Org.objects.filter(parent=org)] + qs = super().derive_queryset(**kwargs) - return queryset.filter(id__in=ids, is_active=True).order_by("-parent", "name") + # return this org and its children + org = self.request.org + return ( + qs.filter(Q(id=org.id) | Q(id__in=[c.id for c in org.children.all()])) + .filter(is_active=True) + .order_by("-parent", "name") + ) class Create(NonAtomicMixin, RequireFeatureMixin, ModalFormMixin, InferOrgMixin, OrgPermsMixin, SmartCreateView): class Form(forms.ModelForm): @@ -1504,7 +1508,7 @@ def derive_fields(self): def get_success_url(self): # if we created a child org, redirect to its management if self.object.is_child: - return reverse("orgs.org_sub_orgs") + return reverse("orgs.org_list") # if we created a new separate org, switch to it switch_to_org(self.request, self.object) @@ -1940,8 +1944,8 @@ class Meta: def derive_exclude(self): return ["language"] if len(settings.LANGUAGES) == 1 else [] - class EditSubOrg(SpaMixin, ModalFormMixin, Edit): - success_url = "@orgs.org_sub_orgs" + class EditSubOrg(ModalFormMixin, Edit): + success_url = "@orgs.org_list" def get_success_url(self): return super().get_success_url() diff --git a/temba/settings_common.py b/temba/settings_common.py index f0174ba1a1d..bac91575de8 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -393,7 +393,6 @@ "service", "signup", "spa", - "sub_orgs", "trial", "twilio_account", "twilio_connect", @@ -494,12 +493,12 @@ "orgs.org_export", "orgs.org_flow_smtp", "orgs.org_languages", + "orgs.org_list", "orgs.org_manage_integrations", "orgs.org_menu", "orgs.org_prometheus", "orgs.org_read", "orgs.org_resthooks", - "orgs.org_sub_orgs", "orgs.org_workspace", "orgs.orgimport.*", "orgs.user_list", diff --git a/templates/orgs/org_list.html b/templates/orgs/org_list.html new file mode 100644 index 00000000000..6ce47c5a5a0 --- /dev/null +++ b/templates/orgs/org_list.html @@ -0,0 +1,75 @@ +{% extends "smartmin/list.html" %} +{% load i18n temba smartmin humanize %} + +{% block content %} + {% block pre-table %} + + + + + {% endblock pre-table %} + + + + + +
    {% include "includes/short_pagination.html" %}
    +
    +
  • {{ obj.role.display }} - {% if manage_users %} - {{ org.users.all|length }} - {% else %} - {{ org.users.all|length }} - {% endif %} - {{ org.users.all|length }} {{ org.get_contact_count|intcomma }} {{ org.created_on|day }} From a3a3c4041a350b10c78116193fe96a3d06b8d654 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Mon, 14 Oct 2024 23:01:01 +0200 Subject: [PATCH 184/557] Fix claim number to display non field errors --- templates/channels/channel_claim_number.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/channels/channel_claim_number.html b/templates/channels/channel_claim_number.html index 6db22961047..a399eac995c 100644 --- a/templates/channels/channel_claim_number.html +++ b/templates/channels/channel_claim_number.html @@ -49,10 +49,14 @@ - {% else %} + {% elif form.errors.phone_number %} {{ form.errors.phone_number }} + {% elif form.non_field_errors %} + + {{ form.non_field_errors }} + {% endif %} {% endif %} From 99ca427336ac222a6ab3d4783c61f7062c20b4d1 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 16 Oct 2024 11:50:56 +0200 Subject: [PATCH 185/557] Fix displaying the log missing HTTP response --- temba/channels/models.py | 4 +++- temba/channels/tests.py | 26 ++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 01dc56cfa25..316b893f31f 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -944,7 +944,9 @@ def get_display(self, *, anonymize: bool, urn) -> dict: # out of an abundance of caution, check that we're not returning one of our own credential values for log in data["http_logs"]: for secret in self.channel.type.get_redact_values(self.channel): - assert secret not in log["url"] and secret not in log["request"] and secret not in log["response"] + assert ( + secret not in log["url"] and secret not in log["request"] and secret not in log.get("response", "") + ) return data diff --git a/temba/channels/tests.py b/temba/channels/tests.py index 16274dc24f0..763a7cd8fad 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -1451,16 +1451,25 @@ def test_get_display(self): ) def test_get_display_timed_out(self): - channel = self.create_channel("TG", "Telegram", "mybot") - contact = self.create_contact("Fred Jones", urns=["telegram:74747474"]) + channel = self.create_channel( + "D3C", + "360Dialog channel", + address="1234", + country="BR", + config={ + Channel.CONFIG_BASE_URL: "https://waba-v2.360dialog.io", + Channel.CONFIG_AUTH_TOKEN: "123456789", + }, + ) + contact = self.create_contact("Bob", urns=["whatsapp:75757575"]) log = ChannelLog.objects.create( channel=channel, log_type=ChannelLog.LOG_TYPE_MSG_SEND, is_error=True, http_logs=[ { - "url": "https://telegram.com/send?to=74747474", - "request": 'POST https://telegram.com/send?to=74747474 HTTP/1.1\r\n\r\n{"to":"74747474"}', + "url": "https://waba-v2.360dialog.io/send?to=75757575", + "request": 'POST https://waba-v2.360dialog.io/send?to=75757575 HTTP/1.1\r\n\r\n{"to":"75757575"}', "elapsed_ms": 30001, "retries": 0, "created_on": "2022-08-17T14:07:30Z", @@ -1476,8 +1485,8 @@ def test_get_display_timed_out(self): "type": "msg_send", "http_logs": [ { - "url": "https://telegram.com/send?to=74747474", - "request": 'POST https://telegram.com/send?to=74747474 HTTP/1.1\r\n\r\n{"to":"74747474"}', + "url": "https://waba-v2.360dialog.io/send?to=75757575", + "request": 'POST https://waba-v2.360dialog.io/send?to=75757575 HTTP/1.1\r\n\r\n{"to":"75757575"}', "elapsed_ms": 30001, "retries": 0, "created_on": "2022-08-17T14:07:30Z", @@ -1489,14 +1498,15 @@ def test_get_display_timed_out(self): }, log.get_display(anonymize=False, urn=msg_out.contact_urn), ) + self.assertEqual( { "uuid": str(log.uuid), "type": "msg_send", "http_logs": [ { - "url": "https://telegram.com/send?to=********", - "request": 'POST https://telegram.com/send?to=******** HTTP/1.1\r\n\r\n{"to":"********"}', + "url": "https://waba-v2.360dialog.io/send?to=********", + "request": 'POST https://waba-v2.360dialog.io/send?to=******** HTTP/1.1\r\n\r\n{"to":"********"}', "response": "", "elapsed_ms": 30001, "retries": 0, From fc71e725161547286d9927dccf0549aa0fa52a32 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 09:06:24 -0500 Subject: [PATCH 186/557] Update CHANGELOG.md for v9.3.66 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7019502bd2..e706de7c69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.66 (2024-10-16) +------------------------- + * Fix displaying the channel log missing HTTP response + * Fix claim number to display non field errors + * Remove support for user management of sub-orgs without switching to those orgs + v9.3.65 (2024-10-10) ------------------------- * Add mixin for views that require a feature diff --git a/pyproject.toml b/pyproject.toml index 83464bb87b7..ccd93f506f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.65" +version = "9.3.66" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a89645dd708..8c684c0ffac 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.65" +__version__ = "9.3.66" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From e4e95eeec0ebd19b06d3248dd767a72261bd3e25 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 15:05:10 +0000 Subject: [PATCH 187/557] Remove old user management view --- temba/orgs/tests.py | 143 +-------------------- temba/orgs/views/views.py | 159 +----------------------- temba/settings_common.py | 2 - templates/orgs/org_manage_accounts.html | 70 ----------- templates/orgs/org_sub_orgs.html | 2 +- 5 files changed, 7 insertions(+), 369 deletions(-) delete mode 100644 templates/orgs/org_manage_accounts.html diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 03de4cf00db..d11421062e6 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -92,21 +92,21 @@ def test_group_perms_wrapper(self): self.assertTrue(perms["msgs"]["msg_list"]) self.assertTrue(perms["contacts"]["contact_update"]) self.assertTrue(perms["orgs"]["org_country"]) - self.assertTrue(perms["orgs"]["org_manage_accounts"]) + self.assertTrue(perms["orgs"]["user_list"]) self.assertTrue(perms["orgs"]["org_delete_child"]) perms = RolePermsWrapper(OrgRole.EDITOR) self.assertTrue(perms["msgs"]["msg_list"]) self.assertTrue(perms["contacts"]["contact_update"]) - self.assertFalse(perms["orgs"]["org_manage_accounts"]) + self.assertFalse(perms["orgs"]["user_list"]) self.assertFalse(perms["orgs"]["org_delete_child"]) perms = RolePermsWrapper(OrgRole.VIEWER) self.assertTrue(perms["msgs"]["msg_list"]) self.assertFalse(perms["contacts"]["contact_update"]) - self.assertFalse(perms["orgs"]["org_manage_accounts"]) + self.assertFalse(perms["orgs"]["user_list"]) self.assertFalse(perms["orgs"]["org_delete_child"]) self.assertFalse(perms["msgs"]["foo"]) # no blow up if perm doesn't exist @@ -1543,143 +1543,6 @@ def test_contacts(self): class OrgCRUDLTest(TembaTest, CRUDLTestMixin): - def test_manage_accounts(self): - accounts_url = reverse("orgs.org_manage_accounts") - settings_url = reverse("orgs.org_workspace") - - # nobody can access if we don't have users feature - self.login(self.admin) - self.assertRedirect(self.client.get(accounts_url), settings_url) - - self.org.features = [Org.FEATURE_USERS] - self.org.save(update_fields=("features",)) - - # create invitations - invitation1 = Invitation.create(self.org, self.admin, "norkans7@gmail.com", OrgRole.ADMINISTRATOR) - invitation2 = Invitation.create(self.org, self.admin, "bob@tickets.com", OrgRole.AGENT) - - # add a second editor to the org - editor2 = self.create_user("editor2@nyaruka.com", first_name="Edwina") - self.org.add_user(editor2, OrgRole.EDITOR) - - # only admins can access - self.assertRequestDisallowed(accounts_url, [None, self.user, self.editor]) - - # order should be users by email, then invitations by email - expected_fields = [] - for user in self.org.users.order_by("email"): - expected_fields.extend([f"user_{user.id}_role", f"user_{user.id}_remove"]) - for inv in self.org.invitations.order_by("email"): - expected_fields.extend([f"invite_{inv.id}_role", f"invite_{inv.id}_remove"]) - - response = self.assertUpdateFetch(accounts_url, [self.admin], form_fields=expected_fields) - - self.assertEqual("A", response.context["form"].fields[f"user_{self.admin.id}_role"].initial) - self.assertEqual("E", response.context["form"].fields[f"user_{self.editor.id}_role"].initial) - self.assertEqual("V", response.context["form"].fields[f"user_{self.user.id}_role"].initial) - self.assertEqual("T", response.context["form"].fields[f"user_{self.agent.id}_role"].initial) - - # only a user which is already a viewer has the option to stay a viewer - self.assertEqual( - [("A", "Administrator"), ("E", "Editor"), ("T", "Agent")], - response.context["form"].fields[f"user_{self.admin.id}_role"].choices, - ) - self.assertEqual( - [("A", "Administrator"), ("E", "Editor"), ("T", "Agent"), ("V", "Viewer")], - response.context["form"].fields[f"user_{self.user.id}_role"].choices, - ) - - self.assertContains(response, "norkans7@gmail.com") - - # give users an API token - APIToken.create(self.org, self.admin) - APIToken.create(self.org, self.editor) - APIToken.create(self.org, editor2) - - # leave admin, editor and agent as is, but change user to an editor too, and remove the second editor - response = self.assertUpdateSubmit( - accounts_url, - self.admin, - { - f"user_{self.admin.id}_role": "A", - f"user_{self.editor.id}_role": "E", - f"user_{self.user.id}_role": "E", - f"user_{editor2.id}_role": "E", - f"user_{editor2.id}_remove": "1", - f"user_{self.agent.id}_role": "T", - }, - ) - self.assertRedirect(response, reverse("orgs.org_manage_accounts")) - - self.assertEqual({self.admin, self.agent, self.editor, self.user}, set(self.org.users.all())) - self.assertEqual({self.admin}, set(self.org.get_users(roles=[OrgRole.ADMINISTRATOR]))) - self.assertEqual({self.user, self.editor}, set(self.org.get_users(roles=[OrgRole.EDITOR]))) - self.assertEqual(set(), set(self.org.get_users(roles=[OrgRole.VIEWER]))) - self.assertEqual({self.agent}, set(self.org.get_users(roles=[OrgRole.AGENT]))) - - # pretend our first invite was acted on - invitation1.release() - - # no longer appears in list - response = self.client.get(accounts_url) - self.assertNotContains(response, "norkans7@gmail.com") - - # try to remove ourselves as admin - response = self.assertUpdateSubmit( - accounts_url, - self.admin, - { - f"user_{self.admin.id}_role": "A", - f"user_{self.admin.id}_remove": "1", - f"user_{self.editor.id}_role": "E", - f"user_{self.user.id}_role": "E", - f"user_{self.agent.id}_role": "T", - }, - form_errors={"__all__": "A workspace must have at least one administrator."}, - object_unchanged=self.org, - ) - - # try to downgrade ourselves to an editor - response = self.assertUpdateSubmit( - accounts_url, - self.admin, - { - f"user_{self.admin.id}_role": "E", - f"user_{self.editor.id}_role": "E", - f"user_{self.user.id}_role": "E", - f"user_{self.agent.id}_role": "T", - }, - form_errors={"__all__": "A workspace must have at least one administrator."}, - object_unchanged=self.org, - ) - - # finally upgrade agent to admin, downgrade editor to agent, remove ourselves entirely and remove last invite - response = self.assertUpdateSubmit( - accounts_url, - self.admin, - { - f"user_{self.admin.id}_role": "A", - f"user_{self.admin.id}_remove": "1", - f"user_{self.editor.id}_role": "T", - f"user_{self.user.id}_role": "E", - f"user_{self.agent.id}_role": "A", - f"invite_{invitation2.id}_remove": "1", - }, - ) - - # we should be redirected to chooser page - self.assertRedirect(response, reverse("orgs.org_choose")) - - self.assertEqual(0, self.org.invitations.filter(is_active=True).count()) - - # and removed from this org - self.org.refresh_from_db() - self.assertEqual(set(self.org.users.all()), {self.agent, self.editor, self.user}) - self.assertEqual({self.agent}, set(self.org.get_users(roles=[OrgRole.ADMINISTRATOR]))) - self.assertEqual({self.user}, set(self.org.get_users(roles=[OrgRole.EDITOR]))) - self.assertEqual(set(), set(self.org.get_users(roles=[OrgRole.VIEWER]))) - self.assertEqual({self.editor}, set(self.org.get_users(roles=[OrgRole.AGENT]))) - def test_menu(self): menu_url = reverse("orgs.org_menu") diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 892e1b61727..5046834b206 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -962,7 +962,6 @@ class OrgCRUDL(SmartCRUDL): "grant", "choose", "delete_child", - "manage_accounts", "menu", "country", "languages", @@ -1028,13 +1027,10 @@ def derive_menu(self): ) ) - if self.has_org_perm("orgs.org_manage_accounts") and Org.FEATURE_USERS in org.features: + if self.has_org_perm("orgs.user_list") and Org.FEATURE_USERS in org.features: menu.append( self.create_menu_item( - name=_("Users"), - icon="users", - href="orgs.org_manage_accounts", - count=org.users.count(), + name=_("Users"), icon="users", href="orgs.user_list", count=org.users.count() ) ) @@ -1453,155 +1449,6 @@ def post(self, request, *args, **kwargs): self.object.release(request.user) return self.render_modal_response() - class ManageAccounts( - SpaMixin, InferOrgMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, SmartUpdateView - ): - class AccountsForm(forms.ModelForm): - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.org = org - self.user_rows = [] - self.invite_rows = [] - self.add_per_user_fields(org) - self.add_per_invite_fields(org) - - def add_per_user_fields(self, org: Org): - role_choices = [(r.code, r.display) for r in (OrgRole.ADMINISTRATOR, OrgRole.EDITOR, OrgRole.AGENT)] - role_choices_inc_viewer = role_choices + [(OrgRole.VIEWER.code, OrgRole.VIEWER.display)] - - for user in org.users.order_by("email"): - role = org.get_user_role(user) - - role_field = forms.ChoiceField( - choices=role_choices_inc_viewer if role == OrgRole.VIEWER else role_choices, - required=True, - initial=role.code, - label=" ", - widget=SelectWidget(), - ) - remove_field = forms.BooleanField( - required=False, label=" ", widget=CheckboxWidget(attrs={"widget_only": True}) - ) - - self.fields.update( - OrderedDict([(f"user_{user.id}_role", role_field), (f"user_{user.id}_remove", remove_field)]) - ) - self.user_rows.append( - {"user": user, "role_field": f"user_{user.id}_role", "remove_field": f"user_{user.id}_remove"} - ) - - def add_per_invite_fields(self, org: Org): - for invite in org.invitations.filter(is_active=True).order_by("email"): - role_field = forms.ChoiceField( - choices=[(r.code, r.display) for r in (OrgRole.ADMINISTRATOR, OrgRole.EDITOR, OrgRole.AGENT)], - required=True, - initial=invite.role.code, - label=" ", - widget=SelectWidget(), - disabled=True, - ) - remove_field = forms.BooleanField( - required=False, label=" ", widget=CheckboxWidget(attrs={"widget_only": True}) - ) - - self.fields.update( - OrderedDict( - [(f"invite_{invite.id}_role", role_field), (f"invite_{invite.id}_remove", remove_field)] - ) - ) - self.invite_rows.append( - { - "invite": invite, - "role_field": f"invite_{invite.id}_role", - "remove_field": f"invite_{invite.id}_remove", - } - ) - - def get_submitted_roles(self) -> dict: - """ - Returns a dict of users to roles from the current form data. None role means removal. - """ - roles = {} - - for row in self.user_rows: - role = self.cleaned_data.get(row["role_field"]) - remove = self.cleaned_data.get(row["remove_field"]) - roles[row["user"]] = OrgRole.from_code(role) if not remove else None - return roles - - def get_submitted_invite_removals(self) -> list: - """ - Returns a list of invites to be removed. - """ - invites = [] - for row in self.invite_rows: - if self.cleaned_data[row["remove_field"]]: - invites.append(row["invite"]) - return invites - - def clean(self): - super().clean() - - new_roles = self.get_submitted_roles() - has_admin = False - for new_role in new_roles.values(): - if new_role == OrgRole.ADMINISTRATOR: - has_admin = True - break - - if not has_admin: - raise forms.ValidationError(_("A workspace must have at least one administrator.")) - - class Meta: - model = Invitation - fields = () - - form_class = AccountsForm - success_url = "@orgs.org_manage_accounts" - title = _("Users") - menu_path = "/settings/users" - require_feature = Org.FEATURE_USERS - - def build_context_menu(self, menu): - menu.add_modax(_("Invite"), "invite-create", reverse("orgs.invitation_create"), as_button=True) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["org"] = self.get_object() - return kwargs - - def post_save(self, obj): - obj = super().post_save(obj) - org = self.get_object() - - # delete any invitations which have been checked for removal - for invite in self.form.get_submitted_invite_removals(): - org.invitations.filter(id=invite.id).delete() - - # update org users with new roles - for user, new_role in self.form.get_submitted_roles().items(): - if not new_role: - org.remove_user(user) - elif org.get_user_role(user) != new_role: - org.add_user(user, new_role) - - return obj - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - org = self.get_object() - context["org"] = org - context["has_invites"] = org.invitations.filter(is_active=True).exists() - context["has_viewers"] = org.get_users(roles=[OrgRole.VIEWER]).exists() - return context - - def get_success_url(self): - still_in_org = self.get_object().has_user(self.request.user) or self.request.user.is_staff - - # if current user no longer belongs to this org, redirect to org chooser - return reverse("orgs.org_manage_accounts") if still_in_org else reverse("orgs.org_choose") - class Service(StaffOnlyMixin, SmartFormView): class ServiceForm(forms.Form): other_org = ModelChoiceField(queryset=Org.objects.all(), widget=forms.HiddenInput()) @@ -2325,7 +2172,7 @@ class Meta: require_feature = Org.FEATURE_USERS title = "" submit_button_name = _("Send") - success_url = "@orgs.org_manage_accounts" + success_url = "@orgs.invitation_list" def get_form_kwargs(self): kwargs = super().get_form_kwargs() diff --git a/temba/settings_common.py b/temba/settings_common.py index fd3e846483c..f0174ba1a1d 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -385,7 +385,6 @@ "join_accept", "join", "languages", - "manage_accounts", "manage_integrations", "manage", "menu", @@ -495,7 +494,6 @@ "orgs.org_export", "orgs.org_flow_smtp", "orgs.org_languages", - "orgs.org_manage_accounts", "orgs.org_manage_integrations", "orgs.org_menu", "orgs.org_prometheus", diff --git a/templates/orgs/org_manage_accounts.html b/templates/orgs/org_manage_accounts.html deleted file mode 100644 index fbf248ea675..00000000000 --- a/templates/orgs/org_manage_accounts.html +++ /dev/null @@ -1,70 +0,0 @@ -{% extends "smartmin/form.html" %} -{% load smartmin temba compress i18n %} - -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} -{% block fields %} - {% if has_viewers %} - - {% blocktrans trimmed with cutoff="2024-12-31"|day %} - The Viewer role for users is being removed. Please update any users with that role or remove from your - workspace. After {{ cutoff }} these users will no longer be able to access the workspace. - {% endblocktrans %} - - {% endif %} - - - - - - - - - - {% for row in form.user_rows %} - - - - - - {% endfor %} - - {% if has_invites %} - - - - {% endif %} - {% for row in form.invite_rows %} - - - - - - {% endfor %} - -
    {% trans "Login" %}{% trans "Role" %} -
    {% trans "Remove" %}
    -
    {% render_field row.role_field %} -
    {% render_field row.remove_field %}
    -
    {% trans "Pending Invitations" %}
    {{ row.invite.email }}{% render_field row.role_field %} -
    {% render_field row.remove_field %}
    -
    -{% endblock fields %} -{% block form-buttons %} -
    - -
    -{% endblock form-buttons %} diff --git a/templates/orgs/org_sub_orgs.html b/templates/orgs/org_sub_orgs.html index 07504f3e214..4d309dd2837 100644 --- a/templates/orgs/org_sub_orgs.html +++ b/templates/orgs/org_sub_orgs.html @@ -79,7 +79,7 @@
    {% if org.id == user_org.id %} - {{ org.name }} + {{ org.name }} {% else %}
    {{ org.name }}
    {% endif %} From 27c6ca4ca1fd5f7292f1920479f2ce3dc2246838 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 15:41:26 +0000 Subject: [PATCH 188/557] Add invitations to settings menu --- package.json | 2 +- temba/orgs/tests.py | 3 ++- temba/orgs/views/views.py | 40 ++++++++++++++++++++------------------- yarn.lock | 8 ++++---- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 4ce4ba0d89e..b7f433c82ce 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.6", + "@nyaruka/temba-components": "0.108.7", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index d11421062e6..5e9e68964bc 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1688,9 +1688,10 @@ def test_workspace(self): "Workspaces (1)", "Dashboard", "Account", - "Users (4)", "Resthooks", "Incidents", + "Users (4)", + "Invitations (0)", "Export", "Import", ("Channels", ["New Channel", "Test Channel"]), diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 5046834b206..415b344e480 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -359,9 +359,6 @@ class List(SpaMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, Smart menu_path = "/settings/users" search_fields = ("email__icontains", "first_name__icontains", "last_name__icontains") - def build_context_menu(self, menu): - menu.add_modax(_("Invite"), "invite-create", reverse("orgs.invitation_create"), as_button=True) - def derive_queryset(self, **kwargs): return ( super() @@ -402,18 +399,15 @@ def get_object_org(self): return self.request.org def derive_initial(self): - org = self.get_object_org() - # viewers default to editors - role = org.get_user_role(self.object) + role = self.request.org.get_user_role(self.object) return {"role": OrgRole.EDITOR.code if role == OrgRole.VIEWER else role.code} def save(self, obj): - org = self.get_object_org() role = OrgRole.from_code(self.form.cleaned_data["role"]) # don't update if user is the last administrator and role is being changed to something else - has_other_admins = org.get_users(roles=[OrgRole.ADMINISTRATOR]).exclude(id=obj.id).exists() + has_other_admins = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).exclude(id=obj.id).exists() if role != OrgRole.ADMINISTRATOR and not has_other_admins: return obj @@ -440,17 +434,16 @@ def get_context_data(self, **kwargs): return context def post(self, request, *args, **kwargs): - org = self.get_object_org() user = self.get_object() # only actually remove user if they're not the last administator - if org.get_users(roles=[OrgRole.ADMINISTRATOR]).exclude(id=user.id).exists(): - org.remove_user(user) + if self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).exclude(id=user.id).exists(): + self.request.org.remove_user(user) return HttpResponseRedirect(self.get_redirect_url()) def get_redirect_url(self): - still_in_org = self.get_object_org().has_user(self.request.user) or self.request.user.is_staff + still_in_org = self.request.org.has_user(self.request.user) or self.request.user.is_staff # if current user no longer belongs to this org, redirect to org chooser return reverse("orgs.user_list") if still_in_org else reverse("orgs.org_choose") @@ -1027,18 +1020,27 @@ def derive_menu(self): ) ) - if self.has_org_perm("orgs.user_list") and Org.FEATURE_USERS in org.features: + menu.append(self.create_menu_item(name=_("Resthooks"), icon="resthooks", href="orgs.org_resthooks")) + + if self.has_org_perm("notifications.incident_list"): + menu.append( + self.create_menu_item(name=_("Incidents"), icon="incidents", href="notifications.incident_list") + ) + + if Org.FEATURE_USERS in org.features and self.has_org_perm("orgs.user_list"): + menu.append(self.create_divider()) menu.append( self.create_menu_item( name=_("Users"), icon="users", href="orgs.user_list", count=org.users.count() ) ) - - menu.append(self.create_menu_item(name=_("Resthooks"), icon="resthooks", href="orgs.org_resthooks")) - - if self.has_org_perm("notifications.incident_list"): menu.append( - self.create_menu_item(name=_("Incidents"), icon="incidents", href="notifications.incident_list") + self.create_menu_item( + name=_("Invitations"), + icon="invitations", + href="orgs.invitation_list", + count=org.invitations.count(), + ) ) menu.append(self.create_divider()) @@ -2125,7 +2127,7 @@ class InvitationCRUDL(SmartCRUDL): class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): require_feature = Org.FEATURE_USERS title = _("Invitations") - menu_path = "/settings/users" + menu_path = "/settings/invitations" default_order = ("-created_on",) def build_context_menu(self, menu): diff --git a/yarn.lock b/yarn.lock index 942b77ff968..e1338ca5b11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.108.6": - version "0.108.6" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.6.tgz#484890d3a1a77c16bec8726b79802b69a55de6dd" - integrity sha512-gu1qAewnTAcaT8ZFKSU/lGIHpknT58uKrpIVljbMChhBHDvJiLA4OB2jhn/zF/tCOtDc7BuRMLi0vXLS5LJKwQ== +"@nyaruka/temba-components@0.108.7": + version "0.108.7" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.108.7.tgz#f3f4187d21c3c6c346bf135ec94cb33f521fe472" + integrity sha512-2pbzXPWJ1vpjll5iqTTbbWaK/Q+xmK2UO/4bDoiYFOr87YTiuVOee471k/8L2V/OWzLa+vGNvB06sMuZUG1bTw== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From d64362356739f483720c864d738b526f93077e90 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 16 Oct 2024 16:00:22 +0000 Subject: [PATCH 189/557] Hide user delete button if they're the sole admin --- temba/orgs/views/views.py | 6 ++---- templates/orgs/user_list.html | 14 ++++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 415b344e480..c36ec3eceeb 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -370,13 +370,11 @@ def derive_queryset(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - has_viewers = False for user in context["object_list"]: user.role = self.request.org.get_user_role(user) - if user.role == OrgRole.VIEWER: - has_viewers = True - context["has_viewers"] = has_viewers + context["has_viewers"] = self.request.org.get_users(roles=[OrgRole.VIEWER]).exists() + context["admin_count"] = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).count() return context class Update(RequireFeatureMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 82f64cca220..992ad3971c9 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -39,12 +39,14 @@
    {{ obj.name }} {{ obj.role.display }} - + {% if obj.role.code != "A" or admin_count > 1 %} + + {% endif %}
    + + + + + + + + + + {% for obj in object_list %} + + + + + + + + {% endfor %} + + +
    {% trans "Name" %}{% trans "Users" %}{% trans "Contacts" %}{% trans "Created On" %}
    {{ obj.name }}{{ obj.users.all|length }}{{ obj.get_contact_count|intcomma }}{{ obj.created_on|day }} + {% if obj.id != user_org.id %} + + {% endif %} +
    +
    +{% endblock content %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %} diff --git a/templates/orgs/org_sub_orgs.html b/templates/orgs/org_sub_orgs.html deleted file mode 100644 index 4d309dd2837..00000000000 --- a/templates/orgs/org_sub_orgs.html +++ /dev/null @@ -1,105 +0,0 @@ -{% extends "smartmin/list.html" %} -{% load compress temba smartmin humanize %} -{% load i18n %} - -{% block title-text %} - {% trans "Workspaces" %} -{% endblock title-text %} -{% block subtitle %} - {{ user_org.name|capfirst }} -{% endblock subtitle %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} -{% block extra-script %} - {{ block.super }} - -{% endblock extra-script %} -{% block table %} - - - - - - - - - - - {% for org in object_list %} - - - - - - - - {% empty %} - - - - {% endfor %} - - -
    {% trans "Name" %}{% trans "Users" %}{% trans "Contacts" %}{% trans "Created" %}
    - {% if org.id == user_org.id %} - {{ org.name }} - {% else %} -
    {{ org.name }}
    - {% endif %} -
    {{ org.users.all|length }}{{ org.get_contact_count|intcomma }}{{ org.created_on|day }} -
    - - -
    -
    -{% endblock table %} From 2017128acdb47065046a996eb81ac332c3635b8d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 17:20:51 +0000 Subject: [PATCH 207/557] Rework /org/edit_sub_org/?org=1 to /org/update/1/ --- temba/orgs/tests.py | 27 +++++---- temba/orgs/views/views.py | 56 ++++++++++--------- temba/settings_common.py | 6 +- ...{org_delete_child.html => org_delete.html} | 0 templates/orgs/org_list.html | 4 +- 5 files changed, 51 insertions(+), 42 deletions(-) rename templates/orgs/{org_delete_child.html => org_delete.html} (100%) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 473402514ae..c852bb4d2c7 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -85,21 +85,21 @@ def test_group_perms_wrapper(self): self.assertTrue(perms["contacts"]["contact_update"]) self.assertTrue(perms["orgs"]["org_country"]) self.assertTrue(perms["orgs"]["user_list"]) - self.assertTrue(perms["orgs"]["org_delete_child"]) + self.assertTrue(perms["orgs"]["org_delete"]) perms = RolePermsWrapper(OrgRole.EDITOR) self.assertTrue(perms["msgs"]["msg_list"]) self.assertTrue(perms["contacts"]["contact_update"]) self.assertFalse(perms["orgs"]["user_list"]) - self.assertFalse(perms["orgs"]["org_delete_child"]) + self.assertFalse(perms["orgs"]["org_delete"]) perms = RolePermsWrapper(OrgRole.VIEWER) self.assertTrue(perms["msgs"]["msg_list"]) self.assertFalse(perms["contacts"]["contact_update"]) self.assertFalse(perms["orgs"]["user_list"]) - self.assertFalse(perms["orgs"]["org_delete_child"]) + self.assertFalse(perms["orgs"]["org_delete"]) self.assertFalse(perms["msgs"]["foo"]) # no blow up if perm doesn't exist self.assertFalse(perms["chickens"]["foo"]) # or app doesn't exist @@ -2485,13 +2485,18 @@ def test_list(self): response = self.assertListFetch( list_url, [self.admin], context_objects=[self.org, child1, child2], choose_org=self.org ) - - child1_edit_url = reverse("orgs.org_edit_sub_org") + f"?org={child1.id}" self.assertContains(response, "Child Org 1") + self.assertContains(response, "Child Org 2") + + # can search by name + self.assertListFetch( + list_url + "?search=child", [self.admin], context_objects=[child1, child2], choose_org=self.org + ) # edit our sub org's details + child1_update_url = reverse("orgs.org_update", args=[child1.id]) response = self.client.post( - child1_edit_url, + child1_update_url, {"name": "New Child Name", "timezone": "Africa/Nairobi", "date_format": "Y", "language": "es"}, ) self.assertEqual(list_url, response.url) @@ -2502,7 +2507,7 @@ def test_list(self): # edit our sub org's details in a spa view response = self.client.post( - child1_edit_url, + child1_update_url, {"name": "Spa Child Name", "timezone": "Africa/Nairobi", "date_format": "Y", "language": "es"}, HTTP_TEMBA_SPA=1, ) @@ -2516,13 +2521,13 @@ def test_list(self): self.assertEqual("es", child1.language) # if org doesn't exist, 404 - response = self.client.get(f"{reverse('orgs.org_edit_sub_org')}?org=3464374") + response = self.client.get(reverse("orgs.org_update", args=[3464374])) self.assertEqual(404, response.status_code) self.login(self.admin2) # same if it's not a child of the request org - response = self.client.get(f"{reverse('orgs.org_edit_sub_org')}?org={child1.id}") + response = self.client.get(reverse("orgs.org_update", args=[child1.id])) self.assertEqual(404, response.status_code) def test_start(self): @@ -2644,12 +2649,12 @@ def test_edit(self): self.assertEqual("Y", self.org.date_format) self.assertEqual("es", self.org.language) - def test_delete_child(self): + def test_delete(self): self.org.features = [Org.FEATURE_CHILD_ORGS] self.org.save(update_fields=("features",)) child = self.org.create_new(self.admin, "Child Workspace", self.org.timezone, as_child=True) - delete_url = reverse("orgs.org_delete_child", args=[child.id]) + delete_url = reverse("orgs.org_delete", args=[child.id]) self.assertRequestDisallowed(delete_url, [None, self.user, self.editor, self.agent, self.admin2]) self.assertDeleteFetch(delete_url, [self.admin], choose_org=self.org) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 67826caa21a..3066ee9dd73 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -28,7 +28,7 @@ from django.core.exceptions import ValidationError from django.db.models import Q from django.db.models.functions import Lower -from django.http import Http404, HttpResponseRedirect, JsonResponse +from django.http import HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, resolve_url from django.urls import reverse, reverse_lazy from django.utils import timezone @@ -946,13 +946,13 @@ class OrgCRUDL(SmartCRUDL): "signup", "start", "edit", - "edit_sub_org", + "update", "join", "join_signup", "join_accept", "grant", "choose", - "delete_child", + "delete", "menu", "country", "languages", @@ -1427,16 +1427,36 @@ def extract_from(smtp_url: str) -> str: context["from_email_custom"] = from_email_custom return context - class DeleteChild(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): + class Update(ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Form(forms.ModelForm): + name = forms.CharField(max_length=128, label=_("Name"), widget=InputWidget()) + timezone = TimeZoneFormField(label=_("Timezone"), widget=SelectWidget(attrs={"searchable": True})) + + class Meta: + model = Org + fields = ("name", "timezone", "date_format", "language") + widgets = {"date_format": SelectWidget(), "language": SelectWidget()} + + form_class = Form + success_url = "@orgs.org_list" + + def get_object_org(self): + return self.request.org + + def get_queryset(self, *args, **kwargs): + return self.request.org.children.all() + + class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): cancel_url = "@orgs.org_list" success_url = "@orgs.org_list" fields = ("id",) submit_button_name = _("Delete") def get_object_org(self): - # child orgs work in the context of their parent - org = self.get_object() - return org if not org.is_child else org.parent + return self.request.org + + def get_queryset(self, *args, **kwargs): + return self.request.org.children.all() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -1450,7 +1470,7 @@ def post(self, request, *args, **kwargs): self.object.release(request.user) return self.render_modal_response() - class List(SpaMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, SmartListView): + class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): require_feature = Org.FEATURE_CHILD_ORGS title = _("Workspaces") menu_path = "/settings/workspaces" @@ -1461,7 +1481,7 @@ def build_context_menu(self, menu): menu.add_modax(_("New"), "new_workspace", reverse("orgs.org_create"), as_button=True) def derive_queryset(self, **kwargs): - qs = super().derive_queryset(**kwargs) + qs = super(BaseListView, self).derive_queryset(**kwargs) # return this org and its children org = self.request.org @@ -1929,10 +1949,8 @@ def derive_formax_sections(self, formax, context): class Edit(InferOrgMixin, OrgPermsMixin, SmartUpdateView): class Form(forms.ModelForm): - name = forms.CharField(max_length=128, label=_("Workspace Name"), help_text="", widget=InputWidget()) - timezone = TimeZoneFormField( - label=_("Timezone"), help_text="", widget=SelectWidget(attrs={"searchable": True}) - ) + name = forms.CharField(max_length=128, label=_("Name"), widget=InputWidget()) + timezone = TimeZoneFormField(label=_("Timezone"), widget=SelectWidget(attrs={"searchable": True})) class Meta: model = Org @@ -1944,18 +1962,6 @@ class Meta: def derive_exclude(self): return ["language"] if len(settings.LANGUAGES) == 1 else [] - class EditSubOrg(ModalFormMixin, Edit): - success_url = "@orgs.org_list" - - def get_success_url(self): - return super().get_success_url() - - def get_object(self, *args, **kwargs): - try: - return self.request.org.children.get(id=int(self.request.GET.get("org"))) - except Org.DoesNotExist: - raise Http404(_("No such child workspace")) - class Country(InferOrgMixin, OrgPermsMixin, SmartUpdateView): class CountryForm(forms.ModelForm): country = forms.ModelChoiceField( diff --git a/temba/settings_common.py b/temba/settings_common.py index bac91575de8..8f439eded57 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -375,9 +375,7 @@ "country", "create", "dashboard", - "delete_child", "download", - "edit_sub_org", "edit", "export", "flow_smtp", @@ -486,9 +484,8 @@ "orgs.org_country", "orgs.org_create", "orgs.org_dashboard", - "orgs.org_delete_child", + "orgs.org_delete", "orgs.org_download", - "orgs.org_edit_sub_org", "orgs.org_edit", "orgs.org_export", "orgs.org_flow_smtp", @@ -499,6 +496,7 @@ "orgs.org_prometheus", "orgs.org_read", "orgs.org_resthooks", + "orgs.org_update", "orgs.org_workspace", "orgs.orgimport.*", "orgs.user_list", diff --git a/templates/orgs/org_delete_child.html b/templates/orgs/org_delete.html similarity index 100% rename from templates/orgs/org_delete_child.html rename to templates/orgs/org_delete.html diff --git a/templates/orgs/org_list.html b/templates/orgs/org_list.html index 6ce47c5a5a0..2f0e765e661 100644 --- a/templates/orgs/org_list.html +++ b/templates/orgs/org_list.html @@ -54,13 +54,13 @@ From 48367a7f5efa183e7196e5a6e5ee8340bdd55c8f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 18:13:32 +0000 Subject: [PATCH 208/557] Improve tests --- temba/orgs/tests.py | 86 +++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index c852bb4d2c7..d5e6a94eba0 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2476,8 +2476,12 @@ def test_create_child_spa(self): def test_list(self): list_url = reverse("orgs.org_list") + # nobody can access if child orgs feature not enabled + response = self.requestView(list_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) + # enable child orgs and create some child orgs - self.org.features = [Org.FEATURE_CHILD_ORGS, Org.FEATURE_USERS] + self.org.features = [Org.FEATURE_CHILD_ORGS] self.org.save(update_fields=("features",)) child1 = self.org.create_new(self.admin, "Child Org 1", self.org.timezone, as_child=True) child2 = self.org.create_new(self.admin, "Child Org 2", self.org.timezone, as_child=True) @@ -2493,42 +2497,53 @@ def test_list(self): list_url + "?search=child", [self.admin], context_objects=[child1, child2], choose_org=self.org ) - # edit our sub org's details - child1_update_url = reverse("orgs.org_update", args=[child1.id]) - response = self.client.post( - child1_update_url, + def test_update(self): + # enable child orgs and create some child orgs + self.org.features = [Org.FEATURE_CHILD_ORGS] + self.org.save(update_fields=("features",)) + child1 = self.org.create_new(self.admin, "Child Org 1", self.org.timezone, as_child=True) + + update_url = reverse("orgs.org_update", args=[child1.id]) + + self.assertRequestDisallowed(update_url, [None, self.user, self.editor, self.agent, self.admin2]) + self.assertUpdateFetch( + update_url, [self.admin], form_fields=["name", "timezone", "date_format", "language"], choose_org=self.org + ) + + response = self.assertUpdateSubmit( + update_url, + self.admin, {"name": "New Child Name", "timezone": "Africa/Nairobi", "date_format": "Y", "language": "es"}, ) - self.assertEqual(list_url, response.url) child1.refresh_from_db() self.assertEqual("New Child Name", child1.name) self.assertEqual("/org/", response.url) - # edit our sub org's details in a spa view - response = self.client.post( - child1_update_url, - {"name": "Spa Child Name", "timezone": "Africa/Nairobi", "date_format": "Y", "language": "es"}, - HTTP_TEMBA_SPA=1, - ) + # if org doesn't exist, 404 + response = self.requestView(reverse("orgs.org_update", args=[3464374]), self.admin, choose_org=self.org) + self.assertEqual(404, response.status_code) - self.assertEqual(list_url, response.url) + def test_delete(self): + self.org.features = [Org.FEATURE_CHILD_ORGS] + self.org.save(update_fields=("features",)) - child1.refresh_from_db() - self.assertEqual("Spa Child Name", child1.name) - self.assertEqual("Africa/Nairobi", str(child1.timezone)) - self.assertEqual("Y", child1.date_format) - self.assertEqual("es", child1.language) + child = self.org.create_new(self.admin, "Child Workspace", self.org.timezone, as_child=True) + delete_url = reverse("orgs.org_delete", args=[child.id]) - # if org doesn't exist, 404 - response = self.client.get(reverse("orgs.org_update", args=[3464374])) - self.assertEqual(404, response.status_code) + self.assertRequestDisallowed(delete_url, [None, self.user, self.editor, self.agent, self.admin2]) + self.assertDeleteFetch(delete_url, [self.admin], choose_org=self.org) - self.login(self.admin2) + # schedule for deletion + response = self.client.get(delete_url) + self.assertContains(response, "You are about to delete the workspace Child Workspace") - # same if it's not a child of the request org - response = self.client.get(reverse("orgs.org_update", args=[child1.id])) - self.assertEqual(404, response.status_code) + # go through with it, redirects to workspaces list page + response = self.client.post(delete_url) + self.assertEqual(reverse("orgs.org_list"), response["Temba-Success"]) + + child.refresh_from_db() + self.assertFalse(child.is_active) def test_start(self): # the start view routes users based on their role @@ -2649,27 +2664,6 @@ def test_edit(self): self.assertEqual("Y", self.org.date_format) self.assertEqual("es", self.org.language) - def test_delete(self): - self.org.features = [Org.FEATURE_CHILD_ORGS] - self.org.save(update_fields=("features",)) - - child = self.org.create_new(self.admin, "Child Workspace", self.org.timezone, as_child=True) - delete_url = reverse("orgs.org_delete", args=[child.id]) - - self.assertRequestDisallowed(delete_url, [None, self.user, self.editor, self.agent, self.admin2]) - self.assertDeleteFetch(delete_url, [self.admin], choose_org=self.org) - - # schedule for deletion - response = self.client.get(delete_url) - self.assertContains(response, "You are about to delete the workspace Child Workspace") - - # go through with it, redirects to workspaces list page - response = self.client.post(delete_url) - self.assertEqual(reverse("orgs.org_list"), response["Temba-Success"]) - - child.refresh_from_db() - self.assertFalse(child.is_active) - def test_urn_schemes(self): # remove existing channels Channel.objects.all().update(is_active=False, org=None) From e57bd21e06f70d7f3d875be5d0e4d141dc8d1c3a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 18:24:59 +0000 Subject: [PATCH 209/557] UserCRUDL.List should be a BaseListView too --- temba/orgs/views/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 3066ee9dd73..810b53063b7 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -352,7 +352,7 @@ class UserCRUDL(SmartCRUDL): "send_verification_email", ) - class List(SpaMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, SmartListView): + class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): require_feature = Org.FEATURE_USERS title = _("Users") menu_path = "/settings/users" @@ -360,7 +360,7 @@ class List(SpaMixin, RequireFeatureMixin, ContextMenuMixin, OrgPermsMixin, Smart def derive_queryset(self, **kwargs): return ( - super() + super(BaseListView, self) .derive_queryset(**kwargs) .filter(id__in=self.request.org.get_users().values_list("id", flat=True)) .order_by(Lower("email")) From 358575d4f74e8ae43894ebd0f32ac8ca3531b411 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 13:44:02 -0500 Subject: [PATCH 210/557] Update CHANGELOG.md for v9.3.73 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69601f424a8..93e659caa92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.73 (2024-10-17) +------------------------- + * Overhaul UI for managing child workspaces + v9.3.72 (2024-10-17) ------------------------- * Move org service view to staff app diff --git a/pyproject.toml b/pyproject.toml index 08cc12a683d..9cfe5881f2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.72" +version = "9.3.73" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index fc335daca3d..0a96b8be5ad 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.72" +__version__ = "9.3.73" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 80aeb60e365971363f9d8130674b2f84cb86a064 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 19:54:39 +0000 Subject: [PATCH 211/557] Fix modal title --- temba/orgs/views/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 810b53063b7..1cb1961a21f 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -13,7 +13,6 @@ SmartCRUDL, SmartDeleteView, SmartFormView, - SmartListView, SmartReadView, SmartTemplateView, SmartUpdateView, @@ -1478,7 +1477,9 @@ class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): def build_context_menu(self, menu): if self.has_org_perm("orgs.org_create"): - menu.add_modax(_("New"), "new_workspace", reverse("orgs.org_create"), as_button=True) + menu.add_modax( + _("New"), "new_workspace", reverse("orgs.org_create"), title=_("New Workspace"), as_button=True + ) def derive_queryset(self, **kwargs): qs = super(BaseListView, self).derive_queryset(**kwargs) From c3ddb6b1789103e31eb260481b8437b9f68aee93 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 20:15:21 +0000 Subject: [PATCH 212/557] Remove pre-spa days code from flow list view --- temba/flows/tests.py | 14 --- temba/flows/views.py | 58 +-------- templates/flows/empty_include.html | 18 --- templates/flows/flow_filter.html | 19 --- templates/flows/flow_list.html | 184 ++++++++++++++--------------- 5 files changed, 92 insertions(+), 201 deletions(-) delete mode 100644 templates/flows/empty_include.html delete mode 100644 templates/flows/flow_filter.html diff --git a/temba/flows/tests.py b/temba/flows/tests.py index ecd5bc1661d..3b7b076cbff 100644 --- a/temba/flows/tests.py +++ b/temba/flows/tests.py @@ -2007,8 +2007,6 @@ def test_list_views(self): response = self.client.get(reverse("flows.flow_list")) self.assertContains(response, flow1.name) self.assertContains(response, flow3.name) - self.assertEqual(2, response.context["folders"][0]["count"]) - self.assertEqual(1, response.context["folders"][1]["count"]) # archive it response = self.client.post(reverse("flows.flow_list"), {"action": "archive", "objects": flow1.id}) @@ -2018,8 +2016,6 @@ def test_list_views(self): response = self.client.get(reverse("flows.flow_list")) self.assertNotContains(response, flow1.name) self.assertContains(response, flow3.name) - self.assertEqual(1, response.context["folders"][0]["count"]) - self.assertEqual(2, response.context["folders"][1]["count"]) self.assertEqual(("archive", "label", "export-results"), response.context["actions"]) @@ -2044,8 +2040,6 @@ def test_list_views(self): response = self.client.get(reverse("flows.flow_list")) self.assertContains(response, flow1.name) self.assertContains(response, flow3.name) - self.assertEqual(2, response.context["folders"][0]["count"]) - self.assertEqual(1, response.context["folders"][1]["count"]) # can label flows label1 = FlowLabel.create(self.org, self.admin, "Important") @@ -2073,25 +2067,18 @@ def test_list_views(self): response = self.client.get(reverse("flows.flow_list")) self.assertContains(response, flow1.name) - self.assertEqual(2, response.context["folders"][0]["count"]) - self.assertEqual(1, response.context["folders"][1]["count"]) # single message flow (flom campaign) should not be included in counts and not even on this list Flow.objects.filter(id=flow1.id).update(is_system=True) response = self.client.get(reverse("flows.flow_list")) - self.assertNotContains(response, flow1.name) - self.assertEqual(1, response.context["folders"][0]["count"]) - self.assertEqual(1, response.context["folders"][1]["count"]) # single message flow should not be even in the archived list Flow.objects.filter(id=flow1.id).update(is_system=True, is_archived=True) response = self.client.get(reverse("flows.flow_archived")) self.assertNotContains(response, flow1.name) - self.assertEqual(1, response.context["folders"][0]["count"]) - self.assertEqual(1, response.context["folders"][1]["count"]) # only flow2 def test_filter(self): flow1 = self.create_flow("Flow 1") @@ -2107,7 +2094,6 @@ def test_filter(self): response = self.client.get(reverse("flows.flow_filter", args=[label1.uuid])) self.assertEqual([flow2, flow1], list(response.context["object_list"])) - self.assertEqual(2, len(response.context["labels"])) self.assertEqual(("label", "export-results"), response.context["actions"]) response = self.client.get(reverse("flows.flow_filter", args=[label2.uuid])) diff --git a/temba/flows/views.py b/temba/flows/views.py index b0cea1b49be..3fe8b230a26 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -19,7 +19,7 @@ from django.conf import settings from django.contrib.humanize.templatetags import humanize from django.core.exceptions import ValidationError -from django.db.models import Count, Max, Min, Sum +from django.db.models import Max, Min, Sum from django.db.models.functions import Lower from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse @@ -669,7 +669,6 @@ def update_triggers(self, flow, user, new_keywords: list): class BaseList(BulkActionMixin, ContextMenuMixin, BaseListView): permission = "flows.flow_list" title = _("Flows") - refresh = 10000 fields = ("name", "modified_on") default_template = "flows/flow_list.html" default_order = ("-saved_on",) @@ -677,11 +676,7 @@ class BaseList(BulkActionMixin, ContextMenuMixin, BaseListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["org_has_flows"] = self.request.org.flows.filter(is_active=True).exists() - context["folders"] = self.get_folders() - context["labels"] = self.get_flow_labels() - context["campaigns"] = self.get_campaigns() - context["request_url"] = self.request.path + context["labels"] = self.request.org.flow_labels.order_by(Lower("name")) # decorate flow objects with their run activity stats for flow in context["object_list"]: @@ -693,22 +688,6 @@ def derive_queryset(self, *args, **kwargs): qs = super().derive_queryset(*args, **kwargs) return qs.exclude(is_system=True).exclude(is_active=False) - def get_campaigns(self): - from temba.campaigns.models import CampaignEvent - - org = self.request.org - events = CampaignEvent.objects.filter( - campaign__org=org, - is_active=True, - campaign__is_active=True, - flow__is_archived=False, - flow__is_active=True, - flow__is_system=False, - ) - return ( - events.values("campaign__name", "campaign__id").annotate(count=Count("id")).order_by("campaign__name") - ) - def apply_bulk_action(self, user, action, objects, label): super().apply_bulk_action(user, action, objects, label) @@ -724,39 +703,6 @@ def apply_bulk_action(self, user, action, objects, label): def get_bulk_action_labels(self): return self.request.org.flow_labels.filter(is_active=True) - def get_flow_labels(self): - labels = [] - for label in self.request.org.flow_labels.order_by("name"): - labels.append( - { - "id": label.id, - "uuid": label.uuid, - "name": label.name, - "count": label.get_flow_count(), - } - ) - return labels - - def get_folders(self): - org = self.request.org - - return [ - dict( - label="Active", - url=reverse("flows.flow_list"), - count=Flow.objects.exclude(is_system=True) - .filter(is_active=True, is_archived=False, org=org) - .count(), - ), - dict( - label="Archived", - url=reverse("flows.flow_archived"), - count=Flow.objects.exclude(is_system=True) - .filter(is_active=True, is_archived=True, org=org) - .count(), - ), - ] - def build_context_menu(self, menu): if self.has_org_perm("flows.flow_create"): menu.add_modax( diff --git a/templates/flows/empty_include.html b/templates/flows/empty_include.html deleted file mode 100644 index 5f2e62dea19..00000000000 --- a/templates/flows/empty_include.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load i18n %} - -
    -
    {% trans "Flows" %}
    -
    - {% blocktrans trimmed %} - Flows let you easily pose a set of questions to a group of users. When you send people through a flow over SMS, - it is natural just like any other conversation. - {% endblocktrans %} -
    -
    - {% blocktrans trimmed with branding.name as brand %} - A flow gives you the power to model complex interactions by simply drawing a flowchart. With {{ brand }}'s drag-and-drop - interface, you can easily build branches based on how people respond to your messages. This means it's easy to - create highly personal and engaging experiences for your users. - {% endblocktrans %} -
    -
    diff --git a/templates/flows/flow_filter.html b/templates/flows/flow_filter.html deleted file mode 100644 index e6533281680..00000000000 --- a/templates/flows/flow_filter.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "flows/flow_list.html" %} -{% load smartmin sms temba compress i18n %} - -{% block above-bar %} - {% block top-form %} -
    - -
    - {% endblock top-form %} -{% endblock above-bar %} -{% block buttons-right %} - {% block gear-menu %} - {% include "spa_page_menu.html" %} - {% endblock gear-menu %} -{% endblock buttons-right %} diff --git a/templates/flows/flow_list.html b/templates/flows/flow_list.html index 95fe0ae95a4..a63b69fa436 100644 --- a/templates/flows/flow_list.html +++ b/templates/flows/flow_list.html @@ -1,5 +1,5 @@ {% extends "smartmin/list.html" %} -{% load smartmin sms temba compress i18n humanize %} +{% load smartmin temba i18n humanize %} {% block content %} {% if org_perms.flows.flow_results %} @@ -14,102 +14,98 @@ id="create-label-modal">
    {% endif %} - {% if org_has_flows %} -
    - - - -
    -
    {% include "includes/short_pagination.html" %}
    -
    - - {% if object_list %} - - - {% if org_perms.flows.flow_update %}{% endif %} - - - - - {% endif %} - - {% for object in object_list %} - - {% if org_perms.flows.flow_update %} - - {% endif %} - + + + {% empty %} + + + + {% endfor %} + +
    Runs / Completion
    - - - -
    -
    -
    -
    - {% if object.flow_type == 'V' %} - - - {% elif object.flow_type == 'S' %} - - - {% elif object.flow_type == 'B' %} - - - {% endif %} -
    -
    {{ object.name }}
    +
    + + + +
    +
    {% include "includes/short_pagination.html" %}
    +
    + + {% if object_list %} + + + {% if org_perms.flows.flow_update %}{% endif %} + + + + + {% endif %} + + {% for object in object_list %} + + {% if org_perms.flows.flow_update %} + + {% endif %} + - - - {% empty %} - - - - {% endfor %} - -
    Runs / Completion
    + + + +
    +
    +
    +
    + {% if object.flow_type == 'V' %} + + + {% elif object.flow_type == 'S' %} + + + {% elif object.flow_type == 'B' %} + + + {% endif %}
    -
    -
    - {% for label in object.labels.all %} - - - {{ label.name }} - - - {% endfor %} +
    {{ object.name }}
    -
    - {% if not object.is_archived %} -
    - {% if object.has_issues %} -
    - - - - -
    - {% endif %} - {% if object.run_stats.total %} -
    {{ object.run_stats.total|intcomma }}
    - / -
    {{ object.run_stats.completion }}%
    - {% endif %} -
    - {% endif %} -
    {% trans "No flows" %}
    -
    - {% else %} - {% include "flows/empty_include.html" %} - {% endif %} +
    + {% for label in object.labels.all %} + + + {{ label.name }} + + + {% endfor %} +
    +
    +
    + {% if not object.is_archived %} +
    + {% if object.has_issues %} +
    + + + + +
    + {% endif %} + {% if object.run_stats.total %} +
    {{ object.run_stats.total|intcomma }}
    + / +
    {{ object.run_stats.completion }}%
    + {% endif %} +
    + {% endif %} +
    {% trans "No flows" %}
    +
    {% endblock content %} {% block extra-script %} {{ block.super }} From 8d3e3b94e3407022243a22ed6e6fb2c140045ce8 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Thu, 17 Oct 2024 23:03:05 +0200 Subject: [PATCH 213/557] Add padding and adjust steps list in HTML --- static/css/tailwind.css | 6 + static/scss/tailwind.scss | 3 + templates/channels/types/freshchat/claim.html | 52 +++-- templates/channels/types/line/claim.html | 216 +++++++++--------- .../channels/types/messagebird/claim.html | 52 +++-- .../channels/types/rocketchat/claim.html | 74 +++--- templates/channels/types/slack/claim.html | 36 +-- templates/channels/types/slack/config.html | 82 +++---- templates/channels/types/telegram/claim.html | 42 ++-- 9 files changed, 294 insertions(+), 269 deletions(-) diff --git a/static/css/tailwind.css b/static/css/tailwind.css index a4340a0c812..07dbf6299e5 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1848,9 +1848,15 @@ code { color: #2980b9; } +ol.steps, ul.steps { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + ol.steps, ul.steps { line-height: 2; + list-style-type: decimal; } .card { diff --git a/static/scss/tailwind.scss b/static/scss/tailwind.scss index 4bb432b4aaa..587f1ef2ee3 100644 --- a/static/scss/tailwind.scss +++ b/static/scss/tailwind.scss @@ -647,7 +647,10 @@ code { ol.steps, ul.steps { + @apply px-6; + line-height: 2; + list-style-type: decimal; } .card { diff --git a/templates/channels/types/freshchat/claim.html b/templates/channels/types/freshchat/claim.html index 6c37e2354e2..398fda2f8df 100644 --- a/templates/channels/types/freshchat/claim.html +++ b/templates/channels/types/freshchat/claim.html @@ -11,31 +11,33 @@ Each message sent to the channel will have a URN in the format {{ format }} where the channel UUID is the FreshChat channel's UUID. {% endblocktrans %}
    -
      -
    1. - {% blocktrans trimmed %} - In the FreshChat web interface, under Settings, API Tokens, select Generate Token. Once the token is created you'll enter it below. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - Under Settings again, select Webhooks or Conversation Webhooks (if you do not see the options, search for that and make sure the feature is enabled) - and then use the Copy button to copy the Public Key (usually RSA Public Key) and paste it below. This assures all webhook request from FreshChat will be authenticated. - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - Lastly, you'll need the UUID of the Agent that {{ name }} will use when it sends FreshChat message. - This is available via the FreshChat API. - {% endblocktrans %} -
    6. -
    7. - {% blocktrans trimmed %} - Once you press Submit below with all the information configured, the confirmation page will give you the URL to use for the FreshChat Webhooks. - Enter this URL on the Settings page, under Webhooks where the Public Key was copied from before. - {% endblocktrans %} -
    8. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + In the FreshChat web interface, under Settings, API Tokens, select Generate Token. Once the token is created you'll enter it below. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + Under Settings again, select Webhooks or Conversation Webhooks (if you do not see the options, search for that and make sure the feature is enabled) + and then use the Copy button to copy the Public Key (usually RSA Public Key) and paste it below. This assures all webhook request from FreshChat will be authenticated. + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + Lastly, you'll need the UUID of the Agent that {{ name }} will use when it sends FreshChat message. + This is available via the FreshChat API. + {% endblocktrans %} +
    6. +
    7. + {% blocktrans trimmed %} + Once you press Submit below with all the information configured, the confirmation page will give you the URL to use for the FreshChat Webhooks. + Enter this URL on the Settings page, under Webhooks where the Public Key was copied from before. + {% endblocktrans %} +
    8. +
    +
    {% endblock pre-form %} {% block extra-style %} {{ block.super }} diff --git a/templates/channels/types/line/claim.html b/templates/channels/types/line/claim.html index e081d164033..9c3ed380306 100644 --- a/templates/channels/types/line/claim.html +++ b/templates/channels/types/line/claim.html @@ -9,119 +9,121 @@ {% blocktrans trimmed %} To start creating a LINE bot, go to Getting started with the Messaging API. {% endblocktrans %} -
      -
    1. - {% blocktrans trimmed %} - Access the LINE Developers Console page. -
    2. - {% endblocktrans %} - -
    3. - {% blocktrans trimmed %} - Create a Line account using the application on your smartphone - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - Activate the permission to login via web application in the Settings > Account > Allow login - {% endblocktrans %} -
    6. -
    7. - {% blocktrans trimmed %} - Register your email and password via the application Settings > Account > Email Account - {% endblocktrans %} -
    8. -
    9. - {% blocktrans trimmed %} - Create a new account on LINE Developers Console (With Messaging API enabled) - {% endblocktrans %} -
    10. -
    11. - {% blocktrans trimmed %} - Follow the steps on Creating a channel on the LINE Developers Console - {% endblocktrans %} -
        -
      1. - {% blocktrans trimmed %} - Log in to the LINE Developers Console - {% endblocktrans %} -
      2. +
        +
        1. {% blocktrans trimmed %} - Register as a developer (only on first login) - {% endblocktrans %} -
        2. -
        3. - {% blocktrans trimmed %} - Enter your credentials created through the application and wait for the confirmation code in it. - {% endblocktrans %} -
          + Access the LINE Developers Console page. +
        4. + {% endblocktrans %} + +
        5. + {% blocktrans trimmed %} + Create a Line account using the application on your smartphone + {% endblocktrans %} +
        6. +
        7. + {% blocktrans trimmed %} + Activate the permission to login via web application in the Settings > Account > Allow login + {% endblocktrans %} +
        8. +
        9. + {% blocktrans trimmed %} + Register your email and password via the application Settings > Account > Email Account + {% endblocktrans %} +
        10. +
        11. + {% blocktrans trimmed %} + Create a new account on LINE Developers Console (With Messaging API enabled) + {% endblocktrans %} +
        12. +
        13. + {% blocktrans trimmed %} + Follow the steps on Creating a channel on the LINE Developers Console + {% endblocktrans %} +
            +
          1. + {% blocktrans trimmed %} + Log in to the LINE Developers Console + {% endblocktrans %} +
          2. +
          3. + {% blocktrans trimmed %} + Register as a developer (only on first login) + {% endblocktrans %} +
          4. +
          5. + {% blocktrans trimmed %} + Enter your credentials created through the application and wait for the confirmation code in it. + {% endblocktrans %} +
            +

            + {% blocktrans trimmed %} + Note: At this time you must create a LINE Developers Console account and some information, for example, address, + phone, etc., will be requested. + {% endblocktrans %} +

            +
            +
          6. +
          7. + {% blocktrans trimmed %} + Create a new provider + {% endblocktrans %} +
          8. +
          9. + {% blocktrans trimmed %} + Create a channel + {% endblocktrans %} +
          10. +
          11. + {% blocktrans trimmed %} + After the creation process of the LINE Developers Console account, you will see the page to add a new channel of + communication Messaging API. Enter the name and select the appropriate category and click OK. + {% endblocktrans %} +
          12. +
          13. + {% blocktrans trimmed %} + In the next step, click the LINE @ Manager and it will redirect you to the API activation page. + {% endblocktrans %} +
          14. +
          15. + {% blocktrans trimmed %} + Click "Enable API" and confirm. (By doing so, the status of your API will be Valid) + {% endblocktrans %} +
          16. +
          17. + {% blocktrans trimmed %} + Enable the option to allow the use of Webhooks and click Save. + {% endblocktrans %} +
          18. +
          +
        14. +
        15. + {% blocktrans trimmed %} + Set your bot: + {% endblocktrans %} +
            +
          1. + {% blocktrans trimmed %} + Click on the menu "Accounts" at the top of the page at the link https://developers.line.biz/console/ + {% endblocktrans %} +
          2. +
          3. + {% blocktrans trimmed %} + In the communication channel of your choice, click on the LINE Developers Console button, accept the terms, + and it will direct you to a page with the information needed to fill out in the form below + (Channel ID, Channel Name, Channel Secret and Channel Access Token). + {% endblocktrans %}

            {% blocktrans trimmed %} - Note: At this time you must create a LINE Developers Console account and some information, for example, address, - phone, etc., will be requested. + Note: To generate the Channel Access Token click on Issue button {% endblocktrans %}

            -
        - -
      3. - {% blocktrans trimmed %} - Create a new provider - {% endblocktrans %} -
      4. -
      5. - {% blocktrans trimmed %} - Create a channel - {% endblocktrans %} -
      6. -
      7. - {% blocktrans trimmed %} - After the creation process of the LINE Developers Console account, you will see the page to add a new channel of - communication Messaging API. Enter the name and select the appropriate category and click OK. - {% endblocktrans %} -
      8. -
      9. - {% blocktrans trimmed %} - In the next step, click the LINE @ Manager and it will redirect you to the API activation page. - {% endblocktrans %} -
      10. -
      11. - {% blocktrans trimmed %} - Click "Enable API" and confirm. (By doing so, the status of your API will be Valid) - {% endblocktrans %} -
      12. -
      13. - {% blocktrans trimmed %} - Enable the option to allow the use of Webhooks and click Save. - {% endblocktrans %} -
      14. -
      -
    12. -
    13. - {% blocktrans trimmed %} - Set your bot: - {% endblocktrans %} -
        -
      1. - {% blocktrans trimmed %} - Click on the menu "Accounts" at the top of the page at the link https://developers.line.biz/console/ - {% endblocktrans %} -
      2. -
      3. - {% blocktrans trimmed %} - In the communication channel of your choice, click on the LINE Developers Console button, accept the terms, - and it will direct you to a page with the information needed to fill out in the form below - (Channel ID, Channel Name, Channel Secret and Channel Access Token). - {% endblocktrans %} -

        - {% blocktrans trimmed %} - Note: To generate the Channel Access Token click on Issue button - {% endblocktrans %} -

        -
      4. -
      -
    14. -
    + + + + +
    {% endblock pre-form %} {% block extra-style %} {{ block.super }} diff --git a/templates/channels/types/messagebird/claim.html b/templates/channels/types/messagebird/claim.html index d836bf132d1..10dee964877 100644 --- a/templates/channels/types/messagebird/claim.html +++ b/templates/channels/types/messagebird/claim.html @@ -6,31 +6,33 @@ You can connect a Messagebird Account to your {{ name }} account to automate sending and receiving messages via Messagebird phone numbers and shortcodes. {% endblocktrans %} -
      -
    1. - {% blocktrans trimmed %} - In the Messagebird Dashboard, under SMS, API Getting Started, select Show on your live key and enter it below. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - In the Messagebird Dashboard again, select Developers, API Settings and Signing Key. Click Show Key and paste it below. This assures all webhook request from Messagebird will be authenticated. - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - Lastly, you'll need the Phone number or shortcode that {{ name }} will use when it sends SMS/MMS message. This is available via - the My Numbers section of the Messagebird API. - {% endblocktrans %} -
    6. -
    7. - {% blocktrans trimmed %} - Once you press Submit below with all the information configured, the confirmation page will give you the URL to use for the Messagebird Message Webhooks. - You'll also receive a url for message status updates. You can configure this url in the Dashboard under Developers, API Settings. - You'll need to provide this URL to the {{ name }} team. You can also create a {{ name }} flow to forward SMS messages to this URL. - {% endblocktrans %} -
    8. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + In the Messagebird Dashboard, under SMS, API Getting Started, select Show on your live key and enter it below. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + In the Messagebird Dashboard again, select Developers, API Settings and Signing Key. Click Show Key and paste it below. This assures all webhook request from Messagebird will be authenticated. + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + Lastly, you'll need the Phone number or shortcode that {{ name }} will use when it sends SMS/MMS message. This is available via + the My Numbers section of the Messagebird API. + {% endblocktrans %} +
    6. +
    7. + {% blocktrans trimmed %} + Once you press Submit below with all the information configured, the confirmation page will give you the URL to use for the Messagebird Message Webhooks. + You'll also receive a url for message status updates. You can configure this url in the Dashboard under Developers, API Settings. + You'll need to provide this URL to the {{ name }} team. You can also create a {{ name }} flow to forward SMS messages to this URL. + {% endblocktrans %} +
    8. +
    +
    {% endblock pre-form %} {% block extra-style %} {{ block.super }} diff --git a/templates/channels/types/rocketchat/claim.html b/templates/channels/types/rocketchat/claim.html index 363b996a32e..e2234ffe09e 100644 --- a/templates/channels/types/rocketchat/claim.html +++ b/templates/channels/types/rocketchat/claim.html @@ -5,42 +5,44 @@ {% blocktrans trimmed with name=branding.name %} You can connect Rocket.Chat to {{ name }} in a few simple steps. {% endblocktrans %} -
      -
    1. - {% blocktrans trimmed %} - In your Rocket.Chat instance, go to Administration > Omnichannel and enable it. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - Add a new user with the "bot" role (Administration > Users > New) - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed with brand=branding.name %} - Install the app {{ brand }} Channel from the Marketplace. - {% endblocktrans %} -
    6. -
    7. - {% blocktrans trimmed with brand=branding.name %} - Open the app details at Administration > Apps > {{ brand }} Channel, and in - its settings section put the following token in the App Secret field: -
      {{ secret }}
      - {% endblocktrans %} -
    8. -
    9. - {% blocktrans trimmed with brand=branding.name %} - Save the changes. Copy the app's URL and paste it in the URL field. It should end with a long ID, - for example: https://my.rocket.chat/api/apps/public/51c5cebe-b8e4-48ae-89d3-2b7746019cc4 - {% endblocktrans %} -
    10. -
    11. - {% blocktrans trimmed %} - You will also need a Auth Token and User ID which you can generate at Profile > My account > - Personal Access Tokens. Ensure that Ignore Two Factor Authentication is checked. - {% endblocktrans %} -
    12. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + In your Rocket.Chat instance, go to Administration > Omnichannel and enable it. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + Add a new user with the "bot" role (Administration > Users > New) + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed with brand=branding.name %} + Install the app {{ brand }} Channel from the Marketplace. + {% endblocktrans %} +
    6. +
    7. + {% blocktrans trimmed with brand=branding.name %} + Open the app details at Administration > Apps > {{ brand }} Channel, and in + its settings section put the following token in the App Secret field: +
      {{ secret }}
      + {% endblocktrans %} +
    8. +
    9. + {% blocktrans trimmed with brand=branding.name %} + Save the changes. Copy the app's URL and paste it in the URL field. It should end with a long ID, + for example: https://my.rocket.chat/api/apps/public/51c5cebe-b8e4-48ae-89d3-2b7746019cc4 + {% endblocktrans %} +
    10. +
    11. + {% blocktrans trimmed %} + You will also need a Auth Token and User ID which you can generate at Profile > My account > + Personal Access Tokens. Ensure that Ignore Two Factor Authentication is checked. + {% endblocktrans %} +
    12. +
    +
    {% endblock pre-form %} {% block extra-script %} {{ block.super }} diff --git a/templates/channels/types/slack/claim.html b/templates/channels/types/slack/claim.html index 9a4c72bf1c9..5e5343ec6ce 100644 --- a/templates/channels/types/slack/claim.html +++ b/templates/channels/types/slack/claim.html @@ -11,21 +11,23 @@ {% blocktrans trimmed %} If you want to better understand these steps, read this guide here please. {% endblocktrans %} -
      -
    1. - {% blocktrans trimmed %} - You'll need to create a Slack App if you haven't already. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - Setup a bot to your Slack App, add the needed scopes for Bot Token (chat:write, files:read, files:write, users:read), User Token (files:read and files:write) and install app on your slack workspace. - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - Fill this form, submit and go redirected to new channel's configuration to finish connection. - {% endblocktrans %} -
    6. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + You'll need to create a Slack App if you haven't already. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + Setup a bot to your Slack App, add the needed scopes for Bot Token (chat:write, files:read, files:write, users:read), User Token (files:read and files:write) and install app on your slack workspace. + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + Fill this form, submit and go redirected to new channel's configuration to finish connection. + {% endblocktrans %} +
    6. +
    +
    {% endblock pre-form %} diff --git a/templates/channels/types/slack/config.html b/templates/channels/types/slack/config.html index 35439ce9d36..449ab98441c 100644 --- a/templates/channels/types/slack/config.html +++ b/templates/channels/types/slack/config.html @@ -15,47 +15,51 @@

    How to setup Request URL and subscribe to events: {% endblocktrans %}

    -
      -
    1. - {% blocktrans trimmed %} - Copy Request URL, access https://api.slack.com/apps and select your bot app. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - From left side menu Features, access Event Subscriptions page, enable Events and paste Request URL into input field. - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - On Events Subscriptions page, Subscribe to bot events (file_created, message.im). - {% endblocktrans %} -
    6. -
    7. - {% blocktrans trimmed %} - Save the changes and then you will be asked to reinstall the app to your slack workspace to complete events subscription. - {% endblocktrans %} -
    8. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + Copy Request URL, access https://api.slack.com/apps and select your bot app. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + From left side menu Features, access Event Subscriptions page, enable Events and paste Request URL into input field. + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + On Events Subscriptions page, Subscribe to bot events (file_created, message.im). + {% endblocktrans %} +
    6. +
    7. + {% blocktrans trimmed %} + Save the changes and then you will be asked to reinstall the app to your slack workspace to complete events subscription. + {% endblocktrans %} +
    8. +
    +

    {% blocktrans trimmed %} How to enable users to send messages to bot on slack workspace: {% endblocktrans %}

    -
      -
    1. - {% blocktrans trimmed %} - Access https://api.slack.com/apps, select your bot app. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - From left side menu Features, access App Home, in "Show Tabs" card section, turn on "Messages Tab". - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - When the Messages Tab is activated a checkbox will appear, check it to allow users to send Slash commands and messages. - {% endblocktrans %} -
    6. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + Access https://api.slack.com/apps, select your bot app. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + From left side menu Features, access App Home, in "Show Tabs" card section, turn on "Messages Tab". + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + When the Messages Tab is activated a checkbox will appear, check it to allow users to send Slash commands and messages. + {% endblocktrans %} +
    6. +
    +
    diff --git a/templates/channels/types/telegram/claim.html b/templates/channels/types/telegram/claim.html index 0f35e9ee7cb..928e9860c94 100644 --- a/templates/channels/types/telegram/claim.html +++ b/templates/channels/types/telegram/claim.html @@ -10,24 +10,26 @@ You will need to create a new Telegram bot and get its Authentication Token. To do so: {% endblocktrans %} -
      -
    1. - {% blocktrans trimmed %} - Start a new chat with the BotFather. You can do so on your - device by searching for "botfather" and starting a new chat. - {% endblocktrans %} -
    2. -
    3. - {% blocktrans trimmed %} - In your @botfather chat, type in the command /newbot. Follow the instructions to name your bot and - choose a username for it. - {% endblocktrans %} -
    4. -
    5. - {% blocktrans trimmed %} - Once you have created your bot, @botfather will provide you with the authentication token to use your bot. - Enter that token below. - {% endblocktrans %} -
    6. -
    +
    +
      +
    1. + {% blocktrans trimmed %} + Start a new chat with the BotFather. You can do so on your + device by searching for "botfather" and starting a new chat. + {% endblocktrans %} +
    2. +
    3. + {% blocktrans trimmed %} + In your @botfather chat, type in the command /newbot. Follow the instructions to name your bot and + choose a username for it. + {% endblocktrans %} +
    4. +
    5. + {% blocktrans trimmed %} + Once you have created your bot, @botfather will provide you with the authentication token to use your bot. + Enter that token below. + {% endblocktrans %} +
    6. +
    +
    {% endblock pre-form %} From 98fcf42982ce816face5ce6fa7bd816f44d19ce3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 16:22:57 -0500 Subject: [PATCH 214/557] Update CHANGELOG.md for v9.3.74 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e659caa92..4d38a9c70c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.74 (2024-10-17) +------------------------- + * Remove pre-spa days code from flow list view + * Add more clarifications to FreshChat claim page + * Cleanup channel claim pages with steps + v9.3.73 (2024-10-17) ------------------------- * Overhaul UI for managing child workspaces diff --git a/pyproject.toml b/pyproject.toml index 9cfe5881f2c..e071e02bdc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.73" +version = "9.3.74" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 0a96b8be5ad..03f2700b8c7 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.73" +__version__ = "9.3.74" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 72257f5097a0f9ff4ecf063915f6b781707ddb86 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 17 Oct 2024 22:00:20 +0000 Subject: [PATCH 215/557] Make some list pages use a common template --- templates/globals/global_list.html | 116 ++++++++++++--------------- templates/orgs/base/list.html | 35 ++++++++ templates/orgs/org_list.html | 98 ++++++++++------------ templates/orgs/user_list.html | 96 +++++++++------------- templates/tickets/shortcut_list.html | 79 ++++++++---------- 5 files changed, 196 insertions(+), 228 deletions(-) create mode 100644 templates/orgs/base/list.html diff --git a/templates/globals/global_list.html b/templates/globals/global_list.html index 78f1b6ec4bd..c8d29c55792 100644 --- a/templates/globals/global_list.html +++ b/templates/globals/global_list.html @@ -1,7 +1,13 @@ -{% extends "smartmin/list.html" %} +{% extends "orgs/base/list.html" %} {% load smartmin temba i18n humanize %} -{% block content %} +{% block pre-table %} + + + + + +
    {% blocktrans trimmed %} Globals are variables you can use across all of your flows but @@ -10,67 +16,49 @@ might change later. {% endblocktrans %}
    - {% block pre-table %} - - - - - - - {% endblock pre-table %} -
    - - - -
    -
    {% include "includes/short_pagination.html" %}
    -
    - - - {% for obj in object_list %} - - - - - - + + + + - - {% empty %} - - - - {% endfor %} - -
    {{ obj.name }}@globals.{{ obj.key }} -
    {{ obj.value|truncatechars:25 }}
    -
    -
    - {% with usage_count=obj.usage_count %} - {% if usage_count %} -
    -
    - {% blocktrans trimmed count counter=usage_count %} - {{ counter }} use - {% plural %} - {{ counter }} uses - {% endblocktrans %} -
    -
    - {% endif %} - {% endwith %} -
    -
    - {% if org_perms.globals.global_delete %} -
    {{ obj.name }}@globals.{{ obj.key }} +
    {{ obj.value|truncatechars:25 }}
    +
    +
    + {% with usage_count=obj.usage_count %} + {% if usage_count %} +
    +
    + {% blocktrans trimmed count counter=usage_count %} + {{ counter }} use + {% plural %} + {{ counter }} uses + {% endblocktrans %}
    - {% endif %} -
    {% trans "No globals" %}
    -
    -{% endblock content %} + + {% endif %} + {% endwith %} + + + + {% if org_perms.globals.global_delete %} + + {% endif %} + + + {% empty %} + + {% trans "No globals" %} + + {% endfor %} +{% endblock table-body %} {% block extra-script %} {{ block.super }} {% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 992ad3971c9..3a7acfcdec2 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -1,7 +1,11 @@ -{% extends "smartmin/list.html" %} +{% extends "orgs/base/list.html" %} {% load smartmin temba i18n %} -{% block content %} +{% block pre-table %} + + + + {% if has_viewers %} {% blocktrans trimmed with cutoff="2024-12-31"|day %} @@ -10,54 +14,38 @@ {% endblocktrans %} {% endif %} - {% block pre-table %} - - - - - {% endblock pre-table %} -
    - - - -
    -
    {% include "includes/short_pagination.html" %}
    -
    - - - - - - - - - - - {% for obj in object_list %} - - - - - - - {% empty %} - - - - {% endfor %} - -
    {% trans "Email" %}{% trans "Name" %}{% trans "Role" %}
    {{ obj.email }}{{ obj.name }}{{ obj.role.display }} - {% if obj.role.code != "A" or admin_count > 1 %} - - {% endif %} -
    {% trans "No users" %}
    -
    -{% endblock content %} +{% endblock pre-table %} +{% block table-head %} + + {% trans "Email" %} + {% trans "Name" %} + {% trans "Role" %} + + +{% endblock table-head %} +{% block table-body %} + {% for obj in object_list %} + + {{ obj.email }} + {{ obj.name }} + {{ obj.role.display }} + + {% if obj.role.code != "A" or admin_count > 1 %} + + {% endif %} + + + {% empty %} + + {% trans "No users" %} + + {% endfor %} +{% endblock table-body %} {% block extra-script %} {{ block.super }} {% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html index d168b0b6c23..be12b26247a 100644 --- a/templates/tickets/shortcut_list.html +++ b/templates/tickets/shortcut_list.html @@ -1,48 +1,41 @@ -{% extends "smartmin/list.html" %} -{% load smartmin temba i18n humanize %} +{% extends "orgs/base/list.html" %} +{% load smartmin temba i18n %} -{% block content %} +{% block pre-table %} + + + +
    {% blocktrans trimmed %} These are canned responses that agents can use to quickly reply to tickets. {% endblocktrans %}
    - {% block pre-table %} - - - - - {% endblock pre-table %} -
    {% include "includes/short_pagination.html" %}
    -
    - - - {% for obj in object_list %} - - - - - - {% empty %} - - - - {% endfor %} - -
    {{ obj.name }} -
    {{ obj.text|truncatechars:100 }}
    -
    - {% if org_perms.tickets.shortcut_delete %} - - {% endif %} -
    {% trans "No shortcuts" %}
    -
    -{% endblock content %} +{% endblock pre-table %} +{% block table-body %} + {% for obj in object_list %} + + {{ obj.name }} + +
    {{ obj.text|truncatechars:100 }}
    + + + {% if org_perms.tickets.shortcut_delete %} + + {% endif %} + + + {% empty %} + + {% trans "No shortcuts" %} + + {% endfor %} +{% endblock table-body %} {% block extra-script %} {{ block.super }} {% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} From 6a83b45e7518def47316c899e88071aab77da7a9 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 17 Oct 2024 22:01:01 +0000 Subject: [PATCH 216/557] Add shortcuts ui --- package.json | 2 +- templates/frame.html | 1 + templates/msgs/broadcast_create_compose.html | 4 ++-- templates/tickets/shortcut_list.html | 17 +++++++++++++++-- templates/tickets/shortcut_update.html | 1 + yarn.lock | 8 ++++---- 6 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 templates/tickets/shortcut_update.html diff --git a/package.json b/package.json index b7f433c82ce..3f912df4323 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.108.7", + "@nyaruka/temba-components": "0.109.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/templates/frame.html b/templates/frame.html index ccbfcdb2351..101f1ba10ec 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -155,6 +155,7 @@ fields="/api/v2/fields.json" groups="/api/v2/groups.json" workspace="/api/v2/workspace.json" + shortcuts="/api/internal/shortcuts.json" users="/api/v2/users.json"> {% else %} diff --git a/templates/msgs/broadcast_create_compose.html b/templates/msgs/broadcast_create_compose.html index 83910ef8fb3..66e0b6ea600 100644 --- a/templates/msgs/broadcast_create_compose.html +++ b/templates/msgs/broadcast_create_compose.html @@ -5,8 +5,8 @@ {{ block.super }} {% endblock extra-style %} diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html index d168b0b6c23..65d2c9d9385 100644 --- a/templates/tickets/shortcut_list.html +++ b/templates/tickets/shortcut_list.html @@ -19,9 +19,19 @@ {% for obj in object_list %} - {{ obj.name }} + {{ obj.name }} -
    {{ obj.text|truncatechars:100 }}
    +
    {{ obj.text }}
    {% if org_perms.tickets.shortcut_delete %} @@ -46,6 +56,9 @@ {% block extra-script %} {{ block.super }} {% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} diff --git a/templates/orgs/org_list.html b/templates/orgs/org_list.html index 22752f44a01..a092eac07b9 100644 --- a/templates/orgs/org_list.html +++ b/templates/orgs/org_list.html @@ -1,12 +1,12 @@ {% extends "orgs/base/list.html" %} {% load i18n temba smartmin humanize %} -{% block pre-table %} +{% block modaxes %} -{% endblock pre-table %} +{% endblock modaxes %} {% block table %} diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 4831d7c63c7..24eb0cc9fe5 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -1,11 +1,13 @@ {% extends "orgs/base/list.html" %} {% load smartmin temba i18n %} -{% block pre-table %} +{% block modaxes %} +{% endblock modaxes %} +{% block pre-table %} {% if has_viewers %} {% blocktrans trimmed with cutoff="2024-12-31"|day %} diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html index 3dec455018f..cf021dd43bb 100644 --- a/templates/tickets/shortcut_list.html +++ b/templates/tickets/shortcut_list.html @@ -1,11 +1,13 @@ {% extends "orgs/base/list.html" %} {% load smartmin temba i18n %} -{% block pre-table %} +{% block modaxes %} +{% endblock modaxes %} +{% block pre-table %}
    {% blocktrans trimmed %} These are canned responses that agents can use to quickly reply to tickets. From 4613fafc1f9c85aebf8b4ed6b95fd9238068c6b1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 15:19:53 +0000 Subject: [PATCH 229/557] Convert API tokens page to be real list page --- temba/api/tests.py | 35 ++++++++++++++++- temba/api/v2/tests.py | 2 +- temba/api/views.py | 40 ++++++++++++++++---- temba/orgs/tests.py | 33 ---------------- temba/orgs/views/views.py | 35 +---------------- temba/settings_common.py | 5 +-- templates/api/apitoken_list.html | 58 +++++++++++++++++++++++++++++ templates/api/v2/explorer.html | 2 +- templates/orgs/base/list.html | 4 +- templates/orgs/user_account.html | 4 +- templates/orgs/user_tokens.html | 64 -------------------------------- 11 files changed, 136 insertions(+), 146 deletions(-) create mode 100644 templates/api/apitoken_list.html delete mode 100644 templates/orgs/user_tokens.html diff --git a/temba/api/tests.py b/temba/api/tests.py index 2e994791767..fa757ca3648 100644 --- a/temba/api/tests.py +++ b/temba/api/tests.py @@ -54,6 +54,39 @@ def test_record_used(self): class APITokenCRUDLTest(CRUDLTestMixin, TembaTest): + def test_list(self): + tokens_url = reverse("api.apitoken_list") + + self.assertRequestDisallowed(tokens_url, [None, self.user, self.agent]) + self.assertListFetch(tokens_url, [self.admin], context_objects=[]) + + # add user to other org and create API tokens for both + self.org2.add_user(self.admin, OrgRole.EDITOR) + token1 = APIToken.create(self.org, self.admin) + token2 = APIToken.create(self.org, self.admin) + APIToken.create(self.org, self.editor) # other user + APIToken.create(self.org2, self.admin) # other org + + response = self.assertListFetch(tokens_url, [self.admin], context_objects=[token1, token2], choose_org=self.org) + self.assertContentMenu(tokens_url, self.admin, ["New"], choose_org=self.org) + + # can POST to create new token + response = self.client.post(tokens_url, {}) + self.assertRedirect(response, tokens_url) + self.assertEqual(3, self.admin.get_api_tokens(self.org).count()) + token3 = self.admin.get_api_tokens(self.org).order_by("created").last() + + # and now option to create new token is gone because we've reached the limit + response = self.assertListFetch( + tokens_url, [self.admin], context_objects=[token1, token2, token3], choose_org=self.org + ) + self.assertContentMenu(tokens_url, self.admin, [], choose_org=self.org) + + # and POSTing is noop + response = self.client.post(tokens_url, {}) + self.assertRedirect(response, tokens_url) + self.assertEqual(3, self.admin.get_api_tokens(self.org).count()) + def test_delete(self): token1 = APIToken.create(self.org, self.admin) token2 = APIToken.create(self.org, self.editor) @@ -66,7 +99,7 @@ def test_delete(self): self.assertContains(response, f"You are about to delete the API token {token1.key[:6]}…") response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=token1) - self.assertRedirect(response, "/user/tokens/") + self.assertRedirect(response, "/apitoken/") token1.refresh_from_db() token2.refresh_from_db() diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index d8c19bb4952..60fb343b572 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -611,7 +611,7 @@ def test_explorer(self): response = self.client.get(explorer_url) self.assertContains(response, "To use the explorer you need to first create") - self.assertContains(response, reverse("orgs.user_tokens")) + self.assertContains(response, reverse("api.apitoken_list")) APIToken.create(self.org, self.admin) diff --git a/temba/api/views.py b/temba/api/views.py index b7fc5d19097..1ee523e262b 100644 --- a/temba/api/views.py +++ b/temba/api/views.py @@ -4,18 +4,19 @@ import iso8601 from rest_framework import generics, mixins, status from rest_framework.response import Response -from smartmin.views import SmartCRUDL, SmartDeleteView +from smartmin.views import SmartCRUDL from django.db import transaction from django.http import HttpResponseRedirect +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from temba import mailroom from temba.api.support import InvalidQueryError from temba.contacts.models import URN -from temba.orgs.views.mixins import OrgObjPermsMixin +from temba.orgs.views.base import BaseDeleteModal, BaseListView from temba.utils.models import TembaModel -from temba.utils.views.mixins import ModalFormMixin, NonAtomicMixin +from temba.utils.views.mixins import ContextMenuMixin, NonAtomicMixin from .models import APIToken, BulkActionFailure @@ -278,13 +279,38 @@ def perform_destroy(self, instance): class APITokenCRUDL(SmartCRUDL): model = APIToken - actions = ("delete",) + actions = ("list", "delete") - class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): + class List(ContextMenuMixin, BaseListView): + title = _("API Tokens") + menu_path = "/settings/account" + paginate_by = None + token_limit = 3 + + def build_context_menu(self, menu): + if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit: + menu.add_url_post(_("New"), reverse("api.apitoken_list"), as_button=True) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["token_limit"] = self.token_limit + return context + + def get_queryset(self, **kwargs): + return self.request.user.get_api_tokens(self.request.org).order_by("created") + + def post(self, request, *args, **kwargs): + # there's no create view - just a POST to this view + if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit: + APIToken.create(self.request.org, self.request.user) + + return HttpResponseRedirect(reverse("api.apitoken_list")) + + class Delete(BaseDeleteModal): slug_url_kwarg = "key" fields = ("key",) - cancel_url = "@orgs.user_tokens" - redirect_url = "@orgs.user_tokens" + cancel_url = "@api.apitoken_list" + redirect_url = "@api.apitoken_list" submit_button_name = _("Delete") def has_permission(self, request, *args, **kwargs): diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index d5e6a94eba0..ee4cf0d8e69 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -3237,39 +3237,6 @@ def test_recover(self): self.assertEqual(0, FailedLogin.objects.filter(username="admin@nyaruka.com").count()) # deleted self.assertEqual(1, FailedLogin.objects.filter(username="editor@nyaruka.com").count()) # unaffected - def test_tokens(self): - tokens_url = reverse("orgs.user_tokens") - - self.assertRequestDisallowed(tokens_url, [None, self.user, self.agent]) - self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin) - - # add user to other org and create API tokens for both - self.org2.add_user(self.admin, OrgRole.EDITOR) - token1 = APIToken.create(self.org, self.admin) - token2 = APIToken.create(self.org, self.admin) - APIToken.create(self.org, self.editor) # other user - APIToken.create(self.org2, self.admin) # other org - - response = self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin, choose_org=self.org) - self.assertEqual([token1, token2], list(response.context["tokens"])) - self.assertContentMenu(tokens_url, self.admin, ["New Token"], choose_org=self.org) - - # can POST to create new token - response = self.client.post(tokens_url, {"new": "1"}) - self.assertRedirect(response, reverse("orgs.user_tokens")) - self.assertEqual(3, self.admin.get_api_tokens(self.org).count()) - token3 = self.admin.get_api_tokens(self.org).order_by("created").last() - - # and now option to create new token is gone because we've reached the limit - response = self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin, choose_org=self.org) - self.assertEqual([token1, token2, token3], list(response.context["tokens"])) - self.assertContentMenu(tokens_url, self.admin, [], choose_org=self.org) - - # and POSTing is noop - response = self.client.post(tokens_url, {"new": "1"}) - self.assertRedirect(response, reverse("orgs.user_tokens")) - self.assertEqual(3, self.admin.get_api_tokens(self.org).count()) - def test_verify_email(self): self.assertEqual(self.admin.settings.email_status, "U") self.assertTrue(self.admin.settings.email_verification_secret) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 1cb1961a21f..007eb46983b 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -35,7 +35,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from temba.api.models import APIToken, Resthook +from temba.api.models import Resthook from temba.campaigns.models import Campaign from temba.flows.models import Flow from temba.formax import FormaxMixin @@ -346,12 +346,11 @@ class UserCRUDL(SmartCRUDL): "two_factor_disable", "two_factor_tokens", "account", - "tokens", "verify_email", "send_verification_email", ) - class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): + class List(RequireFeatureMixin, BaseListView): require_feature = Org.FEATURE_USERS title = _("Users") menu_path = "/settings/users" @@ -883,36 +882,6 @@ def get_context_data(self, **kwargs): def derive_formax_sections(self, formax, context): formax.add_section("profile", reverse("orgs.user_edit"), icon="user") - class Tokens(SpaMixin, InferUserMixin, ContextMenuMixin, OrgPermsMixin, SmartUpdateView): - class Form(forms.ModelForm): - new = forms.BooleanField(required=False) - - class Meta: - model = User - fields = () - - form_class = Form - title = _("API Tokens") - menu_path = "/settings/account" - success_url = "@orgs.user_tokens" - token_limit = 3 - - def build_context_menu(self, menu): - if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit: - menu.add_url_post(_("New Token"), reverse("orgs.user_tokens") + "?new=1", as_button=True) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["tokens"] = self.request.user.get_api_tokens(self.request.org).order_by("created") - context["token_limit"] = self.token_limit - return context - - def form_valid(self, form): - if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit: - APIToken.create(self.request.org, self.request.user) - - return super().form_valid(form) - class InvitationMixin: @cached_property diff --git a/temba/settings_common.py b/temba/settings_common.py index 988871ea83f..c333de375b0 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -396,7 +396,6 @@ "twilio_connect", "workspace", ), - "orgs.user": ("tokens",), "request_logs.httplog": ("webhooks", "classifier"), "tickets.ticket": ("assign", "assignee", "menu", "note", "export_stats", "export"), "triggers.trigger": ("archived", "type", "menu"), @@ -415,6 +414,7 @@ "airtime.airtimetransfer_list", "airtime.airtimetransfer_read", "api.apitoken_explorer", + "api.apitoken_list", "api.resthook_list", "api.resthooksubscriber_create", "api.resthooksubscriber_delete", @@ -500,7 +500,6 @@ "orgs.org_workspace", "orgs.orgimport.*", "orgs.user_list", - "orgs.user_tokens", "orgs.user_update", "request_logs.httplog_list", "request_logs.httplog_read", @@ -515,6 +514,7 @@ "airtime.airtimetransfer_list", "airtime.airtimetransfer_read", "api.apitoken_explorer", + "api.apitoken_list", "api.resthook_list", "api.resthooksubscriber_create", "api.resthooksubscriber_delete", @@ -583,7 +583,6 @@ "orgs.org_resthooks", "orgs.org_workspace", "orgs.orgimport.*", - "orgs.user_tokens", "request_logs.httplog_webhooks", "templates.template_list", "templates.template_read", diff --git a/templates/api/apitoken_list.html b/templates/api/apitoken_list.html new file mode 100644 index 00000000000..c2884f8ca2a --- /dev/null +++ b/templates/api/apitoken_list.html @@ -0,0 +1,58 @@ +{% extends "orgs/base/list.html" %} +{% load i18n temba %} + +{% block modaxes %} + + +{% endblock modaxes %} +{% block pre-table %} +
    + {% url "api.v2.root" as api_url %} + {% blocktrans trimmed with api_url=api_url limit=token_limit %} + These are your personal tokens for accessing the API. You can have a maximum of {{ limit }}. + {% endblocktrans %} +
    +{% endblock pre-table %} +{% block table %} +
    + + + + + + {% for obj in object_list %} + + + + + + {% empty %} + + + + {% endfor %} +
    {% trans "Key" %}{% trans "Last Used" %}
    {{ obj.key }} + {% if obj.last_used_on %} + {{ obj.last_used_on|duration }} + {% else %} + -- + {% endif %} + + +
    {% trans "No tokens" %}
    +{% endblock table %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} diff --git a/templates/api/v2/explorer.html b/templates/api/v2/explorer.html index cf663b4c35f..738dd65ead0 100644 --- a/templates/api/v2/explorer.html +++ b/templates/api/v2/explorer.html @@ -22,7 +22,7 @@ All operations work against real data in the {{ org }} workspace. {% endblocktrans %} {% else %} - {% url "orgs.user_tokens" as tokens_url %} + {% url "api.apitoken_list" as tokens_url %} {% blocktrans trimmed with org=user_org.name %} To use the explorer you need to first create an API token. {% endblocktrans %} diff --git a/templates/orgs/base/list.html b/templates/orgs/base/list.html index 138e62d05f4..0a9f9c02575 100644 --- a/templates/orgs/base/list.html +++ b/templates/orgs/base/list.html @@ -15,7 +15,9 @@ {% endblock search-form %} {% endif %} -
    {% include "includes/short_pagination.html" %}
    + {% if view.paginate_by %} +
    {% include "includes/short_pagination.html" %}
    + {% endif %}
    {% block table %} {% endblock table %} diff --git a/templates/orgs/user_account.html b/templates/orgs/user_account.html index 1cf0a333e22..d46e8e6d722 100644 --- a/templates/orgs/user_account.html +++ b/templates/orgs/user_account.html @@ -25,10 +25,10 @@
    - {% if org_perms.orgs.user_tokens %} + {% if org_perms.api.apitoken_list %}
    + href="{% url 'api.apitoken_list' %}">
    diff --git a/templates/orgs/user_tokens.html b/templates/orgs/user_tokens.html deleted file mode 100644 index 1febbac73e2..00000000000 --- a/templates/orgs/user_tokens.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "smartmin/update.html" %} -{% load i18n temba %} - -{% block subtitle %} - {% url "api.v2.root" as api_url %} - {% blocktrans trimmed with api_url=api_url limit=token_limit %} - These are your personal tokens for accessing the API. You can have a maximum of {{ limit }}. - {% endblocktrans %} -{% endblock subtitle %} -{% block content %} - {% block pre-table %} - - - {% endblock pre-table %} - - - - - - - {% for token in tokens %} - - - - - - {% empty %} - - - - {% endfor %} -
    {% trans "Key" %}{% trans "Last Used" %}
    {{ token.key }} - {% if token.last_used_on %} - {{ token.last_used_on|duration }} - {% else %} - -- - {% endif %} - - -
    No tokens
    -{% endblock content %} -{% block extra-script %} - {{ block.super }} - -{% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} From a31a15dd8d476748dcf3c4e99a8c4025ebdd2f6b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 16:00:58 +0000 Subject: [PATCH 230/557] Remove styles from contact field list page that are no longered used since it became a placeholder for the field management component --- templates/contacts/contactfield_list.html | 72 ++--------------------- 1 file changed, 4 insertions(+), 68 deletions(-) diff --git a/templates/contacts/contactfield_list.html b/templates/contacts/contactfield_list.html index 3beb839c09b..7133b9975ac 100644 --- a/templates/contacts/contactfield_list.html +++ b/templates/contacts/contactfield_list.html @@ -1,13 +1,7 @@ {% extends "smartmin/base.html" %} -{% load smartmin i18n humanize %} +{% load i18n %} -{% block page-title %} - {% trans "Custom Fields" %} -{% endblock page-title %} {% block content %} - - {% block pre-tables %} @@ -16,6 +10,9 @@ {% endblock pre-tables %} + + {% endblock content %} {% block extra-script %} {{ block.super }} @@ -57,64 +54,3 @@ } {% endblock extra-script %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} From e5cb24fe3acc6232646542cf7b0fee5545ede908 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 17:07:17 +0000 Subject: [PATCH 231/557] Change delete links on list views to be clearer --- templates/api/apitoken_list.html | 11 +++++------ templates/globals/global_list.html | 19 +++++++++---------- templates/orgs/base/list.html | 8 -------- templates/orgs/invitation_list.html | 11 +++++------ templates/orgs/org_list.html | 11 +++++------ templates/orgs/user_list.html | 11 +++++------ templates/tickets/shortcut_list.html | 11 +++++------ 7 files changed, 34 insertions(+), 48 deletions(-) diff --git a/templates/api/apitoken_list.html b/templates/api/apitoken_list.html index c2884f8ca2a..0fb37261cb9 100644 --- a/templates/api/apitoken_list.html +++ b/templates/api/apitoken_list.html @@ -31,12 +31,11 @@ {% endif %} - + + {% empty %} diff --git a/templates/globals/global_list.html b/templates/globals/global_list.html index 9c24ecd6a9a..349c25ae94c 100644 --- a/templates/globals/global_list.html +++ b/templates/globals/global_list.html @@ -46,16 +46,15 @@ {% endwith %}
    - - {% if org_perms.globals.global_delete %} - - {% endif %} - + {% if org_perms.globals.global_delete %} + + + + + {% endif %} {% empty %} diff --git a/templates/orgs/base/list.html b/templates/orgs/base/list.html index 0a9f9c02575..70d92d4fd1e 100644 --- a/templates/orgs/base/list.html +++ b/templates/orgs/base/list.html @@ -23,11 +23,3 @@ {% endblock table %}
    {% endblock content %} -{% block extra-style %} - {{ block.super }} - -{% endblock extra-style %} diff --git a/templates/orgs/invitation_list.html b/templates/orgs/invitation_list.html index d679f15b355..7bb93d7a633 100644 --- a/templates/orgs/invitation_list.html +++ b/templates/orgs/invitation_list.html @@ -29,12 +29,11 @@ {{ obj.role.display }} {{ obj.created_on|day }} - + + {% empty %} diff --git a/templates/orgs/org_list.html b/templates/orgs/org_list.html index a092eac07b9..9bf11afb966 100644 --- a/templates/orgs/org_list.html +++ b/templates/orgs/org_list.html @@ -28,12 +28,11 @@ {{ obj.created_on|day }} {% if obj.id != user_org.id %} - + + {% endif %} diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 24eb0cc9fe5..b6d64acdc59 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -35,12 +35,11 @@ {{ obj.role.display }} {% if obj.role.code != "A" or admin_count > 1 %} - + + {% endif %} diff --git a/templates/tickets/shortcut_list.html b/templates/tickets/shortcut_list.html index cf021dd43bb..3dfc0bb7fe4 100644 --- a/templates/tickets/shortcut_list.html +++ b/templates/tickets/shortcut_list.html @@ -35,12 +35,11 @@ {% if org_perms.tickets.shortcut_delete %} - + + {% endif %} From 128788b8b8b4c3c8f7fe2f30ebd980325da5c30a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 19:18:30 +0000 Subject: [PATCH 232/557] Show user team in user list view --- temba/orgs/models.py | 35 +++++++++++++++++++++-------------- temba/orgs/views/views.py | 5 ++++- templates/orgs/user_list.html | 5 ++++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index ac0c18e2cee..83fa854227a 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -539,7 +539,7 @@ class Org(SmartModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._user_role_cache = {} + self._membership_cache = {} @classmethod def get_unique_slug(cls, name): @@ -1069,16 +1069,15 @@ def add_user(self, user: User, role: OrgRole, *, team=None): if self.has_user(user): # remove user from any existing roles self.remove_user(user) - self.users.add(user, through_defaults={"role_code": role.code, "team": team}) - self._user_role_cache[user] = role + self._membership_cache[user] = OrgMembership.objects.create(org=self, user=user, role_code=role.code, team=team) def remove_user(self, user: User): """ Removes the given user from this org by removing them from any roles """ self.users.remove(user) - if user in self._user_role_cache: - del self._user_role_cache[user] + if user in self._membership_cache: + del self._membership_cache[user] def get_owner(self) -> User: # look thru roles in order for the first added user @@ -1090,21 +1089,29 @@ def get_owner(self) -> User: # default to user that created this org (converting to our User proxy model) return User.objects.get(id=self.created_by_id) - def get_user_role(self, user: User): + def get_membership(self, user: User): """ - Gets the role of the given user in this org if any. + Gets the membership of the given user in this org (if any). """ - def get_role(): + def get(): + # for staff we return a faked membership: admin role, no team if user.is_staff: - return OrgRole.ADMINISTRATOR + return OrgMembership(org=self, user=user, role_code=OrgRole.ADMINISTRATOR, team=None) + + return OrgMembership.objects.filter(org=self, user=user).first() + + if user not in self._membership_cache: + self._membership_cache[user] = get() + return self._membership_cache[user] - membership = OrgMembership.objects.filter(org=self, user=user).first() - return membership.role if membership else None + def get_user_role(self, user: User): + """ + Convenience method to get just the role of the given user in this org (if any). + """ - if user not in self._user_role_cache: - self._user_role_cache[user] = get_role() - return self._user_role_cache[user] + membership = self.get_membership(user) + return membership.role if membership else None def create_sample_flows(self, api_url): # get our sample dir diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 007eb46983b..6578ffbe4ac 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -367,8 +367,11 @@ def derive_queryset(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + # annotate the users with their roles and teams for user in context["object_list"]: - user.role = self.request.org.get_user_role(user) + membership = self.request.org.get_membership(user) + user.role = membership.role + user.team = membership.team context["has_viewers"] = self.request.org.get_users(roles=[OrgRole.VIEWER]).exists() context["admin_count"] = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).count() diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index 24eb0cc9fe5..c859f8b6207 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -32,7 +32,10 @@ {{ obj.email }} {{ obj.name }} - {{ obj.role.display }} + + {{ obj.role.display }} + {% if obj.team %}({{ obj.team.name }}){% endif %} + {% if obj.role.code != "A" or admin_count > 1 %}
    Date: Wed, 23 Oct 2024 20:02:45 +0000 Subject: [PATCH 233/557] Add Team.all_topics to more easily model a team that can access all topics --- .../migrations/0065_team_all_topics.py | 12 +++++++++ temba/tickets/models.py | 14 +++++++--- temba/tickets/tests.py | 26 ++++++++++++++----- 3 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 temba/tickets/migrations/0065_team_all_topics.py diff --git a/temba/tickets/migrations/0065_team_all_topics.py b/temba/tickets/migrations/0065_team_all_topics.py new file mode 100644 index 00000000000..89760e2cbe2 --- /dev/null +++ b/temba/tickets/migrations/0065_team_all_topics.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.2 on 2024-10-23 19:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("tickets", "0064_shortcut")] + + operations = [ + migrations.AddField(model_name="team", name="all_topics", field=models.BooleanField(default=False)), + ] diff --git a/temba/tickets/models.py b/temba/tickets/models.py index d949e2288df..7193392eb4f 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -86,8 +86,12 @@ def create_from_import_def(cls, org, user, definition: dict): def release(self, user): assert not (self.is_system and self.org.is_active), "can't release system topics" assert not self.tickets.exists(), "can't release topic with tickets" + super().release(user) + for team in self.teams.all(): + team.topics.remove(self) + self.is_active = False self.name = self._deleted_name() self.modified_by = user @@ -402,20 +406,24 @@ class Meta: class Team(TembaModel): """ - Every user can be a member of a ticketing team + Agent users are assigned to a team which controls which topics they can access. """ org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="teams") topics = models.ManyToManyField(Topic, related_name="teams") + all_topics = models.BooleanField(default=False) org_limit_key = Org.LIMIT_TEAMS @classmethod - def create(cls, org, user, name: str): + def create(cls, org, user, name: str, *, topics=(), all_topics: bool = False): assert cls.is_valid_name(name), f"'{name}' is not a valid team name" assert not org.teams.filter(name__iexact=name, is_active=True).exists() + assert not (topics and all_topics), "can't specify topics and all_topics" - return org.teams.create(name=name, created_by=user, modified_by=user) + team = org.teams.create(name=name, all_topics=all_topics, created_by=user, modified_by=user) + team.topics.add(*topics) + return team def get_users(self): return self.org.users.filter(orgmembership__team=self) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 63c8628aef4..41d72ec431a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1286,14 +1286,20 @@ def _import(definition, preview=False): def test_release(self): topic1 = Topic.create(self.org, self.admin, "Sales") + topic2 = Topic.create(self.org, self.admin, "Support") flow = self.create_flow("Test") flow.topic_dependencies.add(topic1) + team = Team.create(self.org, self.admin, "Sales & Support", topics=[topic1, topic2]) topic1.release(self.admin) self.assertFalse(topic1.is_active) self.assertTrue(topic1.name.startswith("deleted-")) + # topic should be removed from team + self.assertEqual({topic2}, set(team.topics.all())) + + # flow should be flagged as having issues flow.refresh_from_db() self.assertTrue(flow.has_issues) @@ -1313,16 +1319,24 @@ def test_release(self): class TeamTest(TembaTest): def test_create(self): - team1 = Team.create(self.org, self.admin, "Sales") + sales = Topic.create(self.org, self.admin, "Sales") + support = Topic.create(self.org, self.admin, "Support") + team1 = Team.create(self.org, self.admin, "Sales & Support", topics=[sales, support]) agent2 = self.create_user("tickets@nyaruka.com") self.org.add_user(self.agent, OrgRole.AGENT, team=team1) self.org.add_user(agent2, OrgRole.AGENT, team=team1) - self.assertEqual("Sales", team1.name) - self.assertEqual("Sales", str(team1)) - self.assertEqual(f'', repr(team1)) - + self.assertEqual("Sales & Support", team1.name) + self.assertEqual("Sales & Support", str(team1)) + self.assertEqual(f'', repr(team1)) self.assertEqual({self.agent, agent2}, set(team1.get_users())) + self.assertEqual({sales, support}, set(team1.topics.all())) + self.assertFalse(team1.all_topics) + + # create an unrestricted team + team2 = Team.create(self.org, self.admin, "Any Topic", all_topics=True) + self.assertEqual(set(), set(team2.topics.all())) + self.assertTrue(team2.all_topics) # try to create with invalid name with self.assertRaises(AssertionError): @@ -1330,7 +1344,7 @@ def test_create(self): # try to create with name that already exists with self.assertRaises(AssertionError): - Team.create(self.org, self.admin, "Sales") + Team.create(self.org, self.admin, "Sales & Support") def test_release(self): team1 = Team.create(self.org, self.admin, "Sales") From 955bef926bbdd88e6f3ad5e0c8566ba5d3910b48 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 20:35:46 +0000 Subject: [PATCH 234/557] Fix paging on archive list pages and make styling consistent with other list views --- temba/archives/views.py | 29 ++--- templates/archives/archive_list.html | 156 +++++++----------------- templates/archives/archive_message.html | 1 - templates/archives/archive_run.html | 1 - 4 files changed, 51 insertions(+), 136 deletions(-) delete mode 100644 templates/archives/archive_message.html delete mode 100644 templates/archives/archive_run.html diff --git a/temba/archives/views.py b/temba/archives/views.py index 2aea2a100c5..1ce4b9b98b6 100644 --- a/temba/archives/views.py +++ b/temba/archives/views.py @@ -13,49 +13,40 @@ class ArchiveCRUDL(SmartCRUDL): model = Archive actions = ("read", "run", "message") - permissions = True class BaseList(BaseListView): - title = _("Archive") fields = ("url", "start_date", "period", "record_count", "size") default_order = ("-start_date", "-period", "archive_type") - paginate_by = 250 + default_template = "archives/archive_list.html" def derive_queryset(self, **kwargs): - queryset = super().derive_queryset(**kwargs) - - # filter by our archive type - return queryset.filter(archive_type=self.get_archive_type()).exclude(rollup_id__isnull=False) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["archive_types"] = Archive.TYPE_CHOICES - context["selected"] = self.get_archive_type() - return context + # filter by our archive type and exclude archives included in rollups + return ( + super() + .derive_queryset(**kwargs) + .filter(archive_type=self.get_archive_type()) + .exclude(rollup_id__isnull=False) + ) class Run(BaseList): + title = _("Run Archives") menu_path = "/settings/archives/run" @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/$" % (path, Archive.TYPE_FLOWRUN) - def derive_title(self): - return _("Run Archives") - def get_archive_type(self): return Archive.TYPE_FLOWRUN class Message(BaseList): + title = _("Message Archives") menu_path = "/settings/archives/message" @classmethod def derive_url_pattern(cls, path, action): return r"^%s/%s/$" % (path, Archive.TYPE_MSG) - def derive_title(self): - return _("Message Archives") - def get_archive_type(self): return Archive.TYPE_MSG diff --git a/templates/archives/archive_list.html b/templates/archives/archive_list.html index b3abd41cbf9..cf0a1d4b96d 100644 --- a/templates/archives/archive_list.html +++ b/templates/archives/archive_list.html @@ -1,115 +1,41 @@ -{% extends "smartmin/list.html" %} -{% load smartmin sms temba compress i18n humanize %} - -{% block page-title %} - {{ title }} -{% endblock page-title %} -{% block content %} - {% if object_list %} - - - - - - - - {% for archive in object_list %} - - - - - - - {% endfor %} -
    {% trans "Records" %}{% trans "Size" %}{% trans "Period" %}
    -
    {{ archive.record_count|intcomma }}
    -
    -
    {{ archive.size_display }}
    -
    - {% if archive.period == 'D' %} - {{ archive.start_date|date:"M j, Y" }} - {% else %} - {{ archive.start_date|date:"F Y" }} - {% endif %} - - - -
    - {% else %} - {% blocktrans trimmed %} - No archives found. Archives are created after 90 days of inactivity for messages and flow runs. Check back later to - see a list of all archives. - {% endblocktrans %} - {% endif %} - {% block paginator %} - {% if object_list.count %} - {% include "includes/short_pagination.html" %} - {% endif %} - {% endblock paginator %} -{% endblock content %} -{% block extra-style %} - -{% endblock extra-style %} +{% extends "orgs/base/list.html" %} +{% load i18n humanize temba %} + +{% block table %} + + + + + + + + {% for archive in object_list %} + + + + + + + {% empty %} + + + + {% endfor %} +
    {% trans "Period" %}{% trans "Records" %}{% trans "Size" %}
    + {% if archive.period == 'D' %} + {{ archive.start_date|date:"M j, Y" }} + {% else %} + {{ archive.start_date|date:"F Y" }} + {% endif %} + {{ archive.record_count|intcomma }}{{ archive.size_display }} + + +
    + {% blocktrans trimmed %} + No archives found. Archives are created after 90 days of inactivity for messages and flow runs. Check back later to + see a list of all archives. + {% endblocktrans %} +
    +{% endblock table %} diff --git a/templates/archives/archive_message.html b/templates/archives/archive_message.html deleted file mode 100644 index 353405a0167..00000000000 --- a/templates/archives/archive_message.html +++ /dev/null @@ -1 +0,0 @@ -{% extends 'archives/archive_list.html' %} diff --git a/templates/archives/archive_run.html b/templates/archives/archive_run.html deleted file mode 100644 index 353405a0167..00000000000 --- a/templates/archives/archive_run.html +++ /dev/null @@ -1 +0,0 @@ -{% extends 'archives/archive_list.html' %} From 7d53194ccba4134ea2a505e6eb41161838cd8a07 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 20:50:44 +0000 Subject: [PATCH 235/557] Use django filter to format archive size and make empty result treatment consistent with other views --- temba/api/v2/views.py | 20 ++++++++-------- temba/archives/models.py | 34 ++++++++-------------------- temba/archives/tests.py | 9 +------- temba/utils/__init__.py | 8 ------- temba/utils/tests.py | 13 ----------- templates/archives/archive_list.html | 16 +++++++------ 6 files changed, 29 insertions(+), 71 deletions(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 6f83ac54b56..c494e46d9a2 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -325,12 +325,12 @@ class ArchivesEndpoint(ListAPIMixin, BaseEndpoint): A `GET` returns the archives for your organization with the following fields. - * **archive_type** - the type of the archive, one of `message`or `run` (filterable as `archive_type`). + * **archive_type** - the type of the archive, one of `message` or `run` (filterable as `archive_type`). * **start_date** - the UTC date of the archive (string) (filterable as `before` and `after`). * **period** - `daily` for daily archives, `monthly` for monthly archives (filterable as `period`). * **record_count** - number of records in the archive (int). - * **size** - size of the gziped archive content (int). - * **hash** - MD5 hash of the gziped archive (string). + * **size** - size of the gzipped archive content (int). + * **hash** - MD5 hash of the gzipped archive (string). * **download_url** - temporary download URL of the archive (string). Example: @@ -345,13 +345,13 @@ class ArchivesEndpoint(ListAPIMixin, BaseEndpoint): "count": 248, "results": [ { - "archive_type":"message", - "start_date":"2017-02-20", - "period":"daily", - "record_count":1432, - "size":2304, - "hash":"feca9988b7772c003204a28bd741d0d0", - "download_url":"" + "archive_type": "message", + "start_date": "2017-02-20", + "period": "daily", + "record_count": 1432, + "size": 2304, + "hash": "feca9988b7772c003204a28bd741d0d0", + "download_url": "https://..." }, ... } diff --git a/temba/archives/models.py b/temba/archives/models.py index 4c0f21711ae..71972bc14cf 100644 --- a/temba/archives/models.py +++ b/temba/archives/models.py @@ -14,7 +14,7 @@ from django.db.models import Q from django.utils import timezone -from temba.utils import json, s3, sizeof_fmt +from temba.utils import json, s3 from temba.utils.s3 import EventStreamReader KEY_PATTERN = re.compile(r"^(?P\d+)/(?Prun|message)_(?P(D|M)\d+)_(?P[0-9a-f]{32})\.jsonl\.gz$") @@ -35,33 +35,20 @@ class Archive(models.Model): archive_type = models.CharField(choices=TYPE_CHOICES, max_length=16) created_on = models.DateTimeField(default=timezone.now) - # the length of time this archive covers period = models.CharField(max_length=1, choices=PERIOD_CHOICES, default=PERIOD_DAILY) + start_date = models.DateField() # the earliest modified_on date for records (inclusive) + record_count = models.IntegerField(default=0) # number of records in this archive + size = models.BigIntegerField(default=0) # size in bytes of the archive contents (after compression) + hash = models.TextField() # MD5 hash of the archive contents (after compression) + url = models.URLField() # full URL of this archive + build_time = models.IntegerField() # time in ms it took to build and upload this archive - # the earliest modified_on date for records in this archive (inclusive) - start_date = models.DateField() - - # number of records in this archive - record_count = models.IntegerField(default=0) - - # size in bytes of the archive contents (after compression) - size = models.BigIntegerField(default=0) - - # MD5 hash of the archive contents (after compression) - hash = models.TextField() - - # full URL of this archive - url = models.URLField() + # archive we were rolled up into, if any + rollup = models.ForeignKey("archives.Archive", on_delete=models.PROTECT, null=True) # whether the records in this archive need to be deleted needs_deletion = models.BooleanField(default=False) - # number of milliseconds it took to build and upload this archive - build_time = models.IntegerField() - - # archive we were rolled up into, if any - rollup = models.ForeignKey("archives.Archive", on_delete=models.PROTECT, null=True) - # when this archive's records where deleted (if any) deleted_on = models.DateTimeField(null=True) @@ -69,9 +56,6 @@ class Archive(models.Model): def storage(cls): return storages["archives"] - def size_display(self): - return sizeof_fmt(self.size) - def get_storage_location(self) -> tuple: """ Returns a tuple of the storage bucket and key diff --git a/temba/archives/tests.py b/temba/archives/tests.py index 007baa85aca..6eecb95be0a 100644 --- a/temba/archives/tests.py +++ b/temba/archives/tests.py @@ -204,14 +204,7 @@ def purge_jim(record): class ArchiveCRUDLTest(TembaTest, CRUDLTestMixin): - def test_empty_list(self): - response = self.assertListFetch(reverse("archives.archive_run"), [self.editor], context_objects=[]) - self.assertContains(response, "No archives found") - - response = self.assertListFetch(reverse("archives.archive_message"), [self.editor], context_objects=[]) - self.assertContains(response, "No archives found") - - def test_archive_type_filter(self): + def test_list_views(self): # a daily archive that has been rolled up and will not appear in the results d1 = self.create_archive(Archive.TYPE_MSG, "D", date(2020, 7, 31), [{"id": 1}, {"id": 2}]) m1 = self.create_archive(Archive.TYPE_MSG, "M", date(2020, 7, 1), [{"id": 1}, {"id": 2}], rollup_of=(d1,)) diff --git a/temba/utils/__init__.py b/temba/utils/__init__.py index 18e53171621..80384103982 100644 --- a/temba/utils/__init__.py +++ b/temba/utils/__init__.py @@ -44,14 +44,6 @@ def format_number(val): return val -def sizeof_fmt(num, suffix="b"): - for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]: - if abs(num) < 1024.0: - return "%3.1f %s%s" % (num, unit, suffix) - num /= 1024.0 - return "%.1f %s%s" % (num, "Y", suffix) - - def chunk_list(iterable, size): """ Splits a very large list into evenly sized chunks. diff --git a/temba/utils/tests.py b/temba/utils/tests.py index 6edad3e95ec..567b4089fc1 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -28,7 +28,6 @@ percentage, redact, set_nested_key, - sizeof_fmt, str_to_bool, ) from .checks import storage @@ -40,18 +39,6 @@ class InitTest(TembaTest): - def test_sizeof_fmt(self): - self.assertEqual("512.0 b", sizeof_fmt(512)) - self.assertEqual("1.0 Kb", sizeof_fmt(1024)) - self.assertEqual("1.0 Mb", sizeof_fmt(1024**2)) - self.assertEqual("1.0 Gb", sizeof_fmt(1024**3)) - self.assertEqual("1.0 Tb", sizeof_fmt(1024**4)) - self.assertEqual("1.0 Pb", sizeof_fmt(1024**5)) - self.assertEqual("1.0 Eb", sizeof_fmt(1024**6)) - self.assertEqual("1.0 Zb", sizeof_fmt(1024**7)) - self.assertEqual("1.0 Yb", sizeof_fmt(1024**8)) - self.assertEqual("1024.0 Yb", sizeof_fmt(1024**9)) - def test_str_to_bool(self): self.assertFalse(str_to_bool(None)) self.assertFalse(str_to_bool("")) diff --git a/templates/archives/archive_list.html b/templates/archives/archive_list.html index cf0a1d4b96d..f702acf824b 100644 --- a/templates/archives/archive_list.html +++ b/templates/archives/archive_list.html @@ -1,6 +1,13 @@ {% extends "orgs/base/list.html" %} {% load i18n humanize temba %} +{% block pre-table %} +
    + {% blocktrans trimmed %} + Archives are created after 90 days of inactivity for messages and flow runs. + {% endblocktrans %} +
    +{% endblock pre-table %} {% block table %} @@ -19,7 +26,7 @@ {% endif %} - + - + {% endfor %}
    {{ archive.record_count|intcomma }}{{ archive.size_display }}{{ archive.size|filesizeformat }} {% empty %}
    - {% blocktrans trimmed %} - No archives found. Archives are created after 90 days of inactivity for messages and flow runs. Check back later to - see a list of all archives. - {% endblocktrans %} - {% trans "No archives" %}
    From 2a5e631dd6e7a7269c8a12ff2c1b68f0942bd153 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 16:32:18 -0500 Subject: [PATCH 236/557] Update CHANGELOG.md for v9.3.78 --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f806b45f77..e76fd3dedba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +v9.3.78 (2024-10-23) +------------------------- + * Use django filter to format archive size + * Fix paging on archive list pages and make styling consistent with other list views + * Add Team.all_topics to more easily model a team that can access all topics + * Remove styles from contact field list page that are no longered used since it became a placeholder for the field management component + * Convert API tokens page to be real list page + * Make some list pages use a common template + v9.3.77 (2024-10-22) ------------------------- * Update django diff --git a/pyproject.toml b/pyproject.toml index f1bb103ae45..b31e9da2533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.77" +version = "9.3.78" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index dbe7020575c..1f611a219e6 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.77" +__version__ = "9.3.78" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 3d98249cdcdf14dde26d575c1817d2463c88da8f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 22:04:25 +0000 Subject: [PATCH 237/557] Move Team class for convenience --- temba/tickets/models.py | 74 ++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 7193392eb4f..abcf0cf4966 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -101,6 +101,43 @@ class Meta: constraints = [models.UniqueConstraint("org", Lower("name"), name="unique_topic_names")] +class Team(TembaModel): + """ + Agent users are assigned to a team which controls which topics they can access. + """ + + org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="teams") + topics = models.ManyToManyField(Topic, related_name="teams") + all_topics = models.BooleanField(default=False) + + org_limit_key = Org.LIMIT_TEAMS + + @classmethod + def create(cls, org, user, name: str, *, topics=(), all_topics: bool = False): + assert cls.is_valid_name(name), f"'{name}' is not a valid team name" + assert not org.teams.filter(name__iexact=name, is_active=True).exists() + assert not (topics and all_topics), "can't specify topics and all_topics" + + team = org.teams.create(name=name, all_topics=all_topics, created_by=user, modified_by=user) + team.topics.add(*topics) + return team + + def get_users(self): + return self.org.users.filter(orgmembership__team=self) + + def release(self, user): + # remove all users from this team + OrgMembership.objects.filter(org=self.org, team=self).update(team=None) + + self.name = self._deleted_name() + self.is_active = False + self.modified_by = user + self.save(update_fields=("name", "is_active", "modified_by", "modified_on")) + + class Meta: + constraints = [models.UniqueConstraint("org", Lower("name"), name="unique_team_names")] + + class Ticket(models.Model): """ A ticket represents a period of human interaction with a contact. @@ -404,43 +441,6 @@ class Meta: ] -class Team(TembaModel): - """ - Agent users are assigned to a team which controls which topics they can access. - """ - - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="teams") - topics = models.ManyToManyField(Topic, related_name="teams") - all_topics = models.BooleanField(default=False) - - org_limit_key = Org.LIMIT_TEAMS - - @classmethod - def create(cls, org, user, name: str, *, topics=(), all_topics: bool = False): - assert cls.is_valid_name(name), f"'{name}' is not a valid team name" - assert not org.teams.filter(name__iexact=name, is_active=True).exists() - assert not (topics and all_topics), "can't specify topics and all_topics" - - team = org.teams.create(name=name, all_topics=all_topics, created_by=user, modified_by=user) - team.topics.add(*topics) - return team - - def get_users(self): - return self.org.users.filter(orgmembership__team=self) - - def release(self, user): - # remove all users from this team - OrgMembership.objects.filter(org=self.org, team=self).update(team=None) - - self.name = self._deleted_name() - self.is_active = False - self.modified_by = user - self.save(update_fields=("name", "is_active", "modified_by", "modified_on")) - - class Meta: - constraints = [models.UniqueConstraint("org", Lower("name"), name="unique_team_names")] - - class TicketDailyCount(DailyCountModel): """ Ticket activity daily counts by who did it and when. Mailroom writes these. From a647703a31cc29ff792ebbaedda05d625658247f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 23 Oct 2024 22:16:08 +0000 Subject: [PATCH 238/557] Give every workspace a default team with access to all topics --- temba/orgs/models.py | 10 ++++++-- temba/orgs/tests.py | 6 +++-- .../migrations/0066_team_is_default.py | 18 ++++++++++++++ temba/tickets/models.py | 24 ++++++++++++++----- 4 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 temba/tickets/migrations/0066_team_is_default.py diff --git a/temba/orgs/models.py b/temba/orgs/models.py index ac0c18e2cee..b63357f7ae3 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -949,6 +949,10 @@ def get_contact_count(self) -> int: def default_ticket_topic(self): return self.topics.get(is_default=True) + @cached_property + def default_ticket_team(self): + return self.teams.get(is_default=True) + def get_resthooks(self): """ Returns the resthooks configured on this Org @@ -1229,12 +1233,13 @@ def initialize(self, sample_flows=True): Initializes an organization, creating all the dependent objects we need for it to work properly. """ from temba.contacts.models import ContactField, ContactGroup - from temba.tickets.models import Topic + from temba.tickets.models import Team, Topic with transaction.atomic(): ContactGroup.create_system_groups(self) ContactField.create_system_fields(self) - Topic.create_default_topic(self) + Team.create_system(self) + Topic.create_system(self) # outside of the transaction as it's going to call out to mailroom for flow validation if sample_flows: @@ -1339,6 +1344,7 @@ def delete(self) -> dict: delete_in_batches(self.tickets.all()) delete_in_batches(self.ticket_counts.all()) delete_in_batches(self.topics.all()) + delete_in_batches(self.teams.all()) delete_in_batches(self.airtime_transfers.all()) # delete our contacts diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index ee4cf0d8e69..68b29a757df 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -36,7 +36,7 @@ from temba.templates.models import TemplateTranslation from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.tests.base import get_contact_search -from temba.tickets.models import TicketExport +from temba.tickets.models import Team, TicketExport, Topic from temba.triggers.models import Trigger from temba.utils import json, languages from temba.utils.uuid import uuid4 @@ -1311,10 +1311,12 @@ def _create_campaign_content(self, org, user, fields, groups, flows, contacts, a add(FlowStartCount.objects.create(start=start1, count=1)) def _create_ticket_content(self, org, user, contacts, flows, add): - ticket1 = add(self.create_ticket(contacts[0])) + topic = add(Topic.create(org, user, "Spam")) + ticket1 = add(self.create_ticket(contacts[0], topic)) ticket1.events.create(org=org, contact=contacts[0], event_type="N", note="spam", created_by=user) add(self.create_ticket(contacts[0], opened_in=flows[0])) + add(Team.create(org, user, "Spam Only", topics=[topic])) def _create_export_content(self, org, user, flows, groups, fields, labels, add): results = add( diff --git a/temba/tickets/migrations/0066_team_is_default.py b/temba/tickets/migrations/0066_team_is_default.py new file mode 100644 index 00000000000..9591a9ca472 --- /dev/null +++ b/temba/tickets/migrations/0066_team_is_default.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-10-23 22:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tickets", "0065_team_all_topics"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="is_default", + field=models.BooleanField(default=False), + ), + ] diff --git a/temba/tickets/models.py b/temba/tickets/models.py index abcf0cf4966..3b036492a0d 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -53,19 +53,17 @@ class Topic(TembaModel, DependencyMixin): The topic of a ticket which controls who can access that ticket. """ - DEFAULT_TOPIC = "General" - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="topics") is_default = models.BooleanField(default=False) org_limit_key = Org.LIMIT_TOPICS @classmethod - def create_default_topic(cls, org): + def create_system(cls, org): assert not org.topics.filter(is_default=True).exists(), "org already has default topic" org.topics.create( - name=cls.DEFAULT_TOPIC, + name="General", is_default=True, is_system=True, created_by=org.created_by, @@ -109,9 +107,23 @@ class Team(TembaModel): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="teams") topics = models.ManyToManyField(Topic, related_name="teams") all_topics = models.BooleanField(default=False) + is_default = models.BooleanField(default=False) org_limit_key = Org.LIMIT_TEAMS + @classmethod + def create_system(cls, org): + assert not org.teams.filter(is_default=True).exists(), "org already has default team" + + org.teams.create( + name="All Topics", + is_default=True, + is_system=True, + all_topics=True, + created_by=org.created_by, + modified_by=org.modified_by, + ) + @classmethod def create(cls, org, user, name: str, *, topics=(), all_topics: bool = False): assert cls.is_valid_name(name), f"'{name}' is not a valid team name" @@ -126,8 +138,8 @@ def get_users(self): return self.org.users.filter(orgmembership__team=self) def release(self, user): - # remove all users from this team - OrgMembership.objects.filter(org=self.org, team=self).update(team=None) + # re-assign agents in this team to the default team + OrgMembership.objects.filter(org=self.org, team=self).update(team=self.org.default_ticket_team) self.name = self._deleted_name() self.is_active = False From 969d472f0935a9b6c65d3de132294a8fc7f21ba7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 14:13:06 +0000 Subject: [PATCH 239/557] Add max length of 10,000 to shortcut text --- .../migrations/0066_alter_shortcut_text.py | 18 ++++++++++++++++++ temba/tickets/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 temba/tickets/migrations/0066_alter_shortcut_text.py diff --git a/temba/tickets/migrations/0066_alter_shortcut_text.py b/temba/tickets/migrations/0066_alter_shortcut_text.py new file mode 100644 index 00000000000..2a445114e6f --- /dev/null +++ b/temba/tickets/migrations/0066_alter_shortcut_text.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-10-24 14:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tickets", "0065_team_all_topics"), + ] + + operations = [ + migrations.AlterField( + model_name="shortcut", + name="text", + field=models.TextField(max_length=10000), + ), + ] diff --git a/temba/tickets/models.py b/temba/tickets/models.py index abcf0cf4966..c8d3eab1812 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -29,7 +29,7 @@ class Shortcut(TembaModel): """ org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="shortcuts") - text = models.TextField() + text = models.TextField(max_length=10_000) @classmethod def create(cls, org, user, name: str, text: str): From 98c10810e586026a62f1c8c03a1c8d408f738255 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 15:16:13 +0000 Subject: [PATCH 240/557] Fix migration conflict and add test for agent re-assignment --- .../{0066_team_is_default.py => 0067_team_is_default.py} | 2 +- temba/tickets/tests.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) rename temba/tickets/migrations/{0066_team_is_default.py => 0067_team_is_default.py} (87%) diff --git a/temba/tickets/migrations/0066_team_is_default.py b/temba/tickets/migrations/0067_team_is_default.py similarity index 87% rename from temba/tickets/migrations/0066_team_is_default.py rename to temba/tickets/migrations/0067_team_is_default.py index 9591a9ca472..64bc3444822 100644 --- a/temba/tickets/migrations/0066_team_is_default.py +++ b/temba/tickets/migrations/0067_team_is_default.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("tickets", "0065_team_all_topics"), + ("tickets", "0066_alter_shortcut_text"), ] operations = [ diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 41d72ec431a..a6cfc875e9a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1354,9 +1354,11 @@ def test_release(self): self.assertFalse(team1.is_active) self.assertTrue(team1.name.startswith("deleted-")) - self.assertEqual(0, team1.get_users().count()) + # check agent was re-assigned to default team + self.assertEqual({self.agent}, set(self.org.default_ticket_team.get_users())) + class TicketDailyCountTest(TembaTest): def test_model(self): From 711d00c5d9f5738154181a36c5d1a767d56cca64 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 10:34:34 -0500 Subject: [PATCH 241/557] Update CHANGELOG.md for v9.3.79 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e76fd3dedba..054a1d295c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.79 (2024-10-24) +------------------------- + * Add max length of 10,000 to shortcut text + * Give every workspace a default team with access to all topics + * Change delete links on list views to be clearer + v9.3.78 (2024-10-23) ------------------------- * Use django filter to format archive size diff --git a/pyproject.toml b/pyproject.toml index b31e9da2533..808969d7e5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.78" +version = "9.3.79" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 1f611a219e6..73547646176 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.78" +__version__ = "9.3.79" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 5b3400e1fdf317fe775a2aaf60e7a0dcee955b28 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 16:41:20 +0000 Subject: [PATCH 242/557] Data migration to give existing orgs a default team --- .../migrations/0068_backfill_default_teams.py | 24 +++++++++++++++++++ temba/tickets/tests.py | 19 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 temba/tickets/migrations/0068_backfill_default_teams.py diff --git a/temba/tickets/migrations/0068_backfill_default_teams.py b/temba/tickets/migrations/0068_backfill_default_teams.py new file mode 100644 index 00000000000..0e7d02046c8 --- /dev/null +++ b/temba/tickets/migrations/0068_backfill_default_teams.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-10-24 16:34 + +from django.db import migrations + + +def backfill_default_teams(apps, schema_editor): + Org = apps.get_model("orgs", "Org") + + for org in Org.objects.filter(teams=None): + org.teams.create( + name="All Topics", + is_default=True, + is_system=True, + all_topics=True, + created_by=org.created_by, + modified_by=org.modified_by, + ) + + +class Migration(migrations.Migration): + + dependencies = [("tickets", "0067_team_is_default")] + + operations = [migrations.RunPython(backfill_default_teams, migrations.RunPython.noop)] diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index a6cfc875e9a..50000997462 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -10,7 +10,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, OrgMembership, OrgRole -from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom +from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -1532,3 +1532,20 @@ def _record_last_close(self, org, d: date, seconds: int, undo: bool = False): TicketDailyTiming.objects.create( count_type=TicketDailyTiming.TYPE_LAST_CLOSE, scope=f"o:{org.id}", day=d, count=count, seconds=seconds ) + + +class BackfillDefaultTeamsTest(MigrationTest): + app = "tickets" + migrate_from = "0067_team_is_default" + migrate_to = "0068_backfill_default_teams" + + def setUpBeforeMigration(self, apps): + self.org2.teams.all().delete() + + def test_migration(self): + self.assertEqual( + 1, self.org.teams.filter(name="All Topics", is_default=True, is_system=True, all_topics=True).count() + ) + self.assertEqual( + 1, self.org2.teams.filter(name="All Topics", is_default=True, is_system=True, all_topics=True).count() + ) From 1864e291dd056f2cf98e131db90a05cc8f31fbf2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 16:48:39 +0000 Subject: [PATCH 243/557] Assign new agent users to the default team if team not specified --- temba/orgs/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index b63357f7ae3..12368d4da6e 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1073,6 +1073,9 @@ def add_user(self, user: User, role: OrgRole, *, team=None): if self.has_user(user): # remove user from any existing roles self.remove_user(user) + if role == OrgRole.AGENT and not team: + team = self.default_ticket_team + self.users.add(user, through_defaults={"role_code": role.code, "team": team}) self._user_role_cache[user] = role From 31222f73072f11b8faf3474dbe4537f12dfd650e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 16:49:36 +0000 Subject: [PATCH 244/557] Prevent deletion of system teams --- temba/tickets/models.py | 2 ++ temba/tickets/tests.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index e535427467e..f2f3a48c334 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -138,6 +138,8 @@ def get_users(self): return self.org.users.filter(orgmembership__team=self) def release(self, user): + assert not (self.is_system and self.org.is_active), "can't release system teams" + # re-assign agents in this team to the default team OrgMembership.objects.filter(org=self.org, team=self).update(team=self.org.default_ticket_team) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 50000997462..0e6cd3ff794 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1303,7 +1303,7 @@ def test_release(self): flow.refresh_from_db() self.assertTrue(flow.has_issues) - # can't release default topic + # can't release system topic with self.assertRaises(AssertionError): self.org.default_ticket_topic.release(self.admin) @@ -1359,6 +1359,10 @@ def test_release(self): # check agent was re-assigned to default team self.assertEqual({self.agent}, set(self.org.default_ticket_team.get_users())) + # can't release system team + with self.assertRaises(AssertionError): + self.org.default_ticket_team.release(self.admin) + class TicketDailyCountTest(TembaTest): def test_model(self): From 937ce36782b51b9d63fc055a9d5323a6bda9cd19 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 13:27:16 -0500 Subject: [PATCH 245/557] Add migration to assign teamless agents to the default team --- temba/orgs/models.py | 2 +- temba/orgs/views/views.py | 2 +- .../migrations/0068_backfill_default_teams.py | 2 +- .../0069_assign_agents_to_default_team.py | 22 +++++++++++++++++++ temba/tickets/tests.py | 16 +++++--------- 5 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 temba/tickets/migrations/0069_assign_agents_to_default_team.py diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 9d435ad1889..3b676a2e064 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1104,7 +1104,7 @@ def get_membership(self, user: User): def get(): # for staff we return a faked membership: admin role, no team if user.is_staff: - return OrgMembership(org=self, user=user, role_code=OrgRole.ADMINISTRATOR, team=None) + return OrgMembership(org=self, user=user, role_code=OrgRole.ADMINISTRATOR.code, team=None) return OrgMembership.objects.filter(org=self, user=user).first() diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 6578ffbe4ac..d2df906c574 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -371,7 +371,7 @@ def get_context_data(self, **kwargs): for user in context["object_list"]: membership = self.request.org.get_membership(user) user.role = membership.role - user.team = membership.team + # user.team = membership.team # TODO enable this when orgs can create teams context["has_viewers"] = self.request.org.get_users(roles=[OrgRole.VIEWER]).exists() context["admin_count"] = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).count() diff --git a/temba/tickets/migrations/0068_backfill_default_teams.py b/temba/tickets/migrations/0068_backfill_default_teams.py index 0e7d02046c8..7b5523e4dee 100644 --- a/temba/tickets/migrations/0068_backfill_default_teams.py +++ b/temba/tickets/migrations/0068_backfill_default_teams.py @@ -3,7 +3,7 @@ from django.db import migrations -def backfill_default_teams(apps, schema_editor): +def backfill_default_teams(apps, schema_editor): # pragma: no cover Org = apps.get_model("orgs", "Org") for org in Org.objects.filter(teams=None): diff --git a/temba/tickets/migrations/0069_assign_agents_to_default_team.py b/temba/tickets/migrations/0069_assign_agents_to_default_team.py new file mode 100644 index 00000000000..9c3ea0ccd9d --- /dev/null +++ b/temba/tickets/migrations/0069_assign_agents_to_default_team.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.2 on 2024-10-24 18:08 + +from django.db import migrations + + +def assign_agents_to_default_team(apps, schema_editor): + OrgMembership = apps.get_model("orgs", "OrgMembership") + + for membership in OrgMembership.objects.filter(role_code="T"): + membership.team = membership.org.teams.get(is_default=True) + membership.save(update_fields=["team"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("tickets", "0068_backfill_default_teams"), + ] + + operations = [ + migrations.RunPython(assign_agents_to_default_team, migrations.RunPython.noop), + ] diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 0e6cd3ff794..1f0755d0028 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1538,18 +1538,14 @@ def _record_last_close(self, org, d: date, seconds: int, undo: bool = False): ) -class BackfillDefaultTeamsTest(MigrationTest): +class AssignAgentsToDefaultTeamTest(MigrationTest): app = "tickets" - migrate_from = "0067_team_is_default" - migrate_to = "0068_backfill_default_teams" + migrate_from = "0068_backfill_default_teams" + migrate_to = "0069_assign_agents_to_default_team" def setUpBeforeMigration(self, apps): - self.org2.teams.all().delete() + OrgMembership.objects.filter(user=self.agent).update(team=None) def test_migration(self): - self.assertEqual( - 1, self.org.teams.filter(name="All Topics", is_default=True, is_system=True, all_topics=True).count() - ) - self.assertEqual( - 1, self.org2.teams.filter(name="All Topics", is_default=True, is_system=True, all_topics=True).count() - ) + self.assertEqual(1, OrgMembership.objects.filter(user=self.agent, team=self.org.default_ticket_team).count()) + self.assertEqual(1, OrgMembership.objects.exclude(team=None).count()) From 820c009d4744862d48730cc2f1054224a654901d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 18:49:22 +0000 Subject: [PATCH 246/557] Make BaseListView always filter by is_active=True and BaseUpdateView always filter by is_system=False --- temba/contacts/views.py | 2 +- temba/flows/views.py | 3 +-- temba/globals/views.py | 2 +- temba/orgs/views/base.py | 25 ++++++++++++++++++------- temba/orgs/views/views.py | 3 --- temba/templates/views.py | 5 +---- temba/tickets/tests.py | 7 ++++--- temba/tickets/views.py | 2 +- temba/triggers/views.py | 9 ++++----- 9 files changed, 31 insertions(+), 27 deletions(-) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index a3f7a4de483..df4728cae36 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -1112,7 +1112,7 @@ def build_context_menu(self, menu): ) def derive_queryset(self, **kwargs): - return super().derive_queryset(**kwargs).filter(is_active=True, is_system=False) + return super().derive_queryset(**kwargs).filter(is_proxy=False) class Usages(FieldLookupMixin, BaseUsagesModal): permission = "contacts.contactfield_read" diff --git a/temba/flows/views.py b/temba/flows/views.py index 01595b87a77..2ee76776bd4 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -684,8 +684,7 @@ def get_context_data(self, **kwargs): return context def derive_queryset(self, *args, **kwargs): - qs = super().derive_queryset(*args, **kwargs) - return qs.exclude(is_system=True).exclude(is_active=False) + return super().derive_queryset(*args, **kwargs).exclude(is_system=True) def apply_bulk_action(self, user, action, objects, label): super().apply_bulk_action(user, action, objects, label) diff --git a/temba/globals/views.py b/temba/globals/views.py index cd52393fde9..d4c119abdef 100644 --- a/temba/globals/views.py +++ b/temba/globals/views.py @@ -132,7 +132,7 @@ def build_context_menu(self, menu): ) def get_queryset(self, **kwargs): - qs = super().get_queryset(**kwargs).filter(org=self.request.org, is_active=True) + qs = super().get_queryset(**kwargs) return Global.annotate_usage(qs) def get_context_data(self, **kwargs): diff --git a/temba/orgs/views/base.py b/temba/orgs/views/base.py index f58c2bd0173..15f5530c283 100644 --- a/temba/orgs/views/base.py +++ b/temba/orgs/views/base.py @@ -54,7 +54,15 @@ def get_form_kwargs(self): return kwargs def derive_queryset(self, **kwargs): - return super().derive_queryset(**kwargs).filter(org=self.request.org, is_active=True) + qs = super().derive_queryset(**kwargs).filter(org=self.request.org) + + if hasattr(self.model, "is_active"): + qs = qs.filter(is_active=True) + + if hasattr(self.model, "is_system"): + qs = qs.filter(is_system=False) + + return qs class BaseDeleteModal(OrgObjPermsMixin, SmartDeleteView): @@ -67,7 +75,10 @@ def get_context_data(self, **kwargs): return context def post(self, request, *args, **kwargs): - self.get_object().release(self.request.user) + obj = self.get_object() + + if not getattr(obj, "is_system", False): + obj.release(self.request.user) return HttpResponseRedirect(self.get_redirect_url()) @@ -78,12 +89,12 @@ class BaseListView(SpaMixin, OrgPermsMixin, SmartListView): """ def derive_queryset(self, **kwargs): - qs = super().derive_queryset(**kwargs) + qs = super().derive_queryset(**kwargs).filter(org=self.request.org) + + if hasattr(self.model, "is_active"): + qs = qs.filter(is_active=True) - if not self.request.user.is_authenticated: - return qs.none() # pragma: no cover - else: - return qs.filter(org=self.request.org) + return qs class BaseMenuView(OrgPermsMixin, SmartTemplateView): diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 007eb46983b..bcf5d446125 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -2078,9 +2078,6 @@ class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): def build_context_menu(self, menu): menu.add_modax(_("New"), "invite-create", reverse("orgs.invitation_create"), as_button=True) - def derive_queryset(self, **kwargs): - return super().derive_queryset(**kwargs).filter(is_active=True) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["validity_days"] = settings.INVITATION_VALIDITY.days diff --git a/temba/templates/views.py b/temba/templates/views.py index 76852edaf7b..db52761e890 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -19,10 +19,7 @@ def derive_menu_path(self): def get_queryset(self, **kwargs): return Template.annotate_usage( - super() - .get_queryset(**kwargs) - .filter(org=self.request.org, is_active=True) - .exclude(base_translation=None) # don't show "empty" templates + super().get_queryset(**kwargs).exclude(base_translation=None) # don't show "empty" templates ) class Read(SpaMixin, OrgObjPermsMixin, SmartReadView): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 0e6cd3ff794..0ea51ce3c2b 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -326,7 +326,7 @@ def test_delete(self): # submit to delete it response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=shortcut1, success_status=302) - # other shortcut unafected + # other shortcut unaffected shortcut2.refresh_from_db() self.assertTrue(shortcut2.is_active) @@ -406,8 +406,9 @@ def test_update(self): self.assertEqual(topic.name, "Boring") # can't edit a system topic - with self.assertRaises(AssertionError): - self.requestView(reverse("tickets.topic_update", args=[self.org.default_ticket_topic.uuid]), self.admin) + self.assertRequestDisallowed( + reverse("tickets.topic_update", args=[self.org.default_ticket_topic.uuid]), [self.admin] + ) def test_delete(self): topic1 = Topic.create(self.org, self.admin, "Planes") diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 4fdce5d3ea1..e70553d87b4 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -67,7 +67,7 @@ class List(ContextMenuMixin, BaseListView): menu_path = "/ticket/shortcuts" def derive_queryset(self, **kwargs): - return super().derive_queryset(**kwargs).filter(is_active=True).order_by(Lower("name")) + return super().derive_queryset(**kwargs).order_by(Lower("name")) def build_context_menu(self, menu): if self.has_org_perm("tickets.shortcut_create"): diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 6b1c94ae60b..756922368a3 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -434,15 +434,14 @@ class BaseList(BulkActionMixin, BaseListView): default_template = "triggers/trigger_list.html" search_fields = ("keywords__icontains", "flow__name__icontains", "channel__name__icontains") - def get_queryset(self, *args, **kwargs): - qs = super().get_queryset(*args, **kwargs) - qs = ( - qs.filter(is_active=True) + def derive_queryset(self, *args, **kwargs): + return ( + super() + .derive_queryset(*args, **kwargs) .order_by("-created_on") .select_related("flow", "channel") .prefetch_related("contacts", "groups", "exclude_groups") ) - return qs class List(BaseList): """ From b769f9a44665839d6bfc55c4f7a20fa9ca2617f3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 20:17:27 +0000 Subject: [PATCH 247/557] Add basic team list page (WIP) --- package.json | 2 +- temba/orgs/views/views.py | 8 ++++++++ temba/settings_common.py | 1 + temba/tickets/urls.py | 3 ++- temba/tickets/views.py | 12 +++++++++++ templates/tickets/team_list.html | 34 ++++++++++++++++++++++++++++++++ yarn.lock | 8 ++++---- 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 templates/tickets/team_list.html diff --git a/package.json b/package.json index 3f912df4323..9193cc5ea97 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.109.0", + "@nyaruka/temba-components": "0.109.1", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index d2df906c574..7fd44b6b792 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -1012,6 +1012,14 @@ def derive_menu(self): count=org.invitations.filter(is_active=True).count(), ) ) + menu.append( + self.create_menu_item( + name=_("Teams"), + icon="team", + href="tickets.team_list", + count=org.teams.filter(is_active=True).count(), + ) + ) menu.append(self.create_divider()) if self.has_org_perm("orgs.org_export"): diff --git a/temba/settings_common.py b/temba/settings_common.py index c333de375b0..05a4df59d37 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -506,6 +506,7 @@ "request_logs.httplog_webhooks", "templates.template.*", "tickets.shortcut.*", + "tickets.team.*", "tickets.ticket.*", "tickets.topic.*", "triggers.trigger.*", diff --git a/temba/tickets/urls.py b/temba/tickets/urls.py index 209c20a7673..717802248ab 100644 --- a/temba/tickets/urls.py +++ b/temba/tickets/urls.py @@ -1,10 +1,11 @@ from django.conf.urls import include from django.urls import re_path -from .views import ShortcutCRUDL, TicketCRUDL, TopicCRUDL +from .views import ShortcutCRUDL, TeamCRUDL, TicketCRUDL, TopicCRUDL urlpatterns = [ re_path(r"^", include(ShortcutCRUDL().as_urlpatterns())), + re_path(r"^", include(TeamCRUDL().as_urlpatterns())), re_path(r"^", include(TicketCRUDL().as_urlpatterns())), re_path(r"^", include(TopicCRUDL().as_urlpatterns())), ] diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 4fdce5d3ea1..e3770fc20e2 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -33,6 +33,7 @@ AllFolder, MineFolder, Shortcut, + Team, Ticket, TicketCount, TicketExport, @@ -119,6 +120,17 @@ def get_redirect_url(self, **kwargs): return f"/ticket/{str(default_topic.uuid)}/open/" +class TeamCRUDL(SmartCRUDL): + model = Team + actions = ("list",) + + class List(BaseListView): + menu_path = "/settings/teams" + + def derive_queryset(self, **kwargs): + return super().derive_queryset(**kwargs).filter(is_active=True).order_by(Lower("name")) + + class TicketCRUDL(SmartCRUDL): model = Ticket actions = ("list", "update", "folder", "note", "menu", "export_stats", "export") diff --git a/templates/tickets/team_list.html b/templates/tickets/team_list.html new file mode 100644 index 00000000000..43a5ec221d6 --- /dev/null +++ b/templates/tickets/team_list.html @@ -0,0 +1,34 @@ +{% extends "orgs/base/list.html" %} +{% load smartmin temba i18n %} + +{% block pre-table %} +
    + {% blocktrans trimmed %} + Agent users can be organized into teams to restrict which ticket topics they can access. + {% endblocktrans %} +
    +{% endblock pre-table %} +{% block table %} + + + + + + + + + + {% for obj in object_list %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Name" %}{% trans "Users" %}{% trans "Topics" %}
    {{ obj.name }}
    {% trans "No teams" %}
    +{% endblock table %} diff --git a/yarn.lock b/yarn.lock index 4f06a032984..08b9558842b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.109.0": - version "0.109.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.109.0.tgz#a7aef7c222719b55cb2f09f371b814aad9f05d8d" - integrity sha512-ctSRjGIlDi9otTkvw59acU1Ji0nh1RpxJkHufnxuqXguSTdJauNTYGAgwukpVrpOII5ehWgdl6ACPgpg41F+vw== +"@nyaruka/temba-components@0.109.1": + version "0.109.1" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.109.1.tgz#f2a92eb44a726b03d069f97299a6c8626aec8774" + integrity sha512-ZwGrbdS0qc43yPkmFjRqw//BVxOQrqaFr1SbC0e4w7dC1gwoo5V1UHfz1keyXuGXF/T1HeCMUVgPSY+5pZBHEg== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From d2398b3629eb4753598f870145baa8bad47a2d8e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 20:35:15 +0000 Subject: [PATCH 248/557] Filter topics in topic selection menu based on team membership --- temba/tickets/models.py | 12 ++++++++++++ temba/tickets/tests.py | 19 +++++++++++++++++++ temba/tickets/views.py | 2 +- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index f2f3a48c334..1e05fb92c8d 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -81,6 +81,18 @@ def create(cls, org, user, name: str): def create_from_import_def(cls, org, user, definition: dict): return cls.create(org, user, definition["name"]) + @classmethod + def get_accessible(cls, org, user): + """ + Gets the topics accessible to the given user in the given org. + """ + + membership = org.get_membership(user) + if membership.team and not membership.team.all_topics: + return membership.team.topics.all() + + return org.topics.filter(is_active=True) + def release(self, user): assert not (self.is_system and self.org.is_active), "can't release system topics" assert not self.tickets.exists(), "can't release topic with tickets" diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 1f0755d0028..1fc70394a89 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1284,6 +1284,25 @@ def _import(definition, preview=False): self.assertIsNone(topic15) self.assertEqual(Topic.ImportResult.IGNORED_LIMIT_REACHED, result) + def test_get_accessible(self): + topic1 = Topic.create(self.org, self.admin, "Sales") + topic2 = Topic.create(self.org, self.admin, "Support") + team1 = Team.create(self.org, self.admin, "Sales & Support", topics=[topic1, topic2]) + team2 = Team.create(self.org, self.admin, "Nothing", topics=[]) + agent2 = self.create_user("agent2@nyaruka.com") + self.org.add_user(agent2, OrgRole.AGENT, team=team1) + agent3 = self.create_user("agent3@nyaruka.com") + self.org.add_user(agent3, OrgRole.AGENT, team=team2) + + self.assertEqual( + {self.org.default_ticket_topic, topic1, topic2}, set(Topic.get_accessible(self.org, self.admin)) + ) + self.assertEqual( + {self.org.default_ticket_topic, topic1, topic2}, set(Topic.get_accessible(self.org, self.agent)) + ) + self.assertEqual({topic1, topic2}, set(Topic.get_accessible(self.org, agent2))) + self.assertEqual(set(), set(Topic.get_accessible(self.org, agent3))) + def test_release(self): topic1 = Topic.create(self.org, self.admin, "Sales") topic2 = Topic.create(self.org, self.admin, "Support") diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 4fdce5d3ea1..3a838a969f8 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -299,7 +299,7 @@ def derive_menu(self): menu.append(self.create_divider()) - topics = list(org.topics.filter(is_active=True).order_by("-is_system", "name")) + topics = list(Topic.get_accessible(org, user).order_by("-is_system", "name")) counts = TicketCount.get_by_topics(org, topics, Ticket.STATUS_OPEN) for topic in topics: menu.append( From 3164f5bf0b5acef8b892205c7f7002181d2d60f2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 15:42:53 -0500 Subject: [PATCH 249/557] Update CHANGELOG.md for v9.3.80 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 054a1d295c1..3e6817fe846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +v9.3.80 (2024-10-24) +------------------------- + * Add migration to assign teamless agents to the default team + * Prevent deletion of system teams + * Assign new agent users to the default team if team not specified + * Data migration to give existing orgs a default team + v9.3.79 (2024-10-24) ------------------------- * Add max length of 10,000 to shortcut text diff --git a/pyproject.toml b/pyproject.toml index 808969d7e5c..26ebd566add 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.79" +version = "9.3.80" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 73547646176..a8ebbf630d3 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.79" +__version__ = "9.3.80" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From a784bd215fa6f6955975a8e474eff83c6fe9219d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 21:08:54 +0000 Subject: [PATCH 250/557] Remove unused placeholder for workspaces with no contacts --- temba/contacts/views.py | 10 ++-------- templates/contacts/contact_list.html | 2 +- templates/contacts/empty_include.html | 17 ----------------- 3 files changed, 3 insertions(+), 26 deletions(-) delete mode 100644 templates/contacts/empty_include.html diff --git a/temba/contacts/views.py b/temba/contacts/views.py index a3f7a4de483..54bdee79d2f 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -149,16 +149,10 @@ def get_queryset(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - org = self.request.org - - # resolve the paginated object list so we can initialize a cache of URNs - contacts = context["object_list"] - Contact.bulk_urn_cache_initialize(contacts) + # prefetch contact URNs + Contact.bulk_urn_cache_initialize(context["object_list"]) - context["contacts"] = contacts - context["has_contacts"] = contacts or org.get_contact_count() > 0 context["search_error"] = self.search_error - context["sort_direction"] = self.sort_direction context["sort_field"] = self.sort_field diff --git a/templates/contacts/contact_list.html b/templates/contacts/contact_list.html index b67e51fe352..d482f330cd8 100644 --- a/templates/contacts/contact_list.html +++ b/templates/contacts/contact_list.html @@ -219,7 +219,7 @@ {% endif %} - {% for object in contacts %} + {% for object in object_list %} -
    {% trans "Contacts" %}
    -
    - {% blocktrans trimmed with name=branding.name %} - Contacts will automatically be added here as you communicate with them using {{ name }}. - From here you can change a contact's name, organize them into groups and see the communication you've had with each. - {% endblocktrans %} -
    -
    - {% url 'contacts.contactimport_create' as contact_import_url %} - {% blocktrans trimmed with url=contact_import_url %} - To get started you can import contacts from a file you create in Excel. - {% endblocktrans %} -
    -
    From 03b6548728d9ed3d73ec43c3d7bb0d0f9f597472 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 24 Oct 2024 21:50:48 +0000 Subject: [PATCH 251/557] Simplify call list template --- templates/ivr/call_list.html | 84 +++++++++++++++--------------------- 1 file changed, 34 insertions(+), 50 deletions(-) diff --git a/templates/ivr/call_list.html b/templates/ivr/call_list.html index 3ef6578b50c..2b1cc1ee3b9 100644 --- a/templates/ivr/call_list.html +++ b/templates/ivr/call_list.html @@ -1,55 +1,39 @@ -{% extends "smartmin/list.html" %} +{% extends "orgs/base/list.html" %} {% load i18n contacts channels %} -{% block content %} -
    {% include "includes/short_pagination.html" %}
    -
    - {% if has_messages %} - - - {% for object in object_list %} - - - - - - - {% endfor %} - {% if not object_list %} - - - - {% endif %} - -
    - {% if object.direction == "I" %} - - - {% else %} - - - {% endif %} - {{ object.contact|name_or_urn:user_org|truncatechars:20 }} -
    -
    {{ object.get_duration }}
    -
    -
    -
    -
    - - -
    - {% channel_log_link object %} -
    -
    {% trans "No calls" %}
    - {% else %} - {% include "msgs/empty_include.html" %} - {% endif %} -
    -{% endblock content %} +{% block table %} + + + {% for object in object_list %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    + {% if object.direction == "I" %} + + + {% else %} + + + {% endif %} + {{ object.contact|name_or_urn:user_org|truncatechars:20 }}{{ object.get_duration }} +
    +
    {{ object.created_on|timedate }}
    + {% channel_log_link object %} +
    +
    {% trans "No calls" %}
    +{% endblock table %} {% block extra-script %} {{ block.super }} {% endblock extra-script %} -{% block content %} -
    - - - -
    -
    {% include "includes/short_pagination.html" %}
    -
    - - - {% for obj in object_list %} - - {% if org_perms.campaigns.campaign_update %} - - {% endif %} - - - - - {% empty %} - - - - {% endfor %} - {% block extra-rows %} - {% endblock extra-rows %} - -
    - - - {{ obj.name }} - {# in the past we let users delete groups used by campaigns #} - {% if obj.group.is_active %} -
    {% include "includes/recipients_group.html" with group=obj.group %}
    - {% endif %} -
    {{ obj.get_events|length }} event{{ obj.get_events|length|pluralize }}
    {% trans "No campaigns" %}
    -
    -{% endblock content %} From 0d1449716fce6fc08b2c8494806f0b7e50821e6a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 25 Oct 2024 19:28:50 +0000 Subject: [PATCH 253/557] Cleanup trigger list template --- templates/triggers/trigger_list.html | 184 ++++++++++++--------------- 1 file changed, 84 insertions(+), 100 deletions(-) diff --git a/templates/triggers/trigger_list.html b/templates/triggers/trigger_list.html index 70cdefcd31d..d76615c342e 100644 --- a/templates/triggers/trigger_list.html +++ b/templates/triggers/trigger_list.html @@ -1,107 +1,91 @@ -{% extends "smartmin/list.html" %} -{% load smartmin sms temba compress humanize i18n %} +{% extends "orgs/base/list.html" %} +{% load smartmin temba i18n %} -{% block content %} -
    - {% block pjax %} - -
    - {% blocktrans trimmed with count=paginator.count %} - Are you sure you want to delete all {{ count }} archived triggers? This cannot be undone. +{% block modaxes %} + + + +
    + {% blocktrans trimmed with count=paginator.count %} + Are you sure you want to delete all {{ count }} archived triggers? This cannot be undone. + {% endblocktrans %} + {% if paginator.count > 50 %} +
    +
    + {% blocktrans trimmed %} + This operation can take a while to complete. Triggers may remain in this view during the process. {% endblocktrans %} - {% if paginator.count > 50 %} -
    -
    - {% blocktrans trimmed %} - This operation can take a while to complete. Triggers may remain in this view during the process. - {% endblocktrans %} - {% endif %} -
    -
    - -
    {% trans "Are you sure you want to delete the selected triggers? This cannot be undone." %}
    -
    - {% endblock pjax %} -
    - - - -
    -
    - {% include "includes/short_pagination.html" %} - {% if paginator.is_es_search and not page_obj.has_next_page and page_obj.number == paginator.num_pages and paginator.count > 10000 %} -
    {% trans "To view more than 10,000 search results, save it as a group." %}
    - {% endif %} -
    -
    - - - {% for obj in object_list %} - - {% if org_perms.triggers.trigger_update %} - - {% endif %} - + + + {% empty %} + + + + {% endfor %} + +
    - - - -
    -
    -
    - - -
    -
    - {% with "triggers/types/"|add:obj.type.slug|add:"/desc.html" as type_template %} - {% include type_template with trigger=obj %} - {% endwith %} -
    -
    - {% if obj.channel or obj.contacts.all or obj.groups.all or obj.exclude_groups.all %} -
    - {% if obj.channel %} - {# djlint:off #} - {{ obj.channel }} - {# djlint:on #} - {% endif %} - {% include "includes/recipients.html" with contacts=obj.contacts.all groups=obj.groups.all exclude_groups=obj.exclude_groups.all groups_as_filters=True %} -
    - {% endif %} -
    + {% endif %} + + + +
    {% trans "Are you sure you want to delete the selected triggers? This cannot be undone." %}
    +
    +{% endblock modaxes %} +{% block table %} + + + {% for obj in object_list %} + + {% if org_perms.triggers.trigger_update %} + - - - {% empty %} - - - - {% endfor %} - - {% block extra-rows %} - {% endblock extra-rows %} -
    + + - +
    +
    +
    + + +
    +
    + {% with "triggers/types/"|add:obj.type.slug|add:"/desc.html" as type_template %} + {% include type_template with trigger=obj %} + {% endwith %} +
    -
    {% trans "No triggers" %}
    - - - -{% endblock content %} + {% if obj.channel or obj.contacts.all or obj.groups.all or obj.exclude_groups.all %} +
    + {% if obj.channel %} + {# djlint:off #} + {{ obj.channel }} + {# djlint:on #} + {% endif %} + {% include "includes/recipients.html" with contacts=obj.contacts.all groups=obj.groups.all exclude_groups=obj.exclude_groups.all groups_as_filters=True %} +
    + {% endif %} + +
    + +
    {% trans "No triggers" %}
    +{% endblock table %} {% block extra-script %} {{ block.super }} {% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %} diff --git a/templates/contacts/contact_stopped.html b/templates/contacts/contact_stopped.html index 9e95095d49b..eb6fbc86de1 100644 --- a/templates/contacts/contact_stopped.html +++ b/templates/contacts/contact_stopped.html @@ -1,6 +1,6 @@ {% extends "contacts/contact_list.html" %} {% load i18n %} -{% block subtitle %} - {% trans "These contacts have opted out and you can no longer send them messages, but inbound messages will unstop them. They have also been removed from all groups." %} -{% endblock subtitle %} +{% block pre-table %} +
    {% trans "These contacts have opted out and you can no longer send them messages, but inbound messages will unstop them. They have also been removed from all groups." %}
    +{% endblock pre-table %} diff --git a/templates/orgs/base/list.html b/templates/orgs/base/list.html index 70d92d4fd1e..945c088f8fd 100644 --- a/templates/orgs/base/list.html +++ b/templates/orgs/base/list.html @@ -14,9 +14,12 @@ {% endblock search-form %} + {% if search_error %}
    {{ search_error }}
    {% endif %} {% endif %} {% if view.paginate_by %} -
    {% include "includes/short_pagination.html" %}
    + {% block pagination %} +
    {% include "includes/short_pagination.html" %}
    + {% endblock pagination %} {% endif %}
    {% block table %} From 7712922a1b9e06d50e81a4256ff4be50fad17e58 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 25 Oct 2024 21:35:08 +0000 Subject: [PATCH 255/557] Leverage proxy fields to simplify contact list view field rendering --- temba/contacts/models.py | 9 +- temba/contacts/templatetags/contacts.py | 10 +- temba/contacts/templatetags/tests.py | 52 +++++++---- temba/contacts/tests.py | 27 ++---- temba/contacts/views.py | 20 ++-- templates/contacts/contact_list.html | 118 +++++------------------- 6 files changed, 79 insertions(+), 157 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index f8d6d69299d..fd7cc8bcd79 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -495,13 +495,16 @@ def get_or_create(cls, org, user, key: str, name: str = None, value_type=None): ) @classmethod - def get_fields(cls, org: Org, viewable_by=None): + def get_fields(cls, org: Org, featured=None, viewable_by=None): """ Gets the fields for the given org """ fields = org.fields.filter(is_active=True, is_proxy=False) + if featured is not None: + fields = fields.filter(show_in_table=featured) + if viewable_by and org.get_user_role(viewable_by) == OrgRole.AGENT: fields = fields.exclude(agent_access=cls.ACCESS_NONE) @@ -836,7 +839,7 @@ def get_field_serialized(self, field) -> str: return value_dict.get(engine_type) - def get_field_value(self, field): + def get_field_value(self, field: ContactField): """ Given the passed in contact field object, returns the value (as a string, decimal, datetime, AdminBoundary) for this contact or None. @@ -858,7 +861,7 @@ def get_field_value(self, field): elif field.value_type in [ContactField.TYPE_STATE, ContactField.TYPE_DISTRICT, ContactField.TYPE_WARD]: return AdminBoundary.get_by_path(self.org, string_value) - def get_field_display(self, field): + def get_field_display(self, field: ContactField) -> str: """ Returns the display value for the passed in field, or empty string if None """ diff --git a/temba/contacts/templatetags/contacts.py b/temba/contacts/templatetags/contacts.py index 183ab999a45..a469bcbd5f8 100644 --- a/temba/contacts/templatetags/contacts.py +++ b/temba/contacts/templatetags/contacts.py @@ -24,16 +24,14 @@ @register.simple_tag() -def contact_field(contact, key): - field = contact.org.fields.filter(is_active=True, key=key).first() - if field is None: - return MISSING_VALUE - +def contact_field(contact, field): value = contact.get_field_display(field) + if value and field.value_type == ContactField.TYPE_DATETIME: value = contact.get_field_value(field) if value: - return mark_safe(f"") + display = "timedate" if field.is_proxy else "date" + return mark_safe(f"") return value or MISSING_VALUE diff --git a/temba/contacts/templatetags/tests.py b/temba/contacts/templatetags/tests.py index 235cb1f4e8a..b5d16dcda1e 100644 --- a/temba/contacts/templatetags/tests.py +++ b/temba/contacts/templatetags/tests.py @@ -1,49 +1,65 @@ +from temba.contacts.models import ContactField from temba.tests import TembaTest -from .contacts import format_urn, name_or_urn, urn_icon, urn_or_anon +from . import contacts as tags class ContactsTest(TembaTest): + def test_contact_field(self): + gender = self.create_field("gender", "Gender", ContactField.TYPE_TEXT) + age = self.create_field("age", "Age", ContactField.TYPE_NUMBER) + joined = self.create_field("joined", "Joined", ContactField.TYPE_DATETIME) + last_seen_on = self.org.fields.get(key="last_seen_on") + contact = self.create_contact("Bob", fields={"age": 30, "gender": "M", "joined": "2024-01-01T00:00:00Z"}) + + self.assertEqual("M", tags.contact_field(contact, gender)) + self.assertEqual("30", tags.contact_field(contact, age)) + self.assertEqual( + "", + tags.contact_field(contact, joined), + ) + self.assertEqual("--", tags.contact_field(contact, last_seen_on)) + def test_name_or_urn(self): contact1 = self.create_contact("", urns=[]) contact2 = self.create_contact("Ann", urns=[]) contact3 = self.create_contact("Bob", urns=["tel:+12024561111", "telegram:098761111"]) contact4 = self.create_contact("", urns=["tel:+12024562222", "telegram:098762222"]) - self.assertEqual("", name_or_urn(contact1, self.org)) - self.assertEqual("Ann", name_or_urn(contact2, self.org)) - self.assertEqual("Bob", name_or_urn(contact3, self.org)) - self.assertEqual("(202) 456-2222", name_or_urn(contact4, self.org)) + self.assertEqual("", tags.name_or_urn(contact1, self.org)) + self.assertEqual("Ann", tags.name_or_urn(contact2, self.org)) + self.assertEqual("Bob", tags.name_or_urn(contact3, self.org)) + self.assertEqual("(202) 456-2222", tags.name_or_urn(contact4, self.org)) with self.anonymous(self.org): - self.assertEqual(f"{contact1.id:010}", name_or_urn(contact1, self.org)) - self.assertEqual("Ann", name_or_urn(contact2, self.org)) - self.assertEqual("Bob", name_or_urn(contact3, self.org)) - self.assertEqual(f"{contact4.id:010}", name_or_urn(contact4, self.org)) + self.assertEqual(f"{contact1.id:010}", tags.name_or_urn(contact1, self.org)) + self.assertEqual("Ann", tags.name_or_urn(contact2, self.org)) + self.assertEqual("Bob", tags.name_or_urn(contact3, self.org)) + self.assertEqual(f"{contact4.id:010}", tags.name_or_urn(contact4, self.org)) def test_urn_or_anon(self): contact1 = self.create_contact("Bob", urns=[]) contact2 = self.create_contact("Uri", urns=["tel:+12024561414", "telegram:098765432"]) - self.assertEqual("--", urn_or_anon(contact1, self.org)) - self.assertEqual("+1 202-456-1414", urn_or_anon(contact2, self.org)) + self.assertEqual("--", tags.urn_or_anon(contact1, self.org)) + self.assertEqual("+1 202-456-1414", tags.urn_or_anon(contact2, self.org)) with self.anonymous(self.org): - self.assertEqual(f"{contact1.id:010}", urn_or_anon(contact1, self.org)) - self.assertEqual(f"{contact2.id:010}", urn_or_anon(contact2, self.org)) + self.assertEqual(f"{contact1.id:010}", tags.urn_or_anon(contact1, self.org)) + self.assertEqual(f"{contact2.id:010}", tags.urn_or_anon(contact2, self.org)) def test_urn_icon(self): contact = self.create_contact("Uri", urns=["tel:+1234567890", "telegram:098765432", "viber:346376373"]) tel_urn, tg_urn, viber_urn = contact.urns.order_by("-priority") - self.assertEqual("icon-phone", urn_icon(tel_urn)) - self.assertEqual("icon-telegram", urn_icon(tg_urn)) - self.assertEqual("", urn_icon(viber_urn)) + self.assertEqual("icon-phone", tags.urn_icon(tel_urn)) + self.assertEqual("icon-telegram", tags.urn_icon(tg_urn)) + self.assertEqual("", tags.urn_icon(viber_urn)) def test_format_urn(self): contact = self.create_contact("Uri", urns=["tel:+12024561414"]) - self.assertEqual("+1 202-456-1414", format_urn(contact.get_urn(), self.org)) + self.assertEqual("+1 202-456-1414", tags.format_urn(contact.get_urn(), self.org)) with self.anonymous(self.org): - self.assertEqual("••••••••", format_urn(contact.get_urn(), self.org)) + self.assertEqual("••••••••", tags.format_urn(contact.get_urn(), self.org)) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 26aa1c29690..6b7b24bc895 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -49,7 +49,7 @@ ContactURN, ) from .tasks import squash_group_counts -from .templatetags.contacts import contact_field, msg_status_badge +from .templatetags.contacts import msg_status_badge class ContactCRUDLTest(CRUDLTestMixin, TembaTest): @@ -59,8 +59,8 @@ def setUp(self): self.country = AdminBoundary.create(osm_id="171496", name="Rwanda", level=0) AdminBoundary.create(osm_id="1708283", name="Kigali", level=1, parent=self.country) - self.create_field("age", "Age", value_type="N") - self.create_field("home", "Home", value_type="S", priority=10) + self.create_field("age", "Age", value_type="N", show_in_table=True) + self.create_field("home", "Home", value_type="S", show_in_table=True, priority=10) # sample flows don't actually get created by org initialization during tests because there are no users at that # point so create them explicitly here, so that we also get the sample groups @@ -141,7 +141,7 @@ def test_list(self, mr_mocks): mr_mocks.contact_search('name != ""', contacts=[]) self.create_group("No Name", query='name = ""') - with self.assertNumQueries(15): + with self.assertNumQueries(16): response = self.client.get(list_url) self.assertEqual([frank, joe], list(response.context["object_list"])) @@ -162,7 +162,9 @@ def test_list(self, mr_mocks): self.assertEqual(response.context["search"], "age = 18") self.assertEqual(response.context["save_dynamic_search"], True) self.assertIsNone(response.context["search_error"]) - self.assertEqual(list(response.context["contact_fields"].values_list("name", flat=True)), ["Home", "Age"]) + self.assertEqual( + [f.name for f in response.context["contact_fields"]], ["Home", "Age", "Last Seen On", "Created On"] + ) mr_mocks.contact_search("age = 18", contacts=[frank], total=10020) @@ -3131,21 +3133,6 @@ def test_get_or_create(self): self.assertEqual("new_key", field7.key) self.assertEqual("New Key", field7.name) # generated - def test_contact_templatetag(self): - ContactField.get_or_create( - self.org, self.admin, "date_joined", name="join date", value_type=ContactField.TYPE_DATETIME - ) - - self.set_contact_field(self.joe, "first", "Starter") - self.set_contact_field(self.joe, "date_joined", "01-01-2022 8:30") - - self.assertEqual(contact_field(self.joe, "first"), "Starter") - self.assertEqual( - contact_field(self.joe, "date_joined"), - "", - ) - self.assertEqual(contact_field(self.joe, "not_there"), "--") - def test_make_key(self): self.assertEqual("first_name", ContactField.make_key("First Name")) self.assertEqual("second_name", ContactField.make_key("Second Name ")) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 010514a73e0..df13b03c50a 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -97,13 +97,6 @@ def derive_export_url(self): search = quote_plus(self.request.GET.get("search", "")) return f"{reverse('contacts.contact_export')}?g={self.group.uuid}&s={search}" - def derive_refresh(self): - # smart groups that are reevaluating should refresh every 2 seconds - if self.group.is_smart and self.group.status != ContactGroup.STATUS_READY: - return 200000 - - return None - def get_queryset(self, **kwargs): org = self.request.org self.search_error = None @@ -149,10 +142,16 @@ def get_queryset(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + org = self.request.org # prefetch contact URNs Contact.bulk_urn_cache_initialize(context["object_list"]) + # get the first 6 featured fields as well as the last seen and created fields + featured_fields = ContactField.get_fields(org, featured=True).order_by("-priority", "id")[0:6] + proxy_fields = org.fields.filter(key__in=("last_seen_on", "created_on"), is_proxy=True).order_by("-key") + context["contact_fields"] = list(featured_fields) + list(proxy_fields) + context["search_error"] = self.search_error context["sort_direction"] = self.sort_direction context["sort_field"] = self.sort_field @@ -542,13 +541,6 @@ def build_context_menu(self, menu): if self.has_org_perm("contacts.contact_export"): menu.add_modax(_("Export"), "export-contacts", self.derive_export_url(), title=_("Export Contacts")) - def get_context_data(self, *args, **kwargs): - context = super().get_context_data(*args, **kwargs) - org = self.request.org - - context["contact_fields"] = ContactField.get_fields(org).order_by("-show_in_table", "-priority", "id")[0:6] - return context - class Blocked(ContextMenuMixin, ContactListView): title = _("Blocked") system_group = ContactGroup.TYPE_DB_BLOCKED diff --git a/templates/contacts/contact_list.html b/templates/contacts/contact_list.html index e72f6ad48e2..a1849c56bcc 100644 --- a/templates/contacts/contact_list.html +++ b/templates/contacts/contact_list.html @@ -35,102 +35,42 @@ {% endblock modaxes %} {% block table %} - + {% if org_perms.contacts.contact_update %}{% endif %} {% for field in contact_fields %} - {% if field.show_in_table %} - - {% endif %} - {% endfor %} - - + + {% endfor %} @@ -153,22 +93,8 @@
    {{ object|urn_or_anon:user_org }}
    {% for field in contact_fields %} - {% if field.show_in_table %} - - {% endif %} + {% endfor %} - -
    - {% if sort_field == field.key %} - {% if sort_direction == 'desc' %} - -
    - {{ field.name }} - - -
    -
    - {% else %} - -
    - {{ field.name }} - - -
    -
    - {% endif %} +
    + {% if sort_field == field.key %} + {% if sort_direction == 'desc' %} + +
    + {{ field.name }} + + +
    +
    {% else %}
    {{ field.name }} - +
    {% endif %} -
    - {% if sort_field == 'last_seen_on' %} - {% if sort_direction == 'desc' %} - -
    - {% trans "Last Seen On" %} - - -
    -
    - {% else %} - -
    - {% trans "Last Seen On" %} - - -
    -
    - {% endif %} - {% else %} - -
    - {% trans "Last Seen On" %} - - -
    -
    - {% endif %} -
    - {% if sort_field == 'created_on' %} - {% if sort_direction == 'desc' %} - -
    - {% trans "Created On" %} - - -
    -
    {% else %} - +
    - {% trans "Created On" %} - + {{ field.name }} +
    {% endif %} - {% else %} - -
    - {% trans "Created On" %} - - -
    -
    - {% endif %} -
    {% contact_field object field.key %}{% contact_field object field %} -
    - {% if object.last_seen_on %} - {{ object.last_seen_on|timedate }} - {% else %} - {{ "--" }} - {% endif %} -
    -
    -
    {{ object.created_on|timedate }}
    -
    @@ -300,23 +226,23 @@ {% block extra-style %} {{ block.super }} From 5024df033353e9831948441615b9842fd659fd08 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 09:01:06 -0500 Subject: [PATCH 256/557] Update CHANGELOG.md for v9.3.81 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6817fe846..89619ed0a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.81 (2024-10-28) +------------------------- + * Fix N+1 query on contact list page + * Cleanup more list pages and move more functionality to org/base views + v9.3.80 (2024-10-24) ------------------------- * Add migration to assign teamless agents to the default team diff --git a/pyproject.toml b/pyproject.toml index 26ebd566add..7db499b00bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.80" +version = "9.3.81" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a8ebbf630d3..cd23bd0fb33 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.80" +__version__ = "9.3.81" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 84d5dc8a292ce501e498f7cd3d443141c926e440 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 15:13:33 +0000 Subject: [PATCH 257/557] Tweak name/url of contact group filter list page --- temba/contacts/tests.py | 14 +++++++------- temba/contacts/views.py | 14 +++++++------- temba/settings_common.py | 10 +--------- temba/utils/templatetags/temba.py | 2 +- temba/utils/templatetags/tests.py | 2 +- .../{contact_filter.html => contact_group.html} | 0 templates/contacts/contact_list.html | 2 +- templates/contacts/contactimport_read.html | 2 +- templates/contacts/export_download.html | 2 +- templates/includes/recipients_group.html | 2 +- 10 files changed, 21 insertions(+), 29 deletions(-) rename templates/contacts/{contact_filter.html => contact_group.html} (100%) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 6b7b24bc895..65a629d6766 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -403,7 +403,7 @@ def test_archived(self, mr_mocks): self.assertEqual(0, len(response.context["object_list"])) @mock_mailroom - def test_filter(self, mr_mocks): + def test_group(self, mr_mocks): open_tickets = self.org.groups.get(name="Open Tickets") joe = self.create_contact("Joe", phone="123") frank = self.create_contact("Frank", phone="124") @@ -416,10 +416,10 @@ def test_filter(self, mr_mocks): group2.contacts.add(frank) group3 = self.create_group("Other Org", org=self.org2) - group1_url = reverse("contacts.contact_filter", args=[group1.uuid]) - group2_url = reverse("contacts.contact_filter", args=[group2.uuid]) - group3_url = reverse("contacts.contact_filter", args=[group3.uuid]) - open_tickets_url = reverse("contacts.contact_filter", args=[open_tickets.uuid]) + group1_url = reverse("contacts.contact_group", args=[group1.uuid]) + group2_url = reverse("contacts.contact_group", args=[group2.uuid]) + group3_url = reverse("contacts.contact_group", args=[group3.uuid]) + open_tickets_url = reverse("contacts.contact_group", args=[open_tickets.uuid]) self.assertRequestDisallowed(group1_url, [None, self.agent, self.admin2]) response = self.assertReadFetch(group1_url, [self.user, self.editor, self.admin]) @@ -447,7 +447,7 @@ def test_filter(self, mr_mocks): self.assertContentMenu(open_tickets_url, self.admin, ["Export", "Usages"]) # if a user tries to access a non-existent group, that's a 404 - response = self.requestView(reverse("contacts.contact_filter", args=["21343253"]), self.admin) + response = self.requestView(reverse("contacts.contact_group", args=["21343253"]), self.admin) self.assertEqual(404, response.status_code) # if a user tries to access a group in another org, send them to the login page @@ -1087,7 +1087,7 @@ def test_create_smart(self, mr_mocks): # dynamic group should not have remove to group button self.login(self.admin) - filter_url = reverse("contacts.contact_filter", args=[group.uuid]) + filter_url = reverse("contacts.contact_group", args=[group.uuid]) self.client.get(filter_url) # put group back into evaluation state diff --git a/temba/contacts/views.py b/temba/contacts/views.py index df13b03c50a..8f358f07088 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -175,7 +175,7 @@ class ContactCRUDL(SmartCRUDL): "list", "menu", "read", - "filter", + "group", "blocked", "omnibox", "open_ticket", @@ -257,7 +257,7 @@ def render_to_response(self, context, **response_kwargs): name=g.name, icon=g.icon, count=group_counts[g], - href=reverse("contacts.contact_filter", args=[g.uuid]), + href=reverse("contacts.contact_group", args=[g.uuid]), ) ) @@ -600,8 +600,8 @@ def build_context_menu(self, menu): if self.has_org_perm("contacts.contact_delete"): menu.add_js("contacts_delete_all", _("Delete All")) - class Filter(OrgObjPermsMixin, ContextMenuMixin, ContactListView): - template_name = "contacts/contact_filter.html" + class Group(OrgObjPermsMixin, ContextMenuMixin, ContactListView): + template_name = "contacts/contact_group.html" def build_context_menu(self, menu): if not self.group.is_system and self.has_org_perm("contacts.contactgroup_update"): @@ -843,7 +843,7 @@ class ContactGroupCRUDL(SmartCRUDL): class Create(ComponentFormMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): form_class = ContactGroupForm fields = ("name", "preselected_contacts", "group_query") - success_url = "uuid@contacts.contact_filter" + success_url = "uuid@contacts.contact_group" submit_button_name = _("Create") def save(self, obj): @@ -877,7 +877,7 @@ def get_form_kwargs(self): class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): form_class = ContactGroupForm fields = ("name",) - success_url = "uuid@contacts.contact_filter" + success_url = "uuid@contacts.contact_group" def get_queryset(self): return super().get_queryset().filter(is_system=False) @@ -906,7 +906,7 @@ class Usages(BaseUsagesModal): permission = "contacts.contactgroup_read" class Delete(BaseDependencyDeleteModal): - cancel_url = "uuid@contacts.contact_filter" + cancel_url = "uuid@contacts.contact_group" success_url = "@contacts.contact_list" diff --git a/temba/settings_common.py b/temba/settings_common.py index bd37922c430..5636384c63d 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -347,15 +347,7 @@ "channels.channel": ("chart", "claim", "configuration", "errors", "facebook_whitelist"), "channels.channellog": ("connection",), "classifiers.classifier": ("connect", "sync"), - "contacts.contact": ( - "export", - "history", - "interrupt", - "menu", - "omnibox", - "open_ticket", - "start", - ), + "contacts.contact": ("export", "history", "interrupt", "menu", "omnibox", "open_ticket", "start"), "contacts.contactfield": ("update_priority",), "contacts.contactgroup": ("menu",), "contacts.contactimport": ("preview",), diff --git a/temba/utils/templatetags/temba.py b/temba/utils/templatetags/temba.py index 31bfaf9043f..b7e53c99ed4 100644 --- a/temba/utils/templatetags/temba.py +++ b/temba/utils/templatetags/temba.py @@ -19,7 +19,7 @@ Flow: lambda o: reverse("flows.flow_editor", args=[o.uuid]), Campaign: lambda o: reverse("campaigns.campaign_read", args=[o.uuid]), CampaignEvent: lambda o: reverse("campaigns.campaign_read", args=[o.uuid]), - ContactGroup: lambda o: reverse("contacts.contact_filter", args=[o.uuid]), + ContactGroup: lambda o: reverse("contacts.contact_group", args=[o.uuid]), Trigger: lambda o: reverse("triggers.trigger_list"), } diff --git a/temba/utils/templatetags/tests.py b/temba/utils/templatetags/tests.py index 564ee231fe6..d306afd0e2d 100644 --- a/temba/utils/templatetags/tests.py +++ b/temba/utils/templatetags/tests.py @@ -158,7 +158,7 @@ def test_object_url(self): group = self.create_group("Testers", contacts=[]) self.assertEqual(f"/flow/editor/{flow.uuid}/", tags.object_url(flow)) - self.assertEqual(f"/contact/filter/{group.uuid}/", tags.object_url(group)) + self.assertEqual(f"/contact/group/{group.uuid}/", tags.object_url(group)) def test_object_class_plural(self): self.assertEqual("Flow", tags.object_class_name(Flow())) diff --git a/templates/contacts/contact_filter.html b/templates/contacts/contact_group.html similarity index 100% rename from templates/contacts/contact_filter.html rename to templates/contacts/contact_group.html diff --git a/templates/contacts/contact_list.html b/templates/contacts/contact_list.html index a1849c56bcc..f86c2b9145c 100644 --- a/templates/contacts/contact_list.html +++ b/templates/contacts/contact_list.html @@ -102,7 +102,7 @@ {% for group in object.all_groups.all %} {% if group.group_type == 'U' %} - {{ group.name }} + {{ group.name }} {% endif %} {% endfor %} diff --git a/templates/contacts/contactimport_read.html b/templates/contacts/contactimport_read.html index 6db0523537c..eda04c7bf28 100644 --- a/templates/contacts/contactimport_read.html +++ b/templates/contacts/contactimport_read.html @@ -52,7 +52,7 @@
    - {% url 'contacts.contact_filter' object.group.uuid as group_url %} + {% url 'contacts.contact_group' object.group.uuid as group_url %} {% blocktrans trimmed count info.num_created|add:info.num_updated as count with group_url=group_url group_name=object.group.name %} Added {{ count }} contact to the {{ group_name }} group {% plural %} diff --git a/templates/contacts/export_download.html b/templates/contacts/export_download.html index b166f4c5715..01e2da7f0bf 100644 --- a/templates/contacts/export_download.html +++ b/templates/contacts/export_download.html @@ -6,7 +6,7 @@
    {% trans "Group" %} {% if group.group_type == "M" or group.group_type == "S" %} - {{ group.name }} + {{ group.name }} {% else %} {{ group.name }} {% endif %} diff --git a/templates/includes/recipients_group.html b/templates/includes/recipients_group.html index fba9764c5b9..2dec64f6d2b 100644 --- a/templates/includes/recipients_group.html +++ b/templates/includes/recipients_group.html @@ -1,3 +1,3 @@ {# djlint:off #} -{{ group.name }} +{{ group.name }} {# djlint:on #} From 5b30aa8a99959c8c3247884dc087543055f525d4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 18:30:32 +0000 Subject: [PATCH 258/557] Add create and update team modals --- temba/orgs/tests.py | 1 + temba/orgs/views/views.py | 54 +++++++++++++++++++++++++++++- temba/tickets/forms.py | 41 ++++++++++++++++++++--- temba/tickets/tests.py | 53 +++++++++++++++++++++++++++++ temba/tickets/views.py | 40 ++++++++++++++++++++-- templates/orgs/user_team.html | 36 ++++++++++++++++++++ templates/tickets/team_delete.html | 8 +++++ templates/tickets/team_list.html | 18 +++++++--- 8 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 templates/orgs/user_team.html create mode 100644 templates/tickets/team_delete.html diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 68b29a757df..2a8d61aa5e6 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1686,6 +1686,7 @@ def test_workspace(self): "Dashboard", "Users (4)", "Invitations (0)", + "Teams (1)", "Export", "Import", ("Channels", ["New Channel", "Test Channel"]), diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 919cdc74726..3cc0d1e947b 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -337,6 +337,7 @@ class UserCRUDL(SmartCRUDL): model = User actions = ( "list", + "team", "update", "delete", "edit", @@ -377,6 +378,57 @@ def get_context_data(self, **kwargs): context["admin_count"] = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).count() return context + class Team(RequireFeatureMixin, ContextMenuMixin, BaseListView): + permission = "orgs.user_list" + require_feature = Org.FEATURE_USERS + menu_path = "/settings/teams" + search_fields = ("email__icontains", "first_name__icontains", "last_name__icontains") + + @classmethod + def derive_url_pattern(cls, path, action): + return r"^%s/%s/(?P\d+)/$" % (path, action) + + def derive_title(self): + return self.team.name + + @cached_property + def team(self): + from temba.tickets.models import Team + + return get_object_or_404(Team, id=self.kwargs["team_id"]) + + def build_context_menu(self, menu): + if not self.team.is_system: + if self.has_org_perm("tickets.team_update"): + menu.add_modax( + _("Edit"), + "update-team", + reverse("tickets.team_update", args=[self.team.id]), + title=_("Edit Team"), + as_button=True, + ) + if self.has_org_perm("tickets.team_delete"): + menu.add_modax( + _("Delete"), + "delete-team", + reverse("tickets.team_delete", args=[self.team.id]), + title=_("Delete Team"), + ) + + def derive_queryset(self, **kwargs): + return ( + super(BaseListView, self) + .derive_queryset(**kwargs) + .filter(id__in=self.team.get_users().values_list("id", flat=True)) + .order_by(Lower("email")) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["team"] = self.team + context["team_topics"] = self.team.topics.order_by(Lower("name")) + return context + class Update(RequireFeatureMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class Form(forms.ModelForm): role = forms.ChoiceField(choices=OrgRole.choices(), required=True, label=_("Role"), widget=SelectWidget()) @@ -1015,7 +1067,7 @@ def derive_menu(self): menu.append( self.create_menu_item( name=_("Teams"), - icon="team", + icon="agent", href="tickets.team_list", count=org.teams.filter(is_active=True).count(), ) diff --git a/temba/tickets/forms.py b/temba/tickets/forms.py index 1f99f297b82..45d4c56776d 100644 --- a/temba/tickets/forms.py +++ b/temba/tickets/forms.py @@ -1,11 +1,11 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from .models import Shortcut, Topic +from .models import Shortcut, Team, Topic class ShortcutForm(forms.ModelForm): - def __init__(self, org, *args, **kwargs) -> None: + def __init__(self, org, *args, **kwargs): super().__init__(*args, **kwargs) self.org = org @@ -28,11 +28,42 @@ class Meta: fields = ("name", "text") -class TopicForm(forms.ModelForm): - def __init__(self, org, *args, **kwargs) -> None: +class TeamForm(forms.ModelForm): + topics = forms.ModelMultipleChoiceField(queryset=Topic.objects.none(), required=False) + + def __init__(self, org, *args, **kwargs): super().__init__(*args, **kwargs) - assert not self.instance or not self.instance.is_system, "cannot edit system topic" + self.org = org + self.fields["topics"].queryset = org.topics.filter(is_active=True) + + def clean_name(self): + name = self.cleaned_data["name"] + + # make sure the name isn't already taken + conflicts = self.org.teams.filter(name__iexact=name) + if self.instance: + conflicts = conflicts.exclude(id=self.instance.id) + + if conflicts.exists(): + raise forms.ValidationError(_("Team with this name already exists.")) + + return name + + def clean_education(self): + topics = self.cleaned_data["topics"] + if len(topics) > 10: + raise forms.ValidationError("Maximum number of topics is 10.") + return topics + + class Meta: + model = Team + fields = ("name", "topics") + + +class TopicForm(forms.ModelForm): + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) self.org = org diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index f058f499940..cd9fcde25ea 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -432,6 +432,59 @@ def test_delete(self): self.assertEqual(f"/ticket/{self.org.default_ticket_topic.uuid}/open/", response.url) +class TeamCRUDLTest(TembaTest, CRUDLTestMixin): + def test_create(self): + create_url = reverse("tickets.team_create") + + self.assertRequestDisallowed(create_url, [None, self.agent, self.user, self.editor]) + + self.assertCreateFetch(create_url, [self.admin], form_fields=("name", "topics")) + + sales = Topic.create(self.org, self.admin, "Sales") + + # try to create with empty values + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "", "topics": []}, + form_errors={"name": "This field is required."}, + ) + + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "all topics", "topics": []}, + form_errors={"name": "Team with this name already exists."}, + ) + + # try to create with name that has invalid characters + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "\\ministry", "topics": []}, + form_errors={"name": "Cannot contain the character: \\"}, + ) + + # try to create with name that is too long + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "X" * 65, "topics": []}, + form_errors={"name": "Ensure this value has at most 64 characters (it has 65)."}, + ) + + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "Sales", "topics": [sales.id]}, + new_obj_query=Team.objects.filter(name="Sales", is_system=False), + success_status=302, + ) + + team = Team.objects.get(name="Sales") + self.assertEqual({sales}, set(team.topics.all())) + + class TicketCRUDLTest(TembaTest, CRUDLTestMixin): def setUp(self): super().setUp() diff --git a/temba/tickets/views.py b/temba/tickets/views.py index ef6c339f7c7..16ea0fcf507 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -28,7 +28,7 @@ from temba.utils.uuid import UUID_REGEX from temba.utils.views.mixins import ComponentFormMixin, ContextMenuMixin, ModalFormMixin, SpaMixin -from .forms import ShortcutForm, TopicForm +from .forms import ShortcutForm, TeamForm, TopicForm from .models import ( AllFolder, MineFolder, @@ -122,14 +122,48 @@ def get_redirect_url(self, **kwargs): class TeamCRUDL(SmartCRUDL): model = Team - actions = ("list",) + actions = ("create", "update", "delete", "list") + + class Create(BaseCreateModal): + form_class = TeamForm + success_url = "@tickets.team_list" - class List(BaseListView): + def save(self, obj): + return Team.create(self.request.org, self.request.user, obj.name, topics=self.form.cleaned_data["topics"]) + + class Update(BaseUpdateModal): + form_class = TeamForm + success_url = "id@orgs.user_team" + + class Delete(BaseDeleteModal): + cancel_url = "id@orgs.user_team" + redirect_url = "@tickets.team_list" + + class List(ContextMenuMixin, BaseListView): menu_path = "/settings/teams" def derive_queryset(self, **kwargs): return super().derive_queryset(**kwargs).filter(is_active=True).order_by(Lower("name")) + def build_context_menu(self, menu): + if self.has_org_perm("tickets.team_create"): + menu.add_modax( + _("New"), + "new-team", + reverse("tickets.team_create"), + title=_("New Team"), + as_button=True, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # annotate each team with its user count + for team in context["object_list"]: + team.user_count = team.get_users().count() + + return context + class TicketCRUDL(SmartCRUDL): model = Ticket diff --git a/templates/orgs/user_team.html b/templates/orgs/user_team.html new file mode 100644 index 00000000000..89632e68b1e --- /dev/null +++ b/templates/orgs/user_team.html @@ -0,0 +1,36 @@ +{% extends "orgs/base/list.html" %} +{% load smartmin temba i18n %} + +{% block pre-table %} + {% if not team.all_topics %} +
    + {% for topic in team_topics %} + + {{ topic.name }} + + {% endfor %} +
    + {% endif %} +{% endblock pre-table %} +{% block table %} + + + + + + + + + {% for obj in object_list %} + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Email" %}{% trans "Name" %}
    {{ obj.email }}{{ obj.name }}
    {% trans "No users" %}
    +{% endblock table %} diff --git a/templates/tickets/team_delete.html b/templates/tickets/team_delete.html new file mode 100644 index 00000000000..23a3a0fca47 --- /dev/null +++ b/templates/tickets/team_delete.html @@ -0,0 +1,8 @@ +{% extends "includes/modax.html" %} +{% load i18n %} + +{% block fields %} + {% blocktrans trimmed %} + You are about to delete the {{ object }} team. There is no way to undo this. Are you sure? + {% endblocktrans %} +{% endblock fields %} diff --git a/templates/tickets/team_list.html b/templates/tickets/team_list.html index 43a5ec221d6..da5beff5b83 100644 --- a/templates/tickets/team_list.html +++ b/templates/tickets/team_list.html @@ -9,7 +9,7 @@ {% endblock pre-table %} {% block table %} - +
    @@ -19,10 +19,20 @@ {% for obj in object_list %} - + - - + + {% empty %} From 8e5a858f6bad196d7c4ad0afa3314e753c108d68 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 18:34:28 +0000 Subject: [PATCH 259/557] Revert components update --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9193cc5ea97..3f912df4323 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.109.1", + "@nyaruka/temba-components": "0.109.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index 08b9558842b..4f06a032984 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.109.1": - version "0.109.1" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.109.1.tgz#f2a92eb44a726b03d069f97299a6c8626aec8774" - integrity sha512-ZwGrbdS0qc43yPkmFjRqw//BVxOQrqaFr1SbC0e4w7dC1gwoo5V1UHfz1keyXuGXF/T1HeCMUVgPSY+5pZBHEg== +"@nyaruka/temba-components@0.109.0": + version "0.109.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.109.0.tgz#a7aef7c222719b55cb2f09f371b814aad9f05d8d" + integrity sha512-ctSRjGIlDi9otTkvw59acU1Ji0nh1RpxJkHufnxuqXguSTdJauNTYGAgwukpVrpOII5ehWgdl6ACPgpg41F+vw== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 72cef6827a161d814626ce3385d4e97e1af65c3e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 19:02:35 +0000 Subject: [PATCH 260/557] Tests and coverage --- temba/orgs/tests.py | 14 +++++++++ temba/tickets/tests.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 2a8d61aa5e6..ded39eacf1e 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2821,6 +2821,20 @@ def test_list(self): self.assertListFetch(list_url + "?search=andy", [self.admin], context_objects=[self.admin]) self.assertListFetch(list_url + "?search=editor@nyaruka.com", [self.admin], context_objects=[self.editor]) + def test_team(self): + team_url = reverse("orgs.user_team", args=[self.org.default_ticket_team.id]) + + # nobody can access if users feature not enabled + response = self.requestView(team_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) + + self.org.features = [Org.FEATURE_USERS] + self.org.save(update_fields=("features",)) + + self.assertRequestDisallowed(team_url, [None, self.user, self.editor, self.agent]) + + self.assertListFetch(team_url, [self.admin], context_objects=[self.agent]) + def test_update(self): update_url = reverse("orgs.user_update", args=[self.agent.id]) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index cd9fcde25ea..9bc8fbc3e88 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -484,6 +484,73 @@ def test_create(self): team = Team.objects.get(name="Sales") self.assertEqual({sales}, set(team.topics.all())) + def test_update(self): + sales = Topic.create(self.org, self.admin, "Sales") + marketing = Topic.create(self.org, self.admin, "Marketing") + team = Team.create(self.org, self.admin, "Sales", topics=[sales]) + + update_url = reverse("tickets.team_update", args=[team.id]) + + self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.editor, self.admin2]) + + self.assertUpdateFetch(update_url, [self.admin], form_fields=["name", "topics"]) + + # names must be unique (case-insensitive) + self.assertUpdateSubmit( + update_url, + self.admin, + {"name": "all topics"}, + form_errors={"name": "Team with this name already exists."}, + object_unchanged=team, + ) + + self.assertUpdateSubmit( + update_url, self.admin, {"name": "Marketing", "topics": [marketing.id]}, success_status=302 + ) + + team.refresh_from_db() + self.assertEqual(team.name, "Marketing") + self.assertEqual({marketing}, set(team.topics.all())) + + # can't edit a system team + self.assertRequestDisallowed( + reverse("tickets.team_update", args=[self.org.default_ticket_team.id]), [self.admin] + ) + + def test_delete(self): + sales = Topic.create(self.org, self.admin, "Sales") + team1 = Team.create(self.org, self.admin, "Sales", topics=[sales]) + team2 = Team.create(self.org, self.admin, "Other", topics=[sales]) + + delete_url = reverse("tickets.team_delete", args=[team1.id]) + + self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.editor, self.admin2]) + + response = self.assertDeleteFetch(delete_url, [self.admin]) + self.assertContains(response, "You are about to delete") + + # submit to delete it + response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=team1, success_status=302) + + # other team unafected + team2.refresh_from_db() + self.assertTrue(team2.is_active) + + # we should have been redirected to the team list + self.assertEqual("/team/", response.url) + + def test_list(self): + sales = Topic.create(self.org, self.admin, "Sales") + team1 = Team.create(self.org, self.admin, "Sales", topics=[sales]) + team2 = Team.create(self.org, self.admin, "Other", topics=[sales]) + Team.create(self.org2, self.admin2, "Cars", topics=[]) + + list_url = reverse("tickets.team_list") + + self.assertRequestDisallowed(list_url, [None, self.agent, self.editor]) + + self.assertListFetch(list_url, [self.admin], context_objects=[self.org.default_ticket_team, team2, team1]) + class TicketCRUDLTest(TembaTest, CRUDLTestMixin): def setUp(self): From be077ac339c4c34501c6b6ce61d79dab4943b31d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 20:09:15 +0000 Subject: [PATCH 261/557] Some cleanup to topic crudl and ticket folders --- temba/tickets/models.py | 55 ++++++++++++------------ temba/tickets/tests.py | 6 +-- temba/tickets/views.py | 95 ++++++++++++++++------------------------- 3 files changed, 66 insertions(+), 90 deletions(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 1e05fb92c8d..838d90be6b2 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -18,7 +18,7 @@ from temba.utils.dates import date_range from temba.utils.export import MultiSheetExporter from temba.utils.models import DailyCountModel, DailyTimingModel, SquashableModel, TembaModel -from temba.utils.uuid import uuid4 +from temba.utils.uuid import is_uuid, uuid4 logger = logging.getLogger(__name__) @@ -295,13 +295,13 @@ class Meta: class TicketFolder(metaclass=ABCMeta): - id = None + slug = None name = None icon = None verbose_name = None def get_queryset(self, org, user, ordered): - qs = Ticket.objects.filter(org=org) + qs = org.tickets.all() if ordered: qs = qs.order_by("-last_activity_on", "-id") @@ -309,40 +309,25 @@ def get_queryset(self, org, user, ordered): return qs.select_related("topic", "assignee").prefetch_related("contact") @classmethod - def from_id(cls, org, id: str): - folder = FOLDERS.get(id, None) - if not folder: - topic = Topic.objects.filter(org=org, uuid=id).first() + def from_slug(cls, org, slug_or_uuid: str): + if is_uuid(slug_or_uuid): + topic = org.topics.filter(uuid=slug_or_uuid, is_active=True).first() if topic: - folder = TopicFolder(topic) - return folder + return TopicFolder(topic) + + return FOLDERS.get(slug_or_uuid, None) @classmethod def all(cls): return FOLDERS -class TopicFolder(TicketFolder): - """ - Tickets assigned to the current user - """ - - def __init__(self, topic: Topic): - self.topic = topic - self.id = topic.uuid - self.name = topic.name - self.is_system = topic.is_system - - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(topic=self.topic) - - class MineFolder(TicketFolder): """ Tickets assigned to the current user """ - id = "mine" + slug = "mine" name = _("My Tickets") icon = "tickets_mine" @@ -355,7 +340,7 @@ class UnassignedFolder(TicketFolder): Tickets not assigned to any user """ - id = "unassigned" + slug = "unassigned" name = _("Unassigned") verbose_name = _("Unassigned Tickets") icon = "tickets_unassigned" @@ -369,7 +354,7 @@ class AllFolder(TicketFolder): All tickets """ - id = "all" + slug = "all" name = _("All") verbose_name = _("All Tickets") icon = "tickets_all" @@ -378,7 +363,21 @@ def get_queryset(self, org, user, ordered): return super().get_queryset(org, user, ordered) -FOLDERS = {f.id: f() for f in TicketFolder.__subclasses__() if f.id} +FOLDERS = {f.slug: f() for f in TicketFolder.__subclasses__()} + + +class TopicFolder(TicketFolder): + """ + Tickets with a specific topic + """ + + def __init__(self, topic: Topic): + self.slug = topic.uuid + self.name = topic.name + self.topic = topic + + def get_queryset(self, org, user, ordered): + return super().get_queryset(org, user, ordered).filter(topic=self.topic) class TicketCount(SquashableModel): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index f058f499940..10c5cec46f8 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -385,7 +385,7 @@ def test_create(self): def test_update(self): topic = Topic.create(self.org, self.admin, "Hot Topic") - update_url = reverse("tickets.topic_update", args=[topic.uuid]) + update_url = reverse("tickets.topic_update", args=[topic.id]) self.assertRequestDisallowed(update_url, [None, self.user, self.agent, self.admin2]) @@ -407,14 +407,14 @@ def test_update(self): # can't edit a system topic self.assertRequestDisallowed( - reverse("tickets.topic_update", args=[self.org.default_ticket_topic.uuid]), [self.admin] + reverse("tickets.topic_update", args=[self.org.default_ticket_topic.id]), [self.admin] ) def test_delete(self): topic1 = Topic.create(self.org, self.admin, "Planes") topic2 = Topic.create(self.org, self.admin, "Trains") - delete_url = reverse("tickets.topic_delete", args=[topic1.uuid]) + delete_url = reverse("tickets.topic_delete", args=[topic1.id]) self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2]) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 41d0e85bd59..a1d4b58c3f3 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -1,11 +1,11 @@ from datetime import timedelta -from smartmin.views import SmartCRUDL, SmartDeleteView, SmartListView, SmartTemplateView, SmartUpdateView +from smartmin.views import SmartCRUDL, SmartListView, SmartTemplateView, SmartUpdateView from django import forms from django.db.models.aggregates import Max from django.db.models.functions import Lower -from django.http import Http404, HttpResponseRedirect, JsonResponse +from django.http import Http404, JsonResponse from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -94,29 +94,17 @@ def save(self, obj): class Update(BaseUpdateModal): form_class = TopicForm success_url = "hide" - slug_url_kwarg = "uuid" - class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView): - default_template = "smartmin/delete_confirm.html" - submit_button_name = _("Delete") - slug_url_kwarg = "uuid" - fields = ("uuid",) + class Delete(BaseDeleteModal): cancel_url = "@tickets.ticket_list" - redirect_url = "@tickets.ticket_list" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["has_tickets"] = self.object.tickets.exists() return context - def post(self, request, *args, **kwargs): - self.get_object().release(self.request.user) - redirect_url = self.get_redirect_url() - return HttpResponseRedirect(redirect_url) - def get_redirect_url(self, **kwargs): - default_topic = self.get_object().org.topics.filter(is_default=True).first() - return f"/ticket/{str(default_topic.uuid)}/open/" + return f"/ticket/{self.request.org.default_ticket_topic.uuid}/open/" class TicketCRUDL(SmartCRUDL): @@ -153,9 +141,9 @@ def derive_url_pattern(cls, path, action): def get_notification_scope(self) -> tuple: folder, status, _, _ = self.tickets_path - if folder == UnassignedFolder.id and status == "open": + if folder == UnassignedFolder.slug and status == "open": return "tickets:opened", "" - elif folder == MineFolder.id and status == "open": + elif folder == MineFolder.slug and status == "open": return "tickets:activity", "" return "", "" @@ -177,7 +165,7 @@ def tickets_path(self) -> tuple: status_code = Ticket.STATUS_OPEN if status == "open" else Ticket.STATUS_CLOSED org = self.request.org user = self.request.user - ticket_folder = TicketFolder.from_id(org, folder) + ticket_folder = TicketFolder.from_slug(org, folder) if not ticket_folder: raise Http404() @@ -191,10 +179,10 @@ def tickets_path(self) -> tuple: # if it's not, switch our folder to everything with that ticket's state ticket = org.tickets.filter(uuid=uuid).first() if ticket: - folder = AllFolder.id + folder = AllFolder.slug status = "open" if ticket.status == Ticket.STATUS_OPEN else "closed" - return folder or MineFolder.id, status or "open", uuid, in_page + return folder or MineFolder.slug, status or "open", uuid, in_page def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -204,7 +192,7 @@ def get_context_data(self, **kwargs): context["status"] = status context["has_tickets"] = self.request.org.tickets.exists() - folder = TicketFolder.from_id(self.request.org, folder) + folder = TicketFolder.from_slug(self.request.org, folder) context["title"] = folder.name if uuid: @@ -213,10 +201,6 @@ def get_context_data(self, **kwargs): return context def build_context_menu(self, menu): - # we only support dynamic content menus - if "HTTP_TEMBA_CONTENT_MENU" not in self.request.META: - return - uuid = self.kwargs.get("uuid") if uuid: ticket = self.request.org.tickets.filter(uuid=uuid).first() @@ -265,20 +249,20 @@ def derive_menu(self): user = self.request.user count_by_assignee = TicketCount.get_by_assignees(org, [None, user], Ticket.STATUS_OPEN) counts = { - MineFolder.id: count_by_assignee[user], - UnassignedFolder.id: count_by_assignee[None], - AllFolder.id: TicketCount.get_all(org, Ticket.STATUS_OPEN), + MineFolder.slug: count_by_assignee[user], + UnassignedFolder.slug: count_by_assignee[None], + AllFolder.slug: TicketCount.get_all(org, Ticket.STATUS_OPEN), } menu = [] for folder in TicketFolder.all().values(): menu.append( { - "id": folder.id, + "id": folder.slug, "name": folder.name, "icon": folder.icon, - "count": counts[folder.id], - "href": f"/ticket/{folder.id}/open/", + "count": counts[folder.slug], + "href": f"/ticket/{folder.slug}/open/", } ) @@ -324,37 +308,30 @@ def derive_url_pattern(cls, path, action): return rf"^{path}/{action}/(?P{folders}|{UUID_REGEX.pattern})/(?Popen|closed)/((?P[a-z0-9\-]+))?$" @cached_property - def folder(self): - folder = TicketFolder.from_id(self.request.org, self.kwargs["folder"]) + def folder(self) -> TicketFolder: + folder = TicketFolder.from_slug(self.request.org, self.kwargs["folder"]) if not folder: raise Http404() + return folder def build_context_menu(self, menu): - # we only support dynamic content menus - if "HTTP_TEMBA_CONTENT_MENU" not in self.request.META: - return - - if ( - self.has_org_perm("tickets.topic_update") - and isinstance(self.folder, TopicFolder) - and not self.folder.is_system - ): - - menu.add_modax( - _("Edit"), - "edit-topic", - f"{reverse('tickets.topic_update', args=[self.folder.id])}", - title=_("Edit Topic"), - on_submit="handleTopicUpdated()", - ) - - menu.add_modax( - _("Delete"), - "delete-topic", - f"{reverse('tickets.topic_delete', args=[self.folder.id])}", - title=_("Delete"), - ) + if isinstance(self.folder, TopicFolder) and not self.folder.topic.is_system: + if self.has_org_perm("tickets.topic_update"): + menu.add_modax( + _("Edit"), + "edit-topic", + f"{reverse('tickets.topic_update', args=[self.folder.topic.id])}", + title=_("Edit Topic"), + on_submit="handleTopicUpdated()", + ) + if self.has_org_perm("tickets.topic_delete"): + menu.add_modax( + _("Delete"), + "delete-topic", + f"{reverse('tickets.topic_delete', args=[self.folder.topic.id])}", + title=_("Delete Topic"), + ) def get_queryset(self, **kwargs): org = self.request.org @@ -456,7 +433,7 @@ def as_json(t): # build up our next link if we have more if len(context["tickets"]) >= self.paginate_by: folder_url = reverse( - "tickets.ticket_folder", kwargs={"folder": self.folder.id, "status": self.kwargs["status"]} + "tickets.ticket_folder", kwargs={"folder": self.folder.slug, "status": self.kwargs["status"]} ) last_time = results["results"][-1]["ticket"]["last_activity_on"] results["next"] = f"{folder_url}?before={datetime_to_timestamp(last_time)}" From 51188d27054bddaf95efd8a3be3b7db874f228ba Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 20:18:26 +0000 Subject: [PATCH 262/557] Fix checking topic per team limit --- temba/tickets/forms.py | 8 +++++--- temba/tickets/models.py | 1 + temba/tickets/tests.py | 10 ++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/temba/tickets/forms.py b/temba/tickets/forms.py index 45d4c56776d..cdfd16874e8 100644 --- a/temba/tickets/forms.py +++ b/temba/tickets/forms.py @@ -50,10 +50,12 @@ def clean_name(self): return name - def clean_education(self): + def clean_topics(self): topics = self.cleaned_data["topics"] - if len(topics) > 10: - raise forms.ValidationError("Maximum number of topics is 10.") + if len(topics) > Team.max_topics: + raise forms.ValidationError( + _("Teams can have at most %(limit)d topics."), params={"limit": Team.max_topics} + ) return topics class Meta: diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 1e05fb92c8d..d3e4e118dae 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -122,6 +122,7 @@ class Team(TembaModel): is_default = models.BooleanField(default=False) org_limit_key = Org.LIMIT_TEAMS + max_topics = 10 @classmethod def create_system(cls, org): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 9bc8fbc3e88..ede1e8586ef 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -441,6 +441,8 @@ def test_create(self): self.assertCreateFetch(create_url, [self.admin], form_fields=("name", "topics")) sales = Topic.create(self.org, self.admin, "Sales") + for n in range(Team.max_topics + 1): + Topic.create(self.org, self.admin, f"Topic {n}") # try to create with empty values self.assertCreateSubmit( @@ -473,6 +475,14 @@ def test_create(self): form_errors={"name": "Ensure this value has at most 64 characters (it has 65)."}, ) + # try to create with too many topics + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "Everything", "topics": [t.id for t in self.org.topics.all()]}, + form_errors={"topics": "Teams can have at most 10 topics."}, + ) + self.assertCreateSubmit( create_url, self.admin, From 9d00908b79c48ec9232c1a9cb28df80600cfd96a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 28 Oct 2024 22:02:22 +0000 Subject: [PATCH 263/557] Make teams an org feature.. that nobody has for now --- temba/orgs/models.py | 2 ++ temba/orgs/tests.py | 14 ++++++++++---- temba/orgs/views/views.py | 17 +++++++++-------- temba/tickets/tests.py | 16 +++++++++++++++- temba/tickets/views.py | 9 ++++++--- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 3b676a2e064..b7a63579bfb 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -458,10 +458,12 @@ class Org(SmartModel): FEATURE_USERS = "users" # can invite users to this org FEATURE_NEW_ORGS = "new_orgs" # can create new workspace with same login FEATURE_CHILD_ORGS = "child_orgs" # can create child workspaces of this org + FEATURE_TEAMS = "teams" # can create teams to organize agent users FEATURES_CHOICES = ( (FEATURE_USERS, _("Users")), (FEATURE_NEW_ORGS, _("New Orgs")), (FEATURE_CHILD_ORGS, _("Child Orgs")), + (FEATURE_TEAMS, _("Teams")), ) LIMIT_CHANNELS = "channels" diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index ded39eacf1e..830edc7ae1e 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -1657,8 +1657,8 @@ def test_workspace(self): ], ) - # enable child workspaces and users - self.org.features = [Org.FEATURE_USERS, Org.FEATURE_CHILD_ORGS] + # enable child workspaces, users and teams + self.org.features = [Org.FEATURE_USERS, Org.FEATURE_CHILD_ORGS, Org.FEATURE_TEAMS] self.org.save(update_fields=("features",)) self.child_org = Org.objects.create( @@ -2824,16 +2824,22 @@ def test_list(self): def test_team(self): team_url = reverse("orgs.user_team", args=[self.org.default_ticket_team.id]) - # nobody can access if users feature not enabled + # nobody can access if teams feature not enabled response = self.requestView(team_url, self.admin) self.assertRedirect(response, reverse("orgs.org_workspace")) - self.org.features = [Org.FEATURE_USERS] + self.org.features = [Org.FEATURE_TEAMS] self.org.save(update_fields=("features",)) self.assertRequestDisallowed(team_url, [None, self.user, self.editor, self.agent]) self.assertListFetch(team_url, [self.admin], context_objects=[self.agent]) + self.assertContentMenu(team_url, self.admin, []) # because it's a system team + + team = Team.create(self.org, self.admin, "My Team") + team_url = reverse("orgs.user_team", args=[team.id]) + + self.assertContentMenu(team_url, self.admin, ["Edit", "Delete"]) def test_update(self): update_url = reverse("orgs.user_update", args=[self.agent.id]) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 3cc0d1e947b..96cc76f91fb 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -380,7 +380,7 @@ def get_context_data(self, **kwargs): class Team(RequireFeatureMixin, ContextMenuMixin, BaseListView): permission = "orgs.user_list" - require_feature = Org.FEATURE_USERS + require_feature = Org.FEATURE_TEAMS menu_path = "/settings/teams" search_fields = ("email__icontains", "first_name__icontains", "last_name__icontains") @@ -1064,14 +1064,15 @@ def derive_menu(self): count=org.invitations.filter(is_active=True).count(), ) ) - menu.append( - self.create_menu_item( - name=_("Teams"), - icon="agent", - href="tickets.team_list", - count=org.teams.filter(is_active=True).count(), + if Org.FEATURE_TEAMS in org.features: + menu.append( + self.create_menu_item( + name=_("Teams"), + icon="agent", + href="tickets.team_list", + count=org.teams.filter(is_active=True).count(), + ) ) - ) menu.append(self.create_divider()) if self.has_org_perm("orgs.org_export"): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index f75e7e2b40c..5dbc393a325 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -9,7 +9,7 @@ from django.utils import timezone from temba.contacts.models import Contact, ContactField, ContactURN -from temba.orgs.models import Export, OrgMembership, OrgRole +from temba.orgs.models import Export, Org, OrgMembership, OrgRole from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -436,6 +436,13 @@ class TeamCRUDLTest(TembaTest, CRUDLTestMixin): def test_create(self): create_url = reverse("tickets.team_create") + # nobody can access if new orgs feature not enabled + response = self.requestView(create_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) + + self.org.features = [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) + self.assertRequestDisallowed(create_url, [None, self.agent, self.user, self.editor]) self.assertCreateFetch(create_url, [self.admin], form_fields=("name", "topics")) @@ -557,6 +564,13 @@ def test_list(self): list_url = reverse("tickets.team_list") + # nobody can access if new orgs feature not enabled + response = self.requestView(list_url, self.admin) + self.assertRedirect(response, reverse("orgs.org_workspace")) + + self.org.features = [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) + self.assertRequestDisallowed(list_url, [None, self.agent, self.editor]) self.assertListFetch(list_url, [self.admin], context_objects=[self.org.default_ticket_team, team2, team1]) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 3e9c92f71a1..1be7c1f21e3 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -13,6 +13,7 @@ from temba.msgs.models import Msg from temba.notifications.views import NotificationTargetMixin +from temba.orgs.models import Org from temba.orgs.views.base import ( BaseCreateModal, BaseDeleteModal, @@ -21,7 +22,7 @@ BaseMenuView, BaseUpdateModal, ) -from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin +from temba.orgs.views.mixins import OrgObjPermsMixin, OrgPermsMixin, RequireFeatureMixin from temba.utils.dates import datetime_to_timestamp, timestamp_to_datetime from temba.utils.export import response_from_workbook from temba.utils.fields import InputWidget @@ -112,7 +113,8 @@ class TeamCRUDL(SmartCRUDL): model = Team actions = ("create", "update", "delete", "list") - class Create(BaseCreateModal): + class Create(RequireFeatureMixin, BaseCreateModal): + require_feature = Org.FEATURE_TEAMS form_class = TeamForm success_url = "@tickets.team_list" @@ -127,7 +129,8 @@ class Delete(BaseDeleteModal): cancel_url = "id@orgs.user_team" redirect_url = "@tickets.team_list" - class List(ContextMenuMixin, BaseListView): + class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): + require_feature = Org.FEATURE_TEAMS menu_path = "/settings/teams" def derive_queryset(self, **kwargs): From 607ca5899f0514e0d2af07de1b75c89f458d3bbe Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 29 Oct 2024 09:39:16 -0500 Subject: [PATCH 264/557] Update CHANGELOG.md for v9.3.82 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89619ed0a38..3e6e535b70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v9.3.82 (2024-10-29) +------------------------- + * Make teams an org feature.. that nobody has for now + * Some cleanup to topic crudl and ticket folders + * Tweak name/url of contact group filter list page + * Filter topics in topic selection menu based on team membership + * Add basic team CRUDL views + v9.3.81 (2024-10-28) ------------------------- * Fix N+1 query on contact list page diff --git a/pyproject.toml b/pyproject.toml index 7db499b00bf..4b01c56f270 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.81" +version = "9.3.82" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index cd23bd0fb33..9f1e6a076e3 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.81" +__version__ = "9.3.82" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From a385918935862fdbcb96bdeabd81f21f8daca3ac Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 29 Oct 2024 16:25:20 +0000 Subject: [PATCH 265/557] Tweak ordering of ticket crudl views for consistency with other crudls --- temba/tickets/views.py | 138 ++++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 1be7c1f21e3..8be95f82f78 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -158,25 +158,62 @@ def get_context_data(self, **kwargs): class TicketCRUDL(SmartCRUDL): model = Ticket - actions = ("list", "update", "folder", "note", "menu", "export_stats", "export") + actions = ("menu", "list", "folder", "update", "note", "export_stats", "export") - class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): - class Form(forms.ModelForm): - def __init__(self, **kwargs): - super().__init__(**kwargs) + class Menu(BaseMenuView): + def derive_menu(self): + org = self.request.org + user = self.request.user + count_by_assignee = TicketCount.get_by_assignees(org, [None, user], Ticket.STATUS_OPEN) + counts = { + MineFolder.slug: count_by_assignee[user], + UnassignedFolder.slug: count_by_assignee[None], + AllFolder.slug: TicketCount.get_all(org, Ticket.STATUS_OPEN), + } - self.fields["topic"].queryset = self.instance.org.topics.filter(is_active=True).order_by( - "-is_system", "name" + menu = [] + for folder in TicketFolder.all().values(): + menu.append( + { + "id": folder.slug, + "name": folder.name, + "icon": folder.icon, + "count": counts[folder.slug], + "href": f"/ticket/{folder.slug}/open/", + } ) - class Meta: - fields = ("topic",) - model = Ticket + menu.append(self.create_divider()) + menu.append( + self.create_menu_item( + menu_id="shortcuts", + name=_("Shortcuts"), + icon="shortcut", + count=org.shortcuts.filter(is_active=True).count(), + href="tickets.shortcut_list", + ) + ) + menu.append(self.create_modax_button(_("Export"), "tickets.ticket_export", icon="export")) + menu.append( + self.create_modax_button(_("New Topic"), "tickets.topic_create", icon="add", on_submit="refreshMenu()") + ) - form_class = Form - fields = ("topic",) - slug_url_kwarg = "uuid" - success_url = "hide" + menu.append(self.create_divider()) + + topics = list(Topic.get_accessible(org, user).order_by("-is_system", "name")) + counts = TicketCount.get_by_topics(org, topics, Ticket.STATUS_OPEN) + for topic in topics: + menu.append( + { + "id": topic.uuid, + "name": topic.name, + "icon": "topic", + "count": counts[topic], + "href": f"/ticket/{topic.uuid}/open/", + } + ) + + return menu class List(SpaMixin, ContextMenuMixin, OrgPermsMixin, NotificationTargetMixin, SmartListView): """ @@ -292,61 +329,6 @@ def build_context_menu(self, menu): def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).none() - class Menu(BaseMenuView): - def derive_menu(self): - org = self.request.org - user = self.request.user - count_by_assignee = TicketCount.get_by_assignees(org, [None, user], Ticket.STATUS_OPEN) - counts = { - MineFolder.slug: count_by_assignee[user], - UnassignedFolder.slug: count_by_assignee[None], - AllFolder.slug: TicketCount.get_all(org, Ticket.STATUS_OPEN), - } - - menu = [] - for folder in TicketFolder.all().values(): - menu.append( - { - "id": folder.slug, - "name": folder.name, - "icon": folder.icon, - "count": counts[folder.slug], - "href": f"/ticket/{folder.slug}/open/", - } - ) - - menu.append(self.create_divider()) - menu.append( - self.create_menu_item( - menu_id="shortcuts", - name=_("Shortcuts"), - icon="shortcut", - count=org.shortcuts.filter(is_active=True).count(), - href="tickets.shortcut_list", - ) - ) - menu.append(self.create_modax_button(_("Export"), "tickets.ticket_export", icon="export")) - menu.append( - self.create_modax_button(_("New Topic"), "tickets.topic_create", icon="add", on_submit="refreshMenu()") - ) - - menu.append(self.create_divider()) - - topics = list(Topic.get_accessible(org, user).order_by("-is_system", "name")) - counts = TicketCount.get_by_topics(org, topics, Ticket.STATUS_OPEN) - for topic in topics: - menu.append( - { - "id": topic.uuid, - "name": topic.name, - "icon": "topic", - "count": counts[topic], - "href": f"/ticket/{topic.uuid}/open/", - } - ) - - return menu - class Folder(ContextMenuMixin, OrgPermsMixin, SmartTemplateView): permission = "tickets.ticket_list" paginate_by = 25 @@ -489,6 +471,24 @@ def as_json(t): return JsonResponse(results) + class Update(ComponentFormMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): + class Form(forms.ModelForm): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.fields["topic"].queryset = self.instance.org.topics.filter(is_active=True).order_by( + "-is_system", "name" + ) + + class Meta: + fields = ("topic",) + model = Ticket + + form_class = Form + fields = ("topic",) + slug_url_kwarg = "uuid" + success_url = "hide" + class Note(ModalFormMixin, ComponentFormMixin, OrgObjPermsMixin, SmartUpdateView): """ Creates a note for this contact From 54e7a59e492579c46fd13b3176cc87482989e582 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 29 Oct 2024 18:15:00 +0000 Subject: [PATCH 266/557] Some cleanup to tickets list view --- temba/tickets/models.py | 25 +++---- temba/tickets/tests.py | 31 +++++---- temba/tickets/views.py | 148 ++++++++++++++++++++-------------------- 3 files changed, 102 insertions(+), 102 deletions(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 03b25f35f32..f1f32bf006a 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -301,7 +301,7 @@ class TicketFolder(metaclass=ABCMeta): icon = None verbose_name = None - def get_queryset(self, org, user, ordered): + def get_queryset(self, org, user, *, ordered: bool): qs = org.tickets.all() if ordered: @@ -325,20 +325,20 @@ def all(cls): class MineFolder(TicketFolder): """ - Tickets assigned to the current user + Tickets assigned to the current user. """ slug = "mine" name = _("My Tickets") icon = "tickets_mine" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(assignee=user) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(assignee=user) class UnassignedFolder(TicketFolder): """ - Tickets not assigned to any user + Tickets not assigned to any user. """ slug = "unassigned" @@ -346,13 +346,13 @@ class UnassignedFolder(TicketFolder): verbose_name = _("Unassigned Tickets") icon = "tickets_unassigned" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(assignee=None) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(assignee=None) class AllFolder(TicketFolder): """ - All tickets + All tickets the user can access. """ slug = "all" @@ -360,16 +360,13 @@ class AllFolder(TicketFolder): verbose_name = _("All Tickets") icon = "tickets_all" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered) - FOLDERS = {f.slug: f() for f in TicketFolder.__subclasses__()} class TopicFolder(TicketFolder): """ - Tickets with a specific topic + Wraps a topic so we can use it like a folder. """ def __init__(self, topic: Topic): @@ -377,8 +374,8 @@ def __init__(self, topic: Topic): self.name = topic.name self.topic = topic - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(topic=self.topic) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(topic=self.topic) class TicketCount(SquashableModel): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 5dbc393a325..54f2c8cc341 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -590,34 +590,35 @@ def test_list(self): self.assertRequestDisallowed(list_url, [None]) self.assertListFetch(list_url, [self.user, self.editor, self.admin, self.agent], context_objects=[]) - # can hit this page with a uuid - # TODO: work out reverse for deep link - # deep_link = reverse( - # "tickets.ticket_list", kwargs={"folder": "all", "status": "open", "uuid": str(ticket.uuid)} - # ) - + # link to our ticket within the All folder deep_link = f"{list_url}all/open/{str(ticket.uuid)}/" - self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) + response = self.assertListFetch(deep_link, [self.user, self.editor, self.admin, self.agent], context_objects=[]) + self.assertEqual("all", response.context["folder"]) + self.assertEqual("open", response.context["status"]) # our ticket exists on the first page, so it'll get flagged to be focused self.assertEqual(str(ticket.uuid), response.context["nextUUID"]) - # deep link into a page that doesn't have our ticket - deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" + # we have a specific ticket so we should show context menu for it + self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) - self.login(self.admin) + # try to link to our ticket but with mismatched status + deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" - response = self.client.get(deep_link) + response = self.assertListFetch(deep_link, [self.agent], context_objects=[]) - # now our ticket is listed as the uuid and we were redirected to all/open + # now our ticket is listed as the uuid and we were redirected to All folder with Open status self.assertEqual("all", response.context["folder"]) self.assertEqual("open", response.context["status"]) self.assertEqual(str(ticket.uuid), response.context["uuid"]) - # bad topic should give a 404 + # and again we have a specific ticket so we should show context menu for it + self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) + + # non-existent topic should give a 404 bad_topic_link = f"{list_url}{uuid4()}/open/{str(ticket.uuid)}/" - response = self.client.get(bad_topic_link) + response = self.requestView(bad_topic_link, self.agent) self.assertEqual(404, response.status_code) response = self.client.get( @@ -637,7 +638,7 @@ def test_list(self): # closed our tickets don't get extra menu options ticket.status = Ticket.STATUS_CLOSED - ticket.save() + ticket.save(update_fields=("status",)) deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" self.assertContentMenu(deep_link, self.admin, []) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 8be95f82f78..08f5379fc04 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -217,7 +217,7 @@ def derive_menu(self): class List(SpaMixin, ContextMenuMixin, OrgPermsMixin, NotificationTargetMixin, SmartListView): """ - A placeholder view for the ticket handling frontend components which fetch tickets from the endpoint below + Placeholder view for the ticketing frontend components which fetch tickets from the folders view below. """ @classmethod @@ -226,105 +226,107 @@ def derive_url_pattern(cls, path, action): return rf"^ticket/((?P{folders}|{UUID_REGEX.pattern})/((?Popen|closed)/((?P[a-z0-9\-]+)/)?)?)?$" def get_notification_scope(self) -> tuple: - folder, status, _, _ = self.tickets_path - if folder == UnassignedFolder.slug and status == "open": + folder, status, ticket, in_page = self.tickets_path + + if folder.slug == UnassignedFolder.slug and status == Ticket.STATUS_OPEN: return "tickets:opened", "" - elif folder == MineFolder.slug and status == "open": + elif folder.slug == MineFolder.slug and status == Ticket.STATUS_OPEN: return "tickets:activity", "" return "", "" def derive_menu_path(self): - return f"/ticket/{self.kwargs.get('folder', 'mine')}/" + folder, status, ticket, in_page = self.tickets_path + + return f"/ticket/{folder.slug}/" @cached_property - def tickets_path(self) -> tuple: + def tickets_path(self) -> tuple[TicketFolder, str, Ticket, bool]: """ - Returns tuple of folder, status, ticket uuid, and whether that ticket exists in first page of tickets + Returns tuple of folder, status, ticket, and whether that ticket exists in first page of tickets """ - folder = self.kwargs.get("folder") - status = self.kwargs.get("status") - uuid = self.kwargs.get("uuid") + + # get requested folder, defaulting to Mine + folder = TicketFolder.from_slug(self.request.org, self.kwargs.get("folder", MineFolder.slug)) + if not folder: + raise Http404() + + status = Ticket.STATUS_OPEN if self.kwargs.get("status", "open") == "open" else Ticket.STATUS_CLOSED + ticket = None in_page = False - # 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 + # is the request for a specific ticket? + if uuid := self.kwargs.get("uuid"): org = self.request.org user = self.request.user - ticket_folder = TicketFolder.from_slug(org, folder) - if not ticket_folder: - raise Http404() + # is the ticket in the first page from of current folder? + for t in list(folder.get_queryset(org, user, ordered=True).filter(status=status)[:25]): + if str(t.uuid) == uuid: + ticket = t + in_page = True + break - tickets = list(ticket_folder.get_queryset(org, user, True).filter(status=status_code)[:25]) + # if not, see if we can access it in the All tickets folder and if so switch to that + if not in_page: + all_folder = TicketFolder.from_slug(self.request.org, AllFolder.slug) + ticket = all_folder.get_queryset(org, user, ordered=False).filter(uuid=uuid).first() - found = list(filter(lambda t: str(t.uuid) == uuid, tickets)) - if found: - in_page = True - else: - # if it's not, switch our folder to everything with that ticket's state - ticket = org.tickets.filter(uuid=uuid).first() if ticket: - folder = AllFolder.slug - status = "open" if ticket.status == Ticket.STATUS_OPEN else "closed" + folder = all_folder + status = ticket.status - return folder or MineFolder.slug, status or "open", uuid, in_page + return folder, status, ticket, in_page def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - folder, status, uuid, in_page = self.tickets_path - context["folder"] = folder - context["status"] = status - context["has_tickets"] = self.request.org.tickets.exists() + folder, status, ticket, in_page = self.tickets_path - folder = TicketFolder.from_slug(self.request.org, folder) context["title"] = folder.name + context["folder"] = folder.slug + context["status"] = "open" if status == Ticket.STATUS_OPEN else "closed" + context["has_tickets"] = self.request.org.tickets.exists() - if uuid: - context["nextUUID" if in_page else "uuid"] = uuid + if ticket: + context["nextUUID" if in_page else "uuid"] = str(ticket.uuid) return context def build_context_menu(self, menu): - uuid = self.kwargs.get("uuid") - if uuid: - ticket = self.request.org.tickets.filter(uuid=uuid).first() - if ticket: - if ticket.status == Ticket.STATUS_OPEN: - if self.has_org_perm("tickets.ticket_update"): - menu.add_modax( - _("Edit"), - "edit-ticket", - f"{reverse('tickets.ticket_update', args=[ticket.uuid])}", - title=_("Edit Ticket"), - on_submit="handleTicketEditComplete()", - ) - - if self.has_org_perm("tickets.ticket_note"): - menu.add_modax( - _("Add Note"), - "add-note", - f"{reverse('tickets.ticket_note', args=[ticket.uuid])}", - on_submit="handleNoteAdded()", - ) - - # we don't want to show start flow if interrupt was given as an option - interrupt_added = False - if self.has_org_perm("contacts.contact_interrupt") and ticket.contact.current_flow: - menu.add_url_post( - _("Interrupt"), reverse("contacts.contact_interrupt", args=(ticket.contact.id,)) - ) - interrupt_added = True - - if not interrupt_added and self.has_org_perm("flows.flow_start"): - menu.add_modax( - _("Start Flow"), - "start-flow", - f"{reverse('flows.flow_start')}?c={ticket.contact.uuid}", - disabled=True, - on_submit="handleFlowStarted()", - ) + folder, status, ticket, in_page = self.tickets_path + + if ticket and ticket.status == Ticket.STATUS_OPEN: + if self.has_org_perm("tickets.ticket_update"): + menu.add_modax( + _("Edit"), + "edit-ticket", + f"{reverse('tickets.ticket_update', args=[ticket.uuid])}", + title=_("Edit Ticket"), + on_submit="handleTicketEditComplete()", + ) + + if self.has_org_perm("tickets.ticket_note"): + menu.add_modax( + _("Add Note"), + "add-note", + f"{reverse('tickets.ticket_note', args=[ticket.uuid])}", + on_submit="handleNoteAdded()", + ) + + if ticket.contact.current_flow: + if self.has_org_perm("contacts.contact_interrupt"): + menu.add_url_post( + _("Interrupt"), reverse("contacts.contact_interrupt", args=(ticket.contact.id,)) + ) + else: + if self.has_org_perm("flows.flow_start"): + menu.add_modax( + _("Start Flow"), + "start-flow", + f"{reverse('flows.flow_start')}?c={ticket.contact.uuid}", + disabled=True, + on_submit="handleFlowStarted()", + ) def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).none() @@ -374,7 +376,7 @@ def get_queryset(self, **kwargs): # fetching new activity gets a different order later ordered = False if after else True - qs = self.folder.get_queryset(org, user, ordered).filter(status=status) + qs = self.folder.get_queryset(org, user, ordered=ordered).filter(status=status) # all new activity after = int(self.request.GET.get("after", 0)) @@ -395,7 +397,7 @@ def get_queryset(self, **kwargs): if count == self.paginate_by: last_ticket = qs[len(qs) - 1] - qs = self.folder.get_queryset(org, user, ordered).filter( + qs = self.folder.get_queryset(org, user, ordered=ordered).filter( status=status, last_activity_on__gte=last_ticket.last_activity_on ) From 46ec65ef1e82d6c875d6227f46489b0bf739a27f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 29 Oct 2024 19:50:00 +0000 Subject: [PATCH 267/557] Add query checks to ticket view tests and fix missing prefetches --- temba/tickets/tests.py | 7 ++++++- temba/tickets/views.py | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 54f2c8cc341..189198c3099 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -603,6 +603,9 @@ def test_list(self): # we have a specific ticket so we should show context menu for it self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) + with self.assertNumQueries(11): + self.client.get(deep_link) + # try to link to our ticket but with mismatched status deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" @@ -740,7 +743,9 @@ def assert_tickets(resp, tickets: list): self.create_outgoing_msg(contact3, "Yes", created_by=self.agent) # fetching open folder returns all open tickets - response = self.client.get(open_url) + with self.assertNumQueries(12): + response = self.client.get(open_url) + assert_tickets(response, [c2_t1, c1_t2, c1_t1]) joes_open_tickets = contact1.tickets.filter(status="O").order_by("-opened_on") diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 08f5379fc04..201f80fc8a6 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -422,7 +422,7 @@ def get_context_data(self, **kwargs): last_msg_ids = Msg.objects.filter(contact_id__in=contact_ids).values("contact").annotate(last_msg=Max("id")) last_msgs = Msg.objects.filter(id__in=[m["last_msg"] for m in last_msg_ids]).select_related("created_by") - context["last_msgs"] = {m.contact: m for m in last_msgs} + context["last_msgs"] = {m.contact_id: m for m in last_msgs} return context def render_to_response(self, context, **response_kwargs): @@ -446,10 +446,10 @@ def as_json(t): """ Converts a ticket to the contact-centric format expected by our frontend components """ - last_msg = context["last_msgs"].get(t.contact) + last_msg = context["last_msgs"].get(t.contact_id) return { "uuid": str(t.contact.uuid), - "name": t.contact.get_display(), + "name": t.contact.get_display(org=self.request.org), "last_seen_on": t.contact.last_seen_on, "last_msg": msg_as_json(last_msg) if last_msg else None, "ticket": { From 32153b8373f298e50c6310786bc886818b240998 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 29 Oct 2024 21:56:24 +0000 Subject: [PATCH 268/557] Implement filtering of tickets by accessible topics --- temba/tickets/models.py | 8 +++-- temba/tickets/tests.py | 73 ++++++++++++++++++++++++++++++++++------- temba/tickets/views.py | 14 ++++---- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index f1f32bf006a..95274449040 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -304,15 +304,19 @@ class TicketFolder(metaclass=ABCMeta): def get_queryset(self, org, user, *, ordered: bool): qs = org.tickets.all() + membership = org.get_membership(user) + if membership.team and not membership.team.all_topics: + qs = qs.filter(topic__in=list(membership.team.topics.all())) + if ordered: qs = qs.order_by("-last_activity_on", "-id") return qs.select_related("topic", "assignee").prefetch_related("contact") @classmethod - def from_slug(cls, org, slug_or_uuid: str): + def from_slug(cls, org, user, slug_or_uuid: str): if is_uuid(slug_or_uuid): - topic = org.topics.filter(uuid=slug_or_uuid, is_active=True).first() + topic = Topic.get_accessible(org, user).filter(uuid=slug_or_uuid).first() if topic: return TopicFolder(topic) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 189198c3099..6bb786c12d3 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -581,19 +581,36 @@ def setUp(self): super().setUp() self.contact = self.create_contact("Bob", urns=["twitter:bobby"]) + self.sales = Topic.create(self.org, self.admin, "Sales") + self.support = Topic.create(self.org, self.admin, "Support") + + # create other agent users in teams with limited topic access + self.agent2 = self.create_user("agent2@nyaruka.com") + sales_only = Team.create(self.org, self.admin, "Sales", topics=[self.sales]) + self.org.add_user(self.agent2, OrgRole.AGENT, team=sales_only) + + self.agent3 = self.create_user("agent3@nyaruka.com") + support_only = Team.create(self.org, self.admin, "Support", topics=[self.support]) + self.org.add_user(self.agent3, OrgRole.AGENT, team=support_only) def test_list(self): list_url = reverse("tickets.ticket_list") - ticket = self.create_ticket(self.contact, assignee=self.admin) + + ticket = self.create_ticket(self.contact, assignee=self.admin, topic=self.support) # just a placeholder view for frontend components self.assertRequestDisallowed(list_url, [None]) - self.assertListFetch(list_url, [self.user, self.editor, self.admin, self.agent], context_objects=[]) + self.assertListFetch( + list_url, [self.user, self.editor, self.admin, self.agent, self.agent2, self.agent3], context_objects=[] + ) # link to our ticket within the All folder - deep_link = f"{list_url}all/open/{str(ticket.uuid)}/" + deep_link = f"{list_url}all/open/{ticket.uuid}/" - response = self.assertListFetch(deep_link, [self.user, self.editor, self.admin, self.agent], context_objects=[]) + response = self.assertListFetch( + deep_link, [self.user, self.editor, self.admin, self.agent, self.agent3], context_objects=[] + ) + self.assertEqual("All", response.context["title"]) self.assertEqual("all", response.context["folder"]) self.assertEqual("open", response.context["status"]) @@ -606,12 +623,39 @@ def test_list(self): with self.assertNumQueries(11): self.client.get(deep_link) - # try to link to our ticket but with mismatched status - deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" + # try same request but for agent that can't see this ticket + response = self.assertListFetch(deep_link, [self.agent2], context_objects=[]) + self.assertEqual("All", response.context["title"]) + self.assertEqual("all", response.context["folder"]) + self.assertEqual("open", response.context["status"]) + self.assertNotIn("nextUUID", response.context) + + # can also link to our ticket within the Support topic + deep_link = f"{list_url}{self.support.uuid}/open/{ticket.uuid}/" + + self.assertRequestDisallowed(deep_link, [self.agent2]) # doesn't have access to that topic + + response = self.assertListFetch( + deep_link, [self.user, self.editor, self.admin, self.agent, self.agent3], context_objects=[] + ) + self.assertEqual("Support", response.context["title"]) + self.assertEqual(str(self.support.uuid), response.context["folder"]) + self.assertEqual("open", response.context["status"]) + # try to link to our ticket but with mismatched topic + deep_link = f"{list_url}{self.sales.uuid}/closed/{str(ticket.uuid)}/" + + # redirected to All response = self.assertListFetch(deep_link, [self.agent], context_objects=[]) + self.assertEqual("all", response.context["folder"]) + self.assertEqual("open", response.context["status"]) + self.assertEqual(str(ticket.uuid), response.context["uuid"]) + + # try to link to our ticket but with mismatched status + deep_link = f"{list_url}all/closed/{ticket.uuid}/" # now our ticket is listed as the uuid and we were redirected to All folder with Open status + response = self.assertListFetch(deep_link, [self.agent], context_objects=[]) self.assertEqual("all", response.context["folder"]) self.assertEqual("open", response.context["status"]) self.assertEqual(str(ticket.uuid), response.context["uuid"]) @@ -620,7 +664,7 @@ def test_list(self): self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) # non-existent topic should give a 404 - bad_topic_link = f"{list_url}{uuid4()}/open/{str(ticket.uuid)}/" + bad_topic_link = f"{list_url}{uuid4()}/open/{ticket.uuid}/" response = self.requestView(bad_topic_link, self.agent) self.assertEqual(404, response.status_code) @@ -629,7 +673,6 @@ def test_list(self): content_type="application/json", HTTP_TEMBA_REFERER_PATH=f"/tickets/mine/open/{ticket.uuid}", ) - self.assertEqual(("tickets", "mine", "open", str(ticket.uuid)), response.context["temba_referer"]) # contacts in a flow get interrupt menu option instead @@ -665,7 +708,7 @@ def test_menu(self): menu_url = reverse("tickets.ticket_menu") self.create_ticket(self.contact, assignee=self.admin) - self.create_ticket(self.contact, assignee=self.admin) + self.create_ticket(self.contact, assignee=self.admin, topic=self.sales) self.create_ticket(self.contact, assignee=None) self.create_ticket(self.contact, closed_on=timezone.now()) @@ -680,10 +723,18 @@ def test_menu(self): "Shortcuts (0)", "Export", "New Topic", - "General (3)", + "General (2)", + "Sales (1)", + "Support (0)", ], ) - self.assertPageMenu(menu_url, self.agent, ["My Tickets (0)", "Unassigned (1)", "All (3)", "General (3)"]) + self.assertPageMenu( + menu_url, + self.agent, + ["My Tickets (0)", "Unassigned (1)", "All (3)", "General (2)", "Sales (1)", "Support (0)"], + ) + self.assertPageMenu(menu_url, self.agent2, ["My Tickets (0)", "Unassigned (1)", "All (3)", "Sales (1)"]) + self.assertPageMenu(menu_url, self.agent3, ["My Tickets (0)", "Unassigned (1)", "All (3)", "Support (0)"]) @mock_mailroom def test_folder(self, mr_mocks): diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 201f80fc8a6..ccb03ce0c38 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -245,8 +245,11 @@ def tickets_path(self) -> tuple[TicketFolder, str, Ticket, bool]: Returns tuple of folder, status, ticket, and whether that ticket exists in first page of tickets """ + org = self.request.org + user = self.request.user + # get requested folder, defaulting to Mine - folder = TicketFolder.from_slug(self.request.org, self.kwargs.get("folder", MineFolder.slug)) + folder = TicketFolder.from_slug(org, user, self.kwargs.get("folder", MineFolder.slug)) if not folder: raise Http404() @@ -256,9 +259,6 @@ def tickets_path(self) -> tuple[TicketFolder, str, Ticket, bool]: # is the request for a specific ticket? if uuid := self.kwargs.get("uuid"): - org = self.request.org - user = self.request.user - # is the ticket in the first page from of current folder? for t in list(folder.get_queryset(org, user, ordered=True).filter(status=status)[:25]): if str(t.uuid) == uuid: @@ -268,7 +268,7 @@ def tickets_path(self) -> tuple[TicketFolder, str, Ticket, bool]: # if not, see if we can access it in the All tickets folder and if so switch to that if not in_page: - all_folder = TicketFolder.from_slug(self.request.org, AllFolder.slug) + all_folder = TicketFolder.from_slug(org, user, AllFolder.slug) ticket = all_folder.get_queryset(org, user, ordered=False).filter(uuid=uuid).first() if ticket: @@ -283,7 +283,7 @@ def get_context_data(self, **kwargs): folder, status, ticket, in_page = self.tickets_path context["title"] = folder.name - context["folder"] = folder.slug + context["folder"] = str(folder.slug) context["status"] = "open" if status == Ticket.STATUS_OPEN else "closed" context["has_tickets"] = self.request.org.tickets.exists() @@ -342,7 +342,7 @@ def derive_url_pattern(cls, path, action): @cached_property def folder(self) -> TicketFolder: - folder = TicketFolder.from_slug(self.request.org, self.kwargs["folder"]) + folder = TicketFolder.from_slug(self.request.org, self.request.user, self.kwargs["folder"]) if not folder: raise Http404() From 04b3578e2664603dfc008ee53b270b5987cd1514 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 30 Oct 2024 10:22:35 +0200 Subject: [PATCH 269/557] Fix scrolling for contact group pages --- static/css/frame.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/frame.css b/static/css/frame.css index 18327a68bd9..7fb6589a3c2 100644 --- a/static/css/frame.css +++ b/static/css/frame.css @@ -155,7 +155,7 @@ temba-menu:defined { .spa-container { background: #f7f7f7; overflow-y: clip; - overflow-x: visible; + overflow-x: auto; } .spa-container.loading .spa-content { From 49d3751d90eb1c8c379a75b9ffd2bbc0e0fb3307 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 30 Oct 2024 10:53:00 +0200 Subject: [PATCH 270/557] Show contact proxy fields on the group pages --- temba/contacts/tests.py | 3 +++ temba/contacts/views.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 65a629d6766..32f7e0fc837 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -426,6 +426,9 @@ def test_group(self, mr_mocks): self.assertEqual([frank, joe], list(response.context["object_list"])) self.assertEqual(["block", "unlabel", "send", "start-flow"], list(response.context["actions"])) + self.assertEqual( + [f.name for f in response.context["contact_fields"]], ["Home", "Age", "Last Seen On", "Created On"] + ) self.assertContentMenu( group1_url, diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 8f358f07088..77f374f0276 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -632,7 +632,11 @@ def get_context_data(self, *args, **kwargs): org = self.request.org context["current_group"] = self.group - context["contact_fields"] = ContactField.get_fields(org).order_by("-priority", "id") + + fields = ContactField.get_fields(org).order_by("-priority", "id") + proxy_fields = org.fields.filter(key__in=("last_seen_on", "created_on"), is_proxy=True).order_by("-key") + context["contact_fields"] = list(fields) + list(proxy_fields) + return context @classmethod From 36a4aebc3a0e1766195c55b07688fe8484b7e3df Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 13:47:44 +0000 Subject: [PATCH 271/557] ContactCRUDL.Group should use contact_fields from ContactListView --- temba/contacts/views.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 77f374f0276..053c3e9f479 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -629,14 +629,7 @@ def get_bulk_actions(self): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - org = self.request.org - context["current_group"] = self.group - - fields = ContactField.get_fields(org).order_by("-priority", "id") - proxy_fields = org.fields.filter(key__in=("last_seen_on", "created_on"), is_proxy=True).order_by("-key") - context["contact_fields"] = list(fields) + list(proxy_fields) - return context @classmethod From 86d7d510ab5deb592bb927187d6e7ba5cd383848 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 09:02:17 -0500 Subject: [PATCH 272/557] Update CHANGELOG.md for v9.3.83 --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6e535b70b..87079ea230d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +v9.3.83 (2024-10-30) +------------------------- + * Merge pull request #5596 from nyaruka/group_page_tweak + * ContactCRUDL.Group should use contact_fields from ContactListView + * Merge pull request #5595 from nyaruka/group-page-proxy-fields + * Show contact proxy fields on the group pages + * Fix scrolling for contact group pages + * Merge pull request #5592 from nyaruka/ticket_queries + * Add query checks to ticket view tests and fix missing prefetches + * Merge pull request #5591 from nyaruka/ticket_cleanup + * Some cleanup to tickets list view + * Tweak ordering of ticket crudl views for consistency with other crudls + v9.3.82 (2024-10-29) ------------------------- * Make teams an org feature.. that nobody has for now diff --git a/pyproject.toml b/pyproject.toml index 4b01c56f270..adc0d9c8ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.82" +version = "9.3.83" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 9f1e6a076e3..06658ff9f30 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.82" +__version__ = "9.3.83" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 0789d67e199e7e735874232538e142b82914edb1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 09:04:20 -0500 Subject: [PATCH 273/557] Cleanup CHANGELOG --- CHANGELOG.md | 20965 ++++++++++++++++++++++++------------------------- 1 file changed, 10480 insertions(+), 10485 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87079ea230d..7475e183e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10487 +1,10481 @@ -v9.3.83 (2024-10-30) -------------------------- - * Merge pull request #5596 from nyaruka/group_page_tweak - * ContactCRUDL.Group should use contact_fields from ContactListView - * Merge pull request #5595 from nyaruka/group-page-proxy-fields - * Show contact proxy fields on the group pages - * Fix scrolling for contact group pages - * Merge pull request #5592 from nyaruka/ticket_queries - * Add query checks to ticket view tests and fix missing prefetches - * Merge pull request #5591 from nyaruka/ticket_cleanup - * Some cleanup to tickets list view - * Tweak ordering of ticket crudl views for consistency with other crudls - -v9.3.82 (2024-10-29) -------------------------- - * Make teams an org feature.. that nobody has for now - * Some cleanup to topic crudl and ticket folders - * Tweak name/url of contact group filter list page - * Filter topics in topic selection menu based on team membership - * Add basic team CRUDL views - -v9.3.81 (2024-10-28) -------------------------- - * Fix N+1 query on contact list page - * Cleanup more list pages and move more functionality to org/base views - -v9.3.80 (2024-10-24) -------------------------- - * Add migration to assign teamless agents to the default team - * Prevent deletion of system teams - * Assign new agent users to the default team if team not specified - * Data migration to give existing orgs a default team - -v9.3.79 (2024-10-24) -------------------------- - * Add max length of 10,000 to shortcut text - * Give every workspace a default team with access to all topics - * Change delete links on list views to be clearer - -v9.3.78 (2024-10-23) -------------------------- - * Use django filter to format archive size - * Fix paging on archive list pages and make styling consistent with other list views - * Add Team.all_topics to more easily model a team that can access all topics - * Remove styles from contact field list page that are no longered used since it became a placeholder for the field management component - * Convert API tokens page to be real list page - * Make some list pages use a common template - -v9.3.77 (2024-10-22) -------------------------- - * Update django - * Update to python 3.12 - * Simplify bulk labeling of msgs and flows - * Remove unused code from MsgCRUDL.Menu and add test - -v9.3.76 (2024-10-18) -------------------------- - * Fix agents shortcuts permission - -v9.3.75 (2024-10-17) -------------------------- - * Add Shortcuts UI - * Normal menu navigation for tickets - -v9.3.74 (2024-10-17) -------------------------- - * Remove pre-spa days code from flow list view - * Add more clarifications to FreshChat claim page - * Cleanup channel claim pages with steps - -v9.3.73 (2024-10-17) -------------------------- - * Overhaul UI for managing child workspaces - -v9.3.72 (2024-10-17) -------------------------- - * Move org service view to staff app - * Drop Invitation.user_group and UserSettings.team - -v9.3.71 (2024-10-17) -------------------------- - * Fix invitations count on org menu to exclude expired invitations - -v9.3.70 (2024-10-16) -------------------------- - * Data migration to set Invitation.role_code - -v9.3.69 (2024-10-16) -------------------------- - * Fix how we model team membership so that users can belong to different teams in different workspaces - -v9.3.68 (2024-10-16) -------------------------- - * Tweak user update and delete forms to return 404 for users not in the current org - -v9.3.67 (2024-10-16) -------------------------- - * New CRUDL views for org users and invitations - -v9.3.66 (2024-10-16) -------------------------- - * Fix displaying the channel log missing HTTP response - * Fix claim number to display non field errors - * Remove support for user management of sub-orgs without switching to those orgs - -v9.3.65 (2024-10-10) -------------------------- - * Add mixin for views that require a feature - -v9.3.64 (2024-10-09) -------------------------- - * Fix modal for deleting a shortcut - * Tweak list view templates for consistency - * Data migration to tweak names of existing status groups - -v9.3.63 (2024-10-09) -------------------------- - * Create status groups with invalid names to avoid conflicts with real group names - * Bump django from 5.1 to 5.1.1 - -v9.3.62 (2024-10-08) -------------------------- - * Fix double character rendering on autogrow inputs - -v9.3.61 (2024-10-08) -------------------------- - * Move staff only rg and user views to new staff app - -v9.3.60 (2024-10-08) -------------------------- - * Improve invitation emails - -v9.3.59 (2024-10-08) -------------------------- - * Fix not creating invitation accepted notifications in case of new user signup - -v9.3.58 (2024-10-08) -------------------------- - * Use mailroom to trigger android channel sync - * Add new notification type for when an invitation to join a workspace is accepted - * More refactoring of modal views - -v9.3.57 (2024-10-04) -------------------------- - * More view refactoring - -v9.3.56 (2024-10-03) -------------------------- - * Cleanup some view mixins - -v9.3.55 (2024-10-03) -------------------------- - * Temporarily hide menu item for shortcuts - * Add pagination to flow starts and webhook logs pages - * Add internal API endpoint for fetching shortcuts - * Add model and CRUDL views for ticket shortcuts - * Fix topic create and update and tweak list pages for consistency - -v9.3.54 (2024-10-02) -------------------------- - * Adjust background flow start preview to include all contacts in other flows - * Make template sync use consistent components order to avoid breaking flows variables - -v9.3.53 (2024-10-01) -------------------------- - * Fix location aliases to only update in one workspace - -v9.3.52 (2024-09-30) -------------------------- - * Add test_errors to mailroom client - -v9.3.51 (2024-09-27) -------------------------- - * Update components with progress bar tweaks - -v9.3.50 (2024-09-27) -------------------------- - * Add commas for broadcast message count - -v9.3.49 (2024-09-26) -------------------------- - * Tweak deindexing a deleted contact - -v9.3.48 (2024-09-26) -------------------------- - * Use 10th anniversary rp logo - * Explicitly de-index contacts when released - * Request de-indexing of contacts when hard deleting an org - * Switch to flowstart_list permission for status - * Add status and interrupt for broadcasts and starts - -v9.3.47 (2024-09-25) -------------------------- - * Re-introduce QUEUED status for FlowStarts and Broadcasts - * Remove progress field from flow starts endpoint docs - -v9.3.46 (2024-09-23) -------------------------- - * Add progress field to broadcasts API endpoint - * Add Broadcast.interrupt(user) - -v9.3.45 (2024-09-23) -------------------------- - * Add PENDING/STARTED statuses and contact_count field to broadcasts - -v9.3.44 (2024-09-23) -------------------------- - * Validate channel variable in the body for EX channels - * Replace broadcast status S with C - -v9.3.43 (2024-09-19) -------------------------- - * Add support broadcast status (C)COMPLETED - * Remove broadcasts from Outbox now that they have their own page - * Put starts before webhooks on flow history menu - -v9.3.42 (2024-09-18) -------------------------- - * Cleanup how we read and anonymize channel logs - -v9.3.41 (2024-09-18) -------------------------- - * Limit SetRunResult category length in editor - * Add --testing argument to migrate_dynamo command - * Start reading attached channel logs from DynamoDB instead of S3 - -v9.3.40 (2024-09-17) -------------------------- - * Add INTERRUPTED as a status for flow starts - * Switch flow starting blocker to warning - -v9.3.39 (2024-09-17) -------------------------- - * Show bad import file error as validation errors to the user - * Fix flow start progress bar with high pcts - * Simplify outbox limit to be hardcoded at 1M - * Validate body for EX channel type will be valid JSON after replacing variables - -v9.3.38 (2024-09-14) -------------------------- - * Add flow start progress bar - -v9.3.37 (2024-09-13) -------------------------- - * Fix import read page title - * Fix importing contacts from spreadsheet with broken dimensions - * Fix TTL attribute name on DynamoDB channel logs table - -v9.3.36 (2024-09-12) -------------------------- - * Use 'tasks:batch' queue name instead of 'batch' - -v9.3.35 (2024-09-12) -------------------------- - * Add progress field to flow starts endpoint - -v9.3.34 (2024-09-11) -------------------------- - * Add timing controls around flow starts - -v9.3.33 (2024-09-11) -------------------------- - * Rename dynamodb channel logs table - -v9.3.32 (2024-09-07) -------------------------- - * Add outbox monitor for large queues - -v9.3.31 (2024-09-05) -------------------------- - * Add an org limit for too many messages in outbox - -v9.3.30 (2024-09-02) -------------------------- - * Import cell data value instead of formulas using data_only flag to load the workbook - -v9.3.29 (2024-08-27) -------------------------- - * Fix authorization code, verification, redirect URI - -v9.3.28 (2024-08-27) -------------------------- - * Authorization code cannot be debugged - * Fix channel URLs to have a trailing slash - * Delete no longer used test flows - * Simplify functions for loading flows in tests and move flows used by legacy migration tests into their own directory - * TembaTest.create_flow should return a flow in latest version without migrating - * Only import real flows in tests where it's required - * Update README.md - -v9.3.27 (2024-08-21) -------------------------- - * Updates to migrate_dynamo command - -v9.3.26 (2024-08-21) -------------------------- - * Add redirect for contact interrupt - * Create dynamo table with on-demand billing by default - -v9.3.25 (2024-08-21) -------------------------- - * Fix matching for invites with email case insensitively - * Tweak migrate_dynamo command - -v9.3.24 (2024-08-20) -------------------------- - * Add dynamo table prefix setting - -v9.3.23 (2024-08-20) -------------------------- - * Add management command to create DynamoDB tables - * Add option for connection pooling - -v9.3.22 (2024-08-19) -------------------------- - * Drop APIToken.role field - -v9.3.21 (2024-08-19) -------------------------- - * Use correct URL when breaking spa-container - * Delete API tokens when user deleted and use generate_secret to create new tokens - * Update API token management UI to support multiple tokens - -v9.3.20 (2024-08-14) -------------------------- - * Rework S3 code to always use real S3 clients, even in tests - -v9.3.19 (2024-08-14) -------------------------- - * Fix DTOne formax section - * Change default settings to use minio for file storage - -v9.3.18 (2024-08-13) -------------------------- - * Record when API tokens were last used - * Only support import contacts using .xlsx files with openpyxl - -v9.3.17 (2024-08-12) -------------------------- - * Data migration to delete old surveyor and prometheus API tokens - -v9.3.16 (2024-08-08) -------------------------- - * Stop generating prometheus API tokens - * Drop Ticket.body - -v9.3.15 (2024-08-08) -------------------------- - * Add Org.prometheus_token and backill from API tokens - -v9.3.14 (2024-08-08) -------------------------- - * Update tests to not set ticket body - * Add data migration to move body to ticket on open ticket event - -v9.3.13 (2024-08-08) -------------------------- - * Show notes on ticket open events in contact history - * Remove body from ticket endpoint documentation - * Update floweditor which now also refers to ticket body as note - * Update open ticket modal to use note instead of body - * Add cutoff date for using viewer role - -v9.3.12 (2024-08-07) -------------------------- - * Don't create surveyor user in mailroom test db - * Add warning to manage accounts page if org has viewers - * Remove viewers as an org feature, only allow existing viewer users to remain as viewers - * Update to latest Django - -v9.3.11 (2024-08-07) -------------------------- - * Remove Org.surveyor_password and always disable creating surveyor flows - * Remove non-modal response support from export translation view - * Remove surveyor user role and test user - -v9.3.10 (2024-08-07) -------------------------- - * Remove surveyor users from workspaces - -v9.3.9 (2024-08-07) -------------------------- - * Fix incidents templates name - * Let Ticket.body be null and make note length match contact note length - -v9.3.8 (2024-08-06) -------------------------- - * Show tabs on tickets when contact is set - -v9.3.7 (2024-08-06) -------------------------- - * Add contact notes ui - -v9.3.6 (2024-08-06) -------------------------- - * Adjust the grant view for new UI - * Fix Android claim page - * Add incident for Android client app version out of date - * Tweak fail_old_messages to only fail Android messages and add an index - -v9.3.5 (2024-07-31) -------------------------- - * Support FCM changes - * Require E164 phone numbers for contacts created from UI - -v9.3.4 (2024-07-30) -------------------------- - * Add contact notes and expose over contacts API endpoint - -v9.3.3 (2024-07-29) -------------------------- - * Clamp messages on message views to one line - * Adjust max length for AT API key - * Make 'New Field' a button - -v9.3.2 (2024-07-29) -------------------------- - * Allow deleting of empty ticket topics - * Add support for buttons in side menu and use where appropriate - -v9.3.0 (2024-07-25) -------------------------- - * Add User.get_by_email to ensure consistent behaviour where we look up a user by their email - * Omnibox fixes and cleanup - -v9.2.5 (2024-07-24) -------------------------- - * Ensure that emails are consistently treated as case insensitive - -v9.2.4 (2024-07-23) -------------------------- - * Simplify FCM config setting names - -v9.2.3 (2024-07-23) -------------------------- - * More updates to WhatsApp claiming - -v9.2.2 (2024-07-23) -------------------------- - * Fix WhatsApp embedded signup - -v9.2.1 (2024-07-18) -------------------------- - * Catch errors from xlrd reading import rows and return errors with row numbers - * Update xlrd - * Honor meta key keyboard press inside contact chat - -v9.2.0 (2024-07-17) -------------------------- - * Simplify permissions in flows app - * Tweak menu items for msg views and flow results - -v9.1.198 (2024-07-17) -------------------------- - * Allow template image variables to be text with expressions - -v9.1.196 (2024-07-16) -------------------------- - * Add __repr__ to more models and tweak existing ones for consistency - * Fix rendering of flow starts for deleted flows - * Add data migration to trim old broadcasts to nodes that resulted in very large contact lists - -v9.1.195 (2024-07-16) -------------------------- - * Remove special error handling for broadcast to node that resolves to no recipients - * Fix setting a template on a new broadcast - * Fix query broadcast creation and update - * Add rendering of exclusions on broadcasts - * Fix not showing query on broadcast recipients list and add node_uuid - -v9.1.194 (2024-07-15) -------------------------- - * Add Broadcast.node_uuid field - * Remove old code for getting message created_by from broadcasts - * Make some exception clauses more specific - -v9.1.193 (2024-07-15) -------------------------- - * Replace TemplateTranslation.STATUS_UNSUPPORTED completely - -v9.1.192 (2024-07-15) -------------------------- - * Add new template statuses and stop using fake "unsupported" status - -v9.1.191 (2024-07-15) -------------------------- - * Fix deactivating a legacy WhatsApp channel - * Update format of templates on API endpoint - * Show template translation problems as errors on template read page - -v9.1.190 (2024-07-12) -------------------------- - * Fix padding for broadcast schedule update - -v9.1.189 (2024-07-12) -------------------------- - * Fix mailroom_db - * Data migration to populate TemplateTranslation.is_supported and is_compatible - -v9.1.188 (2024-07-12) -------------------------- - * Add new boolean fields to TemplateTranslation model to determine whether it's usable - -v9.1.187 (2024-07-12) -------------------------- - * Add templates to broadcasts - -v9.1.186 (2024-07-11) -------------------------- - * Fix handling of POSTs to API docs - * Exclude empty templates from list, and show base translation apart on read page - * Ensure we choose a new base for a template whenever an existing base translation is deleted - -v9.1.185 (2024-07-11) -------------------------- - * Update deps - * Replace telegram library by requests use - * Fix dashboard menu link permission - * Expose Template.base_translation on API endpoint - -v9.1.184 (2024-07-11) -------------------------- - * Use dropdowns for location fields - -v9.1.183 (2024-07-11) -------------------------- - * Use dropdowns for location fields - -v9.1.182 (2024-07-10) -------------------------- - * Locations API endpoint should allow searching on the path - * Fix template syncing when channel gives us invalid template data - -v9.1.181 (2024-07-10) -------------------------- - * Add Template.base_translation - * Fix dashboard workspace data - * Allow creation of contacts with non-active statuses - -v9.1.180 (2024-07-10) -------------------------- - * Drop no longer used is_active field from TemplateTranslation - * Tweak wording on template list page - * Add db constraint to ensure contact status is valid - -v9.1.179 (2024-07-10) -------------------------- - * Keep FCM ID in channel config when soft deleting the channel - * Stop using TemplateTranslation.is_active and make nullable - -v9.1.178 (2024-07-09) -------------------------- - * Allow broadcast creation with zero matches - -v9.1.177 (2024-07-08) -------------------------- - * Hard delete remaining soft-deleted template translations - -v9.1.176 (2024-07-08) -------------------------- - * Update Template to a TembaModel - * Hard delete template translations that no longer exist on the channel side - -v9.1.175 (2024-07-05) -------------------------- - * Make send_when optional when updating broadcasts - -v9.1.174 (2024-07-05) -------------------------- - * Fix updating scheduled broadcasts - * Remove old unused code for queueing broadcasts - -v9.1.173 (2024-07-05) -------------------------- - * Add Msg.is_android field - * Add internal API endpoint for searching locations by level and name - * Remove option to send now on broadcast update - -v9.1.172 (2024-07-04) -------------------------- - * Add templates to broadcasts (hidden for now) - * Remove deprecated broadcast.template_state field on mailroom queue payload - -v9.1.171 (2024-07-03) -------------------------- - * Update payload for queueing a bradocast - -v9.1.170 (2024-07-03) -------------------------- - * Remove no longer needed task to sync stale Android relayers - * Don't allow template localization - * Update dependencies - -v9.1.169 (2024-07-02) -------------------------- - * Use python 3.11.x - * Add Broadcast.template_variables - * Add new template list and read pages and remove old channel specific ones - * Fix globals list template - -v9.1.168 (2024-06-28) -------------------------- - * Don't sync classifiers in suspended orgs - * Fix empty contact search with query present - -v9.1.167 (2024-06-28) -------------------------- - * Disallow empty recipient targeting - * Fix external links within spa container - -v9.1.166 (2024-06-27) -------------------------- - * Tweak logging for failure during classifier syncing - * Switch broadcast tests to use contact search - -v9.1.165 (2024-06-27) -------------------------- - * Rework remaining mailroom client methods - * Add unique constraint on template translations - -v9.1.164 (2024-06-27) -------------------------- - * Add data migration to remove duplicate template translations - -v9.1.163 (2024-06-27) -------------------------- - * Change template translation syncing to enforce uniqueness over channel+locale - -v9.1.162 (2024-06-27) -------------------------- - * Make templatetranslation locale non-null - * Add migration to release translations for released channels - -v9.1.161 (2024-06-27) -------------------------- - * Fix not releasing template translations when channel released - -v9.1.160 (2024-06-27) -------------------------- - * Fix creating scheduled broadcasts - * Tweak menu on campaign read page - * Update to latest smartmin - -v9.1.159 (2024-06-26) -------------------------- - * Simplify some button labels and make edit a button on contact read page - * Don't show empty contact filter list - * Rework more mailroom client methods to use models instead of primitives - -v9.1.158 (2024-06-26) -------------------------- - * Add day selection when doing flow start search - * Tweak mailroom_db to run on different port - -v9.1.157 (2024-06-25) -------------------------- - * Reorg of mailroom client - * Add Broadcast.exclusions - -v9.1.156 (2024-06-24) -------------------------- - * Change broadcast creation from UI to use mailroom - -v9.1.155 (2024-06-24) -------------------------- - * Fix WAC to addEventListener in OnSpload - * Fix horizontal scrolling for contacts list - * Add Broadcast.template - -v9.1.154 (2024-06-21) -------------------------- - * Fix z-index issue properly - -v9.1.153 (2024-06-21) -------------------------- - * Fix z-index issue with content menu and chat - -v9.1.152 (2024-06-21) -------------------------- - * Fix ticket switching bug - -v9.1.151 (2024-06-21) -------------------------- - * Update chat rendering - -v9.1.148 (2024-06-20) -------------------------- - * Fix Broadcast.create - -v9.1.147 (2024-06-20) -------------------------- - * Use mailroom to create broadcasts from API calls - * Use mailroom to send broadcasts to flow nodes - -v9.1.146 (2024-06-17) -------------------------- - * Don't clip footer when ticket history grows - * Fix migration to add uuid field to airtime transfers - -v9.1.145 (2024-06-17) -------------------------- - * Don't send forgot password email if one was sent in last 5 minutes - * Delete failed login records on successful password reset - * Make transer UUID unique field, use TembaUUIDMixin on model - -v9.1.144 (2024-06-14) -------------------------- - * Add pagination on channel templates page - * Add settings config for Android clients FCM config - * Remove pyfcm and use google auth library to send sync messages for FCM - * Create our own password recovery view - -v9.1.143 (2024-06-12) -------------------------- - * Update smartmin - * Delete recovery tokens when new ones are created or email changed - * Populate airtime transfer uuids - -v9.1.142 (2024-06-12) -------------------------- - * Add AirtimeTransfer.external_id - * Add data migration to cleanup template translations - -v9.1.141 (2024-06-12) -------------------------- - * Update to latest smartmin - * Add uuid field to airtime transfer model - -v9.1.140 (2024-06-12) -------------------------- - * Really actually fix template attachments for real - -v9.1.139 (2024-06-11) -------------------------- - * Fix split issue for template editor - -v9.1.138 (2024-06-10) -------------------------- - * Template editor fix for empty content - * Tweak component types to be header/*, body/* etc - * Support Twilio media in templates - -v9.1.137 (2024-06-10) -------------------------- - * Support WhatsApp templates with header images - * Remove no longer used URN related code - * Generate email verification secret when account created, change when email changed - -v9.1.136 (2024-06-07) -------------------------- - * Add spa mixin to transfer logs views - * Allow editing TWA messaging service SID - * Lean on mailroom for URN validation during contact update - * Some tidy up of the update contact form - -v9.1.135 (2024-06-05) -------------------------- - * Fix login error message styling - * Remove unused JS libs - -v9.1.134 (2024-06-05) -------------------------- - * Contact API endpoint should let mailroom decide if a URN is taken - * Revert "Remove csrf token hidden element not under a form" - -v9.1.133 (2024-06-05) -------------------------- - * Fix API explorer POSTs - * Make CSRF cookie age 2 weeks and remove non-form hidden CSRF hidden elements - -v9.1.132 (2024-06-04) -------------------------- - * Make sure the CSRF element is present for all page header blocks - -v9.1.131 (2024-05-31) -------------------------- - * Fix DT One submit buttons - -v9.1.130 (2024-05-31) -------------------------- - * Fix flow and msgs unlabel action - * Remove no longer used params field on synched whatsapp type templates - -v9.1.129 (2024-05-29) -------------------------- - * Increase DATA_UPLOAD_MAX_NUMBER_FIELDS to 2500 - * Fix FB and IG claim getFBpages - -v9.1.128 (2024-05-27) -------------------------- - * Lean on mailroom for validation of phone numbers from android events / messages - -v9.1.127 (2024-05-27) -------------------------- - * Rework contact create view to let mailroom do URN validation - -v9.1.126 (2024-05-24) -------------------------- - * Mailroom client should use content-type header on responses to know whether to parse as JSON - * Ensure anon users can access API docs - -v9.1.125 (2024-05-23) -------------------------- - * Add csrf on hidden element - -v9.1.124 (2024-05-22) -------------------------- - * Rework handling of errors from mailroom client - * Update test db flows - -v9.1.123 (2024-05-20) -------------------------- - * Replace django messages rendering with toasts - -v9.1.121 (2024-05-16) -------------------------- - * Fix action to remove from group. - * Report bulk action errors to users with django messages - -v9.1.120 (2024-05-16) -------------------------- - * Remove old unused ES sorting code - * Update to latest smartmin and disable auto success messages - * Add data migration to fix system fields for existing orgs and start using is_proxy - * Reduce reserved keys for fields to bare minimum - -v9.1.119 (2024-05-16) -------------------------- - * Add ContactField.is_proxy and reduce SYSTEM_FIELDS to the two proxy date fields - * Don't use error level alerts for form errors - -v9.1.118 (2024-05-15) -------------------------- - * Remove unused args from MailroomClient.parse_query - * Re-add search errors to contact list views - -v9.1.117 (2024-05-15) -------------------------- - * Add support for unknown_property_type search errors - * Add support for twilio card type content templates - * Add way to view webhook logs errors only - -v9.1.116 (2024-05-14) -------------------------- - * Fix issues with twilio templates sync - -v9.1.115 (2024-05-10) -------------------------- - * Fix Twilio template type slug and register its template type - -v9.1.114 (2024-05-10) -------------------------- - * Add message templates menu for TWA channels - * Activate Twilio Whatsapp to sync templates with twilio type - * Update to allow matching sender ID as valid phones - -v9.1.113 (2024-05-09) -------------------------- - * Fix gaps it contact history - -v9.1.112 (2024-05-09) -------------------------- - * Ignore android msg/event cmds with non numeric phones - -v9.1.111 (2024-05-08) -------------------------- - * Send phone instead of urn to mailroom android endpoints - * Add Twilio content template type, and TWA fetch_templates - -v9.1.110 (2024-05-08) -------------------------- - * Remove messages block that duplicates alert-messages - * Tweak DefinitionExport.name for consistency - -v9.1.109 (2024-05-07) -------------------------- - * Tweak export finished emails so they don't say Excel - -v9.1.108 (2024-05-07) -------------------------- - * Update temba-components to 0.86.1 - * Change flow definitions export to be async, use new export type - -v9.1.107 (2024-05-07) -------------------------- - * Fix variable name in http log read page - * Fix claiming instagram - -v9.1.106 (2024-05-06) -------------------------- - * Fix globals API endpoint - -v9.1.105 (2024-05-03) -------------------------- - * Fix race condition on editor load - -v9.1.104 (2024-05-03) -------------------------- - * Fix template bug and loading error for editor - -v9.1.103 (2024-05-02) -------------------------- - * Fix contact field selection - -v9.1.102 (2024-05-02) -------------------------- - * Delete all sessions and runs in org deletion in batches - * Tiny style change for loader wrapping on editor - -v9.1.101 (2024-05-01) -------------------------- - * Update editor and flow spec version - -v9.1.100 (2024-04-29) -------------------------- - * Tweak time limit for sessions to 89 days so things are always interrupted before archiver gets to them - * Cleanup API endpoint docs - -v9.1.99 (2024-04-26) -------------------------- - * Remove elastic search - * Add support for read msg status - -v9.1.98 (2024-04-25) -------------------------- - * Fix ticket status selection - -v9.1.97 (2024-04-25) -------------------------- - * Include url for org chooser - -v9.1.96 (2024-04-25) -------------------------- - * Remove jQuery - -v9.1.95 (2024-04-25) -------------------------- - * Change ordering of non-search based exports to be id to match search based - * Use mailroom endpoint for search based contact exports - * Remove cancel button from contact import page and remove duplicate styles - * Tweak layout of user edit form - * Email notification that account email has changed should include the new email address - -v9.1.94 (2024-04-24) -------------------------- - * Fix changing password so user isn't logged out - * Fix user edit form allowing insecure passwords - -v9.1.93 (2024-04-24) -------------------------- - * Add notification types for when email or password is changed - * Expire unaccepted invitations after 30 days - * Move invitation form into modal - -v9.1.92 (2024-04-23) -------------------------- - * Remove start url for surveyors and instead do login redirect - * Fix to disallow content type vs extension mismatching for media uploads - * Fix to limit sending user verification email to 1 per 10 minutes - * Remove warning for flows that don't specify Facebook topic - -v9.1.91 (2024-04-18) -------------------------- - * Fix select race - * Fix header matching - * Simplify URL for template list page - -v9.1.90 (2024-04-16) -------------------------- - * Fix race on initial load for select and tabs - -v9.1.89 (2024-04-16) -------------------------- - * Fix API docs scrolling - * Fix mailroom_db data file - * Simplify channel claim page styling and remove unused styles - * Add Msg.templating - -v9.1.88 (2024-04-15) -------------------------- - * Drop FlowRun.submitted_by and cleanup superfulous constants - * Make whatsapp template type an actual package - * Simplify page titles so section isn't repeated in title - -v9.1.87 (2024-04-12) -------------------------- - * Add inline attachment style and wrapping on logs - * Don't re-release released triggers - -v9.1.86 (2024-04-12) -------------------------- - * Prune unnecessary styles, move to heavier fonts - -v9.1.85 (2024-04-12) -------------------------- - * Drop support for Submitted By in results exports - * Add constraint to limit Msg.DIRECTION to I or O - * Add constraint to incoming messages have channel and URN - -v9.1.83 (2024-04-11) -------------------------- - * Add TemplateType and rework whatsapp to be a type - * Remove special treatment for exports of surveyor flows - * Add TemplateTranslation.variables - -v9.1.82 (2024-04-10) -------------------------- - * Unpublicize the channel events API endpoint - * Drop unused Msg.queued_on field - -v9.1.81 (2024-04-10) -------------------------- - * Update temba-components - -v9.1.80 (2024-04-10) -------------------------- - * Assume js is pre-minified - -v9.1.79 (2024-04-09) -------------------------- - * Update flow editor - -v9.1.78 (2024-04-09) -------------------------- - * Use new components bundle - -v9.1.77 (2024-04-09) -------------------------- - * Deprecate Msg.queued_on as it isn't used and make Msg.modified_on non-null - -v9.1.76 (2024-04-08) -------------------------- - * Add data migration to backfill missing user settings - * Add signal receiver to ensure new users always have settings - -v9.1.75 (2024-04-04) -------------------------- - * Add data migration to archive campaigns with deleted groups - * Fix rendering of campaigns with deleted groups - * Improve styling on template list page - -v9.1.74 (2024-04-04) -------------------------- - * Update temba-components - * Use timedate formatting for last_seen_on / created_on on contact list pages - * Remove unused BRAND properties - * Cleanup displaying of channel name, address and type - -v9.1.73 (2024-04-03) -------------------------- - * Make Channel.name non-null and remove unused channel list view - * Replace format_datetime and short_datetime tags with day or datetime filters - -v9.1.72 (2024-04-03) -------------------------- - * Update temba-components - * Add data migration to backfill empty channel names - * Ensure Android channels get a default name when registering - -v9.1.71 (2024-04-03) -------------------------- - * Ignore empty messages from Android relayers - -v9.1.70 (2024-04-03) -------------------------- - * Update flow editor - * Remove unused option on assets endpoint to return environment - -v9.1.69 (2024-04-02) -------------------------- - * Remove no longer used template tag as_icon - * Fix export blocking due to multiple users exporting at same time - * Switch formax to expand vertically - * Add ChannelEvent.status field and prevent creating channel events of unknown types from Android syncs - -v9.1.68 (2024-04-02) -------------------------- - * Use mailroom endpoints to create messages and events during Android syncing - * Drop support for returning template components as dict - -v9.1.67 (2024-04-01) -------------------------- - * Update template editor to work with comps as list - * Add task to trim old channel events - -v9.1.66 (2024-03-28) -------------------------- - * Update format of tasks queued to mailroom - -v9.1.65 (2024-03-28) -------------------------- - * Update to django 5.0 and DRF 3.15.1 - -v9.1.64 (2024-03-25) -------------------------- - * Tweak menu styling - -v9.1.63 (2024-03-22) -------------------------- - * Add open tab event - -v9.1.62 (2024-03-22) -------------------------- - * Make workspace selection use common event pattern - * Truncate long template name to not break the page - * Replace iso630 with iso639-lang package - * Fix non Django 5 compatible code - -v9.1.61 (2024-03-21) -------------------------- - * Support for menu events - -v9.1.60 (2024-03-21) -------------------------- - * Update to latest ruff, isort and djlint - * Drop TemplateTranslation.comps_as_dict - * Get rid of channel typed owned sync log views and use new channel view on HTTP log CRUDL - * Convert templates views to actual CRUDL and fix permissions - -v9.1.59 (2024-03-21) -------------------------- - * Move template code into templates app - * Stop writing TemplateTranslation.comps_as_dict - -v9.1.58 (2024-03-20) -------------------------- - * Some fixes for on-device mobile issues - * Allow returning of components in list format from API endpoint - * Update to latest black - * Don't try to extract parameters from template url button component display values - -v9.1.57 (2024-03-20) -------------------------- - * Add name field also to template components - * Tweak template list page to use components list instead of comps_as_dict - -v9.1.56 (2024-03-19) -------------------------- - * Save TemplateTranslation.components as list, use comps_as_dict for API endpoint - -v9.1.55 (2024-03-19) -------------------------- - * Add temporary TemplateTranslation.comps_as_dict field - -v9.1.54 (2024-03-19) -------------------------- - * Add type to template components - * Remove deprecated fields from template translations - -v9.1.53 (2024-03-18) -------------------------- - * Fix mobile notice - -v9.1.52 (2024-03-18) -------------------------- - * Don't migrate flows when listing campaign events - -v9.1.51 (2024-03-17) -------------------------- - * Tweaks to make the interface more mobile friendly - -v9.1.50 (2024-03-17) -------------------------- - * Better feedback when editing contact fields - -v9.1.49 (2024-03-15) -------------------------- - * Add url param type for buttons with URLs - -v9.1.48 (2024-03-14) -------------------------- - * Show more components for WA templates list - * Add display to WA templates button components - -v9.1.47 (2024-03-14) -------------------------- - * Remove old templates API endpoint - * Update flow version for campaigns events single message flows - -v9.1.46 (2024-03-13) -------------------------- - * Reduce WA template sync error logging to ignore those in http logs - -v9.1.45 (2024-03-12) -------------------------- - * Fix the size limit for contact exports - -v9.1.44 (2024-03-12) -------------------------- - * Drop old export models and assets app - -v9.1.43 (2024-03-11) -------------------------- - * Data migration to delete old flow results exports - * Data migration to delete old msgs exports - -v9.1.42 (2024-03-11) -------------------------- - * Data migration to delete old contacts exports - -v9.1.41 (2024-03-11) -------------------------- - * Mark templates with button URLs and attachment in header not supported - * Convert exports to use shared export modal view - -v9.1.40 (2024-03-08) -------------------------- - * Allow more WhatsApp templates to be usable in the flows - -v9.1.39 (2024-03-07) -------------------------- - * Updated editor with sendmsg update fix - * Improve contact export modal and use mailroom endpoint to know how many contacts will be exported - -v9.1.38 (2024-03-07) -------------------------- - * Updated component button rendering - -v9.1.37 (2024-03-07) -------------------------- - * Do not sync templates for channels on suspended orgs or inactive orgs - * Redact WA password config in HTTP logs - -v9.1.36 (2024-03-06) -------------------------- - * Bump spec version to 13.4 - * Update editor to support template components - -v9.1.35 (2024-03-06) -------------------------- - * Restrict exports of contact groups that are too big - * Redact auth tokens from http logs when fetching whatsapp templates - * Cleanup code for fetching whatsapp templates and only create incidents after 5 failures - * Add data migration to delete old ticket exports - -v9.1.34 (2024-03-04) -------------------------- - * Update floweditor - -v9.1.33 (2024-03-04) -------------------------- - * Bump current flow spec version to 13.3 - * Ensure incidents are ended when releasing a channel - -v9.1.32 (2024-03-04) -------------------------- - * Update temba-components - * Always send verification email with branding of current org - * Add incident for WhatsApp templates sync failed - -v9.1.31 (2024-02-28) -------------------------- - * Fix editing user when language is not an option - -v9.1.30 (2024-02-28) -------------------------- - * Hide UI language options when there aren't any - * Update test_db templates - -v9.1.29 (2024-02-27) -------------------------- - * Remove DS from available channel and only accessible to beta group - * Prevent further creation of surveyor users since that functionality no longer works - -v9.1.28 (2024-02-22) -------------------------- - * Store servicing flag in session to avoid needing user orgs in context processor - * Add select_related to user loading for sessions and API tokens - * Bump cryptography from 42.0.2 to 42.0.4 - -v9.1.27 (2024-02-21) -------------------------- - * Update floweditor - -v9.1.26 (2024-02-18) -------------------------- - * Bump cryptography from 42.0.0 to 42.0.2 - * Improve the form for setting flow SMTP and make reusable - -v9.1.25 (2024-02-14) -------------------------- - * Update temba-components - -v9.1.24 (2024-02-12) -------------------------- - * Use dict for flow type icons instead of nested if elses - * Simplify export finished notification emails - * Use Org.Export for flows results exports - -v9.1.23 (2024-02-09) -------------------------- - * Fix org avatar scale for menu - * Fix widget for user avatar - -v9.1.22 (2024-02-08) -------------------------- - * Fix croppie dependency - * Prefetch user settings on users endpoint - -v9.1.21 (2024-02-08) -------------------------- - * Make user settings one to one - -v9.1.20 (2024-02-08) -------------------------- - * Use orgs.Export for messages exports - * Simplify sending template emails - * Add new endpoint to internal API for templates - * Trim old export and notifications - * Add support for user avatars - -v9.1.19 (2024-02-07) -------------------------- - * Save transformed components for WA templates - -v9.1.18 (2024-02-06) -------------------------- - * Cleanup flow SMTP formax and show parent settings as default to match mailroom changes - * Remove old code for saving SMTP into org config - -v9.1.17 (2024-02-06) -------------------------- - * Data migration to backfill Org.flow_smtp - -v9.1.16 (2024-02-06) -------------------------- - * Add new dedicated Org.flow_smtp field for email settings - -v9.1.15 (2024-02-06) -------------------------- - * Bump cryptography from 41.0.7 to 42.0.0 - * Simplify getting default flow email address - -v9.1.14 (2024-01-30) -------------------------- - * Remove using readonly DB connection for fetching groups and fields - -v9.1.13 (2024-01-29) -------------------------- - * Simplify how we check for existing running exports - * Dta migration to mark old notifications as seen - * Improve export download page - * Allow marking all notifications as read by DELETE request to notifications endpoint - * Use orgs.Export for contact exports - -v9.1.12 (2024-01-23) -------------------------- - * Tweak mailgun channel claiming - -v9.1.11 (2024-01-18) -------------------------- - * Some cleanup to new exports framework - -v9.1.10 (2024-01-18) -------------------------- - * Add skeleton staff only mailgun channel type - * Add export download view - -v9.1.7 (2024-01-18) -------------------------- - * Update temba-components - * Save storage path on exports and fix ticket exports not having a download URL - -v9.1.6 (2024-01-18) -------------------------- - * Add new generic orgs.Export model and replace ExportTicketsTask - * Simplify messaging when export is started - -v9.1.5 (2024-01-15) -------------------------- - * Allow webchat channels to have new convo triggers - * Finished exports should record number of items exported - -v9.1.4 (2024-01-12) -------------------------- - * Add skeleton temba chat channel type - -v9.1.3 (2024-01-12) -------------------------- - * Add notification for flow exports - -v9.1.2 (2024-01-11) -------------------------- - * Fix issue with completion input focus - -v9.1.1 (2024-01-11) -------------------------- - * Update notification text - -v9.1.0 (2024-01-11) -------------------------- - * Add notifications to UI - * Fix test_db command - * Update stable versions in README - -v9.0.0 (2024-01-05) -------------------------- - * Test against mailroom v9 - * Replace dummy migrations with real squashed migrations - -v8.3.123 (2024-01-05) -------------------------- - * Add empty versions of squashed migrations - -v8.3.122 (2024-01-04) -------------------------- - * Update to latest editor - -v8.3.121 (2024-01-04) -------------------------- - * Update to latest floweditor with open ticket changes - -v8.3.120 (2024-01-03) -------------------------- - * Allow ticket body to be optional - -v8.3.119 (2024-01-03) -------------------------- - * Drop ticketer model - -v8.3.118 (2024-01-03) -------------------------- - * Remove view of http logs by ticketer - * Drop Ticket.ticketer and HTTPLog.ticketer - -v8.3.117 (2024-01-03) -------------------------- - * Remove ticketer types - -v8.3.116 (2024-01-03) -------------------------- - * Fix editor routing edge case - * Remove ticketers API endpoint - -v8.3.115 (2024-01-02) -------------------------- - * Update to latest flow editor - * Drop index on ticket.external_id - -v8.3.114 (2024-01-02) -------------------------- - * Stop exposing ticket ticketer on endpoints - -v8.3.113 (2024-01-02) -------------------------- - * Update temba-components - * Finish cleaning up API v2 tests to use APITestMixin - -v8.3.112 (2023-12-14) -------------------------- - * ContactChat with less padding - -v8.3.111 (2023-12-14) -------------------------- - * Introduce footer - -v8.3.110 (2023-12-13) -------------------------- - * Add index to help fetching scheduled event fires and another to find template translations by channel+external id - -v8.3.109 (2023-12-13) -------------------------- - * Move last indexews from SQL file into Django models and drop unused - -v8.3.108 (2023-12-12) -------------------------- - * Move all remaining flowrun and flowsession indexes onto their models - -v8.3.107 (2023-12-12) -------------------------- - * Fix channel log display when missing URN - * Queued message treatment, flow editor fix - * Update poetry deps - * Move more indexes onto models and remove unnecessary one - -v8.3.106 (2023-12-11) -------------------------- - * Cleanup indexes for FlowStartCount, SystemLabelCount and ContactGroupCount - * Use datetime timezone aliased as tzone - * Update django timezone field to 6.1.0 - -v8.3.105 (2023-12-07) -------------------------- - * Email changes should reset email status to unverified - -v8.3.104 (2023-12-07) -------------------------- - * Remove duplication between channel read and chart views - * Cleanup indexes in channels app - * Remove unhelpful index on eventfire and move other into Django model - -v8.3.103 (2023-12-05) -------------------------- - * Data migration to fix bad last seen on values - * Add support for user to start the email verification and send themselves the verification link - -v8.3.102 (2023-11-30) -------------------------- - * Testing auto-versioning again - -v8.3.99 (2023-11-29) -------------------------- - * Fix syncing OTP utility templates - * Drop unused TemplateTranslate.language and country fields - -v8.3.98 (2023-11-29) -------------------------- - * Fix mailroom DB templates components structure - * Bump cryptography from 41.0.4 to 41.0.6 - * Stop writing TemplateTranslation.language and country and remove unsupported language as a possibility - -v8.3.97 (2023-11-28) -------------------------- - * Stop reading from TemplateTranslation.language and country - * Undocument the templates API endpoint and add locale field to translations - * Fix syncing OTP utility templates - -v8.3.96 (2023-11-27) -------------------------- - * Migration to backfill TemplateTranslation.locale and external_locale - -v8.3.95 (2023-11-27) -------------------------- - * Add TemplateTranslation.locale and .external_locale to replace language and country - * Support saving components and params to message templates - -v8.3.94 (2023-11-23) -------------------------- - * Update temba-components - -v8.3.93 (2023-11-23) -------------------------- - * Fix IVR simulation - -v8.3.92 (2023-11-22) -------------------------- - * Tweak appearance of API explorer - -v8.3.91 (2023-11-21) -------------------------- - * Cleanup API docs - -v8.3.90 (2023-11-17) -------------------------- - * Add pillow dependency - -v8.3.89 (2023-11-15) -------------------------- - * Don't allow oeverwriting of flows with a different type during imports - * Enforce unique addresses for more channel types - -v8.3.88 (2023-11-14) -------------------------- - * Expose org.input_collation on languages formax - * Remove blog redirect pattern and sitemap - * Add unique_address to channel type and use that to validate channel is unique before claiming it - -v8.3.87 (2023-11-13) -------------------------- - * Data migration to delete schedules attached to deleted triggers - * Simulator should use workspace collation setting - * Don't include email only notifications in unseen count for UI - -v8.3.86 (2023-11-13) -------------------------- - * Update mailroom endpoint names - -v8.3.85 (2023-11-10) -------------------------- - * Data migration to pause schedules of existing archived triggers - -v8.3.84 (2023-11-09) -------------------------- - * Allow schedules to be paused when triggers are archived - -v8.3.83 (2023-11-09) -------------------------- - * Fix login redirection to next param - * Drop no longer used fields on Schedule and Label - * Overrride mailroom URL in mailroom_db command - * Add view to verify email - -v8.3.82 (2023-11-08) -------------------------- - * Ensure that schedules are actually deleted when a broadcast or trigger is soft deleted - * Fix trigger list keyword search - * Make Notifications.medium non-null and use to filter notifications on API endpoint - * Make deprecated fields o schedule nullable - * Remove unused ScheduleCRUDL - -v8.3.81 (2023-11-07) -------------------------- - * Add data migration to backfill Notification.medium - * Add data migration to actually delete inactive schedules - -v8.3.80 (2023-11-07) -------------------------- - * Fix constraint on Trigger to allow deleting of schedules - * Add medium field Notification to let us model notifications which should be email only - -v8.3.79 (2023-11-07) -------------------------- - * Add data migration to delete ended and orphaned schedules - * Remove no longer used flow_type field on queued flow starts - -v8.3.78 (2023-11-02) -------------------------- - * Update scheduled broadcast to send now - -v8.3.77 (2023-11-01) -------------------------- - * Move optins inside compose widget - -v8.3.76 (2023-11-01) -------------------------- - * Fix org start view when org isn't set - * Add data migration to remove scheduled triggers without a schedule and constraint to prevent new ones - * Fix not showing non-field errors on wizard forms - -v8.3.75 (2023-10-31) -------------------------- - * Remove register "trigger" type - * Add user settings fields for email verification - * Update trigger type icons - * Allow staff to add users - * Add send broadcast and start flow bulk actions to contact group page - -v8.3.74 (2023-10-30) -------------------------- - * Update temba-components with attachment rendering - -v8.3.73 (2023-10-30) -------------------------- - * Add quick replies to broadcasts - -v8.3.72 (2023-10-27) -------------------------- - * Make sure the missing external ID we make for D360 channels is truncated to 64 characters - * Un-gate optins - * Add support for Facebook login for business configurations - * Move API token formax to Account section - -v8.3.71 (2023-10-26) -------------------------- - * Consistent brand references in templates - -v8.3.70 (2023-10-26) -------------------------- - * Merge pull request #4930 from nyaruka/use-org-brand-domain - * Remove brand link - * Replace all brand link with brand domain use - -v8.3.69 (2023-10-26) -------------------------- - * Use org brand domain instead of link - * Update to use Facebook API v18.0 - -v8.3.67 (2023-10-26) -------------------------- - * Update revisions url - -v8.3.66 (2023-10-25) -------------------------- - * Simplify brands - -v8.3.65 (2023-10-25) -------------------------- - * Fix and cleanup view for accepting invitations - -v8.3.64 (2023-10-25) -------------------------- - * Fix start views for agent users - * Allow agent users to access account settings page - * Move two factor views out of main menu and into the account view - -v8.3.63 (2023-10-23) -------------------------- - * Fix SendBroadcast action to work with localized compose - -v8.3.62 (2023-10-23) -------------------------- - * Make Trigger.priority non-null and use for ordering - -v8.3.61 (2023-10-23) -------------------------- - * Add data migration to backfill Trigger.priority - -v8.3.60 (2023-10-23) -------------------------- - * Add Trigger.priority and start writing - -v8.3.59 (2023-10-20) -------------------------- - * Fix maxlength for campaign events and focus on compose - -v8.3.58 (2023-10-19) -------------------------- - * Allow triggers to wrap - -v8.3.57 (2023-10-19) -------------------------- - * Update oxford template filter to allow different conjunctions - * Move all trigger type templates into their own folders - * Add data migration to merge compatible keyword triggers - -v8.3.56 (2023-10-18) -------------------------- - * Improve display of triggers on list pages - * Support multiple keywords per trigger in UI - * Fix WA legacy config page - -v8.3.55 (2023-10-17) -------------------------- - * Show urns properly for urn change events - * Use localized validation errors for import validation - * Support multi-keyword triggers in exports and imports - -v8.3.54 (2023-10-17) -------------------------- - * Drop Trigger.keyword - -v8.3.53 (2023-10-17) -------------------------- - * Fix fetching of keywords across triggers when editing a flow - -v8.3.52 (2023-10-17) -------------------------- - * Stop writing Trigger.keyword - -v8.3.51 (2023-10-17) -------------------------- - * Only read from Trigger.keywords - -v8.3.50 (2023-10-16) -------------------------- - * Make ticketer nullable on ticket - * Convert tickets API endpoints to use CRUDL perms - * Make sure we show the issue icon on the flow list page - -v8.3.49 (2023-10-13) -------------------------- - * Add data migration to populate keywords on trigger - * Add localization to create broadcast wizard - -v8.3.47 (2023-10-12) -------------------------- - * Add Trigger.keywords and start writing - * Switch contacts API endpoints to use CRUDL perms - * Cleanup BroadcastCRUDL.Send which is now only for sending to a flow node - * Remove unused LabelCRUDL.List view - * Convert messages, media and label API endpoints to use CRUDL perms - -v8.3.46 (2023-10-11) -------------------------- - * Remove no longer needed deprecated options on definitions endpoint - * Replace orgs.org_api permission - * Drop no longer used fields on FlowRevision - -v8.3.45 (2023-10-10) -------------------------- - * Show exclusion groups on trigger list pages - * Fix updating keyword triggers for flows - * Make sure we display trigger channel if set - * Limit access to API explorer to editors and admins - * Convert resthook API endpoints to use CRUDL based permissions - -v8.3.44 (2023-10-06) -------------------------- - * Allow request optin if optins exist - * Fix blurb for opt-out trigger - * Remove last usages of FlowLabel.parent and FlowRevision.modifiy_by - * Switch optins, topics, ticketers and templates API endpoints to use CRUDL perms - * Replace brand specific flow users with a single system user - -v8.3.43 (2023-10-05) -------------------------- - * Update editor and components - -v8.3.42 (2023-10-05) -------------------------- - * Make channel on trigger forms clearable - * Prepare unused fields on FlowRevision for removal and change all models in flows app to use orgs.User - * Allow beta testers to access optin features - * Switch flows, flow_starts and runs API endpoints to use CRUDL permissions - * Add optional channel field to call triggers types that are based on channel activity - -v8.3.41 (2023-10-04) -------------------------- - * Add optin as field to channelevents - * Allow perms to be made API specific so that we can limit agent access to the UI - -v8.3.40 (2023-10-03) -------------------------- - * Remove globals from agent store when missing permission - * Remove arst - -v8.3.39 (2023-10-03) -------------------------- - * Fix compose clear on send - * Use more CRUDL perms with API endpoints - -v8.3.38 (2023-10-03) -------------------------- - * Remove completion from contact chat - * Do not recreate the events when the campaign is archived - -v8.3.37 (2023-10-02) -------------------------- - * Abstract functionality for triggers based on channel actvity into base classes - * API endpoint should default to CRUDL based permissions if permission not specified - * Update to use Facebook API v17 - -v8.3.36 (2023-09-29) -------------------------- - * Remove minutes label from channel chart - * Add workspace breakdown for dashboard - -v8.3.35 (2023-09-28) -------------------------- - * Update opt-in styling - * Fix generation of history events from messages with optins - -v8.3.34 (2023-09-28) -------------------------- - * Fix migration conflict - -v8.3.33 (2023-09-28) -------------------------- - * Fix rendering of optin triggers - * Completely remove channel alerts - -v8.3.32 (2023-09-27) -------------------------- - * Fix previous accidental merge to main to add optin import support - * Cleanup views accessing request org - * Add optin as option to broadcast create wizard - -v8.3.30 (2023-09-27) -------------------------- - * Allow the target_urls of incident notifications to differ by type - * Use proper secret generation for recovery tokens and re-org code - * Fix task discover for legacy whatsapp channel type - * Implement channel disconnected alert as incident - -v8.3.29 (2023-09-26) -------------------------- - * Update editor to include opt-ins - -v8.3.28 (2023-09-26) -------------------------- - * Fix Contact Importss - * Rename old legacy channel types - * Add title to incident list page and tweak styling - * Implement email notifications for incidents - * Fix ticket squashable count models - -v8.3.27 (2023-09-25) -------------------------- - * Tweak mailroom_db to create an FBA channel instead of a TWT channel - * Remove ticketers as a feature and the views for connecting external ticketers - * Re-add optin as distinct message type - * Add undocumented API endpoint for opt-ins - -v8.3.26 (2023-09-22) -------------------------- - * Bump cryptography from 41.0.3 to 41.0.4 - * Add optin field to Broadcast - -v8.3.25 (2023-09-21) -------------------------- - * Fix trigger ordering - -v8.3.24 (2023-09-21) -------------------------- - * Add opt-in and opt-out trigger types (staff only for now) - * Group keyword triggers and catch all triggers under a Messages folder - * Move broadcasts and scheduled to their own pages - -v8.3.23 (2023-09-21) -------------------------- - * Replace Msg.type=optin with optin reference on msg - * Group trigger types into folders - * Make sure staff can update the log policy on all channel types - -v8.3.22 (2023-09-19) -------------------------- - * Make ticketers API endpoint unpublicized - * Add 'Send Now' to broadcast creation - -v8.3.21 (2023-09-18) -------------------------- - * Add basic OptIn model - * Use env variable for dev mode host - -v8.3.20 (2023-09-12) -------------------------- - * Update editor for localized attachment fix - -v8.3.19 (2023-09-12) -------------------------- - * Add new data migration to fix IVR call counts - * Drop Channel.parent, ContactURN.auth and Org.input_cleaners - * Remove support for delegate channels - -v8.3.18 (2023-09-07) -------------------------- - * Add data migration to populate ContactURN.auth_tokens - -v8.3.17 (2023-09-06) -------------------------- - * Add ContactURN.auth_tokens to replace .auth - -v8.3.16 (2023-09-06) -------------------------- - * Tweak documentation for flow_starts endpoint - * Allow agents to update tickets topics - -v8.3.15 (2023-09-06) -------------------------- - * Add hover-darker button option - * Update icons - -v8.3.14 (2023-08-31) -------------------------- - * Limit to load the recent 100 sessions - * Disallow GET request for media upload view - -v8.3.13 (2023-08-28) -------------------------- - * Tweaks to the channel config blurbs for consistency - * Fetching messages by label should include arched messages - * Use secrets module instead of random for random_string - * Little bit of cleanup in channel types like removing unused fields - -v8.3.12 (2023-08-23) -------------------------- - * Add ChannelType.config_ui to replace configuration_urls, configuration_blurb etc - * Show Somleng config URLs based on channel role - * Add Org.input_collation - * Remove Blackmnyna, Chikka, Junebug, Twitter legacy, old Zenvia channel types - -v8.3.11 (2023-08-17) -------------------------- - * Convert final haml templates in root directory - -v8.3.10 (2023-08-17) -------------------------- - * Add Org.input_cleaners - * Always show name / anon id for anon orgs in contact lists - * Don't let mailroom handle tasks during tests - * Fix title on welcome page - -v8.3.9 (2023-08-16) -------------------------- - * Fix onSpload fire when initial page doesn't call it - -v8.3.8 (2023-08-16) -------------------------- - * Use $ instead of onSpload - -v8.3.7 (2023-08-16) -------------------------- - * Fix Javascript on claim number view - * Switch test_db to assume a docker container - -v8.3.6 (2023-08-15) -------------------------- - * Convert haml templates in includes folder and utils app - * Cleanup page titles in settings section - -v8.3.5 (2023-08-14) -------------------------- - * Convert haml templates in public and orgs apps - -v8.3.4 (2023-08-14) -------------------------- - * Convert templates in assets, channels, msgs, request_logs and schedules apps as well as overridden smartmin templates - -v8.3.3 (2023-08-10) -------------------------- - * Simplify message indexes and system label queries - -v8.3.2 (2023-08-10) -------------------------- - * Add data migration to convert old I/F msg types - -v8.3.1 (2023-08-09) -------------------------- - * Merge pull request #4779 from nyaruka/less_haml - * Some tweaks to templates based on linter - * Convert all haml templates in channel types - -v8.3.0 (2023-08-09) -------------------------- - * Drop no longer used Org.brand field - * Add messagebird channel type - -v8.2.0 (2023-08-07) -------------------------- - * Update stable versions - -v8.1.245 (2023-08-05) -------------------------- - * Truncate query lables on flow start - * Fix line length formatting - * Fixes for login and API titles - -v8.1.244 (2023-08-04) -------------------------- - * Fix error handling for temba-contact-search - -v8.1.243 (2023-08-03) -------------------------- - * Fix DELETE endpoints in API explorer - * Bump cryptography from 41.0.2 to 41.0.3 - -v8.1.242 (2023-08-02) -------------------------- - * Update to components with modax serialize fix - -v8.1.241 (2023-08-02) -------------------------- - * Fix two factor disable and initial QR code rendering - -v8.1.240 (2023-08-01) -------------------------- - * Update components with checkbox value update - * Stop writing no longer used Org.brand - -v8.1.239 (2023-08-01) -------------------------- - * Temp fix for org export page by replacing temba-checkbox with regular inputs - * Cleanup msg_console - -v8.1.238 (2023-07-28) -------------------------- - * Fix flow start log when starts don't have exclusions - * Remove unnecessary CSS class to hover - -v8.1.237 (2023-07-28) -------------------------- - * Only consider the parsed query string in contact_search clean - * Add show CSS class to icon for contact list sorting - -v8.1.236 (2023-07-27) -------------------------- - * Rename flow_broadcast to flow_start - * Update editor to fix cases on result split - * Add new channel log types used by courier - * Update contact search widget for flow starts - -v8.1.235 (2023-07-26) -------------------------- - * Convert templates in dashboard, docs, globals, ivr, locations and notifications apps - * Use title-text for just overriding the text - * Restore missing msg box templates - -v8.1.234 (2023-07-25) -------------------------- - * Fix org export page - * Fix permissions for viewer for flow results - -v8.1.233 (2023-07-25) -------------------------- - * Simpliy convert_templates script - * Consistent title for initial page load - * Remove spa-title and spa-style - * Add archives to STORAGES - -v8.1.232 (2023-07-24) -------------------------- - * Do not set the max for y axis chart to allow that to be calculated - * Convert templates in the triggers app from haml - -v8.1.231 (2023-07-21) -------------------------- - * Simplify redis settings and organize settings better in sections - -v8.1.230 (2023-07-20) -------------------------- - * Tweak system check for storage settings to check different storages are configured - * Convert S3 log access to be via django storages - * Use pg_dump/restore from docker container in mailroom_db command so it's always correct version - -v8.1.229 (2023-07-19) -------------------------- - * Fix tickets list, to show compose properly on Firefox - * Add cpAddress parameter as optional for MTN channel type - -v8.1.228 (2023-07-18) -------------------------- - * Update Instagram docs broken link - * Allow initiating flow results download form the the flow labels filter view - -v8.1.227 (2023-07-17) -------------------------- - * Bump cryptography from 41.0.0 to 41.0.2 - -v8.1.226 (2023-07-13) -------------------------- - * Rework trimming cron tasks to use delete_in_batches - * Drop no longer used Binary Optional Data field - -v8.1.225 (2023-07-13) -------------------------- - * Fix icon for globals delete - * Migrate old Twilio channels using .bod to use .config instead - * Remove duplicate menu views in classifiers and channels apps - -v8.1.224 (2023-07-12) -------------------------- - * Add log_policy to channel - -v8.1.223 (2023-07-11) -------------------------- - * More tweaks to org deletion - -v8.1.222 (2023-07-11) -------------------------- - * Add delete_in_batches util function to improve org deletion - * Actually fix deletion of campaign events during org deletion - -v8.1.221 (2023-07-11) -------------------------- - * Fix deleting of campaign events and add more logging to org deletion - -v8.1.220 (2023-07-10) -------------------------- - * Delete is only for deleting child workspaces - -v8.1.219 (2023-07-10) -------------------------- - * Fix problems with org deletion - -v8.1.218 (2023-07-07) -------------------------- - * Update to flow editor with fix for ward cases - -v8.1.217 (2023-07-06) -------------------------- - * Convert haml files in contacts app - * Bump django from 4.2.2 to 4.2.3 - -v8.1.216 (2023-07-05) -------------------------- - * Add data migration to fix archived message counts for labels - * Convert haml templates in campaigns and classifiers apps - -v8.1.215 (2023-07-05) -------------------------- - * Add missing migration that rebuilds constraint on contact URNs - * Update channel log retention to 2 weeks - * Disable old 360 Dilalog channel type, and take the new integration out of beta - -v8.1.214 (2023-07-03) -------------------------- - * Update to psycopg3 non-binary - * Reference templates as html - -v8.1.213 (2023-07-03) -------------------------- - * Convert flows app to be hamless - -v8.1.212 (2023-07-03) -------------------------- - * Sorted group list when editing contacts - * Switch channel charts to load with json instead of embedded data - -v8.1.211 (2023-06-28) -------------------------- - * Fix Twilio channel update modal - -v8.1.210 (2023-06-28) -------------------------- - * Fix mangling of option attributes - * Save channel logs with channels/ prefix - * Add configurable agent access per contact field - -v8.1.209 (2023-06-28) -------------------------- - * Fix creating PublicFileStorage - -v8.1.208 (2023-06-28) -------------------------- - * Fix S3 channel logs paths to not start with slash - * Update to Django 4.2 - -v8.1.207 (2023-06-27) -------------------------- - * Convert some haml templates to html - -v8.1.206 (2023-06-27) -------------------------- - * Drop duplicate index - * Look for channel logs in S3 when not found in database - * Move tracking label counts to statement level triggers - -v8.1.205 (2023-06-27) -------------------------- - * Replace index on channellog.channel - -v8.1.204 (2023-06-26) -------------------------- - * Fix inline group created and broadcast action - -v8.1.203 (2023-06-26) -------------------------- - * Update contact action fix - -v8.1.202 (2023-06-26) -------------------------- - * Rework settings for S3 buckets - -v8.1.201 (2023-06-23) -------------------------- - * Support runtime locales in components - -v8.1.200 (2023-06-23) -------------------------- - * Update for flow editor text inputs with null values - -v8.1.199 (2023-06-22) -------------------------- - * Updates for select widget to behave with more standard form controls - -v8.1.198 (2023-06-22) -------------------------- - * Rollback components - -v8.1.197 (2023-06-22) -------------------------- - * Override the correct alpha3 code for Oromifa - * Update form components to use element internals - * Rework loading of channel logs so easier to fetch from S3 too - -v8.1.196 (2023-06-21) -------------------------- - * Improve ExternalURLField and don't assume http - * Use org import task to import flows - -v8.1.195 (2023-06-19) -------------------------- - * Name override for oro language - * Remove no longer used code relating to contact fields - -v8.1.194 (2023-06-19) -------------------------- - * Don't ignore user provided role for somleng shortcodes - * Fix flow export button height - * Fix import translation to use new UI - * Fix parent ID lookup in import geojson - * Support Dialog360 Cloud API channels - -v8.1.193 (2023-06-14) -------------------------- - * Add surveyor icon - -v8.1.192 (2023-06-14) -------------------------- - * Add icons for flows, fix issue with some spload fires - -v8.1.191 (2023-06-13) -------------------------- - * Broadcast update via wizard and updated list styling - -v8.1.190 (2023-06-12) -------------------------- - * Add agent_access to API fields endpoint - * Restrict agent users view of field values on API contacts endpoint - * Remove use of django tags inside javascript - -v8.1.189 (2023-06-12) -------------------------- - * Fix broken list view template - * Add djlint and latest django-hamlpy - -v8.1.188 (2023-06-09) -------------------------- - * Tweak contact field access backfill migration - -v8.1.187 (2023-06-09) -------------------------- - * Add ContactField.agent_access and backfill to view - * Use statement level triggers for tracking current node counts - * Remove old scheduled broadcast create view - -v8.1.186 (2023-06-08) -------------------------- - * Format api_root.html and fix errors - * Fix channel log pretty printing - -v8.1.183 (2023-06-08) -------------------------- - * Add djLint config - * Add basic wizard support - -v8.1.182 (2023-06-08) -------------------------- - * Support imports with Status column - * Make viewer role users a feature that can be toggled - * Allow exporting of blocked, stopped and archived contacts - -v8.1.181 (2023-06-07) -------------------------- - * Add redact_values for FBA and IG channel types - * Remove unused code for legacy UI contact read and list pages - * Rework channel log anonymization so even staff users have to explicitly break out of it - * Rework channel log rendering to start from JSONified version - * Fix adding queued braodcasts to Outbox view and counts - * Cleanup db triggers for broadcasts - -v8.1.180 (2023-06-05) -------------------------- - * Fix failed message resending and archived message deletion - -v8.1.179 (2023-06-05) -------------------------- - * Drop ChannelLog.msg and .call - -v8.1.178 (2023-06-05) -------------------------- - * Bump cryptography from 39.0.2 to 41.0.0 - * Stop reading from ChannelLog.msg and .call - * Use per-statement db triggers for system label counts - -v8.1.177 (2023-06-02) -------------------------- - * Remove dupe from changelog - -v8.1.176 (2023-06-02) -------------------------- - * Add some blocks on main templates - -v8.1.175 (2023-06-02) -------------------------- - * Add select all on list pages - -v8.1.174 (2023-06-01) -------------------------- - * Noop when releasing an already released org - * Rework and simplify channel count db triggers - -v8.1.173 (2023-06-01) -------------------------- - * Remove support for filtering channel logs by folder - -v8.1.171 (2023-05-31) -------------------------- - * Add index on channellog.uuid - * Impove and expose the call list view - -v8.1.170 (2023-05-31) -------------------------- - * Remove rendering of contact history as template now that new UI only consumes it as JSON - * Fix inbox msg type for Android channels - -v8.1.169 (2023-05-30) -------------------------- - * Allow call count backfill migration to be called offline - * Fix ivr call trigger migration - * Remove unused stuff from inbox views - -v8.1.168 (2023-05-30) -------------------------- - * Add data migration to backfill ivr call counts - -v8.1.167 (2023-05-29) -------------------------- - * Add DB triggers to track counts of calls as a new system label - -v8.1.166 (2023-05-29) -------------------------- - * Stop writing SystemLabelCount.is_archived so it can be dropped - -v8.1.165 (2023-05-29) -------------------------- - * Always write system label counts with is_archived=False and make field nullable - -v8.1.164 (2023-05-29) -------------------------- - * Add data migration to delete old system label counts for is_archived=true because they're no longer updated - * Fix getting FB business ID for WAC channels - -v8.1.163 (2023-05-25) -------------------------- - * Return empty sample/fields on preview_start endpoint until contactsearch component is updated - -v8.1.162 (2023-05-25) -------------------------- - * Add BroadcastCRUDL.Preview - * Fix broadcast send history template - -v8.1.161 (2023-05-24) -------------------------- - * User orgs based on request - * Switch brand array to dict - * Move plivo connect view to channel type - -v8.1.160 (2023-05-19) -------------------------- - * Fix field update and deleting with same key - -v8.1.159 (2023-05-19) -------------------------- - * Don't allow horizontal scroll by default - -v8.1.158 (2023-05-19) -------------------------- - * Fix scrolling for content pages without full height - * Tweak how we run python scripts in CI - -v8.1.157 (2023-05-18) -------------------------- - * Add ticket editing - * Remove old ticket assign view and support for notes with assignment - * Add ticket topic menu and resizer - * Move WAC connect view to the WhatsApp cloud channel type package - * Remove accounts formax from workspace view as it isn't needed with new UI - -v8.1.156 (2023-05-17) -------------------------- - * Update components for 302 fix - * Make post_url work identically to posterize - -v8.1.155 (2023-05-17) -------------------------- - * Better handling of post_url for spa content menu - * Really fix hiding surveyor form - -v8.1.154 (2023-05-17) -------------------------- - * Hide the surveyor password input and not just the help texti - * Fix URLs in JS files - -v8.1.153 (2023-05-17) -------------------------- - * Move channel type constants to the channel type class - * Don't show option to enter surveyor password if surveyor feature not enabled - * Scoped javascript for flow broadcast modal - -v8.1.152 (2023-05-15) -------------------------- - * Make js function name unique - * Fix no_nav extra-script blocks - -v8.1.151 (2023-05-15) -------------------------- - * Fix the API explorer scripts and styles blocks - -v8.1.150 (2023-05-15) -------------------------- - * Cleanup broken or unused posterized links - * Drop old flow start fields - -v8.1.149 (2023-05-14) -------------------------- - * Fix signups - -v8.1.148 (2023-05-12) -------------------------- - * Fix backwards compat for send message to somebody else - -v8.1.147 (2023-05-12) -------------------------- - * Fix flow refresh and global redirect hook - -v8.1.146 (2023-05-12) -------------------------- - * Add some null checks for frame selectors - -v8.1.145 (2023-05-11) -------------------------- - * Fix width for other views and posterize on choose - -v8.1.144 (2023-05-11) -------------------------- - * Fix login width - * Tweak Somleng claim blurb - -v8.1.143 (2023-05-11) -------------------------- - * Stop reading from old FlowStart fields - * Merge and clean up main frame - * Rename Twiml API channel to Somleng - -v8.1.142 (2023-05-11) -------------------------- - * Add base mixin for channel type specific views that gives access to the type class - * Update components and editor to support compose for somebody else - * Move vonage connect view to the channel type - * Allow deleting of archived triggers - -v8.1.141 (2023-05-10) -------------------------- - * Fix contacts title - * Fix vanilla landing - * Remove lessblock and replace with compiled css - * Bump django from 4.1.7 to 4.1.9 - -v8.1.140 (2023-05-09) -------------------------- - * Fix ticket padding - * Remove remaining spa files - * Add link to reset the latest credentials - * Preset channel connection - -v8.1.139 (2023-05-09) -------------------------- - * Add blocked icon - -v8.1.138 (2023-05-09) -------------------------- - * Update labeling to use temba-checkbox and remove jQuery - * Fix trim_channel_logs config and rework so task olny runs for an hour max - * Change test_db to create single org at a time - -v8.1.137 (2023-05-09) -------------------------- - * Add exclusions and params fields to FlowStart and start writing them - -v8.1.136 (2023-05-09) -------------------------- - * Don't include brand variables in less node - -v8.1.135 (2023-05-09) -------------------------- - * Remove references to old icon set - * Remove unused jquery bits and intercooler - * Remove bootstrap - -v8.1.134 (2023-05-08) -------------------------- - * Remove no longer used perms - * Remove any old non-spa templates not being extended by the spa version - * Remove is_spa logic from templates - * Remove old contact update fields views - -v8.1.133 (2023-05-05) -------------------------- - * Add default color - -v8.1.132 (2023-05-05) -------------------------- - * Remove settings turd - -v8.1.131 (2023-05-05) -------------------------- - * Remove old nav from landing page - -v8.1.130 (2023-05-04) -------------------------- - * Remove spa checking in views - -v8.1.129 (2023-05-04) -------------------------- - * Remove JSON view to list notifications now that has moved to the internal API - * Remove non-spa items from content menus - -v8.1.128 (2023-05-03) -------------------------- - * Fix contact import - -v8.1.127 (2023-05-03) -------------------------- - * Remove support for adding bulk sender delegate channels - * Remove ability to create IVR delegates for android channels - * Remove org home view altogether and update links to point to workspace view - -v8.1.126 (2023-05-03) -------------------------- - * Change cookie checking for UI so that we always default to new UI - * Add color picker widget - * Remove ability to store twilio credentials on the org - -v8.1.125 (2023-05-02) -------------------------- - * Tweak notifications index to match API endpoint - * Add new internal API with a notifications endpoint - * Use DRF defaults for STRICT_JSON and UNICODE_JSON - * Remove unused .api URL suffixes - -v8.1.124 (2023-05-01) -------------------------- - * Make contact.modify work with new and old format - * Make ticket a reserved field name - -v8.1.123 (2023-04-27) -------------------------- - * Hide Open Ticket option on contact read page if there's already an open a ticket - * Rework soft and hard msg deleting to be more performant - -v8.1.122 (2023-04-26) -------------------------- - * Remove db constriants on Msg.flow and Msg.ticket - -v8.1.121 (2023-04-26) -------------------------- - * Tweak migration dependency - * Show counts of tickets by topic on tickets menu - -v8.1.120 (2023-04-25) -------------------------- - * Add topic counts to the API endpoint - * Add undocumented param to contacts API endpoint which allows URNs to be expanded - * Data migration to backfill ticket counts by topic - -v8.1.119 (2023-04-25) -------------------------- - * Start writing ticket counts for topics - -v8.1.118 (2023-04-24) -------------------------- - * Fix deleting of flows and tickets which are referenced by messages - * Fix pattern match for folder uuid - * Stop writing TicketCount.assignee - -v8.1.117 (2023-04-24) -------------------------- - * Stop reading from TicketCount.assignee - -v8.1.116 (2023-04-21) -------------------------- - * Add more channel icons - -v8.1.115 (2023-04-21) -------------------------- - * Update icons - * Add ticket topic folders - -v8.1.114 (2023-04-20) -------------------------- - * Add migration to backfill TicketCount.scope - -v8.1.113 (2023-04-20) -------------------------- - * Add scope field to TicketCount and start writing - -v8.1.112 (2023-04-20) -------------------------- - * Dropdowns for slow clickers - * Tighten up animations - * Use services for redis, elastic and postgres in CI - -v8.1.111 (2023-04-18) -------------------------- - * Fix and archive keyword triggers with no match_type - -v8.1.110 (2023-04-18) -------------------------- - * Prefetch flows on message views and make titles consistent - -v8.1.109 (2023-04-18) -------------------------- - * Add links for menu, add flow badge, update label badges - * Remove Chikka channel type which no longer exists - * Update mailroom_db command to allow connecting to non-file socket postgres - -v8.1.108 (2023-04-17) -------------------------- - * Add ticket field to msg model - -v8.1.107 (2023-04-13) -------------------------- - * Allow deleting of groups used in triggers - -v8.1.106 (2023-04-13) -------------------------- - * Don't show topics on tickets until clicked - -v8.1.105 (2023-04-12) -------------------------- - * Fix js items on context menus - -v8.1.104 (2023-04-11) -------------------------- - * Do not display schedule events for archived triggers - * Don't require db superuser for test_db command - * Make ticket banner expandable - -v8.1.103 (2023-04-10) -------------------------- - * Fix urls when searching and paging - * Follow message on auto assign for unassigned folder - -v8.1.102 (2023-04-10) -------------------------- - * Add contact details pane, hide empty tabs - * Auto assign tickets when sending messages - * Add nicer ticket assignment using temba-contact-tickets component - * Fix deleting of orgs with incidents - -v8.1.101 (2023-04-06) -------------------------- - * Add field search handler on tickets - -v8.1.100 (2023-04-06) -------------------------- - * Add fields to tickets - -v8.1.99 (2023-04-06) -------------------------- - * Add test util to make it easier to mess with brands - * Drop Org.stripe_customer_id - -v8.1.98 (2023-04-06) -------------------------- - * Link contact name on tickets to the contact page if permitted - * Drop Org.plan, plan_start and plan_end - -v8.1.97 (2023-04-05) -------------------------- - * Pull tickets out of contact chat - * Scheduled messages to broadcasts with compose widget - -v8.1.96 (2023-04-03) -------------------------- - * Stop reading Org.plan and .plan_end - * Bump redis from 4.5.3 to 4.5.4 - -v8.1.95 (2023-03-31) -------------------------- - * Fix temba-store race on load - -v8.1.94 (2023-03-29) -------------------------- - * Bump version of openpyxl - -v8.1.93 (2023-03-29) -------------------------- - * Update Excel reading dependencies - -v8.1.92 (2023-03-29) -------------------------- - * Use unittests.mock.Mock in tests instead of custom mock_object - -v8.1.91 (2023-03-28) -------------------------- - * Upgrade redis library version - -v8.1.90 (2023-03-27) -------------------------- - * NOOP instead of assert if archiving msg which is already archived etc - -v8.1.89 (2023-03-27) -------------------------- - * Do not fail to release channel when missing mtn subscription id in config - * Add incident type for org suspension - -v8.1.88 (2023-03-23) -------------------------- - * Fix suspending and unsuspending orgs so that it correctly updates children - * Use a name for the active org that doesn't collide - -v8.1.87 (2023-03-23) -------------------------- - * Manually fix version number - -v8.1.86 (2023-03-23) -------------------------- - * Fix scrolling on WhatsApp templates page - -v8.1.85 (2023-03-23) -------------------------- - * Handle short screens better on run list page - -v8.1.84 (2023-03-22) -------------------------- - * Update to coverage 7.x - -v8.1.83 (2023-03-22) -------------------------- - * Use onSpload to wire handlers on account form - -v8.1.82 (2023-03-22) -------------------------- - * Support setting and removing the subscription URL for MTN channels - -v8.1.81 (2023-03-21) -------------------------- - * Update ruff and isort - -v8.1.80 (2023-03-21) -------------------------- - * Update black - -v8.1.79 (2023-03-20) -------------------------- - * Add mouseover text for temba-date - * Reload page on org mismatch - * Use embedded title instead of response header - -v8.1.78 (2023-03-20) -------------------------- - * Add globals to new ui - * Make it harder to accidentally delete an org - * Rewrite org deletion test and fix deletion issues - -v8.1.77 (2023-03-16) -------------------------- - * Limit groups to a single line on contact page - -v8.1.76 (2023-03-16) -------------------------- - * Remove unused fields and indexes on broadcast model - * Reload page on version mismatch - * Add support for MTN Developer Portal channel - -v8.1.75 (2023-03-16) -------------------------- - * Add menu path for org export and import - * Fix legacy goto function for old UI - * Warn users who go back to the old interface - * Remove support for broadcasts with associated tickets - -v8.1.74 (2023-03-15) -------------------------- - * Show version number on public index page - * Add poetry plugin to maintain version number in temba/__init__.py - * Fix textinput inner scrolling - -v8.1.73 (2023-03-15) -------------------------- - * Stop returning type=flow|inbox on messages endpoint - * Cleanup location app models - -v8.1.72 (2023-03-14) -------------------------- - * Convert Org.config and Channel.config to be real JSON - -v8.1.71 (2023-03-14) -------------------------- - * Strip out invalid HTTP header characters from page title response headers - * Fix mailroom db command to patch uuid generation after migrations are run - * Expose flow on messages API endpoint - -v8.1.70 (2023-03-13) -------------------------- - * Broad support for meta click for new tabs - * Make Org.config and Channel.config non-null - -v8.1.69 (2023-03-13) -------------------------- - * Simplify use of config fields on channel update forms - * Fix alias editor to use the new UI frame - * Support updating Twilio credentials for T, TMS and TWA channels - -v8.1.68 (2023-03-13) -------------------------- - * Rework messages and broadcasts API endpoints to accept media ojects UUIDs as attachments - * Make Msg.uuid and msg_type non-null - -v8.1.67 (2023-03-10) -------------------------- - * Fix layering for menu - -v8.1.66 (2023-03-09) -------------------------- - * Fix initial editor load - * Schedule message validation - -v8.1.65 (2023-03-09) -------------------------- - * Update endpoints for messages and media - -v8.1.64 (2023-03-08) -------------------------- - * Tweak layout for editor - * Cleanup fail_old_messages task. Use correct statuses and return number failed. - -v8.1.63 (2023-03-08) -------------------------- - * Adjust export download page for new UI - * Make media list page (still staff only) filter by org and add index - -v8.1.62 (2023-03-08) -------------------------- - * Small z-index tweak - -v8.1.61 (2023-03-07) -------------------------- - * Tweak simulator placement in new ui - -v8.1.60 (2023-03-07) -------------------------- - * Encourage users to try the new interface - * Add lightbox for contact history - -v8.1.59 (2023-03-07) -------------------------- - * Rework code depending on msg_type=I|F - -v8.1.58 (2023-03-07) -------------------------- - * Add missing channels migration - * Use msg.created_by if set in ticket list view - * Remove SMS type channel alerts - -v8.1.57 (2023-03-06) -------------------------- - * Move index on msg.external_id onto the model - -v8.1.56 (2023-03-06) -------------------------- - * Fix soft deleting of scheduled messages so schedule is deleted too - * Stop saving JSONAsTextField values as null for empty dicts and lists - * Update select s3 usage for msg exports to not rely on type=inbox|flow - * Add created_by to Msg and populate on events in contact histories - -v8.1.55 (2023-03-02) -------------------------- - * Fix import for sync fcm task - * Create new filters and partial indexes for Inbox, Flows and Archived - -v8.1.54 (2023-03-02) -------------------------- - * Fix enter on compose - -v8.1.53 (2023-03-01) -------------------------- - * Add compose component to contact chat - * Pixel tweak on contact read page - * Move more Android relayer code out of Channel - -v8.1.52 (2023-03-01) -------------------------- - * Simplify what we display for Android channels on read page - -v8.1.50 (2023-02-28) -------------------------- - * Make spload universal - -v8.1.49 (2023-02-28) -------------------------- - * Make spload work on formax pages - -v8.1.48 (2023-02-28) -------------------------- - * Add more goto(event) - * Fix content differing from page-load vs inline load - * Add page title for spa response headers - * Clean up subtitles on spa pages - * Add link to flow starts (and clean up list page styling) - * Add link for webhook calls (and cleanup styling here too) - * Update styling for log pages for both old / new ui - -v8.1.47 (2023-02-27) -------------------------- - * Be less clever with page titles. Fix label js errors. - * Make sure tests can run without making requests to external URLs - * Unpublicize folder=incoming on messages API docs and re-add index with status=H - -v8.1.46 (2023-02-23) -------------------------- - * Fix external links in old ui - -v8.1.45 (2023-02-23) -------------------------- - * Fix external channel links - * No longer intercept clicks in spa-content - * Cleanup Channel model fields - * Fix channel claim external URLs in new UI - -v8.1.44 (2023-02-23) -------------------------- - * Exclude PENDING messages in contact history and API by org and contact - * Add -id to msg fetch ordering in Contact.get_history - * For both messages and tickets, replace the default indexes on org and contact with indexes that match the API ordering - -v8.1.43 (2023-02-23) -------------------------- - * Use statement level db trigger for broadcast msg counts - * Update django to 4.1.7 - -v8.1.42 (2023-02-22) -------------------------- - * Only look at queued messages when syncing android channels - * Re-add Msg.STATUS_INITIALIZING to use for outgoing messages which fail to queue - * Include STATUS_ERRORED messages in Outbox views - -v8.1.41 (2023-02-22) -------------------------- - * Remove suprious property - -v8.1.40 (2023-02-22) -------------------------- - * Fix contact imports in new ui - * Fix menu refresh race - * Remove window.lastFetch - * Adjust menu paths for new UI channel views - * Use SpaMixin to more channels extra views - -v8.1.39 (2023-02-22) -------------------------- - * Move Msg.update into android package - * Make text optional on broadcasts endpoint (messages need text or attachments) - -v8.1.38 (2023-02-21) -------------------------- - * Fix dashboard not loading when content - * Fix handling FCM sync failure - -v8.1.37 (2023-02-21) -------------------------- - * Don't lookup related fields in API if lookup value type is wrong - * Update django 4.0.10 - * Fetching sent folder on messages endpoint should return messages ordered by -sent_on same as UI - * Exclude unhandled messages from Incoming folder on messages API endpoint - * More agressive menu refreshing - * Move much of the old android relayer code into its own package - * Add media API endpoint, undocumented for now - * Open up new UI access to everyone - -v8.1.36 (2023-02-20) -------------------------- - * Cleanup use of validators in the API - * Add support for Msg.TYPE_TEXT to be used (for now) for outgoing messages - -v8.1.35 (2023-02-17) -------------------------- - * Add org start redirection view - * Convert Attachment to be a dataclass - * Rework msg write serializer to create a transient Msg instance that the read serializer can use without hitting the db - * Add unpublicized API endpoint to send a single message - * Add msg_send to mailroom client - -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 - -v8.1.19 (2023-02-01) -------------------------- - * Add Msg.quick_replies - * Add Broadcast.query - * More generic servicing for staff users - -v8.1.18 (2023-02-01) -------------------------- - * Drop un-used Media.name field - -v8.1.17 (2023-01-31) -------------------------- - * Fix modax from menu bug - -v8.1.15 (2023-01-30) -------------------------- - * Add new org chooser with avatars in new UI - * Add dashboard to menu in new UI - -v8.1.14 (2023-01-27) -------------------------- - * Add ordering support for filters - * Fix redirect ping pong when managing orgs - * Tweak inspect_flows command to report spec veresion mismatches - -v8.1.13 (2023-01-26) -------------------------- - * Update flow editor - -v8.1.12 (2023-01-26) -------------------------- - * Add locale field to Msg - -v8.1.11 (2023-01-25) -------------------------- - * Add migration to alter flow language field to first update any remaining flows with 'base' - -v8.1.10 (2023-01-25) -------------------------- - * Require flow and broadcast base languages to 3 letters - * Require broadcast.translations to be non-null - -v8.1.9 (2023-01-25) -------------------------- - * Drop unused broadcast fields - -v8.1.8 (2023-01-24) -------------------------- - * Make Broadcast.text nullable and stop writing it - -v8.1.7 (2023-01-24) -------------------------- - * Stop reading from Broadcast.text - -v8.1.6 (2023-01-23) -------------------------- - * Fix campaign imports so we don't import base as a language - * Increase max-width for channel configuration page - * Support bandwidth channel type - -v8.1.5 (2023-01-23) -------------------------- - * Data migration to backfill broadcast.translations and replace base with und - -v8.1.4 (2023-01-20) -------------------------- - * Update campaign message events with language base - * Make servicing to use posterize - -v8.1.3 (2023-01-19) -------------------------- - * Tweak broadcasts API endpoint so it filters by is_active and hits index - * Fix indexes used for tickets API endpoint - * Remove unused indexes on contacts_contact - * Bump engine version to 13.2 - -v8.1.2 (2023-01-19) -------------------------- - * Fixes for content menu changes - * Fix test_db to create orgs with flow languages - -v8.1.1 (2023-01-18) -------------------------- - * Restrict creating surveyor flows unless that is enabled as a feature - * Always create braodcasts with status = QUEUED, create index for fetching queued broadcasts - * Add new translations JSON field to broadcasts and start writing it - * Remove support for creating broadcasts with legacy expressions - * New content menu component - -v8.1.0 (2023-01-17) -------------------------- - * Update contact import styling - * Implement squashed migrations - * Stop trimming flow starts as this will be handled by archiver - -v8.0.1 (2023-01-12) -------------------------- - * Tweak migration dependencies to ensure clean installs run them in order that works - * Add empty migrations required for squashing - -v8.0.0 (2023-01-10) -------------------------- - * Update deps - -v7.5.149 (2023-01-10) -------------------------- - * Drop FlowRunCount model - -v7.5.148 (2023-01-09) -------------------------- - * Stop squashing FlowRunCount - * Add misisng index on FlowRunStatusCount and rework get_category_counts to be deterministic - * Stop creating flows_flowruncount rows in db triggers and remove unsquashed index - * Bump required pg_dump version for mailroom_db command to 14 - -v7.5.147 (2023-01-09) -------------------------- - * Use und (Undetermined) as default flow language and add support for mul (Multiple) - * Disallow empty and null flow languages, change default spec version to zero - * Tweak migrate_flows to have smaller batch size and order by org to increase org assets cache hits - -v7.5.146 (2023-01-05) -------------------------- - * Cleanup migrate_flows command and stop excluding flows with version 11.12 - * Change sample flows language to eng - * Refresh menu when tickets are updated - * Fix frame-top analytics includes - * Fix transparency issue with content menu on editor page - -v7.5.145 (2023-01-04) -------------------------- - * Update flow editor to include fix for no expiration route on ivr - * Stop defaulting to base for new flow languages - -v7.5.144 (2023-01-04) -------------------------- - * Ensure all orgs have at least one flow language - * Switch to using temba-date in more places - -v7.5.143 (2023-01-02) -------------------------- - * Update mailroom version for CI - * Tidy up org creation (signups and grants) - -v7.5.142 (2022-12-16) -------------------------- - * Fix org listing when org has no users left - -v7.5.141 (2022-12-16) -------------------------- - * Fix searching for orgs on manage list page - * Fix highcharts colors - * Fix invalid template name - -v7.5.140 (2022-12-15) -------------------------- - * Fix flow results page - -v7.5.136 (2022-12-15) -------------------------- - * Tell codecov to ignore static/ - * Switch label action buttons to use temba-dropdown - -v7.5.135 (2022-12-13) -------------------------- - * Fix content menu display issues - -v7.5.134 (2022-12-13) -------------------------- - * Switch to yarn - -v7.5.133 (2022-12-12) -------------------------- - * Bump required python version to 3.10 - -v7.5.132 (2022-12-12) -------------------------- - * Support Python 3.10 - -v7.5.131 (2022-12-09) -------------------------- - * Replace .gauge on analytics backend with .gauges which allows backends to send guage values in bulk - * Remove celery auto discovery for jiochat and wechat tasks which were removed - -v7.5.130 (2022-12-09) -------------------------- - * Record cron time in analytics - -v7.5.129 (2022-12-08) -------------------------- - * Cleanup cron task names - * Split task to trim starts and sessions into two separate tasks - * Expose all status counts on flows endpoint - * Read from FlowRunStatusCount instead of FlowRunCount - * Track flow start counts in statement rather than row level trigger - -v7.5.128 (2022-12-07) -------------------------- - * Record cron task last stats in redis - * Switch from flake8 to ruff - * Add data migration to convert exit_type counts to status counts - -v7.5.127 (2022-12-07) -------------------------- - * Fix counts for triggers on the menu - -v7.5.126 (2022-12-06) -------------------------- - * Add new count model for run statuses managed by by-statement db triggers - -v7.5.125 (2022-12-05) -------------------------- - * Tweak index used to find messages to retry so that it includes PENDING messages - -v7.5.124 (2022-12-05) -------------------------- - * Update to latest components - * More updates for manage pages - -v7.5.123 (2022-12-02) -------------------------- - * Fix bulk labelling flows - -v7.5.122 (2022-12-02) -------------------------- - * Add user read page - * Latest components - * Rework notification and incident types to function more like other typed things - * Add org timezone to manage page - * Remove no longer used group list view - * Log celery task completion by default and rework some tasks to return results included in the logging - * Refresh browser on field deletion in legacy - * Show org plan end as relative time - * Don't show location field types as options on deploys where locations aren't enabled - -v7.5.121 (2022-11-30) -------------------------- - * Fix loading of notification types - -v7.5.120 (2022-11-30) -------------------------- - * Rework notification types to work more like channel types - * Update API fields endpoint to use name and type for writes as well as reads - * Remove unused field on campaign events write serializer - * Change undocumented pinned field on fields endpoint to be featured - * Add usages field to fields API endpoint, as well as name and type to replace label and value_type - * Add Line error reference URL - -v7.5.119 (2022-11-29) -------------------------- - * Fix flow label in list buttons - * Fix editor StartSessionForm bug for definitions without exclusions - * Remove no longer needed check for plan=parent - -v7.5.118 (2022-11-28) -------------------------- - * Add telgram and viber error reference URLs - * Make Org.plan optional - * Add support to create new workspaces from org chooser - -v7.5.117 (2022-11-23) -------------------------- - * Update to latest editor - * Drop Org.is_multi_org and Org.is_multi_user which have been replaced by Org.features - -v7.5.116 (2022-11-23) -------------------------- - * Fix flow label name display - -v7.5.115 (2022-11-22) -------------------------- - * Default to no features on new child orgs - * Add features field to org update UI - -v7.5.114 (2022-11-22) -------------------------- - * Add Org.features and start writing it - * Add error ref url for FBA and IG - * Update temba-components to get new link icon - * Cleanup msg status constants - * Always create new orgs with default plan and only show org_plan for non-child orgs - -v7.5.113 ----------- - * Stop reading Label.label_type and make nullable - * Remove all support for labels with parents - -v7.5.112 ----------- - * Remove OrgActivity - -v7.5.111 ----------- - * Delete associated exports when trying to delete message label folders - -v7.5.110 ----------- - * Data migration to flatten msg labels - -v7.5.109 ----------- - * Remove logic for which plan to use for a new org - -v7.5.108 ----------- - * Tweak how get_new_org_plan is called - * Move isort config to pyproject - * Remove no longer used workspace plan - -v7.5.107 ----------- - * Treat parent and workspace plans as equivalent - -v7.5.106 ----------- - * Tweak flow label flatten migration to not allow new names to exceed 64 chars - -v7.5.105 ----------- - * Display channel logs with earliest at top - -v7.5.104 ----------- - * Remove customized 500 handler - * Remove sentry support - * Data migration to flatten flow labels - * Fix choice of brand for new orgs and move plan selection to classmethod - * Catch CSV corrupted errors - -v7.5.103 ----------- - * Some people don't care for icon constants - * Remove shim for browsers older than IE9 - * Remove google analytics settings - -v7.5.102 ----------- - * Remove google analytics - -v7.5.101 ----------- - * Fix Org.promote - -v7.5.100 ----------- - * Add Org.promote utility method - * Simplify determining whether to rate limit an API request by looking at request.auth - * Data migration to simplify org hierarchies - -v7.5.99 ----------- - * Rename security_settings.py > settings_security.py for consistency - * Drop Org.uses_topups, TopUp, and Debit - * Update to latest components - * Remove unused settings - * Remove TopUp, Debit and Org.uses_topups - -v7.5.98 ----------- - * Drop triggers, indexes and functions related to topups - -v7.5.97 ----------- - * Update mailroom_db command to use postgresql 13 - * Remove User.get_org() - * Always explicitly provide org when requesting a user API token - * Remove Msg.topup, TopUpCredits, and CreditAlert - * Test against latest redis 6.2, elastic 7.17.7 and postgres 13 + 14 - -v7.5.96 ----------- - * Remove topup credits squash task from celery beat - -v7.5.95 ----------- - * Update API auth classes to set request.org and use that to set X-Temba-Org header - * Use dropdown for brand field on org update form - * Remove topups - -v7.5.94 ----------- - * Add missing migration - * Remove support for orgs with brand as the host - * Remove brand tiers - -v7.5.93 ----------- - * Fix new event modal listeners - * Re-add org plan and plan end to update form - * Add png of rapidpro logo - * Update mailroom_db and test_db commands to set org brand as slug - * Add data migration to convert org.brand to be the brand slug - -v7.5.92 ----------- - * Create cla.yml - * Rework branding to not require modifying what is in the settings - -v7.5.91 ----------- - * Remove outdated contributor files - -v7.5.90 ----------- - * Update flow editor - * Remove unused fields from ChannelType - * Allow non-beta users to add WeChat channels - -v7.5.89 ----------- - * Properly truncate the channel name when claiming a WAC channel - * Fix not saving selected date format to new child org - * Add redirect from org_create_child if org has a parent - * Remove unused Org.get_account_value - * Don't allow creation of child orgs within child orgs - * Remove low credit checking code - -v7.5.88 ----------- - * Remove the token refresh tasks for jiochat and wechat channels as courier does this on demand - * Remove Stripe and bundles functionality - -v7.5.87 ----------- - * Remove unused segment and intercom dependencies - * Remove unused utils code - * Update TableExporter to prepare values so individual tasks don't have to - * Update versions of mailroom etc that we use for testing - * Add configurable group membership columns to message, ticket and results exports (WIP) - -v7.5.86 ----------- - * Remove no-loner used credit alert email templates - * Drop ChannelConnection - -v7.5.85 ----------- - * Remove unschedule option from scheduled broadcast read page - * Only show workspace children on settings menu - * Allow adding Android channel when its number is used on a WhatsApp channel - * Remove credit alert functionality - * Add scheduled message delete modal - -v7.5.84 ----------- - * No link fields on sub org page - -v7.5.83 ----------- - * Update telegram library which doesn't work with Python 3.10 - * Add user child workspace management - * Remove topup management views - -v7.5.82 ----------- - * Add JustCall channel type - -v7.5.81 ----------- - * Always show plan formax even for orgs on topups plan - -v7.5.80 ----------- - * Remove task to suspend topups orgs - -v7.5.79 ----------- - * Add new indexes for scheduled broadcasts view and API endpoint - * Update broadcast_on_change db trigger to check is_active - * Use database trigger to prevent status changes on flow sessions that go from exited to waiting - -v7.5.78 ----------- - * Remove old crisp templates - * Added Broadcast.is_active backfill migration - -v7.5.77 ----------- - * Proper redirect when removing channels - * Fix api header when logged out - * Take features out of branding and make it deployment level and remove api_link - * Get rid of flow_types as a branding setting - -v7.5.76 ----------- - * Tweak migration to convert missed call triggers to ignore archived triggers - -v7.5.75 ----------- - * Add Broadcast.is_active and set null=true and default=true - * Remove channel_status_processor context processor - * Add data migration to delete or convert missed call triggers - -v7.5.74 ----------- - * Fix webhook list page to not show every call as an error - * Small styling tweaks for api docs - * Remove fields from msgs event payloads that are no longer used - -v7.5.73 ----------- - * Update api docs to be nav agnostic - * Rewrite API Explorer to be vanilla javascript - * Use single permissions for all msg and contact list views - * Rework UI for incoming call triggers to allow selecting non-voice flows - * Remove send action from messages, add download results for flows - * Unload flow editor when navigating away - -v7.5.72 ----------- - * Always put service menu options at end of menu in new group - -v7.5.71 ----------- - * More appropriate login page, remove legacy textit code - -v7.5.70 ----------- - * Fix which fields should be on org update modal - * Honor brand config for signup - -v7.5.69 ----------- - * Fix race on editor load - -v7.5.68 ----------- - * Add failed reason for channel removed - * Remove no longer used channels option from interrupt_sessions task - -v7.5.67 ----------- - * Interrupt channel by mailroom task - -v7.5.66 ----------- - * Remove need for jquery on spa in-page loads - * Remove key/secret hardcoding for boto session - -v7.5.65 ----------- - * Queue relayer messages with channel UUID and id - * No nouns for current object in menus except for New - * Add common contact field inclusion to exports - * Fix new scheduled message menu option - * Fix releasing other archive files to use proper pagination - -v7.5.64 ----------- - * Add an unlinked call list page - * Show channel log links on more pages to more users - -v7.5.63 ----------- - * Fix handling of relayer messages - * Add missing email templates for ticket exports - -v7.5.62 ----------- - * Add attachment_fetch as new channel log type - -v7.5.61 ----------- - * Fix claiming vonage channels for voice - * Better approach for page titles from the menu - * Fix layout for ticket menu in new ui - -v7.5.60 ----------- - * Fix the flow results export modal - -v7.5.59 ----------- - * Delete attachments from storage when deleting messages - * Add base export class for exports with contact data - * Actually make date range required for message exports (currently just required in UI)) - * Add date range filtering to ticket and results exports - * Add ticket export (only in new UI for now) - -v7.5.58 ----------- - * Add twilio and vonage connection formax entries in new UI - * Update both main menu and content menus to align with new conventions - * Gate new UI by Beta group rather than staff - * Don't show new menu UIs until they're defined - -v7.5.57 ----------- - * Move status updates into update contact view - * Some teaks to rendering of channel logs - * Cleanup use of channelconnection in preparation for dropping - -v7.5.56 ----------- - * Really really fix connection migration - -v7.5.55 ----------- - * Really fix connection migration - -v7.5.54 ----------- - * Fix migration to convert connections to calls - -v7.5.53 ----------- - * Add data migration to convert channel connections to calls - -v7.5.52 ----------- - * Replace last non-API usages of User.get_org() - * Use new call model in UI - -v7.5.51 ----------- - * Add new ivr.Call model to replace channels.ChannelConnection - -v7.5.50 ----------- - * Drop no-longer used ChannelLog fields - * Drop Msg.logs (replaced by .log_uuids) - * Drop ChannelConnection.connection_type - -v7.5.49 ----------- - * Fix test failing because python version changed - * Allow background flows for missed call triggers - * Different show url for spa and non-spa tickets - * Update editor to include fix for localizing categories for some splits - * Add data migration to delete existing missed call triggers for non-message flows - * Restrict Missed Call triggers to messaging flows - -v7.5.48 ----------- - * Stop recommending Android, always recommend Telegram - * Drop IVRCall proxy model and use ChannelConnection consistently - * Add migration to delete non-IVR channel connections - * Fix bug in user releasing and remove special superuser handling in favor of uniform treatment of staff users - -v7.5.47 ----------- - * Switch to temba-datepicker - -v7.5.46 ----------- - * Fix new UI messages menu - -v7.5.45 ----------- - * Replace some occurences of User.get_org() - * Add new create modal for scheduled broadcasts - -v7.5.44 ----------- - * Add data migration to cleanup counts for SystemLabel=Calls - * Tweak ordering of Msg menu sections - * Add slack channel - -v7.5.43 ----------- - * Include config for mailroom test db channels - * Remove Calls from msgs section - * Update wording of Missed Call triggers to clarify they should only be used with Android channels - * Only show Missed Call trigger as option for workspaces with an Android channel - * Change ChannelType.is_available_to and is_recommended_to to include org - -v7.5.42 ----------- - * Add data migration to delete legacy channel logs - * Drop support for channel logs in legacy format - -v7.5.41 ----------- - * Fix temba-store - -v7.5.40 ----------- - * Tweak forgot password success message - -v7.5.39 ----------- - * Add log_uuids field to ChannelConnection, ChannelEvent and Msg - * Improve `trim_http_logs_task` performance by splitting the query - -v7.5.38 ----------- - * Add codecov token to ci.yml - * Remove unnecessary maxdiff set in tests - * Fix to allow displaying logs that timed out - * Add HttpLog util and use to save channel logs in new format - * Add UUID to channel log and msgs - -v7.5.37 ----------- - * Show servicing org - -v7.5.36 ----------- - * Clean up chooser a smidge - -v7.5.35 ----------- - * Add org-chooser - * Refresh channel logs - * Add channel uuid to call log url - * Fix history state on tickets and contacts - * Update footer - * Add download icons for archives - * Fix create flow modal opener - * Flow editor embed styling - * Updating copyright dates and TextIt name (dba of Nyaruka) - -v7.5.34 ----------- - * Use elapsed_ms rather than request_time on channel log templates - * Update components (custom widths for temba-dialog, use anon_display where possible) - * Switch to temba-dialog based attachment viewer, remove previous libs - * Nicer collapsing on flow list columns - * Add overview charts for run results - -v7.5.33 ----------- - * ChannelLogCRUDL.List should use get_description so that it works if log_type is set - * Tweak channel log types to match what courier now creates - * Check for tabs after timeouts, don't auto-collapse flows - * Add charts to analytics tab - -v7.5.32 ----------- - * Update components with label fix - -v7.5.31 ----------- - * Add flow results in new UI - -v7.5.30 ----------- - * Remove steps for add WAC credit line to businesses - -v7.5.29 ----------- - * Fix servicing of channel logs - -v7.5.28 ----------- - * Stop writing to unused media name field - * Add missing C Msg failed reason - * Add anon-display field to API contact results if org is anon and make urn display null - -v7.5.27 ----------- - * Revert change to Contact.Bulk_urn_cache_initialize to have it set org on contacts - -v7.5.26 ----------- - * Don't set org on bulk initialized contacts - -v7.5.25 ----------- - * Fix filtering on channel log call page - * Add anon_display and use that when org is anon instead of using urn_display for anon id - * Add urn_display to contact reference on serialized runs in API - -v7.5.24 ----------- - * Fix missing service end button - -v7.5.23 ----------- - * Update to latest floweditor - * Add new ChannelLog log type choices and make description nullable - * Fix more content menus so that they can be fetched as JSON and add more tests - -v7.5.22 ----------- - * Remove unused policies.policy_read perm - * Replace all permission checking against Customer Support group with is_staff check on user - -v7.5.21 ----------- - * Allow views with ContentMenuMixin to be fetched as JSON menu items using a header - * Add new fields to channel log model and start reading from them if they're set - -v7.5.20 ----------- - * Update the links for line developers console on the line claim page - * Rework channel log details views into one generic one, one for messages, one for calls - -v7.5.19 ----------- - * Rework channel log rendering to use common HTTPLog template - * Fix titles on channel, classifier and manage logins pages - -v7.5.18 ----------- - * Workspace and user management in new UI - -v7.5.17 ----------- - * Show send history of scheduled broadcasts in correct order - * Only show option to delete runs to users who have that perm, and give editors that perm - * Update deps - -v7.5.16 ----------- - * Fixed zaper page title - * Validate channel name is not more than 64 characters - * Added 'authentication' to the temba anchor URL text - -v7.5.15 ----------- - * Fix URL for media uploads which was previously conflicting with media directory - -v7.5.14 ----------- - * Deprecate Media.name which can always be inferred from .path - * Improve cleaning of media filenames - * Convert legacy UUID fields on exports and labels - * Request instagram_basic permission for IG channels - -v7.5.11 ----------- - * Don't allow creating of labels with parents or editing labels to have a parent - * Rework the undocumented media API endpoint to be more specific to surveyor attachments - * Add MediaCRUDL with upload and list endpoints - * Remove requiring instagram_basic permission - -v7.5.10 ----------- - * Remove Media.is_ready, fix setting .status on alternates, add limit for upload size - * Rework ContentMenuMixin to put the menu in the context, and include new and legacy formats - -v7.5.9 ----------- - * Add status field to Media, move primary index to UUID field - -v7.5.8 ----------- - * Update floweditor - * Convert all views to use ContentMenuMixin instead of get_gear_links - * Add decorator to mock uuid generation in tests - * Process media uploads with ffmpeg in celery task - -v7.5.7 ----------- - * Add constraint to ensure non-waiting/active runs have exited_on set - * Add constraint to ensure non-waiting sessions have an ended_on - -v7.5.6 ----------- - * Remove unused upload_recording endpoint - * Add Media model - -v7.5.5 ----------- - * Remaining fallback modax references - * Add util for easier gear menu creation - * Add option to interrupt a contact from read page - -v7.5.4 ----------- - * Fix scripts on contact page start modal - * Add logging for IG channel claim failures - * Add features to BRANDING which determines whether brands have access to features - * Sort permissions a-z - * Fix related names on Flow.topics and Flow.users and add Topic.release - * Expose opened_by and opened_in over ticket API - -v7.5.3 ----------- - * Fix id for custom fields modal - -v7.5.2 ----------- - * Fix typo on archive button - * Only show active ticketers and topics on Open Ticket modal - * Add data migration to fix non-waiting sessions with no ended_on - -v7.5.1 ----------- - * Allow claiming WAC test numbers - * Move black setting into pyproject.toml - * Add Open Ticket modal view to contact read page - -v7.5.0 ----------- - * Improve user list page - * Add new fields to Ticket record who or what flow opened a ticket - * Refresh menu on modax redircts, omit excess listeners from legacy lists - * Fix field label vs name in new UI - * Add start flow bulk action in new UI - * Show zeros in menu items in new UI - * Add workspace selection to account page in new UI - * Scroll main content pane up on page replacement in new UI - -v7.4.2 ----------- - * Update copyright notice - * Update stable versions - -v7.4.1 ----------- - * Update locale files - -v7.4.0 ----------- - * Remove superfulous Beta group perm - * Update new UI opt in permissions - * More tweaks to WhatsApp Cloud channel claiming - -v7.3.79 ----------- - * Add missing Facebook ID - -v7.3.78 ----------- - * Add button to allow admin to choose more FB WAC numbers - -v7.3.77 ----------- - * Add contact ticket list in new UI - * Fix permissions to connect WAC - * Register the WAC number in the activate method - -v7.3.76 ----------- - * Add the Facebook dialog login if the token is not submitted successfully on WAC org connect - * Fix campaigns archive and activate buttons - * Update to latest Django - * Only display WA templates that are active - * Update flow start dialog to use start preview endpoint - * Add start flow bulk action for contacts - -v7.3.75 ----------- - * Redirect to channel page after WAC claim - * Fix org update pre form users roles list - * Adjust permission for org whatsapp connect view - * Ignore new conversation triggers without channels in imports - -v7.3.74 ----------- - * Use FB JS SDK for WAC signups - -v7.3.73 ----------- - * Add DB constraint to disallow active or waiting runs without a session - -v7.3.72 ----------- - * Add DB constraint to enforce that flow sessions always have output or output_url - -v7.3.71 ----------- - * Make sure all limits are updatable on the workspace update view - * Remove duplicated pagination - * Enforce channels limit per workspace - -v7.3.70 ----------- - * Fix workspace group limit check for existing group import - * Drop no longer used role m2ms - -v7.3.69 ----------- - * Fix campaign links - -v7.3.68 ----------- - * Add WhatsApp API version choice field - * Stop writing to the role specific m2m tables - * Add pending events tab to contact details - -v7.3.67 ----------- - * Merge pull request #3865 from nyaruka/plivo_claim - * formatting - * Sanitize plivo app names to match new rules - -v7.3.66 ----------- - * Merge pull request #3864 from nyaruka/fix-WA-templates - * Fix message templates syncing for new categories - -v7.3.65 ----------- - * Fix surveyor joins so new users are added to orgmembership as well. - -v7.3.64 ----------- - * Fix fetching org users with given roles - -v7.3.63 ----------- - * Update mailroom_db command to correctly add users to orgs - * Stop reading from org role m2m tables - -v7.3.62 ----------- - * Fix rendering of dates on upcoming events list - * Data migration to backfill OrgMembership - -v7.3.61 ----------- - * Add missing migration - -v7.3.60 ----------- - * Data migration to fail active/waiting runs with no session - * Include scheduled triggers in upcoming contact events - * Add OrgMembership model - -v7.3.59 ----------- - * Spreadsheet layout for contact fields in new UI - * Adjust WAC channel claim to add system admin with user token - -v7.3.58 ----------- - * Clean up chat media treatment - * Add endpoint to get upcoming scheduled events for a contact - * Remove filtering by ticketer on tickets API endpoint and add indexes - * Add status to contacts API endpoint - -v7.3.57 ----------- - * Improve WAC phone number verification flow and feedback - * Adjust name of WAC channels to include the number - * Fix manage user update URL on org update page - * Support missing target_ids key in WAC responses - -v7.3.56 ----------- - * Fix deletion of users - * Cleanup user update form - * Fix missing users manage link page - * Add views to verify and register a WAC number - -v7.3.55 ----------- - * Update contact search summary encoding - -v7.3.54 ----------- - * Make channel type a property and use to determine redact values in HTTP request logs - -v7.3.53 ----------- - * Make WAC channel visible to beta group - -v7.3.52 ----------- - * Fix field name for submitted token - -v7.3.51 ----------- - * Use default API throttle rates for unauthenticated users - * Bump pyjwt from 2.3.0 to 2.4.0 - * Cache user role on org - * Add WhatsApp Cloud channel type - -v7.3.50 ----------- - * Make Twitter channels beta only for now - * Use cached role permissions for permission checking and fix incorrect permissions on some -API views - * Move remaining mockey patched methods on auth.User to orgs.User - -v7.3.49 ----------- - * Timings in export stats spreadsheet should be rounded to nearest second - * Include failed_reason/failed_reason_display on msg_created events - * Move more monkey patching on auth.User to orgs.User - -v7.3.48 ----------- - * Include first reply timings in ticket stats export - * Create a proxy model for User and start moving some of the monkey patching to proper methods on that - -v7.3.47 ----------- - * Data migration to backfill ticket first reply timings - -v7.3.46 ----------- - * Add new squashable model to track average ticket reply times and close times - * Add Ticket.replied_on - -v7.3.45 ----------- - * Add endpoint to export Excel sheet of ticket daily counts for last 90 days - -v7.3.44 ----------- - * Remove omnibox support for fetching by label and message - * Remove functionality for creating new label folders and creating labels with folders - -v7.3.43 ----------- - * Fix generating cloned flow names so they can't end with trailing spaces - * Deleting of globals should be soft like other types - * Simplify checking of workspace limits in UI and API - -v7.3.42 ----------- - * Data migration to backfill ticket daily counts - -v7.3.41 ----------- - * Reorganization of temba.utils.models - * Update the approach to the test a token is valid for FBA and IG channels - * Promote ContactField and Global to be TembaModels whilst for now retaining their custom name validation logic - * Add import support methods to TembaModel and use with Topic - -v7.3.40 ----------- - * Add workspace plan, disallow grandchild org creation. - * Add support for shared usage tracking - -v7.3.39 ----------- - * Move temba.utils.models to its own package - * Queue broadcasts to mailroom with their created_by - * Add teams to mailroom test database - * Add is_system to TembaModel, downgrade Contact to SmartModel - -v7.3.38 ----------- - * Make sure we request a FB long lived page token using a long lived user token - * Convert campaign and campaignevent to use real UUIDs, simplify use of constants in API - -v7.3.37 ----------- - * Don't forget to squash TicketDailyCount - * Fix imports of flows with ticket topic dependencies - -v7.3.36 ----------- - * Add migration to update names of deleted labels and add constraint to enforce uniqueness - * Move org limit checking from serializers to API views - * Generalize preventing deletion of system objects via the API and allow deleting of groups that are used in flows - * Serialized topics in the API should include system field - * Add name uniqueness constraints to Team and Topic - * Add Team and TicketDailyCount models - -v7.3.35 ----------- - * Tweaks to Topic model to enforce name uniqueness - * Add __str__ and __repr__ to TembaModel to replace custom methods and remove several unused ones - * Convert FlowLabel to be a TembaModel - -v7.3.34 ----------- - * Fix copying flows to generate a unique name - * Rework TembaModel to be a base model class with UUID and name - -v7.3.33 ----------- - * Use model mixin for common name functionality across models - -v7.3.32 ----------- - * Add DB constraint to enforce flow name uniqueness - -v7.3.31 ----------- - * Update components with resolved locked file - -v7.3.29 ----------- - * Fix for flatpickr issue breaking date picker - * ContactField.get_or_create should enforce name uniqeuness and ignore invalid names - * Add validation error when changing type of field used by campaign events - -v7.3.28 ----------- - * Tweak flow name uniqueness migration to honor max flow name length - -v7.3.27 ----------- - * Tweak header to be uniform treatment regardless of menu - * Data migration to make flow names unique - * Add flow.preview_start endpoint which calls mailroom endpoint - -v7.3.26 ----------- - * Fix mailroom_db command to set languages on new orgs - * Fix inline menus when they have no children - * Fix message exports - -v7.3.25 ----------- - * Fix modals on spa pages - * Add service button to org edit page - * Update to latest django - * Add flow name to message Export if we have it - -v7.3.24 ----------- - * Allow creating channel with same address when schemes do not overlap - -v7.3.23 ----------- - * Add status to list of reserved field keys - * Migration to drop ContactField.label and field_type - -v7.3.22 ----------- - * Update contact modified_on when deleting a group they belong to - * Add custom name validator and use for groups and flows - -v7.3.21 ----------- - * Fix rendering of field names on contact read page - * Stop writing ContactField.label and field_type - -v7.3.20 ----------- - * Stop reading ContactField.label and field_type - -v7.3.19 ----------- - * Correct set new ContactField fields in mailroom_db test_db commands - * Update version of codecov action as well as versions of rp-indexer and mailroom used by tests - * Data migration to populate name and is_system on ContactField - -v7.3.18 ----------- - * Give contact fields a name and is_system db field - * Update list of reserved keys for contact fields - -v7.3.17 ----------- - * Fix uploading attachments to properly get uploaded URL - -v7.3.16 ----------- - * Fix generating of unique flow, group and campaign names to respect case-insensitivity and max name length - * Add data migration to prefix names of previously deleted flows - * Prefix flow names with a UUID when deleted so they don't conflict with other flow names - * Remove warning about feature on flow start modal being removed - -v7.3.15 ----------- - * Check name uniqueness on flow creation and updating - * Cleanup existing field validation on flow and group forms - * Do not fail to release a channel when we cannot reach the Facebook API for FB channels - -v7.3.14 ----------- - * Convert flows to be a soft dependency - -v7.3.13 ----------- - * Replace default index on FlowRun.contact with one that includes flow_id - -v7.3.12 ----------- - * Data migration to give every workspace an Open Tickets smart system group - -v7.3.11 ----------- - * Fix bulk adding/removing to groups from contact list pages - * Convert groups into a soft dependency for flows - * Use dataclasses instead of NaamedTuples where appropriate - -v7.3.10 ----------- - * Remove path from example result in runs API endpoint docs - * Prevent updating or deleting of system groups via the API or UI - * Add system property to groups endpoint and fix docs - -v7.3.9 ----------- - * Remove IG channel beta gating - -v7.3.8 ----------- - * Fix fetching of groups from API when using separate readonly DB connection - -v7.3.7 ----------- - * Rework how we fetch contact groups - -v7.3.6 ----------- - * For FB / IG claim pages use expiring token if no long lived token is provided - -v7.3.5 ----------- - * Data migration to update group_type=U to M|Q - -v7.3.4 ----------- - * Merge pull request #3734 from nyaruka/FB-IG-claim - -v7.3.3 ----------- - * Check all org groups when creating unique group names - * Make ContactGroup.is_system non-null and switch to using to distinguish between system and user groups - -v7.3.2 ----------- - * Data migration to populate ContactGroup.is_system - -v7.3.1 ----------- - * Add is_system field to ContactGroup and rename 'dynamic' to 'smart' - * Return 404 from edit_sub_org if org doesn't exist - * Use live JS SDK for FBA and IG refresh token views - * Add scheme to flow results exports - -v7.3.0 ----------- - * Add countries supported by Africastalking - * Replace empty squashed migrations with real ones - -v7.2.4 ----------- - * Update stable versions in README - -v7.2.3 ----------- - * Add empty versions of squashed migrations to be implemented in 7.3 - -v7.2.2 ----------- - * Updated translations from Transifex - * Fix searching on calls list page - -v7.2.1 ----------- - * Update locale files - -v7.2.0 ----------- - * Disallow PO export/import for archived flows because mailroom doesn't know about them - * Add campaigns section to new UI - -v7.1.82 ----------- - * Update to latest flake8, black and isort - -v7.1.81 ----------- - * Remove unused collect_metrics_task - * Bump dependencies - -v7.1.80 ----------- - * Remove progress bar on facebook claim - * Replace old indexes based on flows_flowrun.is_active - -v7.1.79 ----------- - * Remove progress dots for FBA and IG channel claim pages - * Actually drop exit_type, is_active and delete_reason on FlowRun - * Fix group name validation to include system groups - -v7.1.78 ----------- - * Test with latest indexer and mailroom - * Stop using FlowRun.exit_type, is_active and delete_reason - -v7.1.77 ----------- - * Tweak migration as Postgres won't let us drop function being used - -v7.1.76 ----------- - * Update vonage deprecated methods - -v7.1.75 ----------- - * Rework flowrun db triggers to use status rather than exit_type or is_active - -v7.1.74 ----------- - * Allow archiving of flow messages - * Don't try interrupting session that is about to be deleted - * Tweak criteria for who can preview new interface - -v7.1.73 ----------- - * Data migration to fix facebook contacts name - -v7.1.72 ----------- - * Revert database trigger changes which stopped deleting path and exit_type counts on flowrun deletion - -v7.1.71 ----------- - * Fix race condition in contact deletion - * Rework flowrun database triggers to look at delete_from_results instead of delete_reason - -v7.1.69 ----------- - * Update to latest floweditor - -v7.1.68 ----------- - * Add FlowRun.delete_from_results to replace delete_reason - -v7.1.67 ----------- - * Drop no longer used Msg.delete_reason and delete_from_counts columns - * Update to Facebook Graph API v12 - -v7.1.66 ----------- - * Fix last reference to Msg.delete_reason in db triggers and stop writing that on deletion - -v7.1.65 ----------- - * Rework msgs database triggers so we don't track counts for messages in archives - -v7.1.64 ----------- - * API rate limits should be org scoped except for staff accounts - * Expose current flow on contact read page for all users - * Add deprecation text for restart_participants - -v7.1.63 ----------- - * Fix documentation of contacts API endpoint - * Release URN channel events in data migration to fix deleted contacts with tickets - * Use original filename inside UUID folder to upload media files - -v7.1.62 ----------- - * Tweak migration to only fully delete inactive contacts with tickets - -v7.1.61 ----------- - * Add flow field to contacts API endpoint - * Add support to the audit_es command for dumping ES queries - * Add migration to make sure contacts which we failed to delete are really deleted - * Fix contact release with tickets having a broadcast - -v7.1.60 ----------- - * Adjust WA message template warning to not be show for Twilio WhatsApp channels - * Add support to increase API rates per org - -v7.1.59 ----------- - * Add migration to populate Contact.current_flow - -v7.1.58 ----------- - * Restrict msg visibility changes on bulk actions endpoint - -v7.1.57 ----------- - * Add sentry id for 500 page - * Display current flow on contact read page for beta users - * Add new msg visibility for msgs deleted by senders and allow deleted msgs to appear redacted in contact histories - * Contact imports should strip empty rows, missing a UUID or URNs - -v7.1.56 ----------- - * Fix issue with sending to step_node - * Add missing languages for whatsapp templates - * Add migration to remove inactive contacts from user groups - -v7.1.55 ----------- - * Fix horizontal scrolling in editor - * Add support to undo_footgun command to revert status changes - -v7.1.53 ----------- - * Relayer syncing should ignore bad URNs that fail validation in mailroom - * Add unique constraint to ContactGroup to enforce name uniqueness within an org - -v7.1.52 ----------- - * Fix scrolling select - -v7.1.51 ----------- - * Merge pull request #3671 from nyaruka/ui-widget-fixes - * Fix select for slow clicks and removing rules in the editor - -v7.1.50 ----------- - * Add migration to make contact group names unique within an organization - * Add cookie based path to opt in and out of new interface - -v7.1.49 ----------- - * Update to Django 4 - -v7.1.48 ----------- - * Make IG channel beta gated - * Remove expires_on, parent_uuid and connection_id fields from FlowRun - * Add background flow options to campaign event dialog - -v7.1.47 ----------- - * Make FlowSession.wait_resume_on_expire not-null - -v7.1.46 ----------- - * Add migration to set wait_resume_on_expire on flow sessions - * Update task used to update run expirations to also update them on the session - -v7.1.45 ----------- - * Make FlowSession.status non-null and add constraint to ensure waiting sessions have wait_started_on and wait_expires_on set - -v7.1.44 ----------- - * Fix login via password managers - * Change gujarati code language to 'guj' - * Add instagram channel type - * Add interstitial when inactive contact search meets threshold - -v7.1.42 ----------- - * Add missing migration - -v7.1.41 ----------- - * Add Contact.current_flow - -v7.1.40 ----------- - * Drop FlowRun.events and FlowPathRecentRun - -v7.1.39 ----------- - * Include qrious.js script - * Add FlowSession.wait_resume_on_expire - * Add Msg.flow - -v7.1.38 ----------- - * Replace uses of deprecated Django functions - * Remove crisp and librato analytics backends and add ConsoleBackend as example - * Data migration to populate FlowSession.wait_started_on and wait_expires_on - -v7.1.37 ----------- - * Migration to remove recent run creation from db triggers - * Remove no longer used recent messages view and functionality on FlowPathRecentRun - -v7.1.36 ----------- - * Add scheme column on contact exports for anon orgs - * Remove option to include router arguments in downloaded PO files - * Make loading of analytics backends dynamic based on setting of backend class paths - -v7.1.35 ----------- - * Only display crisp support widget if brand supports it - * Do crisp chat widget embedding via analytics template hook - -v7.1.34 ----------- - * Update to editor v1.16.1 - -v7.1.33 ----------- - * Add management to fix broken flows - * Use new recent contacts endpoint for editor - -v7.1.32 ----------- - * Temporarily put crisp_website_id back in context - -v7.1.31 ----------- - * Remove include_msgs option of flow result exports - -v7.1.30 ----------- - * Update to latest flow editor - -v7.1.29 ----------- - * Update to latest floweditor - * Add FlowSession.wait_expires_on - * Improve validation of flow expires values - * Remove segment and intercom integrations and rework librato and crisp into a pluggable analytics framwork - -v7.1.28 ----------- - * Convert FlowRun.id and FlowSession.id to BIGINT - -v7.1.27 ----------- - * Drop no longer used FlowRun.parent - -v7.1.26 ----------- - * Prefer UTF-8 if we're not sure about encoding of CSV import - -v7.1.25 ----------- - * Fix Kaleyra claim blurb - * Fix HTTPLog read page showing warning shading for healthy calls - -v7.1.24 ----------- - * Fix crisp identify on signup - * Use same event structure for Crisp as others - -v7.1.23 ----------- - * Update help links for the editor - * Add failed reason for failed destination such as missing channel or URNs - * Add view to fetch recent contacts from Redis - -v7.1.22 ----------- - * Fix join syntax - -v7.1.21 ----------- - * Fix join syntax, argh - -v7.1.20 ----------- - * Arrays not allowed on track events - -v7.1.19 ----------- - * Add missing env to settings_common - -v7.1.18 ----------- - * Implement crisp as an analytics integration - -v7.1.17 ----------- - * Tweak event tracking for results exports - * Revert change to hide non-responded runs in UI - -v7.1.16 ----------- - * Drop Msg.response_to - * Drop Msg.connection_id - -v7.1.15 ----------- - * Remove path field from API runs endpoint docs - * Hide options to include non-responded runs on results download modal and results page - * Fix welcome page widths - * Update mailroom_db to require pg_dump version 12.* - * Update temba-components - * Add workspace page to new UI - -v7.1.14 ----------- - * Fix wrap for recipients list on flow start log - * Set Msg.delete_from_counts when releasing a msg - * Msg.fail_old_messages should set failed_reason - * Add new fields to Msg: delete_from_counts, failed_reason, response_to_external_id - * Tweak msg_dewire command to only fetch messages which have never errored - -v7.1.13 ----------- - * Add management command to dewire messages based on a file of ids - * Render webhook calls which are too slow as errors - -v7.1.12 ----------- - * Remove last of msg sending code - * Fix link to webhook log - -v7.1.11 ----------- - * Remove unnecessary conditional load of jquery - -v7.1.10 ----------- - * Make forgot password email look a little nicer and be easier to localize - -v7.1.9 ----------- - * Fix email template for password forgets - -v7.1.8 ----------- - * Remove chatbase as an integration as it no longer exists - * Clear keyword triggers when switching to flow type that doesn't support them - * Use branded emails for export notifications - -v7.1.5 ----------- - * Remove warning on flow start modal about settings changes - * Add privacy policy link - * Test with Redis 3.2.4 - * Updates for label sub menu and internal menu navigation - -v7.1.4 ----------- - * Remove task to retry errored messages which now handled in mailroom - -v7.1.2 ----------- - * Update poetry dependencies - * Update to latest editor - -v7.1.1 ----------- - * Remove channel alert notifications as these will become incidents - * Add Incident model as well as OrgFlagged and WebhooksUnhealthy types - -v7.1.0 ----------- - * Drop no longer used index on msg UUID - * Re-run collect_sql - * Use std collection types for typing hints and drop use of object in classes - -v7.0.4 ----------- - * Fix contact stop list page - * Update to latest black to fix errors on Python 3.9.8 - * Add missing migration - -v7.0.3 ----------- - * Update to latest editor v1.15.1 - * Update locale files which adds cs and mn - -v7.0.2 ----------- - * Update editor to v1.15 with validation fixes - * Fix outbox pagination - * Add generic title bar with new dropdown on spa - -v7.0.1 ----------- - * Add missing JS function to delete messages in the archived folder - * Update locale files - -v7.0.0 ----------- - * Fix test failing to due bad domain lookup - -v6.5.71 ----------- - * Add migration to remove deleted contacts and groups from scheduled broadcasts - * Releasing a contact or group should also remove it from scheduled broadcasts - -v6.5.70 ----------- - * Fix intermittent credit test failure - * Tidy up Msg and Broadcast constants - * Simplify settings for org limit defaults - * Fix rendering of deleted contacts and groups in recipient lists - -v6.5.69 ----------- - * Remove extra labels on contact fields - -v6.5.68 ----------- - * Reenable chat monitoring - -v6.5.67 ----------- - * Make ticket views and components in sync - -v6.5.66 ----------- - * Add channel menu - * Add test for dynamic contact group list, remove editor_next redirect - * Fix styling on contact list headersa and flow embedding - * Add messages to menu, refresh override - * Switch contact fields and import to use template inheritance - * Use template inheritance for spa work - * Add deeplinking support for non-menued destinations - -v6.5.65 ----------- - * Move to Python 3.9 - -v6.5.64 ----------- - * Fix export notification email links - -v6.5.63 ----------- - * When a contact is released their tickets should be deleted - * Test on PG 12 and 13 - * Use S3 Select for message exports - * Use new notifications system for export emails - -v6.5.62 ----------- - * Use crontab for WA tokens task schedule - * Allow keyword triggers to be single emojis - * Celery 5.x - -v6.5.60 ----------- - * Add option to audit_archives to check flow run counts - * Drop no longer used ticket subject column - * Add contact read page based on contact chat component - -v6.5.59 ----------- - * Less progress updates in audit_archives - * Tweak tickets API endpoint to accept a uuid URL param - -v6.5.58 ----------- - * Add progress feedback to audit_archives - * Update locale files - -v6.5.57 ----------- - * Fix Archive.rewrite - -v6.5.56 ----------- - * Encode content hashes sent to S3 using Base64 - -v6.5.55 ----------- - * Trim mailgun ticketer names to <= 64 chars when creating - * Management command to audit archives - * Use field limiting on omnibox searches - -v6.5.54 ----------- - * Fix S3 select query generation for date fields - -v6.5.53 ----------- - * Disable all sentry transactions - * Use S3 select for flow result exports - * Add utils for compiling S3 select queries - -v6.5.52 ----------- - * Merge pull request #3555 from nyaruka/ticket-att - * Update test to include attachment list for last_msg - * Update CHANGELOG.md for v6.5.51 - * Merge pull request #3553 from nyaruka/httplog_tweaks - * Merge pull request #3554 from nyaruka/s3_retries - * Add other missing migration - * Add retry config to S3 client - * Add missing migration to drop WebhookResult model - * Update CHANGELOG.md for v6.5.50 - * Merge pull request #3552 from nyaruka/fix-WA-check-health-logs - * Fix tests - * Add zero defaults to HTTPLog fields, drop WebHookResult and tweak HTTPLog templates for consistency - * Fix response for WA message template to be HTTP response - * Update CHANGELOG.md for v6.5.49 - * Merge pull request #3549 from nyaruka/retention_periods - * Merge pull request #3546 from nyaruka/readonly_exports - * Merge pull request #3548 from nyaruka/fix-WA-check-health-logs - * Merge pull request #3550 from nyaruka/truncate-org - * Use single retention period setting for all channel logs - * Truncate org name with ellipsis on org chooser - * Add new setting for retention periods for different types and make trimming tasks more consistent - * Use readonly database connection for contact, message and results exports - * Add migration file - * Log update WA status error using HTTPLog - -v6.5.51 ----------- - * Add retry config to S3 client - * Add zero defaults to HTTPLog fields, drop WebHookResult and tweak HTTPLog templates for consistency - -v6.5.50 ----------- - * Fix response for WA message template to be HTTP response - -v6.5.49 ----------- - * Truncate org name with ellipsis on org chooser - * Add new setting for retention periods for different types and make trimming tasks more consistent - * Use readonly database connection for contact, message and results exports - * Log update WA status error using HTTPLog - -v6.5.48 ----------- - * Fix clear contact field event on ticket history - -v6.5.47 ----------- - * Use readonly database connection for contacts API endpoint - * Use webhook_called events from sessions for contact history - * Remove unused webhook result views and improve httplog read view - * Fix API endpoints not always using readonly database connection and add testing - -v6.5.46 ----------- - * Move list refresh registration out of content block - -v6.5.45 ----------- - * Temporarily disable refresh - * Don't use readonly database connection for GETs to contacts endpoint - * Add view for webhook calls saved as HTTP logs - * Pass location support flag to editor as a feature flag - -v6.5.44 ----------- - * GET requests to API should use readonly database on the view's queryset - -v6.5.43 ----------- - * Tweak how HTTP logs are deleted - * Add num_retries field to HTTPLog - -v6.5.42 ----------- - * Pin pyopenxel to 3.0.7 until 3.0.8 release problems resolved - * Add new fields to HTTPLog to support saving webhook results - * Make TPS for Shaqodoon be 5 by default - * Make location support optional via new branding setting - -v6.5.41 ----------- - * Update editor with fix for field creation - * Minor tidying of HTTPLog - * Fix rendering of tickets on contact read page which now don't have subjects - -v6.5.40 ----------- - * Update to floweditor 1.14.2 - * Tweak database settings to add new readonly connection and remove no longer used direct connection - * Update menu on ticket list update - -v6.5.38 ----------- - * Deprecate subjects on tickets in favor of topics - * Tweak ticket bulk action endpoint to allow unassigning - * Add API endpoint to read and write ticket topics - -v6.5.37 ----------- - * Add tracking of unseen notification counts for users - * Clear ticket notifications when visiting appropriate ticket views - * Remove no longer used Log model - -v6.5.36 ----------- - * Revert cryptography update - -v6.5.35 ----------- - * Update to newer pycountry and bump other minor versions - * Fix ticketer HTTP logs not being accessible - * Add management command to re-eval a smart group - * Add comment to event_fires about mailroom issue - * Fix indexes on tickets to match new UI - * Now that mailroom is setting ContactImport.status, use in reads - -v6.5.34 ----------- - * Update to latest components (fixes overzealous list refresh, non-breaking ticket summary, and display name when created_by is null) - -v6.5.33 ----------- - * Fix Add To Group bulk action on contact list page - * Add status field to ContactImport and before starting batches, set redis key mailroom can use to track progress - * Delete unused template and minor cleanup - -v6.5.32 ----------- - * Fix template indentation - * Pass force=True when closing ticket as part of releasing a ticketer - * Add beginings of new nav and SPA based UI (hidden from users for now) - -v6.5.31 ----------- - * Show masked urns for contacts API on anon orgs - * Rework notifications, don't use Log model - -v6.5.30 ----------- - * Fix deleting of imports and exports now that they have associated logs - -v6.5.29 ----------- - * Add basic (and unused for now) JSON endpoint for listing notifications - * Reduce sentry trace sampling to 0.01 - * Override kir language name - * Add change_topic as action to ticket bulk actions API endpoint - * Add Log and Notification model - -v6.5.28 ----------- - * Add new ticket event type for topic changes - * Migrations to assign default topic to all existing tickets - -v6.5.27 ----------- - * Add migration to give all existing orgs a default ticket topic - -v6.5.26 ----------- - * Move mailroom_db data to external JSON file - * Run CI tests with latest mailroom - * Add ticket topic model and initialize orgs with a default topic - -v6.5.25 ----------- - * Improve display of channels logs for calls - -v6.5.24 ----------- - * Add machine detection as config option to channels with call role - * Tweak event_fires management command to show timesince for events in the past - -v6.5.23 ----------- - * Drop retry_count, make error_count non-null - * Improve channel log templates so that we use consistent date formating, show call error reasons, and show back button for calls - * Tweak how we assert form errors and fix where they don't match exactly - * Re-add QUEUED status for channel connections - -v6.5.22 ----------- - * Tweak index used for retrying IVR calls to only include statuses Q and E - * Dont show ticket events like note added or assignment on contact read page - * Include error reason in call_started events in contact history - * Remove channel connection statuses that we don't use and add error_reason - -v6.5.21 ----------- - * Prevent saving of campaign events without start_mode - * Improve handling of group lookups in contact list views - * Add button to see channel error logs - -v6.5.20 ----------- - * Make ChannelConnection.error_count nullable so it can be removed - * Cleanup ChannelConnection and add index for IVR retries - * Fix error display on contact update modal - * Update to zapier app directory, wide formax option and fixes - * Enable filtering on the channel log to see only errors - -v6.5.19 ----------- - * Fix system group labels on contact read page - * Use shared error messages for orgs being flagged or suspended - * Update to latest smartmin (ignores _format=json on views that don't support it) - * Add command to undo events from a flow start - * Send modal should validate URNs - * Use s3 when appropriate to get session output - * Add basic user accounts API endpoint - -v6.5.18 ----------- - * Apply webhook ticket fix to successful webhook calls too - -v6.5.17 ----------- - * Tweak error message on flow start modal now field component is fixed - * Fix issue for ticket window growing with url length - * Update LUIS classifiers to work with latest API requirements - * Tweak migration to populate contact.ticket_count so that it can be run manually - * Switch from django.contrib.postgres.fields.JSONField to django.db.models.JSONField - * Introduce s3 utility functions, use for reading s3 sessions in contact history - -v6.5.16 ----------- - * Update to Django 3.2 - * Migration to populate contact.ticket_count - -v6.5.15 ----------- - * Add warning to flow start modal that options have changed - * Fix importing of dynamic groups when field doesn't exist - -v6.5.14 ----------- - * Update to latest cryptography 3.x - * Add deep linking for tickets - * Update db trigger on ticket table to maintain contact.ticket_count - -v6.5.13 ----------- - * Tweak previous data migration to work with migrate_manual - -v6.5.12 ----------- - * Migration to zeroize contact.ticket_count and make it non-null - -v6.5.11 ----------- - * Allow deletion of fields used by campaign events - * Add last_activity_on to ticket folder endpoints - * Add API endpoint for ticket bulk actions - * Add nullable Contact.ticket_count field - -v6.5.10 ----------- - * Remove textit-whatsapp channel type - * Show ticket counts on ticketing UI - * Update to latest components with fixes for scrollbar and modax reuse - * Use new generic dependency delete modal for contact fields - -v6.5.9 ----------- - * Add management command for listing scheduled event fires - * Add index for ticket count squashing task - * Add data migration to populate ticket counts - * Add constraint to Msg to disallow sent messages without sent_on and migration to fix existing messages like that - -v6.5.8 ----------- - * Fix celery task name - -v6.5.7 ----------- - * Fix flow start modal when starting flows is blocked - * Add more information to audit_es_group command - * Re-save Flow.has_issues on final flow inspection at end of import process - * Add squashable model for ticket counts - * Add usages modal for labels as well - * Update the WA API version for channel that had it set when added - * Break out ticket folders from status, add url state - -v6.5.6 ----------- - * Set sent_on if not already set when handling a mt_dlvd relayer cmd - * Display sent_on time rather than created_on time in Sent view - * Only sample 10% of requests to sentry - * Fix searching for scheduled broadcasts - * Update Dialog360 API usage - -v6.5.5 ----------- - * Fix export page to use new filter to get non-localized class name for ids - * Fix contact field update - * Add searchable to trigger groups - * Add option to not retry IVR calls - * Add usages modal for groups - * Tweak wording on flow start modal - -v6.5.4 ----------- - * Rework flow start modal to show options as exclusions which are unchecked by default - * Change sent messages view to be ordered by -sent_on - -v6.5.3 ----------- - * Add Last Seen On as column to contact exports - * Resuable template for dependency lists - -v6.5.2 ----------- - * Internal ticketer for all orgs - -v6.5.1 ----------- - * Cleanup Msg CRUDL tests - * Cleanup squashable models - * Apply translations in fr - * Replace trigger folders with type specific filtered list pages so that they can be sortable within types - -v6.4.7 ----------- - * Update flow editor to include lone-ticketer submit fix - * Fix pagination on the webhook results page - -v6.4.6 ----------- - * Update flow editor to fix not being able to play audio attachments in simulator - -v6.4.4 ----------- - * Start background flows with include_active = true - * Update flow editor with MediaPlayer fix - * Fix poetry content-hash to remove install warning - * Update translations from transifex - -v6.4.3 ----------- - * Improve contact field forms - * Fix urn sorting on contact update - * Improve wording on forms for contact groups, message labels and flow labels - * Improve wording on campaign form - -v6.4.2 ----------- - * Fix attachment button when attachments don't have extensions - * Add missing ticket events to contact history - * Fix clicking attachments in msgs view sometimes navigating to contact page - * Parameterized form widgets. Bigger, darker form bits. - * Tweak trigger forms for clarity - * Add command to rebuild messages and pull translations from transifex - -v6.4.1 ----------- - * Fix unassigning tickets - -v6.4.0 ----------- - * Update README - -v6.3.90 ----------- - * Fix alias editor to post json - -v6.3.89 ----------- - * Remove beta grating of internal ticketers - * Control which users can have tickets assigned to them with a permission - * Use mailroom endpoints for ticket assignment and notes - * Add custom user recover password view - -v6.3.88 ----------- - * Fix to display email on manage orgs - * Drop no longer used Broadcast.is_active field - -v6.3.87 ----------- - * Update indexes on ticket model - * Tweak ticketer default names - * Add empty ticket list treatment - * Fix API docs for messages endpoint to mention attachments rather than the deprecated media field - * Update to editor with hidden internal ticketers - * Consistent setting of modified_by when releasing/archiving/restoring - * Remove old ticket views - * Change ticketer sections on org home page to have Remove button and not link to old ticket views - * Add assignee to ticketing endpoints, some new filters and new assignment view - -v6.3.86 ----------- - * Stop writing Broadcast.is_active as default value - * Fix keyword triggers being imported without a valid match_type - -v6.3.85 ----------- - * User the current user as the manual trigger user during simulation - * Better trigger exports and imports - * Make broadcast.is_active nullable and stop filtering by it in the API - -v6.3.84 ----------- - * Ignore scheduled triggers in imports because they don't import properly - * Fix redirect after choosing an org for users that can't access the inbox - * Optionally filter ticket events by ticket in contact history view - -v6.3.83 ----------- - * Fix default content type for pjax requests - * Tweak queuing of flow starts to include created_by_id - -v6.3.82 ----------- - * Revert recent formax changes - -v6.3.81 ----------- - * Add Broadcast.ticket and expose as field (undocumented for now) on broadcast write API endpoint - * Refactor scheduling to use shared form - * Add exclusion groups to scheduled triggers - -v6.3.80 ----------- - * Update components so omnibox behaves like a field - * Drop Language model and Org.primary_language field - -v6.3.79 ----------- - * Order tickets by last_activity_on and update indexes to reflect that - * Backfill ticketevent.contact and use that for fetching events in contact history - * Fix creating scheduled triggers not being able to see week day options - * Handle reopen events for tickets - * Stop creating Language instances or setting Org.primary_language - -v6.3.78 ----------- - * Add Ticket.last_activity_on and TicketEvent.contact - * Rreturn tickets by modified_on in the API - * Add ability to reverse results for runs/contacts API endpoints - -v6.3.77 ----------- - * Better validation of invalid tokens when claiming Zenvia channels - * Fix languages formax to not allow empty primary language - -v6.3.76 ----------- - * Read org languages from org.flow_languages instead of Language instances - -v6.3.75 ----------- - * Fix closing and reopening of tickets from API - -v6.3.74 ----------- - * Add better labels and help text for groups on trigger forms - * Load ticket events from database for contact histories - * Fix rendering of closed ticket triggers on trigger list page - * Fix rendering of ticket events as JSON - * Fix for delete modals - -v6.3.73 ----------- - * Backfill ticket open and close events - * Add support for closed ticket triggers - -v6.3.72 ----------- - * Add CSRF tokens to modaxes - -v6.3.70 ----------- - * Add CSRF token to modax form - * Tweak padding for nav so we don't overlap alerts - * Only require current password to change email or password - * Fix icon colors on latest chrome - * Migration to backfill Org.flow_languages - -v6.3.69 ----------- - * Add Org.flow_languages and start populating in Org.set_languages - * Raise the logo so it can be clicked - -v6.3.68 ----------- - * Enable exclusion groups on triggers and make groups an option for all trigger types - * Add users to mailroom test db - * Add ticket note support to UI - -v6.3.67 ----------- - * Pass user id to ticket/close ticket/reopen endpoints to use in the TicketEvent mailroom creates - * Model changes for ticket assignment - * Make flow session output URL have a max length of 2048 - -v6.3.66 ----------- - * Add new ticket event model - * Add output_url field to FlowSession - -v6.3.65 ----------- - * Fix rendering of recipient buttons on outbox - * Rework trigger create forms to make conflict handling more consistent - * Iterate through all pages when syncing whatsapp templates - -v6.3.64 ----------- - * URL field on HTTPRequestLog should have max length of 2048 - -v6.3.63 ----------- - * Drop unused index on contact name, and add new org+modified_on index - -v6.3.62 ----------- - * Update components to single mailroom resource for completion - -v6.3.60 ----------- - * Only retry 5000 messages at a time, prefetch channel and fields - -v6.3.59 ----------- - * Enable model instances to show an icon in selects - -v6.3.58 ----------- - * Add model changes for closed ticket triggers - * Add model changes for exclude groups support on triggers - -v6.3.57 ----------- - * Tweak mailroom_db to make contact created_on values fixed - * Add trigger type folder list views - * Fix filtering of flows for new conversation triggers - * Fix ordering of channel fields on triggers - * Tweak inspect_flows command to handle unreadable flows - * Nest group buttons on campaign list so they don't grow to largest cell - -v6.3.56 ----------- - * Fix migrating flows whose definitions contain decimal values - * Update to tailwind 2, fix security warnings - * Simplify org filtering on CRUDLs - * Remove IS_PROD setting - -v6.3.55 ----------- - * Update layout and color for badge buttons - * Add management command to inspect flows and fix has_issues where needed - * Fix deleting flow labels with parents - * Fix broken org delete modal - * Add user arg to Org.release and User.release - -v6.3.54 ----------- - * Optimize message retries with a perfect index - * Convert channels to soft dependencies - -v6.3.53 ----------- - * Update to latest temba-components - -v6.3.52 ----------- - * Update to latest floweditor - * Adjust WA templates page title - * Fix Dialog360 WA templates sync - -v6.3.51 ----------- - * Adjust WA templates page styles - * Migration to clear next_attempt for android channels - -v6.3.50 ----------- - * Resend messages using web endpoint rather than task - * Convert message labels, globals and classifiers to use soft dependencies - -v6.3.49 ----------- - * Make Msg.next_attempt nullable and add msgs to mailroom_db - * Migration to ensure that inactive flows don't have any deps - * Fix Flow.release to remove template deps - -v6.3.48 ----------- - * Calculate proper msg id commands from relayer that have integer overflow issue - * Add reusable view for dependency deleting modals and switch to that and soft dependencies for ticketers - * Don't do mailroom session interruption during org deletion - * Fix org deletion when broadcasts have parents and webhook results have contacts - * Make sure templates and templates translations are deleted on org release - * Set max fba pages limit to 200 - -v6.3.47 ----------- - * Display warning icon in flow list for flows with issues - * Make Flow.has_issues non-null and cleanup unused localized strings on Flow model - * Support syncing Dialog360 Whatsapp templates - -v6.3.46 ----------- - * Fix channel log icons and disallow message resending for suspended orgs - * Add migration to populate Flow.has_issues - -v6.3.45 ----------- - * Add migration to populate template namespace - * Expose template translation namespace field on API - * Don't save issues into flow metadata but just set new field has_issues instead - * Queue mailroom task to do msg resends - -v6.3.44 ----------- - * Tweak import preview page so when adding to a group isn't enabled, the group controls are disabled - * Update flow editor and temba-components - -v6.3.40 ----------- - * Add namespace field to template translations - * Fetching and saving revisions should return flow issues as separate field - -v6.3.39 ----------- - * Rework task for org deletion - -v6.3.38 ----------- - * Move tickets endpoint to tickets crudl - * Refactor WhatsApp templates - * Add task for releasing of orgs - -v6.3.37 ----------- - * Fix contact imports always creating new groups - * Migration to fix escaped nulls in flow revision definitions - * Rework beta gated agent views to be tikect centric - -v6.3.35 ----------- - * Clear primary language when releasing org - * Strip out NULL characters when serializing JsonAsTextField values - * Override language names and ensure overridden names are used for searching and sorting - -v6.3.33 ----------- - * Update components and flow editor to common versions - * Allow external ticketers to use agent ui, add footer to tickets - -v6.3.32 ----------- - * Release import batches when releasing contact imports - -v6.3.31 ----------- - * Fix serializing JSON to send to mailroom when it includes decimals - -v6.3.30 ----------- - * Restrict org languages to ISO-639-1 plus explicit inclusions - -v6.3.29 ----------- - * Move Twilio, Plivo and Vonage number searching views into their respective channel packages - * Optimize query for fetching contacts with only closed tickets - * Release contact imports when releasing groups - * Proper skip anonymous user for analytics - -v6.3.28 ----------- - * Remove simplejson - * Update to latest vonage client and fix retries - -v6.3.27 ----------- - * Restore menu-2 icon used by org choose menu - -v6.3.26 ----------- - * Make groups searchable on contact update page - -v6.3.25 ----------- - * Add beta-gated tickets view - -v6.3.24 ----------- - * Change analytics.track to expect a user argument - * Add org released_on, use when doing full releases - * Ignore anon user in analytics - -v6.3.23 ----------- - * Clean up countries code used by various channel types - -v6.3.22 ----------- - * Show results in flow order - -v6.3.21 ----------- - * Fix Javascript error on two factor formax - * Beta-gate chatbase integration for now - -v6.3.20 ----------- - * Rework DT One and Chatbase into a new integrations framework - * Expose Org.language as default language for new users on org edit form - -v6.3.19 ----------- - * Add support for Zenvia SMS - * Cleanup parsing unused code on org model - * Fix flow update forms to show correct fields based on flow type - * Tweak JSONAsTextField to allow underlying DB column to be migrated to JSONB - * Add controls to import preview page for selecting existing groups etc - -v6.3.18 ----------- - * Fix template names - -v6.3.17 ----------- - * Fix font reference in scss - -v6.3.16 ----------- - * Add group name field to contact imports so that it can be customized - * Rename Nexmo to Vonage, update icon - * Merge the two used icomoon sets into one and delete unused one - * Cleanup problems in org view templates - -v6.3.15 ----------- - * Revert wording changes when orgs don't have email settings to clarify that we do send - * Fix wording of Results link in editor - -v6.3.14 ----------- - * Fix locale files - * Fix SMTP server settings views to explain that we don't send emails if you don't have a config - * Add API endpoint to fetch tickets filterable by contact - -v6.3.13 ----------- - * Clarify terms for exports vs downloads - * Fix rendering of airtime events in contact history - * Add flows import and flow exports links in the flows tab - -v6.3.12 ----------- - * Update to latest flow-editor - * Cleanup unused dates methods - * Update markdown dependency - * Expose exclude_active on flow start read API - * Support 3 digits short code on Jasmin channel type - * Add support for YYYY-MM-DD date format - * Update DT One support to collect api key and secret to use with new API - * Update parent remaining credits - * Release broadcasts properly - -v6.3.11 ----------- - * Fix redirect after submitting Start In Flow modal - -v6.3.10 ----------- - * Add support to exclude active contacts in other flows when starting a flow on API - * Remove unsupported channel field on broadcast create API endpoint - * Add Start Flow modal to contact read page - * Fix lock file being out of sync with pyproject - -v6.3.9 ----------- - * Revert update to use latest API version to get WA templates - * Fix setting Zenvia webhooks - * Update Django and Django REST Framework - -v6.3.8 ----------- - * Convert to poetry - -v6.3.6 ----------- - * Update pt_BR translation - * Update to use latest API version to get WA templates - * Display failed on flow results charts, more translations - * Zenvia WhatsApp - -v6.3.5 ----------- - * Fix broken flow results charts - -v6.3.4 ----------- - * Update to latest celery 4.x - -v6.3.2 ----------- - * Support reseting the org limits to the default settings by clearing the form field - * Update redis client to latest v3.5.3 - * Fix manage accounts form blowing up when new user has been created in background - -v6.3.1 ----------- - * Add support for runs with exit_type=F - * Support customization for org limits - -v6.3.0 ----------- - * Update stable versions and coverage badge link - * Style Outbox broadcasts with megaphone icons and use includes for other places we render contacts and groups - * Fix spacing on outbox view - * Add discord channel type - -v6.2.4 ----------- - * Update Portuguese translation - * Update to floweditor v1.13.5 - -v6.2.3 ----------- - * Update to latest floweditor v1.13.4 - -v6.2.2 ----------- - * Update to flow editor v1.13.3 - * Update Spanish translation - * Disable old Zenvia channel type - * Fix styles on fields list - -v6.2.1 ----------- - * Return registration details to Android if have the same UUID - * Add spacing between individual channel log events - * Fix external channel claim form - * Do not track Android channels creation by anon user - -v6.2.0 ----------- - * Update translations for es, fr and pt-BR - * Fix rendering of pending broadcasts in outbox view - -v6.1.48 ----------- - * Update editor with dial router changes - * Fix resthook formax validation - -v6.1.47 ----------- - * Change synched to synced - * Update to smartmin 2.3.5 - * Require recent authentication to view backup tokens - -v6.1.46 ----------- - * Update to smartmin 2.3.5 - * Fix handling of attempts to sync old unclaimed channels - * Add view to list all possible channel types - * Fix rendering of nameless channels - -v6.1.45 ----------- - * Open up 2FA to all users - * Do not allow duplicates invites - * Never respond with registration commands in sync handler - -v6.1.44 ----------- - * Enforce time limit between login and two factor verification - * Prevent inviting existing users - * Add disabled textinputs and better expression selection on selects - * Create failed login records when users enter incorrect backup tokens too many times - * Logout user to force login to accept invite and require invite email account exactly - -v6.1.43 ----------- - * Backup tokens can only be used once - * Add new 2FA management views - -v6.1.42 ----------- - * Use Twilio API to determine capabilities of new Twilio channels - * Fix result pages not loading for users using Spanish interface - -v6.1.41 ----------- - * Remove no longer used permissions - * Override login view to redirect to new views for two-factor authentication - * Reduce recent export window to 4 hours - * Change message campaign events to use background flows - -v6.1.40 ----------- - * Remove UserSettings.tel and add UserSettings.last_auth_on - -v6.1.39 ----------- - * Increase max len of URN fields on airtime transfers - * Add toggle to display manual flow starts only - * Cleanup 2FA models - -v6.1.38 ----------- - * Update flow editor to 1.12.10 with failsafe errors - * Make validation of external channel URLs disallow private and link local hosts - * Cleanup middleware used to set org, timezone and language - -v6.1.37 ----------- - * Update components and editor to latest versions - * Switch to microsecond accuracy timestamps - * Switch to default_storage for export assets - -v6.1.33 ----------- - * Tweaks to how we generate contact histories - -v6.1.32 ----------- - * Mute invalid host errors - * Add migration to alter m2ms to use bigints - * Drop no longer used database function - * Switch to big id for msgs and channel logs - -v6.1.31 ----------- - * Add management command to check sentry - * Remove unused context processor and unused code from org_perms - -v6.1.29 ----------- - * Rework contact history so that rendering as events happens in view and we also expose a JSON version - -v6.1.26 ----------- - * Upgrade urllib3 - -v6.1.25 ----------- - * Update to elastic search v7 - -v6.1.24 ----------- - * Broadcast events in history should be white like message events - -v6.1.23 ----------- - * Add index on flow start by start type - * Allow only deleting msg folders without active children labels - * Use engine events (with some extra properties) for msgs in contact history - -v6.1.22 ----------- - * Fix API serialization of background flow type - * Allow background flows to be used in scheduled triggers - * Update pip-tools - -v6.1.21 ----------- - * Configure editor and components to use completions files in current language - -v6.1.20 ----------- - * Update to latest floweditor and temba-components - -v6.1.19 ----------- - * Update to floweditor v1.12.6 - * Fix deleting classifiers - -v6.1.18 ----------- - * Add support for background flows - -v6.1.17 ----------- - * Update to flow editor v1.12.5 - * Fix importing dependencies when it's a clone in the same workspace - * Allow aliases to be reused on boundaries with different parent - * Increase max length on external channels to be configurable up to 6400 chars - * Fix contact export warning for existing export - -v6.1.16 ----------- - * Update to latest flow editor 1.12.3 - * Allow staff users to use the org chooser - -v6.1.15 ----------- - * Add constraint to chek URN identity mathes scheme and path - * Add non-empty constraint for URN scheme and path - * Fix contact list pagination with searches - * Show query on list page for smart groups - -v6.1.14 ----------- - * Change template translations to be TEXT - * Set global email timeout, fixes rapidpro #1345 - * Update tel parsing to match gocommon, fixing how we currently accept local US numbers - -v6.1.13 ----------- - * Bump temba-components to v0.8.11 - -v6.1.12 ----------- - * Un-beta-gate Rocket.Chat channels - -v6.1.10 ----------- - * Login summary on org home page should include agents - * Rework manage accounts UI to include agents - -v6.1.9 ----------- - * Fix deleted flow dependency preventing global deletion - * Cache lookups of auth.Group instances - -v6.1.8 ----------- - * For field columns in imports, only match against user fields - * Add agent role and cleanup code around org roles - -v6.1.7 ----------- - * Wire table listeners on pjax reload - * Update domain from swag.textit.com to whatsapp.textit.com - * Add internal ticketer type for BETA users - * Inner scrolling on contact list page - * Improve styles for recipient lists - -v6.1.6 ----------- - * Trim our start runs 1,000 at a time and by id - * Increase global max value length to 10000 and fix UI to be more consistent with fields - -v6.1.5 ----------- - * Share modals on globals list, truncate values - * Squash migrations - -v6.1.4 ----------- - * Add security settings file - * Fix intent selection on split by intent - * Add empty migrations for squashing in next release - -v6.1.3 ----------- - * Fix intent selection on split by intent - * Update callback URL for textit whatsapp - * Use Django password validators - -v6.1.2 ----------- - * Add TextIt WhatsApp channel type - -v6.1.1 ----------- - * Fix contact exports when orgs have orphaned URNs in schemes they don't currently use - -v6.1.0 ----------- - * Hide editor language dialog blurb until needed to prevent flashing - * Fix broken flows list page if org has no flows - * Allow underscores in global names - * Improve calculating of URN columns for exports so tests don't break every time we add new URN schemes - * Make instruction lists on channel claim pages more consistent - -v6.0.8 ----------- - * Editor fix for split by intents - * Add empty migrations for squashing in next release - -v6.0.7 ----------- - * Fix choose org page - * Fix recipient search - * Fix run deletion - -v6.0.6 ----------- - * Fix for textarea init - -v6.0.5 ----------- - * Adjust contact icon color in recipient lists - -v6.0.4 ----------- - * Fix recipients contacts and urns UI labels - * Fix flow starts log page pagination - * Update temba-components and flow-editor to common versions - * Fix flow label delete modal - * Fix global delete modal - -v6.0.3 ----------- - * Update to components v0.8.6, bugfix release - * Handle CSV imports in encodings other than UTF8 - -v6.0.2 ----------- - * Fix broken ticket re-open button - * Missing updated Fr MO file from previous merge - * Apply translations in fr - -v6.0.1 ----------- - * Fix orgs being suspended due to invalid topup cache - * Set uses_topups on new orgs based on whether our plan is the TOPUP_PLAN - * Fix validation issues on trigger update form - * Fix hover cursor in lists for viewers - * Action button alignment on archived messages - * Fix flow table header for viewers - * Fix tests for channel deletion - * Fix redirects for channel and ticketer deletion. - * Fix dialog when deleting channels with dependencies - * Match headers and contact fields with labels as well as keys during contact imports - -v6.0.0 ----------- - * Add Rocket.Chat ticketer to test database - -v5.7.91 ----------- - * Add Rocket.Chat ticketers - -v5.7.90 ----------- - * Update rocket.chat icon in correct font - -v5.7.89 ----------- - * Improve Rocket.Chat claim page - * Add Rocket.Chat icon - -v5.7.87 ----------- - * Cleanup Rocket.Chat UI - -v5.7.86 ----------- - * Add RocketChat channels (beta-only for now) - -v5.7.85 ----------- - * Add back jquery-migrate and remove debug - -v5.7.84 ----------- - * Remove select2, coffeescript, jquery plugins - -v5.7.83 ----------- - * Fix broken import link on empty contacts page - * Use consistent approach for limits on org - * Globals UI should limit creation of globals to org limit - * Fix archives list styles and add tabs for message and run archives - * Restyle the Facebook app channel claim pages - * Switch to use FBA type by default - -v5.7.82 ----------- - * Don't blow up if import contains invalid URNs but pass values on to mailroom - * Update to version of editor with some small styling tweaks - * Include occurred_on with mo_miss events queued to mailroom - * Adjust Twilio connect to redirect properly to the original claim page - * Remove no longer used FlowRun.timeout_on and drop two unused indexes - * Cleanup more localized strings with trimmed - * Fix 404 error in channel list - -v5.7.81 ----------- - * Add page title to brand so that its configurable - * Dont send alert emails for orgs that aren't using topups - * Consider timezone when infering org default country and display on import create page - * Add page titles to fields and flows - * Allow changing EX channels role on UI - -v5.7.80 ----------- - * Add contact last seen on to list contacts views - * Cleanup channel model fields - * Add charcount to send message dialog - * Show channel logs link for receive only channels - * Fix export flow page styles - * Allow searching for countries on channel claim views - -v5.7.79 ----------- - * Rework imports to allow importing multiple URNs of same scheme - * Cleanup no longer used URN related functionality - * Show contact last seen on on contact read page - -v5.7.78 ----------- - * Clean up models fields in contacts app - -v5.7.77 ----------- - * Fix styling on the API explorer page - * Fix list page selection for viewers - * Move contact field type constants to ContactField class - * Allow brand to be set by env variable - -v5.7.76 ----------- - * Drop support for migrating legacy expressions on API endpoints - * Fix imports blowing up when header is numerical - * Fix 11.4 flow migration when given broken send action - * Drop RuleSet and ActionSet models - -v5.7.75 ----------- - * Last tweaks before RuleSet and ActionSet can be dropped - * Contact id treatment for details - * Update components to ship ajax header and use it in language endpoint - * Remove no longer needed legacy editor completion - -v5.7.74 ----------- - * Remove legacy flow code - * WA channel tokens refresh catch errors for each channel independently - -v5.7.73 ----------- - * Make flows searchable and clickable on triggers - * Make flows searchable on edit campaign event - -v5.7.72 ----------- - * Fix editor whatsapp templates, refresh whatsapp channel pages - * Move omnibox module into temba.contacts.search - -v5.7.71 ----------- - * Remove legacy contact searching - * Remove code for dynamic group reevaluation and campaign event scheduling - -v5.7.70 ----------- - * Fix pdf selection - -v5.7.69 ----------- - * Validate language codes passed to contact API endpoint - * Don't actually create a broadcast if sending to node but nobody is there - * Update to latest floweditor - -v5.7.67 ----------- - * Fix globals endpoint so name is required - * Filter by is_active when updating fields on API endpoint - -v5.7.66 ----------- - * Replace remaining Contact.get_or_create calls with mailroom's resolve endpoint - -v5.7.65 ----------- - * URN lookups onthe contact API endpoint should be normalized with org country - * Archiving a campaign should only recreate events - -v5.7.64 ----------- - * Don't create contacts and URNs for broadcasts but instead defer the raw URNs to mailroom - -v5.7.63 ----------- - * Validate that import files don't contain duplicate UUIDs or URNs - -v5.7.62 ----------- - * Update version of editor and components - * Upload imports to use UUID based path - * Fix issue where all keywords couldnt be removed from a flow - -v5.7.61 ----------- - * Remove old editor, redirect editor_next to editor - -v5.7.60 ----------- - * Fix contact imports from CSV files - * Tweaks to import UI - -v5.7.59 ----------- - * Imports 2.0 - -v5.7.55 ----------- - * Use v13 flow as example on definitions endpoint docs - * Add URNs field to FlowStart and pass to mailroom so that it creates contacts - -v5.7.54 ----------- - * Update editor to get support for expressions in add to group actions - * Remove unused localized text on Msg and Broadcast - -v5.7.52 ----------- - * Migrations and models for new imports - -v5.7.51 ----------- - * Add plan_start, calculate active contacts in plan period, add to OrgActivity - * Tweak how mailroom_db creates extra group contacts - * Update to latest django-hamlpy - -v5.7.50 ----------- - * Optimizations for orgs with many contact fields - -v5.7.49 ----------- - * Update plan_end when suspending topup orgs - * Suspend topup orgs that have no active credits - * Show suspension header when an org is suspended - * Tweak external channel config styling - * Fix styles for button on WA config page - -v5.7.48 ----------- - * Fix button style for channel extra links - * Skip components missing text for WA templates sync - * Editors should have API tokens - -v5.7.47 ----------- - * Queue mailroom task to schedule campaign events outside of import transaction - * Fix margin on fields warning alert - -v5.7.46 ----------- - * Use mailroom task for scheduling of campaign events - -v5.7.45 ----------- - * Make sure form._errors is a list - -v5.7.44 ----------- - * Add index to enforce uniqueness for event fires - -v5.7.43 ----------- - * Fix migration - -v5.7.42 ----------- - * Bump smartmin to 2.2.3 - * Fix attachment download and pdf links - -v5.7.41 ----------- - * Fix messages to send without topup, and migrations - * No topup transfers on suborgs, show contacts, not credits - -v5.7.40 ----------- - * Invalid language codes passed to contact API endpoint should be ignored and logged for now - -v5.7.39 ----------- - * Update widget focus and borders on legacy editor - * Show global form errors and pre-form on modax template - -v5.7.38 ----------- - * Add alpha sort and search to results view - * Searchable contact fields and wired listeners after group changes - * Force policy redirect on welcome page, honor follow-on navigation redirect - * Use mailroom for contact creation in API and mailroom_db command - * Adjust styling for contact import scenarios - * Show address when it doesn't match channel name - -v5.7.37 ----------- - * add topup button to topup manage page - -v5.7.36 ----------- - * Fix deleting ticketers - -v5.7.35 ----------- - * Zendesk file view needs to be csrf exempt - * Use mailroom to create contacts from UI - -v5.7.34 ----------- - * Add view to handle file URL callbacks from Zendesk - -v5.7.33 ----------- - * Fix delete button on archived contacts page - * Don't allow saving queries that aren't supported as smart groups - * Delete no longer used contacts/fields.py - * Fix contacts reppearing in ES searches after being modified by a bulk action - * Adjust pjax block for contact import block - -v5.7.32 ----------- - * Modal max-height in vh to not obscure buttons - -v5.7.31 ----------- - * Add padding for p tags on policies - -v5.7.30 ----------- - * Add content guideline policy option, update styling a bit - -v5.7.29 ----------- - * Sitewide refresh of styles using Tailwind - -v5.7.27 ----------- - * Site refresh of styles using Tailwind. - -v5.7.28 ----------- - * Update to flow editor v1.9.15 - -v5.7.27 ----------- - * Update to flow editor v1.9.14 - * Add support for last_seen_on in legacy search code - -v5.7.26 ----------- - * Handle large deletes of contacts in background task - -v5.7.25 ----------- - * Fix bulk actions against querysets from ES searches - * Fix bulk action permissions on contact views - -v5.7.24 ----------- - * Rename existing 'archive' contact action in API to 'archive_messages' - * Allow deleting of all contacts from Archived view - -v5.7.23 ----------- - * Rename All Contacts to Active - * Add UI for archiving, restoring and deleting contacts - -v5.7.22 ----------- - * Bump version of mailroom and indexer used for tests - * Drop no longer used is_blocked and is_stopped fields - -v5.7.21 ----------- - * Add missing migration from last rev - -v5.7.20 ----------- - * Add missing migration - -v5.7.19 ----------- - * Make contact.is_stopped and is_blocked nullable and stop writing - -v5.7.18 ----------- - * Update sys group trigger to handle archiving - -v5.7.17 ----------- - * Migration to add Archived sys group to all orgs - -v5.7.16 ----------- - * Update to flow editor 1.9.11 - * Update database triggers to use contact status instead of is_blocked or is_stopped - * Make contact.status non-null - * Create new archived system group for new orgs - -v5.7.15 ----------- - * Add nag warning to legacy editor - -v5.7.14 ----------- - * Migration to backfill contact status - -v5.7.13 ----------- - * Enable channelback files for Zendesk ticketers - * Set status as active for new contacts - * Add new status field to contact - * Fix legacy editor by putting html-tag block back - * Change the label for CM channel claim - -v5.7.12 ----------- - * Fix imports that match by UUID - * Fix Nexmo search numbers and claim number - * Use Django language code on html tag - * Add support for ClickMobile channel type - -v5.7.11 ----------- - * Fix creating of campaign events based on last_seen_on - * Tweak msg_console so it can include sent messages which are not replies - * Fix mailroom_db command - * Expose last_seen_on on contact API endpoint - -v5.7.10 ----------- - * Update floweditor to 1.9.10 - * Add Last Seen On as a system field so it can be used in campaigns - * Tweak search_archives command to allow JSONL output - -v5.7.9 ----------- - * Fix reading of S3 event streams - * Migration to populate contact.last_seen_on from msg archives - -v5.7.8 ----------- - * Add plan_end field to Orgs - -v5.7.7 ----------- - * Add search archives management command - -v5.7.6 ----------- - * Optimizations to migration to backfill last_seen_on - -v5.7.5 ----------- - * Add migration to populate contact.last_seen_on - * Update to latest temba-components with support for refresh work - -v5.7.4 ----------- - * Use new metadata field from mailroom searching endpoints - * Make sure we have only one active trigger when importing flows - * Fix org selector and header text alignment when editor is open - -v5.7.3 ----------- - * Add contact.last_seen_on - * Bump floweditor to v1.9.9 - -v5.7.2 ----------- - * Add error messages for all error codes from mailroom query parsing - * Fix org manage quick searches - * Always use mailroom for static group changes - -v5.7.1 ----------- - * Add session history field to flowstarts - * Have mailroom reset URNs after contact creation to ensure order is correct - -v5.7.0 ----------- - * Add start_type and created_by to queued flow starts - * New mixin for list views with bulk actions - * Update some dependencies to work with Python 3.8 and MacOS - -v5.6.5 ----------- - * Set the tps options for Twilio based on country and number type - * Fix wit.ai classifiers and double logging of errors on all classifier types - -v5.6.3 ----------- - * Add variables for nav colors - -v5.6.2 ----------- - * Fix failing to manage logins when the we are logged in the same org - -v5.6.1 ----------- - * instead of dates, keep track of seen runs when excluding archived runs from exports - -v5.6.0 ----------- - * 5.6.0 Release Candidate - -v5.5.78 ----------- - * Improve the visuals and guides on the FBA claim page - * Block flow starts and broadcasts for suspended orgs - * Add a way to suspend orgs from org manage page - -v5.5.77 ----------- - * Subscribe to the Facebook app for webhook events - -v5.5.76 ----------- - * Add Facebook App channel type - -v5.5.75 ----------- - * always update both language and country if different - -v5.5.74 ----------- - * allow augmentation of templates with new country - -v5.5.73 ----------- - * Add support for urn property in search queries - * Add support for uuid in search queries - * Set country on WhatsApp templates syncing and add more supported languages - * Add country on TemplateTranslation - -v5.5.72 ----------- - * Use modifiers for field value updates - -v5.5.71 ----------- - * Fix to allow all orgs to import flows - -v5.5.70 ----------- - * Use modifiers and mailroom to update contact URNs - -v5.5.69 ----------- - * Refresh contact after letting mailroom make changes - * Contact API endpoint can't call mailroom from within a transaction - -v5.5.68 ----------- - * Fix contact update view - * Allow multi-user / multi-org to be set on each org - * Fix additional urls import - -v5.5.66 ----------- - * Implement Contact.update_static_groups using modifiers - * Consistent use of account/login/workspace - -v5.5.64 ----------- - * Fix editor - -v5.5.63 ----------- - * Make new org fields non-null and remove no longer needed legacy method - -v5.5.62 ----------- - * Rename whitelisted to verified - * Add migration to populate new org fields - -v5.5.61 ----------- - * Add new boolean fields to org for suspended, flagged and uses_topups and remove no longer used plan stuff - -v5.5.60 ----------- - * Move webhook log button to flow list page - * Add confirmation dialog to handle flow language change - -v5.5.59 ----------- - * Update to floweditor v1.9.8 - -v5.5.58 ----------- - * Update to floweditor 1.9.7 - * Remove BETA gating for tickets - -v5.5.57 ----------- - * Restore logic for when dashboard and android nav icons should appear - * Add translations in ru and fr - -v5.5.56 ----------- - * Improvements to ticketer connect views - * Still need to allow word only OSM ids - -v5.5.55 ----------- - * Fix boundaries URL regex to accept more numbers - -v5.5.54 ----------- - * Add index for mailroom looking up tickets by ticketer and external ID - * Make it easier to differentiate open and closed tickets - * Update to temba-components 0.1.7 for chrome textinput fix - -v5.5.53 ----------- - * Add indexes on HTTP log views - * Simplify HTTP log views for different types whilst given each type its own permission - -v5.5.52 ----------- - * More ticket view tweaks - -v5.5.51 ----------- - * Tweak zendesk manifest view - -v5.5.50 ----------- - * Tweak zendesk mailroom URLs - -v5.5.49 ----------- - * Store brand name in mailgun ticketer config to use in emails from mailroom - -v5.5.48 ----------- - * Defer to mailroom for ticket closing and reopening - -v5.5.47 ----------- -* Beta-gated views for Mailgun and Zendesk ticketers - -v5.5.46 ----------- - * Bump black version - * Fix layering of menu with simulator - -v5.5.45 ----------- - * Increase the template name field to accept up to 512 characters - * Make sending of Stripe receipts optional - * Add OrgActivity model that tracks contacts, active contacts, incoming and outgoing messages - -v5.5.43 ----------- - * Fix JS escaping on channel log page - -v5.5.42 ----------- - * Remove csrf exemption for views that don't need it (all our pjax includes csrf) - * Escape translations in JS literals - * Upgrade FB graph API to 3.3 - -v5.5.41 ----------- - * Use branding keys when picking which orgs to show on manage - -v5.5.40 ----------- - * Allow branding to have aliases - * Fix bug of removing URNs when updating fields looking up by URN - -v5.5.39 ----------- - * Update to floweditor 1.9.6 - * New task to track daily msgs per user for analytics - * Add support for Russian as a UI language - * Models and editor API endpoint for tickets - * Skip duplicate relayer call events - -v5.5.38 ----------- - * Update to flow editor 1.9.5 - * Allow custom TS send URLs - -v5.5.37 ----------- - * Remove all uses of _blank frame name - * Strip exif data from images - -v5.5.36 ----------- - * Better tracking of channel creation and triggers, track simulation - * Do not use font checkboxes for contact import extra fields - -v5.5.35 ----------- - * Revert Segment.io identify change to stay consistent with other tools - -v5.5.34 ----------- - * Identify users in Segment.io using best practice of user id, not email - -v5.5.33 ----------- - * Add context processor to stuff analytics keys into request context - * Restrict 2FA functionality to BETA users - -v5.5.32 ----------- - * Add basic 2FA support - -v5.5.31 ----------- - * Update to latest smartmin - -v5.5.30 ----------- - * Add new flow start type to record that flow was started by a Zapier API call - * Contact bulk actions endpoint should error if passed no contacts - * Remove mentioning the countries for AT claim section - * Add Telesom channel type - -v5.5.29 ----------- - * Fix trimming flow starts with start counts - -v5.5.28 ----------- - * Update Africa's Talking supported countries - -v5.5.27 ----------- - * Remove temporary NOOP celery tasks - * Drop Contact.is_paused field - * Editor 1.9.4, better modal centering - -v5.5.26 ----------- - * Add NOOP versions of renamed celery tasks to avoid problems during deploy - -v5.5.23 ----------- - * Remove default value on Contact.is_paused so it can be dropped - * Trim completed mailroom created flow starts - * Update flow starts API endpoint to only show user created flow starts and add index - -v5.5.22 ----------- - * Add nullable contact.is_paused field - * Display run count on flow start list page - -v5.5.21 ----------- - * Optimze flow start list page with DB prefetching - * Indicate on flow start list page where start was created by an API call - -v5.5.20 ----------- - * Use actual PO library to check for msgid differences - * Migration to backfill FlowStart.start_type - * Log error of WA channel failing to sync templates - -v5.5.19 ----------- - * Add FlowStart.start_type - * Ensure flow starts created via the API are only sent to mailroom after the open transaction is committed - -v5.5.18 ----------- - * Add flow start log page - -v5.5.17 ----------- - * Add index to list manually created flow starts - * Make FlowStart.org and modified_on non-NULL - * Move contact modification for name and language to be done by mailroom - -v5.5.16 ----------- - * bower no longer supported for package installs - * Migration to backfill FlowStart.org and modified_on - -v5.5.15 ----------- - * Update to flow-editor 1.9.2, security patches - -v5.5.14 ----------- - * Ensure IVR retry is preserved on new revisions - * Import flows for mailroom test db as v13 - * Make UUID generation fully mockable - * Add run UUID on flow results exports - * Drop unused fields on FlowStart and add org - -v5.5.13 ----------- - * Stop using FlowStart.modified_on so that it can be removed - * Disable syncing templates with variables in headers and footers - -v5.5.12 ----------- - * Import and export of PO files - -v5.5.10 ----------- - * Bump up the simulator when popped so it fits on more screens - * Editor performance improvements - -v5.5.8 ----------- - * Update help text on contact edit dialog - * Add prometheus endpoint config on account page - * Fix boundary aliases filtering by org - -v5.5.7 ----------- - * Fix open modal check on pjax refersh - * Show warnings on contact field page when org is approaching the limit and has hit the limit - -v5.5.6 ----------- - * Temporaly disable templates requests to FB when claiming WA channels - -v5.5.5 ----------- - * newest smartmin with BoM fix - -v5.5.4 ----------- - * Show better summary of schedules on trigger list page - * Fix display of trigger on contact group delete modal - -v5.5.3 ----------- - * Update to floweditor 1.8.9 - * Move EX constants to channel type package - * Remove unused deps and address npm security warnings - * Add 18 hours as flow expiration option - * FlowCRUDL.Revisions should return validation errors from engine as detail field - * Allow setting authentication header on External channels - * Add normalize contact tels task - * Drop full resolution geometry, only keep simplified - * Add attachments columns to flow results messages sheet - -v5.5.0 ----------- - * Increase the WA channels tps to 45 by default - -v5.4.13 ----------- - * Fix URL related test errors - -v5.4.12 ----------- - * Don't allow localhost for URL fields - -v5.4.11 ----------- - * Make sure external channel URLs are external - -v5.4.10 ----------- - * Complete FR translations - * Update to floweditor 1.8.8 - -v5.4.9 ----------- - * Fix submitting API explorer requests where there is no editor for query part - * Lockdown redirects on exports - * Add more detailed fresh chat instructions - -v5.4.8 ----------- - * Find and fix more cases of not filtering by org - -v5.4.7 ----------- - * Fix org filtering on updates to globals - * Fix campaign event update view not filtering by event org - * Fix error in API contact references when passed a JSON number - * Replace Whatsapp by WhatsApp - -v5.4.6 ----------- - * Merge pull request #2718 from nyaruka/fe187 - -v5.4.4 ----------- - * fix various filtering issues - -v5.4.3 ----------- - * Update sample flow test - -v5.4.2 ----------- - * remove use of webhook where not appropriate - -v5.4.1 ----------- - * Update sample flows to use @webhook instead of @legacy_extra - -v5.4.0 ----------- - * Add API endpoint to update Globals - * Keep latest sync event for Android channels when trimming - -v5.3.64 ----------- - * Add support for Twilio Whatsapp channel type - -v5.3.63 ----------- - * Add pre_deploy command to check imports/exports - * Fix link to android APK downloads on claim page - -v5.3.62 ----------- - * Temporarily disable resume imports task - -v5.3.61 ----------- - * Fix text of save as group dialog - * Add support to restart export tasks that might have been stopped by deploy - -v5.3.60 ----------- - * Update to latest mailroom - * Add urns to runs API endpoint - -v5.3.59 ----------- - * Update to latest mailroom which returns allow_as_group from query parsing - * Don't create missing contact fields on flow save - -v5.3.57 ----------- - * Update flow editor 1.7.16 - * Fix translations on external channel claim page - * Add tabs to toggle between full flow event history and summary of messages - * Increase the max height on the flow results export modal dialog - -v5.3.56 ----------- - * Add params to flow starts API - * Change name of org_id param in calls to flow/inspect - * Add quick replies variable to external channel claim page - -v5.3.55 ----------- - * Allow editing of allow_international on channel update forms - * Use consistent format for datetimes like created_on on contact list page - -v5.3.54 ----------- - * Hide loader on start flow dialog when there are no channels - -v5.3.53 ----------- - * Fix creation of Android channels - -v5.3.52 ----------- - * Convert Android to dynamic channel type - -v5.3.51 ----------- - * Update to floweditor 1.7.15 - * Add python script to do all CI required formatting and locale rebuilding - * Use mailroom for query parsing for contact exports - * Fix text positioning on list pages - * Fix delete contact group modal buttons when blocked by dependencies - * Completion with upper case functions - -v5.3.50 ----------- - * Migration to set allow_international=true in configs of existing tel channels - * Remove no longer used flow definition caching stuff - -v5.3.49 ----------- - * Use realistic phone numbers in mailroom test db - * Remove contact filtering from flow results page - * Add migration to populate Flow.template_dependencies - -v5.3.48 ----------- - * Use mailroom searching for omnibox results - -v5.3.47 ----------- - * Add template_dependencies m2m - -v5.3.46 ----------- - * Do not subject requests to the API with sessions to rate limiting - * Migration to convert flow dependencies metadata to new format - * Update description on the flow results export to be clear - -v5.3.45 ----------- - * Fix deletion of orgs and locations so that aliases are properly deleted - * Remove syntax highlighting in API explorer as it can't handle big responses - * Use new dependencies format from mailroom - -v5.3.44 ----------- - * Dynamic group creation / reevaluation through Mailroom - -v5.3.43 ----------- - * Update to latest mailroom - -v5.3.42 ----------- - * Fix actions on blocked contact list page - -v5.3.41 ----------- - * Disable simulation for archived flows - * Fix query explosion on Android channel alerts - -v5.3.40 ----------- - * Add subflow parameters to editor - -v5.3.39 ----------- - * Rework migration code so new flows are migrated too - -v5.3.38 ----------- - * Use mailroom for contact searches, contact list pages and flow starts via search - -v5.3.35 ----------- - * Rebuild components - -v5.3.34 ----------- - * Update to flow editor 1.7.13 - * Don't include 'version' in current definitions - * Migrate imports of flows to new spec by default - -v5.3.30 ----------- - * Exclude inactive template translations from API endpoint - -v5.3.29 ----------- - * Fix edge case for default alias dialog - * Add sending back to contact list page - * Save parent result refs in flow metadata - * Change name BotHub to Bothub - -v5.3.28 ----------- - * remove auto-now on modified_on on FlowRun - -v5.3.27 ----------- - * Update to floweditor 1.7.9 - * Warn users if starting for facebook without a topic - -v5.3.26 ----------- - * Allow arbitrary numbers when sending messages - * Componentized message sending - -v5.3.25 ----------- - * Show empty message list if we have archived them all - * Update to flow editior 1.7.8 - * Replace flow/validate call to mailroom with flow/inspect - * Add facebook topic selection - -v5.3.24 ----------- - * Pass version to mailroom migrate endpoint - * Fix saving on alias editor - * Support the whatsapp templates HEADER and FOOTER components - * Write HTTP log for errors in connection - -v5.3.23 ----------- - * Add support for whatsapp templates with headers and footers - * Make sure we have one posterizer form and we bind one click event handler for posterize links - -v5.3.22 ----------- - * Convert add/edit campaign event to components - -v5.3.21 ----------- - * Add UI for managing globals - -v5.3.16 ----------- - * Update to flow editor v1.7.7 - -v5.3.13 ----------- - * Update to floweditor v1.7.5 - * Re-add msg_console management command with new support for mailroom - * Cleanup somes usages of trans/blocktrans - -v5.3.12 ----------- - * Add error and failure events to contact history - * Use form components on campaign create/update - -v5.3.11 ----------- - * Migrate sample flows to new editor - * Localize URNs in API using org country - * Write HTTPLogs for Whatsapp template syncing - * Remove Broadcast recipient_count field - -v5.3.10 ----------- - * Add read API endpoint for globals - -v5.3.9 ----------- - * Add trimming task for flow revisions - * Add models for globals support - * Add FreshChat channel support - -v5.3.8 ----------- - * Make sure imported flows are unarchived - * Validate we do not have a caller on a channel before adding a new one - -v5.3.7 ----------- - * Release URNs on Org release - -v5.3.6 ----------- - * Release Channel sync events and alarms - -v5.3.5 ----------- - * release Campaigns when releasing Orgs - -v5.3.4 ----------- - * Release flow starts when releasing flows - -v5.3.3 ----------- - * Add releasing to Classifiers and HTTPLogs - -v5.3.2 ----------- - * Allow manual syncing of classifiers - -v5.3.1 ----------- - * Update documentation for FB webhook events to subscribe to - -v5.3.0 ----------- - * Fix DT One branding and add new icon - * Fix validation problem on update schedule trigger form - * Use brand when granting orgs, not host - * Update contactsql parser to support same quotes escaping as goflow - -v5.2.6 ----------- - * Change slug for Bothub classifier to 'bothub' - -v5.2.5 ----------- - * Fix various Schedule trigger UI validation errors - * Fix intermittently failing excel export tests - * Add noop reverse in migration - -v5.2.1 ----------- - * Fix order of Schedule migrations (thanks @matmsa27) - -v5.2.0 ----------- - * Show date for broadcast schedules - * Honor initial datetime on trigger schedule ui - -v5.1.64 ----------- - * Update to flow editor version 1.7.3 - * Fix weekly buttons resetting on trigger schedule form validation - * Validate schedule details on schedule trigger form - * Show query editors in contact search - * Add migration to fix schedules with None/NaN repeat_days_of_week values - * Move IE9 shim into the main template header - * Update README with final 5.0 versions - -v5.1.63 ----------- - * Update to flow editor v1.7.2 - -v5.1.62 ----------- - * Validate repeat_days_of_week when updating schedules - * Include airtime transfers in contact history - -v5.1.61 ----------- - * Tweak styling on contact field list page - * Send test email when the SMTP server config are set - -v5.1.60 ----------- - * Add Bothub classifier type - -v5.1.59 ----------- - * Update flow editor to version 1.7.0 - * Add Split by Intent action in flows - * Update Send Airtime action for use with DTOne - -v5.1.58 ----------- - * Unify max contact fields - * Don't allow deletion of flow labels with children - * Rename TransferTo to DTOne - -v5.1.57 ----------- - * Check pg_dump version when creating dumps - * Add missing block super in extra script blocks - * Fix omnibox being not actually required on send message form - * Rework airtime transfers to have separate http logs - * Allow flow starts by query - -v5.1.55 ----------- - * Sync intents on classifier creation - * Trim HTTP logs older than 3 days - -v5.1.54 ----------- - * remove fragile AT links to configuration pages - * Exclude hidden results from flow results page - * Exclude results with names starting with _ from exports - -v5.1.53 ----------- - * Classifier models and views - * HTTPLog models and views - -v5.1.52 ----------- - * add prefetch to retry - -v5.1.51 ----------- - * Add ThinQ Channel Type - -v5.1.50 ----------- - * Fix contact history rendering of broadcast messages with null recipient count - * Fix for start_session action in the editor - -v5.1.49 ----------- - * Fire schedules in Mailroom instead of celery - -v5.1.48 ----------- - * Rework contact history to include engine events - -v5.1.47 ----------- - * Update to flow editor 1.6.20 - -v5.1.46 ----------- - * Rev Flow Editor v1.6.19 - -v5.1.45 ----------- - * Fix rendering of campaigns on export page - * Fix ivr channel logs - * Make FlowRun.status non-NULL - * Make FlowSession.uuid unique and indexed - -v5.1.44 ----------- - * Tidy up fields on flow activity models - - -v5.1.43 ----------- - * Fix styling on create flow dialog - * Make user fields nullable on broadcasts - * Populate repeat_minute_of_hour in data migration - -v5.1.42 ----------- - * Update trigger update views to take into account new schedule fields - -v5.1.41 ----------- - * Update docs on flow start extra to be accessible via @trigger - * Change input selector to work cross-browser on send modal - * Don't inner scroll for modax fetches - -v5.1.40 ----------- - * Fix issues with web components in Microsoft Edge - -v5.1.37 ----------- - * Cleanup Schedule class - * Drop unused columns on FlowRun - * Remove legacy engine code - * Remove legacy braodcast and message sending code - -v5.1.36 ----------- - * Temporarily disable compression for components JS - -v5.1.33 ----------- - * Use new expressions for campaign message events, broadcasts and join group triggers - * List contact fields with new expression syntax and fix how campaign dependencies are rendered - -v5.1.28 ----------- - * Use mailroom to interrupt runs when archiving or releasing a flow - * Re-organize legacy engine code - * Initial library of web components - -v5.1.27 ----------- - * Update to floweditor 1.6.13 - * Allow viewers to do GETs on some API endpoints - -v5.1.26 ----------- - * Fix rendering of campaign and event names in UI - * Move remaining channel client functionality into channel type packages - * Remove unused asset server stuff - -v5.1.25 ----------- - * Update floweditor to 1.6.12 - * Allow viewing of channel logs in anonymous orgs with URN values redacted - -v5.1.24 ----------- - * Cleanup campaighn models fields - -v5.1.23 ----------- - * Really fix copying of flows with nameless has_group tests and add a test this time - -v5.1.22 ----------- - * Remove trigger firing functionality (except schedule triggers) and drop unused fields on trigger - -v5.1.21 ----------- - * Migration to backfill FlowRun.status - -v5.1.20 ----------- - * Limit group fetching to active groups - * Get rid of caching on org object as that's no longer used needed - * Fix importing/copying flows when flow has group dependency with no name - -v5.1.19 ----------- - * Migration to add FlowRun.status - -v5.1.18 ----------- - * Cleanup fields on FlowRun (single migration with no real SQL changes which can be faked) - -v5.1.17 ----------- - * Remove all IVR flow running functionality which is now handled by mailroom - -v5.1.15 ----------- - * Update to flow editor v1.6.11 - * Releasing Nexmo channel shouldn't blow up if application can't be deleted on Nexmo side - -v5.1.14 ----------- - * Fix Nexmo IVR to work with mailroom - * Add migration to populate session UUIDs - * Update to Django 2.2 - * Send topup expiration emails to all org administrators - -v5.1.12 ----------- - * Drop ActionLog model - * Switch to new editor as the default, use v1.6.10 - * Add query field to FlowStart - -v5.1.11 ----------- - * Add FlowSession.uuid which is nullable for now - * Update to floweditor 1.6.9, scrolling rules - -v5.1.10 ----------- - * Update to flow editor 1.6.8, add completion config - * Add FlowStart.parent_summary, start deprecating fields - * Switch to bionic beaver for CI builds - * Add trigger params access to ivr flow - * Drop no longer used Broadcast.purged field - -v5.1.9 ----------- - * Make Broadcast.purged nullable in preparation for dropping it - -v5.1.8 ----------- - * Update floweditor to 1.6.7 and npm audit - -v5.1.7 ----------- - * Remove unused IVR tasks - * Simplify failed IVR call handling - -v5.1.6 ----------- - * Fix format_number to be able to handle decimals with more digits than current context precision - -v5.1.5 ----------- - * Update to flow editor 1.6.6 - -v5.1.4 ----------- - * Update to flow editor 1.6.5 - * Update Django to 2.1.10 - -v5.1.3 ----------- - * Update flow editor to 1.6.3 - -v5.1.2 ----------- - * Remove fields no longer needed by new engine - * Trim sync events in a separate task - -v5.1.1 ----------- - * Stop writing legacy engine fields and make them nullable - * Remove no longer used send_broadcast_task and other unused sending code - * Squash migrations into previously added dummy migrations - -v5.1.0 ----------- - * Populate account sid and and auth token on twilio callers when added - * Disable legacy IVR tasks - -v5.0.9 ----------- - * Add dummy migrations for all migrations to be created by squashing - -v5.0.8 ----------- - * Update recommended versions in README - * Fix API runs serializer when run doesn't have category (i.e. from save_run_result action) - * Update to latest floweditor - * Update search parser to convert timestamps into UTC - -v5.0.7 ----------- - * Force a save when migrating flows - -v5.0.6 ----------- - * Show search error if input is not a date - * Group being imported into should be in state=INITIALIZING whilist being populated, and hide such groups in the UI - * Only add initially changed files in post-commit hook - * Fix to make sure the initial form data is properly shown on signup - -v5.0.5 ----------- - * sync whatsapp templates with unsupported languages, show them as such - -v5.0.4 ----------- - * Update to floweditor v1.5.15 - * Add pagination to outbox - * Fix import of contact field when field exists with same name but different key - * Fix (old) mac excel dates in imports - -v5.0.3 ----------- - * Update flow editor to 1.5.14 - -v5.0.2 ----------- - * Remove reference to webhook API page which no longer exists - * Update to flow-editor 1.5.12 - * Update some LS libs for security - * Tweaks to migrate_to_version_11_1 to handle "base" as a lang key - * Tweak old flow migrations to allow missing webhook_action and null ruleset labels - -v5.0.1 ----------- - * Fix max length for WA claim facebook_access_token - * Fix WhatsApp number formatting on contact page, add icon - -v5.0.0 ----------- - * add validation of localized messages to Travis - -v4.27.3 ----------- - * Make contact.is_test nullable - * Migration to remove orphaned schedules and changes to prevent creating them in future - * Migration to merge path counts from rules which are merged into a single exit in new engine - -v4.27.2 ----------- - * fix broadcast API test - -v4.27.1 ----------- - * temporarily increase throttling on broadcasts endpoint - -v4.27.0 ----------- - * Cleanup webhook fields left on Org - * Stop checking flow_server_enabled and remove support for editing it - -v4.26.1 ----------- - * Remove no longer used check_campaigns_task - -v4.26.0 ----------- - * Remove handling of incoming messages, channel events and campaigns.. all of which is now handled by mailroom - -v4.25.0 ----------- - * Add sentry error to handle_event_task as it shouldnt be handling anything - * Remove processing of timeouts which is now handled by mailroom - * Start broadcast mailroom tasks with HIGH_PRIORITY - * Fix EX settings page load - * Migration to convert any remaining orgs to use mailroom - * Fix broken links to webhook docs - * Simplify WebHookEvent model - -v4.23.3 ----------- - * Send broadcasts through mailroom - * Add org name in the email subject for exports - * Add org name in export filename - -v4.24.0 ----------- - * Add org name in the export email subject and filename - * Update flow editor to 1.5.9 - * Remove functionality for handling legacy surveyor submissions - -v4.23.1 ----------- - * Make exported fields match goflow representation and add .as_export_ref() to exportable classes - * Update to latest floweditor v1.5.5 - * Persist group and field definitions in exports - * Add support for SignalWire (https://signalwire.com) for SMS and IVR - -v4.23.0 ----------- - * Save channel and message label dependencies on flows - -v4.22.63 ----------- - * Update to latest floweditor v1.5.5 - * Allow switching between editors - * Update Django to version 2.1.9 - -v4.22.62 ----------- - * add US/ timezones for clicksend as well - -v4.22.61 ----------- - * add clicksend channel type - -v4.22.60 ----------- - * Update flow editor to 1.5.4 - * Allow imports and exports of v13 flows - -v4.22.55 ----------- - * Enable export of new flows - * Update Nexmo supported countries list - -v4.22.54 ----------- - * rename migration, better printing - -v4.22.53 ----------- - * add migration to repopulate metadata for all flows - -v4.22.52 ----------- - * Expose result specs in flow metadata on flows API endpoint - * Use Temba JSON adapter when reading JSON data from DB - * Don't update TwiML channel when claiming it - * Use most recent topup for credit transfers between orgs - -v4.22.51 ----------- - * Update to flow-editor 1.5.3 - -v4.22.50 ----------- - * Update to floweditor v1.5.2 - -v4.22.49 ----------- - * Only do mailroom validation on new flows - -v4.22.48 ----------- - * Fix 11.12 migration and importing flows when flow contains a reference to a channel in a different org - * Make WhatsApp endpoint configurable, either FB or self-hosted - -v4.22.47 ----------- - * tweak to WA language mapping - -v4.22.46 ----------- - * add hormuud channel type - * newest editor - * update invitation secret when user is re-invited - -v4.22.45 ----------- - * Tweak compress for vendor - -v4.22.44 ----------- - * Update to flow editor 1.4.18 - * Add mailroom endpoints for functions, tweak styles for selection - * Honor is_active when creating contact fields - * Cache busting for flow editor - -v4.22.43 ----------- - * Update flow editor to 1.4.17 - * Warn users when starting a flow when they have a WhatsApp channel that they should use templates - -v4.22.42 ----------- - * add page to view synched WhatsApp templates for a channel - -v4.22.41 ----------- - * Update flow editor to 1.4.16 - * View absolute attachments in old editor - -v4.22.40 ----------- - * Update editor to 1.4.14 - -v4.22.39 ----------- - * latest editor - -v4.22.38 ----------- - * update defs with db values both when writing and reading - * remove clearing of external ids for messages - -v4.22.37 ----------- - * Update to flow-editor 1.4.12 - * Remove footer gap on new editor - -v4.22.36 ----------- - * allow Alpha users to build flows in new editor - * don't use RuleSets in figuring results, exports, categories - -v4.22.28 ----------- - * Adjust `!=` search operator to include unset data - * Remove broadcast recipients table - * IMPORTANT * You must make sure that all purged broadcasts have been archived using - rp-archiver v1.0.2 before deploying this version of RapidPro - -v4.22.27 ----------- - * styling tweaks to contacts page - -v4.22.26 ----------- - * Always show featured ContactFields on Contact.read page - * Do not migrate ruleset with label null and action msg text null - -v4.22.25 ----------- - * only show pagination warning when we have more than 10k results - -v4.22.24 ----------- - * support != search operator - -v4.22.23 ----------- - * simplify squashing of squashable models - * show a notification when users open the last page of the search - * update `modified_on` once msgs export is finished - -v4.22.22 ----------- - * Fix issue with pagination when editing custom fields - -v4.22.21 ----------- - * Add new page for contact field management - -v4.22.20 ----------- - * add management command to reactivate fb channels - -v4.22.19 ----------- - * api for templates, add access token and fb user id to claim, sync with facebook endpoint - -v4.22.18 ----------- - * fix recalculating event fires for fields when that field is created_on - -v4.22.17 ----------- - * Don't overwrite show_in_table flag on contact import - * Prevent updates of contact field labels when adding a field to a flow - * Add migration to populate results and waiting_exit_uuids in Flow.metadata - -v4.22.15 ----------- - * Do not immediately expire flow when updating expirations (leave that to mailroom) - * Fix boundary aliases duplicates creation - * Add org lock for users to deal with similtaneous updates of org users - * Add results and waiting_exit_uuids to flow metadata and start populating on Flow.update - -v4.22.14 ----------- - * CreateSubOrg needs to be non-atomic as well as it creates flows which need to be validated - * Remove unused download view - -v4.22.13 ----------- - * allow blank pack, update permissions - -v4.22.12 ----------- - * remove APK read view, only have update - * allow setting pack number - -v4.22.11 ----------- - * Add APK app and new Android claiming pipeline for Android Relayer - -v4.22.10 ----------- - * Use output of flow validation in mailroom to set flow dependencies - * Make message_actions.json API endpoint support partial updates - * Log to librato only pending messages older than a minute - -v4.22.6 ----------- - * Add Viber Welcome Message event type and config - * More customer support service buttons - -v4.22.5 ----------- - * queue incoming messages and incoming calls from relayer to mailroom - -v4.22.4 ----------- - * Temporarily disable flow validation until we can fix it for new orgs - -v4.22.3 ----------- - * Lazily create any dependent objects when we save - * MAILROOM_URL in settings.py.dev should default to http://localhost:8090 - * Call to mailroom to validate a flow before saving a new definition (and fix invalid flows in our tests) - -v4.22.2 ----------- - * Fix schedule next fire calculation bug when schedule is greater than number of days - * Fix to allow archiving flow for removed(inactive) campaign events - * Strip resthook slug during creation - * Ignore request from old android clients using GCM - -v4.22.1 ----------- - * Increase the schedule broadcast text max length to be consistent on the form - -v4.22.0 ----------- - * Fix case of single node flow with invalid channel reference - * Remove ChannelConnection.created_by and ChannelConnection.is_active - * Fix flow export results to include results from replaced rulesets - -v4.21.15 ----------- - * correct exclusion - -v4.21.14 ----------- - * Dont requeue flow server enabled msgs - * Exit sessions in bulk exit, ignore mailroom flow starts - -v4.21.13 ----------- - * Fix import with invalid channel reference - * Add flow migration to remove actions with invalid channel reference - -v4.21.12 ----------- - * improve simulator for goflow simulation - -v4.21.11 ----------- - * work around JS split to show simulator images - -v4.21.10 ----------- - * display attachments that are just 'image:' - -v4.21.9 ----------- - * simulator tweaks - * show Django warning if mailroom URL not configured - -v4.21.8 ----------- - * make sure we save flow_server_enabled in initialize - -v4.21.7 ----------- - * Update status demo view to match the current webhook posted data - * Remove all remaining reads of contact.is_test - -v4.21.6 ----------- - * Use pretty datetime on contact page for upcoming events - -v4.21.5 ----------- - * Replace final index which references contact.is_test - * Fix labels remap on flow import - -v4.21.4 ----------- - * All new orgs flow server enabled - * Fallback to org domain when no channe domain set - -v4.21.3 ----------- - * Remove all remaining checks of is_test, except where used in queries - * Update contact indexes to not include is_test - * Prevent users from updating dynamic groups if query is invalid - * Update Python module dependencies - -v4.21.2 ----------- - * set country code on test channel - -v4.21.1 ----------- - * do not log errors for more common exceptions - -v4.21.0 ----------- - * Include fake channel asset when simulating - * Add test for event retrying, fix out of date model - * Stop checking contact.is_test in db triggers - -v4.20.1 ----------- - * Remove unused fields on webhookevent - * Default page title when contact has no name or URN (e.g. a surveyor contact) - -v4.19.7 ----------- - * fix simulator to allow fields with empty value - * remove remaining usages of test contacts for testing - -v4.19.6 ----------- - * add incoming_extra flow to mailroom test - * fix for test contact deletion migration - -v4.19.5 ----------- - * pass extra to mailroom start task - -v4.19.4 ----------- - * Support audio/mp4 as playable audio - * Add migration to remove test contacts - -v4.19.3 ----------- - * Ensure scheduled triggers start flows in mailroom if enabled - -v4.19.2 ----------- - * remap incoming ivr endpoints for Twilio channels when enabling flow server - * interrupt flow runs when enabling flow server - * add enable_flow_server method to org, call in org update view - -v4.19.1 ----------- - * Scope API throttling by org and user - * Add export link on campaign read page - * Fix SMTP serever config to percentage encode slashes - -v4.19.0 ----------- - * Add session_type field on FlowSession - * Use provided flow definition when simulating if provided - * Remove USSD app completely - * Adjust broadcast status to API endpoint - * Remove legacy (non-mailroom) simulation - -v4.18.0 ----------- - * Make ChannelConnection.is_active nullable so it can be eventually removed - * Replace traceback.print_exc() with logger.error - * Make sure contacts ids are iterable when starting a flow - * Remove USSD proxy model - -v4.17.0 ----------- - * Use URL kwargs for channel logs list to pass the channel uuid - * Fix message campaign events on normal flows not being skipped - * Default to month first date format for US timezones - * Make Contact.created_by nullable - * Fix to prevent campaign event to create empty translations - * Use new editor wrapper to embed instead of building - * Remove USSD functionality from engine - -v4.16.15 ----------- - * Fix Stripe integration - -v4.16.14 ----------- - * fix webhook bodies to be json - -v4.16.13 ----------- - * better request logging for webhook results - -v4.16.12 ----------- - * further simplication of webhook result model, add new read and list pages - -v4.16.11 ----------- - * add org field to webhook results - -v4.16.10 ----------- - * Add surveyor content in mailroom_db command - * Fix flows with missing flow_type - * Update more Python dependencies - * Prevent flows of one modality from starting subflows of a different modality - -v4.16.8 ----------- - * Add support for Movile/Wavy channels - * Switch to codecov for code coverage - * Allow overriding brand domain via env - * Add mailroom_db management command for mailroom tests - * Start flow_server_enabled ivr flows in mailroom - * Remove legacty channel sending code - * Remove flow dependencies when deactivating USSD flows - * Migrations to deactivate USSD content - -v4.16.5 ----------- - * Fix quick replies in simulator - -v4.16.4 ----------- - * More teaks to Bongolive channel - * Use mailroom simulation for IVR and Surveyor flows - * Add a way to see all run on flow results runs table - -v4.16.3 ----------- - * Simplify generation of upload URLs with new STORAGE_URL setting - -v4.16.2 ----------- - * Switch BL channels used API - * Fix rendering of attachments for mailroom simulation - * Update black to the version 18.9b0 - -v4.16.0 ----------- - * Fix flow_entered event name in simulator - * Make created_by, modified_by on FlowStart nullable, add connections M2M on FlowStart - * Rename ChannelSession to ChannelConnection - -v4.15.2 ----------- - * Fix for flow dependency migration - * Fix rendering of single digit hours in pretty_datetime tag - * Use mailroom for flow migration instead of goflow - * Add support for Bongo Live channel type - -v4.15.1 ----------- - * Include default country in serialized environments used for simulation - * Add short_datetime and pretty_datetime tags which format based on org settings - * Prevent users from choosing flow they are editing in some cases - -v4.15.0 ----------- - * Fix nexmo claim - * Tweak 11.7 migration to not blow up if webhook action has empty URL - * Bump module minor versions and remove unused modules - * Remove ChannelSession.modified_by - -v4.14.1 ----------- - * Make older flow migrations more fault tolerant - * Tweaks to migrate_flows command to make error reporting more useful - * Add flow migration to fix duplicate rule UUIDs - * Update python-telegram-bot to 11.1.0 - * Update nexmo to 2.3.0 - -v4.14.0 ----------- - * Fix recent messages rollover with 0 messages - * Use flowserver only for flow migration - * Make created_by and modified_by optional on channel session - -v4.13.2 ----------- - * create empty revisions for empty flows - * proper handle of empty errors on index page - * fix error for policy read URL failing - * add quick replies to mailroom simulator - -v4.13.1 ----------- - * populate simulator environment for triggers and resumes - * honour Flow.is_active on the Web view - * fix android channel release to not throw if no FCM ID - * add Play Mobile aggregator - -v4.13.0 ----------- - * Add index for fast Android channel fetch by last seen - * Remove gcm_id field - * No messages sheet for flow results export on anon orgs - * Add periodic task to sync channels we have not seen for a while - * Add wait_started_on field to flow session - -v4.12.6 ----------- - * Remove flow server trialling - * Replace tab characters for GSM7 - * Use mailroom on messaging flows for simulation - * Raise ValidationError for ContactFields with null chars - * upgrade to Django 2.1 - -v4.12.5 ----------- - * Make sure Flow.update clears prefetched nodes after potentialy deleting them - -v4.12.4 ----------- - * Fix Flow.update not deleting nodes properly when they change type - -v4.12.3 ----------- - * Add try/except block on FCM sync - * Issue #828, remove numbers replace - -v4.12.2 ----------- - * Dont show queued scheduled broadcasts in outbox - * Prevent deleting groups with active campaigns - * Activate support for media attachment for Twitter channels - * Remove ability to create webhook actions in editor - * Add flow migration to replace webhook actions with rulesets - -v4.12.1 ----------- - * Fix importing campaign events based on created_om - * Fix event fires creation for immutable fields - * Remove WA status endpoint - * Fix IVR runs expiration date initialization - * Add UUID field to org - -v4.11.7 ----------- - * Interrupt old IVR calls and related flow sessions - * Move webhook docs button from the token view to the webhook view - -v4.11.6 ----------- - * Faster squashing - * Fix EX bulk sender form fields - -v4.11.5 ----------- - * simulate flow_server_enabled flows in mailroom - -v4.11.3 ----------- - * Add session log links to contact history for staff users - * Hide old webhook config page if not yet set - -v4.11.2 ----------- - * Fix passing false/true to archived param of flows API endpoint - -v4.11.1 ----------- - * Turn on the attachment support for VP channels - * Tweak 11.6 flow migration so that we remap groups, but never create them - * Flows API endpoint should support filtering by archived and type - * Log how many flow sessions are deleted and the time taken - * Turn on the attachment support for WA channels - * Adjust UI for adding quick replies and attachment in random order - -v4.11.0 ----------- - * Add index for fetching waiting sessions by contact - * Ensure test_db users have same username and email - * Add index to FlowSession.ended_on - * Make FlowSession.created_on non-null - * Add warning class to skipped campaigns event fire on contact history - * Add fired_result field to campaign event fires - -v4.10.9 ----------- - * Log and fail calls that cannot be started - * Allow contact.created_on in flows, init new event - -v4.10.8 ----------- - * Deactivate events when updating campaigns - * Less aggressive event fire recreation - * Use SMTP SERVER org config and migrate old config keys - -v4.10.4 ----------- - * Retry failed IVR calls - -v4.10.3 ----------- - * Show all split types on run results, use elastic for searching - -v4.10.2 ----------- - * Flow migration for mismatched group uuids in existing flows - * Remap group uuids on flow import - * Migration to backfill FlowSession.created_on / ended_on - -v4.10.1 ----------- - * Add config to specify content that should be present in the response of the request, if not mark that as msg failed - * Allow campaign events to be skipped if contacts already active in flows - -v4.10.0 ----------- - * Add FlowRun.parent_uuid - * Add FlowSession.timeout_on - * Create new flows with flow_server_enabled when org is enabled - * Add flow-server-enabled to org, dont deal with flow server enabled timeouts or expirations on rapidpro - -v4.9.2 ----------- - * Fix flowserver resume tests by including modified_on on runs sent to goflow - -v4.9.1 ----------- - * Dont set preferred channels if they can't send or call - * Don't assume events from goflow have step_uuid - * Add indexes for flow node and category count squashing - -v4.9.0 ----------- - * Delete event fires in bulk for inactive events - * Fix using contact language for categories when it's not a valid org language - * Fix translation of quick replies - * Add FlowSession.current_flow and start populating - * Refresh contacts list page after managing fields - * Update to latest goflow (no more caller events, resumes, etc) - * Fix flow results export to read old archive format - * Batch event fires by event ID and not by flow ID - * Make campaign events immutable - -v4.8.1 ----------- - * Add novo channel - -v4.8.0 ----------- - * Remove trialing of campaign events - * Remove no longer used ruleset_analytis.haml - * Expose @contact.created_on in expressions - * Make Contact.modified_by nullable and stop writing to it - * Optimize group releases - * Add created_on/ended_on to FlowSession - -v4.7.0 ----------- - * Bump Smartmin and Django versions - * Expose @contact.created_on in expressions - * Make Contact.modified_by nullable and stop writing to it - -v4.6.0 ----------- - * Latest goflow - -v4.5.2 ----------- - * Add config for deduping messages - * Add created_on/ended_on to FlowSession - * Update to latest goflow (event changes) - * Do not delete campaign events, deactivate them - * Do not delete runs when deleting a flow - * Fix Campaigns events delete for system flow - -v4.5.1 ----------- - * Use constants for queue names and switch single contact flow starts to use the handler queue - * Raise ValidationError if flow.extra is not a valid JSON - * Defer group.release in a background task - * Fix saving dynamic groups by reverting back to escapejs for contact group query on dialog - -v4.5.0 ----------- - * Add Stopped event to message history and unknown/unsupported events - * Switch result value to be status code from webhook rulesets, save body as @extra. and migrate result references to that - -v4.4.20 ----------- - * Fix channel selection for sending to TEL_SCHEME - * Add campaigns to all test orgs for make_db - * Correctly embed JS in templates - * Escape data before using `mark_safe` - -v4.4.19 ----------- - * Fix validating URNField when input isn't a string - -v4.4.18 ----------- - * Fix incorrect units in wehbook_stats - * Result input should always be a string - -v4.4.17 ----------- - * Don't do duplicate message check for surveyor messages which are already SENT - * Update to goflow 0.15.1 - * Update Location URLs to work with GADM IDs - * Fix potential XSS issue: embed script only if `View.refresh` is set - -v4.4.16 ----------- - * Fix IVR simulation - -v4.4.15 ----------- - * Fix importing with Created On columns - * Validate URNs during import - * Classify flow server trials as simple if they don't have subflows etc - * Use latest goflow for testing - -v4.4.14 ----------- - * Enable import of GADM data using import_geojson - -v4.4.13 ----------- - * Defer to mailroom for processing event fires for flows that are flowserver enabled - * Tweaks to comparing events during flow server trials - * Fix saved operand for group tests on anon orgs - -v4.4.12 ----------- - * Add step URN editor completions - * Add name to the channels shown on the flow editor - * Don't zero pad anon ids in context - * Update to latest expressions - -v4.4.11 ----------- - * Ensure API v1 writes are atomic - * JSONFields should use our JSON encoder - * Use authenticated user for events on Org.signup - * Trial shouldn't blow up if run has no events - * Add urn to step/message context and make urn scheme accessible for anon org - * Get rid of Flow.FLOW - -v4.4.8 ----------- - * Don't trial flow starts from triggers - * Fix messages from non-interactive subflows being added to their parent run - * Setup user tracking before creating an Org - * Migrate flows during flowserver trials with collapse_exits=false to keep paths exactly the same - * Input for a webhook result test should be a single request - * Migration to update F type flows to M - -v4.4.7 ----------- - * Enforce validation on OrgSignup and OrgGrant forms - * Cleanup encoding of datetimes in JSON - * New flows should be created with type M and rename constants for clarity - -v4.4.6 ----------- - * Fix updating dynamic groups on contact update from the UI - * Make editor agnostic to F/M flow types - -v4.4.5 ----------- - * Remove mage functionality - * Fix Twilio number searching - -v4.4.2 ----------- - * Use SystemContactFields for Dynamic Groups - * Add our own json module for loads, dumps, always preserve decimals and ordering - * Replace reads of Flow.flow_type=MESSAGE with Flow.is_system=True - * Migration to populate Flow.is_system based on flow_type - -v4.4.0 ----------- - * Fix intercom ResourceNotFound on Org.Signup - * Remove follow triggers and channel events - * Add Flow.is_system and start populating for new campaign event single message flows - -v4.3.8 ----------- - * Data migration to deactivate all old style Twitter channels - * Update Nexmo client - -v4.3.4 ----------- - * Increase IVR logging verbosity - * Trial all campaign message flows in flowserver - * Tweak android recommendation - -v4.3.3 ----------- - * Run Table should only exclude the referenced run, and include greater Ids - * Raise validation error ehen trying action inactive contacts over API - * Remove uservoice as a dependency - * Update versions of Celery, Postgis, Nexmo, Twilio - * Fix Python 3.7 issues - * Clear out archive org directory when full releasing orgs - -v4.3.2 ----------- - * Update expressions library to get EPOCH() function - -v4.3.1 ----------- - * Update to Django 2.0 - * Update postgres adapter to use psycopg2-binary - -v4.3.0 ----------- - * Wrap asset responses in a results object - * Use trigger type of campaign when starting campign event flows in flowserver - * Fix count for blocktrans to not use string from intcomma - * Use audio/mp4 content type for m4a files - -v4.2.4 ----------- - * Update to latest goflow and enable asset caching - * Actually fix uploading mp4 files - -v4.2.2 ----------- - * Show only user fields when updating field values for a contact - * Fix MIME type for M4A files - * Allow test_db command to work without having ES installed - -v4.2.1 ----------- - * Ignore search exceptions in omnibox - * Actually enable users to use system contact fields in campaign events - -v4.2.0 ----------- - * Enable users to choose 'system fields' like created_on for campaign events - -v4.1.0 ----------- - * Management commnd to recalculate node counts - * Fix run path triggers when paths are trimmed - * Allow file overwrite for public S3 uploads - -v4.0.3 ----------- - * Handle cases when surveyor submits run with deleted action set - * Document modified_on on our API endpoint - * Use ElasticSearch for the omnibox widget - -v4.0.2 ----------- - * fix count of suborgs after org deletion - -v4.0.1 ----------- - * remove group settings call for WhatsApp which is no longer supported - * easier way to service flows for CS reps - -v4.0.0 ----------- - * Squash all migrations - -v3.0.1000 ----------- - * fix display of archives formax on home page - -v3.0.999 ----------- - * Fix chatbase font icon name - * Add encoding config to EX channel type - * Show archive link and information on org page - -v3.0.449 ----------- - * Improve error message when saving surveyor run fails - * Allow surveyor submissions to match rules on old revisions - * Fix bug in msg export from archives - -v3.0.448 ----------- - * Support audio attachments in all the audio formats that we can play - * Add name and input to runs API v2 endpoint - * Update InGroup test to match latest goflow - * Expose resthooks over the assets endpoint and update logic to match new engine - * Support messages export from archives - -v3.0.447 ----------- - * Configure Celery to discover Wechat and Whatsapp tasks - * Add Rwanda and Nigeria to AT claim form options - * Extend timeout for archives links to 24h - * Add created_on to the contact export - -v3.0.446 ----------- - * Use constants for max contact fields and max group membership columns - * Tweaks to twitter activity claiming that deals with webhooks already being claimed, shows errors etc - * Rename form field to be consistent with the constants we use - * Writes only now use XLSLite, more coverage - * Limit number of groups for group memberships in results exports - * Swicth message export to use XLSLite - * Fix default ACL value for S3 files - * Add WeChat (for beta users) - -v3.0.445 ----------- - * fix dupe sends in broadcast action - -v3.0.444 ----------- - * fix per credit calculation - -v3.0.443 ----------- - * two decimals for per credit costs, remove trailing 0s - -v3.0.442 ----------- - * Fix ContactField priority on filtered groups - * Update Django to version 1.11.14 - * Reenable group broadcasts - -v3.0.438 ----------- - * When comparsing msg events in flowserver trials, make paths relative again - * Change VariableContactAction to create contacts even without URNs - * Fix import of ID columns from anon export - * Don't fail twilio channel releases if auth key is no longer vaild - * Add UI messaging for archived data - -v3.0.437 ----------- - * Fix import of header ID from anon export - -v3.0.436 ----------- - * Fix supported scheme display lookup - * Move action log delete to flow run release - -v3.0.435 ----------- - * Fix group test operand when contact name is null - * Mention all AfricasTalking countries on claim page - * Warn user of columns to remove on import - * Release events properly on campaign import - * Add languages endpoint to asset server - -v3.0.434 ----------- - * Add option for two day run expiration - * Change group rulesets to use contact as operand same as new engine - * Fix reconstructing sessions for runs being trialled in the flowserver so that we include all session runs - -v3.0.433 ----------- - * Write boolean natively when exporting to xlsx - * Improve reporting of flow server errors during trials - * Clarify about contact import columns - * Update flow result exports to match recent changes to contact exports - -v3.0.432 ----------- - * Update modified_on on contacts that have their URN stolen - * Full releasing of orgs and users - -v3.0.431 ----------- - * Set exit_uuid at end of path when run completes - * Make twitter activity API the default twitter channel type - * Add Nigeria and Rwanda to AT supported countries - * Don't exclude result input from flowserver trial result comparisons - * Use operand rather than msg text for result input - * Remove reporting to sentry when @flow.foo.text doesn't equal @step.text - * Add flow migration to replace @flow.foo.text expressions on non-waiting rulesets - -v3.0.430 ----------- - * Fix message flow updating - -v3.0.429 ----------- - * Remove org.is_purgeable - * Fix format of archived run json to match latest rp-archiver - * Fix checking of result.text values in the context - * Import/Export column headers with type prefixes - * Add groups membership to contacts exports - * Retry calls that are in IVRCall.RETRY_CALL - * Retry IVR outgoing calls if contact did not answer - -v3.0.428 ----------- - * Add FlowRun.modified_on to results exports - * Change how we select archives for use in run exports to avoid race conditions - * Report to sentry when @flow.foo.text doesn't match @step.text - -v3.0.427 ----------- - * Release webhook events on run release - * Fetch run results from archives when exporting results - * Don't create action logs for non-test contacts - -v3.0.426 ----------- - * Migrations for FK protects, including all SmartModels - * Update to latest xlsxlite to fix exporting date fields - * Remove merged runs sheet from results exports - * Modified the key used in the transferto API call - -v3.0.425 ----------- - * Enable burst sms type - -v3.0.424 ----------- - * add burst sms channel type (Australia and New Zealand) - -v3.0.423 ----------- - * trim event fires every 15 minutes - -v3.0.422 ----------- - * Trim event fires older than a certain age - * More consistent name of date field on archive model - * Remove no longer needed functionality for runs that don't have child_context/parent_context set - -v3.0.421 ----------- - * Degroup contacts on deactivate - -v3.0.420 ----------- - * release sessions on reclaimed urns - -v3.0.419 ----------- - * special case deleted scheme in urn parsing - * release urn messages when releasing a contact - * add delete reason to run - -v3.0.418 ----------- - * Clear child run parent reference when releasing parent - * Make sync events release their alerts - * Release sessions, anonymize urns - -v3.0.417 ----------- - * add protect to contacts and flows, you can fake the migrations in this release - -v3.0.416 ----------- - * add deletion_date, use full path as link name - * add unique constraint to disallow dupe archives - -v3.0.415 ----------- - * add needs_deletion field, remove is_purged - -v3.0.414 ----------- - * Set run.child_context when child has no waits - * Use latest openpyxl and log the errors to sentry - * Don't blow up if trialled run has no events - * Allow editors to see archives / api - * Migration to backfill run parent_context and child_context - -v3.0.412 ----------- - * Fix archive filter test - * Include id when serializing contacts for goflow - -v3.0.411 ----------- - * Show when build failed becuse black was not executed - * Fix calculation of low threshold for credits to consider only the top with unused credits - * All flows with subflows to be trialled in the flowserver - * Create webhook mocks for use in flowserver trials from webhook results - * Enable Archive list API endpoint - -v3.0.410 ----------- - * Remove purging, add release with delete_reason - * Set parent_context in Flow.start and use it in FlowRun.build_expressions_context if available - * Add is_archived counts for LabelCounts and SystemLabelCounts, update triggers - -v3.0.409 ----------- - * Remove explicit use of uservoice - * Use step_uuids for recent message calculation - -v3.0.408 ----------- - * Format code with blackify - * Add management commands to update consent status and org membership - * Update to latest goflow to fix tests - * Fix 'raise None' in migration and make flow server trial period be 15 seconds - * Fix the campaign events fields to be datetime fields - * Move flow server stuff from utils.goflow to flows.server - * Add messangi channel type - -v3.0.407 ----------- - * Reenable requiring policy consent - * Allow msgs endpoint to return ALL messages for an org sorted by created_on - * Return error message if non-existent asset requested from assets endpoint - * If contact sends message whilst being started in a flow, don't blow up - * Remove option to have a flow never expire, migrate current flows with never to 30 days instead - * Request the user to fill the LINE channel ID and channel name on the claim form - -v3.0.406 ----------- - * Fix logging events to intercom - -v3.0.405 ----------- - * Migration to remove FlowStep - -v3.0.404 ----------- - * remove old privacy page in favor of new policy app - * use python3 `super` method - * migration to backfill step UUIDs on recent runs - -v3.0.403 ----------- - * tweaks to add_analytics users - -v3.0.402 ----------- - * add native intercom support, add management command to update all users - -v3.0.401 ----------- - * Fix quick replies in simulator - * Lower the min length for Facebook page access token - * Update Facebook claim to ask for Page ID and Page name from the user - * Add new policies and consent app - * Fix another migration that adds a field and writes to it in same transaction - * Add step UUID fields to FlowPathRecentRun and update trigger on run paths to start populating them - -v3.0.400 ----------- - * Don't create flow steps - * Remove remaining usages of six - -v3.0.399 ----------- - * Drop no longer used FlowRun.message_ids field - * Don't allow nested flowserver trials - * Fix migrations which can lead to locks because they add a field and populate it in same transaction - * Remove a lot of six stuff - * Use bulk_create's returned msgs instead of forcing created_on to be same for batches of messages created by Broadcast.send - * Use sent_on for incoming messages's real world time - * Don't require steps for flow resumptions - -v3.0.398 ----------- - * Add period, rollup fields to archive - -v3.0.397 ----------- - * Stop writing .recipients when sending broadcasts as this is only needed for purged broadcasts - * Rework run_audit command to check JSON fields and not worry about steps - * Replace json_date_to_datetime with iso8601.parse_date - * Stepless surveyor runs - -v3.0.396 ----------- - * Use run path instead of steps to recalculate run expirations - * Stop writing to FlowRun.message_ids - -v3.0.395 ----------- - * Change FlowRun.get_last_msg to use message events instead of FlowRun.message_ids - * Stop saving message associations with steps - -v3.0.393 ----------- - * Drop values_value - -v3.0.392 ----------- - * Remove broadcast purging - -v3.0.391 ----------- - * remove reference to nyaruka for trackings users - * fix test decoration to work when no flow server configured - -v3.0.390 ----------- - * Disable webhook calls during flowserver trials - * Use FlowRun.events for recent messages rollovers - -v3.0.389 ----------- - * add archive model, migrations - -v3.0.388 ----------- - * Make ContactField header clickable when sorting - * Add first python2 incompatible code change - * Add contact groups sheet on contact exports - * Remove contact export as CSV - * Update to latest goflow - * Fix test_db contact fields serialization - -v3.0.387 ----------- - * fix flowstarts migration - -v3.0.386 ----------- - * update start contact migration to work with malformed extra - -v3.0.384 ----------- - * fix not selecting contact id from ES in canary task - -v3.0.383 ----------- - * add canary task for elasticsearch - * record metrics about flowserver trial to librarto - * allow sorting of contact fields via dragging in manage dialog - -v3.0.382 ----------- - * rename flow migration - -v3.0.381 ----------- - * limit number of flows exited at once, order by expired_on to encourage index - * remove python 2.7 build target in travis - * start flow starts in the flows queue vs our global celery one - * add flow start count model to track # of runs in a flow start - * Always use channel.name for channel assets - -v3.0.380 ----------- - * update to latest goflow to get location support - * better output logs for goflow differences - -v3.0.379 ----------- - * add v2 editor through /v2 command in simulator - -v3.0.378 ----------- - * get all possible existing Twilio numbers on the Twilio account - * reenable group sends * - * remove Value model usage, Contact.search - -v3.0.377 ----------- - * do not allow dupe broadcasts to groups - * Use ElasticSearch to export contacts and create dynamic groups - * remove celery super auto scaler - * update whatsapp activation by setting rate limits using new endpoints - * fix incorrect keys for tokens and account sids for twiml apps - * add ability to test flow results against goflow - -v3.0.376 ----------- - * remove celery super auto scaler since we don't use it anywhere - * update whatsapp activation by setting rate limits using new endpoints - * fix incorrect keys for tokens and account sids for twiml apps - * add admin command to help audit ES and DB discrepencies - -v3.0.375 ----------- - * update whatsapp for new API - * new index on contacts_contact.fields optimized for space - -v3.0.374 ----------- - * allow reading, just not writing of sends with groups - * remove old seaching from contact views - -v3.0.373 ----------- - * optimize group views - * don't allow sends to groups to be imported or copied - * remove normal junebug, keep only junebug ussd - * fix isset/~isset, sort by 'modified_on_mu' in ES - * use ES to search for contacts - -v3.0.372 ----------- - * remap sms and status Twilio urls, log people still calling old ones - * fix to display Export buttons on sent msgs folder and failed msgs folder - * use message events in run.events for results exports instead of run.message_ids - -v3.0.371 ----------- - * add twilio messaging handling back in - -v3.0.370 ----------- - * remove logging of base handler being called - -v3.0.369 ----------- - * rename contact field types of decimal to number - * finalize contact imports so that updated contacts have modified_on outside transaction - * try to fetch IVR recordings for up to a minute before giving up - * remove handling and sendind code for all channel types (except twitter and junebug) - -v3.0.368 ----------- - * Fewer sentry errors from ES searching - * Don't assume messages have a UUID in FlowRun.add_messages - -v3.0.367 ----------- - * allow up to two minutes for elastic search lag - -v3.0.366 ----------- - * fix empty queryset case for ES comparison - -v3.0.365 ----------- - * chill the f out with sentry if the first contact in our queryset is less than 30 seconds old - * fix duplicate messages when searching on msgs whose contacts have more than one urn - -v3.0.364 ----------- - * fix environment variable for elastic search, catch all exceptions - -v3.0.363 ----------- - * Add Elastic searching for contacts, for now only validating that results through ES are the same as through postgres searches - -v3.0.361 ----------- - * Migrate Dart/Hub9 Contact urns and channels to support ext schemes - -v3.0.360 ----------- - * Use more efficient queries for check channels task - * Fix Location geojson import - -v3.0.359 ----------- - * Add API endpoint to view failed messages - -v3.0.358 ----------- - * Allow filtering by uuid on runs API endpoint, and include run uuid in webhooks - * Fix blockstrans failing on label count - -v3.0.357 ----------- - * Add linear backdown for our refresh rate on inbox pages - -v3.0.356 ----------- - * Do not log MageHandler calls - * Serialize contact field label as name instead - -v3.0.355 ----------- - * Use force_text on uuids read from redis - * Log errors for any channel handler methods - -v3.0.354 ----------- - * Set placeholder msg.id = 0 - * Fix comparison when price is None - -v3.0.353 ----------- - * Evaluate contact field with no value as False - -v3.0.352 ----------- - * Update to Facebook graph api v2.12 - -v3.0.351 ----------- - * Support plain ISO dates (not just datetimes) - -v3.0.350 ----------- - * Swallow exceptions encountered when parsing, don't add to group - * Set placeholder msg.id = 0 - -v3.0.349 ----------- - * Deal with null state values in contact search evaluation - -v3.0.348 ----------- - * Fix off by one error in calculating best channel based on prefixes - * Reevaluate dynamic groups using local contact fields instead of SQL - -v3.0.347 ----------- - * Add modified_on index for elasticsearch - -v3.0.346 ----------- - * Don't start archived flows - * Don't show stale dates on campaign events - * Allow brands to configure flow types - * Remove group search from send to others action - * Fixes for test contact activity - -v3.0.345 ----------- - * Migration to backfill run.events and add step uuids to run.path - * Do the right thing when we are presented with NaN decimals - -v3.0.344 ----------- - * Use real JSONField for FlowRun.events - * Add FlowRun.events and start populating with msg events for new runs - * Serialize Contact.fields in test_db - * Update to latest goflow release - -v3.0.342 ----------- - * Fix for decimal values in JSON fields attribute - * Fix for not being able to change contact field types if campaign event inactive - -v3.0.341 ----------- - * Add if not exists to index creation for fields - * Last of Py3 compatibility changes - -v3.0.340 ----------- - * Use fields JSON field on Contact instead of Value table for all reading. - * Force campaign events to be based off of DateTime fields - * Migration to change all contact fields used in campaign events to DateTime - * Migration to add GIN index on Contact.fields - -v3.0.339 ----------- - * Remove leading and trailing spaces on location string before boundaries path query - * Require use of update_fields with Contact.save() - * Event time of contact_changed is when contact was modified - * Use latest goflow release - * Make special channel accessible during simulator use - -v3.0.338 ----------- - * Always serialize contact field datetime values in the org timezone - * Add migration for population of the contact field json - -v3.0.336 ----------- - * Update middlewares to Django defaults for security - * Add JSON fields to Contact, set in set_field - * backfill any null location paths, make not null, update import to set path, set other levels on fields when setting location - -v3.0.335 ----------- - * Allow groups when scheduling flows or triggers - * Fix configuration page URLs and use courier URLs - * Replace contact.channel in goflow serialization with a channel query param in each contact URN - * Serialize contact.group_uuids as groups with name and UUID - -v3.0.334 ----------- - * Add response to external ID to courier serialized msg if we have response to - * More Py3 migration work - * Remove broadcasting to groups from Send Message dialog - -v3.0.332 ----------- - * Do not delete RuleSets only disconnect them from flows - -v3.0.331 ----------- - * Fix scoping for sim show/hide - -v3.0.330 ----------- - * Allow toggling of new engine on demand with /v2 command in simulator - -v3.0.329 ----------- - * Fix negative cache ttl for topups - -v3.0.328 ----------- - * Remove Vumi Type - * Remove custom autoscaler for Celery - * Implement Plivo without Plivo library - -v3.0.325 ----------- - * Build dynamic groups in background thread - * Dynamic Channel changes, use uuids in URLs, allow custom views - * Allow WhatsApp channels to refresh contacts manually - * Allow brands to specifiy includes for the document head - * Fix external claim page, rename auth_urn for courier - * Change VB channel type to be a dynamic channel - * Remove unused templates - -v3.0.324 ----------- - * Add ability to run select flows against a flowserver instance - -v3.0.323 ----------- - * Move JioChat access creation to channel task - * Use 'list()' on python3 dict iterators - * Use analytics-python===1.2.9, python3 compatible - * Fix using PlayAction in simulator and add tests - * Fix HasEmailTest to strip surrounding punctuation - * ContainsPhraseTest shouldn't blow up if test string is empty - * Use 'six' library for urlparse, urlencode - -v3.0.322 ----------- - * Unfreeze phonenumbers library so we always use latest - * Remove old Viber VI channel type - * Add config template for LN channel type - * Move configuration blurbs to channel types - * Move to use new custom model JSONAsTextField where appropriate - -v3.0.321 ----------- - * Fix quick-reply button in flow editor - -v3.0.320 ----------- - * Fix webhook rule as first step in run interpreting msg wrong - * Change mailto URN importing to use header 'mailto' and make 'email' always a field. Rename 'mailto' fields to 'email'. - -v3.0.319 ----------- - * Add ArabiaCell channel type - * Tweaks to Mtarget channel type - * Pathfix for highcharts - -v3.0.318 ----------- - * Add input to webhook payload - -v3.0.317 ----------- - * Remove support for legacy webhook payload format - * Fix org-choose redirects for brands - -v3.0.316 ----------- - * Remove stop endpoint for MT - -v3.0.315 ----------- - * Inactive flows should not be listed on the API endpoint - * Add Mtarget channel type - -v3.0.314 ----------- - * Add run dict to default webhook payload - -v3.0.313 ----------- - * have URNs resolve to dicts instead of just the display - * order transfer credit options by name - * show dashboard link even if org is chosen - -v3.0.312 ----------- - * include contact URN in webhook payload - -v3.0.311 ----------- - * Allow exporting results of archived flows - * Update Twitter Activity channels to work with latest beta changes - * Increase maximum attachment URL length to 2048 - * Tweak contact searching so that set/not-set conditions check the type specific column - * Migration to delete value decimal/datetime instances where string value is "None" - * Don't normalize nulls in @extra as "None" - * Clear timeouts for msgs which dont have credits assigned to them - * Simpler contact get_or_create method to lookup a contact by urn and channel - * Prevent updating name for existing contact when we receive a message - * Remove fuzzy matching for ContainsTest - -v3.0.310 ----------- - * Reimplement clickatell as a Courier only channel against new API - -v3.0.309 ----------- - * Use database trigger for inserting new recent run records - * Handle stop contact channel events - * Remove no longer used FlowPathRecentRun model - -v3.0.308 ----------- +## v9.3.83 (2024-10-30) + +- Show same featured + proxy fields on the group pages +- Fix scrolling for contact group pages +- Add query checks to ticket view tests and fix missing prefetches + +## v9.3.82 (2024-10-29) + +- Make teams an org feature.. that nobody has for now +- Some cleanup to topic crudl and ticket folders +- Tweak name/url of contact group filter list page +- Filter topics in topic selection menu based on team membership +- Add basic team CRUDL views + +## v9.3.81 (2024-10-28) + +- Fix N+1 query on contact list page +- Cleanup more list pages and move more functionality to org/base views + +## v9.3.80 (2024-10-24) + +- Add migration to assign teamless agents to the default team +- Prevent deletion of system teams +- Assign new agent users to the default team if team not specified +- Data migration to give existing orgs a default team + +## v9.3.79 (2024-10-24) + +- Add max length of 10,000 to shortcut text +- Give every workspace a default team with access to all topics +- Change delete links on list views to be clearer + +## v9.3.78 (2024-10-23) + +- Use django filter to format archive size +- Fix paging on archive list pages and make styling consistent with other list views +- Add Team.all_topics to more easily model a team that can access all topics +- Remove styles from contact field list page that are no longered used since it became a placeholder for the field management component +- Convert API tokens page to be real list page +- Make some list pages use a common template + +## v9.3.77 (2024-10-22) + +- Update django +- Update to python 3.12 +- Simplify bulk labeling of msgs and flows +- Remove unused code from MsgCRUDL.Menu and add test + +## v9.3.76 (2024-10-18) + +- Fix agents shortcuts permission + +## v9.3.75 (2024-10-17) + +- Add Shortcuts UI +- Normal menu navigation for tickets + +## v9.3.74 (2024-10-17) + +- Remove pre-spa days code from flow list view +- Add more clarifications to FreshChat claim page +- Cleanup channel claim pages with steps + +## v9.3.73 (2024-10-17) + +- Overhaul UI for managing child workspaces + +## v9.3.72 (2024-10-17) + +- Move org service view to staff app +- Drop Invitation.user_group and UserSettings.team + +## v9.3.71 (2024-10-17) + +- Fix invitations count on org menu to exclude expired invitations + +## v9.3.70 (2024-10-16) + +- Data migration to set Invitation.role_code + +## v9.3.69 (2024-10-16) + +- Fix how we model team membership so that users can belong to different teams in different workspaces + +## v9.3.68 (2024-10-16) + +- Tweak user update and delete forms to return 404 for users not in the current org + +## v9.3.67 (2024-10-16) + +- New CRUDL views for org users and invitations + +## v9.3.66 (2024-10-16) + +- Fix displaying the channel log missing HTTP response +- Fix claim number to display non field errors +- Remove support for user management of sub-orgs without switching to those orgs + +## v9.3.65 (2024-10-10) + +- Add mixin for views that require a feature + +## v9.3.64 (2024-10-09) + +- Fix modal for deleting a shortcut +- Tweak list view templates for consistency +- Data migration to tweak names of existing status groups + +## v9.3.63 (2024-10-09) + +- Create status groups with invalid names to avoid conflicts with real group names +- Bump django from 5.1 to 5.1.1 + +## v9.3.62 (2024-10-08) + +- Fix double character rendering on autogrow inputs + +## v9.3.61 (2024-10-08) + +- Move staff only rg and user views to new staff app + +## v9.3.60 (2024-10-08) + +- Improve invitation emails + +## v9.3.59 (2024-10-08) + +- Fix not creating invitation accepted notifications in case of new user signup + +## v9.3.58 (2024-10-08) + +- Use mailroom to trigger android channel sync +- Add new notification type for when an invitation to join a workspace is accepted +- More refactoring of modal views + +## v9.3.57 (2024-10-04) + +- More view refactoring + +## v9.3.56 (2024-10-03) + +- Cleanup some view mixins + +## v9.3.55 (2024-10-03) + +- Temporarily hide menu item for shortcuts +- Add pagination to flow starts and webhook logs pages +- Add internal API endpoint for fetching shortcuts +- Add model and CRUDL views for ticket shortcuts +- Fix topic create and update and tweak list pages for consistency + +## v9.3.54 (2024-10-02) + +- Adjust background flow start preview to include all contacts in other flows +- Make template sync use consistent components order to avoid breaking flows variables + +## v9.3.53 (2024-10-01) + +- Fix location aliases to only update in one workspace + +## v9.3.52 (2024-09-30) + +- Add test_errors to mailroom client + +## v9.3.51 (2024-09-27) + +- Update components with progress bar tweaks + +## v9.3.50 (2024-09-27) + +- Add commas for broadcast message count + +## v9.3.49 (2024-09-26) + +- Tweak deindexing a deleted contact + +## v9.3.48 (2024-09-26) + +- Use 10th anniversary rp logo +- Explicitly de-index contacts when released +- Request de-indexing of contacts when hard deleting an org +- Switch to flowstart_list permission for status +- Add status and interrupt for broadcasts and starts + +## v9.3.47 (2024-09-25) + +- Re-introduce QUEUED status for FlowStarts and Broadcasts +- Remove progress field from flow starts endpoint docs + +## v9.3.46 (2024-09-23) + +- Add progress field to broadcasts API endpoint +- Add Broadcast.interrupt(user) + +## v9.3.45 (2024-09-23) + +- Add PENDING/STARTED statuses and contact_count field to broadcasts + +## v9.3.44 (2024-09-23) + +- Validate channel variable in the body for EX channels +- Replace broadcast status S with C + +## v9.3.43 (2024-09-19) + +- Add support broadcast status (C)COMPLETED +- Remove broadcasts from Outbox now that they have their own page +- Put starts before webhooks on flow history menu + +## v9.3.42 (2024-09-18) + +- Cleanup how we read and anonymize channel logs + +## v9.3.41 (2024-09-18) + +- Limit SetRunResult category length in editor +- Add --testing argument to migrate_dynamo command +- Start reading attached channel logs from DynamoDB instead of S3 + +## v9.3.40 (2024-09-17) + +- Add INTERRUPTED as a status for flow starts +- Switch flow starting blocker to warning + +## v9.3.39 (2024-09-17) + +- Show bad import file error as validation errors to the user +- Fix flow start progress bar with high pcts +- Simplify outbox limit to be hardcoded at 1M +- Validate body for EX channel type will be valid JSON after replacing variables + +## v9.3.38 (2024-09-14) + +- Add flow start progress bar + +## v9.3.37 (2024-09-13) + +- Fix import read page title +- Fix importing contacts from spreadsheet with broken dimensions +- Fix TTL attribute name on DynamoDB channel logs table + +## v9.3.36 (2024-09-12) + +- Use 'tasks:batch' queue name instead of 'batch' + +## v9.3.35 (2024-09-12) + +- Add progress field to flow starts endpoint + +## v9.3.34 (2024-09-11) + +- Add timing controls around flow starts + +## v9.3.33 (2024-09-11) + +- Rename dynamodb channel logs table + +## v9.3.32 (2024-09-07) + +- Add outbox monitor for large queues + +## v9.3.31 (2024-09-05) + +- Add an org limit for too many messages in outbox + +## v9.3.30 (2024-09-02) + +- Import cell data value instead of formulas using data_only flag to load the workbook + +## v9.3.29 (2024-08-27) + +- Fix authorization code, verification, redirect URI + +## v9.3.28 (2024-08-27) + +- Authorization code cannot be debugged +- Fix channel URLs to have a trailing slash +- Delete no longer used test flows +- Simplify functions for loading flows in tests and move flows used by legacy migration tests into their own directory +- TembaTest.create_flow should return a flow in latest version without migrating +- Only import real flows in tests where it's required +- Update README.md + +## v9.3.27 (2024-08-21) + +- Updates to migrate_dynamo command + +## v9.3.26 (2024-08-21) + +- Add redirect for contact interrupt +- Create dynamo table with on-demand billing by default + +## v9.3.25 (2024-08-21) + +- Fix matching for invites with email case insensitively +- Tweak migrate_dynamo command + +## v9.3.24 (2024-08-20) + +- Add dynamo table prefix setting + +## v9.3.23 (2024-08-20) + +- Add management command to create DynamoDB tables +- Add option for connection pooling + +## v9.3.22 (2024-08-19) + +- Drop APIToken.role field + +## v9.3.21 (2024-08-19) + +- Use correct URL when breaking spa-container +- Delete API tokens when user deleted and use generate_secret to create new tokens +- Update API token management UI to support multiple tokens + +## v9.3.20 (2024-08-14) + +- Rework S3 code to always use real S3 clients, even in tests + +## v9.3.19 (2024-08-14) + +- Fix DTOne formax section +- Change default settings to use minio for file storage + +## v9.3.18 (2024-08-13) + +- Record when API tokens were last used +- Only support import contacts using .xlsx files with openpyxl + +## v9.3.17 (2024-08-12) + +- Data migration to delete old surveyor and prometheus API tokens + +## v9.3.16 (2024-08-08) + +- Stop generating prometheus API tokens +- Drop Ticket.body + +## v9.3.15 (2024-08-08) + +- Add Org.prometheus_token and backill from API tokens + +## v9.3.14 (2024-08-08) + +- Update tests to not set ticket body +- Add data migration to move body to ticket on open ticket event + +## v9.3.13 (2024-08-08) + +- Show notes on ticket open events in contact history +- Remove body from ticket endpoint documentation +- Update floweditor which now also refers to ticket body as note +- Update open ticket modal to use note instead of body +- Add cutoff date for using viewer role + +## v9.3.12 (2024-08-07) + +- Don't create surveyor user in mailroom test db +- Add warning to manage accounts page if org has viewers +- Remove viewers as an org feature, only allow existing viewer users to remain as viewers +- Update to latest Django + +## v9.3.11 (2024-08-07) + +- Remove Org.surveyor_password and always disable creating surveyor flows +- Remove non-modal response support from export translation view +- Remove surveyor user role and test user + +## v9.3.10 (2024-08-07) + +- Remove surveyor users from workspaces + +## v9.3.9 (2024-08-07) + +- Fix incidents templates name +- Let Ticket.body be null and make note length match contact note length + +## v9.3.8 (2024-08-06) + +- Show tabs on tickets when contact is set + +## v9.3.7 (2024-08-06) + +- Add contact notes ui + +## v9.3.6 (2024-08-06) + +- Adjust the grant view for new UI +- Fix Android claim page +- Add incident for Android client app version out of date +- Tweak fail_old_messages to only fail Android messages and add an index + +## v9.3.5 (2024-07-31) + +- Support FCM changes +- Require E164 phone numbers for contacts created from UI + +## v9.3.4 (2024-07-30) + +- Add contact notes and expose over contacts API endpoint + +## v9.3.3 (2024-07-29) + +- Clamp messages on message views to one line +- Adjust max length for AT API key +- Make 'New Field' a button + +## v9.3.2 (2024-07-29) + +- Allow deleting of empty ticket topics +- Add support for buttons in side menu and use where appropriate + +## v9.3.0 (2024-07-25) + +- Add User.get_by_email to ensure consistent behaviour where we look up a user by their email +- Omnibox fixes and cleanup + +## v9.2.5 (2024-07-24) + +- Ensure that emails are consistently treated as case insensitive + +## v9.2.4 (2024-07-23) + +- Simplify FCM config setting names + +## v9.2.3 (2024-07-23) + +- More updates to WhatsApp claiming + +## v9.2.2 (2024-07-23) + +- Fix WhatsApp embedded signup + +## v9.2.1 (2024-07-18) + +- Catch errors from xlrd reading import rows and return errors with row numbers +- Update xlrd +- Honor meta key keyboard press inside contact chat + +## v9.2.0 (2024-07-17) + +- Simplify permissions in flows app +- Tweak menu items for msg views and flow results + +## v9.1.198 (2024-07-17) + +- Allow template image variables to be text with expressions + +## v9.1.196 (2024-07-16) + +- Add **repr** to more models and tweak existing ones for consistency +- Fix rendering of flow starts for deleted flows +- Add data migration to trim old broadcasts to nodes that resulted in very large contact lists + +## v9.1.195 (2024-07-16) + +- Remove special error handling for broadcast to node that resolves to no recipients +- Fix setting a template on a new broadcast +- Fix query broadcast creation and update +- Add rendering of exclusions on broadcasts +- Fix not showing query on broadcast recipients list and add node_uuid + +## v9.1.194 (2024-07-15) + +- Add Broadcast.node_uuid field +- Remove old code for getting message created_by from broadcasts +- Make some exception clauses more specific + +## v9.1.193 (2024-07-15) + +- Replace TemplateTranslation.STATUS_UNSUPPORTED completely + +## v9.1.192 (2024-07-15) + +- Add new template statuses and stop using fake "unsupported" status + +## v9.1.191 (2024-07-15) + +- Fix deactivating a legacy WhatsApp channel +- Update format of templates on API endpoint +- Show template translation problems as errors on template read page + +## v9.1.190 (2024-07-12) + +- Fix padding for broadcast schedule update + +## v9.1.189 (2024-07-12) + +- Fix mailroom_db +- Data migration to populate TemplateTranslation.is_supported and is_compatible + +## v9.1.188 (2024-07-12) + +- Add new boolean fields to TemplateTranslation model to determine whether it's usable + +## v9.1.187 (2024-07-12) + +- Add templates to broadcasts + +## v9.1.186 (2024-07-11) + +- Fix handling of POSTs to API docs +- Exclude empty templates from list, and show base translation apart on read page +- Ensure we choose a new base for a template whenever an existing base translation is deleted + +## v9.1.185 (2024-07-11) + +- Update deps +- Replace telegram library by requests use +- Fix dashboard menu link permission +- Expose Template.base_translation on API endpoint + +## v9.1.184 (2024-07-11) + +- Use dropdowns for location fields + +## v9.1.183 (2024-07-11) + +- Use dropdowns for location fields + +## v9.1.182 (2024-07-10) + +- Locations API endpoint should allow searching on the path +- Fix template syncing when channel gives us invalid template data + +## v9.1.181 (2024-07-10) + +- Add Template.base_translation +- Fix dashboard workspace data +- Allow creation of contacts with non-active statuses + +## v9.1.180 (2024-07-10) + +- Drop no longer used is_active field from TemplateTranslation +- Tweak wording on template list page +- Add db constraint to ensure contact status is valid + +## v9.1.179 (2024-07-10) + +- Keep FCM ID in channel config when soft deleting the channel +- Stop using TemplateTranslation.is_active and make nullable + +## v9.1.178 (2024-07-09) + +- Allow broadcast creation with zero matches + +## v9.1.177 (2024-07-08) + +- Hard delete remaining soft-deleted template translations + +## v9.1.176 (2024-07-08) + +- Update Template to a TembaModel +- Hard delete template translations that no longer exist on the channel side + +## v9.1.175 (2024-07-05) + +- Make send_when optional when updating broadcasts + +## v9.1.174 (2024-07-05) + +- Fix updating scheduled broadcasts +- Remove old unused code for queueing broadcasts + +## v9.1.173 (2024-07-05) + +- Add Msg.is_android field +- Add internal API endpoint for searching locations by level and name +- Remove option to send now on broadcast update + +## v9.1.172 (2024-07-04) + +- Add templates to broadcasts (hidden for now) +- Remove deprecated broadcast.template_state field on mailroom queue payload + +## v9.1.171 (2024-07-03) + +- Update payload for queueing a bradocast + +## v9.1.170 (2024-07-03) + +- Remove no longer needed task to sync stale Android relayers +- Don't allow template localization +- Update dependencies + +## v9.1.169 (2024-07-02) + +- Use python 3.11.x +- Add Broadcast.template_variables +- Add new template list and read pages and remove old channel specific ones +- Fix globals list template + +## v9.1.168 (2024-06-28) + +- Don't sync classifiers in suspended orgs +- Fix empty contact search with query present + +## v9.1.167 (2024-06-28) + +- Disallow empty recipient targeting +- Fix external links within spa container + +## v9.1.166 (2024-06-27) + +- Tweak logging for failure during classifier syncing +- Switch broadcast tests to use contact search + +## v9.1.165 (2024-06-27) + +- Rework remaining mailroom client methods +- Add unique constraint on template translations + +## v9.1.164 (2024-06-27) + +- Add data migration to remove duplicate template translations + +## v9.1.163 (2024-06-27) + +- Change template translation syncing to enforce uniqueness over channel+locale + +## v9.1.162 (2024-06-27) + +- Make templatetranslation locale non-null +- Add migration to release translations for released channels + +## v9.1.161 (2024-06-27) + +- Fix not releasing template translations when channel released + +## v9.1.160 (2024-06-27) + +- Fix creating scheduled broadcasts +- Tweak menu on campaign read page +- Update to latest smartmin + +## v9.1.159 (2024-06-26) + +- Simplify some button labels and make edit a button on contact read page +- Don't show empty contact filter list +- Rework more mailroom client methods to use models instead of primitives + +## v9.1.158 (2024-06-26) + +- Add day selection when doing flow start search +- Tweak mailroom_db to run on different port + +## v9.1.157 (2024-06-25) + +- Reorg of mailroom client +- Add Broadcast.exclusions + +## v9.1.156 (2024-06-24) + +- Change broadcast creation from UI to use mailroom + +## v9.1.155 (2024-06-24) + +- Fix WAC to addEventListener in OnSpload +- Fix horizontal scrolling for contacts list +- Add Broadcast.template + +## v9.1.154 (2024-06-21) + +- Fix z-index issue properly + +## v9.1.153 (2024-06-21) + +- Fix z-index issue with content menu and chat + +## v9.1.152 (2024-06-21) + +- Fix ticket switching bug + +## v9.1.151 (2024-06-21) + +- Update chat rendering + +## v9.1.148 (2024-06-20) + +- Fix Broadcast.create + +## v9.1.147 (2024-06-20) + +- Use mailroom to create broadcasts from API calls +- Use mailroom to send broadcasts to flow nodes + +## v9.1.146 (2024-06-17) + +- Don't clip footer when ticket history grows +- Fix migration to add uuid field to airtime transfers + +## v9.1.145 (2024-06-17) + +- Don't send forgot password email if one was sent in last 5 minutes +- Delete failed login records on successful password reset +- Make transer UUID unique field, use TembaUUIDMixin on model + +## v9.1.144 (2024-06-14) + +- Add pagination on channel templates page +- Add settings config for Android clients FCM config +- Remove pyfcm and use google auth library to send sync messages for FCM +- Create our own password recovery view + +## v9.1.143 (2024-06-12) + +- Update smartmin +- Delete recovery tokens when new ones are created or email changed +- Populate airtime transfer uuids + +## v9.1.142 (2024-06-12) + +- Add AirtimeTransfer.external_id +- Add data migration to cleanup template translations + +## v9.1.141 (2024-06-12) + +- Update to latest smartmin +- Add uuid field to airtime transfer model + +## v9.1.140 (2024-06-12) + +- Really actually fix template attachments for real + +## v9.1.139 (2024-06-11) + +- Fix split issue for template editor + +## v9.1.138 (2024-06-10) + +- Template editor fix for empty content +- Tweak component types to be header/_, body/_ etc +- Support Twilio media in templates + +## v9.1.137 (2024-06-10) + +- Support WhatsApp templates with header images +- Remove no longer used URN related code +- Generate email verification secret when account created, change when email changed + +## v9.1.136 (2024-06-07) + +- Add spa mixin to transfer logs views +- Allow editing TWA messaging service SID +- Lean on mailroom for URN validation during contact update +- Some tidy up of the update contact form + +## v9.1.135 (2024-06-05) + +- Fix login error message styling +- Remove unused JS libs + +## v9.1.134 (2024-06-05) + +- Contact API endpoint should let mailroom decide if a URN is taken +- Revert "Remove csrf token hidden element not under a form" + +## v9.1.133 (2024-06-05) + +- Fix API explorer POSTs +- Make CSRF cookie age 2 weeks and remove non-form hidden CSRF hidden elements + +## v9.1.132 (2024-06-04) + +- Make sure the CSRF element is present for all page header blocks + +## v9.1.131 (2024-05-31) + +- Fix DT One submit buttons + +## v9.1.130 (2024-05-31) + +- Fix flow and msgs unlabel action +- Remove no longer used params field on synched whatsapp type templates + +## v9.1.129 (2024-05-29) + +- Increase DATA_UPLOAD_MAX_NUMBER_FIELDS to 2500 +- Fix FB and IG claim getFBpages + +## v9.1.128 (2024-05-27) + +- Lean on mailroom for validation of phone numbers from android events / messages + +## v9.1.127 (2024-05-27) + +- Rework contact create view to let mailroom do URN validation + +## v9.1.126 (2024-05-24) + +- Mailroom client should use content-type header on responses to know whether to parse as JSON +- Ensure anon users can access API docs + +## v9.1.125 (2024-05-23) + +- Add csrf on hidden element + +## v9.1.124 (2024-05-22) + +- Rework handling of errors from mailroom client +- Update test db flows + +## v9.1.123 (2024-05-20) + +- Replace django messages rendering with toasts + +## v9.1.121 (2024-05-16) + +- Fix action to remove from group. +- Report bulk action errors to users with django messages + +## v9.1.120 (2024-05-16) + +- Remove old unused ES sorting code +- Update to latest smartmin and disable auto success messages +- Add data migration to fix system fields for existing orgs and start using is_proxy +- Reduce reserved keys for fields to bare minimum + +## v9.1.119 (2024-05-16) + +- Add ContactField.is_proxy and reduce SYSTEM_FIELDS to the two proxy date fields +- Don't use error level alerts for form errors + +## v9.1.118 (2024-05-15) + +- Remove unused args from MailroomClient.parse_query +- Re-add search errors to contact list views + +## v9.1.117 (2024-05-15) + +- Add support for unknown_property_type search errors +- Add support for twilio card type content templates +- Add way to view webhook logs errors only + +## v9.1.116 (2024-05-14) + +- Fix issues with twilio templates sync + +## v9.1.115 (2024-05-10) + +- Fix Twilio template type slug and register its template type + +## v9.1.114 (2024-05-10) + +- Add message templates menu for TWA channels +- Activate Twilio Whatsapp to sync templates with twilio type +- Update to allow matching sender ID as valid phones + +## v9.1.113 (2024-05-09) + +- Fix gaps it contact history + +## v9.1.112 (2024-05-09) + +- Ignore android msg/event cmds with non numeric phones + +## v9.1.111 (2024-05-08) + +- Send phone instead of urn to mailroom android endpoints +- Add Twilio content template type, and TWA fetch_templates + +## v9.1.110 (2024-05-08) + +- Remove messages block that duplicates alert-messages +- Tweak DefinitionExport.name for consistency + +## v9.1.109 (2024-05-07) + +- Tweak export finished emails so they don't say Excel + +## v9.1.108 (2024-05-07) + +- Update temba-components to 0.86.1 +- Change flow definitions export to be async, use new export type + +## v9.1.107 (2024-05-07) + +- Fix variable name in http log read page +- Fix claiming instagram + +## v9.1.106 (2024-05-06) + +- Fix globals API endpoint + +## v9.1.105 (2024-05-03) + +- Fix race condition on editor load + +## v9.1.104 (2024-05-03) + +- Fix template bug and loading error for editor + +## v9.1.103 (2024-05-02) + +- Fix contact field selection + +## v9.1.102 (2024-05-02) + +- Delete all sessions and runs in org deletion in batches +- Tiny style change for loader wrapping on editor + +## v9.1.101 (2024-05-01) + +- Update editor and flow spec version + +## v9.1.100 (2024-04-29) + +- Tweak time limit for sessions to 89 days so things are always interrupted before archiver gets to them +- Cleanup API endpoint docs + +## v9.1.99 (2024-04-26) + +- Remove elastic search +- Add support for read msg status + +## v9.1.98 (2024-04-25) + +- Fix ticket status selection + +## v9.1.97 (2024-04-25) + +- Include url for org chooser + +## v9.1.96 (2024-04-25) + +- Remove jQuery + +## v9.1.95 (2024-04-25) + +- Change ordering of non-search based exports to be id to match search based +- Use mailroom endpoint for search based contact exports +- Remove cancel button from contact import page and remove duplicate styles +- Tweak layout of user edit form +- Email notification that account email has changed should include the new email address + +## v9.1.94 (2024-04-24) + +- Fix changing password so user isn't logged out +- Fix user edit form allowing insecure passwords + +## v9.1.93 (2024-04-24) + +- Add notification types for when email or password is changed +- Expire unaccepted invitations after 30 days +- Move invitation form into modal + +## v9.1.92 (2024-04-23) + +- Remove start url for surveyors and instead do login redirect +- Fix to disallow content type vs extension mismatching for media uploads +- Fix to limit sending user verification email to 1 per 10 minutes +- Remove warning for flows that don't specify Facebook topic + +## v9.1.91 (2024-04-18) + +- Fix select race +- Fix header matching +- Simplify URL for template list page + +## v9.1.90 (2024-04-16) + +- Fix race on initial load for select and tabs + +## v9.1.89 (2024-04-16) + +- Fix API docs scrolling +- Fix mailroom_db data file +- Simplify channel claim page styling and remove unused styles +- Add Msg.templating + +## v9.1.88 (2024-04-15) + +- Drop FlowRun.submitted_by and cleanup superfulous constants +- Make whatsapp template type an actual package +- Simplify page titles so section isn't repeated in title + +## v9.1.87 (2024-04-12) + +- Add inline attachment style and wrapping on logs +- Don't re-release released triggers + +## v9.1.86 (2024-04-12) + +- Prune unnecessary styles, move to heavier fonts + +## v9.1.85 (2024-04-12) + +- Drop support for Submitted By in results exports +- Add constraint to limit Msg.DIRECTION to I or O +- Add constraint to incoming messages have channel and URN + +## v9.1.83 (2024-04-11) + +- Add TemplateType and rework whatsapp to be a type +- Remove special treatment for exports of surveyor flows +- Add TemplateTranslation.variables + +## v9.1.82 (2024-04-10) + +- Unpublicize the channel events API endpoint +- Drop unused Msg.queued_on field + +## v9.1.81 (2024-04-10) + +- Update temba-components + +## v9.1.80 (2024-04-10) + +- Assume js is pre-minified + +## v9.1.79 (2024-04-09) + +- Update flow editor + +## v9.1.78 (2024-04-09) + +- Use new components bundle + +## v9.1.77 (2024-04-09) + +- Deprecate Msg.queued_on as it isn't used and make Msg.modified_on non-null + +## v9.1.76 (2024-04-08) + +- Add data migration to backfill missing user settings +- Add signal receiver to ensure new users always have settings + +## v9.1.75 (2024-04-04) + +- Add data migration to archive campaigns with deleted groups +- Fix rendering of campaigns with deleted groups +- Improve styling on template list page + +## v9.1.74 (2024-04-04) + +- Update temba-components +- Use timedate formatting for last_seen_on / created_on on contact list pages +- Remove unused BRAND properties +- Cleanup displaying of channel name, address and type + +## v9.1.73 (2024-04-03) + +- Make Channel.name non-null and remove unused channel list view +- Replace format_datetime and short_datetime tags with day or datetime filters + +## v9.1.72 (2024-04-03) + +- Update temba-components +- Add data migration to backfill empty channel names +- Ensure Android channels get a default name when registering + +## v9.1.71 (2024-04-03) + +- Ignore empty messages from Android relayers + +## v9.1.70 (2024-04-03) + +- Update flow editor +- Remove unused option on assets endpoint to return environment + +## v9.1.69 (2024-04-02) + +- Remove no longer used template tag as_icon +- Fix export blocking due to multiple users exporting at same time +- Switch formax to expand vertically +- Add ChannelEvent.status field and prevent creating channel events of unknown types from Android syncs + +## v9.1.68 (2024-04-02) + +- Use mailroom endpoints to create messages and events during Android syncing +- Drop support for returning template components as dict + +## v9.1.67 (2024-04-01) + +- Update template editor to work with comps as list +- Add task to trim old channel events + +## v9.1.66 (2024-03-28) + +- Update format of tasks queued to mailroom + +## v9.1.65 (2024-03-28) + +- Update to django 5.0 and DRF 3.15.1 + +## v9.1.64 (2024-03-25) + +- Tweak menu styling + +## v9.1.63 (2024-03-22) + +- Add open tab event + +## v9.1.62 (2024-03-22) + +- Make workspace selection use common event pattern +- Truncate long template name to not break the page +- Replace iso630 with iso639-lang package +- Fix non Django 5 compatible code + +## v9.1.61 (2024-03-21) + +- Support for menu events + +## v9.1.60 (2024-03-21) + +- Update to latest ruff, isort and djlint +- Drop TemplateTranslation.comps_as_dict +- Get rid of channel typed owned sync log views and use new channel view on HTTP log CRUDL +- Convert templates views to actual CRUDL and fix permissions + +## v9.1.59 (2024-03-21) + +- Move template code into templates app +- Stop writing TemplateTranslation.comps_as_dict + +## v9.1.58 (2024-03-20) + +- Some fixes for on-device mobile issues +- Allow returning of components in list format from API endpoint +- Update to latest black +- Don't try to extract parameters from template url button component display values + +## v9.1.57 (2024-03-20) + +- Add name field also to template components +- Tweak template list page to use components list instead of comps_as_dict + +## v9.1.56 (2024-03-19) + +- Save TemplateTranslation.components as list, use comps_as_dict for API endpoint + +## v9.1.55 (2024-03-19) + +- Add temporary TemplateTranslation.comps_as_dict field + +## v9.1.54 (2024-03-19) + +- Add type to template components +- Remove deprecated fields from template translations + +## v9.1.53 (2024-03-18) + +- Fix mobile notice + +## v9.1.52 (2024-03-18) + +- Don't migrate flows when listing campaign events + +## v9.1.51 (2024-03-17) + +- Tweaks to make the interface more mobile friendly + +## v9.1.50 (2024-03-17) + +- Better feedback when editing contact fields + +## v9.1.49 (2024-03-15) + +- Add url param type for buttons with URLs + +## v9.1.48 (2024-03-14) + +- Show more components for WA templates list +- Add display to WA templates button components + +## v9.1.47 (2024-03-14) + +- Remove old templates API endpoint +- Update flow version for campaigns events single message flows + +## v9.1.46 (2024-03-13) + +- Reduce WA template sync error logging to ignore those in http logs + +## v9.1.45 (2024-03-12) + +- Fix the size limit for contact exports + +## v9.1.44 (2024-03-12) + +- Drop old export models and assets app + +## v9.1.43 (2024-03-11) + +- Data migration to delete old flow results exports +- Data migration to delete old msgs exports + +## v9.1.42 (2024-03-11) + +- Data migration to delete old contacts exports + +## v9.1.41 (2024-03-11) + +- Mark templates with button URLs and attachment in header not supported +- Convert exports to use shared export modal view + +## v9.1.40 (2024-03-08) + +- Allow more WhatsApp templates to be usable in the flows + +## v9.1.39 (2024-03-07) + +- Updated editor with sendmsg update fix +- Improve contact export modal and use mailroom endpoint to know how many contacts will be exported + +## v9.1.38 (2024-03-07) + +- Updated component button rendering + +## v9.1.37 (2024-03-07) + +- Do not sync templates for channels on suspended orgs or inactive orgs +- Redact WA password config in HTTP logs + +## v9.1.36 (2024-03-06) + +- Bump spec version to 13.4 +- Update editor to support template components + +## v9.1.35 (2024-03-06) + +- Restrict exports of contact groups that are too big +- Redact auth tokens from http logs when fetching whatsapp templates +- Cleanup code for fetching whatsapp templates and only create incidents after 5 failures +- Add data migration to delete old ticket exports + +## v9.1.34 (2024-03-04) + +- Update floweditor + +## v9.1.33 (2024-03-04) + +- Bump current flow spec version to 13.3 +- Ensure incidents are ended when releasing a channel + +## v9.1.32 (2024-03-04) + +- Update temba-components +- Always send verification email with branding of current org +- Add incident for WhatsApp templates sync failed + +## v9.1.31 (2024-02-28) + +- Fix editing user when language is not an option + +## v9.1.30 (2024-02-28) + +- Hide UI language options when there aren't any +- Update test_db templates + +## v9.1.29 (2024-02-27) + +- Remove DS from available channel and only accessible to beta group +- Prevent further creation of surveyor users since that functionality no longer works + +## v9.1.28 (2024-02-22) + +- Store servicing flag in session to avoid needing user orgs in context processor +- Add select_related to user loading for sessions and API tokens +- Bump cryptography from 42.0.2 to 42.0.4 + +## v9.1.27 (2024-02-21) + +- Update floweditor + +## v9.1.26 (2024-02-18) + +- Bump cryptography from 42.0.0 to 42.0.2 +- Improve the form for setting flow SMTP and make reusable + +## v9.1.25 (2024-02-14) + +- Update temba-components + +## v9.1.24 (2024-02-12) + +- Use dict for flow type icons instead of nested if elses +- Simplify export finished notification emails +- Use Org.Export for flows results exports + +## v9.1.23 (2024-02-09) + +- Fix org avatar scale for menu +- Fix widget for user avatar + +## v9.1.22 (2024-02-08) + +- Fix croppie dependency +- Prefetch user settings on users endpoint + +## v9.1.21 (2024-02-08) + +- Make user settings one to one + +## v9.1.20 (2024-02-08) + +- Use orgs.Export for messages exports +- Simplify sending template emails +- Add new endpoint to internal API for templates +- Trim old export and notifications +- Add support for user avatars + +## v9.1.19 (2024-02-07) + +- Save transformed components for WA templates + +## v9.1.18 (2024-02-06) + +- Cleanup flow SMTP formax and show parent settings as default to match mailroom changes +- Remove old code for saving SMTP into org config + +## v9.1.17 (2024-02-06) + +- Data migration to backfill Org.flow_smtp + +## v9.1.16 (2024-02-06) + +- Add new dedicated Org.flow_smtp field for email settings + +## v9.1.15 (2024-02-06) + +- Bump cryptography from 41.0.7 to 42.0.0 +- Simplify getting default flow email address + +## v9.1.14 (2024-01-30) + +- Remove using readonly DB connection for fetching groups and fields + +## v9.1.13 (2024-01-29) + +- Simplify how we check for existing running exports +- Dta migration to mark old notifications as seen +- Improve export download page +- Allow marking all notifications as read by DELETE request to notifications endpoint +- Use orgs.Export for contact exports + +## v9.1.12 (2024-01-23) + +- Tweak mailgun channel claiming + +## v9.1.11 (2024-01-18) + +- Some cleanup to new exports framework + +## v9.1.10 (2024-01-18) + +- Add skeleton staff only mailgun channel type +- Add export download view + +## v9.1.7 (2024-01-18) + +- Update temba-components +- Save storage path on exports and fix ticket exports not having a download URL + +## v9.1.6 (2024-01-18) + +- Add new generic orgs.Export model and replace ExportTicketsTask +- Simplify messaging when export is started + +## v9.1.5 (2024-01-15) + +- Allow webchat channels to have new convo triggers +- Finished exports should record number of items exported + +## v9.1.4 (2024-01-12) + +- Add skeleton temba chat channel type + +## v9.1.3 (2024-01-12) + +- Add notification for flow exports + +## v9.1.2 (2024-01-11) + +- Fix issue with completion input focus + +## v9.1.1 (2024-01-11) + +- Update notification text + +## v9.1.0 (2024-01-11) + +- Add notifications to UI +- Fix test_db command +- Update stable versions in README + +## v9.0.0 (2024-01-05) + +- Test against mailroom v9 +- Replace dummy migrations with real squashed migrations + +## v8.3.123 (2024-01-05) + +- Add empty versions of squashed migrations + +## v8.3.122 (2024-01-04) + +- Update to latest editor + +## v8.3.121 (2024-01-04) + +- Update to latest floweditor with open ticket changes + +## v8.3.120 (2024-01-03) + +- Allow ticket body to be optional + +## v8.3.119 (2024-01-03) + +- Drop ticketer model + +## v8.3.118 (2024-01-03) + +- Remove view of http logs by ticketer +- Drop Ticket.ticketer and HTTPLog.ticketer + +## v8.3.117 (2024-01-03) + +- Remove ticketer types + +## v8.3.116 (2024-01-03) + +- Fix editor routing edge case +- Remove ticketers API endpoint + +## v8.3.115 (2024-01-02) + +- Update to latest flow editor +- Drop index on ticket.external_id + +## v8.3.114 (2024-01-02) + +- Stop exposing ticket ticketer on endpoints + +## v8.3.113 (2024-01-02) + +- Update temba-components +- Finish cleaning up API v2 tests to use APITestMixin + +## v8.3.112 (2023-12-14) + +- ContactChat with less padding + +## v8.3.111 (2023-12-14) + +- Introduce footer + +## v8.3.110 (2023-12-13) + +- Add index to help fetching scheduled event fires and another to find template translations by channel+external id + +## v8.3.109 (2023-12-13) + +- Move last indexews from SQL file into Django models and drop unused + +## v8.3.108 (2023-12-12) + +- Move all remaining flowrun and flowsession indexes onto their models + +## v8.3.107 (2023-12-12) + +- Fix channel log display when missing URN +- Queued message treatment, flow editor fix +- Update poetry deps +- Move more indexes onto models and remove unnecessary one + +## v8.3.106 (2023-12-11) + +- Cleanup indexes for FlowStartCount, SystemLabelCount and ContactGroupCount +- Use datetime timezone aliased as tzone +- Update django timezone field to 6.1.0 + +## v8.3.105 (2023-12-07) + +- Email changes should reset email status to unverified + +## v8.3.104 (2023-12-07) + +- Remove duplication between channel read and chart views +- Cleanup indexes in channels app +- Remove unhelpful index on eventfire and move other into Django model + +## v8.3.103 (2023-12-05) + +- Data migration to fix bad last seen on values +- Add support for user to start the email verification and send themselves the verification link + +## v8.3.102 (2023-11-30) + +- Testing auto-versioning again + +## v8.3.99 (2023-11-29) + +- Fix syncing OTP utility templates +- Drop unused TemplateTranslate.language and country fields + +## v8.3.98 (2023-11-29) + +- Fix mailroom DB templates components structure +- Bump cryptography from 41.0.4 to 41.0.6 +- Stop writing TemplateTranslation.language and country and remove unsupported language as a possibility + +## v8.3.97 (2023-11-28) + +- Stop reading from TemplateTranslation.language and country +- Undocument the templates API endpoint and add locale field to translations +- Fix syncing OTP utility templates + +## v8.3.96 (2023-11-27) + +- Migration to backfill TemplateTranslation.locale and external_locale + +## v8.3.95 (2023-11-27) + +- Add TemplateTranslation.locale and .external_locale to replace language and country +- Support saving components and params to message templates + +## v8.3.94 (2023-11-23) + +- Update temba-components + +## v8.3.93 (2023-11-23) + +- Fix IVR simulation + +## v8.3.92 (2023-11-22) + +- Tweak appearance of API explorer + +## v8.3.91 (2023-11-21) + +- Cleanup API docs + +## v8.3.90 (2023-11-17) + +- Add pillow dependency + +## v8.3.89 (2023-11-15) + +- Don't allow oeverwriting of flows with a different type during imports +- Enforce unique addresses for more channel types + +## v8.3.88 (2023-11-14) + +- Expose org.input_collation on languages formax +- Remove blog redirect pattern and sitemap +- Add unique_address to channel type and use that to validate channel is unique before claiming it + +## v8.3.87 (2023-11-13) + +- Data migration to delete schedules attached to deleted triggers +- Simulator should use workspace collation setting +- Don't include email only notifications in unseen count for UI + +## v8.3.86 (2023-11-13) + +- Update mailroom endpoint names + +## v8.3.85 (2023-11-10) + +- Data migration to pause schedules of existing archived triggers + +## v8.3.84 (2023-11-09) + +- Allow schedules to be paused when triggers are archived + +## v8.3.83 (2023-11-09) + +- Fix login redirection to next param +- Drop no longer used fields on Schedule and Label +- Overrride mailroom URL in mailroom_db command +- Add view to verify email + +## v8.3.82 (2023-11-08) + +- Ensure that schedules are actually deleted when a broadcast or trigger is soft deleted +- Fix trigger list keyword search +- Make Notifications.medium non-null and use to filter notifications on API endpoint +- Make deprecated fields o schedule nullable +- Remove unused ScheduleCRUDL + +## v8.3.81 (2023-11-07) + +- Add data migration to backfill Notification.medium +- Add data migration to actually delete inactive schedules + +## v8.3.80 (2023-11-07) + +- Fix constraint on Trigger to allow deleting of schedules +- Add medium field Notification to let us model notifications which should be email only + +## v8.3.79 (2023-11-07) + +- Add data migration to delete ended and orphaned schedules +- Remove no longer used flow_type field on queued flow starts + +## v8.3.78 (2023-11-02) + +- Update scheduled broadcast to send now + +## v8.3.77 (2023-11-01) + +- Move optins inside compose widget + +## v8.3.76 (2023-11-01) + +- Fix org start view when org isn't set +- Add data migration to remove scheduled triggers without a schedule and constraint to prevent new ones +- Fix not showing non-field errors on wizard forms + +## v8.3.75 (2023-10-31) + +- Remove register "trigger" type +- Add user settings fields for email verification +- Update trigger type icons +- Allow staff to add users +- Add send broadcast and start flow bulk actions to contact group page + +## v8.3.74 (2023-10-30) + +- Update temba-components with attachment rendering + +## v8.3.73 (2023-10-30) + +- Add quick replies to broadcasts + +## v8.3.72 (2023-10-27) + +- Make sure the missing external ID we make for D360 channels is truncated to 64 characters +- Un-gate optins +- Add support for Facebook login for business configurations +- Move API token formax to Account section + +## v8.3.71 (2023-10-26) + +- Consistent brand references in templates + +## v8.3.70 (2023-10-26) + +- Merge pull request #4930 from nyaruka/use-org-brand-domain +- Remove brand link +- Replace all brand link with brand domain use + +## v8.3.69 (2023-10-26) + +- Use org brand domain instead of link +- Update to use Facebook API v18.0 + +## v8.3.67 (2023-10-26) + +- Update revisions url + +## v8.3.66 (2023-10-25) + +- Simplify brands + +## v8.3.65 (2023-10-25) + +- Fix and cleanup view for accepting invitations + +## v8.3.64 (2023-10-25) + +- Fix start views for agent users +- Allow agent users to access account settings page +- Move two factor views out of main menu and into the account view + +## v8.3.63 (2023-10-23) + +- Fix SendBroadcast action to work with localized compose + +## v8.3.62 (2023-10-23) + +- Make Trigger.priority non-null and use for ordering + +## v8.3.61 (2023-10-23) + +- Add data migration to backfill Trigger.priority + +## v8.3.60 (2023-10-23) + +- Add Trigger.priority and start writing + +## v8.3.59 (2023-10-20) + +- Fix maxlength for campaign events and focus on compose + +## v8.3.58 (2023-10-19) + +- Allow triggers to wrap + +## v8.3.57 (2023-10-19) + +- Update oxford template filter to allow different conjunctions +- Move all trigger type templates into their own folders +- Add data migration to merge compatible keyword triggers + +## v8.3.56 (2023-10-18) + +- Improve display of triggers on list pages +- Support multiple keywords per trigger in UI +- Fix WA legacy config page + +## v8.3.55 (2023-10-17) + +- Show urns properly for urn change events +- Use localized validation errors for import validation +- Support multi-keyword triggers in exports and imports + +## v8.3.54 (2023-10-17) + +- Drop Trigger.keyword + +## v8.3.53 (2023-10-17) + +- Fix fetching of keywords across triggers when editing a flow + +## v8.3.52 (2023-10-17) + +- Stop writing Trigger.keyword + +## v8.3.51 (2023-10-17) + +- Only read from Trigger.keywords + +## v8.3.50 (2023-10-16) + +- Make ticketer nullable on ticket +- Convert tickets API endpoints to use CRUDL perms +- Make sure we show the issue icon on the flow list page + +## v8.3.49 (2023-10-13) + +- Add data migration to populate keywords on trigger +- Add localization to create broadcast wizard + +## v8.3.47 (2023-10-12) + +- Add Trigger.keywords and start writing +- Switch contacts API endpoints to use CRUDL perms +- Cleanup BroadcastCRUDL.Send which is now only for sending to a flow node +- Remove unused LabelCRUDL.List view +- Convert messages, media and label API endpoints to use CRUDL perms + +## v8.3.46 (2023-10-11) + +- Remove no longer needed deprecated options on definitions endpoint +- Replace orgs.org_api permission +- Drop no longer used fields on FlowRevision + +## v8.3.45 (2023-10-10) + +- Show exclusion groups on trigger list pages +- Fix updating keyword triggers for flows +- Make sure we display trigger channel if set +- Limit access to API explorer to editors and admins +- Convert resthook API endpoints to use CRUDL based permissions + +## v8.3.44 (2023-10-06) + +- Allow request optin if optins exist +- Fix blurb for opt-out trigger +- Remove last usages of FlowLabel.parent and FlowRevision.modifiy_by +- Switch optins, topics, ticketers and templates API endpoints to use CRUDL perms +- Replace brand specific flow users with a single system user + +## v8.3.43 (2023-10-05) + +- Update editor and components + +## v8.3.42 (2023-10-05) + +- Make channel on trigger forms clearable +- Prepare unused fields on FlowRevision for removal and change all models in flows app to use orgs.User +- Allow beta testers to access optin features +- Switch flows, flow_starts and runs API endpoints to use CRUDL permissions +- Add optional channel field to call triggers types that are based on channel activity + +## v8.3.41 (2023-10-04) + +- Add optin as field to channelevents +- Allow perms to be made API specific so that we can limit agent access to the UI + +## v8.3.40 (2023-10-03) + +- Remove globals from agent store when missing permission +- Remove arst + +## v8.3.39 (2023-10-03) + +- Fix compose clear on send +- Use more CRUDL perms with API endpoints + +## v8.3.38 (2023-10-03) + +- Remove completion from contact chat +- Do not recreate the events when the campaign is archived + +## v8.3.37 (2023-10-02) + +- Abstract functionality for triggers based on channel actvity into base classes +- API endpoint should default to CRUDL based permissions if permission not specified +- Update to use Facebook API v17 + +## v8.3.36 (2023-09-29) + +- Remove minutes label from channel chart +- Add workspace breakdown for dashboard + +## v8.3.35 (2023-09-28) + +- Update opt-in styling +- Fix generation of history events from messages with optins + +## v8.3.34 (2023-09-28) + +- Fix migration conflict + +## v8.3.33 (2023-09-28) + +- Fix rendering of optin triggers +- Completely remove channel alerts + +## v8.3.32 (2023-09-27) + +- Fix previous accidental merge to main to add optin import support +- Cleanup views accessing request org +- Add optin as option to broadcast create wizard + +## v8.3.30 (2023-09-27) + +- Allow the target_urls of incident notifications to differ by type +- Use proper secret generation for recovery tokens and re-org code +- Fix task discover for legacy whatsapp channel type +- Implement channel disconnected alert as incident + +## v8.3.29 (2023-09-26) + +- Update editor to include opt-ins + +## v8.3.28 (2023-09-26) + +- Fix Contact Importss +- Rename old legacy channel types +- Add title to incident list page and tweak styling +- Implement email notifications for incidents +- Fix ticket squashable count models + +## v8.3.27 (2023-09-25) + +- Tweak mailroom_db to create an FBA channel instead of a TWT channel +- Remove ticketers as a feature and the views for connecting external ticketers +- Re-add optin as distinct message type +- Add undocumented API endpoint for opt-ins + +## v8.3.26 (2023-09-22) + +- Bump cryptography from 41.0.3 to 41.0.4 +- Add optin field to Broadcast + +## v8.3.25 (2023-09-21) + +- Fix trigger ordering + +## v8.3.24 (2023-09-21) + +- Add opt-in and opt-out trigger types (staff only for now) +- Group keyword triggers and catch all triggers under a Messages folder +- Move broadcasts and scheduled to their own pages + +## v8.3.23 (2023-09-21) + +- Replace Msg.type=optin with optin reference on msg +- Group trigger types into folders +- Make sure staff can update the log policy on all channel types + +## v8.3.22 (2023-09-19) + +- Make ticketers API endpoint unpublicized +- Add 'Send Now' to broadcast creation + +## v8.3.21 (2023-09-18) + +- Add basic OptIn model +- Use env variable for dev mode host + +## v8.3.20 (2023-09-12) + +- Update editor for localized attachment fix + +## v8.3.19 (2023-09-12) + +- Add new data migration to fix IVR call counts +- Drop Channel.parent, ContactURN.auth and Org.input_cleaners +- Remove support for delegate channels + +## v8.3.18 (2023-09-07) + +- Add data migration to populate ContactURN.auth_tokens + +## v8.3.17 (2023-09-06) + +- Add ContactURN.auth_tokens to replace .auth + +## v8.3.16 (2023-09-06) + +- Tweak documentation for flow_starts endpoint +- Allow agents to update tickets topics + +## v8.3.15 (2023-09-06) + +- Add hover-darker button option +- Update icons + +## v8.3.14 (2023-08-31) + +- Limit to load the recent 100 sessions +- Disallow GET request for media upload view + +## v8.3.13 (2023-08-28) + +- Tweaks to the channel config blurbs for consistency +- Fetching messages by label should include arched messages +- Use secrets module instead of random for random_string +- Little bit of cleanup in channel types like removing unused fields + +## v8.3.12 (2023-08-23) + +- Add ChannelType.config_ui to replace configuration_urls, configuration_blurb etc +- Show Somleng config URLs based on channel role +- Add Org.input_collation +- Remove Blackmnyna, Chikka, Junebug, Twitter legacy, old Zenvia channel types + +## v8.3.11 (2023-08-17) + +- Convert final haml templates in root directory + +## v8.3.10 (2023-08-17) + +- Add Org.input_cleaners +- Always show name / anon id for anon orgs in contact lists +- Don't let mailroom handle tasks during tests +- Fix title on welcome page + +## v8.3.9 (2023-08-16) + +- Fix onSpload fire when initial page doesn't call it + +## v8.3.8 (2023-08-16) + +- Use $ instead of onSpload + +## v8.3.7 (2023-08-16) + +- Fix Javascript on claim number view +- Switch test_db to assume a docker container + +## v8.3.6 (2023-08-15) + +- Convert haml templates in includes folder and utils app +- Cleanup page titles in settings section + +## v8.3.5 (2023-08-14) + +- Convert haml templates in public and orgs apps + +## v8.3.4 (2023-08-14) + +- Convert templates in assets, channels, msgs, request_logs and schedules apps as well as overridden smartmin templates + +## v8.3.3 (2023-08-10) + +- Simplify message indexes and system label queries + +## v8.3.2 (2023-08-10) + +- Add data migration to convert old I/F msg types + +## v8.3.1 (2023-08-09) + +- Merge pull request #4779 from nyaruka/less_haml +- Some tweaks to templates based on linter +- Convert all haml templates in channel types + +## v8.3.0 (2023-08-09) + +- Drop no longer used Org.brand field +- Add messagebird channel type + +## v8.2.0 (2023-08-07) + +- Update stable versions + +## v8.1.245 (2023-08-05) + +- Truncate query lables on flow start +- Fix line length formatting +- Fixes for login and API titles + +## v8.1.244 (2023-08-04) + +- Fix error handling for temba-contact-search + +## v8.1.243 (2023-08-03) + +- Fix DELETE endpoints in API explorer +- Bump cryptography from 41.0.2 to 41.0.3 + +## v8.1.242 (2023-08-02) + +- Update to components with modax serialize fix + +## v8.1.241 (2023-08-02) + +- Fix two factor disable and initial QR code rendering + +## v8.1.240 (2023-08-01) + +- Update components with checkbox value update +- Stop writing no longer used Org.brand + +## v8.1.239 (2023-08-01) + +- Temp fix for org export page by replacing temba-checkbox with regular inputs +- Cleanup msg_console + +## v8.1.238 (2023-07-28) + +- Fix flow start log when starts don't have exclusions +- Remove unnecessary CSS class to hover + +## v8.1.237 (2023-07-28) + +- Only consider the parsed query string in contact_search clean +- Add show CSS class to icon for contact list sorting + +## v8.1.236 (2023-07-27) + +- Rename flow_broadcast to flow_start +- Update editor to fix cases on result split +- Add new channel log types used by courier +- Update contact search widget for flow starts + +## v8.1.235 (2023-07-26) + +- Convert templates in dashboard, docs, globals, ivr, locations and notifications apps +- Use title-text for just overriding the text +- Restore missing msg box templates + +## v8.1.234 (2023-07-25) + +- Fix org export page +- Fix permissions for viewer for flow results + +## v8.1.233 (2023-07-25) + +- Simpliy convert_templates script +- Consistent title for initial page load +- Remove spa-title and spa-style +- Add archives to STORAGES + +## v8.1.232 (2023-07-24) + +- Do not set the max for y axis chart to allow that to be calculated +- Convert templates in the triggers app from haml + +## v8.1.231 (2023-07-21) + +- Simplify redis settings and organize settings better in sections + +## v8.1.230 (2023-07-20) + +- Tweak system check for storage settings to check different storages are configured +- Convert S3 log access to be via django storages +- Use pg_dump/restore from docker container in mailroom_db command so it's always correct version + +## v8.1.229 (2023-07-19) + +- Fix tickets list, to show compose properly on Firefox +- Add cpAddress parameter as optional for MTN channel type + +## v8.1.228 (2023-07-18) + +- Update Instagram docs broken link +- Allow initiating flow results download form the the flow labels filter view + +## v8.1.227 (2023-07-17) + +- Bump cryptography from 41.0.0 to 41.0.2 + +## v8.1.226 (2023-07-13) + +- Rework trimming cron tasks to use delete_in_batches +- Drop no longer used Binary Optional Data field + +## v8.1.225 (2023-07-13) + +- Fix icon for globals delete +- Migrate old Twilio channels using .bod to use .config instead +- Remove duplicate menu views in classifiers and channels apps + +## v8.1.224 (2023-07-12) + +- Add log_policy to channel + +## v8.1.223 (2023-07-11) + +- More tweaks to org deletion + +## v8.1.222 (2023-07-11) + +- Add delete_in_batches util function to improve org deletion +- Actually fix deletion of campaign events during org deletion + +## v8.1.221 (2023-07-11) + +- Fix deleting of campaign events and add more logging to org deletion + +## v8.1.220 (2023-07-10) + +- Delete is only for deleting child workspaces + +## v8.1.219 (2023-07-10) + +- Fix problems with org deletion + +## v8.1.218 (2023-07-07) + +- Update to flow editor with fix for ward cases + +## v8.1.217 (2023-07-06) + +- Convert haml files in contacts app +- Bump django from 4.2.2 to 4.2.3 + +## v8.1.216 (2023-07-05) + +- Add data migration to fix archived message counts for labels +- Convert haml templates in campaigns and classifiers apps + +## v8.1.215 (2023-07-05) + +- Add missing migration that rebuilds constraint on contact URNs +- Update channel log retention to 2 weeks +- Disable old 360 Dilalog channel type, and take the new integration out of beta + +## v8.1.214 (2023-07-03) + +- Update to psycopg3 non-binary +- Reference templates as html + +## v8.1.213 (2023-07-03) + +- Convert flows app to be hamless + +## v8.1.212 (2023-07-03) + +- Sorted group list when editing contacts +- Switch channel charts to load with json instead of embedded data + +## v8.1.211 (2023-06-28) + +- Fix Twilio channel update modal + +## v8.1.210 (2023-06-28) + +- Fix mangling of option attributes +- Save channel logs with channels/ prefix +- Add configurable agent access per contact field + +## v8.1.209 (2023-06-28) + +- Fix creating PublicFileStorage + +## v8.1.208 (2023-06-28) + +- Fix S3 channel logs paths to not start with slash +- Update to Django 4.2 + +## v8.1.207 (2023-06-27) + +- Convert some haml templates to html + +## v8.1.206 (2023-06-27) + +- Drop duplicate index +- Look for channel logs in S3 when not found in database +- Move tracking label counts to statement level triggers + +## v8.1.205 (2023-06-27) + +- Replace index on channellog.channel + +## v8.1.204 (2023-06-26) + +- Fix inline group created and broadcast action + +## v8.1.203 (2023-06-26) + +- Update contact action fix + +## v8.1.202 (2023-06-26) + +- Rework settings for S3 buckets + +## v8.1.201 (2023-06-23) + +- Support runtime locales in components + +## v8.1.200 (2023-06-23) + +- Update for flow editor text inputs with null values + +## v8.1.199 (2023-06-22) + +- Updates for select widget to behave with more standard form controls + +## v8.1.198 (2023-06-22) + +- Rollback components + +## v8.1.197 (2023-06-22) + +- Override the correct alpha3 code for Oromifa +- Update form components to use element internals +- Rework loading of channel logs so easier to fetch from S3 too + +## v8.1.196 (2023-06-21) + +- Improve ExternalURLField and don't assume http +- Use org import task to import flows + +## v8.1.195 (2023-06-19) + +- Name override for oro language +- Remove no longer used code relating to contact fields + +## v8.1.194 (2023-06-19) + +- Don't ignore user provided role for somleng shortcodes +- Fix flow export button height +- Fix import translation to use new UI +- Fix parent ID lookup in import geojson +- Support Dialog360 Cloud API channels + +## v8.1.193 (2023-06-14) + +- Add surveyor icon + +## v8.1.192 (2023-06-14) + +- Add icons for flows, fix issue with some spload fires + +## v8.1.191 (2023-06-13) + +- Broadcast update via wizard and updated list styling + +## v8.1.190 (2023-06-12) + +- Add agent_access to API fields endpoint +- Restrict agent users view of field values on API contacts endpoint +- Remove use of django tags inside javascript + +## v8.1.189 (2023-06-12) + +- Fix broken list view template +- Add djlint and latest django-hamlpy + +## v8.1.188 (2023-06-09) + +- Tweak contact field access backfill migration + +## v8.1.187 (2023-06-09) + +- Add ContactField.agent_access and backfill to view +- Use statement level triggers for tracking current node counts +- Remove old scheduled broadcast create view + +## v8.1.186 (2023-06-08) + +- Format api_root.html and fix errors +- Fix channel log pretty printing + +## v8.1.183 (2023-06-08) + +- Add djLint config +- Add basic wizard support + +## v8.1.182 (2023-06-08) + +- Support imports with Status column +- Make viewer role users a feature that can be toggled +- Allow exporting of blocked, stopped and archived contacts + +## v8.1.181 (2023-06-07) + +- Add redact_values for FBA and IG channel types +- Remove unused code for legacy UI contact read and list pages +- Rework channel log anonymization so even staff users have to explicitly break out of it +- Rework channel log rendering to start from JSONified version +- Fix adding queued braodcasts to Outbox view and counts +- Cleanup db triggers for broadcasts + +## v8.1.180 (2023-06-05) + +- Fix failed message resending and archived message deletion + +## v8.1.179 (2023-06-05) + +- Drop ChannelLog.msg and .call + +## v8.1.178 (2023-06-05) + +- Bump cryptography from 39.0.2 to 41.0.0 +- Stop reading from ChannelLog.msg and .call +- Use per-statement db triggers for system label counts + +## v8.1.177 (2023-06-02) + +- Remove dupe from changelog + +## v8.1.176 (2023-06-02) + +- Add some blocks on main templates + +## v8.1.175 (2023-06-02) + +- Add select all on list pages + +## v8.1.174 (2023-06-01) + +- Noop when releasing an already released org +- Rework and simplify channel count db triggers + +## v8.1.173 (2023-06-01) + +- Remove support for filtering channel logs by folder + +## v8.1.171 (2023-05-31) + +- Add index on channellog.uuid +- Impove and expose the call list view + +## v8.1.170 (2023-05-31) + +- Remove rendering of contact history as template now that new UI only consumes it as JSON +- Fix inbox msg type for Android channels + +## v8.1.169 (2023-05-30) + +- Allow call count backfill migration to be called offline +- Fix ivr call trigger migration +- Remove unused stuff from inbox views + +## v8.1.168 (2023-05-30) + +- Add data migration to backfill ivr call counts + +## v8.1.167 (2023-05-29) + +- Add DB triggers to track counts of calls as a new system label + +## v8.1.166 (2023-05-29) + +- Stop writing SystemLabelCount.is_archived so it can be dropped + +## v8.1.165 (2023-05-29) + +- Always write system label counts with is_archived=False and make field nullable + +## v8.1.164 (2023-05-29) + +- Add data migration to delete old system label counts for is_archived=true because they're no longer updated +- Fix getting FB business ID for WAC channels + +## v8.1.163 (2023-05-25) + +- Return empty sample/fields on preview_start endpoint until contactsearch component is updated + +## v8.1.162 (2023-05-25) + +- Add BroadcastCRUDL.Preview +- Fix broadcast send history template + +## v8.1.161 (2023-05-24) + +- User orgs based on request +- Switch brand array to dict +- Move plivo connect view to channel type + +## v8.1.160 (2023-05-19) + +- Fix field update and deleting with same key + +## v8.1.159 (2023-05-19) + +- Don't allow horizontal scroll by default + +## v8.1.158 (2023-05-19) + +- Fix scrolling for content pages without full height +- Tweak how we run python scripts in CI + +## v8.1.157 (2023-05-18) + +- Add ticket editing +- Remove old ticket assign view and support for notes with assignment +- Add ticket topic menu and resizer +- Move WAC connect view to the WhatsApp cloud channel type package +- Remove accounts formax from workspace view as it isn't needed with new UI + +## v8.1.156 (2023-05-17) + +- Update components for 302 fix +- Make post_url work identically to posterize + +## v8.1.155 (2023-05-17) + +- Better handling of post_url for spa content menu +- Really fix hiding surveyor form + +## v8.1.154 (2023-05-17) + +- Hide the surveyor password input and not just the help texti +- Fix URLs in JS files + +## v8.1.153 (2023-05-17) + +- Move channel type constants to the channel type class +- Don't show option to enter surveyor password if surveyor feature not enabled +- Scoped javascript for flow broadcast modal + +## v8.1.152 (2023-05-15) + +- Make js function name unique +- Fix no_nav extra-script blocks + +## v8.1.151 (2023-05-15) + +- Fix the API explorer scripts and styles blocks + +## v8.1.150 (2023-05-15) + +- Cleanup broken or unused posterized links +- Drop old flow start fields + +## v8.1.149 (2023-05-14) + +- Fix signups + +## v8.1.148 (2023-05-12) + +- Fix backwards compat for send message to somebody else + +## v8.1.147 (2023-05-12) + +- Fix flow refresh and global redirect hook + +## v8.1.146 (2023-05-12) + +- Add some null checks for frame selectors + +## v8.1.145 (2023-05-11) + +- Fix width for other views and posterize on choose + +## v8.1.144 (2023-05-11) + +- Fix login width +- Tweak Somleng claim blurb + +## v8.1.143 (2023-05-11) + +- Stop reading from old FlowStart fields +- Merge and clean up main frame +- Rename Twiml API channel to Somleng + +## v8.1.142 (2023-05-11) + +- Add base mixin for channel type specific views that gives access to the type class +- Update components and editor to support compose for somebody else +- Move vonage connect view to the channel type +- Allow deleting of archived triggers + +## v8.1.141 (2023-05-10) + +- Fix contacts title +- Fix vanilla landing +- Remove lessblock and replace with compiled css +- Bump django from 4.1.7 to 4.1.9 + +## v8.1.140 (2023-05-09) + +- Fix ticket padding +- Remove remaining spa files +- Add link to reset the latest credentials +- Preset channel connection + +## v8.1.139 (2023-05-09) + +- Add blocked icon + +## v8.1.138 (2023-05-09) + +- Update labeling to use temba-checkbox and remove jQuery +- Fix trim_channel_logs config and rework so task olny runs for an hour max +- Change test_db to create single org at a time + +## v8.1.137 (2023-05-09) + +- Add exclusions and params fields to FlowStart and start writing them + +## v8.1.136 (2023-05-09) + +- Don't include brand variables in less node + +## v8.1.135 (2023-05-09) + +- Remove references to old icon set +- Remove unused jquery bits and intercooler +- Remove bootstrap + +## v8.1.134 (2023-05-08) + +- Remove no longer used perms +- Remove any old non-spa templates not being extended by the spa version +- Remove is_spa logic from templates +- Remove old contact update fields views + +## v8.1.133 (2023-05-05) + +- Add default color + +## v8.1.132 (2023-05-05) + +- Remove settings turd + +## v8.1.131 (2023-05-05) + +- Remove old nav from landing page + +## v8.1.130 (2023-05-04) + +- Remove spa checking in views + +## v8.1.129 (2023-05-04) + +- Remove JSON view to list notifications now that has moved to the internal API +- Remove non-spa items from content menus + +## v8.1.128 (2023-05-03) + +- Fix contact import + +## v8.1.127 (2023-05-03) + +- Remove support for adding bulk sender delegate channels +- Remove ability to create IVR delegates for android channels +- Remove org home view altogether and update links to point to workspace view + +## v8.1.126 (2023-05-03) + +- Change cookie checking for UI so that we always default to new UI +- Add color picker widget +- Remove ability to store twilio credentials on the org + +## v8.1.125 (2023-05-02) + +- Tweak notifications index to match API endpoint +- Add new internal API with a notifications endpoint +- Use DRF defaults for STRICT_JSON and UNICODE_JSON +- Remove unused .api URL suffixes + +## v8.1.124 (2023-05-01) + +- Make contact.modify work with new and old format +- Make ticket a reserved field name + +## v8.1.123 (2023-04-27) + +- Hide Open Ticket option on contact read page if there's already an open a ticket +- Rework soft and hard msg deleting to be more performant + +## v8.1.122 (2023-04-26) + +- Remove db constriants on Msg.flow and Msg.ticket + +## v8.1.121 (2023-04-26) + +- Tweak migration dependency +- Show counts of tickets by topic on tickets menu + +## v8.1.120 (2023-04-25) + +- Add topic counts to the API endpoint +- Add undocumented param to contacts API endpoint which allows URNs to be expanded +- Data migration to backfill ticket counts by topic + +## v8.1.119 (2023-04-25) + +- Start writing ticket counts for topics + +## v8.1.118 (2023-04-24) + +- Fix deleting of flows and tickets which are referenced by messages +- Fix pattern match for folder uuid +- Stop writing TicketCount.assignee + +## v8.1.117 (2023-04-24) + +- Stop reading from TicketCount.assignee + +## v8.1.116 (2023-04-21) + +- Add more channel icons + +## v8.1.115 (2023-04-21) + +- Update icons +- Add ticket topic folders + +## v8.1.114 (2023-04-20) + +- Add migration to backfill TicketCount.scope + +## v8.1.113 (2023-04-20) + +- Add scope field to TicketCount and start writing + +## v8.1.112 (2023-04-20) + +- Dropdowns for slow clickers +- Tighten up animations +- Use services for redis, elastic and postgres in CI + +## v8.1.111 (2023-04-18) + +- Fix and archive keyword triggers with no match_type + +## v8.1.110 (2023-04-18) + +- Prefetch flows on message views and make titles consistent + +## v8.1.109 (2023-04-18) + +- Add links for menu, add flow badge, update label badges +- Remove Chikka channel type which no longer exists +- Update mailroom_db command to allow connecting to non-file socket postgres + +## v8.1.108 (2023-04-17) + +- Add ticket field to msg model + +## v8.1.107 (2023-04-13) + +- Allow deleting of groups used in triggers + +## v8.1.106 (2023-04-13) + +- Don't show topics on tickets until clicked + +## v8.1.105 (2023-04-12) + +- Fix js items on context menus + +## v8.1.104 (2023-04-11) + +- Do not display schedule events for archived triggers +- Don't require db superuser for test_db command +- Make ticket banner expandable + +## v8.1.103 (2023-04-10) + +- Fix urls when searching and paging +- Follow message on auto assign for unassigned folder + +## v8.1.102 (2023-04-10) + +- Add contact details pane, hide empty tabs +- Auto assign tickets when sending messages +- Add nicer ticket assignment using temba-contact-tickets component +- Fix deleting of orgs with incidents + +## v8.1.101 (2023-04-06) + +- Add field search handler on tickets + +## v8.1.100 (2023-04-06) + +- Add fields to tickets + +## v8.1.99 (2023-04-06) + +- Add test util to make it easier to mess with brands +- Drop Org.stripe_customer_id + +## v8.1.98 (2023-04-06) + +- Link contact name on tickets to the contact page if permitted +- Drop Org.plan, plan_start and plan_end + +## v8.1.97 (2023-04-05) + +- Pull tickets out of contact chat +- Scheduled messages to broadcasts with compose widget + +## v8.1.96 (2023-04-03) + +- Stop reading Org.plan and .plan_end +- Bump redis from 4.5.3 to 4.5.4 + +## v8.1.95 (2023-03-31) + +- Fix temba-store race on load + +## v8.1.94 (2023-03-29) + +- Bump version of openpyxl + +## v8.1.93 (2023-03-29) + +- Update Excel reading dependencies + +## v8.1.92 (2023-03-29) + +- Use unittests.mock.Mock in tests instead of custom mock_object + +## v8.1.91 (2023-03-28) + +- Upgrade redis library version + +## v8.1.90 (2023-03-27) + +- NOOP instead of assert if archiving msg which is already archived etc + +## v8.1.89 (2023-03-27) + +- Do not fail to release channel when missing mtn subscription id in config +- Add incident type for org suspension + +## v8.1.88 (2023-03-23) + +- Fix suspending and unsuspending orgs so that it correctly updates children +- Use a name for the active org that doesn't collide + +## v8.1.87 (2023-03-23) + +- Manually fix version number + +## v8.1.86 (2023-03-23) + +- Fix scrolling on WhatsApp templates page + +## v8.1.85 (2023-03-23) + +- Handle short screens better on run list page + +## v8.1.84 (2023-03-22) + +- Update to coverage 7.x + +## v8.1.83 (2023-03-22) + +- Use onSpload to wire handlers on account form + +## v8.1.82 (2023-03-22) + +- Support setting and removing the subscription URL for MTN channels + +## v8.1.81 (2023-03-21) + +- Update ruff and isort + +## v8.1.80 (2023-03-21) + +- Update black + +## v8.1.79 (2023-03-20) + +- Add mouseover text for temba-date +- Reload page on org mismatch +- Use embedded title instead of response header + +## v8.1.78 (2023-03-20) + +- Add globals to new ui +- Make it harder to accidentally delete an org +- Rewrite org deletion test and fix deletion issues + +## v8.1.77 (2023-03-16) + +- Limit groups to a single line on contact page + +## v8.1.76 (2023-03-16) + +- Remove unused fields and indexes on broadcast model +- Reload page on version mismatch +- Add support for MTN Developer Portal channel + +## v8.1.75 (2023-03-16) + +- Add menu path for org export and import +- Fix legacy goto function for old UI +- Warn users who go back to the old interface +- Remove support for broadcasts with associated tickets + +## v8.1.74 (2023-03-15) + +- Show version number on public index page +- Add poetry plugin to maintain version number in temba/**init**.py +- Fix textinput inner scrolling + +## v8.1.73 (2023-03-15) + +- Stop returning type=flow|inbox on messages endpoint +- Cleanup location app models + +## v8.1.72 (2023-03-14) + +- Convert Org.config and Channel.config to be real JSON + +## v8.1.71 (2023-03-14) + +- Strip out invalid HTTP header characters from page title response headers +- Fix mailroom db command to patch uuid generation after migrations are run +- Expose flow on messages API endpoint + +## v8.1.70 (2023-03-13) + +- Broad support for meta click for new tabs +- Make Org.config and Channel.config non-null + +## v8.1.69 (2023-03-13) + +- Simplify use of config fields on channel update forms +- Fix alias editor to use the new UI frame +- Support updating Twilio credentials for T, TMS and TWA channels + +## v8.1.68 (2023-03-13) + +- Rework messages and broadcasts API endpoints to accept media ojects UUIDs as attachments +- Make Msg.uuid and msg_type non-null + +## v8.1.67 (2023-03-10) + +- Fix layering for menu + +## v8.1.66 (2023-03-09) + +- Fix initial editor load +- Schedule message validation + +## v8.1.65 (2023-03-09) + +- Update endpoints for messages and media + +## v8.1.64 (2023-03-08) + +- Tweak layout for editor +- Cleanup fail_old_messages task. Use correct statuses and return number failed. + +## v8.1.63 (2023-03-08) + +- Adjust export download page for new UI +- Make media list page (still staff only) filter by org and add index + +## v8.1.62 (2023-03-08) + +- Small z-index tweak + +## v8.1.61 (2023-03-07) + +- Tweak simulator placement in new ui + +## v8.1.60 (2023-03-07) + +- Encourage users to try the new interface +- Add lightbox for contact history + +## v8.1.59 (2023-03-07) + +- Rework code depending on msg_type=I|F + +## v8.1.58 (2023-03-07) + +- Add missing channels migration +- Use msg.created_by if set in ticket list view +- Remove SMS type channel alerts + +## v8.1.57 (2023-03-06) + +- Move index on msg.external_id onto the model + +## v8.1.56 (2023-03-06) + +- Fix soft deleting of scheduled messages so schedule is deleted too +- Stop saving JSONAsTextField values as null for empty dicts and lists +- Update select s3 usage for msg exports to not rely on type=inbox|flow +- Add created_by to Msg and populate on events in contact histories + +## v8.1.55 (2023-03-02) + +- Fix import for sync fcm task +- Create new filters and partial indexes for Inbox, Flows and Archived + +## v8.1.54 (2023-03-02) + +- Fix enter on compose + +## v8.1.53 (2023-03-01) + +- Add compose component to contact chat +- Pixel tweak on contact read page +- Move more Android relayer code out of Channel + +## v8.1.52 (2023-03-01) + +- Simplify what we display for Android channels on read page + +## v8.1.50 (2023-02-28) + +- Make spload universal + +## v8.1.49 (2023-02-28) + +- Make spload work on formax pages + +## v8.1.48 (2023-02-28) + +- Add more goto(event) +- Fix content differing from page-load vs inline load +- Add page title for spa response headers +- Clean up subtitles on spa pages +- Add link to flow starts (and clean up list page styling) +- Add link for webhook calls (and cleanup styling here too) +- Update styling for log pages for both old / new ui + +## v8.1.47 (2023-02-27) + +- Be less clever with page titles. Fix label js errors. +- Make sure tests can run without making requests to external URLs +- Unpublicize folder=incoming on messages API docs and re-add index with status=H + +## v8.1.46 (2023-02-23) + +- Fix external links in old ui + +## v8.1.45 (2023-02-23) + +- Fix external channel links +- No longer intercept clicks in spa-content +- Cleanup Channel model fields +- Fix channel claim external URLs in new UI + +## v8.1.44 (2023-02-23) + +- Exclude PENDING messages in contact history and API by org and contact +- Add -id to msg fetch ordering in Contact.get_history +- For both messages and tickets, replace the default indexes on org and contact with indexes that match the API ordering + +## v8.1.43 (2023-02-23) + +- Use statement level db trigger for broadcast msg counts +- Update django to 4.1.7 + +## v8.1.42 (2023-02-22) + +- Only look at queued messages when syncing android channels +- Re-add Msg.STATUS_INITIALIZING to use for outgoing messages which fail to queue +- Include STATUS_ERRORED messages in Outbox views + +## v8.1.41 (2023-02-22) + +- Remove suprious property + +## v8.1.40 (2023-02-22) + +- Fix contact imports in new ui +- Fix menu refresh race +- Remove window.lastFetch +- Adjust menu paths for new UI channel views +- Use SpaMixin to more channels extra views + +## v8.1.39 (2023-02-22) + +- Move Msg.update into android package +- Make text optional on broadcasts endpoint (messages need text or attachments) + +## v8.1.38 (2023-02-21) + +- Fix dashboard not loading when content +- Fix handling FCM sync failure + +## v8.1.37 (2023-02-21) + +- Don't lookup related fields in API if lookup value type is wrong +- Update django 4.0.10 +- Fetching sent folder on messages endpoint should return messages ordered by -sent_on same as UI +- Exclude unhandled messages from Incoming folder on messages API endpoint +- More agressive menu refreshing +- Move much of the old android relayer code into its own package +- Add media API endpoint, undocumented for now +- Open up new UI access to everyone + +## v8.1.36 (2023-02-20) + +- Cleanup use of validators in the API +- Add support for Msg.TYPE_TEXT to be used (for now) for outgoing messages + +## v8.1.35 (2023-02-17) + +- Add org start redirection view +- Convert Attachment to be a dataclass +- Rework msg write serializer to create a transient Msg instance that the read serializer can use without hitting the db +- Add unpublicized API endpoint to send a single message +- Add msg_send to mailroom client + +## 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 + +## v8.1.19 (2023-02-01) + +- Add Msg.quick_replies +- Add Broadcast.query +- More generic servicing for staff users + +## v8.1.18 (2023-02-01) + +- Drop un-used Media.name field + +## v8.1.17 (2023-01-31) + +- Fix modax from menu bug + +## v8.1.15 (2023-01-30) + +- Add new org chooser with avatars in new UI +- Add dashboard to menu in new UI + +## v8.1.14 (2023-01-27) + +- Add ordering support for filters +- Fix redirect ping pong when managing orgs +- Tweak inspect_flows command to report spec veresion mismatches + +## v8.1.13 (2023-01-26) + +- Update flow editor + +## v8.1.12 (2023-01-26) + +- Add locale field to Msg + +## v8.1.11 (2023-01-25) + +- Add migration to alter flow language field to first update any remaining flows with 'base' + +## v8.1.10 (2023-01-25) + +- Require flow and broadcast base languages to 3 letters +- Require broadcast.translations to be non-null + +## v8.1.9 (2023-01-25) + +- Drop unused broadcast fields + +## v8.1.8 (2023-01-24) + +- Make Broadcast.text nullable and stop writing it + +## v8.1.7 (2023-01-24) + +- Stop reading from Broadcast.text + +## v8.1.6 (2023-01-23) + +- Fix campaign imports so we don't import base as a language +- Increase max-width for channel configuration page +- Support bandwidth channel type + +## v8.1.5 (2023-01-23) + +- Data migration to backfill broadcast.translations and replace base with und + +## v8.1.4 (2023-01-20) + +- Update campaign message events with language base +- Make servicing to use posterize + +## v8.1.3 (2023-01-19) + +- Tweak broadcasts API endpoint so it filters by is_active and hits index +- Fix indexes used for tickets API endpoint +- Remove unused indexes on contacts_contact +- Bump engine version to 13.2 + +## v8.1.2 (2023-01-19) + +- Fixes for content menu changes +- Fix test_db to create orgs with flow languages + +## v8.1.1 (2023-01-18) + +- Restrict creating surveyor flows unless that is enabled as a feature +- Always create braodcasts with status = QUEUED, create index for fetching queued broadcasts +- Add new translations JSON field to broadcasts and start writing it +- Remove support for creating broadcasts with legacy expressions +- New content menu component + +## v8.1.0 (2023-01-17) + +- Update contact import styling +- Implement squashed migrations +- Stop trimming flow starts as this will be handled by archiver + +## v8.0.1 (2023-01-12) + +- Tweak migration dependencies to ensure clean installs run them in order that works +- Add empty migrations required for squashing + +## v8.0.0 (2023-01-10) + +- Update deps + +## v7.5.149 (2023-01-10) + +- Drop FlowRunCount model + +## v7.5.148 (2023-01-09) + +- Stop squashing FlowRunCount +- Add misisng index on FlowRunStatusCount and rework get_category_counts to be deterministic +- Stop creating flows_flowruncount rows in db triggers and remove unsquashed index +- Bump required pg_dump version for mailroom_db command to 14 + +## v7.5.147 (2023-01-09) + +- Use und (Undetermined) as default flow language and add support for mul (Multiple) +- Disallow empty and null flow languages, change default spec version to zero +- Tweak migrate_flows to have smaller batch size and order by org to increase org assets cache hits + +## v7.5.146 (2023-01-05) + +- Cleanup migrate_flows command and stop excluding flows with version 11.12 +- Change sample flows language to eng +- Refresh menu when tickets are updated +- Fix frame-top analytics includes +- Fix transparency issue with content menu on editor page + +## v7.5.145 (2023-01-04) + +- Update flow editor to include fix for no expiration route on ivr +- Stop defaulting to base for new flow languages + +## v7.5.144 (2023-01-04) + +- Ensure all orgs have at least one flow language +- Switch to using temba-date in more places + +## v7.5.143 (2023-01-02) + +- Update mailroom version for CI +- Tidy up org creation (signups and grants) + +## v7.5.142 (2022-12-16) + +- Fix org listing when org has no users left + +## v7.5.141 (2022-12-16) + +- Fix searching for orgs on manage list page +- Fix highcharts colors +- Fix invalid template name + +## v7.5.140 (2022-12-15) + +- Fix flow results page + +## v7.5.136 (2022-12-15) + +- Tell codecov to ignore static/ +- Switch label action buttons to use temba-dropdown + +## v7.5.135 (2022-12-13) + +- Fix content menu display issues + +## v7.5.134 (2022-12-13) + +- Switch to yarn + +## v7.5.133 (2022-12-12) + +- Bump required python version to 3.10 + +## v7.5.132 (2022-12-12) + +- Support Python 3.10 + +## v7.5.131 (2022-12-09) + +- Replace .gauge on analytics backend with .gauges which allows backends to send guage values in bulk +- Remove celery auto discovery for jiochat and wechat tasks which were removed + +## v7.5.130 (2022-12-09) + +- Record cron time in analytics + +## v7.5.129 (2022-12-08) + +- Cleanup cron task names +- Split task to trim starts and sessions into two separate tasks +- Expose all status counts on flows endpoint +- Read from FlowRunStatusCount instead of FlowRunCount +- Track flow start counts in statement rather than row level trigger + +## v7.5.128 (2022-12-07) + +- Record cron task last stats in redis +- Switch from flake8 to ruff +- Add data migration to convert exit_type counts to status counts + +## v7.5.127 (2022-12-07) + +- Fix counts for triggers on the menu + +## v7.5.126 (2022-12-06) + +- Add new count model for run statuses managed by by-statement db triggers + +## v7.5.125 (2022-12-05) + +- Tweak index used to find messages to retry so that it includes PENDING messages + +## v7.5.124 (2022-12-05) + +- Update to latest components +- More updates for manage pages + +## v7.5.123 (2022-12-02) + +- Fix bulk labelling flows + +## v7.5.122 (2022-12-02) + +- Add user read page +- Latest components +- Rework notification and incident types to function more like other typed things +- Add org timezone to manage page +- Remove no longer used group list view +- Log celery task completion by default and rework some tasks to return results included in the logging +- Refresh browser on field deletion in legacy +- Show org plan end as relative time +- Don't show location field types as options on deploys where locations aren't enabled + +## v7.5.121 (2022-11-30) + +- Fix loading of notification types + +## v7.5.120 (2022-11-30) + +- Rework notification types to work more like channel types +- Update API fields endpoint to use name and type for writes as well as reads +- Remove unused field on campaign events write serializer +- Change undocumented pinned field on fields endpoint to be featured +- Add usages field to fields API endpoint, as well as name and type to replace label and value_type +- Add Line error reference URL + +## v7.5.119 (2022-11-29) + +- Fix flow label in list buttons +- Fix editor StartSessionForm bug for definitions without exclusions +- Remove no longer needed check for plan=parent + +## v7.5.118 (2022-11-28) + +- Add telgram and viber error reference URLs +- Make Org.plan optional +- Add support to create new workspaces from org chooser + +## v7.5.117 (2022-11-23) + +- Update to latest editor +- Drop Org.is_multi_org and Org.is_multi_user which have been replaced by Org.features + +## v7.5.116 (2022-11-23) + +- Fix flow label name display + +## v7.5.115 (2022-11-22) + +- Default to no features on new child orgs +- Add features field to org update UI + +## v7.5.114 (2022-11-22) + +- Add Org.features and start writing it +- Add error ref url for FBA and IG +- Update temba-components to get new link icon +- Cleanup msg status constants +- Always create new orgs with default plan and only show org_plan for non-child orgs + +## v7.5.113 + +- Stop reading Label.label_type and make nullable +- Remove all support for labels with parents + +## v7.5.112 + +- Remove OrgActivity + +## v7.5.111 + +- Delete associated exports when trying to delete message label folders + +## v7.5.110 + +- Data migration to flatten msg labels + +## v7.5.109 + +- Remove logic for which plan to use for a new org + +## v7.5.108 + +- Tweak how get_new_org_plan is called +- Move isort config to pyproject +- Remove no longer used workspace plan + +## v7.5.107 + +- Treat parent and workspace plans as equivalent + +## v7.5.106 + +- Tweak flow label flatten migration to not allow new names to exceed 64 chars + +## v7.5.105 + +- Display channel logs with earliest at top + +## v7.5.104 + +- Remove customized 500 handler +- Remove sentry support +- Data migration to flatten flow labels +- Fix choice of brand for new orgs and move plan selection to classmethod +- Catch CSV corrupted errors + +## v7.5.103 + +- Some people don't care for icon constants +- Remove shim for browsers older than IE9 +- Remove google analytics settings + +## v7.5.102 + +- Remove google analytics + +## v7.5.101 + +- Fix Org.promote + +## v7.5.100 + +- Add Org.promote utility method +- Simplify determining whether to rate limit an API request by looking at request.auth +- Data migration to simplify org hierarchies + +## v7.5.99 + +- Rename security_settings.py > settings_security.py for consistency +- Drop Org.uses_topups, TopUp, and Debit +- Update to latest components +- Remove unused settings +- Remove TopUp, Debit and Org.uses_topups + +## v7.5.98 + +- Drop triggers, indexes and functions related to topups + +## v7.5.97 + +- Update mailroom_db command to use postgresql 13 +- Remove User.get_org() +- Always explicitly provide org when requesting a user API token +- Remove Msg.topup, TopUpCredits, and CreditAlert +- Test against latest redis 6.2, elastic 7.17.7 and postgres 13 + 14 + +## v7.5.96 + +- Remove topup credits squash task from celery beat + +## v7.5.95 + +- Update API auth classes to set request.org and use that to set X-Temba-Org header +- Use dropdown for brand field on org update form +- Remove topups + +## v7.5.94 + +- Add missing migration +- Remove support for orgs with brand as the host +- Remove brand tiers + +## v7.5.93 + +- Fix new event modal listeners +- Re-add org plan and plan end to update form +- Add png of rapidpro logo +- Update mailroom_db and test_db commands to set org brand as slug +- Add data migration to convert org.brand to be the brand slug + +## v7.5.92 + +- Create cla.yml +- Rework branding to not require modifying what is in the settings + +## v7.5.91 + +- Remove outdated contributor files + +## v7.5.90 + +- Update flow editor +- Remove unused fields from ChannelType +- Allow non-beta users to add WeChat channels + +## v7.5.89 + +- Properly truncate the channel name when claiming a WAC channel +- Fix not saving selected date format to new child org +- Add redirect from org_create_child if org has a parent +- Remove unused Org.get_account_value +- Don't allow creation of child orgs within child orgs +- Remove low credit checking code + +## v7.5.88 + +- Remove the token refresh tasks for jiochat and wechat channels as courier does this on demand +- Remove Stripe and bundles functionality + +## v7.5.87 + +- Remove unused segment and intercom dependencies +- Remove unused utils code +- Update TableExporter to prepare values so individual tasks don't have to +- Update versions of mailroom etc that we use for testing +- Add configurable group membership columns to message, ticket and results exports (WIP) + +## v7.5.86 + +- Remove no-loner used credit alert email templates +- Drop ChannelConnection + +## v7.5.85 + +- Remove unschedule option from scheduled broadcast read page +- Only show workspace children on settings menu +- Allow adding Android channel when its number is used on a WhatsApp channel +- Remove credit alert functionality +- Add scheduled message delete modal + +## v7.5.84 + +- No link fields on sub org page + +## v7.5.83 + +- Update telegram library which doesn't work with Python 3.10 +- Add user child workspace management +- Remove topup management views + +## v7.5.82 + +- Add JustCall channel type + +## v7.5.81 + +- Always show plan formax even for orgs on topups plan + +## v7.5.80 + +- Remove task to suspend topups orgs + +## v7.5.79 + +- Add new indexes for scheduled broadcasts view and API endpoint +- Update broadcast_on_change db trigger to check is_active +- Use database trigger to prevent status changes on flow sessions that go from exited to waiting + +## v7.5.78 + +- Remove old crisp templates +- Added Broadcast.is_active backfill migration + +## v7.5.77 + +- Proper redirect when removing channels +- Fix api header when logged out +- Take features out of branding and make it deployment level and remove api_link +- Get rid of flow_types as a branding setting + +## v7.5.76 + +- Tweak migration to convert missed call triggers to ignore archived triggers + +## v7.5.75 + +- Add Broadcast.is_active and set null=true and default=true +- Remove channel_status_processor context processor +- Add data migration to delete or convert missed call triggers + +## v7.5.74 + +- Fix webhook list page to not show every call as an error +- Small styling tweaks for api docs +- Remove fields from msgs event payloads that are no longer used + +## v7.5.73 + +- Update api docs to be nav agnostic +- Rewrite API Explorer to be vanilla javascript +- Use single permissions for all msg and contact list views +- Rework UI for incoming call triggers to allow selecting non-voice flows +- Remove send action from messages, add download results for flows +- Unload flow editor when navigating away + +## v7.5.72 + +- Always put service menu options at end of menu in new group + +## v7.5.71 + +- More appropriate login page, remove legacy textit code + +## v7.5.70 + +- Fix which fields should be on org update modal +- Honor brand config for signup + +## v7.5.69 + +- Fix race on editor load + +## v7.5.68 + +- Add failed reason for channel removed +- Remove no longer used channels option from interrupt_sessions task + +## v7.5.67 + +- Interrupt channel by mailroom task + +## v7.5.66 + +- Remove need for jquery on spa in-page loads +- Remove key/secret hardcoding for boto session + +## v7.5.65 + +- Queue relayer messages with channel UUID and id +- No nouns for current object in menus except for New +- Add common contact field inclusion to exports +- Fix new scheduled message menu option +- Fix releasing other archive files to use proper pagination + +## v7.5.64 + +- Add an unlinked call list page +- Show channel log links on more pages to more users + +## v7.5.63 + +- Fix handling of relayer messages +- Add missing email templates for ticket exports + +## v7.5.62 + +- Add attachment_fetch as new channel log type + +## v7.5.61 + +- Fix claiming vonage channels for voice +- Better approach for page titles from the menu +- Fix layout for ticket menu in new ui + +## v7.5.60 + +- Fix the flow results export modal + +## v7.5.59 + +- Delete attachments from storage when deleting messages +- Add base export class for exports with contact data +- Actually make date range required for message exports (currently just required in UI)) +- Add date range filtering to ticket and results exports +- Add ticket export (only in new UI for now) + +## v7.5.58 + +- Add twilio and vonage connection formax entries in new UI +- Update both main menu and content menus to align with new conventions +- Gate new UI by Beta group rather than staff +- Don't show new menu UIs until they're defined + +## v7.5.57 + +- Move status updates into update contact view +- Some teaks to rendering of channel logs +- Cleanup use of channelconnection in preparation for dropping + +## v7.5.56 + +- Really really fix connection migration + +## v7.5.55 + +- Really fix connection migration + +## v7.5.54 + +- Fix migration to convert connections to calls + +## v7.5.53 + +- Add data migration to convert channel connections to calls + +## v7.5.52 + +- Replace last non-API usages of User.get_org() +- Use new call model in UI + +## v7.5.51 + +- Add new ivr.Call model to replace channels.ChannelConnection + +## v7.5.50 + +- Drop no-longer used ChannelLog fields +- Drop Msg.logs (replaced by .log_uuids) +- Drop ChannelConnection.connection_type + +## v7.5.49 + +- Fix test failing because python version changed +- Allow background flows for missed call triggers +- Different show url for spa and non-spa tickets +- Update editor to include fix for localizing categories for some splits +- Add data migration to delete existing missed call triggers for non-message flows +- Restrict Missed Call triggers to messaging flows + +## v7.5.48 + +- Stop recommending Android, always recommend Telegram +- Drop IVRCall proxy model and use ChannelConnection consistently +- Add migration to delete non-IVR channel connections +- Fix bug in user releasing and remove special superuser handling in favor of uniform treatment of staff users + +## v7.5.47 + +- Switch to temba-datepicker + +## v7.5.46 + +- Fix new UI messages menu + +## v7.5.45 + +- Replace some occurences of User.get_org() +- Add new create modal for scheduled broadcasts + +## v7.5.44 + +- Add data migration to cleanup counts for SystemLabel=Calls +- Tweak ordering of Msg menu sections +- Add slack channel + +## v7.5.43 + +- Include config for mailroom test db channels +- Remove Calls from msgs section +- Update wording of Missed Call triggers to clarify they should only be used with Android channels +- Only show Missed Call trigger as option for workspaces with an Android channel +- Change ChannelType.is_available_to and is_recommended_to to include org + +## v7.5.42 + +- Add data migration to delete legacy channel logs +- Drop support for channel logs in legacy format + +## v7.5.41 + +- Fix temba-store + +## v7.5.40 + +- Tweak forgot password success message + +## v7.5.39 + +- Add log_uuids field to ChannelConnection, ChannelEvent and Msg +- Improve `trim_http_logs_task` performance by splitting the query + +## v7.5.38 + +- Add codecov token to ci.yml +- Remove unnecessary maxdiff set in tests +- Fix to allow displaying logs that timed out +- Add HttpLog util and use to save channel logs in new format +- Add UUID to channel log and msgs + +## v7.5.37 + +- Show servicing org + +## v7.5.36 + +- Clean up chooser a smidge + +## v7.5.35 + +- Add org-chooser +- Refresh channel logs +- Add channel uuid to call log url +- Fix history state on tickets and contacts +- Update footer +- Add download icons for archives +- Fix create flow modal opener +- Flow editor embed styling +- Updating copyright dates and TextIt name (dba of Nyaruka) + +## v7.5.34 + +- Use elapsed_ms rather than request_time on channel log templates +- Update components (custom widths for temba-dialog, use anon_display where possible) +- Switch to temba-dialog based attachment viewer, remove previous libs +- Nicer collapsing on flow list columns +- Add overview charts for run results + +## v7.5.33 + +- ChannelLogCRUDL.List should use get_description so that it works if log_type is set +- Tweak channel log types to match what courier now creates +- Check for tabs after timeouts, don't auto-collapse flows +- Add charts to analytics tab + +## v7.5.32 + +- Update components with label fix + +## v7.5.31 + +- Add flow results in new UI + +## v7.5.30 + +- Remove steps for add WAC credit line to businesses + +## v7.5.29 + +- Fix servicing of channel logs + +## v7.5.28 + +- Stop writing to unused media name field +- Add missing C Msg failed reason +- Add anon-display field to API contact results if org is anon and make urn display null + +## v7.5.27 + +- Revert change to Contact.Bulk_urn_cache_initialize to have it set org on contacts + +## v7.5.26 + +- Don't set org on bulk initialized contacts + +## v7.5.25 + +- Fix filtering on channel log call page +- Add anon_display and use that when org is anon instead of using urn_display for anon id +- Add urn_display to contact reference on serialized runs in API + +## v7.5.24 + +- Fix missing service end button + +## v7.5.23 + +- Update to latest floweditor +- Add new ChannelLog log type choices and make description nullable +- Fix more content menus so that they can be fetched as JSON and add more tests + +## v7.5.22 + +- Remove unused policies.policy_read perm +- Replace all permission checking against Customer Support group with is_staff check on user + +## v7.5.21 + +- Allow views with ContentMenuMixin to be fetched as JSON menu items using a header +- Add new fields to channel log model and start reading from them if they're set + +## v7.5.20 + +- Update the links for line developers console on the line claim page +- Rework channel log details views into one generic one, one for messages, one for calls + +## v7.5.19 + +- Rework channel log rendering to use common HTTPLog template +- Fix titles on channel, classifier and manage logins pages + +## v7.5.18 + +- Workspace and user management in new UI + +## v7.5.17 + +- Show send history of scheduled broadcasts in correct order +- Only show option to delete runs to users who have that perm, and give editors that perm +- Update deps + +## v7.5.16 + +- Fixed zaper page title +- Validate channel name is not more than 64 characters +- Added 'authentication' to the temba anchor URL text + +## v7.5.15 + +- Fix URL for media uploads which was previously conflicting with media directory + +## v7.5.14 + +- Deprecate Media.name which can always be inferred from .path +- Improve cleaning of media filenames +- Convert legacy UUID fields on exports and labels +- Request instagram_basic permission for IG channels + +## v7.5.11 + +- Don't allow creating of labels with parents or editing labels to have a parent +- Rework the undocumented media API endpoint to be more specific to surveyor attachments +- Add MediaCRUDL with upload and list endpoints +- Remove requiring instagram_basic permission + +## v7.5.10 + +- Remove Media.is_ready, fix setting .status on alternates, add limit for upload size +- Rework ContentMenuMixin to put the menu in the context, and include new and legacy formats + +## v7.5.9 + +- Add status field to Media, move primary index to UUID field + +## v7.5.8 + +- Update floweditor +- Convert all views to use ContentMenuMixin instead of get_gear_links +- Add decorator to mock uuid generation in tests +- Process media uploads with ffmpeg in celery task + +## v7.5.7 + +- Add constraint to ensure non-waiting/active runs have exited_on set +- Add constraint to ensure non-waiting sessions have an ended_on + +## v7.5.6 + +- Remove unused upload_recording endpoint +- Add Media model + +## v7.5.5 + +- Remaining fallback modax references +- Add util for easier gear menu creation +- Add option to interrupt a contact from read page + +## v7.5.4 + +- Fix scripts on contact page start modal +- Add logging for IG channel claim failures +- Add features to BRANDING which determines whether brands have access to features +- Sort permissions a-z +- Fix related names on Flow.topics and Flow.users and add Topic.release +- Expose opened_by and opened_in over ticket API + +## v7.5.3 + +- Fix id for custom fields modal + +## v7.5.2 + +- Fix typo on archive button +- Only show active ticketers and topics on Open Ticket modal +- Add data migration to fix non-waiting sessions with no ended_on + +## v7.5.1 + +- Allow claiming WAC test numbers +- Move black setting into pyproject.toml +- Add Open Ticket modal view to contact read page + +## v7.5.0 + +- Improve user list page +- Add new fields to Ticket record who or what flow opened a ticket +- Refresh menu on modax redircts, omit excess listeners from legacy lists +- Fix field label vs name in new UI +- Add start flow bulk action in new UI +- Show zeros in menu items in new UI +- Add workspace selection to account page in new UI +- Scroll main content pane up on page replacement in new UI + +## v7.4.2 + +- Update copyright notice +- Update stable versions + +## v7.4.1 + +- Update locale files + +## v7.4.0 + +- Remove superfulous Beta group perm +- Update new UI opt in permissions +- More tweaks to WhatsApp Cloud channel claiming + +## v7.3.79 + +- Add missing Facebook ID + +## v7.3.78 + +- Add button to allow admin to choose more FB WAC numbers + +## v7.3.77 + +- Add contact ticket list in new UI +- Fix permissions to connect WAC +- Register the WAC number in the activate method + +## v7.3.76 + +- Add the Facebook dialog login if the token is not submitted successfully on WAC org connect +- Fix campaigns archive and activate buttons +- Update to latest Django +- Only display WA templates that are active +- Update flow start dialog to use start preview endpoint +- Add start flow bulk action for contacts + +## v7.3.75 + +- Redirect to channel page after WAC claim +- Fix org update pre form users roles list +- Adjust permission for org whatsapp connect view +- Ignore new conversation triggers without channels in imports + +## v7.3.74 + +- Use FB JS SDK for WAC signups + +## v7.3.73 + +- Add DB constraint to disallow active or waiting runs without a session + +## v7.3.72 + +- Add DB constraint to enforce that flow sessions always have output or output_url + +## v7.3.71 + +- Make sure all limits are updatable on the workspace update view +- Remove duplicated pagination +- Enforce channels limit per workspace + +## v7.3.70 + +- Fix workspace group limit check for existing group import +- Drop no longer used role m2ms + +## v7.3.69 + +- Fix campaign links + +## v7.3.68 + +- Add WhatsApp API version choice field +- Stop writing to the role specific m2m tables +- Add pending events tab to contact details + +## v7.3.67 + +- Merge pull request #3865 from nyaruka/plivo_claim +- formatting +- Sanitize plivo app names to match new rules + +## v7.3.66 + +- Merge pull request #3864 from nyaruka/fix-WA-templates +- Fix message templates syncing for new categories + +## v7.3.65 + +- Fix surveyor joins so new users are added to orgmembership as well. + +## v7.3.64 + +- Fix fetching org users with given roles + +## v7.3.63 + +- Update mailroom_db command to correctly add users to orgs +- Stop reading from org role m2m tables + +## v7.3.62 + +- Fix rendering of dates on upcoming events list +- Data migration to backfill OrgMembership + +## v7.3.61 + +- Add missing migration + +## v7.3.60 + +- Data migration to fail active/waiting runs with no session +- Include scheduled triggers in upcoming contact events +- Add OrgMembership model + +## v7.3.59 + +- Spreadsheet layout for contact fields in new UI +- Adjust WAC channel claim to add system admin with user token + +## v7.3.58 + +- Clean up chat media treatment +- Add endpoint to get upcoming scheduled events for a contact +- Remove filtering by ticketer on tickets API endpoint and add indexes +- Add status to contacts API endpoint + +## v7.3.57 + +- Improve WAC phone number verification flow and feedback +- Adjust name of WAC channels to include the number +- Fix manage user update URL on org update page +- Support missing target_ids key in WAC responses + +## v7.3.56 + +- Fix deletion of users +- Cleanup user update form +- Fix missing users manage link page +- Add views to verify and register a WAC number + +## v7.3.55 + +- Update contact search summary encoding + +## v7.3.54 + +- Make channel type a property and use to determine redact values in HTTP request logs + +## v7.3.53 + +- Make WAC channel visible to beta group + +## v7.3.52 + +- Fix field name for submitted token + +## v7.3.51 + +- Use default API throttle rates for unauthenticated users +- Bump pyjwt from 2.3.0 to 2.4.0 +- Cache user role on org +- Add WhatsApp Cloud channel type + +## v7.3.50 + +- Make Twitter channels beta only for now +- Use cached role permissions for permission checking and fix incorrect permissions on some + API views +- Move remaining mockey patched methods on auth.User to orgs.User + +## v7.3.49 + +- Timings in export stats spreadsheet should be rounded to nearest second +- Include failed_reason/failed_reason_display on msg_created events +- Move more monkey patching on auth.User to orgs.User + +## v7.3.48 + +- Include first reply timings in ticket stats export +- Create a proxy model for User and start moving some of the monkey patching to proper methods on that + +## v7.3.47 + +- Data migration to backfill ticket first reply timings + +## v7.3.46 + +- Add new squashable model to track average ticket reply times and close times +- Add Ticket.replied_on + +## v7.3.45 + +- Add endpoint to export Excel sheet of ticket daily counts for last 90 days + +## v7.3.44 + +- Remove omnibox support for fetching by label and message +- Remove functionality for creating new label folders and creating labels with folders + +## v7.3.43 + +- Fix generating cloned flow names so they can't end with trailing spaces +- Deleting of globals should be soft like other types +- Simplify checking of workspace limits in UI and API + +## v7.3.42 + +- Data migration to backfill ticket daily counts + +## v7.3.41 + +- Reorganization of temba.utils.models +- Update the approach to the test a token is valid for FBA and IG channels +- Promote ContactField and Global to be TembaModels whilst for now retaining their custom name validation logic +- Add import support methods to TembaModel and use with Topic + +## v7.3.40 + +- Add workspace plan, disallow grandchild org creation. +- Add support for shared usage tracking + +## v7.3.39 + +- Move temba.utils.models to its own package +- Queue broadcasts to mailroom with their created_by +- Add teams to mailroom test database +- Add is_system to TembaModel, downgrade Contact to SmartModel + +## v7.3.38 + +- Make sure we request a FB long lived page token using a long lived user token +- Convert campaign and campaignevent to use real UUIDs, simplify use of constants in API + +## v7.3.37 + +- Don't forget to squash TicketDailyCount +- Fix imports of flows with ticket topic dependencies + +## v7.3.36 + +- Add migration to update names of deleted labels and add constraint to enforce uniqueness +- Move org limit checking from serializers to API views +- Generalize preventing deletion of system objects via the API and allow deleting of groups that are used in flows +- Serialized topics in the API should include system field +- Add name uniqueness constraints to Team and Topic +- Add Team and TicketDailyCount models + +## v7.3.35 + +- Tweaks to Topic model to enforce name uniqueness +- Add **str** and **repr** to TembaModel to replace custom methods and remove several unused ones +- Convert FlowLabel to be a TembaModel + +## v7.3.34 + +- Fix copying flows to generate a unique name +- Rework TembaModel to be a base model class with UUID and name + +## v7.3.33 + +- Use model mixin for common name functionality across models + +## v7.3.32 + +- Add DB constraint to enforce flow name uniqueness + +## v7.3.31 + +- Update components with resolved locked file + +## v7.3.29 + +- Fix for flatpickr issue breaking date picker +- ContactField.get_or_create should enforce name uniqeuness and ignore invalid names +- Add validation error when changing type of field used by campaign events + +## v7.3.28 + +- Tweak flow name uniqueness migration to honor max flow name length + +## v7.3.27 + +- Tweak header to be uniform treatment regardless of menu +- Data migration to make flow names unique +- Add flow.preview_start endpoint which calls mailroom endpoint + +## v7.3.26 + +- Fix mailroom_db command to set languages on new orgs +- Fix inline menus when they have no children +- Fix message exports + +## v7.3.25 + +- Fix modals on spa pages +- Add service button to org edit page +- Update to latest django +- Add flow name to message Export if we have it + +## v7.3.24 + +- Allow creating channel with same address when schemes do not overlap + +## v7.3.23 + +- Add status to list of reserved field keys +- Migration to drop ContactField.label and field_type + +## v7.3.22 + +- Update contact modified_on when deleting a group they belong to +- Add custom name validator and use for groups and flows + +## v7.3.21 + +- Fix rendering of field names on contact read page +- Stop writing ContactField.label and field_type + +## v7.3.20 + +- Stop reading ContactField.label and field_type + +## v7.3.19 + +- Correct set new ContactField fields in mailroom_db test_db commands +- Update version of codecov action as well as versions of rp-indexer and mailroom used by tests +- Data migration to populate name and is_system on ContactField + +## v7.3.18 + +- Give contact fields a name and is_system db field +- Update list of reserved keys for contact fields + +## v7.3.17 + +- Fix uploading attachments to properly get uploaded URL + +## v7.3.16 + +- Fix generating of unique flow, group and campaign names to respect case-insensitivity and max name length +- Add data migration to prefix names of previously deleted flows +- Prefix flow names with a UUID when deleted so they don't conflict with other flow names +- Remove warning about feature on flow start modal being removed + +## v7.3.15 + +- Check name uniqueness on flow creation and updating +- Cleanup existing field validation on flow and group forms +- Do not fail to release a channel when we cannot reach the Facebook API for FB channels + +## v7.3.14 + +- Convert flows to be a soft dependency + +## v7.3.13 + +- Replace default index on FlowRun.contact with one that includes flow_id + +## v7.3.12 + +- Data migration to give every workspace an Open Tickets smart system group + +## v7.3.11 + +- Fix bulk adding/removing to groups from contact list pages +- Convert groups into a soft dependency for flows +- Use dataclasses instead of NaamedTuples where appropriate + +## v7.3.10 + +- Remove path from example result in runs API endpoint docs +- Prevent updating or deleting of system groups via the API or UI +- Add system property to groups endpoint and fix docs + +## v7.3.9 + +- Remove IG channel beta gating + +## v7.3.8 + +- Fix fetching of groups from API when using separate readonly DB connection + +## v7.3.7 + +- Rework how we fetch contact groups + +## v7.3.6 + +- For FB / IG claim pages use expiring token if no long lived token is provided + +## v7.3.5 + +- Data migration to update group_type=U to M|Q + +## v7.3.4 + +- Merge pull request #3734 from nyaruka/FB-IG-claim + +## v7.3.3 + +- Check all org groups when creating unique group names +- Make ContactGroup.is_system non-null and switch to using to distinguish between system and user groups + +## v7.3.2 + +- Data migration to populate ContactGroup.is_system + +## v7.3.1 + +- Add is_system field to ContactGroup and rename 'dynamic' to 'smart' +- Return 404 from edit_sub_org if org doesn't exist +- Use live JS SDK for FBA and IG refresh token views +- Add scheme to flow results exports + +## v7.3.0 + +- Add countries supported by Africastalking +- Replace empty squashed migrations with real ones + +## v7.2.4 + +- Update stable versions in README + +## v7.2.3 + +- Add empty versions of squashed migrations to be implemented in 7.3 + +## v7.2.2 + +- Updated translations from Transifex +- Fix searching on calls list page + +## v7.2.1 + +- Update locale files + +## v7.2.0 + +- Disallow PO export/import for archived flows because mailroom doesn't know about them +- Add campaigns section to new UI + +## v7.1.82 + +- Update to latest flake8, black and isort + +## v7.1.81 + +- Remove unused collect_metrics_task +- Bump dependencies + +## v7.1.80 + +- Remove progress bar on facebook claim +- Replace old indexes based on flows_flowrun.is_active + +## v7.1.79 + +- Remove progress dots for FBA and IG channel claim pages +- Actually drop exit_type, is_active and delete_reason on FlowRun +- Fix group name validation to include system groups + +## v7.1.78 + +- Test with latest indexer and mailroom +- Stop using FlowRun.exit_type, is_active and delete_reason + +## v7.1.77 + +- Tweak migration as Postgres won't let us drop function being used + +## v7.1.76 + +- Update vonage deprecated methods + +## v7.1.75 + +- Rework flowrun db triggers to use status rather than exit_type or is_active + +## v7.1.74 + +- Allow archiving of flow messages +- Don't try interrupting session that is about to be deleted +- Tweak criteria for who can preview new interface + +## v7.1.73 + +- Data migration to fix facebook contacts name + +## v7.1.72 + +- Revert database trigger changes which stopped deleting path and exit_type counts on flowrun deletion + +## v7.1.71 + +- Fix race condition in contact deletion +- Rework flowrun database triggers to look at delete_from_results instead of delete_reason + +## v7.1.69 + +- Update to latest floweditor + +## v7.1.68 + +- Add FlowRun.delete_from_results to replace delete_reason + +## v7.1.67 + +- Drop no longer used Msg.delete_reason and delete_from_counts columns +- Update to Facebook Graph API v12 + +## v7.1.66 + +- Fix last reference to Msg.delete_reason in db triggers and stop writing that on deletion + +## v7.1.65 + +- Rework msgs database triggers so we don't track counts for messages in archives + +## v7.1.64 + +- API rate limits should be org scoped except for staff accounts +- Expose current flow on contact read page for all users +- Add deprecation text for restart_participants + +## v7.1.63 + +- Fix documentation of contacts API endpoint +- Release URN channel events in data migration to fix deleted contacts with tickets +- Use original filename inside UUID folder to upload media files + +## v7.1.62 + +- Tweak migration to only fully delete inactive contacts with tickets + +## v7.1.61 + +- Add flow field to contacts API endpoint +- Add support to the audit_es command for dumping ES queries +- Add migration to make sure contacts which we failed to delete are really deleted +- Fix contact release with tickets having a broadcast + +## v7.1.60 + +- Adjust WA message template warning to not be show for Twilio WhatsApp channels +- Add support to increase API rates per org + +## v7.1.59 + +- Add migration to populate Contact.current_flow + +## v7.1.58 + +- Restrict msg visibility changes on bulk actions endpoint + +## v7.1.57 + +- Add sentry id for 500 page +- Display current flow on contact read page for beta users +- Add new msg visibility for msgs deleted by senders and allow deleted msgs to appear redacted in contact histories +- Contact imports should strip empty rows, missing a UUID or URNs + +## v7.1.56 + +- Fix issue with sending to step_node +- Add missing languages for whatsapp templates +- Add migration to remove inactive contacts from user groups + +## v7.1.55 + +- Fix horizontal scrolling in editor +- Add support to undo_footgun command to revert status changes + +## v7.1.53 + +- Relayer syncing should ignore bad URNs that fail validation in mailroom +- Add unique constraint to ContactGroup to enforce name uniqueness within an org + +## v7.1.52 + +- Fix scrolling select + +## v7.1.51 + +- Merge pull request #3671 from nyaruka/ui-widget-fixes +- Fix select for slow clicks and removing rules in the editor + +## v7.1.50 + +- Add migration to make contact group names unique within an organization +- Add cookie based path to opt in and out of new interface + +## v7.1.49 + +- Update to Django 4 + +## v7.1.48 + +- Make IG channel beta gated +- Remove expires_on, parent_uuid and connection_id fields from FlowRun +- Add background flow options to campaign event dialog + +## v7.1.47 + +- Make FlowSession.wait_resume_on_expire not-null + +## v7.1.46 + +- Add migration to set wait_resume_on_expire on flow sessions +- Update task used to update run expirations to also update them on the session + +## v7.1.45 + +- Make FlowSession.status non-null and add constraint to ensure waiting sessions have wait_started_on and wait_expires_on set + +## v7.1.44 + +- Fix login via password managers +- Change gujarati code language to 'guj' +- Add instagram channel type +- Add interstitial when inactive contact search meets threshold + +## v7.1.42 + +- Add missing migration + +## v7.1.41 + +- Add Contact.current_flow + +## v7.1.40 + +- Drop FlowRun.events and FlowPathRecentRun + +## v7.1.39 + +- Include qrious.js script +- Add FlowSession.wait_resume_on_expire +- Add Msg.flow + +## v7.1.38 + +- Replace uses of deprecated Django functions +- Remove crisp and librato analytics backends and add ConsoleBackend as example +- Data migration to populate FlowSession.wait_started_on and wait_expires_on + +## v7.1.37 + +- Migration to remove recent run creation from db triggers +- Remove no longer used recent messages view and functionality on FlowPathRecentRun + +## v7.1.36 + +- Add scheme column on contact exports for anon orgs +- Remove option to include router arguments in downloaded PO files +- Make loading of analytics backends dynamic based on setting of backend class paths + +## v7.1.35 + +- Only display crisp support widget if brand supports it +- Do crisp chat widget embedding via analytics template hook + +## v7.1.34 + +- Update to editor v1.16.1 + +## v7.1.33 + +- Add management to fix broken flows +- Use new recent contacts endpoint for editor + +## v7.1.32 + +- Temporarily put crisp_website_id back in context + +## v7.1.31 + +- Remove include_msgs option of flow result exports + +## v7.1.30 + +- Update to latest flow editor + +## v7.1.29 + +- Update to latest floweditor +- Add FlowSession.wait_expires_on +- Improve validation of flow expires values +- Remove segment and intercom integrations and rework librato and crisp into a pluggable analytics framwork + +## v7.1.28 + +- Convert FlowRun.id and FlowSession.id to BIGINT + +## v7.1.27 + +- Drop no longer used FlowRun.parent + +## v7.1.26 + +- Prefer UTF-8 if we're not sure about encoding of CSV import + +## v7.1.25 + +- Fix Kaleyra claim blurb +- Fix HTTPLog read page showing warning shading for healthy calls + +## v7.1.24 + +- Fix crisp identify on signup +- Use same event structure for Crisp as others + +## v7.1.23 + +- Update help links for the editor +- Add failed reason for failed destination such as missing channel or URNs +- Add view to fetch recent contacts from Redis + +## v7.1.22 + +- Fix join syntax + +## v7.1.21 + +- Fix join syntax, argh + +## v7.1.20 + +- Arrays not allowed on track events + +## v7.1.19 + +- Add missing env to settings_common + +## v7.1.18 + +- Implement crisp as an analytics integration + +## v7.1.17 + +- Tweak event tracking for results exports +- Revert change to hide non-responded runs in UI + +## v7.1.16 + +- Drop Msg.response_to +- Drop Msg.connection_id + +## v7.1.15 + +- Remove path field from API runs endpoint docs +- Hide options to include non-responded runs on results download modal and results page +- Fix welcome page widths +- Update mailroom_db to require pg_dump version 12.\* +- Update temba-components +- Add workspace page to new UI + +## v7.1.14 + +- Fix wrap for recipients list on flow start log +- Set Msg.delete_from_counts when releasing a msg +- Msg.fail_old_messages should set failed_reason +- Add new fields to Msg: delete_from_counts, failed_reason, response_to_external_id +- Tweak msg_dewire command to only fetch messages which have never errored + +## v7.1.13 + +- Add management command to dewire messages based on a file of ids +- Render webhook calls which are too slow as errors + +## v7.1.12 + +- Remove last of msg sending code +- Fix link to webhook log + +## v7.1.11 + +- Remove unnecessary conditional load of jquery + +## v7.1.10 + +- Make forgot password email look a little nicer and be easier to localize + +## v7.1.9 + +- Fix email template for password forgets + +## v7.1.8 + +- Remove chatbase as an integration as it no longer exists +- Clear keyword triggers when switching to flow type that doesn't support them +- Use branded emails for export notifications + +## v7.1.5 + +- Remove warning on flow start modal about settings changes +- Add privacy policy link +- Test with Redis 3.2.4 +- Updates for label sub menu and internal menu navigation + +## v7.1.4 + +- Remove task to retry errored messages which now handled in mailroom + +## v7.1.2 + +- Update poetry dependencies +- Update to latest editor + +## v7.1.1 + +- Remove channel alert notifications as these will become incidents +- Add Incident model as well as OrgFlagged and WebhooksUnhealthy types + +## v7.1.0 + +- Drop no longer used index on msg UUID +- Re-run collect_sql +- Use std collection types for typing hints and drop use of object in classes + +## v7.0.4 + +- Fix contact stop list page +- Update to latest black to fix errors on Python 3.9.8 +- Add missing migration + +## v7.0.3 + +- Update to latest editor v1.15.1 +- Update locale files which adds cs and mn + +## v7.0.2 + +- Update editor to v1.15 with validation fixes +- Fix outbox pagination +- Add generic title bar with new dropdown on spa + +## v7.0.1 + +- Add missing JS function to delete messages in the archived folder +- Update locale files + +## v7.0.0 + +- Fix test failing to due bad domain lookup + +## v6.5.71 + +- Add migration to remove deleted contacts and groups from scheduled broadcasts +- Releasing a contact or group should also remove it from scheduled broadcasts + +## v6.5.70 + +- Fix intermittent credit test failure +- Tidy up Msg and Broadcast constants +- Simplify settings for org limit defaults +- Fix rendering of deleted contacts and groups in recipient lists + +## v6.5.69 + +- Remove extra labels on contact fields + +## v6.5.68 + +- Reenable chat monitoring + +## v6.5.67 + +- Make ticket views and components in sync + +## v6.5.66 + +- Add channel menu +- Add test for dynamic contact group list, remove editor_next redirect +- Fix styling on contact list headersa and flow embedding +- Add messages to menu, refresh override +- Switch contact fields and import to use template inheritance +- Use template inheritance for spa work +- Add deeplinking support for non-menued destinations + +## v6.5.65 + +- Move to Python 3.9 + +## v6.5.64 + +- Fix export notification email links + +## v6.5.63 + +- When a contact is released their tickets should be deleted +- Test on PG 12 and 13 +- Use S3 Select for message exports +- Use new notifications system for export emails + +## v6.5.62 + +- Use crontab for WA tokens task schedule +- Allow keyword triggers to be single emojis +- Celery 5.x + +## v6.5.60 + +- Add option to audit_archives to check flow run counts +- Drop no longer used ticket subject column +- Add contact read page based on contact chat component + +## v6.5.59 + +- Less progress updates in audit_archives +- Tweak tickets API endpoint to accept a uuid URL param + +## v6.5.58 + +- Add progress feedback to audit_archives +- Update locale files + +## v6.5.57 + +- Fix Archive.rewrite + +## v6.5.56 + +- Encode content hashes sent to S3 using Base64 + +## v6.5.55 + +- Trim mailgun ticketer names to <= 64 chars when creating +- Management command to audit archives +- Use field limiting on omnibox searches + +## v6.5.54 + +- Fix S3 select query generation for date fields + +## v6.5.53 + +- Disable all sentry transactions +- Use S3 select for flow result exports +- Add utils for compiling S3 select queries + +## v6.5.52 + +- Merge pull request #3555 from nyaruka/ticket-att +- Update test to include attachment list for last_msg +- Update CHANGELOG.md for v6.5.51 +- Merge pull request #3553 from nyaruka/httplog_tweaks +- Merge pull request #3554 from nyaruka/s3_retries +- Add other missing migration +- Add retry config to S3 client +- Add missing migration to drop WebhookResult model +- Update CHANGELOG.md for v6.5.50 +- Merge pull request #3552 from nyaruka/fix-WA-check-health-logs +- Fix tests +- Add zero defaults to HTTPLog fields, drop WebHookResult and tweak HTTPLog templates for consistency +- Fix response for WA message template to be HTTP response +- Update CHANGELOG.md for v6.5.49 +- Merge pull request #3549 from nyaruka/retention_periods +- Merge pull request #3546 from nyaruka/readonly_exports +- Merge pull request #3548 from nyaruka/fix-WA-check-health-logs +- Merge pull request #3550 from nyaruka/truncate-org +- Use single retention period setting for all channel logs +- Truncate org name with ellipsis on org chooser +- Add new setting for retention periods for different types and make trimming tasks more consistent +- Use readonly database connection for contact, message and results exports +- Add migration file +- Log update WA status error using HTTPLog + +## v6.5.51 + +- Add retry config to S3 client +- Add zero defaults to HTTPLog fields, drop WebHookResult and tweak HTTPLog templates for consistency + +## v6.5.50 + +- Fix response for WA message template to be HTTP response + +## v6.5.49 + +- Truncate org name with ellipsis on org chooser +- Add new setting for retention periods for different types and make trimming tasks more consistent +- Use readonly database connection for contact, message and results exports +- Log update WA status error using HTTPLog + +## v6.5.48 + +- Fix clear contact field event on ticket history + +## v6.5.47 + +- Use readonly database connection for contacts API endpoint +- Use webhook_called events from sessions for contact history +- Remove unused webhook result views and improve httplog read view +- Fix API endpoints not always using readonly database connection and add testing + +## v6.5.46 + +- Move list refresh registration out of content block + +## v6.5.45 + +- Temporarily disable refresh +- Don't use readonly database connection for GETs to contacts endpoint +- Add view for webhook calls saved as HTTP logs +- Pass location support flag to editor as a feature flag + +## v6.5.44 + +- GET requests to API should use readonly database on the view's queryset + +## v6.5.43 + +- Tweak how HTTP logs are deleted +- Add num_retries field to HTTPLog + +## v6.5.42 + +- Pin pyopenxel to 3.0.7 until 3.0.8 release problems resolved +- Add new fields to HTTPLog to support saving webhook results +- Make TPS for Shaqodoon be 5 by default +- Make location support optional via new branding setting + +## v6.5.41 + +- Update editor with fix for field creation +- Minor tidying of HTTPLog +- Fix rendering of tickets on contact read page which now don't have subjects + +## v6.5.40 + +- Update to floweditor 1.14.2 +- Tweak database settings to add new readonly connection and remove no longer used direct connection +- Update menu on ticket list update + +## v6.5.38 + +- Deprecate subjects on tickets in favor of topics +- Tweak ticket bulk action endpoint to allow unassigning +- Add API endpoint to read and write ticket topics + +## v6.5.37 + +- Add tracking of unseen notification counts for users +- Clear ticket notifications when visiting appropriate ticket views +- Remove no longer used Log model + +## v6.5.36 + +- Revert cryptography update + +## v6.5.35 + +- Update to newer pycountry and bump other minor versions +- Fix ticketer HTTP logs not being accessible +- Add management command to re-eval a smart group +- Add comment to event_fires about mailroom issue +- Fix indexes on tickets to match new UI +- Now that mailroom is setting ContactImport.status, use in reads + +## v6.5.34 + +- Update to latest components (fixes overzealous list refresh, non-breaking ticket summary, and display name when created_by is null) + +## v6.5.33 + +- Fix Add To Group bulk action on contact list page +- Add status field to ContactImport and before starting batches, set redis key mailroom can use to track progress +- Delete unused template and minor cleanup + +## v6.5.32 + +- Fix template indentation +- Pass force=True when closing ticket as part of releasing a ticketer +- Add beginings of new nav and SPA based UI (hidden from users for now) + +## v6.5.31 + +- Show masked urns for contacts API on anon orgs +- Rework notifications, don't use Log model + +## v6.5.30 + +- Fix deleting of imports and exports now that they have associated logs + +## v6.5.29 + +- Add basic (and unused for now) JSON endpoint for listing notifications +- Reduce sentry trace sampling to 0.01 +- Override kir language name +- Add change_topic as action to ticket bulk actions API endpoint +- Add Log and Notification model + +## v6.5.28 + +- Add new ticket event type for topic changes +- Migrations to assign default topic to all existing tickets + +## v6.5.27 + +- Add migration to give all existing orgs a default ticket topic + +## v6.5.26 + +- Move mailroom_db data to external JSON file +- Run CI tests with latest mailroom +- Add ticket topic model and initialize orgs with a default topic + +## v6.5.25 + +- Improve display of channels logs for calls + +## v6.5.24 + +- Add machine detection as config option to channels with call role +- Tweak event_fires management command to show timesince for events in the past + +## v6.5.23 + +- Drop retry_count, make error_count non-null +- Improve channel log templates so that we use consistent date formating, show call error reasons, and show back button for calls +- Tweak how we assert form errors and fix where they don't match exactly +- Re-add QUEUED status for channel connections + +## v6.5.22 + +- Tweak index used for retrying IVR calls to only include statuses Q and E +- Dont show ticket events like note added or assignment on contact read page +- Include error reason in call_started events in contact history +- Remove channel connection statuses that we don't use and add error_reason + +## v6.5.21 + +- Prevent saving of campaign events without start_mode +- Improve handling of group lookups in contact list views +- Add button to see channel error logs + +## v6.5.20 + +- Make ChannelConnection.error_count nullable so it can be removed +- Cleanup ChannelConnection and add index for IVR retries +- Fix error display on contact update modal +- Update to zapier app directory, wide formax option and fixes +- Enable filtering on the channel log to see only errors + +## v6.5.19 + +- Fix system group labels on contact read page +- Use shared error messages for orgs being flagged or suspended +- Update to latest smartmin (ignores \_format=json on views that don't support it) +- Add command to undo events from a flow start +- Send modal should validate URNs +- Use s3 when appropriate to get session output +- Add basic user accounts API endpoint + +## v6.5.18 + +- Apply webhook ticket fix to successful webhook calls too + +## v6.5.17 + +- Tweak error message on flow start modal now field component is fixed +- Fix issue for ticket window growing with url length +- Update LUIS classifiers to work with latest API requirements +- Tweak migration to populate contact.ticket_count so that it can be run manually +- Switch from django.contrib.postgres.fields.JSONField to django.db.models.JSONField +- Introduce s3 utility functions, use for reading s3 sessions in contact history + +## v6.5.16 + +- Update to Django 3.2 +- Migration to populate contact.ticket_count + +## v6.5.15 + +- Add warning to flow start modal that options have changed +- Fix importing of dynamic groups when field doesn't exist + +## v6.5.14 + +- Update to latest cryptography 3.x +- Add deep linking for tickets +- Update db trigger on ticket table to maintain contact.ticket_count + +## v6.5.13 + +- Tweak previous data migration to work with migrate_manual + +## v6.5.12 + +- Migration to zeroize contact.ticket_count and make it non-null + +## v6.5.11 + +- Allow deletion of fields used by campaign events +- Add last_activity_on to ticket folder endpoints +- Add API endpoint for ticket bulk actions +- Add nullable Contact.ticket_count field + +## v6.5.10 + +- Remove textit-whatsapp channel type +- Show ticket counts on ticketing UI +- Update to latest components with fixes for scrollbar and modax reuse +- Use new generic dependency delete modal for contact fields + +## v6.5.9 + +- Add management command for listing scheduled event fires +- Add index for ticket count squashing task +- Add data migration to populate ticket counts +- Add constraint to Msg to disallow sent messages without sent_on and migration to fix existing messages like that + +## v6.5.8 + +- Fix celery task name + +## v6.5.7 + +- Fix flow start modal when starting flows is blocked +- Add more information to audit_es_group command +- Re-save Flow.has_issues on final flow inspection at end of import process +- Add squashable model for ticket counts +- Add usages modal for labels as well +- Update the WA API version for channel that had it set when added +- Break out ticket folders from status, add url state + +## v6.5.6 + +- Set sent_on if not already set when handling a mt_dlvd relayer cmd +- Display sent_on time rather than created_on time in Sent view +- Only sample 10% of requests to sentry +- Fix searching for scheduled broadcasts +- Update Dialog360 API usage + +## v6.5.5 + +- Fix export page to use new filter to get non-localized class name for ids +- Fix contact field update +- Add searchable to trigger groups +- Add option to not retry IVR calls +- Add usages modal for groups +- Tweak wording on flow start modal + +## v6.5.4 + +- Rework flow start modal to show options as exclusions which are unchecked by default +- Change sent messages view to be ordered by -sent_on + +## v6.5.3 + +- Add Last Seen On as column to contact exports +- Resuable template for dependency lists + +## v6.5.2 + +- Internal ticketer for all orgs + +## v6.5.1 + +- Cleanup Msg CRUDL tests +- Cleanup squashable models +- Apply translations in fr +- Replace trigger folders with type specific filtered list pages so that they can be sortable within types + +## v6.4.7 + +- Update flow editor to include lone-ticketer submit fix +- Fix pagination on the webhook results page + +## v6.4.6 + +- Update flow editor to fix not being able to play audio attachments in simulator + +## v6.4.4 + +- Start background flows with include_active = true +- Update flow editor with MediaPlayer fix +- Fix poetry content-hash to remove install warning +- Update translations from transifex + +## v6.4.3 + +- Improve contact field forms +- Fix urn sorting on contact update +- Improve wording on forms for contact groups, message labels and flow labels +- Improve wording on campaign form + +## v6.4.2 + +- Fix attachment button when attachments don't have extensions +- Add missing ticket events to contact history +- Fix clicking attachments in msgs view sometimes navigating to contact page +- Parameterized form widgets. Bigger, darker form bits. +- Tweak trigger forms for clarity +- Add command to rebuild messages and pull translations from transifex + +## v6.4.1 + +- Fix unassigning tickets + +## v6.4.0 + +- Update README + +## v6.3.90 + +- Fix alias editor to post json + +## v6.3.89 + +- Remove beta grating of internal ticketers +- Control which users can have tickets assigned to them with a permission +- Use mailroom endpoints for ticket assignment and notes +- Add custom user recover password view + +## v6.3.88 + +- Fix to display email on manage orgs +- Drop no longer used Broadcast.is_active field + +## v6.3.87 + +- Update indexes on ticket model +- Tweak ticketer default names +- Add empty ticket list treatment +- Fix API docs for messages endpoint to mention attachments rather than the deprecated media field +- Update to editor with hidden internal ticketers +- Consistent setting of modified_by when releasing/archiving/restoring +- Remove old ticket views +- Change ticketer sections on org home page to have Remove button and not link to old ticket views +- Add assignee to ticketing endpoints, some new filters and new assignment view + +## v6.3.86 + +- Stop writing Broadcast.is_active as default value +- Fix keyword triggers being imported without a valid match_type + +## v6.3.85 + +- User the current user as the manual trigger user during simulation +- Better trigger exports and imports +- Make broadcast.is_active nullable and stop filtering by it in the API + +## v6.3.84 + +- Ignore scheduled triggers in imports because they don't import properly +- Fix redirect after choosing an org for users that can't access the inbox +- Optionally filter ticket events by ticket in contact history view + +## v6.3.83 + +- Fix default content type for pjax requests +- Tweak queuing of flow starts to include created_by_id + +## v6.3.82 + +- Revert recent formax changes + +## v6.3.81 + +- Add Broadcast.ticket and expose as field (undocumented for now) on broadcast write API endpoint +- Refactor scheduling to use shared form +- Add exclusion groups to scheduled triggers + +## v6.3.80 + +- Update components so omnibox behaves like a field +- Drop Language model and Org.primary_language field + +## v6.3.79 + +- Order tickets by last_activity_on and update indexes to reflect that +- Backfill ticketevent.contact and use that for fetching events in contact history +- Fix creating scheduled triggers not being able to see week day options +- Handle reopen events for tickets +- Stop creating Language instances or setting Org.primary_language + +## v6.3.78 + +- Add Ticket.last_activity_on and TicketEvent.contact +- Rreturn tickets by modified_on in the API +- Add ability to reverse results for runs/contacts API endpoints + +## v6.3.77 + +- Better validation of invalid tokens when claiming Zenvia channels +- Fix languages formax to not allow empty primary language + +## v6.3.76 + +- Read org languages from org.flow_languages instead of Language instances + +## v6.3.75 + +- Fix closing and reopening of tickets from API + +## v6.3.74 + +- Add better labels and help text for groups on trigger forms +- Load ticket events from database for contact histories +- Fix rendering of closed ticket triggers on trigger list page +- Fix rendering of ticket events as JSON +- Fix for delete modals + +## v6.3.73 + +- Backfill ticket open and close events +- Add support for closed ticket triggers + +## v6.3.72 + +- Add CSRF tokens to modaxes + +## v6.3.70 + +- Add CSRF token to modax form +- Tweak padding for nav so we don't overlap alerts +- Only require current password to change email or password +- Fix icon colors on latest chrome +- Migration to backfill Org.flow_languages + +## v6.3.69 + +- Add Org.flow_languages and start populating in Org.set_languages +- Raise the logo so it can be clicked + +## v6.3.68 + +- Enable exclusion groups on triggers and make groups an option for all trigger types +- Add users to mailroom test db +- Add ticket note support to UI + +## v6.3.67 + +- Pass user id to ticket/close ticket/reopen endpoints to use in the TicketEvent mailroom creates +- Model changes for ticket assignment +- Make flow session output URL have a max length of 2048 + +## v6.3.66 + +- Add new ticket event model +- Add output_url field to FlowSession + +## v6.3.65 + +- Fix rendering of recipient buttons on outbox +- Rework trigger create forms to make conflict handling more consistent +- Iterate through all pages when syncing whatsapp templates + +## v6.3.64 + +- URL field on HTTPRequestLog should have max length of 2048 + +## v6.3.63 + +- Drop unused index on contact name, and add new org+modified_on index + +## v6.3.62 + +- Update components to single mailroom resource for completion + +## v6.3.60 + +- Only retry 5000 messages at a time, prefetch channel and fields + +## v6.3.59 + +- Enable model instances to show an icon in selects + +## v6.3.58 + +- Add model changes for closed ticket triggers +- Add model changes for exclude groups support on triggers + +## v6.3.57 + +- Tweak mailroom_db to make contact created_on values fixed +- Add trigger type folder list views +- Fix filtering of flows for new conversation triggers +- Fix ordering of channel fields on triggers +- Tweak inspect_flows command to handle unreadable flows +- Nest group buttons on campaign list so they don't grow to largest cell + +## v6.3.56 + +- Fix migrating flows whose definitions contain decimal values +- Update to tailwind 2, fix security warnings +- Simplify org filtering on CRUDLs +- Remove IS_PROD setting + +## v6.3.55 + +- Update layout and color for badge buttons +- Add management command to inspect flows and fix has_issues where needed +- Fix deleting flow labels with parents +- Fix broken org delete modal +- Add user arg to Org.release and User.release + +## v6.3.54 + +- Optimize message retries with a perfect index +- Convert channels to soft dependencies + +## v6.3.53 + +- Update to latest temba-components + +## v6.3.52 + +- Update to latest floweditor +- Adjust WA templates page title +- Fix Dialog360 WA templates sync + +## v6.3.51 + +- Adjust WA templates page styles +- Migration to clear next_attempt for android channels + +## v6.3.50 + +- Resend messages using web endpoint rather than task +- Convert message labels, globals and classifiers to use soft dependencies + +## v6.3.49 + +- Make Msg.next_attempt nullable and add msgs to mailroom_db +- Migration to ensure that inactive flows don't have any deps +- Fix Flow.release to remove template deps + +## v6.3.48 + +- Calculate proper msg id commands from relayer that have integer overflow issue +- Add reusable view for dependency deleting modals and switch to that and soft dependencies for ticketers +- Don't do mailroom session interruption during org deletion +- Fix org deletion when broadcasts have parents and webhook results have contacts +- Make sure templates and templates translations are deleted on org release +- Set max fba pages limit to 200 + +## v6.3.47 + +- Display warning icon in flow list for flows with issues +- Make Flow.has_issues non-null and cleanup unused localized strings on Flow model +- Support syncing Dialog360 Whatsapp templates + +## v6.3.46 + +- Fix channel log icons and disallow message resending for suspended orgs +- Add migration to populate Flow.has_issues + +## v6.3.45 + +- Add migration to populate template namespace +- Expose template translation namespace field on API +- Don't save issues into flow metadata but just set new field has_issues instead +- Queue mailroom task to do msg resends + +## v6.3.44 + +- Tweak import preview page so when adding to a group isn't enabled, the group controls are disabled +- Update flow editor and temba-components + +## v6.3.40 + +- Add namespace field to template translations +- Fetching and saving revisions should return flow issues as separate field + +## v6.3.39 + +- Rework task for org deletion + +## v6.3.38 + +- Move tickets endpoint to tickets crudl +- Refactor WhatsApp templates +- Add task for releasing of orgs + +## v6.3.37 + +- Fix contact imports always creating new groups +- Migration to fix escaped nulls in flow revision definitions +- Rework beta gated agent views to be tikect centric + +## v6.3.35 + +- Clear primary language when releasing org +- Strip out NULL characters when serializing JsonAsTextField values +- Override language names and ensure overridden names are used for searching and sorting + +## v6.3.33 + +- Update components and flow editor to common versions +- Allow external ticketers to use agent ui, add footer to tickets + +## v6.3.32 + +- Release import batches when releasing contact imports + +## v6.3.31 + +- Fix serializing JSON to send to mailroom when it includes decimals + +## v6.3.30 + +- Restrict org languages to ISO-639-1 plus explicit inclusions + +## v6.3.29 + +- Move Twilio, Plivo and Vonage number searching views into their respective channel packages +- Optimize query for fetching contacts with only closed tickets +- Release contact imports when releasing groups +- Proper skip anonymous user for analytics + +## v6.3.28 + +- Remove simplejson +- Update to latest vonage client and fix retries + +## v6.3.27 + +- Restore menu-2 icon used by org choose menu + +## v6.3.26 + +- Make groups searchable on contact update page + +## v6.3.25 + +- Add beta-gated tickets view + +## v6.3.24 + +- Change analytics.track to expect a user argument +- Add org released_on, use when doing full releases +- Ignore anon user in analytics + +## v6.3.23 + +- Clean up countries code used by various channel types + +## v6.3.22 + +- Show results in flow order + +## v6.3.21 + +- Fix Javascript error on two factor formax +- Beta-gate chatbase integration for now + +## v6.3.20 + +- Rework DT One and Chatbase into a new integrations framework +- Expose Org.language as default language for new users on org edit form + +## v6.3.19 + +- Add support for Zenvia SMS +- Cleanup parsing unused code on org model +- Fix flow update forms to show correct fields based on flow type +- Tweak JSONAsTextField to allow underlying DB column to be migrated to JSONB +- Add controls to import preview page for selecting existing groups etc + +## v6.3.18 + +- Fix template names + +## v6.3.17 + +- Fix font reference in scss + +## v6.3.16 + +- Add group name field to contact imports so that it can be customized +- Rename Nexmo to Vonage, update icon +- Merge the two used icomoon sets into one and delete unused one +- Cleanup problems in org view templates + +## v6.3.15 + +- Revert wording changes when orgs don't have email settings to clarify that we do send +- Fix wording of Results link in editor + +## v6.3.14 + +- Fix locale files +- Fix SMTP server settings views to explain that we don't send emails if you don't have a config +- Add API endpoint to fetch tickets filterable by contact + +## v6.3.13 + +- Clarify terms for exports vs downloads +- Fix rendering of airtime events in contact history +- Add flows import and flow exports links in the flows tab + +## v6.3.12 + +- Update to latest flow-editor +- Cleanup unused dates methods +- Update markdown dependency +- Expose exclude_active on flow start read API +- Support 3 digits short code on Jasmin channel type +- Add support for YYYY-MM-DD date format +- Update DT One support to collect api key and secret to use with new API +- Update parent remaining credits +- Release broadcasts properly + +## v6.3.11 + +- Fix redirect after submitting Start In Flow modal + +## v6.3.10 + +- Add support to exclude active contacts in other flows when starting a flow on API +- Remove unsupported channel field on broadcast create API endpoint +- Add Start Flow modal to contact read page +- Fix lock file being out of sync with pyproject + +## v6.3.9 + +- Revert update to use latest API version to get WA templates +- Fix setting Zenvia webhooks +- Update Django and Django REST Framework + +## v6.3.8 + +- Convert to poetry + +## v6.3.6 + +- Update pt_BR translation +- Update to use latest API version to get WA templates +- Display failed on flow results charts, more translations +- Zenvia WhatsApp + +## v6.3.5 + +- Fix broken flow results charts + +## v6.3.4 + +- Update to latest celery 4.x + +## v6.3.2 + +- Support reseting the org limits to the default settings by clearing the form field +- Update redis client to latest v3.5.3 +- Fix manage accounts form blowing up when new user has been created in background + +## v6.3.1 + +- Add support for runs with exit_type=F +- Support customization for org limits + +## v6.3.0 + +- Update stable versions and coverage badge link +- Style Outbox broadcasts with megaphone icons and use includes for other places we render contacts and groups +- Fix spacing on outbox view +- Add discord channel type + +## v6.2.4 + +- Update Portuguese translation +- Update to floweditor v1.13.5 + +## v6.2.3 + +- Update to latest floweditor v1.13.4 + +## v6.2.2 + +- Update to flow editor v1.13.3 +- Update Spanish translation +- Disable old Zenvia channel type +- Fix styles on fields list + +## v6.2.1 + +- Return registration details to Android if have the same UUID +- Add spacing between individual channel log events +- Fix external channel claim form +- Do not track Android channels creation by anon user + +## v6.2.0 + +- Update translations for es, fr and pt-BR +- Fix rendering of pending broadcasts in outbox view + +## v6.1.48 + +- Update editor with dial router changes +- Fix resthook formax validation + +## v6.1.47 + +- Change synched to synced +- Update to smartmin 2.3.5 +- Require recent authentication to view backup tokens + +## v6.1.46 + +- Update to smartmin 2.3.5 +- Fix handling of attempts to sync old unclaimed channels +- Add view to list all possible channel types +- Fix rendering of nameless channels + +## v6.1.45 + +- Open up 2FA to all users +- Do not allow duplicates invites +- Never respond with registration commands in sync handler + +## v6.1.44 + +- Enforce time limit between login and two factor verification +- Prevent inviting existing users +- Add disabled textinputs and better expression selection on selects +- Create failed login records when users enter incorrect backup tokens too many times +- Logout user to force login to accept invite and require invite email account exactly + +## v6.1.43 + +- Backup tokens can only be used once +- Add new 2FA management views + +## v6.1.42 + +- Use Twilio API to determine capabilities of new Twilio channels +- Fix result pages not loading for users using Spanish interface + +## v6.1.41 + +- Remove no longer used permissions +- Override login view to redirect to new views for two-factor authentication +- Reduce recent export window to 4 hours +- Change message campaign events to use background flows + +## v6.1.40 + +- Remove UserSettings.tel and add UserSettings.last_auth_on + +## v6.1.39 + +- Increase max len of URN fields on airtime transfers +- Add toggle to display manual flow starts only +- Cleanup 2FA models + +## v6.1.38 + +- Update flow editor to 1.12.10 with failsafe errors +- Make validation of external channel URLs disallow private and link local hosts +- Cleanup middleware used to set org, timezone and language + +## v6.1.37 + +- Update components and editor to latest versions +- Switch to microsecond accuracy timestamps +- Switch to default_storage for export assets + +## v6.1.33 + +- Tweaks to how we generate contact histories + +## v6.1.32 + +- Mute invalid host errors +- Add migration to alter m2ms to use bigints +- Drop no longer used database function +- Switch to big id for msgs and channel logs + +## v6.1.31 + +- Add management command to check sentry +- Remove unused context processor and unused code from org_perms + +## v6.1.29 + +- Rework contact history so that rendering as events happens in view and we also expose a JSON version + +## v6.1.26 + +- Upgrade urllib3 + +## v6.1.25 + +- Update to elastic search v7 + +## v6.1.24 + +- Broadcast events in history should be white like message events + +## v6.1.23 + +- Add index on flow start by start type +- Allow only deleting msg folders without active children labels +- Use engine events (with some extra properties) for msgs in contact history + +## v6.1.22 + +- Fix API serialization of background flow type +- Allow background flows to be used in scheduled triggers +- Update pip-tools + +## v6.1.21 + +- Configure editor and components to use completions files in current language + +## v6.1.20 + +- Update to latest floweditor and temba-components + +## v6.1.19 + +- Update to floweditor v1.12.6 +- Fix deleting classifiers + +## v6.1.18 + +- Add support for background flows + +## v6.1.17 + +- Update to flow editor v1.12.5 +- Fix importing dependencies when it's a clone in the same workspace +- Allow aliases to be reused on boundaries with different parent +- Increase max length on external channels to be configurable up to 6400 chars +- Fix contact export warning for existing export + +## v6.1.16 + +- Update to latest flow editor 1.12.3 +- Allow staff users to use the org chooser + +## v6.1.15 + +- Add constraint to chek URN identity mathes scheme and path +- Add non-empty constraint for URN scheme and path +- Fix contact list pagination with searches +- Show query on list page for smart groups + +## v6.1.14 + +- Change template translations to be TEXT +- Set global email timeout, fixes rapidpro #1345 +- Update tel parsing to match gocommon, fixing how we currently accept local US numbers + +## v6.1.13 + +- Bump temba-components to v0.8.11 + +## v6.1.12 + +- Un-beta-gate Rocket.Chat channels + +## v6.1.10 + +- Login summary on org home page should include agents +- Rework manage accounts UI to include agents + +## v6.1.9 + +- Fix deleted flow dependency preventing global deletion +- Cache lookups of auth.Group instances + +## v6.1.8 + +- For field columns in imports, only match against user fields +- Add agent role and cleanup code around org roles + +## v6.1.7 + +- Wire table listeners on pjax reload +- Update domain from swag.textit.com to whatsapp.textit.com +- Add internal ticketer type for BETA users +- Inner scrolling on contact list page +- Improve styles for recipient lists + +## v6.1.6 + +- Trim our start runs 1,000 at a time and by id +- Increase global max value length to 10000 and fix UI to be more consistent with fields + +## v6.1.5 + +- Share modals on globals list, truncate values +- Squash migrations + +## v6.1.4 + +- Add security settings file +- Fix intent selection on split by intent +- Add empty migrations for squashing in next release + +## v6.1.3 + +- Fix intent selection on split by intent +- Update callback URL for textit whatsapp +- Use Django password validators + +## v6.1.2 + +- Add TextIt WhatsApp channel type + +## v6.1.1 + +- Fix contact exports when orgs have orphaned URNs in schemes they don't currently use + +## v6.1.0 + +- Hide editor language dialog blurb until needed to prevent flashing +- Fix broken flows list page if org has no flows +- Allow underscores in global names +- Improve calculating of URN columns for exports so tests don't break every time we add new URN schemes +- Make instruction lists on channel claim pages more consistent + +## v6.0.8 + +- Editor fix for split by intents +- Add empty migrations for squashing in next release + +## v6.0.7 + +- Fix choose org page +- Fix recipient search +- Fix run deletion + +## v6.0.6 + +- Fix for textarea init + +## v6.0.5 + +- Adjust contact icon color in recipient lists + +## v6.0.4 + +- Fix recipients contacts and urns UI labels +- Fix flow starts log page pagination +- Update temba-components and flow-editor to common versions +- Fix flow label delete modal +- Fix global delete modal + +## v6.0.3 + +- Update to components v0.8.6, bugfix release +- Handle CSV imports in encodings other than UTF8 + +## v6.0.2 + +- Fix broken ticket re-open button +- Missing updated Fr MO file from previous merge +- Apply translations in fr + +## v6.0.1 + +- Fix orgs being suspended due to invalid topup cache +- Set uses_topups on new orgs based on whether our plan is the TOPUP_PLAN +- Fix validation issues on trigger update form +- Fix hover cursor in lists for viewers +- Action button alignment on archived messages +- Fix flow table header for viewers +- Fix tests for channel deletion +- Fix redirects for channel and ticketer deletion. +- Fix dialog when deleting channels with dependencies +- Match headers and contact fields with labels as well as keys during contact imports + +## v6.0.0 + +- Add Rocket.Chat ticketer to test database + +## v5.7.91 + +- Add Rocket.Chat ticketers + +## v5.7.90 + +- Update rocket.chat icon in correct font + +## v5.7.89 + +- Improve Rocket.Chat claim page +- Add Rocket.Chat icon + +## v5.7.87 + +- Cleanup Rocket.Chat UI + +## v5.7.86 + +- Add RocketChat channels (beta-only for now) + +## v5.7.85 + +- Add back jquery-migrate and remove debug + +## v5.7.84 + +- Remove select2, coffeescript, jquery plugins + +## v5.7.83 + +- Fix broken import link on empty contacts page +- Use consistent approach for limits on org +- Globals UI should limit creation of globals to org limit +- Fix archives list styles and add tabs for message and run archives +- Restyle the Facebook app channel claim pages +- Switch to use FBA type by default + +## v5.7.82 + +- Don't blow up if import contains invalid URNs but pass values on to mailroom +- Update to version of editor with some small styling tweaks +- Include occurred_on with mo_miss events queued to mailroom +- Adjust Twilio connect to redirect properly to the original claim page +- Remove no longer used FlowRun.timeout_on and drop two unused indexes +- Cleanup more localized strings with trimmed +- Fix 404 error in channel list + +## v5.7.81 + +- Add page title to brand so that its configurable +- Dont send alert emails for orgs that aren't using topups +- Consider timezone when infering org default country and display on import create page +- Add page titles to fields and flows +- Allow changing EX channels role on UI + +## v5.7.80 + +- Add contact last seen on to list contacts views +- Cleanup channel model fields +- Add charcount to send message dialog +- Show channel logs link for receive only channels +- Fix export flow page styles +- Allow searching for countries on channel claim views + +## v5.7.79 + +- Rework imports to allow importing multiple URNs of same scheme +- Cleanup no longer used URN related functionality +- Show contact last seen on on contact read page + +## v5.7.78 + +- Clean up models fields in contacts app + +## v5.7.77 + +- Fix styling on the API explorer page +- Fix list page selection for viewers +- Move contact field type constants to ContactField class +- Allow brand to be set by env variable + +## v5.7.76 + +- Drop support for migrating legacy expressions on API endpoints +- Fix imports blowing up when header is numerical +- Fix 11.4 flow migration when given broken send action +- Drop RuleSet and ActionSet models + +## v5.7.75 + +- Last tweaks before RuleSet and ActionSet can be dropped +- Contact id treatment for details +- Update components to ship ajax header and use it in language endpoint +- Remove no longer needed legacy editor completion + +## v5.7.74 + +- Remove legacy flow code +- WA channel tokens refresh catch errors for each channel independently + +## v5.7.73 + +- Make flows searchable and clickable on triggers +- Make flows searchable on edit campaign event + +## v5.7.72 + +- Fix editor whatsapp templates, refresh whatsapp channel pages +- Move omnibox module into temba.contacts.search + +## v5.7.71 + +- Remove legacy contact searching +- Remove code for dynamic group reevaluation and campaign event scheduling + +## v5.7.70 + +- Fix pdf selection + +## v5.7.69 + +- Validate language codes passed to contact API endpoint +- Don't actually create a broadcast if sending to node but nobody is there +- Update to latest floweditor + +## v5.7.67 + +- Fix globals endpoint so name is required +- Filter by is_active when updating fields on API endpoint + +## v5.7.66 + +- Replace remaining Contact.get_or_create calls with mailroom's resolve endpoint + +## v5.7.65 + +- URN lookups onthe contact API endpoint should be normalized with org country +- Archiving a campaign should only recreate events + +## v5.7.64 + +- Don't create contacts and URNs for broadcasts but instead defer the raw URNs to mailroom + +## v5.7.63 + +- Validate that import files don't contain duplicate UUIDs or URNs + +## v5.7.62 + +- Update version of editor and components +- Upload imports to use UUID based path +- Fix issue where all keywords couldnt be removed from a flow + +## v5.7.61 + +- Remove old editor, redirect editor_next to editor + +## v5.7.60 + +- Fix contact imports from CSV files +- Tweaks to import UI + +## v5.7.59 + +- Imports 2.0 + +## v5.7.55 + +- Use v13 flow as example on definitions endpoint docs +- Add URNs field to FlowStart and pass to mailroom so that it creates contacts + +## v5.7.54 + +- Update editor to get support for expressions in add to group actions +- Remove unused localized text on Msg and Broadcast + +## v5.7.52 + +- Migrations and models for new imports + +## v5.7.51 + +- Add plan_start, calculate active contacts in plan period, add to OrgActivity +- Tweak how mailroom_db creates extra group contacts +- Update to latest django-hamlpy + +## v5.7.50 + +- Optimizations for orgs with many contact fields + +## v5.7.49 + +- Update plan_end when suspending topup orgs +- Suspend topup orgs that have no active credits +- Show suspension header when an org is suspended +- Tweak external channel config styling +- Fix styles for button on WA config page + +## v5.7.48 + +- Fix button style for channel extra links +- Skip components missing text for WA templates sync +- Editors should have API tokens + +## v5.7.47 + +- Queue mailroom task to schedule campaign events outside of import transaction +- Fix margin on fields warning alert + +## v5.7.46 + +- Use mailroom task for scheduling of campaign events + +## v5.7.45 + +- Make sure form.\_errors is a list + +## v5.7.44 + +- Add index to enforce uniqueness for event fires + +## v5.7.43 + +- Fix migration + +## v5.7.42 + +- Bump smartmin to 2.2.3 +- Fix attachment download and pdf links + +## v5.7.41 + +- Fix messages to send without topup, and migrations +- No topup transfers on suborgs, show contacts, not credits + +## v5.7.40 + +- Invalid language codes passed to contact API endpoint should be ignored and logged for now + +## v5.7.39 + +- Update widget focus and borders on legacy editor +- Show global form errors and pre-form on modax template + +## v5.7.38 + +- Add alpha sort and search to results view +- Searchable contact fields and wired listeners after group changes +- Force policy redirect on welcome page, honor follow-on navigation redirect +- Use mailroom for contact creation in API and mailroom_db command +- Adjust styling for contact import scenarios +- Show address when it doesn't match channel name + +## v5.7.37 + +- add topup button to topup manage page + +## v5.7.36 + +- Fix deleting ticketers + +## v5.7.35 + +- Zendesk file view needs to be csrf exempt +- Use mailroom to create contacts from UI + +## v5.7.34 + +- Add view to handle file URL callbacks from Zendesk + +## v5.7.33 + +- Fix delete button on archived contacts page +- Don't allow saving queries that aren't supported as smart groups +- Delete no longer used contacts/fields.py +- Fix contacts reppearing in ES searches after being modified by a bulk action +- Adjust pjax block for contact import block + +## v5.7.32 + +- Modal max-height in vh to not obscure buttons + +## v5.7.31 + +- Add padding for p tags on policies + +## v5.7.30 + +- Add content guideline policy option, update styling a bit + +## v5.7.29 + +- Sitewide refresh of styles using Tailwind + +## v5.7.27 + +- Site refresh of styles using Tailwind. + +## v5.7.28 + +- Update to flow editor v1.9.15 + +## v5.7.27 + +- Update to flow editor v1.9.14 +- Add support for last_seen_on in legacy search code + +## v5.7.26 + +- Handle large deletes of contacts in background task + +## v5.7.25 + +- Fix bulk actions against querysets from ES searches +- Fix bulk action permissions on contact views + +## v5.7.24 + +- Rename existing 'archive' contact action in API to 'archive_messages' +- Allow deleting of all contacts from Archived view + +## v5.7.23 + +- Rename All Contacts to Active +- Add UI for archiving, restoring and deleting contacts + +## v5.7.22 + +- Bump version of mailroom and indexer used for tests +- Drop no longer used is_blocked and is_stopped fields + +## v5.7.21 + +- Add missing migration from last rev + +## v5.7.20 + +- Add missing migration + +## v5.7.19 + +- Make contact.is_stopped and is_blocked nullable and stop writing + +## v5.7.18 + +- Update sys group trigger to handle archiving + +## v5.7.17 + +- Migration to add Archived sys group to all orgs + +## v5.7.16 + +- Update to flow editor 1.9.11 +- Update database triggers to use contact status instead of is_blocked or is_stopped +- Make contact.status non-null +- Create new archived system group for new orgs + +## v5.7.15 + +- Add nag warning to legacy editor + +## v5.7.14 + +- Migration to backfill contact status + +## v5.7.13 + +- Enable channelback files for Zendesk ticketers +- Set status as active for new contacts +- Add new status field to contact +- Fix legacy editor by putting html-tag block back +- Change the label for CM channel claim + +## v5.7.12 + +- Fix imports that match by UUID +- Fix Nexmo search numbers and claim number +- Use Django language code on html tag +- Add support for ClickMobile channel type + +## v5.7.11 + +- Fix creating of campaign events based on last_seen_on +- Tweak msg_console so it can include sent messages which are not replies +- Fix mailroom_db command +- Expose last_seen_on on contact API endpoint + +## v5.7.10 + +- Update floweditor to 1.9.10 +- Add Last Seen On as a system field so it can be used in campaigns +- Tweak search_archives command to allow JSONL output + +## v5.7.9 + +- Fix reading of S3 event streams +- Migration to populate contact.last_seen_on from msg archives + +## v5.7.8 + +- Add plan_end field to Orgs + +## v5.7.7 + +- Add search archives management command + +## v5.7.6 + +- Optimizations to migration to backfill last_seen_on + +## v5.7.5 + +- Add migration to populate contact.last_seen_on +- Update to latest temba-components with support for refresh work + +## v5.7.4 + +- Use new metadata field from mailroom searching endpoints +- Make sure we have only one active trigger when importing flows +- Fix org selector and header text alignment when editor is open + +## v5.7.3 + +- Add contact.last_seen_on +- Bump floweditor to v1.9.9 + +## v5.7.2 + +- Add error messages for all error codes from mailroom query parsing +- Fix org manage quick searches +- Always use mailroom for static group changes + +## v5.7.1 + +- Add session history field to flowstarts +- Have mailroom reset URNs after contact creation to ensure order is correct + +## v5.7.0 + +- Add start_type and created_by to queued flow starts +- New mixin for list views with bulk actions +- Update some dependencies to work with Python 3.8 and MacOS + +## v5.6.5 + +- Set the tps options for Twilio based on country and number type +- Fix wit.ai classifiers and double logging of errors on all classifier types + +## v5.6.3 + +- Add variables for nav colors + +## v5.6.2 + +- Fix failing to manage logins when the we are logged in the same org + +## v5.6.1 + +- instead of dates, keep track of seen runs when excluding archived runs from exports + +## v5.6.0 + +- 5.6.0 Release Candidate + +## v5.5.78 + +- Improve the visuals and guides on the FBA claim page +- Block flow starts and broadcasts for suspended orgs +- Add a way to suspend orgs from org manage page + +## v5.5.77 + +- Subscribe to the Facebook app for webhook events + +## v5.5.76 + +- Add Facebook App channel type + +## v5.5.75 + +- always update both language and country if different + +## v5.5.74 + +- allow augmentation of templates with new country + +## v5.5.73 + +- Add support for urn property in search queries +- Add support for uuid in search queries +- Set country on WhatsApp templates syncing and add more supported languages +- Add country on TemplateTranslation + +## v5.5.72 + +- Use modifiers for field value updates + +## v5.5.71 + +- Fix to allow all orgs to import flows + +## v5.5.70 + +- Use modifiers and mailroom to update contact URNs + +## v5.5.69 + +- Refresh contact after letting mailroom make changes +- Contact API endpoint can't call mailroom from within a transaction + +## v5.5.68 + +- Fix contact update view +- Allow multi-user / multi-org to be set on each org +- Fix additional urls import + +## v5.5.66 + +- Implement Contact.update_static_groups using modifiers +- Consistent use of account/login/workspace + +## v5.5.64 + +- Fix editor + +## v5.5.63 + +- Make new org fields non-null and remove no longer needed legacy method + +## v5.5.62 + +- Rename whitelisted to verified +- Add migration to populate new org fields + +## v5.5.61 + +- Add new boolean fields to org for suspended, flagged and uses_topups and remove no longer used plan stuff + +## v5.5.60 + +- Move webhook log button to flow list page +- Add confirmation dialog to handle flow language change + +## v5.5.59 + +- Update to floweditor v1.9.8 + +## v5.5.58 + +- Update to floweditor 1.9.7 +- Remove BETA gating for tickets + +## v5.5.57 + +- Restore logic for when dashboard and android nav icons should appear +- Add translations in ru and fr + +## v5.5.56 + +- Improvements to ticketer connect views +- Still need to allow word only OSM ids + +## v5.5.55 + +- Fix boundaries URL regex to accept more numbers + +## v5.5.54 + +- Add index for mailroom looking up tickets by ticketer and external ID +- Make it easier to differentiate open and closed tickets +- Update to temba-components 0.1.7 for chrome textinput fix + +## v5.5.53 + +- Add indexes on HTTP log views +- Simplify HTTP log views for different types whilst given each type its own permission + +## v5.5.52 + +- More ticket view tweaks + +## v5.5.51 + +- Tweak zendesk manifest view + +## v5.5.50 + +- Tweak zendesk mailroom URLs + +## v5.5.49 + +- Store brand name in mailgun ticketer config to use in emails from mailroom + +## v5.5.48 + +- Defer to mailroom for ticket closing and reopening + +## v5.5.47 + +- Beta-gated views for Mailgun and Zendesk ticketers + +## v5.5.46 + +- Bump black version +- Fix layering of menu with simulator + +## v5.5.45 + +- Increase the template name field to accept up to 512 characters +- Make sending of Stripe receipts optional +- Add OrgActivity model that tracks contacts, active contacts, incoming and outgoing messages + +## v5.5.43 + +- Fix JS escaping on channel log page + +## v5.5.42 + +- Remove csrf exemption for views that don't need it (all our pjax includes csrf) +- Escape translations in JS literals +- Upgrade FB graph API to 3.3 + +## v5.5.41 + +- Use branding keys when picking which orgs to show on manage + +## v5.5.40 + +- Allow branding to have aliases +- Fix bug of removing URNs when updating fields looking up by URN + +## v5.5.39 + +- Update to floweditor 1.9.6 +- New task to track daily msgs per user for analytics +- Add support for Russian as a UI language +- Models and editor API endpoint for tickets +- Skip duplicate relayer call events + +## v5.5.38 + +- Update to flow editor 1.9.5 +- Allow custom TS send URLs + +## v5.5.37 + +- Remove all uses of \_blank frame name +- Strip exif data from images + +## v5.5.36 + +- Better tracking of channel creation and triggers, track simulation +- Do not use font checkboxes for contact import extra fields + +## v5.5.35 + +- Revert Segment.io identify change to stay consistent with other tools + +## v5.5.34 + +- Identify users in Segment.io using best practice of user id, not email + +## v5.5.33 + +- Add context processor to stuff analytics keys into request context +- Restrict 2FA functionality to BETA users + +## v5.5.32 + +- Add basic 2FA support + +## v5.5.31 + +- Update to latest smartmin + +## v5.5.30 + +- Add new flow start type to record that flow was started by a Zapier API call +- Contact bulk actions endpoint should error if passed no contacts +- Remove mentioning the countries for AT claim section +- Add Telesom channel type + +## v5.5.29 + +- Fix trimming flow starts with start counts + +## v5.5.28 + +- Update Africa's Talking supported countries + +## v5.5.27 + +- Remove temporary NOOP celery tasks +- Drop Contact.is_paused field +- Editor 1.9.4, better modal centering + +## v5.5.26 + +- Add NOOP versions of renamed celery tasks to avoid problems during deploy + +## v5.5.23 + +- Remove default value on Contact.is_paused so it can be dropped +- Trim completed mailroom created flow starts +- Update flow starts API endpoint to only show user created flow starts and add index + +## v5.5.22 + +- Add nullable contact.is_paused field +- Display run count on flow start list page + +## v5.5.21 + +- Optimze flow start list page with DB prefetching +- Indicate on flow start list page where start was created by an API call + +## v5.5.20 + +- Use actual PO library to check for msgid differences +- Migration to backfill FlowStart.start_type +- Log error of WA channel failing to sync templates + +## v5.5.19 + +- Add FlowStart.start_type +- Ensure flow starts created via the API are only sent to mailroom after the open transaction is committed + +## v5.5.18 + +- Add flow start log page + +## v5.5.17 + +- Add index to list manually created flow starts +- Make FlowStart.org and modified_on non-NULL +- Move contact modification for name and language to be done by mailroom + +## v5.5.16 + +- bower no longer supported for package installs +- Migration to backfill FlowStart.org and modified_on + +## v5.5.15 + +- Update to flow-editor 1.9.2, security patches + +## v5.5.14 + +- Ensure IVR retry is preserved on new revisions +- Import flows for mailroom test db as v13 +- Make UUID generation fully mockable +- Add run UUID on flow results exports +- Drop unused fields on FlowStart and add org + +## v5.5.13 + +- Stop using FlowStart.modified_on so that it can be removed +- Disable syncing templates with variables in headers and footers + +## v5.5.12 + +- Import and export of PO files + +## v5.5.10 + +- Bump up the simulator when popped so it fits on more screens +- Editor performance improvements + +## v5.5.8 + +- Update help text on contact edit dialog +- Add prometheus endpoint config on account page +- Fix boundary aliases filtering by org + +## v5.5.7 + +- Fix open modal check on pjax refersh +- Show warnings on contact field page when org is approaching the limit and has hit the limit + +## v5.5.6 + +- Temporaly disable templates requests to FB when claiming WA channels + +## v5.5.5 + +- newest smartmin with BoM fix + +## v5.5.4 + +- Show better summary of schedules on trigger list page +- Fix display of trigger on contact group delete modal + +## v5.5.3 + +- Update to floweditor 1.8.9 +- Move EX constants to channel type package +- Remove unused deps and address npm security warnings +- Add 18 hours as flow expiration option +- FlowCRUDL.Revisions should return validation errors from engine as detail field +- Allow setting authentication header on External channels +- Add normalize contact tels task +- Drop full resolution geometry, only keep simplified +- Add attachments columns to flow results messages sheet + +## v5.5.0 + +- Increase the WA channels tps to 45 by default + +## v5.4.13 + +- Fix URL related test errors + +## v5.4.12 + +- Don't allow localhost for URL fields + +## v5.4.11 + +- Make sure external channel URLs are external + +## v5.4.10 + +- Complete FR translations +- Update to floweditor 1.8.8 + +## v5.4.9 + +- Fix submitting API explorer requests where there is no editor for query part +- Lockdown redirects on exports +- Add more detailed fresh chat instructions + +## v5.4.8 + +- Find and fix more cases of not filtering by org + +## v5.4.7 + +- Fix org filtering on updates to globals +- Fix campaign event update view not filtering by event org +- Fix error in API contact references when passed a JSON number +- Replace Whatsapp by WhatsApp + +## v5.4.6 + +- Merge pull request #2718 from nyaruka/fe187 + +## v5.4.4 + +- fix various filtering issues + +## v5.4.3 + +- Update sample flow test + +## v5.4.2 + +- remove use of webhook where not appropriate + +## v5.4.1 + +- Update sample flows to use @webhook instead of @legacy_extra + +## v5.4.0 + +- Add API endpoint to update Globals +- Keep latest sync event for Android channels when trimming + +## v5.3.64 + +- Add support for Twilio Whatsapp channel type + +## v5.3.63 + +- Add pre_deploy command to check imports/exports +- Fix link to android APK downloads on claim page + +## v5.3.62 + +- Temporarily disable resume imports task + +## v5.3.61 + +- Fix text of save as group dialog +- Add support to restart export tasks that might have been stopped by deploy + +## v5.3.60 + +- Update to latest mailroom +- Add urns to runs API endpoint + +## v5.3.59 + +- Update to latest mailroom which returns allow_as_group from query parsing +- Don't create missing contact fields on flow save + +## v5.3.57 + +- Update flow editor 1.7.16 +- Fix translations on external channel claim page +- Add tabs to toggle between full flow event history and summary of messages +- Increase the max height on the flow results export modal dialog + +## v5.3.56 + +- Add params to flow starts API +- Change name of org_id param in calls to flow/inspect +- Add quick replies variable to external channel claim page + +## v5.3.55 + +- Allow editing of allow_international on channel update forms +- Use consistent format for datetimes like created_on on contact list page + +## v5.3.54 + +- Hide loader on start flow dialog when there are no channels + +## v5.3.53 + +- Fix creation of Android channels + +## v5.3.52 + +- Convert Android to dynamic channel type + +## v5.3.51 + +- Update to floweditor 1.7.15 +- Add python script to do all CI required formatting and locale rebuilding +- Use mailroom for query parsing for contact exports +- Fix text positioning on list pages +- Fix delete contact group modal buttons when blocked by dependencies +- Completion with upper case functions + +## v5.3.50 + +- Migration to set allow_international=true in configs of existing tel channels +- Remove no longer used flow definition caching stuff + +## v5.3.49 + +- Use realistic phone numbers in mailroom test db +- Remove contact filtering from flow results page +- Add migration to populate Flow.template_dependencies + +## v5.3.48 + +- Use mailroom searching for omnibox results + +## v5.3.47 + +- Add template_dependencies m2m + +## v5.3.46 + +- Do not subject requests to the API with sessions to rate limiting +- Migration to convert flow dependencies metadata to new format +- Update description on the flow results export to be clear + +## v5.3.45 + +- Fix deletion of orgs and locations so that aliases are properly deleted +- Remove syntax highlighting in API explorer as it can't handle big responses +- Use new dependencies format from mailroom + +## v5.3.44 + +- Dynamic group creation / reevaluation through Mailroom + +## v5.3.43 + +- Update to latest mailroom + +## v5.3.42 + +- Fix actions on blocked contact list page + +## v5.3.41 + +- Disable simulation for archived flows +- Fix query explosion on Android channel alerts + +## v5.3.40 + +- Add subflow parameters to editor + +## v5.3.39 + +- Rework migration code so new flows are migrated too + +## v5.3.38 + +- Use mailroom for contact searches, contact list pages and flow starts via search + +## v5.3.35 + +- Rebuild components + +## v5.3.34 + +- Update to flow editor 1.7.13 +- Don't include 'version' in current definitions +- Migrate imports of flows to new spec by default + +## v5.3.30 + +- Exclude inactive template translations from API endpoint + +## v5.3.29 + +- Fix edge case for default alias dialog +- Add sending back to contact list page +- Save parent result refs in flow metadata +- Change name BotHub to Bothub + +## v5.3.28 + +- remove auto-now on modified_on on FlowRun + +## v5.3.27 + +- Update to floweditor 1.7.9 +- Warn users if starting for facebook without a topic + +## v5.3.26 + +- Allow arbitrary numbers when sending messages +- Componentized message sending + +## v5.3.25 + +- Show empty message list if we have archived them all +- Update to flow editior 1.7.8 +- Replace flow/validate call to mailroom with flow/inspect +- Add facebook topic selection + +## v5.3.24 + +- Pass version to mailroom migrate endpoint +- Fix saving on alias editor +- Support the whatsapp templates HEADER and FOOTER components +- Write HTTP log for errors in connection + +## v5.3.23 + +- Add support for whatsapp templates with headers and footers +- Make sure we have one posterizer form and we bind one click event handler for posterize links + +## v5.3.22 + +- Convert add/edit campaign event to components + +## v5.3.21 + +- Add UI for managing globals + +## v5.3.16 + +- Update to flow editor v1.7.7 + +## v5.3.13 + +- Update to floweditor v1.7.5 +- Re-add msg_console management command with new support for mailroom +- Cleanup somes usages of trans/blocktrans + +## v5.3.12 + +- Add error and failure events to contact history +- Use form components on campaign create/update + +## v5.3.11 + +- Migrate sample flows to new editor +- Localize URNs in API using org country +- Write HTTPLogs for Whatsapp template syncing +- Remove Broadcast recipient_count field + +## v5.3.10 + +- Add read API endpoint for globals + +## v5.3.9 + +- Add trimming task for flow revisions +- Add models for globals support +- Add FreshChat channel support + +## v5.3.8 + +- Make sure imported flows are unarchived +- Validate we do not have a caller on a channel before adding a new one + +## v5.3.7 + +- Release URNs on Org release + +## v5.3.6 + +- Release Channel sync events and alarms + +## v5.3.5 + +- release Campaigns when releasing Orgs + +## v5.3.4 + +- Release flow starts when releasing flows + +## v5.3.3 + +- Add releasing to Classifiers and HTTPLogs + +## v5.3.2 + +- Allow manual syncing of classifiers + +## v5.3.1 + +- Update documentation for FB webhook events to subscribe to + +## v5.3.0 + +- Fix DT One branding and add new icon +- Fix validation problem on update schedule trigger form +- Use brand when granting orgs, not host +- Update contactsql parser to support same quotes escaping as goflow + +## v5.2.6 + +- Change slug for Bothub classifier to 'bothub' + +## v5.2.5 + +- Fix various Schedule trigger UI validation errors +- Fix intermittently failing excel export tests +- Add noop reverse in migration + +## v5.2.1 + +- Fix order of Schedule migrations (thanks @matmsa27) + +## v5.2.0 + +- Show date for broadcast schedules +- Honor initial datetime on trigger schedule ui + +## v5.1.64 + +- Update to flow editor version 1.7.3 +- Fix weekly buttons resetting on trigger schedule form validation +- Validate schedule details on schedule trigger form +- Show query editors in contact search +- Add migration to fix schedules with None/NaN repeat_days_of_week values +- Move IE9 shim into the main template header +- Update README with final 5.0 versions + +## v5.1.63 + +- Update to flow editor v1.7.2 + +## v5.1.62 + +- Validate repeat_days_of_week when updating schedules +- Include airtime transfers in contact history + +## v5.1.61 + +- Tweak styling on contact field list page +- Send test email when the SMTP server config are set + +## v5.1.60 + +- Add Bothub classifier type + +## v5.1.59 + +- Update flow editor to version 1.7.0 +- Add Split by Intent action in flows +- Update Send Airtime action for use with DTOne + +## v5.1.58 + +- Unify max contact fields +- Don't allow deletion of flow labels with children +- Rename TransferTo to DTOne + +## v5.1.57 + +- Check pg_dump version when creating dumps +- Add missing block super in extra script blocks +- Fix omnibox being not actually required on send message form +- Rework airtime transfers to have separate http logs +- Allow flow starts by query + +## v5.1.55 + +- Sync intents on classifier creation +- Trim HTTP logs older than 3 days + +## v5.1.54 + +- remove fragile AT links to configuration pages +- Exclude hidden results from flow results page +- Exclude results with names starting with \_ from exports + +## v5.1.53 + +- Classifier models and views +- HTTPLog models and views + +## v5.1.52 + +- add prefetch to retry + +## v5.1.51 + +- Add ThinQ Channel Type + +## v5.1.50 + +- Fix contact history rendering of broadcast messages with null recipient count +- Fix for start_session action in the editor + +## v5.1.49 + +- Fire schedules in Mailroom instead of celery + +## v5.1.48 + +- Rework contact history to include engine events + +## v5.1.47 + +- Update to flow editor 1.6.20 + +## v5.1.46 + +- Rev Flow Editor v1.6.19 + +## v5.1.45 + +- Fix rendering of campaigns on export page +- Fix ivr channel logs +- Make FlowRun.status non-NULL +- Make FlowSession.uuid unique and indexed + +## v5.1.44 + +- Tidy up fields on flow activity models + +## v5.1.43 + +- Fix styling on create flow dialog +- Make user fields nullable on broadcasts +- Populate repeat_minute_of_hour in data migration + +## v5.1.42 + +- Update trigger update views to take into account new schedule fields + +## v5.1.41 + +- Update docs on flow start extra to be accessible via @trigger +- Change input selector to work cross-browser on send modal +- Don't inner scroll for modax fetches + +## v5.1.40 + +- Fix issues with web components in Microsoft Edge + +## v5.1.37 + +- Cleanup Schedule class +- Drop unused columns on FlowRun +- Remove legacy engine code +- Remove legacy braodcast and message sending code + +## v5.1.36 + +- Temporarily disable compression for components JS + +## v5.1.33 + +- Use new expressions for campaign message events, broadcasts and join group triggers +- List contact fields with new expression syntax and fix how campaign dependencies are rendered + +## v5.1.28 + +- Use mailroom to interrupt runs when archiving or releasing a flow +- Re-organize legacy engine code +- Initial library of web components + +## v5.1.27 + +- Update to floweditor 1.6.13 +- Allow viewers to do GETs on some API endpoints + +## v5.1.26 + +- Fix rendering of campaign and event names in UI +- Move remaining channel client functionality into channel type packages +- Remove unused asset server stuff + +## v5.1.25 + +- Update floweditor to 1.6.12 +- Allow viewing of channel logs in anonymous orgs with URN values redacted + +## v5.1.24 + +- Cleanup campaighn models fields + +## v5.1.23 + +- Really fix copying of flows with nameless has_group tests and add a test this time + +## v5.1.22 + +- Remove trigger firing functionality (except schedule triggers) and drop unused fields on trigger + +## v5.1.21 + +- Migration to backfill FlowRun.status + +## v5.1.20 + +- Limit group fetching to active groups +- Get rid of caching on org object as that's no longer used needed +- Fix importing/copying flows when flow has group dependency with no name + +## v5.1.19 + +- Migration to add FlowRun.status + +## v5.1.18 + +- Cleanup fields on FlowRun (single migration with no real SQL changes which can be faked) + +## v5.1.17 + +- Remove all IVR flow running functionality which is now handled by mailroom + +## v5.1.15 + +- Update to flow editor v1.6.11 +- Releasing Nexmo channel shouldn't blow up if application can't be deleted on Nexmo side + +## v5.1.14 + +- Fix Nexmo IVR to work with mailroom +- Add migration to populate session UUIDs +- Update to Django 2.2 +- Send topup expiration emails to all org administrators + +## v5.1.12 + +- Drop ActionLog model +- Switch to new editor as the default, use v1.6.10 +- Add query field to FlowStart + +## v5.1.11 + +- Add FlowSession.uuid which is nullable for now +- Update to floweditor 1.6.9, scrolling rules + +## v5.1.10 + +- Update to flow editor 1.6.8, add completion config +- Add FlowStart.parent_summary, start deprecating fields +- Switch to bionic beaver for CI builds +- Add trigger params access to ivr flow +- Drop no longer used Broadcast.purged field + +## v5.1.9 + +- Make Broadcast.purged nullable in preparation for dropping it + +## v5.1.8 + +- Update floweditor to 1.6.7 and npm audit + +## v5.1.7 + +- Remove unused IVR tasks +- Simplify failed IVR call handling + +## v5.1.6 + +- Fix format_number to be able to handle decimals with more digits than current context precision + +## v5.1.5 + +- Update to flow editor 1.6.6 + +## v5.1.4 + +- Update to flow editor 1.6.5 +- Update Django to 2.1.10 + +## v5.1.3 + +- Update flow editor to 1.6.3 + +## v5.1.2 + +- Remove fields no longer needed by new engine +- Trim sync events in a separate task + +## v5.1.1 + +- Stop writing legacy engine fields and make them nullable +- Remove no longer used send_broadcast_task and other unused sending code +- Squash migrations into previously added dummy migrations + +## v5.1.0 + +- Populate account sid and and auth token on twilio callers when added +- Disable legacy IVR tasks + +## v5.0.9 + +- Add dummy migrations for all migrations to be created by squashing + +## v5.0.8 + +- Update recommended versions in README +- Fix API runs serializer when run doesn't have category (i.e. from save_run_result action) +- Update to latest floweditor +- Update search parser to convert timestamps into UTC + +## v5.0.7 + +- Force a save when migrating flows + +## v5.0.6 + +- Show search error if input is not a date +- Group being imported into should be in state=INITIALIZING whilist being populated, and hide such groups in the UI +- Only add initially changed files in post-commit hook +- Fix to make sure the initial form data is properly shown on signup + +## v5.0.5 + +- sync whatsapp templates with unsupported languages, show them as such + +## v5.0.4 + +- Update to floweditor v1.5.15 +- Add pagination to outbox +- Fix import of contact field when field exists with same name but different key +- Fix (old) mac excel dates in imports + +## v5.0.3 + +- Update flow editor to 1.5.14 + +## v5.0.2 + +- Remove reference to webhook API page which no longer exists +- Update to flow-editor 1.5.12 +- Update some LS libs for security +- Tweaks to migrate_to_version_11_1 to handle "base" as a lang key +- Tweak old flow migrations to allow missing webhook_action and null ruleset labels + +## v5.0.1 + +- Fix max length for WA claim facebook_access_token +- Fix WhatsApp number formatting on contact page, add icon + +## v5.0.0 + +- add validation of localized messages to Travis + +## v4.27.3 + +- Make contact.is_test nullable +- Migration to remove orphaned schedules and changes to prevent creating them in future +- Migration to merge path counts from rules which are merged into a single exit in new engine + +## v4.27.2 + +- fix broadcast API test + +## v4.27.1 + +- temporarily increase throttling on broadcasts endpoint + +## v4.27.0 + +- Cleanup webhook fields left on Org +- Stop checking flow_server_enabled and remove support for editing it + +## v4.26.1 + +- Remove no longer used check_campaigns_task + +## v4.26.0 + +- Remove handling of incoming messages, channel events and campaigns.. all of which is now handled by mailroom + +## v4.25.0 + +- Add sentry error to handle_event_task as it shouldnt be handling anything +- Remove processing of timeouts which is now handled by mailroom +- Start broadcast mailroom tasks with HIGH_PRIORITY +- Fix EX settings page load +- Migration to convert any remaining orgs to use mailroom +- Fix broken links to webhook docs +- Simplify WebHookEvent model + +## v4.23.3 + +- Send broadcasts through mailroom +- Add org name in the email subject for exports +- Add org name in export filename + +## v4.24.0 + +- Add org name in the export email subject and filename +- Update flow editor to 1.5.9 +- Remove functionality for handling legacy surveyor submissions + +## v4.23.1 + +- Make exported fields match goflow representation and add .as_export_ref() to exportable classes +- Update to latest floweditor v1.5.5 +- Persist group and field definitions in exports +- Add support for SignalWire (https://signalwire.com) for SMS and IVR + +## v4.23.0 + +- Save channel and message label dependencies on flows + +## v4.22.63 + +- Update to latest floweditor v1.5.5 +- Allow switching between editors +- Update Django to version 2.1.9 + +## v4.22.62 + +- add US/ timezones for clicksend as well + +## v4.22.61 + +- add clicksend channel type + +## v4.22.60 + +- Update flow editor to 1.5.4 +- Allow imports and exports of v13 flows + +## v4.22.55 + +- Enable export of new flows +- Update Nexmo supported countries list + +## v4.22.54 + +- rename migration, better printing + +## v4.22.53 + +- add migration to repopulate metadata for all flows + +## v4.22.52 + +- Expose result specs in flow metadata on flows API endpoint +- Use Temba JSON adapter when reading JSON data from DB +- Don't update TwiML channel when claiming it +- Use most recent topup for credit transfers between orgs + +## v4.22.51 + +- Update to flow-editor 1.5.3 + +## v4.22.50 + +- Update to floweditor v1.5.2 + +## v4.22.49 + +- Only do mailroom validation on new flows + +## v4.22.48 + +- Fix 11.12 migration and importing flows when flow contains a reference to a channel in a different org +- Make WhatsApp endpoint configurable, either FB or self-hosted + +## v4.22.47 + +- tweak to WA language mapping + +## v4.22.46 + +- add hormuud channel type +- newest editor +- update invitation secret when user is re-invited + +## v4.22.45 + +- Tweak compress for vendor + +## v4.22.44 + +- Update to flow editor 1.4.18 +- Add mailroom endpoints for functions, tweak styles for selection +- Honor is_active when creating contact fields +- Cache busting for flow editor + +## v4.22.43 + +- Update flow editor to 1.4.17 +- Warn users when starting a flow when they have a WhatsApp channel that they should use templates + +## v4.22.42 + +- add page to view synched WhatsApp templates for a channel + +## v4.22.41 + +- Update flow editor to 1.4.16 +- View absolute attachments in old editor + +## v4.22.40 + +- Update editor to 1.4.14 + +## v4.22.39 + +- latest editor + +## v4.22.38 + +- update defs with db values both when writing and reading +- remove clearing of external ids for messages + +## v4.22.37 + +- Update to flow-editor 1.4.12 +- Remove footer gap on new editor + +## v4.22.36 + +- allow Alpha users to build flows in new editor +- don't use RuleSets in figuring results, exports, categories + +## v4.22.28 + +- Adjust `!=` search operator to include unset data +- Remove broadcast recipients table +- IMPORTANT \* You must make sure that all purged broadcasts have been archived using + rp-archiver v1.0.2 before deploying this version of RapidPro + +## v4.22.27 + +- styling tweaks to contacts page + +## v4.22.26 + +- Always show featured ContactFields on Contact.read page +- Do not migrate ruleset with label null and action msg text null + +## v4.22.25 + +- only show pagination warning when we have more than 10k results + +## v4.22.24 + +- support != search operator + +## v4.22.23 + +- simplify squashing of squashable models +- show a notification when users open the last page of the search +- update `modified_on` once msgs export is finished + +## v4.22.22 + +- Fix issue with pagination when editing custom fields + +## v4.22.21 + +- Add new page for contact field management + +## v4.22.20 + +- add management command to reactivate fb channels + +## v4.22.19 + +- api for templates, add access token and fb user id to claim, sync with facebook endpoint + +## v4.22.18 + +- fix recalculating event fires for fields when that field is created_on + +## v4.22.17 + +- Don't overwrite show_in_table flag on contact import +- Prevent updates of contact field labels when adding a field to a flow +- Add migration to populate results and waiting_exit_uuids in Flow.metadata + +## v4.22.15 + +- Do not immediately expire flow when updating expirations (leave that to mailroom) +- Fix boundary aliases duplicates creation +- Add org lock for users to deal with similtaneous updates of org users +- Add results and waiting_exit_uuids to flow metadata and start populating on Flow.update + +## v4.22.14 + +- CreateSubOrg needs to be non-atomic as well as it creates flows which need to be validated +- Remove unused download view + +## v4.22.13 + +- allow blank pack, update permissions + +## v4.22.12 + +- remove APK read view, only have update +- allow setting pack number + +## v4.22.11 + +- Add APK app and new Android claiming pipeline for Android Relayer + +## v4.22.10 + +- Use output of flow validation in mailroom to set flow dependencies +- Make message_actions.json API endpoint support partial updates +- Log to librato only pending messages older than a minute + +## v4.22.6 + +- Add Viber Welcome Message event type and config +- More customer support service buttons + +## v4.22.5 + +- queue incoming messages and incoming calls from relayer to mailroom + +## v4.22.4 + +- Temporarily disable flow validation until we can fix it for new orgs + +## v4.22.3 + +- Lazily create any dependent objects when we save +- MAILROOM_URL in settings.py.dev should default to http://localhost:8090 +- Call to mailroom to validate a flow before saving a new definition (and fix invalid flows in our tests) + +## v4.22.2 + +- Fix schedule next fire calculation bug when schedule is greater than number of days +- Fix to allow archiving flow for removed(inactive) campaign events +- Strip resthook slug during creation +- Ignore request from old android clients using GCM + +## v4.22.1 + +- Increase the schedule broadcast text max length to be consistent on the form + +## v4.22.0 + +- Fix case of single node flow with invalid channel reference +- Remove ChannelConnection.created_by and ChannelConnection.is_active +- Fix flow export results to include results from replaced rulesets + +## v4.21.15 + +- correct exclusion + +## v4.21.14 + +- Dont requeue flow server enabled msgs +- Exit sessions in bulk exit, ignore mailroom flow starts + +## v4.21.13 + +- Fix import with invalid channel reference +- Add flow migration to remove actions with invalid channel reference + +## v4.21.12 + +- improve simulator for goflow simulation + +## v4.21.11 + +- work around JS split to show simulator images + +## v4.21.10 + +- display attachments that are just 'image:' + +## v4.21.9 + +- simulator tweaks +- show Django warning if mailroom URL not configured + +## v4.21.8 + +- make sure we save flow_server_enabled in initialize + +## v4.21.7 + +- Update status demo view to match the current webhook posted data +- Remove all remaining reads of contact.is_test + +## v4.21.6 + +- Use pretty datetime on contact page for upcoming events + +## v4.21.5 + +- Replace final index which references contact.is_test +- Fix labels remap on flow import + +## v4.21.4 + +- All new orgs flow server enabled +- Fallback to org domain when no channe domain set + +## v4.21.3 + +- Remove all remaining checks of is_test, except where used in queries +- Update contact indexes to not include is_test +- Prevent users from updating dynamic groups if query is invalid +- Update Python module dependencies + +## v4.21.2 + +- set country code on test channel + +## v4.21.1 + +- do not log errors for more common exceptions + +## v4.21.0 + +- Include fake channel asset when simulating +- Add test for event retrying, fix out of date model +- Stop checking contact.is_test in db triggers + +## v4.20.1 + +- Remove unused fields on webhookevent +- Default page title when contact has no name or URN (e.g. a surveyor contact) + +## v4.19.7 + +- fix simulator to allow fields with empty value +- remove remaining usages of test contacts for testing + +## v4.19.6 + +- add incoming_extra flow to mailroom test +- fix for test contact deletion migration + +## v4.19.5 + +- pass extra to mailroom start task + +## v4.19.4 + +- Support audio/mp4 as playable audio +- Add migration to remove test contacts + +## v4.19.3 + +- Ensure scheduled triggers start flows in mailroom if enabled + +## v4.19.2 + +- remap incoming ivr endpoints for Twilio channels when enabling flow server +- interrupt flow runs when enabling flow server +- add enable_flow_server method to org, call in org update view + +## v4.19.1 + +- Scope API throttling by org and user +- Add export link on campaign read page +- Fix SMTP serever config to percentage encode slashes + +## v4.19.0 + +- Add session_type field on FlowSession +- Use provided flow definition when simulating if provided +- Remove USSD app completely +- Adjust broadcast status to API endpoint +- Remove legacy (non-mailroom) simulation + +## v4.18.0 + +- Make ChannelConnection.is_active nullable so it can be eventually removed +- Replace traceback.print_exc() with logger.error +- Make sure contacts ids are iterable when starting a flow +- Remove USSD proxy model + +## v4.17.0 + +- Use URL kwargs for channel logs list to pass the channel uuid +- Fix message campaign events on normal flows not being skipped +- Default to month first date format for US timezones +- Make Contact.created_by nullable +- Fix to prevent campaign event to create empty translations +- Use new editor wrapper to embed instead of building +- Remove USSD functionality from engine + +## v4.16.15 + +- Fix Stripe integration + +## v4.16.14 + +- fix webhook bodies to be json + +## v4.16.13 + +- better request logging for webhook results + +## v4.16.12 + +- further simplication of webhook result model, add new read and list pages + +## v4.16.11 + +- add org field to webhook results + +## v4.16.10 + +- Add surveyor content in mailroom_db command +- Fix flows with missing flow_type +- Update more Python dependencies +- Prevent flows of one modality from starting subflows of a different modality + +## v4.16.8 + +- Add support for Movile/Wavy channels +- Switch to codecov for code coverage +- Allow overriding brand domain via env +- Add mailroom_db management command for mailroom tests +- Start flow_server_enabled ivr flows in mailroom +- Remove legacty channel sending code +- Remove flow dependencies when deactivating USSD flows +- Migrations to deactivate USSD content + +## v4.16.5 + +- Fix quick replies in simulator + +## v4.16.4 + +- More teaks to Bongolive channel +- Use mailroom simulation for IVR and Surveyor flows +- Add a way to see all run on flow results runs table + +## v4.16.3 + +- Simplify generation of upload URLs with new STORAGE_URL setting + +## v4.16.2 + +- Switch BL channels used API +- Fix rendering of attachments for mailroom simulation +- Update black to the version 18.9b0 + +## v4.16.0 + +- Fix flow_entered event name in simulator +- Make created_by, modified_by on FlowStart nullable, add connections M2M on FlowStart +- Rename ChannelSession to ChannelConnection + +## v4.15.2 + +- Fix for flow dependency migration +- Fix rendering of single digit hours in pretty_datetime tag +- Use mailroom for flow migration instead of goflow +- Add support for Bongo Live channel type + +## v4.15.1 + +- Include default country in serialized environments used for simulation +- Add short_datetime and pretty_datetime tags which format based on org settings +- Prevent users from choosing flow they are editing in some cases + +## v4.15.0 + +- Fix nexmo claim +- Tweak 11.7 migration to not blow up if webhook action has empty URL +- Bump module minor versions and remove unused modules +- Remove ChannelSession.modified_by + +## v4.14.1 + +- Make older flow migrations more fault tolerant +- Tweaks to migrate_flows command to make error reporting more useful +- Add flow migration to fix duplicate rule UUIDs +- Update python-telegram-bot to 11.1.0 +- Update nexmo to 2.3.0 + +## v4.14.0 + +- Fix recent messages rollover with 0 messages +- Use flowserver only for flow migration +- Make created_by and modified_by optional on channel session + +## v4.13.2 + +- create empty revisions for empty flows +- proper handle of empty errors on index page +- fix error for policy read URL failing +- add quick replies to mailroom simulator + +## v4.13.1 + +- populate simulator environment for triggers and resumes +- honour Flow.is_active on the Web view +- fix android channel release to not throw if no FCM ID +- add Play Mobile aggregator + +## v4.13.0 + +- Add index for fast Android channel fetch by last seen +- Remove gcm_id field +- No messages sheet for flow results export on anon orgs +- Add periodic task to sync channels we have not seen for a while +- Add wait_started_on field to flow session + +## v4.12.6 + +- Remove flow server trialling +- Replace tab characters for GSM7 +- Use mailroom on messaging flows for simulation +- Raise ValidationError for ContactFields with null chars +- upgrade to Django 2.1 + +## v4.12.5 + +- Make sure Flow.update clears prefetched nodes after potentialy deleting them + +## v4.12.4 + +- Fix Flow.update not deleting nodes properly when they change type + +## v4.12.3 + +- Add try/except block on FCM sync +- Issue #828, remove numbers replace + +## v4.12.2 + +- Dont show queued scheduled broadcasts in outbox +- Prevent deleting groups with active campaigns +- Activate support for media attachment for Twitter channels +- Remove ability to create webhook actions in editor +- Add flow migration to replace webhook actions with rulesets + +## v4.12.1 + +- Fix importing campaign events based on created_om +- Fix event fires creation for immutable fields +- Remove WA status endpoint +- Fix IVR runs expiration date initialization +- Add UUID field to org + +## v4.11.7 + +- Interrupt old IVR calls and related flow sessions +- Move webhook docs button from the token view to the webhook view + +## v4.11.6 + +- Faster squashing +- Fix EX bulk sender form fields + +## v4.11.5 + +- simulate flow_server_enabled flows in mailroom + +## v4.11.3 + +- Add session log links to contact history for staff users +- Hide old webhook config page if not yet set + +## v4.11.2 + +- Fix passing false/true to archived param of flows API endpoint + +## v4.11.1 + +- Turn on the attachment support for VP channels +- Tweak 11.6 flow migration so that we remap groups, but never create them +- Flows API endpoint should support filtering by archived and type +- Log how many flow sessions are deleted and the time taken +- Turn on the attachment support for WA channels +- Adjust UI for adding quick replies and attachment in random order + +## v4.11.0 + +- Add index for fetching waiting sessions by contact +- Ensure test_db users have same username and email +- Add index to FlowSession.ended_on +- Make FlowSession.created_on non-null +- Add warning class to skipped campaigns event fire on contact history +- Add fired_result field to campaign event fires + +## v4.10.9 + +- Log and fail calls that cannot be started +- Allow contact.created_on in flows, init new event + +## v4.10.8 + +- Deactivate events when updating campaigns +- Less aggressive event fire recreation +- Use SMTP SERVER org config and migrate old config keys + +## v4.10.4 + +- Retry failed IVR calls + +## v4.10.3 + +- Show all split types on run results, use elastic for searching + +## v4.10.2 + +- Flow migration for mismatched group uuids in existing flows +- Remap group uuids on flow import +- Migration to backfill FlowSession.created_on / ended_on + +## v4.10.1 + +- Add config to specify content that should be present in the response of the request, if not mark that as msg failed +- Allow campaign events to be skipped if contacts already active in flows + +## v4.10.0 + +- Add FlowRun.parent_uuid +- Add FlowSession.timeout_on +- Create new flows with flow_server_enabled when org is enabled +- Add flow-server-enabled to org, dont deal with flow server enabled timeouts or expirations on rapidpro + +## v4.9.2 + +- Fix flowserver resume tests by including modified_on on runs sent to goflow + +## v4.9.1 + +- Dont set preferred channels if they can't send or call +- Don't assume events from goflow have step_uuid +- Add indexes for flow node and category count squashing + +## v4.9.0 + +- Delete event fires in bulk for inactive events +- Fix using contact language for categories when it's not a valid org language +- Fix translation of quick replies +- Add FlowSession.current_flow and start populating +- Refresh contacts list page after managing fields +- Update to latest goflow (no more caller events, resumes, etc) +- Fix flow results export to read old archive format +- Batch event fires by event ID and not by flow ID +- Make campaign events immutable + +## v4.8.1 + +- Add novo channel + +## v4.8.0 + +- Remove trialing of campaign events +- Remove no longer used ruleset_analytis.haml +- Expose @contact.created_on in expressions +- Make Contact.modified_by nullable and stop writing to it +- Optimize group releases +- Add created_on/ended_on to FlowSession + +## v4.7.0 + +- Bump Smartmin and Django versions +- Expose @contact.created_on in expressions +- Make Contact.modified_by nullable and stop writing to it + +## v4.6.0 + +- Latest goflow + +## v4.5.2 + +- Add config for deduping messages +- Add created_on/ended_on to FlowSession +- Update to latest goflow (event changes) +- Do not delete campaign events, deactivate them +- Do not delete runs when deleting a flow +- Fix Campaigns events delete for system flow + +## v4.5.1 + +- Use constants for queue names and switch single contact flow starts to use the handler queue +- Raise ValidationError if flow.extra is not a valid JSON +- Defer group.release in a background task +- Fix saving dynamic groups by reverting back to escapejs for contact group query on dialog + +## v4.5.0 + +- Add Stopped event to message history and unknown/unsupported events +- Switch result value to be status code from webhook rulesets, save body as @extra. and migrate result references to that + +## v4.4.20 + +- Fix channel selection for sending to TEL_SCHEME +- Add campaigns to all test orgs for make_db +- Correctly embed JS in templates +- Escape data before using `mark_safe` + +## v4.4.19 + +- Fix validating URNField when input isn't a string + +## v4.4.18 + +- Fix incorrect units in wehbook_stats +- Result input should always be a string + +## v4.4.17 + +- Don't do duplicate message check for surveyor messages which are already SENT +- Update to goflow 0.15.1 +- Update Location URLs to work with GADM IDs +- Fix potential XSS issue: embed script only if `View.refresh` is set + +## v4.4.16 + +- Fix IVR simulation + +## v4.4.15 + +- Fix importing with Created On columns +- Validate URNs during import +- Classify flow server trials as simple if they don't have subflows etc +- Use latest goflow for testing + +## v4.4.14 + +- Enable import of GADM data using import_geojson + +## v4.4.13 + +- Defer to mailroom for processing event fires for flows that are flowserver enabled +- Tweaks to comparing events during flow server trials +- Fix saved operand for group tests on anon orgs + +## v4.4.12 + +- Add step URN editor completions +- Add name to the channels shown on the flow editor +- Don't zero pad anon ids in context +- Update to latest expressions + +## v4.4.11 + +- Ensure API v1 writes are atomic +- JSONFields should use our JSON encoder +- Use authenticated user for events on Org.signup +- Trial shouldn't blow up if run has no events +- Add urn to step/message context and make urn scheme accessible for anon org +- Get rid of Flow.FLOW + +## v4.4.8 + +- Don't trial flow starts from triggers +- Fix messages from non-interactive subflows being added to their parent run +- Setup user tracking before creating an Org +- Migrate flows during flowserver trials with collapse_exits=false to keep paths exactly the same +- Input for a webhook result test should be a single request +- Migration to update F type flows to M + +## v4.4.7 + +- Enforce validation on OrgSignup and OrgGrant forms +- Cleanup encoding of datetimes in JSON +- New flows should be created with type M and rename constants for clarity + +## v4.4.6 + +- Fix updating dynamic groups on contact update from the UI +- Make editor agnostic to F/M flow types + +## v4.4.5 + +- Remove mage functionality +- Fix Twilio number searching + +## v4.4.2 + +- Use SystemContactFields for Dynamic Groups +- Add our own json module for loads, dumps, always preserve decimals and ordering +- Replace reads of Flow.flow_type=MESSAGE with Flow.is_system=True +- Migration to populate Flow.is_system based on flow_type + +## v4.4.0 + +- Fix intercom ResourceNotFound on Org.Signup +- Remove follow triggers and channel events +- Add Flow.is_system and start populating for new campaign event single message flows + +## v4.3.8 + +- Data migration to deactivate all old style Twitter channels +- Update Nexmo client + +## v4.3.4 + +- Increase IVR logging verbosity +- Trial all campaign message flows in flowserver +- Tweak android recommendation + +## v4.3.3 + +- Run Table should only exclude the referenced run, and include greater Ids +- Raise validation error ehen trying action inactive contacts over API +- Remove uservoice as a dependency +- Update versions of Celery, Postgis, Nexmo, Twilio +- Fix Python 3.7 issues +- Clear out archive org directory when full releasing orgs + +## v4.3.2 + +- Update expressions library to get EPOCH() function + +## v4.3.1 + +- Update to Django 2.0 +- Update postgres adapter to use psycopg2-binary + +## v4.3.0 + +- Wrap asset responses in a results object +- Use trigger type of campaign when starting campign event flows in flowserver +- Fix count for blocktrans to not use string from intcomma +- Use audio/mp4 content type for m4a files + +## v4.2.4 + +- Update to latest goflow and enable asset caching +- Actually fix uploading mp4 files + +## v4.2.2 + +- Show only user fields when updating field values for a contact +- Fix MIME type for M4A files +- Allow test_db command to work without having ES installed + +## v4.2.1 + +- Ignore search exceptions in omnibox +- Actually enable users to use system contact fields in campaign events + +## v4.2.0 + +- Enable users to choose 'system fields' like created_on for campaign events + +## v4.1.0 + +- Management commnd to recalculate node counts +- Fix run path triggers when paths are trimmed +- Allow file overwrite for public S3 uploads + +## v4.0.3 + +- Handle cases when surveyor submits run with deleted action set +- Document modified_on on our API endpoint +- Use ElasticSearch for the omnibox widget + +## v4.0.2 + +- fix count of suborgs after org deletion + +## v4.0.1 + +- remove group settings call for WhatsApp which is no longer supported +- easier way to service flows for CS reps + +## v4.0.0 + +- Squash all migrations + +## v3.0.1000 + +- fix display of archives formax on home page + +## v3.0.999 + +- Fix chatbase font icon name +- Add encoding config to EX channel type +- Show archive link and information on org page + +## v3.0.449 + +- Improve error message when saving surveyor run fails +- Allow surveyor submissions to match rules on old revisions +- Fix bug in msg export from archives + +## v3.0.448 + +- Support audio attachments in all the audio formats that we can play +- Add name and input to runs API v2 endpoint +- Update InGroup test to match latest goflow +- Expose resthooks over the assets endpoint and update logic to match new engine +- Support messages export from archives + +## v3.0.447 + +- Configure Celery to discover Wechat and Whatsapp tasks +- Add Rwanda and Nigeria to AT claim form options +- Extend timeout for archives links to 24h +- Add created_on to the contact export + +## v3.0.446 + +- Use constants for max contact fields and max group membership columns +- Tweaks to twitter activity claiming that deals with webhooks already being claimed, shows errors etc +- Rename form field to be consistent with the constants we use +- Writes only now use XLSLite, more coverage +- Limit number of groups for group memberships in results exports +- Swicth message export to use XLSLite +- Fix default ACL value for S3 files +- Add WeChat (for beta users) + +## v3.0.445 + +- fix dupe sends in broadcast action + +## v3.0.444 + +- fix per credit calculation + +## v3.0.443 + +- two decimals for per credit costs, remove trailing 0s + +## v3.0.442 + +- Fix ContactField priority on filtered groups +- Update Django to version 1.11.14 +- Reenable group broadcasts + +## v3.0.438 + +- When comparsing msg events in flowserver trials, make paths relative again +- Change VariableContactAction to create contacts even without URNs +- Fix import of ID columns from anon export +- Don't fail twilio channel releases if auth key is no longer vaild +- Add UI messaging for archived data + +## v3.0.437 + +- Fix import of header ID from anon export + +## v3.0.436 + +- Fix supported scheme display lookup +- Move action log delete to flow run release + +## v3.0.435 + +- Fix group test operand when contact name is null +- Mention all AfricasTalking countries on claim page +- Warn user of columns to remove on import +- Release events properly on campaign import +- Add languages endpoint to asset server + +## v3.0.434 + +- Add option for two day run expiration +- Change group rulesets to use contact as operand same as new engine +- Fix reconstructing sessions for runs being trialled in the flowserver so that we include all session runs + +## v3.0.433 + +- Write boolean natively when exporting to xlsx +- Improve reporting of flow server errors during trials +- Clarify about contact import columns +- Update flow result exports to match recent changes to contact exports + +## v3.0.432 + +- Update modified_on on contacts that have their URN stolen +- Full releasing of orgs and users + +## v3.0.431 + +- Set exit_uuid at end of path when run completes +- Make twitter activity API the default twitter channel type +- Add Nigeria and Rwanda to AT supported countries +- Don't exclude result input from flowserver trial result comparisons +- Use operand rather than msg text for result input +- Remove reporting to sentry when @flow.foo.text doesn't equal @step.text +- Add flow migration to replace @flow.foo.text expressions on non-waiting rulesets + +## v3.0.430 + +- Fix message flow updating + +## v3.0.429 + +- Remove org.is_purgeable +- Fix format of archived run json to match latest rp-archiver +- Fix checking of result.text values in the context +- Import/Export column headers with type prefixes +- Add groups membership to contacts exports +- Retry calls that are in IVRCall.RETRY_CALL +- Retry IVR outgoing calls if contact did not answer + +## v3.0.428 + +- Add FlowRun.modified_on to results exports +- Change how we select archives for use in run exports to avoid race conditions +- Report to sentry when @flow.foo.text doesn't match @step.text + +## v3.0.427 + +- Release webhook events on run release +- Fetch run results from archives when exporting results +- Don't create action logs for non-test contacts + +## v3.0.426 + +- Migrations for FK protects, including all SmartModels +- Update to latest xlsxlite to fix exporting date fields +- Remove merged runs sheet from results exports +- Modified the key used in the transferto API call + +## v3.0.425 + +- Enable burst sms type + +## v3.0.424 + +- add burst sms channel type (Australia and New Zealand) + +## v3.0.423 + +- trim event fires every 15 minutes + +## v3.0.422 + +- Trim event fires older than a certain age +- More consistent name of date field on archive model +- Remove no longer needed functionality for runs that don't have child_context/parent_context set + +## v3.0.421 + +- Degroup contacts on deactivate + +## v3.0.420 + +- release sessions on reclaimed urns + +## v3.0.419 + +- special case deleted scheme in urn parsing +- release urn messages when releasing a contact +- add delete reason to run + +## v3.0.418 + +- Clear child run parent reference when releasing parent +- Make sync events release their alerts +- Release sessions, anonymize urns + +## v3.0.417 + +- add protect to contacts and flows, you can fake the migrations in this release + +## v3.0.416 + +- add deletion_date, use full path as link name +- add unique constraint to disallow dupe archives + +## v3.0.415 + +- add needs_deletion field, remove is_purged + +## v3.0.414 + +- Set run.child_context when child has no waits +- Use latest openpyxl and log the errors to sentry +- Don't blow up if trialled run has no events +- Allow editors to see archives / api +- Migration to backfill run parent_context and child_context + +## v3.0.412 + +- Fix archive filter test +- Include id when serializing contacts for goflow + +## v3.0.411 + +- Show when build failed becuse black was not executed +- Fix calculation of low threshold for credits to consider only the top with unused credits +- All flows with subflows to be trialled in the flowserver +- Create webhook mocks for use in flowserver trials from webhook results +- Enable Archive list API endpoint + +## v3.0.410 + +- Remove purging, add release with delete_reason +- Set parent_context in Flow.start and use it in FlowRun.build_expressions_context if available +- Add is_archived counts for LabelCounts and SystemLabelCounts, update triggers + +## v3.0.409 + +- Remove explicit use of uservoice +- Use step_uuids for recent message calculation + +## v3.0.408 + +- Format code with blackify +- Add management commands to update consent status and org membership +- Update to latest goflow to fix tests +- Fix 'raise None' in migration and make flow server trial period be 15 seconds +- Fix the campaign events fields to be datetime fields +- Move flow server stuff from utils.goflow to flows.server +- Add messangi channel type + +## v3.0.407 + +- Reenable requiring policy consent +- Allow msgs endpoint to return ALL messages for an org sorted by created_on +- Return error message if non-existent asset requested from assets endpoint +- If contact sends message whilst being started in a flow, don't blow up +- Remove option to have a flow never expire, migrate current flows with never to 30 days instead +- Request the user to fill the LINE channel ID and channel name on the claim form + +## v3.0.406 + +- Fix logging events to intercom + +## v3.0.405 + +- Migration to remove FlowStep + +## v3.0.404 + +- remove old privacy page in favor of new policy app +- use python3 `super` method +- migration to backfill step UUIDs on recent runs + +## v3.0.403 + +- tweaks to add_analytics users + +## v3.0.402 + +- add native intercom support, add management command to update all users + +## v3.0.401 + +- Fix quick replies in simulator +- Lower the min length for Facebook page access token +- Update Facebook claim to ask for Page ID and Page name from the user +- Add new policies and consent app +- Fix another migration that adds a field and writes to it in same transaction +- Add step UUID fields to FlowPathRecentRun and update trigger on run paths to start populating them + +## v3.0.400 + +- Don't create flow steps +- Remove remaining usages of six + +## v3.0.399 + +- Drop no longer used FlowRun.message_ids field +- Don't allow nested flowserver trials +- Fix migrations which can lead to locks because they add a field and populate it in same transaction +- Remove a lot of six stuff +- Use bulk_create's returned msgs instead of forcing created_on to be same for batches of messages created by Broadcast.send +- Use sent_on for incoming messages's real world time +- Don't require steps for flow resumptions + +## v3.0.398 + +- Add period, rollup fields to archive + +## v3.0.397 + +- Stop writing .recipients when sending broadcasts as this is only needed for purged broadcasts +- Rework run_audit command to check JSON fields and not worry about steps +- Replace json_date_to_datetime with iso8601.parse_date +- Stepless surveyor runs + +## v3.0.396 + +- Use run path instead of steps to recalculate run expirations +- Stop writing to FlowRun.message_ids + +## v3.0.395 + +- Change FlowRun.get_last_msg to use message events instead of FlowRun.message_ids +- Stop saving message associations with steps + +## v3.0.393 + +- Drop values_value + +## v3.0.392 + +- Remove broadcast purging + +## v3.0.391 + +- remove reference to nyaruka for trackings users +- fix test decoration to work when no flow server configured + +## v3.0.390 + +- Disable webhook calls during flowserver trials +- Use FlowRun.events for recent messages rollovers + +## v3.0.389 + +- add archive model, migrations + +## v3.0.388 + +- Make ContactField header clickable when sorting +- Add first python2 incompatible code change +- Add contact groups sheet on contact exports +- Remove contact export as CSV +- Update to latest goflow +- Fix test_db contact fields serialization + +## v3.0.387 + +- fix flowstarts migration + +## v3.0.386 + +- update start contact migration to work with malformed extra + +## v3.0.384 + +- fix not selecting contact id from ES in canary task + +## v3.0.383 + +- add canary task for elasticsearch +- record metrics about flowserver trial to librarto +- allow sorting of contact fields via dragging in manage dialog + +## v3.0.382 + +- rename flow migration + +## v3.0.381 + +- limit number of flows exited at once, order by expired_on to encourage index +- remove python 2.7 build target in travis +- start flow starts in the flows queue vs our global celery one +- add flow start count model to track # of runs in a flow start +- Always use channel.name for channel assets + +## v3.0.380 + +- update to latest goflow to get location support +- better output logs for goflow differences + +## v3.0.379 + +- add v2 editor through /v2 command in simulator + +## v3.0.378 + +- get all possible existing Twilio numbers on the Twilio account +- reenable group sends \* +- remove Value model usage, Contact.search + +## v3.0.377 + +- do not allow dupe broadcasts to groups +- Use ElasticSearch to export contacts and create dynamic groups +- remove celery super auto scaler +- update whatsapp activation by setting rate limits using new endpoints +- fix incorrect keys for tokens and account sids for twiml apps +- add ability to test flow results against goflow + +## v3.0.376 + +- remove celery super auto scaler since we don't use it anywhere +- update whatsapp activation by setting rate limits using new endpoints +- fix incorrect keys for tokens and account sids for twiml apps +- add admin command to help audit ES and DB discrepencies + +## v3.0.375 + +- update whatsapp for new API +- new index on contacts_contact.fields optimized for space + +## v3.0.374 + +- allow reading, just not writing of sends with groups +- remove old seaching from contact views + +## v3.0.373 + +- optimize group views +- don't allow sends to groups to be imported or copied +- remove normal junebug, keep only junebug ussd +- fix isset/~isset, sort by 'modified_on_mu' in ES +- use ES to search for contacts + +## v3.0.372 + +- remap sms and status Twilio urls, log people still calling old ones +- fix to display Export buttons on sent msgs folder and failed msgs folder +- use message events in run.events for results exports instead of run.message_ids + +## v3.0.371 + +- add twilio messaging handling back in + +## v3.0.370 + +- remove logging of base handler being called + +## v3.0.369 + +- rename contact field types of decimal to number +- finalize contact imports so that updated contacts have modified_on outside transaction +- try to fetch IVR recordings for up to a minute before giving up +- remove handling and sendind code for all channel types (except twitter and junebug) + +## v3.0.368 + +- Fewer sentry errors from ES searching +- Don't assume messages have a UUID in FlowRun.add_messages + +## v3.0.367 + +- allow up to two minutes for elastic search lag + +## v3.0.366 + +- fix empty queryset case for ES comparison + +## v3.0.365 + +- chill the f out with sentry if the first contact in our queryset is less than 30 seconds old +- fix duplicate messages when searching on msgs whose contacts have more than one urn + +## v3.0.364 + +- fix environment variable for elastic search, catch all exceptions + +## v3.0.363 + +- Add Elastic searching for contacts, for now only validating that results through ES are the same as through postgres searches + +## v3.0.361 + +- Migrate Dart/Hub9 Contact urns and channels to support ext schemes + +## v3.0.360 + +- Use more efficient queries for check channels task +- Fix Location geojson import + +## v3.0.359 + +- Add API endpoint to view failed messages + +## v3.0.358 + +- Allow filtering by uuid on runs API endpoint, and include run uuid in webhooks +- Fix blockstrans failing on label count + +## v3.0.357 + +- Add linear backdown for our refresh rate on inbox pages + +## v3.0.356 + +- Do not log MageHandler calls +- Serialize contact field label as name instead + +## v3.0.355 + +- Use force_text on uuids read from redis +- Log errors for any channel handler methods + +## v3.0.354 + +- Set placeholder msg.id = 0 +- Fix comparison when price is None + +## v3.0.353 + +- Evaluate contact field with no value as False + +## v3.0.352 + +- Update to Facebook graph api v2.12 + +## v3.0.351 + +- Support plain ISO dates (not just datetimes) + +## v3.0.350 + +- Swallow exceptions encountered when parsing, don't add to group +- Set placeholder msg.id = 0 + +## v3.0.349 + +- Deal with null state values in contact search evaluation + +## v3.0.348 + +- Fix off by one error in calculating best channel based on prefixes +- Reevaluate dynamic groups using local contact fields instead of SQL + +## v3.0.347 + +- Add modified_on index for elasticsearch + +## v3.0.346 + +- Don't start archived flows +- Don't show stale dates on campaign events +- Allow brands to configure flow types +- Remove group search from send to others action +- Fixes for test contact activity + +## v3.0.345 + +- Migration to backfill run.events and add step uuids to run.path +- Do the right thing when we are presented with NaN decimals + +## v3.0.344 + +- Use real JSONField for FlowRun.events +- Add FlowRun.events and start populating with msg events for new runs +- Serialize Contact.fields in test_db +- Update to latest goflow release + +## v3.0.342 + +- Fix for decimal values in JSON fields attribute +- Fix for not being able to change contact field types if campaign event inactive + +## v3.0.341 + +- Add if not exists to index creation for fields +- Last of Py3 compatibility changes + +## v3.0.340 + +- Use fields JSON field on Contact instead of Value table for all reading. +- Force campaign events to be based off of DateTime fields +- Migration to change all contact fields used in campaign events to DateTime +- Migration to add GIN index on Contact.fields + +## v3.0.339 + +- Remove leading and trailing spaces on location string before boundaries path query +- Require use of update_fields with Contact.save() +- Event time of contact_changed is when contact was modified +- Use latest goflow release +- Make special channel accessible during simulator use + +## v3.0.338 + +- Always serialize contact field datetime values in the org timezone +- Add migration for population of the contact field json + +## v3.0.336 + +- Update middlewares to Django defaults for security +- Add JSON fields to Contact, set in set_field +- backfill any null location paths, make not null, update import to set path, set other levels on fields when setting location + +## v3.0.335 + +- Allow groups when scheduling flows or triggers +- Fix configuration page URLs and use courier URLs +- Replace contact.channel in goflow serialization with a channel query param in each contact URN +- Serialize contact.group_uuids as groups with name and UUID + +## v3.0.334 + +- Add response to external ID to courier serialized msg if we have response to +- More Py3 migration work +- Remove broadcasting to groups from Send Message dialog + +## v3.0.332 + +- Do not delete RuleSets only disconnect them from flows + +## v3.0.331 + +- Fix scoping for sim show/hide + +## v3.0.330 + +- Allow toggling of new engine on demand with /v2 command in simulator + +## v3.0.329 + +- Fix negative cache ttl for topups + +## v3.0.328 + +- Remove Vumi Type +- Remove custom autoscaler for Celery +- Implement Plivo without Plivo library + +## v3.0.325 + +- Build dynamic groups in background thread +- Dynamic Channel changes, use uuids in URLs, allow custom views +- Allow WhatsApp channels to refresh contacts manually +- Allow brands to specifiy includes for the document head +- Fix external claim page, rename auth_urn for courier +- Change VB channel type to be a dynamic channel +- Remove unused templates + +## v3.0.324 + +- Add ability to run select flows against a flowserver instance + +## v3.0.323 + +- Move JioChat access creation to channel task +- Use 'list()' on python3 dict iterators +- Use analytics-python===1.2.9, python3 compatible +- Fix using PlayAction in simulator and add tests +- Fix HasEmailTest to strip surrounding punctuation +- ContainsPhraseTest shouldn't blow up if test string is empty +- Use 'six' library for urlparse, urlencode + +## v3.0.322 + +- Unfreeze phonenumbers library so we always use latest +- Remove old Viber VI channel type +- Add config template for LN channel type +- Move configuration blurbs to channel types +- Move to use new custom model JSONAsTextField where appropriate + +## v3.0.321 + +- Fix quick-reply button in flow editor + +## v3.0.320 + +- Fix webhook rule as first step in run interpreting msg wrong +- Change mailto URN importing to use header 'mailto' and make 'email' always a field. Rename 'mailto' fields to 'email'. + +## v3.0.319 + +- Add ArabiaCell channel type +- Tweaks to Mtarget channel type +- Pathfix for highcharts + +## v3.0.318 + +- Add input to webhook payload + +## v3.0.317 + +- Remove support for legacy webhook payload format +- Fix org-choose redirects for brands + +## v3.0.316 + +- Remove stop endpoint for MT + +## v3.0.315 + +- Inactive flows should not be listed on the API endpoint +- Add Mtarget channel type + +## v3.0.314 + +- Add run dict to default webhook payload + +## v3.0.313 + +- have URNs resolve to dicts instead of just the display +- order transfer credit options by name +- show dashboard link even if org is chosen + +## v3.0.312 + +- include contact URN in webhook payload + +## v3.0.311 + +- Allow exporting results of archived flows +- Update Twitter Activity channels to work with latest beta changes +- Increase maximum attachment URL length to 2048 +- Tweak contact searching so that set/not-set conditions check the type specific column +- Migration to delete value decimal/datetime instances where string value is "None" +- Don't normalize nulls in @extra as "None" +- Clear timeouts for msgs which dont have credits assigned to them +- Simpler contact get_or_create method to lookup a contact by urn and channel +- Prevent updating name for existing contact when we receive a message +- Remove fuzzy matching for ContainsTest + +## v3.0.310 + +- Reimplement clickatell as a Courier only channel against new API + +## v3.0.309 + +- Use database trigger for inserting new recent run records +- Handle stop contact channel events +- Remove no longer used FlowPathRecentRun model + +## v3.0.308 + '# Enter any comments for inclusion in the CHANGELOG on this revision below, you can use markdown - * Update date for webhook change on api docs - * Don't use flow steps for calculating test contact activity - -v3.0.307 ----------- - * Stop using FlowPathRecentMessage - -v3.0.306 ----------- - * Migration to convert recent messages to recent runs - -v3.0.305 ----------- - * Add new model for tracking recent runs - * Add dynamic group optimization for new contacts - -v3.0.304 ----------- - * Drop index on FlowStep.step_uuid as it's no longer needed - -v3.0.303 ----------- - * Still queue messages for sending when interrupted by a child - -v3.0.302 ----------- - * Use FlowRun.current_node_uuid for sending to contacts at a given flow node - -v3.0.301 ----------- - * Tweak process_message_task to not blow up if message doesn't exist - * Use FlowRun.message_ids for flow result exports - -v3.0.300 ----------- - * Use config secret instead of secret field on Channel - * Add tests for datetime contact API field update - -v3.0.299 ----------- - * Fix deleting resthooks - * Fix quick replies UI on Firefox - -v3.0.298 ----------- - * Process contact queue until there's a pending message or empty - * Make date parsing much stricter - * Migration to fix run results which were numeric but parsed as dates - * Use transaction when creating contact URN - * Add support for v2 webhooks - -v3.0.294 ----------- - * Fix run.path trigger to not blow up deleting old steps that don't have exit_uuids - * Define MACHINE_HOSTNAME for librato metrics - -v3.0.293 ----------- - * Fix handle_ruleset so we don't continue the run if a child has exited us - * Migration to backfill FlowRun.message_ids and .current_node_uuid (recommend faking and running manually) - -v3.0.292 ----------- - * Add support for 'direct' db connection - * Stop updating count and triggered on on triggers - * Add FlowRun.current_node_uuid and message_ids - * Catch IntegrityError and lookup again when creating contact URN - * Make sure we dont allow group chats in whatsapp - -v3.0.291 ----------- - * Ignore TMS callbacks - -v3.0.289 ----------- - * Stop writing values in flows to values_value - -v3.0.287 ----------- - * Performance improvements and simplications to flow result exports - * Add some extra options to webhook_stats - * Migration to convert old recent message records - -v3.0.286 ----------- - * Remove incomplete path counts - -v3.0.285 ----------- - * Migrate languages on campaign events - * Rework flow path count trigger to use exit_uuid and not record incomplete segments - -v3.0.282 ----------- - * Don't import contacts with unknown iso639-3 code - * Make angular bits less goofy for quick replies and webhooks - * Add is_active index on flowrun - * Don't disassociate channels from orgs when they're released - * Include language column in Contact export - -v3.0.281 ----------- - * Set tps for nexmo and whatsapp - * Dont overwrite name when receiving a message from a contact that already exists - * Flow start performance improvements - -v3.0.280 ----------- - * Parse ISO dates followed by a period - * Optimize batch flow starts - -v3.0.279 ----------- - * Update Nexmo channels to use new Courier URLs - * Store path on AdminBoundary for faster lookups - * Serialize metata for courier tasks (quick replies support) - * Add default manager to AdminBoundary which doesn't include geometry - -v3.0.278 ----------- - * Fixes to the ISO639-3 migration - * Add support for quick replies - -v3.0.277 ----------- - * Add flow migration for base_language in flow definitions - -v3.0.276 ----------- - * back down to generic override if not found with specific code - * Add esp-spa as exception - -v3.0.275 ----------- - * Fix language migrations - -v3.0.274 ----------- - * Fix serialization of 0 decimal values in API - * Add initial version of WhatsApp channel (simple messaging only) - * Migrate to iso639-3 language codes (from iso639-2) - * Remove indexes on Msg, FlowRun and FlowStep which we don't use - * Remove fields no longer used on org model - -v3.0.273 ----------- - * Don't blow up when a flow result doesn't have input - -v3.0.272 ----------- - * Fix parsing ISO dates with negative offsets - -v3.0.271 ----------- - * Serialize contact field values with org timezone - -v3.0.270 ----------- - * Load results and path from new JSON fields instead of step/value objects on API runs endpoint - -v3.0.269 ----------- - * Fix campaign export issue - * Disable legacy analytics page - * Change date constants and contact fields to use full/canonical format in expressions context - -v3.0.265 ----------- - * Fix not updating versions on import flows - * Require FlowRun saves to use update_fields - * Rework get_results to use FlowRun.results - * Don't allow users to save dynamic groups with 'id' or 'name' attributes - * Add flow version 11.0, create migration to update references to contact fields and flow fields - -v3.0.264 ----------- - * Show summary for non-waits on flow results - * Reduce number of queries during flow handling - -v3.0.263 ----------- - * Start campaigns in separate task - * Enable flow results graphs on flow result page - * Fix run table json parsing - * SuperAutoScaler! - -v3.0.262 ----------- - * Use string comparison to optimize temba_update_flowcategorycount - * Allow path counts to be read by node or exit - * SuperAutoscaler - * Fix inbox views so we don't look up channel logs for views that don't have them - * Add management command for analyzing webhook calls - * Change recent message fetching to work with either node UUID or exit UUID - -v3.0.261 ----------- - * Migrate revisions forward with rev version - * Limit scope of squashing so we can recover from giant unsquashed numbers - -v3.0.260 ----------- - * Make tests go through migration - * Set version number of system created flows - * Block saving old versions over new versions - * Perform apply_topups as a task, tweak org update form - * Updates to credit caches to consider expiration - * Tweak credit expiration email - -v3.0.259 ----------- - * Improve performance and restartability of run.path backfill migration - * Update to latest smartmin - * Use run.results for run results page - -v3.0.258 ----------- - * Set brand domain on channel creations, use for callbacks - -v3.0.257 ----------- - * Migration to populate run paths (timeconsuming, may want to fake aand run manually) - * Ensure actions have UUIDs in single message and join-group flows - * Flow migration command shouldn't blow up if a single flow fails - -v3.0.255 ----------- - * Fix Twilio to redirect to twilio claim page after connecting Twilio - * Add FlowRun.path and start populating it for new flow steps - * Removes no longer used Msg.has_template_error field - -v3.0.254 ----------- - * Use get_host() when calculating signature for voice callbacks - -v3.0.253 ----------- - * use get_host() when validating IVR requests - -v3.0.252 ----------- - * Better Twilio channel claiming - -v3.0.250 ----------- - * Tweaks to recommended channels display - -v3.0.246 ----------- - * Update smartmin to version 1.11.4 - * Dynamic channels: Chikka, Twilio, Twilio Messaging Service and TwiML Rest API - -v3.0.245 ----------- - * Tweaks to the great FlowRun results migration for better logging and for parallel migrations - * Fixes us showing inactive orgs in nav bar and choose page - * Ignore requests missing text for incoming message from Infobip - -v3.0.244 ----------- - * Add exit_uuid to all flow action_sets (needed for goflow migrations) - -v3.0.243 ----------- - * Add index to FlowPathRecentMessage - * Flows API endpoint should filter out campaign message flow type - * Add archived field to campaings API endpoint - * Fix to correctly substitute context brand variable in dynamic channel blurb - -v3.0.242 ----------- - * Data migration to populate results on FlowRun (timeconsuming, may want to fake and run manually) - -v3.0.239 ----------- - * Migration to increase size of category count - -v3.0.238 ----------- - * Increase character limits on category counts - -v3.0.237 ----------- - * Fix Nexmo channel link - * Add results field to FlowRun and start populating - * Add FlowCategoryCount model for aggregating flow results - * Remove duplicate USSD channels section - -v3.0.234 ----------- - * Remove single message flows when events are deleted - -v3.0.233 ----------- - * Remove field dependencies on flow release, cleanup migration - * Update to latest Django 1.11.6 - -v3.0.232 ----------- - * Mage handler shouldn't be accessible using example token in settings_common - * Make Msg.has_template_error nullable and stop using it - -v3.0.231 ----------- - * Add claim page for dmark for more prettiness - * Add management command to migrate flows forward - * Add flow migration for partially localized single message flows - * Recalculate topups more often - * Add dmark channel (only can send and receive through courier) - * Merge pull request #1522 from nyaruka/headers - * Replace TEMBA_HEADERS with http_headers() - * Improve mock server used by tests so it can mock specifc url with specific responses - * Add method to get active channels of a particular channel type category - * Replace remaining occurrences of assertEquals - * Fix the way to check USSD support - * Dynamic channels: Vumi and Vumi USSD - -v3.0.230 ----------- - * Deal with malformed group format as part of group updates - * Allow installs to configure how many fields they want to keep in @extra - * Fix Nexmo icon - * Add logs for incoming requests for InfoBip - * Do both Python 2 and 3 linting in a single build job - -v3.0.229 ----------- - * Do not set external ID for InfoBip we have send them our ID - * Fix channel address comparison to be insensitive to + - * Use status groupId to check from the InfoBip response to know if the request was erroneous - -v3.0.228 ----------- - * Add id to reserved field list - -v3.0.227 ----------- - * Update Infobip channel type to use the latest JSON API - * Migrate flows forward to have dependencies - -v3.0.226 ----------- - * Fix issue with dates in the contact field extractor - * Allow org admin to remove invites - -v3.0.225 ----------- - * Optimize how we check for unsent messages on channels - * Ensure all actions have a UUID in new flow spec version 10.1 - * Fixes viber URN validation: can be up to 24 chars - * Dynamic channels: Zenvia, YO - * Add support for minor flow migrations - -v3.0.224 ----------- - * Remove duplicate excellent includes (only keep compressed version) - -v3.0.222 ----------- - * Only show errors in UI when org level limits of groups etc are exceeded - * Improve error messages when org reaches limit of groups etc - -v3.0.221 ----------- - * Add indexes for retying webhook events - -v3.0.220 ----------- - * Remove no longer used Msg.priority (requires latest Mage) - -v3.0.219 ----------- - * Create channel event only for active channels - * Limit SMS Central channel type to the Kathmandu timezone - * Create fields from expressions on import - * Flow dependencies for fields, groups, and flows - * Dynamic channels: Start - * Dynamic channels: SMS Central - -v3.0.218 ----------- - * Delete simulation messages in batch of 25 to use the response_to index - * Fix Kannel channel type icon - * @step.contact and @contact should both be the run contact - * Migration to set value_type on all RuleSets - -v3.0.217 ----------- - * Add page titles for common pages - * New index for contact history - * Exit flows in batches so we dont have to grab all runs at once - * Check we can create a new groups before importing contact and show the error message to the user - * Fixes value type guessing on rulesets (we had zero typed as dates) - * Update po files - * Dynamic channels: Shaqodoon - -v3.0.216 ----------- - * Should filter user groups by org before limiting to 250 - * Fixes for slow contact history - * Allow updating existing fields via API without checking the count - * Update TWIML IVR protocol check - * Add update form fields in dynamic channel types - * Abstract out the channel update view form classes - * Add ivr_protocol field on channel type - * Mock constants to not create a lot of objects in test DB - * Limit the contact fields max per org to 200 to below the max form post fields allowed - * Limit number of contact groups creation on org to 250 - * Limit number of contact fields creation on org to 250 - * Dynamic channels: Red Rabbit, Plivo Nexmo - -v3.0.212 ----------- - * Make Msg.priority nullable so courier doesn't have to write to it - * Calculate TPS cost for messages and add them to courier queues - * Fix truncate cases in SQL triggers - * Fix migration to recreate trigger on msgs table - * Dynamic channels: Mblox - -v3.0.211 ----------- - * Properly create event fires for campaign events updated through api - * Strip matched string in not empty test - * Dynamic channels: Macrokiosk - -v3.0.210 ----------- - * Make message priority be based on responded state of flow runs - * Support templatized urls in media - * Add UI for URL Attachments - * Prevent creation of groups and labels at flow run time - * Dynamic channels: M3Tech, Kannel, Junebug and Junebug USSD - -v3.0.209 ----------- - * Add a way to specify the prefixes short codes should be matching - * Include both high_priority and priority in courier JSON - * Fix TwiML migration - * Fix JSON response when searching Plivo numbers - -v3.0.208 ----------- - * Msg.bulk_priority -> Msg.high_priority - * Change for currencies for numeric rule - * Dynamic channels for Jasmin, Infobip, and Hub9 - -v3.0.207 ----------- - * Fix Twiml config JSON keys - * Unarchiving a campaign should unarchive all its flows - -v3.0.206 ----------- - * Fix broken Twilio Messaging Service status callback URL - * Only update dynamic groups from set_field if value has changed - * Optimize how we lookup contacts for some API endpoints - * More dynamic channels - -v3.0.205 ----------- - * add way to show recommended channel on claim page for dynamic channels - * change Org.get_recommended_channel to return the channel type instead of a random string - -v3.0.204 ----------- - * separate create and drop index operations in migration - -v3.0.203 ----------- - * create new compound index on channel id and external id, remove old external id index - * consistent header for contact uuid in exports and imports - * unstop contacts in handle message for new messages - * populate @extra even on webhook failures - * fix flow simulator with chatbase connected - * use ContactQL for name of contact querying grammar - * dynamic channels: Clickatell - * fix contact searching where text includes + or / chars - * replace Ply with ANTLR for contact searching (WIP) - -v3.0.201 ----------- - * Make clean string method replace non characteres correctly - -v3.0.200 ----------- - * Support Telegram /start command to trigger new conversation trigger - -v3.0.199 ----------- - * Use correct Twilio callback URL, status is for voice, keep as handler - -v3.0.198 ----------- - * Add /c/kn/uuid-uuid-uuid/receive style endpoints for all channel types - * Delete webhook events in batches - * Dynamic channels: Blackmyna - -v3.0.197 ----------- - * update triggers so that updates in migration work - -v3.0.196 ----------- - * make sure new uuids are honored in in_group tests - * removes giant join through run/flow to figure out flow steps during export - * create contacts from start flow action with ambiguous country - * add tasks for handling of channel events, update handlers to use ChannelEvent.handle - * add org level dashboard for multi-org organizations - -v3.0.195 ----------- - * Tweaks to allow message handling straight from courier - -v3.0.193 ----------- - * Add flow session model and start creating instances for IVR and USSD channel sessions - -v3.0.192 ----------- - * Allow empty contact names for surveyor submissions but make them null - * Honor admin org brand in get_user_orgs - * Fix external channel bulk sender URL - * Send broadcast in the same task as it is created in and JS utility method to format number - * Try the variable as a contact uuid and use its contact when building recipients - * Fix org lookup, use the same code path for sending a broadcast - * Fix broadcast to flow node to consider all current contacts on the the step - -v3.0.191 ----------- - * Update test_db to generate deterministic UUIDs which are also valid UUID4 - -v3.0.190 ----------- - * Turn down default courier TPS to 10/s - -v3.0.189 ----------- - * Make sure msg time never wraps in the inbox - -v3.0.188 ----------- - * Use a real but mockable HTTP server to test flows that hit external URLs instead of mocking the requests - * Add infobip as dynamic channel type and Update it to use the latest Infobip API - * Add support for Courier message sending - -v3.0.183 ----------- - * Use twitter icon for twitter id urns - -v3.0.182 ----------- - * Tweak test_start_flow_action to test parent run states only after child runs have completed - * Stop contacts when they have only an invalid twitter screen name - * Change to max USSD session length - -v3.0.181 ----------- - * Ignore case when looking up twitter screen names - -v3.0.180 ----------- - * Switch to using twitterid scheme for Twitter messages - * Should be shipped before Mage v0.1.84 - -v3.0.179 ----------- - * Allow editing of start conversation triggers - -v3.0.178 ----------- - * Remove urn field, urn compound index, remove last uses of urn field - -v3.0.177 ----------- - * remove all uses of urn (except when writing) - * create display index, backfill identity - * Allow users to specify extra URNs columns to include on the flow results export - -v3.0.176 ----------- - * Add display and identity fields to ContactURN - * Add schemes field to allow channels to support more than one scheme - -v3.0.175 ----------- - * Fix incorrect lambda use so message sending works - -v3.0.174 ----------- - * Make ContactField.uuid unique and non-null - -v3.0.173 ----------- - * Add migration to populate ContactField.uuid - -v3.0.172 ----------- - * Only try to delete Twilio app when channel config contains 'application_sid' - * Surveyor submissions should try rematching the rules if the same ruleset got updated by the user and old rules were removed - * Add uuid field to ContactField - * Convert more channel types to dynamic types - -v3.0.171 ----------- - * Fixes for Twitter Activity channels - * Add stop contact command to mage handler - * Convert Firebase Cloud Messaging to a dynamic channel type - * Convert Viber Public to a dynamic channel type - * Change to the correct way for dynamic channel - * Convert LINE to a dynamic channel type - * Better message in SMS alert email - -v3.0.170 ----------- - * Hide SMTP config password and do not change the set password if blank is submitted - * Validate the length of message campaigns for better user feedback - * Make FlowRun.uuid unique and non-null (advise faking this and building index concurrently) - -v3.0.169 ----------- - * Migration to populate FlowRun.uuid. Advise faking this and running manually. - * More channel logs for Jiochat channel interactions - -v3.0.167 ----------- - * Fix inclusion of attachment urls in webhook payloads and add tests - * Install lxml to improve performance of large Excel exports - * Add proper deactivation of Telegram channels - * Converted Facebook and Telegram to dynamic channel types - * Add nullable uuid field to FlowRun - * Make sure we consider all URN schemes we can send to when looking up the if we have a send channel - * Split Twitter and Twitter Beta into separate channel types - * Remove support for old-style Twilio endpoints - -v3.0.166 ----------- - * Release channels before Twilio/Nexmo configs are cleared - * Expose flow start UUID on runs from the runs endpoint - -v3.0.165 ----------- - * Migration to populate FlowStart.uuid on existing objects (advise faking and run manually) - -v3.0.163 ----------- - * Add uuid field to FlowStart - * Migration to convert TwiML apps - -v3.0.160 ----------- - * Add support for Twitter channels using new beta Activity API - -v3.0.159 ----------- - * Clean incoming message text to remove invalid chars - -v3.0.158 ----------- - * Add more exception currencies for pycountry - * Support channel specific Twilio endpoints - -v3.0.156 ----------- - * Clean up pip-requires and reset pip-freeze - -v3.0.155 ----------- - * Reduce the rate limit for SMS central to 1 requests per second - * Display Jiochat on channel claim page - * Fix date pickers on modal forms - * Update channels to generate messages with multiple attachments - -v3.0.154 ----------- - * Rate limit sending throught SMS central to 10 messages per second - * Fix some more uses of Context objects no longer supported in django 1.11 - * Fix channel log list request time display - * Add @step.text and @step.attachments to message context - -v3.0.153 ----------- - * Jiochat channels - * Django 1.11 - -v3.0.151 ----------- - * Convert all squashable and prunable models to use big primary keys - -v3.0.150 ----------- - * Drop database-level length restrictions on msg and values - * Add sender ID config for Macrokiosk channels - * Expose org credit information on API org endpoint - * Add contact_uuid parameter to update FCM user - * Add configurable webhook header fields - -v3.0.148 ----------- -* Fix simulator with attachments -* Switch to using new recent messages model - -v3.0.147 ----------- - * Migration to populate FlowPathRecentMessage - * Clip messages to 640 chars for recent messages table - -v3.0.145 ----------- - * Change Macrokiosk time format to not have space - * Better error message for external channel handler for wrong time format - * Add new model for tracking recent messages on flow path segments - -v3.0.144 ----------- - * Remove Msg.media field that was replaced by Msg.attachments - * Change default ivr timeouts to 2m - * Fix the content-type for Twilio call response - -v3.0.143 ----------- - * Update contact read page and inbox views to show multiple message attachments - * Fix use of videojs to provide consistent video playback across browsers - * API should return error message if user provides something unparseable for a non-serializer param - -v3.0.142 ----------- - * Fix handling of old msg structs with no attachments attribute - * Tweak in create_outgoing to prevent possible NPEs in flow execution - * Switch to using Msg.attachments instead of Msg.media - * Replace index on Value.string_value with one that is limited to first 32 chars - -v3.0.139 ----------- -* Fix Macrokiosk JSON responses - -v3.0.138 ----------- - * Migration to populate attachments field on old messages - -v3.0.137 ----------- - * Don't assume event fires still exist in process_fire_events - * Add new Msg.attachments field to hold multiple attachments on an incoming message - -v3.0.136 ----------- - * Fix scheduled broadcast text display - -v3.0.135 ----------- - * Make 'only' keyword triggers ignore punctuation - * Make check_campaigns_task lock on the event fires that it will queue - * Break up flow event fires into sub-batches of 500 - * Ignore and ack incoming messages from Android relayer that have no number - -v3.0.134 ----------- - * Add match_type option to triggers so users can create triggers which only match when message only contains keyword - * Allow Africa's talking to retry sending message - * Allow search on the triggers pages - * Clear results for analytics when user removes a flow run - -v3.0.133 ----------- - * Make Msg.get_sync_commands more efficent - * Fix open range airtime transfers - * Fix multiple Android channels sync - * Fix parsing of macrokiosk channel time format - * Ensure that our select2 boxes show "Add new" option even if there is a partial match with an existing item - * Switch to new translatable fields and remove old Broadcast fields - * Add Firebase Cloud messaging support for Android channels - -v3.0.132 ----------- - * Migration to populate new translatable fields on old broadcasts. This migration is slow on a large database so it's - recommended that large deployments fake it and run it manually. - -v3.0.128 ----------- - * Add new translatable fields to Broadcast and ensure they're populated for new stuff - -v3.0.127 ----------- - * Fix autocomplete for items containing digits or other items - * Make autocomplete dropdown disappear when user clicks in input box - * Replace usages of "SMS" with "message" in editor - * Allow same subflow to be called without pause in between - -v3.0.126 ----------- - * Fix exporting messages by a label folder - * Improve performance of org export page for large orgs - * Make it easier to enable/disable debug toolbar - * Increase channel logging for requests and responses - * Change contact api v1 to insert nonexistent fields - * Graceful termination of USSD sessions - -v3.0.125 ----------- - * Don't show deleted flows on list page - * Convert timestamps sent by MacroKiosk from local Kuala Lumpur time - -v3.0.124 ----------- - * Move initial IVR expiration check to status update on the call - * Hide request time in channel log if unset - * Check the existance of broadcast recipients before adding - * Voice flows import should never allow expirations longer than 15 mins - * Fix parse location to correctly use the tokenizized text if the location was matched for the entire text - * Use updates instead of full Channel saves() on realyer syncs, only update when there are changes - -v3.0.123 ----------- - * Use flow starts for triggers that operate on groups - * Handle throttling errors from Nexmo when using API to add new numbers - * Convert campaign event messages to HSTORE fields - -v3.0.121 ----------- - * Add MACROKIOSK channel type - * Show media for MMS in simulator - -v3.0.120 ----------- - * Fix send all bug where we append list of messages to another list of messages - * Flows endpooint should allow filtering by modified_on - -v3.0.119 ----------- - * More vertical form styling tweaks - -v3.0.118 ----------- - * Add flow link on subflow rulesets in flows - -v3.0.117 ----------- - * Fix styling on campaign event modal - -v3.0.116 ----------- - * Update to latest Raven - * Make default form vertical, remove horizontal to vertical css overrides - * Add flow run search and deletion - * Hangup calls on channels release - -v3.0.115 ----------- - * Allow message exports by label, system label or all messages - * Fix for double stacked subflows with immediate exits - -v3.0.112 ----------- - * Archiving a flow should interrupt all the current runs - -v3.0.111 ----------- - * Display webhook results on contact history - * Clean up template tags used on contact history - * Allow broadcasts to be sent to all urns belonging to the specified contacts - -v3.0.109 ----------- - * Data migration to populate broadcast send_all field - -v3.0.108 ----------- - * Add webhook events trim task with configurable retain times for success and error logs - -v3.0.107 ----------- - * Add send_all broadcast field - -v3.0.106 ----------- - * Remove non_atomic_gets and display message at /api/v1/ to explain API v1 has been replaced - * Add squashable model for label counts - * Split system label functionality into SystemLabel and SystemLabelCount - -v3.0.105 ----------- - * Link subflow starts in actions - * Allow wait to wait in flows with warning - -v3.0.104 ----------- - * Add new has email test, contains phrase test and contains only phrase test - -v3.0.103 ----------- - * Migration to populate FlowNodeCount shouldn't include test contacts - -v3.0.102 ----------- - * Add migration to populate FlowNodeCount - -v3.0.101 ----------- - * Migration to clear no-longer-used flow stats redis keys - * Replace remaining cache-based flow stats code with trigger based FlowNodeCount - -v3.0.100 ----------- - * Fix intermittently failing Twilio test - * make sure calls have expiration on initiation - * Update to latest smartmin - * Add redirection for v1 endpoints - * Fix webhook docs - * Fix MsgCreateSerializer not using specified channel - * Test coverage - * Fix test coverage issues caused by removing API v1 tests - * Ensure surveyor users still have access to the API v2 endpoint thats they need - * Remove djangorestframework-xml - * Restrict API v1 access to surveyor users - * Block all API v2 writes for suspended orgs - * Remove all parts of API v1 not used by Surveyor - -v3.0.99 ----------- - * Prioritize msg handling over timeotus and event fires - * Remove hamlcompress command as deployments should use regular compress these days - * Fix not correctly refreshing dynamic groups when a URN is removed - * Allow searching for contacts *with any* value for a given field - -v3.0.98 ----------- - * Fix sidebar nav LESS so that level2 lists don't have fixed height and separate scrolling - * Unstop a contact when we get an explicit user interaction such as follow - -v3.0.96 ----------- - * Fix possible race condition between receiving and handling messages - * Do away with scheme for USSD, will always be TEL - * Make sure events are handled properly for USSD - * Do not specify to & from when using reply_to - * Update JunebugForm for editing Junebug Channel + config fields - -v3.0.95 ----------- - * Log request time on channel log success - -v3.0.94 ----------- - * Fix test, fix template tags - -v3.0.93 ----------- - * Change request times to be in ms instead of seconds - -v3.0.92 ----------- - * Block on handling incoming msgs so we dont process them forever away - * Include Viber channels in new conversation trigger form channel choices - -v3.0.90 ----------- - * Don't use cache+calculations for flow segment counts - these are pre-calculated in FlowPathCount - * Do not include active contacts in flows unless user overrides it - * Clean up middleware imports and add tests - * Feedback to user when simulating a USSD channel without a USSD channel connected - -v3.0.89 ----------- - * Expand base64 charset, fix decode validity heuristic - -v3.0.88 ----------- - * Deal with Twilio arbitrarily sending messages as base64 - * Allow configuration of max text size via settings - -v3.0.87 ----------- - * Set higher priority when sending responses through Kannel - -v3.0.86 ----------- - * Do not add stopped contacts to groups when importing - * Fix an entire flow start batch failing if one run throws an exception - * Limit images file size to be less than 500kB - * Send Facebook message attachments in a different request as the text message - * Include skuid for open range tranfertto accounts - -v3.0.85 ----------- - * Fix exception when handling Viber msg with no text - * Migration to remove no longer used ContactGroup.count - * Fix search queries like 'foo bar' where there are more than one condition on name/URN - * Add indexes for Contact.name and ContactURN.path - * Replace current omnibox search function with faster and simpler top-25-of-each-type approach - -v3.0.84 ----------- - * Fix Line, FCM icons, add Junebug icon - -v3.0.83 ----------- - * Render missing field and URN values as "--" rather than "None" on Contact list page - -v3.0.82 ----------- - * Add ROLE_USSD - * Add Junebug USSD Channel - * Fix Vumi USSD to use USSD Role - -v3.0.81 ----------- - * Archive triggers that do not have a contact to send to - * Disable sending of messages for blocked and stopped contacts - -v3.0.80 ----------- - * Add support for outbound media on reply messages for Twilio MMS (US, CA), Telegram, and Facebook - * Do not throw when viber sends us message missing the media - * Optimizations around Contact searching - * Send flow UUID with webhook flow events - -v3.0.78 ----------- - * Allow configuration of max message length to split on for External channels - -v3.0.77 ----------- - * Use brand key for evaluation instead of host when determining brand - * Add red rabbit type (hidden since MT only) - * Fix flow results exports for broadcast only flows - -v3.0.76 ----------- - * Log Nexmo media responses without including entire body - -v3.0.75 ----------- - * Dont encode to utf8 for XML and JSON since they expect unicode - * Optimize contact searching when used to determine single contact's membership - * Use flow system user when migrating flows, avoid list page reorder after migrations - -v3.0.74 ----------- - * reduce number of lookup to DB - -v3.0.73 ----------- - * Add test case for search URL against empty field value - * Fix sending vumi messages initiated from RapidPro without response to - -v3.0.72 ----------- - * Improvements to external channels to allow configuration against JSON and XML endpoints - * Exclude test contacts from flow results - * Update to latest smartmin to fix empty string searching - -v3.0.70 ----------- - * Allow USSD flows to start someone else in a flow - * Include reply to external_id for Vumi channel - -v3.0.69 ----------- - * Add ID column to result exports for anon orgs - * Deactivate runs when releasing flows - * Fix urn display for call log - * Increased send and receive channel logging for Nexmo, Twilio, Twitter and Telegram - * Allow payments through Bitcoins - * Include TransferTo account currency when asking phone info to TransferTo - * Don't create inbound messages for gather timeouts, letting calls expire - * Don't show channel log for inactive channels on contact history - * Upgrade to latest smartmin which changes created_on/modified_on fields on SmartModels to be overridable - * Uniform call and message logs - -v3.0.64 ----------- - * Add ID column to anonymous org contact exports, also add @contact.id field in message context - * Fix counts for channel log elements - * Only have one link on channel page for sending log - * Attempt to determine file types for msg attachments using libmagic - * Deactivate runs on hangups, Keep ivr runs open on exit - * Add log for nexmo media download - * Add new perf_test command to run performance tests on database generated with make_test_db - -v3.0.62 ----------- - * Fix preferred channels for non-msg channels - -v3.0.61 ----------- - * Make migrations to populate new export task fields non-atomic - * Add indexes for admin boundaries and aliases - * Nexmo: make sure calls are ended on hangup, log hangups and media - * Fix inbound calls on Nexmo to use conversation_uuid - * Style tweaks for zapier widget - * Use shorter timeout for IVR - * Issue hangups on expiration during IVR runs - * Catch all exceptions and log them when initiating call - * Fix update status for Nexmo calls - -v3.0.48 ----------- - * Add channel session log page - * Use brand variable for zaps to show - * Additional logging for nexmo - * Increase non-overlap on timeout queueing, never double queue single timeout - * Fix broken timeout handling when there is a race - * Make field_keys a required parameter - * Speed up the contact import by handling contact update at once after all the fields are set - -v3.0.47 ----------- - * Add channel log for Nexmo call initiation - * Fix import-geojson management command - -v3.0.46 ----------- - * Fix Contact.search so it doesn't evaluate the base_query - * Enable searching in groups and blocked/stopped contacts - -v3.0.45 ----------- - * Fix absolute positioning for account creation form - * Add Line channel icon in fonts - * Add data migrations to update org config to connect to Nexmo - -v3.0.43 ----------- - * Add Malawi as a country for Africa's Talking - -v3.0.42 ----------- - * Widen pages to browser width so more can fit - * Fix the display of URNs on contact list page - * Fix searching of Nexmo number on connected accounts - -v3.0.41 ----------- - * Fix channel countries being duplicated for airtime configuration - * Add make_sql command to generate SQL files for an app, reorganize current SQL reference files - * Added SquashableModel and use it for all squashable count classes - -v3.0.40 ----------- - * Add support for Nexmo IVR - * Log IVR interactions in Channel Log - -v3.0.37 ----------- - * Fix to make label of open ended response be All Response even if there is timeout on the ruleset - * Data migration to rename category for old Values collected with timeouts - -v3.0.36 ----------- - * Add 256 keys to @extra, also enforce ordering so it is predictible which are included - * Make fetching flow run stats more efficient and expose number of active runs on flow run endpoint - * Migration to populate session on msg and ended_on where it is missing - -v3.0.35 ----------- - * Offline context per brand - -v3.0.34 ----------- - * Add Junebug channel type - * Better base styling for dev project - * Pass charset parameter to Kannel when sending unicode - * Zero out minutes, seconds, ms for campaign events with set delivery horus - * Add other URN types to contact context, return '' if missing, '*' mask for anon orgs - * Make sure Campaigns export base_language for simple message events, honor on import - -v3.0.33 ----------- - * Change ansible command run on vagrant up from syncdb to migrate - * Remove no longer needed django-modeltranslation - * Keep up to 256 extra keys from webhooks instead of 128 - * Add documentation of API rate limiting - -v3.0.32 ----------- - * Make styling variables uniform across branding - * Make brand styling optional - -v3.0.28 ----------- - * Add support for subflows over IVR - -v3.0.27 ----------- - * Fix searching for Twilio numbers, add unit tests - * Fix API v1 run serialization when step messages are purged - -v3.0.26 ----------- - * Adds more substitutions from accented characters to gsm7 plain characters - -v3.0.25 ----------- - * Populate ended_on for ivr calls - * Add session foreign key to Msg model - -v3.0.24 ----------- - * Fix bug in starting calls from sessions - -v3.0.23 ----------- - * Remove flow from ChannelSession, sessions can span many runs/flows - * Remove superfluous channelsession.parent - -v3.0.22 ----------- - * Migration to update existing twiml apps with a status_callback, remove api/v1 references - -v3.0.21 ----------- - * Various tweaks to wording and presentation around custom SMTP email config - -v3.0.20 ----------- - * Allow orgs to set their own SMTP server for outgoing emails - * Return better error message when To number not passed to Twilio handler - * Exclude Flow webhook events from retries (we try once and forget) - * Don't pass channel in webhook events if we don't know it - * Use JsonResponse and response.json() consistently - * Replace json.loads(response.content) with response.json() which properly decodes on Python 3 - -v3.0.19 ----------- - * Improve performance of contact searches by location by fetching locations in separate query - -v3.0.18 ----------- - * Update pyparsing to 2.1.10 - * Update to new django-hamlpy - * Display flow runs exits on the contact timeline - * Fix Travis settings file for Python 3 - * Fix more Python 3 syntax issues - * Fix RecentMessages no longer supporting requests with multiple rules, and add tests for that - * Use print as function rather than statement for future Python 3 compatibility - * Do not populate contact name for anon orgs from Viber - * Add is_squashed to FlowPathCount and FlowRunCount - * Updates to using boto3, if using AWS for storing imports or exports you'll need to change your settings file: `DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'` - -v3.0.14 ----------- - * Allow for the creation of Facebook referral triggers (opt-in on FB) - * Allow for whitelisting of domains for Facebook channels - -v3.0.13 ----------- - * New contact field editing UI with Intercooler modals - -v3.0.9 ----------- - * Update RecentMessages view to use new recent messages model - * Remove now unused indexes on FlowStep - -v3.0.8 ----------- - * Adds data migration to populate FlowPathRecentStep from existing Flow Steps - -v3.0.7 ----------- - * Introduce new model, FlowPathRecentStep that tracks recent steps from one flow node to another. This will replace the rather expensive index used to show recent flow activity on a flow path. - -v3.0.10 ----------- - * Log any exceptions encountered in Celery tasks to Raven - * Tell user to get pages_messaging_subscriptions permission for their FB app - -v3.0.6 ----------- - * Replace unicode non breaking spaces with a normal space for GSM7 encoding (Kannel only) - * Add migrations for custom indexes (existing installs before v3 should fake these) - -v3.0.5 ----------- - * fix styling on loader ball animation - -v3.0.4 ----------- - * Fix issue causing flow run table on flow dashboard to be very slow if a flow contained many responses - -v3.0.3 ----------- - * Refactor JSON responses to use native Django JSONResponse - * Dont use proxy for Dart Media and Hub9, expose IPs to whitelist - -v3.0.2 ----------- - * Fixes DartMedia channel for short codes - -v3.0.1 ----------- - * Remove django-celery as it is unneeded, also stop saving Celery tombstones as we now store - all task state (ContactImport for example) directly in models - -v3.0.0 ----------- - * IMPORTANT: This release resets all Temba migrations. You need to run the latest migrations - from a version preceding this one, then fake all temba migrations when deploying: + +- Update date for webhook change on api docs +- Don't use flow steps for calculating test contact activity + +## v3.0.307 + +- Stop using FlowPathRecentMessage + +## v3.0.306 + +- Migration to convert recent messages to recent runs + +## v3.0.305 + +- Add new model for tracking recent runs +- Add dynamic group optimization for new contacts + +## v3.0.304 + +- Drop index on FlowStep.step_uuid as it's no longer needed + +## v3.0.303 + +- Still queue messages for sending when interrupted by a child + +## v3.0.302 + +- Use FlowRun.current_node_uuid for sending to contacts at a given flow node + +## v3.0.301 + +- Tweak process_message_task to not blow up if message doesn't exist +- Use FlowRun.message_ids for flow result exports + +## v3.0.300 + +- Use config secret instead of secret field on Channel +- Add tests for datetime contact API field update + +## v3.0.299 + +- Fix deleting resthooks +- Fix quick replies UI on Firefox + +## v3.0.298 + +- Process contact queue until there's a pending message or empty +- Make date parsing much stricter +- Migration to fix run results which were numeric but parsed as dates +- Use transaction when creating contact URN +- Add support for v2 webhooks + +## v3.0.294 + +- Fix run.path trigger to not blow up deleting old steps that don't have exit_uuids +- Define MACHINE_HOSTNAME for librato metrics + +## v3.0.293 + +- Fix handle_ruleset so we don't continue the run if a child has exited us +- Migration to backfill FlowRun.message_ids and .current_node_uuid (recommend faking and running manually) + +## v3.0.292 + +- Add support for 'direct' db connection +- Stop updating count and triggered on on triggers +- Add FlowRun.current_node_uuid and message_ids +- Catch IntegrityError and lookup again when creating contact URN +- Make sure we dont allow group chats in whatsapp + +## v3.0.291 + +- Ignore TMS callbacks + +## v3.0.289 + +- Stop writing values in flows to values_value + +## v3.0.287 + +- Performance improvements and simplications to flow result exports +- Add some extra options to webhook_stats +- Migration to convert old recent message records + +## v3.0.286 + +- Remove incomplete path counts + +## v3.0.285 + +- Migrate languages on campaign events +- Rework flow path count trigger to use exit_uuid and not record incomplete segments + +## v3.0.282 + +- Don't import contacts with unknown iso639-3 code +- Make angular bits less goofy for quick replies and webhooks +- Add is_active index on flowrun +- Don't disassociate channels from orgs when they're released +- Include language column in Contact export + +## v3.0.281 + +- Set tps for nexmo and whatsapp +- Dont overwrite name when receiving a message from a contact that already exists +- Flow start performance improvements + +## v3.0.280 + +- Parse ISO dates followed by a period +- Optimize batch flow starts + +## v3.0.279 + +- Update Nexmo channels to use new Courier URLs +- Store path on AdminBoundary for faster lookups +- Serialize metata for courier tasks (quick replies support) +- Add default manager to AdminBoundary which doesn't include geometry + +## v3.0.278 + +- Fixes to the ISO639-3 migration +- Add support for quick replies + +## v3.0.277 + +- Add flow migration for base_language in flow definitions + +## v3.0.276 + +- back down to generic override if not found with specific code +- Add esp-spa as exception + +## v3.0.275 + +- Fix language migrations + +## v3.0.274 + +- Fix serialization of 0 decimal values in API +- Add initial version of WhatsApp channel (simple messaging only) +- Migrate to iso639-3 language codes (from iso639-2) +- Remove indexes on Msg, FlowRun and FlowStep which we don't use +- Remove fields no longer used on org model + +## v3.0.273 + +- Don't blow up when a flow result doesn't have input + +## v3.0.272 + +- Fix parsing ISO dates with negative offsets + +## v3.0.271 + +- Serialize contact field values with org timezone + +## v3.0.270 + +- Load results and path from new JSON fields instead of step/value objects on API runs endpoint + +## v3.0.269 + +- Fix campaign export issue +- Disable legacy analytics page +- Change date constants and contact fields to use full/canonical format in expressions context + +## v3.0.265 + +- Fix not updating versions on import flows +- Require FlowRun saves to use update_fields +- Rework get_results to use FlowRun.results +- Don't allow users to save dynamic groups with 'id' or 'name' attributes +- Add flow version 11.0, create migration to update references to contact fields and flow fields + +## v3.0.264 + +- Show summary for non-waits on flow results +- Reduce number of queries during flow handling + +## v3.0.263 + +- Start campaigns in separate task +- Enable flow results graphs on flow result page +- Fix run table json parsing +- SuperAutoScaler! + +## v3.0.262 + +- Use string comparison to optimize temba_update_flowcategorycount +- Allow path counts to be read by node or exit +- SuperAutoscaler +- Fix inbox views so we don't look up channel logs for views that don't have them +- Add management command for analyzing webhook calls +- Change recent message fetching to work with either node UUID or exit UUID + +## v3.0.261 + +- Migrate revisions forward with rev version +- Limit scope of squashing so we can recover from giant unsquashed numbers + +## v3.0.260 + +- Make tests go through migration +- Set version number of system created flows +- Block saving old versions over new versions +- Perform apply_topups as a task, tweak org update form +- Updates to credit caches to consider expiration +- Tweak credit expiration email + +## v3.0.259 + +- Improve performance and restartability of run.path backfill migration +- Update to latest smartmin +- Use run.results for run results page + +## v3.0.258 + +- Set brand domain on channel creations, use for callbacks + +## v3.0.257 + +- Migration to populate run paths (timeconsuming, may want to fake aand run manually) +- Ensure actions have UUIDs in single message and join-group flows +- Flow migration command shouldn't blow up if a single flow fails + +## v3.0.255 + +- Fix Twilio to redirect to twilio claim page after connecting Twilio +- Add FlowRun.path and start populating it for new flow steps +- Removes no longer used Msg.has_template_error field + +## v3.0.254 + +- Use get_host() when calculating signature for voice callbacks + +## v3.0.253 + +- use get_host() when validating IVR requests + +## v3.0.252 + +- Better Twilio channel claiming + +## v3.0.250 + +- Tweaks to recommended channels display + +## v3.0.246 + +- Update smartmin to version 1.11.4 +- Dynamic channels: Chikka, Twilio, Twilio Messaging Service and TwiML Rest API + +## v3.0.245 + +- Tweaks to the great FlowRun results migration for better logging and for parallel migrations +- Fixes us showing inactive orgs in nav bar and choose page +- Ignore requests missing text for incoming message from Infobip + +## v3.0.244 + +- Add exit_uuid to all flow action_sets (needed for goflow migrations) + +## v3.0.243 + +- Add index to FlowPathRecentMessage +- Flows API endpoint should filter out campaign message flow type +- Add archived field to campaings API endpoint +- Fix to correctly substitute context brand variable in dynamic channel blurb + +## v3.0.242 + +- Data migration to populate results on FlowRun (timeconsuming, may want to fake and run manually) + +## v3.0.239 + +- Migration to increase size of category count + +## v3.0.238 + +- Increase character limits on category counts + +## v3.0.237 + +- Fix Nexmo channel link +- Add results field to FlowRun and start populating +- Add FlowCategoryCount model for aggregating flow results +- Remove duplicate USSD channels section + +## v3.0.234 + +- Remove single message flows when events are deleted + +## v3.0.233 + +- Remove field dependencies on flow release, cleanup migration +- Update to latest Django 1.11.6 + +## v3.0.232 + +- Mage handler shouldn't be accessible using example token in settings_common +- Make Msg.has_template_error nullable and stop using it + +## v3.0.231 + +- Add claim page for dmark for more prettiness +- Add management command to migrate flows forward +- Add flow migration for partially localized single message flows +- Recalculate topups more often +- Add dmark channel (only can send and receive through courier) +- Merge pull request #1522 from nyaruka/headers +- Replace TEMBA_HEADERS with http_headers() +- Improve mock server used by tests so it can mock specifc url with specific responses +- Add method to get active channels of a particular channel type category +- Replace remaining occurrences of assertEquals +- Fix the way to check USSD support +- Dynamic channels: Vumi and Vumi USSD + +## v3.0.230 + +- Deal with malformed group format as part of group updates +- Allow installs to configure how many fields they want to keep in @extra +- Fix Nexmo icon +- Add logs for incoming requests for InfoBip +- Do both Python 2 and 3 linting in a single build job + +## v3.0.229 + +- Do not set external ID for InfoBip we have send them our ID +- Fix channel address comparison to be insensitive to + +- Use status groupId to check from the InfoBip response to know if the request was erroneous + +## v3.0.228 + +- Add id to reserved field list + +## v3.0.227 + +- Update Infobip channel type to use the latest JSON API +- Migrate flows forward to have dependencies + +## v3.0.226 + +- Fix issue with dates in the contact field extractor +- Allow org admin to remove invites + +## v3.0.225 + +- Optimize how we check for unsent messages on channels +- Ensure all actions have a UUID in new flow spec version 10.1 +- Fixes viber URN validation: can be up to 24 chars +- Dynamic channels: Zenvia, YO +- Add support for minor flow migrations + +## v3.0.224 + +- Remove duplicate excellent includes (only keep compressed version) + +## v3.0.222 + +- Only show errors in UI when org level limits of groups etc are exceeded +- Improve error messages when org reaches limit of groups etc + +## v3.0.221 + +- Add indexes for retying webhook events + +## v3.0.220 + +- Remove no longer used Msg.priority (requires latest Mage) + +## v3.0.219 + +- Create channel event only for active channels +- Limit SMS Central channel type to the Kathmandu timezone +- Create fields from expressions on import +- Flow dependencies for fields, groups, and flows +- Dynamic channels: Start +- Dynamic channels: SMS Central + +## v3.0.218 + +- Delete simulation messages in batch of 25 to use the response_to index +- Fix Kannel channel type icon +- @step.contact and @contact should both be the run contact +- Migration to set value_type on all RuleSets + +## v3.0.217 + +- Add page titles for common pages +- New index for contact history +- Exit flows in batches so we dont have to grab all runs at once +- Check we can create a new groups before importing contact and show the error message to the user +- Fixes value type guessing on rulesets (we had zero typed as dates) +- Update po files +- Dynamic channels: Shaqodoon + +## v3.0.216 + +- Should filter user groups by org before limiting to 250 +- Fixes for slow contact history +- Allow updating existing fields via API without checking the count +- Update TWIML IVR protocol check +- Add update form fields in dynamic channel types +- Abstract out the channel update view form classes +- Add ivr_protocol field on channel type +- Mock constants to not create a lot of objects in test DB +- Limit the contact fields max per org to 200 to below the max form post fields allowed +- Limit number of contact groups creation on org to 250 +- Limit number of contact fields creation on org to 250 +- Dynamic channels: Red Rabbit, Plivo Nexmo + +## v3.0.212 + +- Make Msg.priority nullable so courier doesn't have to write to it +- Calculate TPS cost for messages and add them to courier queues +- Fix truncate cases in SQL triggers +- Fix migration to recreate trigger on msgs table +- Dynamic channels: Mblox + +## v3.0.211 + +- Properly create event fires for campaign events updated through api +- Strip matched string in not empty test +- Dynamic channels: Macrokiosk + +## v3.0.210 + +- Make message priority be based on responded state of flow runs +- Support templatized urls in media +- Add UI for URL Attachments +- Prevent creation of groups and labels at flow run time +- Dynamic channels: M3Tech, Kannel, Junebug and Junebug USSD + +## v3.0.209 + +- Add a way to specify the prefixes short codes should be matching +- Include both high_priority and priority in courier JSON +- Fix TwiML migration +- Fix JSON response when searching Plivo numbers + +## v3.0.208 + +- Msg.bulk_priority -> Msg.high_priority +- Change for currencies for numeric rule +- Dynamic channels for Jasmin, Infobip, and Hub9 + +## v3.0.207 + +- Fix Twiml config JSON keys +- Unarchiving a campaign should unarchive all its flows + +## v3.0.206 + +- Fix broken Twilio Messaging Service status callback URL +- Only update dynamic groups from set_field if value has changed +- Optimize how we lookup contacts for some API endpoints +- More dynamic channels + +## v3.0.205 + +- add way to show recommended channel on claim page for dynamic channels +- change Org.get_recommended_channel to return the channel type instead of a random string + +## v3.0.204 + +- separate create and drop index operations in migration + +## v3.0.203 + +- create new compound index on channel id and external id, remove old external id index +- consistent header for contact uuid in exports and imports +- unstop contacts in handle message for new messages +- populate @extra even on webhook failures +- fix flow simulator with chatbase connected +- use ContactQL for name of contact querying grammar +- dynamic channels: Clickatell +- fix contact searching where text includes + or / chars +- replace Ply with ANTLR for contact searching (WIP) + +## v3.0.201 + +- Make clean string method replace non characteres correctly + +## v3.0.200 + +- Support Telegram /start command to trigger new conversation trigger + +## v3.0.199 + +- Use correct Twilio callback URL, status is for voice, keep as handler + +## v3.0.198 + +- Add /c/kn/uuid-uuid-uuid/receive style endpoints for all channel types +- Delete webhook events in batches +- Dynamic channels: Blackmyna + +## v3.0.197 + +- update triggers so that updates in migration work + +## v3.0.196 + +- make sure new uuids are honored in in_group tests +- removes giant join through run/flow to figure out flow steps during export +- create contacts from start flow action with ambiguous country +- add tasks for handling of channel events, update handlers to use ChannelEvent.handle +- add org level dashboard for multi-org organizations + +## v3.0.195 + +- Tweaks to allow message handling straight from courier + +## v3.0.193 + +- Add flow session model and start creating instances for IVR and USSD channel sessions + +## v3.0.192 + +- Allow empty contact names for surveyor submissions but make them null +- Honor admin org brand in get_user_orgs +- Fix external channel bulk sender URL +- Send broadcast in the same task as it is created in and JS utility method to format number +- Try the variable as a contact uuid and use its contact when building recipients +- Fix org lookup, use the same code path for sending a broadcast +- Fix broadcast to flow node to consider all current contacts on the the step + +## v3.0.191 + +- Update test_db to generate deterministic UUIDs which are also valid UUID4 + +## v3.0.190 + +- Turn down default courier TPS to 10/s + +## v3.0.189 + +- Make sure msg time never wraps in the inbox + +## v3.0.188 + +- Use a real but mockable HTTP server to test flows that hit external URLs instead of mocking the requests +- Add infobip as dynamic channel type and Update it to use the latest Infobip API +- Add support for Courier message sending + +## v3.0.183 + +- Use twitter icon for twitter id urns + +## v3.0.182 + +- Tweak test_start_flow_action to test parent run states only after child runs have completed +- Stop contacts when they have only an invalid twitter screen name +- Change to max USSD session length + +## v3.0.181 + +- Ignore case when looking up twitter screen names + +## v3.0.180 + +- Switch to using twitterid scheme for Twitter messages +- Should be shipped before Mage v0.1.84 + +## v3.0.179 + +- Allow editing of start conversation triggers + +## v3.0.178 + +- Remove urn field, urn compound index, remove last uses of urn field + +## v3.0.177 + +- remove all uses of urn (except when writing) +- create display index, backfill identity +- Allow users to specify extra URNs columns to include on the flow results export + +## v3.0.176 + +- Add display and identity fields to ContactURN +- Add schemes field to allow channels to support more than one scheme + +## v3.0.175 + +- Fix incorrect lambda use so message sending works + +## v3.0.174 + +- Make ContactField.uuid unique and non-null + +## v3.0.173 + +- Add migration to populate ContactField.uuid + +## v3.0.172 + +- Only try to delete Twilio app when channel config contains 'application_sid' +- Surveyor submissions should try rematching the rules if the same ruleset got updated by the user and old rules were removed +- Add uuid field to ContactField +- Convert more channel types to dynamic types + +## v3.0.171 + +- Fixes for Twitter Activity channels +- Add stop contact command to mage handler +- Convert Firebase Cloud Messaging to a dynamic channel type +- Convert Viber Public to a dynamic channel type +- Change to the correct way for dynamic channel +- Convert LINE to a dynamic channel type +- Better message in SMS alert email + +## v3.0.170 + +- Hide SMTP config password and do not change the set password if blank is submitted +- Validate the length of message campaigns for better user feedback +- Make FlowRun.uuid unique and non-null (advise faking this and building index concurrently) + +## v3.0.169 + +- Migration to populate FlowRun.uuid. Advise faking this and running manually. +- More channel logs for Jiochat channel interactions + +## v3.0.167 + +- Fix inclusion of attachment urls in webhook payloads and add tests +- Install lxml to improve performance of large Excel exports +- Add proper deactivation of Telegram channels +- Converted Facebook and Telegram to dynamic channel types +- Add nullable uuid field to FlowRun +- Make sure we consider all URN schemes we can send to when looking up the if we have a send channel +- Split Twitter and Twitter Beta into separate channel types +- Remove support for old-style Twilio endpoints + +## v3.0.166 + +- Release channels before Twilio/Nexmo configs are cleared +- Expose flow start UUID on runs from the runs endpoint + +## v3.0.165 + +- Migration to populate FlowStart.uuid on existing objects (advise faking and run manually) + +## v3.0.163 + +- Add uuid field to FlowStart +- Migration to convert TwiML apps + +## v3.0.160 + +- Add support for Twitter channels using new beta Activity API + +## v3.0.159 + +- Clean incoming message text to remove invalid chars + +## v3.0.158 + +- Add more exception currencies for pycountry +- Support channel specific Twilio endpoints + +## v3.0.156 + +- Clean up pip-requires and reset pip-freeze + +## v3.0.155 + +- Reduce the rate limit for SMS central to 1 requests per second +- Display Jiochat on channel claim page +- Fix date pickers on modal forms +- Update channels to generate messages with multiple attachments + +## v3.0.154 + +- Rate limit sending throught SMS central to 10 messages per second +- Fix some more uses of Context objects no longer supported in django 1.11 +- Fix channel log list request time display +- Add @step.text and @step.attachments to message context + +## v3.0.153 + +- Jiochat channels +- Django 1.11 + +## v3.0.151 + +- Convert all squashable and prunable models to use big primary keys + +## v3.0.150 + +- Drop database-level length restrictions on msg and values +- Add sender ID config for Macrokiosk channels +- Expose org credit information on API org endpoint +- Add contact_uuid parameter to update FCM user +- Add configurable webhook header fields + +## v3.0.148 + +- Fix simulator with attachments +- Switch to using new recent messages model + +## v3.0.147 + +- Migration to populate FlowPathRecentMessage +- Clip messages to 640 chars for recent messages table + +## v3.0.145 + +- Change Macrokiosk time format to not have space +- Better error message for external channel handler for wrong time format +- Add new model for tracking recent messages on flow path segments + +## v3.0.144 + +- Remove Msg.media field that was replaced by Msg.attachments +- Change default ivr timeouts to 2m +- Fix the content-type for Twilio call response + +## v3.0.143 + +- Update contact read page and inbox views to show multiple message attachments +- Fix use of videojs to provide consistent video playback across browsers +- API should return error message if user provides something unparseable for a non-serializer param + +## v3.0.142 + +- Fix handling of old msg structs with no attachments attribute +- Tweak in create_outgoing to prevent possible NPEs in flow execution +- Switch to using Msg.attachments instead of Msg.media +- Replace index on Value.string_value with one that is limited to first 32 chars + +## v3.0.139 + +- Fix Macrokiosk JSON responses + +## v3.0.138 + +- Migration to populate attachments field on old messages + +## v3.0.137 + +- Don't assume event fires still exist in process_fire_events +- Add new Msg.attachments field to hold multiple attachments on an incoming message + +## v3.0.136 + +- Fix scheduled broadcast text display + +## v3.0.135 + +- Make 'only' keyword triggers ignore punctuation +- Make check_campaigns_task lock on the event fires that it will queue +- Break up flow event fires into sub-batches of 500 +- Ignore and ack incoming messages from Android relayer that have no number + +## v3.0.134 + +- Add match_type option to triggers so users can create triggers which only match when message only contains keyword +- Allow Africa's talking to retry sending message +- Allow search on the triggers pages +- Clear results for analytics when user removes a flow run + +## v3.0.133 + +- Make Msg.get_sync_commands more efficent +- Fix open range airtime transfers +- Fix multiple Android channels sync +- Fix parsing of macrokiosk channel time format +- Ensure that our select2 boxes show "Add new" option even if there is a partial match with an existing item +- Switch to new translatable fields and remove old Broadcast fields +- Add Firebase Cloud messaging support for Android channels + +## v3.0.132 + +- Migration to populate new translatable fields on old broadcasts. This migration is slow on a large database so it's + recommended that large deployments fake it and run it manually. + +## v3.0.128 + +- Add new translatable fields to Broadcast and ensure they're populated for new stuff + +## v3.0.127 + +- Fix autocomplete for items containing digits or other items +- Make autocomplete dropdown disappear when user clicks in input box +- Replace usages of "SMS" with "message" in editor +- Allow same subflow to be called without pause in between + +## v3.0.126 + +- Fix exporting messages by a label folder +- Improve performance of org export page for large orgs +- Make it easier to enable/disable debug toolbar +- Increase channel logging for requests and responses +- Change contact api v1 to insert nonexistent fields +- Graceful termination of USSD sessions + +## v3.0.125 + +- Don't show deleted flows on list page +- Convert timestamps sent by MacroKiosk from local Kuala Lumpur time + +## v3.0.124 + +- Move initial IVR expiration check to status update on the call +- Hide request time in channel log if unset +- Check the existance of broadcast recipients before adding +- Voice flows import should never allow expirations longer than 15 mins +- Fix parse location to correctly use the tokenizized text if the location was matched for the entire text +- Use updates instead of full Channel saves() on realyer syncs, only update when there are changes + +## v3.0.123 + +- Use flow starts for triggers that operate on groups +- Handle throttling errors from Nexmo when using API to add new numbers +- Convert campaign event messages to HSTORE fields + +## v3.0.121 + +- Add MACROKIOSK channel type +- Show media for MMS in simulator + +## v3.0.120 + +- Fix send all bug where we append list of messages to another list of messages +- Flows endpooint should allow filtering by modified_on + +## v3.0.119 + +- More vertical form styling tweaks + +## v3.0.118 + +- Add flow link on subflow rulesets in flows + +## v3.0.117 + +- Fix styling on campaign event modal + +## v3.0.116 + +- Update to latest Raven +- Make default form vertical, remove horizontal to vertical css overrides +- Add flow run search and deletion +- Hangup calls on channels release + +## v3.0.115 + +- Allow message exports by label, system label or all messages +- Fix for double stacked subflows with immediate exits + +## v3.0.112 + +- Archiving a flow should interrupt all the current runs + +## v3.0.111 + +- Display webhook results on contact history +- Clean up template tags used on contact history +- Allow broadcasts to be sent to all urns belonging to the specified contacts + +## v3.0.109 + +- Data migration to populate broadcast send_all field + +## v3.0.108 + +- Add webhook events trim task with configurable retain times for success and error logs + +## v3.0.107 + +- Add send_all broadcast field + +## v3.0.106 + +- Remove non_atomic_gets and display message at /api/v1/ to explain API v1 has been replaced +- Add squashable model for label counts +- Split system label functionality into SystemLabel and SystemLabelCount + +## v3.0.105 + +- Link subflow starts in actions +- Allow wait to wait in flows with warning + +## v3.0.104 + +- Add new has email test, contains phrase test and contains only phrase test + +## v3.0.103 + +- Migration to populate FlowNodeCount shouldn't include test contacts + +## v3.0.102 + +- Add migration to populate FlowNodeCount + +## v3.0.101 + +- Migration to clear no-longer-used flow stats redis keys +- Replace remaining cache-based flow stats code with trigger based FlowNodeCount + +## v3.0.100 + +- Fix intermittently failing Twilio test +- make sure calls have expiration on initiation +- Update to latest smartmin +- Add redirection for v1 endpoints +- Fix webhook docs +- Fix MsgCreateSerializer not using specified channel +- Test coverage +- Fix test coverage issues caused by removing API v1 tests +- Ensure surveyor users still have access to the API v2 endpoint thats they need +- Remove djangorestframework-xml +- Restrict API v1 access to surveyor users +- Block all API v2 writes for suspended orgs +- Remove all parts of API v1 not used by Surveyor + +## v3.0.99 + +- Prioritize msg handling over timeotus and event fires +- Remove hamlcompress command as deployments should use regular compress these days +- Fix not correctly refreshing dynamic groups when a URN is removed +- Allow searching for contacts _with any_ value for a given field + +## v3.0.98 + +- Fix sidebar nav LESS so that level2 lists don't have fixed height and separate scrolling +- Unstop a contact when we get an explicit user interaction such as follow + +## v3.0.96 + +- Fix possible race condition between receiving and handling messages +- Do away with scheme for USSD, will always be TEL +- Make sure events are handled properly for USSD +- Do not specify to & from when using reply_to +- Update JunebugForm for editing Junebug Channel + config fields + +## v3.0.95 + +- Log request time on channel log success + +## v3.0.94 + +- Fix test, fix template tags + +## v3.0.93 + +- Change request times to be in ms instead of seconds + +## v3.0.92 + +- Block on handling incoming msgs so we dont process them forever away +- Include Viber channels in new conversation trigger form channel choices + +## v3.0.90 + +- Don't use cache+calculations for flow segment counts - these are pre-calculated in FlowPathCount +- Do not include active contacts in flows unless user overrides it +- Clean up middleware imports and add tests +- Feedback to user when simulating a USSD channel without a USSD channel connected + +## v3.0.89 + +- Expand base64 charset, fix decode validity heuristic + +## v3.0.88 + +- Deal with Twilio arbitrarily sending messages as base64 +- Allow configuration of max text size via settings + +## v3.0.87 + +- Set higher priority when sending responses through Kannel + +## v3.0.86 + +- Do not add stopped contacts to groups when importing +- Fix an entire flow start batch failing if one run throws an exception +- Limit images file size to be less than 500kB +- Send Facebook message attachments in a different request as the text message +- Include skuid for open range tranfertto accounts + +## v3.0.85 + +- Fix exception when handling Viber msg with no text +- Migration to remove no longer used ContactGroup.count +- Fix search queries like 'foo bar' where there are more than one condition on name/URN +- Add indexes for Contact.name and ContactURN.path +- Replace current omnibox search function with faster and simpler top-25-of-each-type approach + +## v3.0.84 + +- Fix Line, FCM icons, add Junebug icon + +## v3.0.83 + +- Render missing field and URN values as "--" rather than "None" on Contact list page + +## v3.0.82 + +- Add ROLE_USSD +- Add Junebug USSD Channel +- Fix Vumi USSD to use USSD Role + +## v3.0.81 + +- Archive triggers that do not have a contact to send to +- Disable sending of messages for blocked and stopped contacts + +## v3.0.80 + +- Add support for outbound media on reply messages for Twilio MMS (US, CA), Telegram, and Facebook +- Do not throw when viber sends us message missing the media +- Optimizations around Contact searching +- Send flow UUID with webhook flow events + +## v3.0.78 + +- Allow configuration of max message length to split on for External channels + +## v3.0.77 + +- Use brand key for evaluation instead of host when determining brand +- Add red rabbit type (hidden since MT only) +- Fix flow results exports for broadcast only flows + +## v3.0.76 + +- Log Nexmo media responses without including entire body + +## v3.0.75 + +- Dont encode to utf8 for XML and JSON since they expect unicode +- Optimize contact searching when used to determine single contact's membership +- Use flow system user when migrating flows, avoid list page reorder after migrations + +## v3.0.74 + +- reduce number of lookup to DB + +## v3.0.73 + +- Add test case for search URL against empty field value +- Fix sending vumi messages initiated from RapidPro without response to + +## v3.0.72 + +- Improvements to external channels to allow configuration against JSON and XML endpoints +- Exclude test contacts from flow results +- Update to latest smartmin to fix empty string searching + +## v3.0.70 + +- Allow USSD flows to start someone else in a flow +- Include reply to external_id for Vumi channel + +## v3.0.69 + +- Add ID column to result exports for anon orgs +- Deactivate runs when releasing flows +- Fix urn display for call log +- Increased send and receive channel logging for Nexmo, Twilio, Twitter and Telegram +- Allow payments through Bitcoins +- Include TransferTo account currency when asking phone info to TransferTo +- Don't create inbound messages for gather timeouts, letting calls expire +- Don't show channel log for inactive channels on contact history +- Upgrade to latest smartmin which changes created_on/modified_on fields on SmartModels to be overridable +- Uniform call and message logs + +## v3.0.64 + +- Add ID column to anonymous org contact exports, also add @contact.id field in message context +- Fix counts for channel log elements +- Only have one link on channel page for sending log +- Attempt to determine file types for msg attachments using libmagic +- Deactivate runs on hangups, Keep ivr runs open on exit +- Add log for nexmo media download +- Add new perf_test command to run performance tests on database generated with make_test_db + +## v3.0.62 + +- Fix preferred channels for non-msg channels + +## v3.0.61 + +- Make migrations to populate new export task fields non-atomic +- Add indexes for admin boundaries and aliases +- Nexmo: make sure calls are ended on hangup, log hangups and media +- Fix inbound calls on Nexmo to use conversation_uuid +- Style tweaks for zapier widget +- Use shorter timeout for IVR +- Issue hangups on expiration during IVR runs +- Catch all exceptions and log them when initiating call +- Fix update status for Nexmo calls + +## v3.0.48 + +- Add channel session log page +- Use brand variable for zaps to show +- Additional logging for nexmo +- Increase non-overlap on timeout queueing, never double queue single timeout +- Fix broken timeout handling when there is a race +- Make field_keys a required parameter +- Speed up the contact import by handling contact update at once after all the fields are set + +## v3.0.47 + +- Add channel log for Nexmo call initiation +- Fix import-geojson management command + +## v3.0.46 + +- Fix Contact.search so it doesn't evaluate the base_query +- Enable searching in groups and blocked/stopped contacts + +## v3.0.45 + +- Fix absolute positioning for account creation form +- Add Line channel icon in fonts +- Add data migrations to update org config to connect to Nexmo + +## v3.0.43 + +- Add Malawi as a country for Africa's Talking + +## v3.0.42 + +- Widen pages to browser width so more can fit +- Fix the display of URNs on contact list page +- Fix searching of Nexmo number on connected accounts + +## v3.0.41 + +- Fix channel countries being duplicated for airtime configuration +- Add make_sql command to generate SQL files for an app, reorganize current SQL reference files +- Added SquashableModel and use it for all squashable count classes + +## v3.0.40 + +- Add support for Nexmo IVR +- Log IVR interactions in Channel Log + +## v3.0.37 + +- Fix to make label of open ended response be All Response even if there is timeout on the ruleset +- Data migration to rename category for old Values collected with timeouts + +## v3.0.36 + +- Add 256 keys to @extra, also enforce ordering so it is predictible which are included +- Make fetching flow run stats more efficient and expose number of active runs on flow run endpoint +- Migration to populate session on msg and ended_on where it is missing + +## v3.0.35 + +- Offline context per brand + +## v3.0.34 + +- Add Junebug channel type +- Better base styling for dev project +- Pass charset parameter to Kannel when sending unicode +- Zero out minutes, seconds, ms for campaign events with set delivery horus +- Add other URN types to contact context, return '' if missing, '\*' mask for anon orgs +- Make sure Campaigns export base_language for simple message events, honor on import + +## v3.0.33 + +- Change ansible command run on vagrant up from syncdb to migrate +- Remove no longer needed django-modeltranslation +- Keep up to 256 extra keys from webhooks instead of 128 +- Add documentation of API rate limiting + +## v3.0.32 + +- Make styling variables uniform across branding +- Make brand styling optional + +## v3.0.28 + +- Add support for subflows over IVR + +## v3.0.27 + +- Fix searching for Twilio numbers, add unit tests +- Fix API v1 run serialization when step messages are purged + +## v3.0.26 + +- Adds more substitutions from accented characters to gsm7 plain characters + +## v3.0.25 + +- Populate ended_on for ivr calls +- Add session foreign key to Msg model + +## v3.0.24 + +- Fix bug in starting calls from sessions + +## v3.0.23 + +- Remove flow from ChannelSession, sessions can span many runs/flows +- Remove superfluous channelsession.parent + +## v3.0.22 + +- Migration to update existing twiml apps with a status_callback, remove api/v1 references + +## v3.0.21 + +- Various tweaks to wording and presentation around custom SMTP email config + +## v3.0.20 + +- Allow orgs to set their own SMTP server for outgoing emails +- Return better error message when To number not passed to Twilio handler +- Exclude Flow webhook events from retries (we try once and forget) +- Don't pass channel in webhook events if we don't know it +- Use JsonResponse and response.json() consistently +- Replace json.loads(response.content) with response.json() which properly decodes on Python 3 + +## v3.0.19 + +- Improve performance of contact searches by location by fetching locations in separate query + +## v3.0.18 + +- Update pyparsing to 2.1.10 +- Update to new django-hamlpy +- Display flow runs exits on the contact timeline +- Fix Travis settings file for Python 3 +- Fix more Python 3 syntax issues +- Fix RecentMessages no longer supporting requests with multiple rules, and add tests for that +- Use print as function rather than statement for future Python 3 compatibility +- Do not populate contact name for anon orgs from Viber +- Add is_squashed to FlowPathCount and FlowRunCount +- Updates to using boto3, if using AWS for storing imports or exports you'll need to change your settings file: `DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'` + +## v3.0.14 + +- Allow for the creation of Facebook referral triggers (opt-in on FB) +- Allow for whitelisting of domains for Facebook channels + +## v3.0.13 + +- New contact field editing UI with Intercooler modals + +## v3.0.9 + +- Update RecentMessages view to use new recent messages model +- Remove now unused indexes on FlowStep + +## v3.0.8 + +- Adds data migration to populate FlowPathRecentStep from existing Flow Steps + +## v3.0.7 + +- Introduce new model, FlowPathRecentStep that tracks recent steps from one flow node to another. This will replace the rather expensive index used to show recent flow activity on a flow path. + +## v3.0.10 + +- Log any exceptions encountered in Celery tasks to Raven +- Tell user to get pages_messaging_subscriptions permission for their FB app + +## v3.0.6 + +- Replace unicode non breaking spaces with a normal space for GSM7 encoding (Kannel only) +- Add migrations for custom indexes (existing installs before v3 should fake these) + +## v3.0.5 + +- fix styling on loader ball animation + +## v3.0.4 + +- Fix issue causing flow run table on flow dashboard to be very slow if a flow contained many responses + +## v3.0.3 + +- Refactor JSON responses to use native Django JSONResponse +- Dont use proxy for Dart Media and Hub9, expose IPs to whitelist + +## v3.0.2 + +- Fixes DartMedia channel for short codes + +## v3.0.1 + +- Remove django-celery as it is unneeded, also stop saving Celery tombstones as we now store + all task state (ContactImport for example) directly in models + +## v3.0.0 + +- IMPORTANT: This release resets all Temba migrations. You need to run the latest migrations + from a version preceding this one, then fake all temba migrations when deploying: + ``` % python manage.py migrate csv_imports % python manage.py migrate airtime --fake % python manage.py migrate api --fake -% python manage.py migrate campaigns --fake +% python manage.py migrate campaigns --fake % python manage.py migrate channels --fake % python manage.py migrate contacts --fake % python manage.py migrate flows --fake @@ -10497,9 +10491,10 @@ v3.0.0 % python manage.py migrate values --fake % python manage.py migrate ``` - * Django 1.10 - * Guardian 1.4.6 - * MPTT 0.8.7 - * Extensions 1.7.5 - * Boto 2.45.0 - * Django Storages 1.5.1 + +- Django 1.10 +- Guardian 1.4.6 +- MPTT 0.8.7 +- Extensions 1.7.5 +- Boto 2.45.0 +- Django Storages 1.5.1 From 7cd1ef8a733cd47734ae52280d8a7e9dd3e741b3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 16:22:02 +0000 Subject: [PATCH 274/557] Reduce topic limit to 50 and enforce limits for topics and teams --- temba/settings_common.py | 2 +- temba/tickets/forms.py | 30 ++++++++++++++++++++++++++ temba/tickets/tests.py | 46 +++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/temba/settings_common.py b/temba/settings_common.py index 4969f8e3078..34e345bcd96 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -935,7 +935,7 @@ "groups": 250, "labels": 250, "teams": 50, - "topics": 250, + "topics": 50, } RETENTION_PERIODS = { diff --git a/temba/tickets/forms.py b/temba/tickets/forms.py index cdfd16874e8..fb26b505300 100644 --- a/temba/tickets/forms.py +++ b/temba/tickets/forms.py @@ -58,6 +58,21 @@ def clean_topics(self): ) return topics + def clean(self): + cleaned_data = super().clean() + + count, limit = Team.get_org_limit_progress(self.org) + if limit is not None and count >= limit: + raise forms.ValidationError( + _( + "This workspace has reached its limit of %(limit)d teams. " + "You must delete existing ones before you can create new ones." + ), + params={"limit": limit}, + ) + + return cleaned_data + class Meta: model = Team fields = ("name", "topics") @@ -82,6 +97,21 @@ def clean_name(self): return name + def clean(self): + cleaned_data = super().clean() + + count, limit = Topic.get_org_limit_progress(self.org) + if limit is not None and count >= limit: + raise forms.ValidationError( + _( + "This workspace has reached its limit of %(limit)d topics. " + "You must delete existing ones before you can create new ones." + ), + params={"limit": limit}, + ) + + return cleaned_data + class Meta: model = Topic fields = ("name",) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 6bb786c12d3..be89b44e8a7 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -343,6 +343,7 @@ def test_list(self): class TopicCRUDLTest(TembaTest, CRUDLTestMixin): + @override_settings(ORG_LIMIT_DEFAULTS={"topics": 2}) def test_create(self): create_url = reverse("tickets.topic_create") @@ -358,30 +359,48 @@ def test_create(self): form_errors={"name": "This field is required."}, ) - # try to create with name that is already taken + # try to create with name that is too long self.assertCreateSubmit( create_url, self.admin, - {"name": "general"}, - form_errors={"name": "Topic with this name already exists."}, + {"name": "X" * 65}, + form_errors={"name": "Ensure this value has at most 64 characters (it has 65)."}, ) - # try to create with name that is too long self.assertCreateSubmit( create_url, self.admin, - {"name": "X" * 65}, - form_errors={"name": "Ensure this value has at most 64 characters (it has 65)."}, + {"name": "Sales"}, + new_obj_query=Topic.objects.filter(name="Sales", is_system=False), + success_status=302, + ) + + # try again with same name + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "sales"}, + form_errors={"name": "Topic with this name already exists."}, ) self.assertCreateSubmit( create_url, self.admin, - {"name": "Hot Topic"}, - new_obj_query=Topic.objects.filter(name="Hot Topic", is_system=False), + {"name": "Support"}, + new_obj_query=Topic.objects.filter(name="Support", is_system=False), success_status=302, ) + # try to create another now that we've reached the limit + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "Training"}, + form_errors={ + "__all__": "This workspace has reached its limit of 2 topics. You must delete existing ones before you can create new ones." + }, + ) + def test_update(self): topic = Topic.create(self.org, self.admin, "Hot Topic") @@ -433,6 +452,7 @@ def test_delete(self): class TeamCRUDLTest(TembaTest, CRUDLTestMixin): + @override_settings(ORG_LIMIT_DEFAULTS={"teams": 1}) def test_create(self): create_url = reverse("tickets.team_create") @@ -501,6 +521,16 @@ def test_create(self): team = Team.objects.get(name="Sales") self.assertEqual({sales}, set(team.topics.all())) + # try to create another now that we've reached the limit + self.assertCreateSubmit( + create_url, + self.admin, + {"name": "Training", "topics": [sales.id]}, + form_errors={ + "__all__": "This workspace has reached its limit of 1 teams. You must delete existing ones before you can create new ones." + }, + ) + def test_update(self): sales = Topic.create(self.org, self.admin, "Sales") marketing = Topic.create(self.org, self.admin, "Marketing") From acfb2e18aecea0f9a0b0288fa331cd623bed3dcc Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 14:45:05 +0000 Subject: [PATCH 275/557] Tweak ticket counts database trigger to start recording counts scoped by topic and assignee --- temba/sql/current_functions.sql | 22 +++++- .../0069_assign_agents_to_default_team.py | 2 +- .../migrations/0070_update_triggers.py | 68 +++++++++++++++++++ temba/tickets/tests.py | 15 +--- 4 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 temba/tickets/migrations/0070_update_triggers.py diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index 53bdeba980a..15db3c78367 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-04-25 15:06 UTC +-- Generated by collect_sql on 2024-10-30 14:37 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row @@ -394,6 +394,16 @@ BEGIN END; $$ LANGUAGE plpgsql; +---------------------------------------------------------------------- +-- Inserts a new ticketcount row with the given values +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_insert_ticketcount(_org_id INTEGER, status CHAR(1), _topic_id INTEGER, _assignee_id INTEGER, _count INT) RETURNS VOID AS $$ +BEGIN + INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") + VALUES(_org_id, format('%s:%s:%s', status, _topic_id, coalesce(_assignee_id, 0)), 'X', _count, FALSE); +END; +$$ LANGUAGE plpgsql; + ---------------------------------------------------------------------- -- Inserts a new assignee ticketcount row with the given values ---------------------------------------------------------------------- @@ -645,11 +655,12 @@ END; $$ LANGUAGE plpgsql; ---------------------------------------------------------------------- --- Trigger procedure to update user and system labels on column changes +-- Trigger procedure to update ticket counts on column changes ---------------------------------------------------------------------- CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN -- new ticket inserted + PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); @@ -657,11 +668,15 @@ BEGIN UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; END IF; ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated + IF OLD.topic_id != NEW.topic_id OR OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN + PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); + PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); + END IF; + IF OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); END IF; - IF OLD.topic_id != NEW.topic_id OR OLD.status != NEW.status THEN PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); @@ -673,6 +688,7 @@ BEGIN UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; END IF; ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted + PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); diff --git a/temba/tickets/migrations/0069_assign_agents_to_default_team.py b/temba/tickets/migrations/0069_assign_agents_to_default_team.py index 9c3ea0ccd9d..4679098451e 100644 --- a/temba/tickets/migrations/0069_assign_agents_to_default_team.py +++ b/temba/tickets/migrations/0069_assign_agents_to_default_team.py @@ -3,7 +3,7 @@ from django.db import migrations -def assign_agents_to_default_team(apps, schema_editor): +def assign_agents_to_default_team(apps, schema_editor): # pragma: no cover OrgMembership = apps.get_model("orgs", "OrgMembership") for membership in OrgMembership.objects.filter(role_code="T"): diff --git a/temba/tickets/migrations/0070_update_triggers.py b/temba/tickets/migrations/0070_update_triggers.py new file mode 100644 index 00000000000..781213acefc --- /dev/null +++ b/temba/tickets/migrations/0070_update_triggers.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.2 on 2024-10-30 14:24 + +from django.db import migrations + +SQL = """ +---------------------------------------------------------------------- +-- Inserts a new ticketcount row with the given values +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_insert_ticketcount(_org_id INTEGER, status CHAR(1), _topic_id INTEGER, _assignee_id INTEGER, _count INT) RETURNS VOID AS $$ +BEGIN + INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") + VALUES(_org_id, format('%s:%s:%s', status, _topic_id, coalesce(_assignee_id, 0)), 'X', _count, FALSE); +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Trigger procedure to update ticket counts on column changes +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN -- new ticket inserted + PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); + PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); + PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); + + IF NEW.status = 'O' THEN + UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; + END IF; + ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated + IF OLD.topic_id != NEW.topic_id OR OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN + PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); + PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); + END IF; + + IF OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN + PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); + PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); + END IF; + IF OLD.topic_id != NEW.topic_id OR OLD.status != NEW.status THEN + PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); + PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); + END IF; + + IF OLD.status = 'O' AND NEW.status = 'C' THEN -- ticket closed + UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; + ELSIF OLD.status = 'C' AND NEW.status = 'O' THEN -- ticket reopened + UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; + END IF; + ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted + PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); + PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); + PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); + + IF OLD.status = 'O' THEN -- open ticket deleted + UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +""" + + +class Migration(migrations.Migration): + + dependencies = [("tickets", "0069_assign_agents_to_default_team")] + + operations = [migrations.RunSQL(SQL)] diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 6bb786c12d3..512ab00fe82 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -10,7 +10,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, Org, OrgMembership, OrgRole -from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom +from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -1757,16 +1757,3 @@ def _record_last_close(self, org, d: date, seconds: int, undo: bool = False): TicketDailyTiming.objects.create( count_type=TicketDailyTiming.TYPE_LAST_CLOSE, scope=f"o:{org.id}", day=d, count=count, seconds=seconds ) - - -class AssignAgentsToDefaultTeamTest(MigrationTest): - app = "tickets" - migrate_from = "0068_backfill_default_teams" - migrate_to = "0069_assign_agents_to_default_team" - - def setUpBeforeMigration(self, apps): - OrgMembership.objects.filter(user=self.agent).update(team=None) - - def test_migration(self): - self.assertEqual(1, OrgMembership.objects.filter(user=self.agent, team=self.org.default_ticket_team).count()) - self.assertEqual(1, OrgMembership.objects.exclude(team=None).count()) From b247b2c899c868b65c8d69aa50773f8674d097d7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 13:48:40 -0500 Subject: [PATCH 276/557] Update CHANGELOG.md for v9.3.84 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7475e183e6e..2aa8e6d3724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.84 (2024-10-30) +------------------------- + * Reduce topic limit to 50 and enforce limits for topics and teams + * Implement filtering of tickets by accessible topics + ## v9.3.83 (2024-10-30) - Show same featured + proxy fields on the group pages diff --git a/pyproject.toml b/pyproject.toml index adc0d9c8ded..1872421bbe4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.83" +version = "9.3.84" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 06658ff9f30..8d45d57e9de 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.83" +__version__ = "9.3.84" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 78ee3a68b293058d3098125bad723f25e6b4b5e7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 19:33:58 +0000 Subject: [PATCH 277/557] Add generic squashable count model for things owned by orgs --- temba/orgs/migrations/0156_itemcount.py | 45 +++++++++++++++++++++++++ temba/orgs/models.py | 42 +++++++++++++++++++++-- temba/orgs/tasks.py | 7 +++- temba/orgs/tests.py | 24 +++++++++++++ temba/settings_common.py | 1 + 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 temba/orgs/migrations/0156_itemcount.py diff --git a/temba/orgs/migrations/0156_itemcount.py b/temba/orgs/migrations/0156_itemcount.py new file mode 100644 index 00000000000..0bf51ac13a6 --- /dev/null +++ b/temba/orgs/migrations/0156_itemcount.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.2 on 2024-10-30 19:07 + +import django.contrib.postgres.indexes +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("orgs", "0155_remove_invitation_user_group_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ItemCount", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("is_squashed", models.BooleanField(default=False)), + ("scope", models.CharField(max_length=64)), + ("count", models.IntegerField(default=0)), + ( + "org", + models.ForeignKey( + db_index=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="counts", + to="orgs.org", + ), + ), + ], + options={ + "indexes": [ + models.Index( + models.F("org"), + django.contrib.postgres.indexes.OpClass("scope", name="varchar_pattern_ops"), + name="orgcount_org_scope", + ), + models.Index( + condition=models.Q(("is_squashed", False)), fields=["org", "scope"], name="orgcount_unsquashed" + ), + ], + }, + ), + ] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index b7a63579bfb..5842d332ae7 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -20,11 +20,12 @@ from django.conf import settings from django.contrib.auth.models import Group, Permission, User as AuthUser from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import OpClass from django.contrib.postgres.validators import ArrayMinLengthValidator from django.core.files import File from django.core.files.storage import default_storage from django.db import models, transaction -from django.db.models import Count, Prefetch +from django.db.models import Count, Prefetch, Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -40,7 +41,7 @@ from temba.utils.dates import datetime_to_str from temba.utils.email import EmailSender from temba.utils.fields import UploadToIdPathAndRename -from temba.utils.models import JSONField, TembaUUIDMixin, delete_in_batches +from temba.utils.models import JSONField, SquashableModel, TembaUUIDMixin, delete_in_batches from temba.utils.s3 import public_file_storage from temba.utils.text import generate_secret, generate_token from temba.utils.timezones import timezone_to_country_code @@ -1314,7 +1315,7 @@ def delete(self) -> dict: user = self.modified_by counts = defaultdict(int) - # delete notifications and exports + delete_in_batches(self.counts.all()) delete_in_batches(self.notifications.all()) delete_in_batches(self.notification_counts.all()) delete_in_batches(self.incidents.all()) @@ -1872,3 +1873,38 @@ def delete(self): def __repr__(self): # pragma: no cover return f'' + + +class ItemCount(SquashableModel): + """ + Org-level counts of things. + """ + + squash_over = ("org_id", "scope") + + org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="counts", db_index=False) # indexed below + scope = models.CharField(max_length=64) + count = models.IntegerField(default=0) + + @classmethod + def get_squash_query(cls, distinct_set) -> tuple: + sql = """ + WITH removed as ( + DELETE FROM %(table)s WHERE "org_id" = %%s AND "scope" = %%s RETURNING "count" + ) + INSERT INTO %(table)s("org_id", "scope", "count", "is_squashed") + VALUES (%%s, %%s, GREATEST(0, (SELECT SUM("count") FROM removed)), TRUE); + """ % { + "table": cls._meta.db_table + } + + params = (distinct_set.org_id, distinct_set.scope) * 2 + + return sql, params + + class Meta: + indexes = [ + models.Index("org", OpClass("scope", name="varchar_pattern_ops"), name="orgcount_org_scope"), + # for squashing task + models.Index(name="orgcount_unsquashed", fields=("org", "scope"), condition=Q(is_squashed=False)), + ] diff --git a/temba/orgs/tasks.py b/temba/orgs/tasks.py index b892b9ebdd1..7782f177e60 100644 --- a/temba/orgs/tasks.py +++ b/temba/orgs/tasks.py @@ -12,7 +12,7 @@ from temba.utils.crons import cron_task from temba.utils.email import EmailSender -from .models import Export, Invitation, Org, OrgImport, User, UserSettings +from .models import Export, Invitation, ItemCount, Org, OrgImport, User, UserSettings @shared_task @@ -125,3 +125,8 @@ def delete_released_orgs(): num_deleted += 1 return {"deleted": num_deleted, "failed": num_failed} + + +@cron_task(lock_timeout=7200) +def squash_item_counts(): + ItemCount.squash() diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 830edc7ae1e..42208309d15 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -47,6 +47,7 @@ DefinitionExport, Export, Invitation, + ItemCount, Org, OrgImport, OrgMembership, @@ -59,6 +60,7 @@ expire_invitations, restart_stalled_exports, send_user_verification_email, + squash_item_counts, trim_exports, ) @@ -4203,3 +4205,25 @@ def test_download(self): response = self.client.get(download_url + "?raw=1") self.assertRedirect(response, f"/test-default/orgs/{self.org.id}/ticket_exports/{export.uuid}.xlsx") + + +class ItemCountTest(TembaTest): + def test_model(self): + self.org.counts.create(scope="foo:1", count=2) + self.org.counts.create(scope="foo:1", count=3) + self.org.counts.create(scope="foo:2", count=1) + self.org.counts.create(scope="foo:3", count=4) + self.org2.counts.create(scope="foo:4", count=1) + self.org2.counts.create(scope="foo:4", count=1) + + self.assertEqual(9, ItemCount.sum(self.org.counts.filter(scope__in=("foo:1", "foo:3")))) + self.assertEqual(10, ItemCount.sum(self.org.counts.filter(scope__startswith="foo:"))) + self.assertEqual(4, self.org.counts.count()) + + squash_item_counts() + + self.assertEqual(9, ItemCount.sum(self.org.counts.filter(scope__in=("foo:1", "foo:3")))) + self.assertEqual(10, ItemCount.sum(self.org.counts.filter(scope__startswith="foo:"))) + self.assertEqual(3, self.org.counts.count()) + + self.org.counts.all().delete() diff --git a/temba/settings_common.py b/temba/settings_common.py index 34e345bcd96..8fe5d1b62c5 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -764,6 +764,7 @@ "squash-channel-counts": {"task": "squash_channel_counts", "schedule": timedelta(seconds=60)}, "squash-group-counts": {"task": "squash_group_counts", "schedule": timedelta(seconds=60)}, "squash-flow-counts": {"task": "squash_flow_counts", "schedule": timedelta(seconds=60)}, + "squash-item-counts": {"task": "squash_item_counts", "schedule": timedelta(seconds=45)}, "squash-msg-counts": {"task": "squash_msg_counts", "schedule": timedelta(seconds=60)}, "squash-notification-counts": {"task": "squash_notification_counts", "schedule": timedelta(seconds=60)}, "squash-ticket-counts": {"task": "squash_ticket_counts", "schedule": timedelta(seconds=60)}, From a2e2ee19af03db66f61088114dfba41289b8c111 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 20:30:04 +0000 Subject: [PATCH 278/557] Rework to new ItemCount model --- temba/sql/current_functions.sql | 80 ++++++++++---- temba/sql/current_triggers.sql | 14 ++- .../migrations/0070_update_triggers.py | 103 ++++++++++-------- 3 files changed, 131 insertions(+), 66 deletions(-) diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index 15db3c78367..3ea38d31207 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-30 14:37 UTC +-- Generated by collect_sql on 2024-10-30 20:23 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row @@ -394,16 +394,6 @@ BEGIN END; $$ LANGUAGE plpgsql; ----------------------------------------------------------------------- --- Inserts a new ticketcount row with the given values ----------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_insert_ticketcount(_org_id INTEGER, status CHAR(1), _topic_id INTEGER, _assignee_id INTEGER, _count INT) RETURNS VOID AS $$ -BEGIN - INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") - VALUES(_org_id, format('%s:%s:%s', status, _topic_id, coalesce(_assignee_id, 0)), 'X', _count, FALSE); -END; -$$ LANGUAGE plpgsql; - ---------------------------------------------------------------------- -- Inserts a new assignee ticketcount row with the given values ---------------------------------------------------------------------- @@ -655,12 +645,20 @@ END; $$ LANGUAGE plpgsql; ---------------------------------------------------------------------- --- Trigger procedure to update ticket counts on column changes +-- Determines the item count scope for a ticket +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_countscope(_ticket tickets_ticket) RETURNS TEXT STABLE AS $$ +BEGIN + RETURN format('tickets:%s:%s:%s', _ticket.status, _ticket.topic_id, COALESCE(_ticket.assignee_id, 0)); +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Trigger procedure to update user and system labels on column changes ---------------------------------------------------------------------- CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN -- new ticket inserted - PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); @@ -668,15 +666,11 @@ BEGIN UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; END IF; ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated - IF OLD.topic_id != NEW.topic_id OR OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); - PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); - END IF; - IF OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); END IF; + IF OLD.topic_id != NEW.topic_id OR OLD.status != NEW.status THEN PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); @@ -688,7 +682,6 @@ BEGIN UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; END IF; ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted - PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); @@ -700,6 +693,55 @@ BEGIN END; $$ LANGUAGE plpgsql; +---------------------------------------------------------------------- +-- Handles DELETE statements on ticket table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_delete() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_ticket_countscope(oldtab), -count(*), FALSE FROM oldtab + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles INSERT statements on ticket table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_insert() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_ticket_countscope(newtab), count(*), FALSE FROM newtab + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles UPDATE statements on ticket table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_update() RETURNS TRIGGER AS $$ +BEGIN + -- add negative counts for all old count scopes that don't match the new ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT o.org_id, temba_ticket_countscope(o), -count(*), FALSE FROM oldtab o + INNER JOIN newtab n ON n.id = o.id + WHERE temba_ticket_countscope(o) != temba_ticket_countscope(n) + GROUP BY 1, 2; + + -- add positive counts for all new count scopes that don't match the old ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT n.org_id, temba_ticket_countscope(n), count(*), FALSE FROM newtab n + INNER JOIN oldtab o ON o.id = n.id + WHERE temba_ticket_countscope(o) != temba_ticket_countscope(n) + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + CREATE OR REPLACE FUNCTION temba_update_category_counts(_flow_id integer, new json, old json) RETURNS void LANGUAGE plpgsql diff --git a/temba/sql/current_triggers.sql b/temba/sql/current_triggers.sql index d5a166d8a7b..1d24612e117 100644 --- a/temba/sql/current_triggers.sql +++ b/temba/sql/current_triggers.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-04-25 15:06 UTC +-- Generated by collect_sql on 2024-10-30 20:23 UTC CREATE TRIGGER temba_broadcast_on_delete AFTER DELETE ON msgs_broadcast REFERENCING OLD TABLE AS oldtab @@ -83,8 +83,8 @@ AFTER INSERT ON msgs_msg REFERENCING NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_msg_on_insert(); CREATE TRIGGER temba_msg_on_update -AFTER UPDATE ON msgs_msg REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab -FOR EACH STATEMENT EXECUTE PROCEDURE temba_msg_on_update(); +AFTER UPDATE ON tickets_ticket REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_update(); CREATE TRIGGER temba_notifications_update_notificationcount AFTER INSERT OR UPDATE OF is_seen OR DELETE @@ -95,6 +95,14 @@ CREATE TRIGGER temba_ticket_on_change_trg AFTER INSERT OR UPDATE OR DELETE ON tickets_ticket FOR EACH ROW EXECUTE PROCEDURE temba_ticket_on_change(); +CREATE TRIGGER temba_ticket_on_delete +AFTER DELETE ON tickets_ticket REFERENCING OLD TABLE AS oldtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_delete(); + +CREATE TRIGGER temba_ticket_on_insert +AFTER INSERT ON tickets_ticket REFERENCING NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_insert(); + CREATE TRIGGER when_contact_groups_changed_then_update_count_trg AFTER INSERT OR DELETE ON contacts_contactgroup_contacts FOR EACH ROW EXECUTE PROCEDURE update_group_count(); diff --git a/temba/tickets/migrations/0070_update_triggers.py b/temba/tickets/migrations/0070_update_triggers.py index 781213acefc..125e40f099e 100644 --- a/temba/tickets/migrations/0070_update_triggers.py +++ b/temba/tickets/migrations/0070_update_triggers.py @@ -4,60 +4,75 @@ SQL = """ ---------------------------------------------------------------------- --- Inserts a new ticketcount row with the given values +-- Determines the item count scope for a ticket ---------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_insert_ticketcount(_org_id INTEGER, status CHAR(1), _topic_id INTEGER, _assignee_id INTEGER, _count INT) RETURNS VOID AS $$ +CREATE OR REPLACE FUNCTION temba_ticket_countscope(_ticket tickets_ticket) RETURNS TEXT STABLE AS $$ BEGIN - INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") - VALUES(_org_id, format('%s:%s:%s', status, _topic_id, coalesce(_assignee_id, 0)), 'X', _count, FALSE); + RETURN format('tickets:%s:%s:%s', _ticket.status, _ticket.topic_id, COALESCE(_ticket.assignee_id, 0)); END; $$ LANGUAGE plpgsql; ---------------------------------------------------------------------- --- Trigger procedure to update ticket counts on column changes +-- Handles INSERT statements on ticket table ---------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ +CREATE OR REPLACE FUNCTION temba_ticket_on_insert() RETURNS TRIGGER AS $$ BEGIN - IF TG_OP = 'INSERT' THEN -- new ticket inserted - PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); - PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); - PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); - - IF NEW.status = 'O' THEN - UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; - END IF; - ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated - IF OLD.topic_id != NEW.topic_id OR OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); - PERFORM temba_insert_ticketcount(NEW.org_id, NEW.status, NEW.topic_id, NEW.assignee_id, 1); - END IF; - - IF OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); - END IF; - IF OLD.topic_id != NEW.topic_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); - END IF; - - IF OLD.status = 'O' AND NEW.status = 'C' THEN -- ticket closed - UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; - ELSIF OLD.status = 'C' AND NEW.status = 'O' THEN -- ticket reopened - UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; - END IF; - ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted - PERFORM temba_insert_ticketcount(OLD.org_id, OLD.status, OLD.topic_id, OLD.assignee_id, -1); - PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); - - IF OLD.status = 'O' THEN -- open ticket deleted - UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; - END IF; - END IF; - RETURN NULL; + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_ticket_countscope(newtab), count(*), FALSE FROM newtab + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles DELETE statements on ticket table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_delete() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_ticket_countscope(oldtab), -count(*), FALSE FROM oldtab + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles UPDATE statements on ticket table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_update() RETURNS TRIGGER AS $$ +BEGIN + -- add negative counts for all old count scopes that don't match the new ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT o.org_id, temba_ticket_countscope(o), -count(*), FALSE FROM oldtab o + INNER JOIN newtab n ON n.id = o.id + WHERE temba_ticket_countscope(o) != temba_ticket_countscope(n) + GROUP BY 1, 2; + + -- add positive counts for all new count scopes that don't match the old ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT n.org_id, temba_ticket_countscope(n), count(*), FALSE FROM newtab n + INNER JOIN oldtab o ON o.id = n.id + WHERE temba_ticket_countscope(o) != temba_ticket_countscope(n) + GROUP BY 1, 2; + + RETURN NULL; END; $$ LANGUAGE plpgsql; + + +CREATE TRIGGER temba_ticket_on_delete +AFTER DELETE ON tickets_ticket REFERENCING OLD TABLE AS oldtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_delete(); + +CREATE TRIGGER temba_ticket_on_insert +AFTER INSERT ON tickets_ticket REFERENCING NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_insert(); + +CREATE TRIGGER temba_msg_on_update +AFTER UPDATE ON tickets_ticket REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_update(); """ From 5138991b4134fa43481576d2a59082fb1e4ebc8b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 20:33:14 +0000 Subject: [PATCH 279/557] Fix ticket table trigger name --- temba/sql/current_functions.sql | 2 +- temba/sql/current_triggers.sql | 10 +++++++--- temba/tickets/migrations/0070_update_triggers.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index 3ea38d31207..a6cc2601bae 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-30 20:23 UTC +-- Generated by collect_sql on 2024-10-30 20:32 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row diff --git a/temba/sql/current_triggers.sql b/temba/sql/current_triggers.sql index 1d24612e117..a31fbdedfe0 100644 --- a/temba/sql/current_triggers.sql +++ b/temba/sql/current_triggers.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-30 20:23 UTC +-- Generated by collect_sql on 2024-10-30 20:32 UTC CREATE TRIGGER temba_broadcast_on_delete AFTER DELETE ON msgs_broadcast REFERENCING OLD TABLE AS oldtab @@ -83,8 +83,8 @@ AFTER INSERT ON msgs_msg REFERENCING NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_msg_on_insert(); CREATE TRIGGER temba_msg_on_update -AFTER UPDATE ON tickets_ticket REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab -FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_update(); +AFTER UPDATE ON msgs_msg REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_msg_on_update(); CREATE TRIGGER temba_notifications_update_notificationcount AFTER INSERT OR UPDATE OF is_seen OR DELETE @@ -103,6 +103,10 @@ CREATE TRIGGER temba_ticket_on_insert AFTER INSERT ON tickets_ticket REFERENCING NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_insert(); +CREATE TRIGGER temba_ticket_on_update +AFTER UPDATE ON tickets_ticket REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_update(); + CREATE TRIGGER when_contact_groups_changed_then_update_count_trg AFTER INSERT OR DELETE ON contacts_contactgroup_contacts FOR EACH ROW EXECUTE PROCEDURE update_group_count(); diff --git a/temba/tickets/migrations/0070_update_triggers.py b/temba/tickets/migrations/0070_update_triggers.py index 125e40f099e..221ab7fa96d 100644 --- a/temba/tickets/migrations/0070_update_triggers.py +++ b/temba/tickets/migrations/0070_update_triggers.py @@ -70,7 +70,7 @@ AFTER INSERT ON tickets_ticket REFERENCING NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_insert(); -CREATE TRIGGER temba_msg_on_update +CREATE TRIGGER temba_ticket_on_update AFTER UPDATE ON tickets_ticket REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_ticket_on_update(); """ From 8d0f058cfefbd523b9c34e5a8d62938b35ec8492 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 20:54:17 +0000 Subject: [PATCH 280/557] Fix releasing orgs and add more tests --- temba/orgs/models.py | 4 ++-- temba/tickets/tests.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 5842d332ae7..b55c5350412 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1315,7 +1315,6 @@ def delete(self) -> dict: user = self.modified_by counts = defaultdict(int) - delete_in_batches(self.counts.all()) delete_in_batches(self.notifications.all()) delete_in_batches(self.notification_counts.all()) delete_in_batches(self.incidents.all()) @@ -1412,8 +1411,9 @@ def delete(self) -> dict: delete_in_batches(self.boundaryalias_set.all()) delete_in_batches(self.templates.all()) - # needs to come after deletion of msgs and broadcasts as those insert new counts + # needs to come after deletion of other things as those insert new negative counts delete_in_batches(self.system_labels.all()) + delete_in_batches(self.counts.all()) # now that contacts are no longer in the database, we can start de-indexing them from search mailroom.get_client().org_deindex(self) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 031fcbcee91..0f12ca59ec8 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -10,6 +10,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, Org, OrgMembership, OrgRole +from temba.orgs.tasks import squash_item_counts from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -236,6 +237,24 @@ def assert_counts( contacts={org2_contact: 0}, ) + squash_item_counts() + + # check new count model raw values are consistent + self.assertEqual( + { + f"tickets:C:{general.id}:{self.admin.id}": 0, + f"tickets:C:{general.id}:{self.agent.id}": 0, + f"tickets:C:{cats.id}:0": 0, + f"tickets:C:{cats.id}:{self.admin.id}": 0, + f"tickets:O:{general.id}:0": 0, + f"tickets:O:{general.id}:{self.editor.id}": 1, + f"tickets:O:{general.id}:{self.agent.id}": 0, + f"tickets:O:{cats.id}:0": 1, + f"tickets:O:{cats.id}:{self.admin.id}": 1, + }, + {c["scope"]: c["count"] for c in self.org.counts.order_by("scope").values("scope", "count")}, + ) + class ShortcutCRUDLTest(TembaTest, CRUDLTestMixin): def test_create(self): From 53c87634b8f4a0256d18459339b7effc07c10501 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 21:12:11 +0000 Subject: [PATCH 281/557] Ensure that tickets counts are cleaned up when a topic is deleted --- temba/tickets/models.py | 7 +++++++ temba/tickets/tests.py | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 95274449040..59d72b0e6d7 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -93,6 +93,9 @@ def get_accessible(cls, org, user): return org.topics.filter(is_active=True) + def get_count_prefixes(self) -> tuple[str]: + return f"tickets:{Ticket.STATUS_OPEN}:{self.id}:", f"tickets:{Ticket.STATUS_CLOSED}:{self.id}:" + def release(self, user): assert not (self.is_system and self.org.is_active), "can't release system topics" assert not self.tickets.exists(), "can't release topic with tickets" @@ -102,6 +105,10 @@ def release(self, user): for team in self.teams.all(): team.topics.remove(self) + # delete ticket counts for this topic + for prefix in self.get_count_prefixes(): + self.org.counts.filter(scope__startswith=prefix).delete() + self.is_active = False self.name = self._deleted_name() self.modified_by = user diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 0f12ca59ec8..4b7b20171a1 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -1560,6 +1560,14 @@ def test_release(self): flow = self.create_flow("Test") flow.topic_dependencies.add(topic1) team = Team.create(self.org, self.admin, "Sales & Support", topics=[topic1, topic2]) + ticket = self.create_ticket(self.create_contact("Ann"), topic=topic1) + self.create_ticket(self.create_contact("Bob"), topic=topic2) + + # can't release a topic with tickets + with self.assertRaises(AssertionError): + topic1.release(self.admin) + + ticket.delete() topic1.release(self.admin) @@ -1569,6 +1577,10 @@ def test_release(self): # topic should be removed from team self.assertEqual({topic2}, set(team.topics.all())) + # counts should be deleted + self.assertEqual(0, self.org.counts.filter(scope__startswith=f"tickets:O:{topic1.id}:").count()) + self.assertEqual(1, self.org.counts.filter(scope__startswith=f"tickets:O:{topic2.id}:").count()) + # flow should be flagged as having issues flow.refresh_from_db() self.assertTrue(flow.has_issues) @@ -1582,10 +1594,6 @@ def test_release(self): with self.assertRaises(AssertionError): topic1.release(self.admin) - # can delete a topic with no tickets - ticket.delete() - topic1.release(self.admin) - class TeamTest(TembaTest): def test_create(self): From 859e53dee7a6d9b8bc06ea199b4d83d25e5f292f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 22:03:30 +0000 Subject: [PATCH 282/557] Data migration to back fill item counts for tickets --- .../migrations/0071_backfill_item_counts.py | 28 +++++++++++++++ temba/tickets/tests.py | 36 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 temba/tickets/migrations/0071_backfill_item_counts.py diff --git a/temba/tickets/migrations/0071_backfill_item_counts.py b/temba/tickets/migrations/0071_backfill_item_counts.py new file mode 100644 index 00000000000..18d18dc0d7f --- /dev/null +++ b/temba/tickets/migrations/0071_backfill_item_counts.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2024-10-30 21:23 + +from django.db import migrations, transaction +from django.db.models import Count + + +def backfill_item_counts(apps, schema_editor): + Org = apps.get_model("orgs", "Org") + + for org in Org.objects.all(): + for topic in org.topics.all(): + with transaction.atomic(): + counts = topic.tickets.values("status", "assignee_id").annotate(count=Count("*")) + for count in counts: + status = count["status"] + assignee_id = count["assignee_id"] + scope = f"tickets:{status}:{topic.id}:{assignee_id or 0}" + org.counts.filter(scope=scope).delete() + org.counts.create(scope=scope, count=count["count"], is_squashed=True) + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [("tickets", "0070_update_triggers")] + + operations = [migrations.RunPython(backfill_item_counts, migrations.RunPython.noop)] diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 4b7b20171a1..d56eba723c0 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -11,7 +11,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, Org, OrgMembership, OrgRole from temba.orgs.tasks import squash_item_counts -from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom +from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom, MigrationTest from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -1814,3 +1814,37 @@ def _record_last_close(self, org, d: date, seconds: int, undo: bool = False): TicketDailyTiming.objects.create( count_type=TicketDailyTiming.TYPE_LAST_CLOSE, scope=f"o:{org.id}", day=d, count=count, seconds=seconds ) + + +class BackfillItemCountsTest(MigrationTest): + app = "tickets" + migrate_from = "0070_update_triggers" + migrate_to = "0071_backfill_item_counts" + + def setUpBeforeMigration(self, apps): + self.general = self.org.default_ticket_topic + self.cats = Topic.create(self.org, self.admin, "Cats") + + contact1 = self.create_contact("Bob", urns=["twitter:bobby"]) + contact2 = self.create_contact("Jim", urns=["twitter:jimmy"]) + + org2_general = self.org2.default_ticket_topic + org2_contact = self.create_contact("Bob", urns=["twitter:bobby"], org=self.org2) + + self.create_ticket(contact1, topic=self.general, assignee=self.admin) + self.create_ticket(contact2, topic=self.general, assignee=self.admin) + self.create_ticket(contact1, topic=self.general, assignee=self.admin, closed_on=timezone.now()) + self.create_ticket(contact2, topic=self.cats, assignee=self.agent) + self.create_ticket(contact1, topic=self.cats) + self.create_ticket(org2_contact, topic=org2_general) + + def test_migration(self): + self.assertEqual( + { + f"tickets:C:{self.general.id}:{self.admin.id}": 1, + f"tickets:O:{self.general.id}:{self.admin.id}": 2, + f"tickets:O:{self.cats.id}:0": 1, + f"tickets:O:{self.cats.id}:{self.agent.id}": 1, + }, + {c["scope"]: c["count"] for c in self.org.counts.order_by("scope").values("scope", "count")}, + ) From 31fc7c9b90a69be1f07f6d64579531add29ebc94 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 17:06:32 -0500 Subject: [PATCH 283/557] Update CHANGELOG.md for v9.3.85 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa8e6d3724..a56032bdfac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.85 (2024-10-30) +------------------------- + * Add generic squashable count model for things owned by orgs + * Implement tickets counts by topic and assignee using new count model + * Ensure that ticket counts are cleaned up when a topic is deleted + v9.3.84 (2024-10-30) ------------------------- * Reduce topic limit to 50 and enforce limits for topics and teams diff --git a/pyproject.toml b/pyproject.toml index 1872421bbe4..0bd57a39ec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.84" +version = "9.3.85" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 8d45d57e9de..b96c412888e 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.84" +__version__ = "9.3.85" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From b1aa2d3a582bcaf79f8c12b7055c375622cb054b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 30 Oct 2024 22:14:51 +0000 Subject: [PATCH 284/557] isort --- temba/tickets/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index d56eba723c0..d05d174287c 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -11,7 +11,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, Org, OrgMembership, OrgRole from temba.orgs.tasks import squash_item_counts -from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom, MigrationTest +from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 From b857201e3ed366df867981103660b3082e52bbed Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 14:34:43 +0000 Subject: [PATCH 285/557] Fetch logs from DynamoDB in batches of 100 --- temba/channels/models.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/temba/channels/models.py b/temba/channels/models.py index 316b893f31f..ade9559ee0f 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -1,3 +1,4 @@ +import itertools import logging from abc import ABCMeta from dataclasses import dataclass @@ -897,25 +898,29 @@ def get_by_uuid(cls, channel, uuids: list) -> list: return [] client = dynamo.get_client() - resp = client.batch_get_item( - RequestItems={dynamo.table_name(cls.DYNAMO_TABLE): {"Keys": [{"UUID": {"S": str(u)}} for u in uuids]}} - ) - logs = [] - for log in resp["Responses"][dynamo.table_name(cls.DYNAMO_TABLE)]: - data = dynamo.load_jsongz(log["DataGZ"]["B"]) - logs.append( - ChannelLog( - uuid=log["UUID"]["S"], - channel=channel, - log_type=log["Type"]["S"], - http_logs=data["http_logs"], - errors=data["errors"], - elapsed_ms=int(log["ElapsedMS"]["N"]), - created_on=datetime.fromtimestamp(int(log["CreatedOn"]["N"]), tz=tzone.utc), - ) + + for uuid_batch in itertools.batched(uuids, 100): + resp = client.batch_get_item( + RequestItems={ + dynamo.table_name(cls.DYNAMO_TABLE): {"Keys": [{"UUID": {"S": str(u)}} for u in uuid_batch]} + } ) + for log in resp["Responses"][dynamo.table_name(cls.DYNAMO_TABLE)]: + data = dynamo.load_jsongz(log["DataGZ"]["B"]) + logs.append( + ChannelLog( + uuid=log["UUID"]["S"], + channel=channel, + log_type=log["Type"]["S"], + http_logs=data["http_logs"], + errors=data["errors"], + elapsed_ms=int(log["ElapsedMS"]["N"]), + created_on=datetime.fromtimestamp(int(log["CreatedOn"]["N"]), tz=tzone.utc), + ) + ) + return sorted(logs, key=lambda l: l.uuid) def get_display(self, *, anonymize: bool, urn) -> dict: From e5dc39e22131b476d5f948a193db97fc892476f4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 14:42:08 +0000 Subject: [PATCH 286/557] Replace custom chunk_list with new itertools.batched --- temba/api/tasks.py | 5 ++-- temba/contacts/models.py | 7 +++-- temba/contacts/tasks.py | 4 +-- .../management/commands/migrate_flows.py | 4 +-- .../flows/management/commands/undo_footgun.py | 4 +-- temba/flows/models.py | 7 +++-- temba/flows/tasks.py | 6 ++-- temba/msgs/management/commands/msg_dewire.py | 4 +-- temba/msgs/models.py | 7 +++-- temba/tickets/models.py | 4 +-- temba/utils/__init__.py | 14 ---------- temba/utils/tests.py | 28 +------------------ temba/utils/whatsapp/tasks.py | 4 +-- 13 files changed, 31 insertions(+), 67 deletions(-) diff --git a/temba/api/tasks.py b/temba/api/tasks.py index d3f4045c54e..15ec0b3c960 100644 --- a/temba/api/tasks.py +++ b/temba/api/tasks.py @@ -1,8 +1,9 @@ +import itertools + from django.conf import settings from django.utils import timezone from temba.api.models import APIToken -from temba.utils import chunk_list from temba.utils.crons import cron_task from .models import WebHookEvent @@ -33,7 +34,7 @@ def trim_webhook_events(): if settings.RETENTION_PERIODS["webhookevent"]: trim_before = timezone.now() - settings.RETENTION_PERIODS["webhookevent"] event_ids = WebHookEvent.objects.filter(created_on__lte=trim_before).values_list("id", flat=True) - for batch in chunk_list(event_ids, 1000): + for batch in itertools.batched(event_ids, 1000): num_deleted, _ = WebHookEvent.objects.filter(id__in=batch).delete() return {"deleted": num_deleted} diff --git a/temba/contacts/models.py b/temba/contacts/models.py index fd7cc8bcd79..02111df77dc 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1,3 +1,4 @@ +import itertools import logging from datetime import date, datetime, timedelta, timezone as tzone from decimal import Decimal @@ -26,7 +27,7 @@ from temba.locations.models import AdminBoundary from temba.mailroom import ContactSpec, modifiers, queue_populate_dynamic_group from temba.orgs.models import DependencyMixin, Export, ExportType, Org, OrgRole, User -from temba.utils import chunk_list, format_number, on_transaction_commit +from temba.utils import format_number, on_transaction_commit from temba.utils.export import MultiSheetExporter from temba.utils.models import JSONField, LegacyUUIDMixin, SquashableModel, TembaModel, delete_in_batches from temba.utils.text import unsnakify @@ -1647,7 +1648,7 @@ def _full_release(self): ContactGroupContacts = self.contacts.through memberships = ContactGroupContacts.objects.filter(contactgroup_id=self.id) - for batch in chunk_list(memberships, 100): + for batch in itertools.batched(memberships, 100): ContactGroupContacts.objects.filter(id__in=[m.id for m in batch]).delete() Contact.objects.filter(id__in=[m.contact_id for m in batch]).update(modified_on=timezone.now()) @@ -1875,7 +1876,7 @@ def write(self, export): num_records = 0 # write out contacts in batches to limit memory usage - for batch_ids in chunk_list(contact_ids, 1000): + for batch_ids in itertools.batched(contact_ids, 1000): # fetch all the contacts for our batch batch_contacts = ( Contact.objects.filter(id__in=batch_ids).prefetch_related("org", "groups").using("readonly") diff --git a/temba/contacts/tasks.py b/temba/contacts/tasks.py index 9238dd6cd56..0e195d3d4c8 100644 --- a/temba/contacts/tasks.py +++ b/temba/contacts/tasks.py @@ -1,10 +1,10 @@ +import itertools import logging from celery import shared_task from django.contrib.auth.models import User -from temba.utils import chunk_list from temba.utils.crons import cron_task from .models import Contact, ContactGroup, ContactGroupCount, ContactImport @@ -19,7 +19,7 @@ def release_contacts(user_id, contact_ids): """ user = User.objects.get(pk=user_id) - for id_batch in chunk_list(contact_ids, 100): + for id_batch in itertools.batched(contact_ids, 100): batch = Contact.objects.filter(id__in=id_batch, is_active=True).prefetch_related("urns") for contact in batch: contact.release(user) diff --git a/temba/flows/management/commands/migrate_flows.py b/temba/flows/management/commands/migrate_flows.py index 3eabc10e927..24692b43a0c 100644 --- a/temba/flows/management/commands/migrate_flows.py +++ b/temba/flows/management/commands/migrate_flows.py @@ -1,9 +1,9 @@ +import itertools import traceback from django.core.management.base import BaseCommand from temba.flows.models import Flow -from temba.utils import chunk_list class Command(BaseCommand): @@ -30,7 +30,7 @@ def migrate_flows(self): num_updated = 0 num_errored = 0 - for id_batch in chunk_list(flow_ids, 1000): + for id_batch in itertools.batched(flow_ids, 1000): for flow in Flow.objects.filter(id__in=id_batch): try: flow.ensure_current_version() diff --git a/temba/flows/management/commands/undo_footgun.py b/temba/flows/management/commands/undo_footgun.py index f6f9a15d18a..28280477829 100644 --- a/temba/flows/management/commands/undo_footgun.py +++ b/temba/flows/management/commands/undo_footgun.py @@ -1,3 +1,4 @@ +import itertools from collections import defaultdict from django.core.management.base import BaseCommand, CommandError @@ -5,7 +6,6 @@ from temba.contacts.models import Contact, ContactGroup from temba.flows.models import FlowRun, FlowSession, FlowStart -from temba.utils import chunk_list class Command(BaseCommand): @@ -43,7 +43,7 @@ def handle(self, start_id: int, event_types: list, dry_run: bool, quiet: bool, * num_fixed = 0 # process runs in batches - for run_id_batch in chunk_list(run_ids, self.batch_size): + for run_id_batch in itertools.batched(run_ids, self.batch_size): run_batch = list(FlowRun.objects.filter(id__in=run_id_batch).only("id", "contact_id", "session_id")) self.undo_for_batch(run_batch, undoers, dry_run) diff --git a/temba/flows/models.py b/temba/flows/models.py index 61cf007894d..2646d2a41e0 100644 --- a/temba/flows/models.py +++ b/temba/flows/models.py @@ -1,3 +1,4 @@ +import itertools import logging from array import array from collections import defaultdict @@ -24,7 +25,7 @@ from temba.orgs.models import DependencyMixin, Export, ExportType, Org, User from temba.templates.models import Template from temba.tickets.models import Topic -from temba.utils import analytics, chunk_list, json, on_transaction_commit, s3 +from temba.utils import analytics, json, on_transaction_commit, s3 from temba.utils.export.models import MultiSheetExporter from temba.utils.models import JSONAsTextField, LegacyUUIDMixin, SquashableModel, TembaModel, delete_in_batches from temba.utils.uuid import uuid4 @@ -1678,7 +1679,7 @@ def _get_run_batches(self, export, start_date, end_date, flows, responded_only: ) seen = set() - for record_batch in chunk_list(records, 1000): + for record_batch in itertools.batched(records, 1000): matching = [] for record in record_batch: seen.add(record["id"]) @@ -1699,7 +1700,7 @@ def _get_run_batches(self, export, start_date, end_date, flows, responded_only: f"Results export #{export.id} for org #{export.org.id}: found {len(run_ids)} runs in database to export" ) - for id_batch in chunk_list(run_ids, 1000): + for id_batch in itertools.batched(run_ids, 1000): run_batch = ( FlowRun.objects.filter(id__in=id_batch) .order_by("modified_on", "id") diff --git a/temba/flows/tasks.py b/temba/flows/tasks.py index b04742e05e7..722ff5dc105 100644 --- a/temba/flows/tasks.py +++ b/temba/flows/tasks.py @@ -1,3 +1,4 @@ +import itertools import logging from collections import defaultdict from datetime import datetime, timedelta, timezone as tzone @@ -11,7 +12,6 @@ from django.utils.timesince import timesince from temba import mailroom -from temba.utils import chunk_list from temba.utils.crons import cron_task from temba.utils.models import delete_in_batches @@ -39,7 +39,7 @@ def update_session_wait_expires(flow_id): flow = Flow.objects.get(id=flow_id) session_ids = flow.sessions.filter(status=FlowSession.STATUS_WAITING).values_list("id", flat=True) - for id_batch in chunk_list(session_ids, 1000): + for id_batch in itertools.batched(session_ids, 1000): batch = FlowSession.objects.filter(id__in=id_batch) batch.update(wait_expires_on=F("wait_started_on") + timedelta(minutes=flow.expires_after_minutes)) @@ -93,7 +93,7 @@ def interrupt_flow_sessions(): by_org[session.org].append(session) for org, sessions in by_org.items(): - for batch in chunk_list(sessions, 100): + for batch in itertools.batched(sessions, 100): mailroom.queue_interrupt(org, sessions=batch) num_interrupted += len(sessions) diff --git a/temba/msgs/management/commands/msg_dewire.py b/temba/msgs/management/commands/msg_dewire.py index c6601bb96ba..6a2e09da6c9 100644 --- a/temba/msgs/management/commands/msg_dewire.py +++ b/temba/msgs/management/commands/msg_dewire.py @@ -1,3 +1,4 @@ +import itertools import math from datetime import timedelta @@ -5,7 +6,6 @@ from django.utils import timezone from temba.msgs.models import Msg -from temba.utils import chunk_list BATCH_SIZE = 1000 DEFAULT_BATCH = 1000 @@ -55,7 +55,7 @@ def handle(self, file_path: str, batch_size: int, tps: int, *args, **options): self.stdout.write(f"> estimated batch send time of {batch_send_time} seconds at {tps} TPS") - for id_batch in chunk_list(msg_ids, batch_size): + for id_batch in itertools.batched(msg_ids, batch_size): # only fetch messages which are WIRED and have never errored batch = Msg.objects.filter(id__in=id_batch, status=Msg.STATUS_WIRED, error_count=0) num_updated = batch.update(status=Msg.STATUS_ERRORED, error_count=1, next_attempt=next_attempt) diff --git a/temba/msgs/models.py b/temba/msgs/models.py index 1f9a03d29f2..06aa8673bee 100644 --- a/temba/msgs/models.py +++ b/temba/msgs/models.py @@ -1,3 +1,4 @@ +import itertools import logging import mimetypes import os @@ -24,7 +25,7 @@ from temba.contacts.models import Contact, ContactGroup, ContactURN from temba.orgs.models import DependencyMixin, Export, ExportType, Org from temba.schedules.models import Schedule -from temba.utils import chunk_list, languages, on_transaction_commit +from temba.utils import languages, on_transaction_commit from temba.utils.export.models import MultiSheetExporter from temba.utils.models import JSONAsTextField, SquashableModel, TembaModel from temba.utils.s3 import public_file_storage @@ -613,7 +614,7 @@ def archive_all_for_contacts(cls, contacts): # update modified on in small batches to avoid long table lock, and having too many non-unique values for # modified_on which is the primary ordering for the API - for batch in chunk_list(msg_ids, 100): + for batch in itertools.batched(msg_ids, 100): Msg.objects.filter(pk__in=batch).update(visibility=cls.VISIBILITY_ARCHIVED, modified_on=timezone.now()) def restore(self): @@ -1197,7 +1198,7 @@ def _get_msg_batches(self, export, system_label, label, start_date, end_date): records = Archive.iter_all_records(export.org, Archive.TYPE_MSG, start_date, end_date, where=where) last_created_on = None - for record_batch in chunk_list(records, 1000): + for record_batch in itertools.batched(records, 1000): matching = [] for record in record_batch: created_on = iso8601.parse_date(record["created_on"]) diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 59d72b0e6d7..ee5667ddaa0 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -1,3 +1,4 @@ +import itertools import logging from abc import ABCMeta from datetime import date @@ -14,7 +15,6 @@ from temba import mailroom from temba.contacts.models import Contact from temba.orgs.models import DependencyMixin, Export, ExportType, Org, OrgMembership, User -from temba.utils import chunk_list from temba.utils.dates import date_range from temba.utils.export import MultiSheetExporter from temba.utils.models import DailyCountModel, DailyTimingModel, SquashableModel, TembaModel @@ -627,7 +627,7 @@ def write(self, export): num_records = 0 # add tickets to the export in batches of 1k to limit memory usage - for batch_ids in chunk_list(ticket_ids, 1000): + for batch_ids in itertools.batched(ticket_ids, 1000): tickets = ( Ticket.objects.filter(id__in=batch_ids) .order_by("opened_on") diff --git a/temba/utils/__init__.py b/temba/utils/__init__.py index 80384103982..bb572e204a7 100644 --- a/temba/utils/__init__.py +++ b/temba/utils/__init__.py @@ -1,5 +1,3 @@ -from itertools import islice - from django.conf import settings from django.db import transaction @@ -44,18 +42,6 @@ def format_number(val): return val -def chunk_list(iterable, size): - """ - Splits a very large list into evenly sized chunks. - Returns an iterator of lists that are no more than the size passed in. - """ - it = iter(iterable) - item = list(islice(it, size)) - while item: - yield item - item = list(islice(it, size)) - - def on_transaction_commit(func): """ Requests that the given function be called after the current transaction has been committed. However function will diff --git a/temba/utils/tests.py b/temba/utils/tests.py index 567b4089fc1..ba4d6dac6d5 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -19,17 +19,7 @@ from temba.utils import json, uuid from temba.utils.compose import compose_serialize -from . import ( - chunk_list, - countries, - format_number, - get_nested_key, - languages, - percentage, - redact, - set_nested_key, - str_to_bool, -) +from . import countries, format_number, get_nested_key, languages, percentage, redact, set_nested_key, str_to_bool from .checks import storage from .crons import clear_cron_stats, cron_task from .dates import date_range, datetime_to_str, datetime_to_timestamp, timestamp_to_datetime @@ -103,22 +93,6 @@ def test_replace_non_characters(self): def test_generate_token(self): self.assertEqual(len(generate_token()), 8) - def test_chunk_list(self): - curr = 0 - for chunk in chunk_list(range(100), 7): - batch_curr = curr - for item in chunk: - self.assertEqual(item, curr) - curr += 1 - - # again to make sure things work twice - curr = batch_curr - for item in chunk: - self.assertEqual(item, curr) - curr += 1 - - self.assertEqual(curr, 100) - def test_nested_keys(self): nested = {} diff --git a/temba/utils/whatsapp/tasks.py b/temba/utils/whatsapp/tasks.py index e8f05de16ee..f7209c9bcb5 100644 --- a/temba/utils/whatsapp/tasks.py +++ b/temba/utils/whatsapp/tasks.py @@ -1,3 +1,4 @@ +import itertools import logging import time @@ -10,7 +11,6 @@ from temba.channels.models import Channel from temba.contacts.models import URN, Contact, ContactURN from temba.request_logs.models import HTTPLog -from temba.utils import chunk_list logger = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def refresh_whatsapp_contacts(channel_id): # 1,000 contacts at a time, we ask WhatsApp to look up our contacts based on the path refreshed = 0 - for urn_batch in chunk_list(wa_urns, 1000): + for urn_batch in itertools.batched(wa_urns, 1000): # need to wait 10 seconds between each batch of 1000 if refreshed > 0: # pragma: no cover time.sleep(10) From 6a05a80b54e48a030e5021276d8ded9e182c519c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 10:01:52 -0500 Subject: [PATCH 287/557] Update CHANGELOG.md for v9.3.86 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a56032bdfac..530c9241ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.86 (2024-10-31) +------------------------- + * Replace custom chunk_list with new itertools.batched + * Fetch logs from DynamoDB in batches of 100 + * Data migration to back fill item counts for tickets + v9.3.85 (2024-10-30) ------------------------- * Add generic squashable count model for things owned by orgs diff --git a/pyproject.toml b/pyproject.toml index 0bd57a39ec8..6de150730b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.85" +version = "9.3.86" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index b96c412888e..7e61e7feef5 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.85" +__version__ = "9.3.86" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From cc7b40efc342b64f4d777f57251565c44433adc6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 17:03:56 +0000 Subject: [PATCH 288/557] Start reading by topic counts from new ticket counts --- temba/tickets/models.py | 22 ++++++++++++++++++++-- temba/tickets/tests.py | 3 +++ temba/tickets/views.py | 2 +- temba/utils/db/__init__.py | 0 temba/utils/db/functions.py | 7 +++++++ temba/utils/db/tests.py | 17 +++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 temba/utils/db/__init__.py create mode 100644 temba/utils/db/functions.py create mode 100644 temba/utils/db/tests.py diff --git a/temba/tickets/models.py b/temba/tickets/models.py index ee5667ddaa0..e77ce0c1419 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -7,8 +7,8 @@ from django.conf import settings from django.db import models -from django.db.models import Q, Sum -from django.db.models.functions import Lower +from django.db.models import F, Q, Sum, Value +from django.db.models.functions import Cast, Lower from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -16,6 +16,7 @@ from temba.contacts.models import Contact from temba.orgs.models import DependencyMixin, Export, ExportType, Org, OrgMembership, User from temba.utils.dates import date_range +from temba.utils.db.functions import SplitPart from temba.utils.export import MultiSheetExporter from temba.utils.models import DailyCountModel, DailyTimingModel, SquashableModel, TembaModel from temba.utils.uuid import is_uuid, uuid4 @@ -237,6 +238,23 @@ def bulk_reopen(cls, org, user, tickets): def get_allowed_assignees(cls, org): return org.get_users(with_perm=cls.ASSIGNEE_PERMISSION) + @classmethod + def get_topic_counts(cls, org, topics, status: str) -> dict[Topic, int]: + """ + Gets the count for each topic and status. + """ + + # count scopes are stored as 'tickets:::' so get all counts with the prefix + # 'tickets::' and group by topic-id extracted as the 3rd split part. + counts = ( + org.counts.filter(scope__startswith=f"tickets:{status}:") + .annotate(topic_id=Cast(SplitPart(F("scope"), Value(":"), Value(3)), output_field=models.IntegerField())) + .values_list("topic_id") + .annotate(count_sum=Sum("count")) + ) + by_topic_id = {c[0]: c[1] for c in counts} + return {t: by_topic_id.get(t.id, 0) for t in topics} + def delete(self): self.events.all().delete() diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index d05d174287c..de4d30f2dd1 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -115,6 +115,9 @@ def assert_counts( self.assertEqual(topic_open, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_OPEN)) self.assertEqual(topic_closed, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_CLOSED)) + self.assertEqual(topic_open, Ticket.get_topic_counts(org, list(org.topics.all()), Ticket.STATUS_OPEN)) + self.assertEqual(topic_closed, Ticket.get_topic_counts(org, list(org.topics.all()), Ticket.STATUS_CLOSED)) + self.assertEqual(contacts, {c: Contact.objects.get(id=c.id).ticket_count for c in contacts}) # t1:O/None/General t2:O/None/General t3:O/None/General t4:O/None/Cats t5:O/None/Cats t6:O/None/General diff --git a/temba/tickets/views.py b/temba/tickets/views.py index ccb03ce0c38..93353fa9592 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -201,7 +201,7 @@ def derive_menu(self): menu.append(self.create_divider()) topics = list(Topic.get_accessible(org, user).order_by("-is_system", "name")) - counts = TicketCount.get_by_topics(org, topics, Ticket.STATUS_OPEN) + counts = Ticket.get_topic_counts(org, topics, Ticket.STATUS_OPEN) for topic in topics: menu.append( { diff --git a/temba/utils/db/__init__.py b/temba/utils/db/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/temba/utils/db/functions.py b/temba/utils/db/functions.py new file mode 100644 index 00000000000..e6bed6f2478 --- /dev/null +++ b/temba/utils/db/functions.py @@ -0,0 +1,7 @@ +from django.db import models +from django.db.models import Func + + +class SplitPart(Func): + function = "SPLIT_PART" + output_field = models.CharField() diff --git a/temba/utils/db/tests.py b/temba/utils/db/tests.py new file mode 100644 index 00000000000..2e07abe215e --- /dev/null +++ b/temba/utils/db/tests.py @@ -0,0 +1,17 @@ +from django.db.models import F, Value + +from temba.tests import TembaTest + +from .functions import SplitPart + + +class FunctionsTest(TembaTest): + def test_split_part(self): + self.org.counts.create(scope="foo:bar:baz") + + count1 = self.org.counts.annotate( + part1=SplitPart(F("scope"), Value(":"), Value(1)), part2=SplitPart(F("scope"), Value(":"), Value(2)) + ).get() + + self.assertEqual(count1.part1, "foo") + self.assertEqual(count1.part2, "bar") From 69ecbbd365dac10b73c3bf7b30bd77473ab6f0ff Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 31 Oct 2024 19:14:03 +0000 Subject: [PATCH 289/557] Make shortcuts an optional attribute on compose --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3f912df4323..e7b65a1e286 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.109.0", + "@nyaruka/temba-components": "0.110.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index 4f06a032984..ccb615c765a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.109.0": - version "0.109.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.109.0.tgz#a7aef7c222719b55cb2f09f371b814aad9f05d8d" - integrity sha512-ctSRjGIlDi9otTkvw59acU1Ji0nh1RpxJkHufnxuqXguSTdJauNTYGAgwukpVrpOII5ehWgdl6ACPgpg41F+vw== +"@nyaruka/temba-components@0.110.0": + version "0.110.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.110.0.tgz#bdb6fb20fe1d073e086fcc39d940f3d279dec82f" + integrity sha512-PGPNTV09tKUu7FoQUnEhdn7tVt8xNRM+ZBPjDvjKibNM1QAfHPcTK2RfnTt1ZI4HU/LlDHiVqjid8F9EgXnV1Q== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From 35e7153a2fa091a930d5f87283c56fdaa5eafb6f Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 31 Oct 2024 12:44:09 -0700 Subject: [PATCH 290/557] Update CHANGELOG.md for v9.3.87 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530c9241ae4..a5490fb3c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.87 (2024-10-31) +------------------------- + * Make shortcuts an optional attribute on compose + v9.3.86 (2024-10-31) ------------------------- * Replace custom chunk_list with new itertools.batched diff --git a/pyproject.toml b/pyproject.toml index 6de150730b5..92d8948ba53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.86" +version = "9.3.87" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 7e61e7feef5..cfca1158e38 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.86" +__version__ = "9.3.87" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 1f5423cea856cc479ca30c9ecb9e498f77ffff66 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 19:54:17 +0000 Subject: [PATCH 291/557] Read mine and unassigned ticket counts from new counts --- temba/orgs/models.py | 13 ++++++++++++- temba/tickets/models.py | 16 +++++++++++++++- temba/tickets/tests.py | 18 ++++++++++++++++-- temba/tickets/views.py | 10 ++++------ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index b55c5350412..eaab8f6bd74 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1,5 +1,7 @@ +import functools import itertools import logging +import operator import os from abc import ABCMeta from collections import defaultdict @@ -25,7 +27,7 @@ from django.core.files import File from django.core.files.storage import default_storage from django.db import models, transaction -from django.db.models import Count, Prefetch, Q +from django.db.models import Count, Prefetch, Q, Sum from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -1886,6 +1888,15 @@ class ItemCount(SquashableModel): scope = models.CharField(max_length=64) count = models.IntegerField(default=0) + class QuerySet(models.QuerySet): + def prefixes(self, prefixes: list): + return self.filter(functools.reduce(operator.or_, [Q(scope__startswith=p) for p in prefixes])) + + def sum(self) -> int: + return self.aggregate(count_sum=Sum("count"))["count_sum"] or 0 + + objects = QuerySet.as_manager() + @classmethod def get_squash_query(cls, distinct_set) -> tuple: sql = """ diff --git a/temba/tickets/models.py b/temba/tickets/models.py index e77ce0c1419..8138824002b 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -238,10 +238,24 @@ def bulk_reopen(cls, org, user, tickets): def get_allowed_assignees(cls, org): return org.get_users(with_perm=cls.ASSIGNEE_PERMISSION) + @classmethod + def get_assignee_count(cls, org, user, topics, status: str) -> int: + """ + Gets the count of tickets assigned to the given user across the given topics and status. + """ + return org.counts.filter(scope__in=[f"tickets:{status}:{t.id}:{user.id if user else 0}" for t in topics]).sum() + + @classmethod + def get_status_count(cls, org, topics, status: str) -> int: + """ + Gets the count across the given topics and status. + """ + return org.counts.prefixes([f"tickets:{status}:{t.id}:" for t in topics]).sum() + @classmethod def get_topic_counts(cls, org, topics, status: str) -> dict[Topic, int]: """ - Gets the count for each topic and status. + Gets the count for each topic and the given status. """ # count scopes are stored as 'tickets:::' so get all counts with the prefix diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index de4d30f2dd1..c59d364d33a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -104,14 +104,28 @@ def test_counts(self, mr_mocks): def assert_counts( org, *, assignee_open: dict, assignee_closed: dict, topic_open: dict, topic_closed: dict, contacts: dict ): + all_topics = org.topics.filter(is_active=True) assignees = [None] + list(Ticket.get_allowed_assignees(org)) self.assertEqual(assignee_open, TicketCount.get_by_assignees(org, assignees, Ticket.STATUS_OPEN)) self.assertEqual(assignee_closed, TicketCount.get_by_assignees(org, assignees, Ticket.STATUS_CLOSED)) + self.assertEqual( + assignee_open, {u: Ticket.get_assignee_count(org, u, all_topics, Ticket.STATUS_OPEN) for u in assignees} + ) + self.assertEqual( + assignee_closed, + {u: Ticket.get_assignee_count(org, u, all_topics, Ticket.STATUS_CLOSED) for u in assignees}, + ) + self.assertEqual(sum(assignee_open.values()), TicketCount.get_all(org, Ticket.STATUS_OPEN)) self.assertEqual(sum(assignee_closed.values()), TicketCount.get_all(org, Ticket.STATUS_CLOSED)) + self.assertEqual(sum(assignee_open.values()), Ticket.get_status_count(org, all_topics, Ticket.STATUS_OPEN)) + self.assertEqual( + sum(assignee_closed.values()), Ticket.get_status_count(org, all_topics, Ticket.STATUS_CLOSED) + ) + self.assertEqual(topic_open, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_OPEN)) self.assertEqual(topic_closed, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_CLOSED)) @@ -785,8 +799,8 @@ def test_menu(self): self.agent, ["My Tickets (0)", "Unassigned (1)", "All (3)", "General (2)", "Sales (1)", "Support (0)"], ) - self.assertPageMenu(menu_url, self.agent2, ["My Tickets (0)", "Unassigned (1)", "All (3)", "Sales (1)"]) - self.assertPageMenu(menu_url, self.agent3, ["My Tickets (0)", "Unassigned (1)", "All (3)", "Support (0)"]) + self.assertPageMenu(menu_url, self.agent2, ["My Tickets (0)", "Unassigned (0)", "All (1)", "Sales (1)"]) + self.assertPageMenu(menu_url, self.agent3, ["My Tickets (0)", "Unassigned (0)", "All (0)", "Support (0)"]) @mock_mailroom def test_folder(self, mr_mocks): diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 93353fa9592..6f89fdd2b71 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -36,7 +36,6 @@ Shortcut, Team, Ticket, - TicketCount, TicketExport, TicketFolder, Topic, @@ -164,11 +163,11 @@ class Menu(BaseMenuView): def derive_menu(self): org = self.request.org user = self.request.user - count_by_assignee = TicketCount.get_by_assignees(org, [None, user], Ticket.STATUS_OPEN) + topics = Topic.get_accessible(org, user).order_by("-is_system", "name") counts = { - MineFolder.slug: count_by_assignee[user], - UnassignedFolder.slug: count_by_assignee[None], - AllFolder.slug: TicketCount.get_all(org, Ticket.STATUS_OPEN), + MineFolder.slug: Ticket.get_assignee_count(org, user, topics, Ticket.STATUS_OPEN), + UnassignedFolder.slug: Ticket.get_assignee_count(org, None, topics, Ticket.STATUS_OPEN), + AllFolder.slug: Ticket.get_status_count(org, topics, Ticket.STATUS_OPEN), } menu = [] @@ -200,7 +199,6 @@ def derive_menu(self): menu.append(self.create_divider()) - topics = list(Topic.get_accessible(org, user).order_by("-is_system", "name")) counts = Ticket.get_topic_counts(org, topics, Ticket.STATUS_OPEN) for topic in topics: menu.append( From 957fa43792f0414cfd873d06748e2646d20cf97d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 20:25:34 +0000 Subject: [PATCH 292/557] Prevent deletion of non-empty teams --- temba/tickets/tests.py | 23 +++++++++++++++++++---- temba/tickets/views.py | 5 +++++ templates/tickets/team_delete.html | 15 ++++++++++++--- templates/tickets/topic_delete.html | 11 +++++------ 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index d05d174287c..67a1e4c029f 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -451,15 +451,23 @@ def test_update(self): def test_delete(self): topic1 = Topic.create(self.org, self.admin, "Planes") topic2 = Topic.create(self.org, self.admin, "Trains") + ticket = self.create_ticket(self.create_contact("Bob", urns=["twitter:bobby"]), topic=topic1) delete_url = reverse("tickets.topic_delete", args=[topic1.id]) self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.admin2]) + # deleting blocked for topic with tickets response = self.assertDeleteFetch(delete_url, [self.editor, self.admin]) - self.assertContains(response, "You are about to delete") + self.assertContains(response, "Sorry, the Planes topic can't be deleted") + + ticket.topic = topic2 + ticket.save(update_fields=("topic",)) + + # try again... + response = self.assertDeleteFetch(delete_url, [self.editor, self.admin]) + self.assertContains(response, "You are about to delete the Planes topic") - # submit to delete it response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=topic1, success_status=302) # other topic unafected @@ -587,15 +595,22 @@ def test_delete(self): sales = Topic.create(self.org, self.admin, "Sales") team1 = Team.create(self.org, self.admin, "Sales", topics=[sales]) team2 = Team.create(self.org, self.admin, "Other", topics=[sales]) + self.org.add_user(self.agent, OrgRole.AGENT, team=team1) delete_url = reverse("tickets.team_delete", args=[team1.id]) self.assertRequestDisallowed(delete_url, [None, self.user, self.agent, self.editor, self.admin2]) + # deleting blocked for team with agents response = self.assertDeleteFetch(delete_url, [self.admin]) - self.assertContains(response, "You are about to delete") + self.assertContains(response, "Sorry, the Sales team can't be deleted") + + self.org.add_user(self.agent, OrgRole.AGENT, team=team2) + + # try again... + response = self.assertDeleteFetch(delete_url, [self.admin]) + self.assertContains(response, "You are about to delete the Sales team") - # submit to delete it response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=team1, success_status=302) # other team unafected diff --git a/temba/tickets/views.py b/temba/tickets/views.py index ccb03ce0c38..9caf492fd14 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -129,6 +129,11 @@ class Delete(BaseDeleteModal): cancel_url = "id@orgs.user_team" redirect_url = "@tickets.team_list" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["has_agents"] = self.object.get_users().exists() + return context + class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): require_feature = Org.FEATURE_TEAMS menu_path = "/settings/teams" diff --git a/templates/tickets/team_delete.html b/templates/tickets/team_delete.html index 23a3a0fca47..4d0330a09f2 100644 --- a/templates/tickets/team_delete.html +++ b/templates/tickets/team_delete.html @@ -2,7 +2,16 @@ {% load i18n %} {% block fields %} - {% blocktrans trimmed %} - You are about to delete the {{ object }} team. There is no way to undo this. Are you sure? - {% endblocktrans %} + {% if has_agents %} + {% blocktrans trimmed %} + Sorry, the {{ object }} team can't be deleted while it still has agents. Move the agents to another team first and then try again. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + You are about to delete the {{ object }} team. There is no way to undo this. Are you sure? + {% endblocktrans %} + {% endif %} {% endblock fields %} +{% block form-buttons %} + {% if not has_agents %}{% endif %} +{% endblock form-buttons %} diff --git a/templates/tickets/topic_delete.html b/templates/tickets/topic_delete.html index 66ffe0f11ad..96b61948607 100644 --- a/templates/tickets/topic_delete.html +++ b/templates/tickets/topic_delete.html @@ -1,15 +1,14 @@ -{% extends 'includes/modax.html' %} +{% extends "includes/modax.html" %} {% load i18n %} {% block fields %} {% if has_tickets %} - {% blocktrans with topic=object.name%} - Sorry, the topic {{ topic }} can't be deleted while it still has tickets. Move the tickets to another topic first and then try again. + {% blocktrans trimmed %} + Sorry, the {{ object }} topic can't be deleted while it still has tickets. Move the tickets to another topic first and then try again. {% endblocktrans %} {% else %} - {% blocktrans with topic=object.name%} - You are about to delete the topic {{ topic }}. - There is no way to undo this. Are you sure? + {% blocktrans trimmed %} + You are about to delete the {{ object }} topic. There is no way to undo this. Are you sure? {% endblocktrans %} {% endif %} {% endblock fields %} From 6c9e67e6b60535b5dc7a02acb309d273c200c5fa Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 21:06:34 +0000 Subject: [PATCH 293/557] Use new tickets counts for API endpoint --- temba/api/v2/views.py | 6 +++--- temba/contacts/tests.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index c494e46d9a2..b6e8bfedee0 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -23,7 +23,7 @@ from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, OptIn, SystemLabel from temba.orgs.models import OrgMembership, User from temba.orgs.views.mixins import OrgPermsMixin -from temba.tickets.models import Ticket, TicketCount, Topic +from temba.tickets.models import Ticket, Topic from temba.utils import str_to_bool from temba.utils.uuid import is_uuid @@ -3459,8 +3459,8 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) def prepare_for_serialization(self, object_list, using: str): - open_counts = TicketCount.get_by_topics(self.request.org, object_list, Ticket.STATUS_OPEN) - closed_counts = TicketCount.get_by_topics(self.request.org, object_list, Ticket.STATUS_CLOSED) + open_counts = Ticket.get_topic_counts(self.request.org, object_list, Ticket.STATUS_OPEN) + closed_counts = Ticket.get_topic_counts(self.request.org, object_list, Ticket.STATUS_CLOSED) for topic in object_list: topic.open_count = open_counts[topic] topic.closed_count = closed_counts[topic] diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 32f7e0fc837..637229822d7 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -31,7 +31,7 @@ from temba.schedules.models import Schedule from temba.tests import CRUDLTestMixin, MigrationTest, MockResponse, TembaTest, matchers, mock_mailroom from temba.tests.engine import MockSessionWriter -from temba.tickets.models import Ticket, TicketCount, Topic +from temba.tickets.models import Ticket, Topic from temba.triggers.models import Trigger from temba.utils import json, s3 from temba.utils.dates import datetime_to_timestamp @@ -1842,8 +1842,8 @@ def test_release(self, mr_mocks): self.assertEqual(2, len(contact.fields)) self.assertEqual(1, contact.campaign_fires.count()) - self.assertEqual(2, TicketCount.get_all(self.org, Ticket.STATUS_OPEN)) - self.assertEqual(1, TicketCount.get_all(self.org, Ticket.STATUS_CLOSED)) + self.assertEqual(2, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_OPEN)) + self.assertEqual(1, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_CLOSED)) # first try releasing with _full_release patched so we can check the state of the contact before the task # to do a full release has kicked off @@ -1879,8 +1879,8 @@ def test_release(self, mr_mocks): # tickets deleted (only for this contact) self.assertEqual(0, contact.tickets.count()) - self.assertEqual(1, TicketCount.get_all(self.org, Ticket.STATUS_OPEN)) - self.assertEqual(0, TicketCount.get_all(self.org, Ticket.STATUS_CLOSED)) + self.assertEqual(1, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_OPEN)) + self.assertEqual(10, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_CLOSED)) # contact who used to own our urn had theirs released too self.assertEqual(0, old_contact.calls.all().count()) From 0bdd12ed95e9650f9df5cec227305a9ca51a8376 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 21:22:33 +0000 Subject: [PATCH 294/557] Stop writing old ticket counts --- temba/sql/current_functions.sql | 40 +---------- temba/sql/current_triggers.sql | 2 +- .../migrations/0071_backfill_item_counts.py | 2 +- .../migrations/0072_drop_old_triggers.py | 39 +++++++++++ temba/tickets/models.py | 68 +------------------ temba/tickets/tasks.py | 3 +- temba/tickets/tests.py | 49 +------------ 7 files changed, 47 insertions(+), 156 deletions(-) create mode 100644 temba/tickets/migrations/0072_drop_old_triggers.py diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index a6cc2601bae..5e9bb6472dd 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-30 20:32 UTC +-- Generated by collect_sql on 2024-10-31 21:17 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row @@ -394,26 +394,6 @@ BEGIN END; $$ LANGUAGE plpgsql; ----------------------------------------------------------------------- --- Inserts a new assignee ticketcount row with the given values ----------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_insert_ticketcount_for_assignee(_org_id INTEGER, _assignee_id INTEGER, status CHAR(1), _count INT) RETURNS VOID AS $$ -BEGIN - INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") - VALUES(_org_id, format('assignee:%s', coalesce(_assignee_id, 0)), status, _count, FALSE); -END; -$$ LANGUAGE plpgsql; - ----------------------------------------------------------------------- --- Inserts a new topic ticketcount row with the given values ----------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_insert_ticketcount_for_topic(_org_id INTEGER, _topic_id INTEGER, status CHAR(1), _count INT) RETURNS VOID AS $$ -BEGIN - INSERT INTO tickets_ticketcount("org_id", "scope", "status", "count", "is_squashed") - VALUES(_org_id, format('topic:%s', _topic_id), status, _count, FALSE); -END; -$$ LANGUAGE plpgsql; - ---------------------------------------------------------------------- -- Handles DELETE statements on ivr_call table ---------------------------------------------------------------------- @@ -654,37 +634,21 @@ END; $$ LANGUAGE plpgsql; ---------------------------------------------------------------------- --- Trigger procedure to update user and system labels on column changes +-- Trigger procedure to update contact ticket counts ---------------------------------------------------------------------- CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN -- new ticket inserted - PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); - PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); - IF NEW.status = 'O' THEN UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; END IF; ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated - IF OLD.assignee_id IS DISTINCT FROM NEW.assignee_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_assignee(NEW.org_id, NEW.assignee_id, NEW.status, 1); - END IF; - - IF OLD.topic_id != NEW.topic_id OR OLD.status != NEW.status THEN - PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_topic(NEW.org_id, NEW.topic_id, NEW.status, 1); - END IF; - IF OLD.status = 'O' AND NEW.status = 'C' THEN -- ticket closed UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; ELSIF OLD.status = 'C' AND NEW.status = 'O' THEN -- ticket reopened UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; END IF; ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted - PERFORM temba_insert_ticketcount_for_assignee(OLD.org_id, OLD.assignee_id, OLD.status, -1); - PERFORM temba_insert_ticketcount_for_topic(OLD.org_id, OLD.topic_id, OLD.status, -1); - IF OLD.status = 'O' THEN -- open ticket deleted UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; END IF; diff --git a/temba/sql/current_triggers.sql b/temba/sql/current_triggers.sql index a31fbdedfe0..36661be93f8 100644 --- a/temba/sql/current_triggers.sql +++ b/temba/sql/current_triggers.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-30 20:32 UTC +-- Generated by collect_sql on 2024-10-31 21:17 UTC CREATE TRIGGER temba_broadcast_on_delete AFTER DELETE ON msgs_broadcast REFERENCING OLD TABLE AS oldtab diff --git a/temba/tickets/migrations/0071_backfill_item_counts.py b/temba/tickets/migrations/0071_backfill_item_counts.py index 18d18dc0d7f..a489cff0ec9 100644 --- a/temba/tickets/migrations/0071_backfill_item_counts.py +++ b/temba/tickets/migrations/0071_backfill_item_counts.py @@ -4,7 +4,7 @@ from django.db.models import Count -def backfill_item_counts(apps, schema_editor): +def backfill_item_counts(apps, schema_editor): # pragma: no cover Org = apps.get_model("orgs", "Org") for org in Org.objects.all(): diff --git a/temba/tickets/migrations/0072_drop_old_triggers.py b/temba/tickets/migrations/0072_drop_old_triggers.py new file mode 100644 index 00000000000..fa3231467d0 --- /dev/null +++ b/temba/tickets/migrations/0072_drop_old_triggers.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.2 on 2024-10-31 21:11 + +from django.db import migrations + +SQL = """ +---------------------------------------------------------------------- +-- Trigger procedure to update contact ticket counts +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_ticket_on_change() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN -- new ticket inserted + IF NEW.status = 'O' THEN + UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = NEW.contact_id; + END IF; + ELSIF TG_OP = 'UPDATE' THEN -- existing ticket updated + IF OLD.status = 'O' AND NEW.status = 'C' THEN -- ticket closed + UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; + ELSIF OLD.status = 'C' AND NEW.status = 'O' THEN -- ticket reopened + UPDATE contacts_contact SET ticket_count = ticket_count + 1, modified_on = NOW() WHERE id = OLD.contact_id; + END IF; + ELSIF TG_OP = 'DELETE' THEN -- existing ticket deleted + IF OLD.status = 'O' THEN -- open ticket deleted + UPDATE contacts_contact SET ticket_count = ticket_count - 1, modified_on = NOW() WHERE id = OLD.contact_id; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION temba_insert_ticketcount_for_assignee(INTEGER, INTEGER, CHAR(1), INT); +DROP FUNCTION temba_insert_ticketcount_for_topic(INTEGER, INTEGER, CHAR(1), INT); +""" + + +class Migration(migrations.Migration): + + dependencies = [("tickets", "0071_backfill_item_counts")] + + operations = [migrations.RunSQL(SQL)] diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 8138824002b..6f1f30967cd 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -423,84 +423,18 @@ def get_queryset(self, org, user, *, ordered: bool): class TicketCount(SquashableModel): """ - Counts of tickets by assignment/topic and status + TODO drop """ - squash_over = ("org_id", "scope", "status") - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="ticket_counts") scope = models.CharField(max_length=32) status = models.CharField(max_length=1, choices=Ticket.STATUS_CHOICES) count = models.IntegerField(default=0) - @classmethod - def get_squash_query(cls, distinct_set) -> tuple: - sql = """ - WITH removed as ( - DELETE FROM %(table)s WHERE "org_id" = %%s AND "scope" = %%s AND "status" = %%s RETURNING "count" - ) - INSERT INTO %(table)s("org_id", "scope", "status", "count", "is_squashed") - VALUES (%%s, %%s, %%s, GREATEST(0, (SELECT SUM("count") FROM removed)), TRUE); - """ % { - "table": cls._meta.db_table - } - - params = (distinct_set.org_id, distinct_set.scope, distinct_set.status) * 2 - - return sql, params - - @classmethod - def get_by_assignees(cls, org, assignees: list, status: str) -> dict: - """ - Gets counts for a set of assignees (None means no assignee) - """ - - scopes = [cls._assignee_scope(a) for a in assignees] - counts = ( - cls.objects.filter(org=org, scope__in=scopes, status=status) - .values_list("scope") - .annotate(count_sum=Sum("count")) - ) - counts_by_scope = {c[0]: c[1] for c in counts} - - return {a: counts_by_scope.get(cls._assignee_scope(a), 0) for a in assignees} - - @classmethod - def get_by_topics(cls, org, topics: list, status: str) -> dict: - """ - Gets counts for a set of topics - """ - - scopes = [cls._topic_scope(t) for t in topics] - counts = ( - cls.objects.filter(org=org, scope__in=scopes, status=status) - .values_list("scope") - .annotate(count_sum=Sum("count")) - ) - counts_by_scope = {c[0]: c[1] for c in counts} - - return {t: counts_by_scope.get(cls._topic_scope(t), 0) for t in topics} - - @classmethod - def get_all(cls, org, status: str) -> int: - """ - Gets count for org and status regardless of assignee - """ - return cls.sum(cls.objects.filter(org=org, scope__startswith="assignee:", status=status)) - - @staticmethod - def _assignee_scope(user) -> str: - return f"assignee:{user.id if user else 0}" - - @staticmethod - def _topic_scope(topic) -> str: - return f"topic:{topic.id}" - class Meta: indexes = [ models.Index(fields=("org", "status")), models.Index(fields=("org", "scope", "status")), - # for squashing task models.Index( name="ticket_count_unsquashed", fields=("org", "scope", "status"), condition=Q(is_squashed=False) ), diff --git a/temba/tickets/tasks.py b/temba/tickets/tasks.py index 021a8b1587f..c95bff4299b 100644 --- a/temba/tickets/tasks.py +++ b/temba/tickets/tasks.py @@ -1,10 +1,9 @@ from temba.utils.crons import cron_task -from .models import TicketCount, TicketDailyCount, TicketDailyTiming +from .models import TicketDailyCount, TicketDailyTiming @cron_task(lock_timeout=7200) def squash_ticket_counts(): - TicketCount.squash() TicketDailyCount.squash() TicketDailyTiming.squash() diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 061caed4553..df669ae378a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -11,7 +11,7 @@ from temba.contacts.models import Contact, ContactField, ContactURN from temba.orgs.models import Export, Org, OrgMembership, OrgRole from temba.orgs.tasks import squash_item_counts -from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers, mock_mailroom +from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp from temba.utils.uuid import uuid4 @@ -19,7 +19,6 @@ Shortcut, Team, Ticket, - TicketCount, TicketDailyCount, TicketDailyTiming, TicketEvent, @@ -27,7 +26,6 @@ Topic, export_ticket_stats, ) -from .tasks import squash_ticket_counts class TicketTest(TembaTest): @@ -107,9 +105,6 @@ def assert_counts( all_topics = org.topics.filter(is_active=True) assignees = [None] + list(Ticket.get_allowed_assignees(org)) - self.assertEqual(assignee_open, TicketCount.get_by_assignees(org, assignees, Ticket.STATUS_OPEN)) - self.assertEqual(assignee_closed, TicketCount.get_by_assignees(org, assignees, Ticket.STATUS_CLOSED)) - self.assertEqual( assignee_open, {u: Ticket.get_assignee_count(org, u, all_topics, Ticket.STATUS_OPEN) for u in assignees} ) @@ -118,17 +113,11 @@ def assert_counts( {u: Ticket.get_assignee_count(org, u, all_topics, Ticket.STATUS_CLOSED) for u in assignees}, ) - self.assertEqual(sum(assignee_open.values()), TicketCount.get_all(org, Ticket.STATUS_OPEN)) - self.assertEqual(sum(assignee_closed.values()), TicketCount.get_all(org, Ticket.STATUS_CLOSED)) - self.assertEqual(sum(assignee_open.values()), Ticket.get_status_count(org, all_topics, Ticket.STATUS_OPEN)) self.assertEqual( sum(assignee_closed.values()), Ticket.get_status_count(org, all_topics, Ticket.STATUS_CLOSED) ) - self.assertEqual(topic_open, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_OPEN)) - self.assertEqual(topic_closed, TicketCount.get_by_topics(org, list(org.topics.all()), Ticket.STATUS_CLOSED)) - self.assertEqual(topic_open, Ticket.get_topic_counts(org, list(org.topics.all()), Ticket.STATUS_OPEN)) self.assertEqual(topic_closed, Ticket.get_topic_counts(org, list(org.topics.all()), Ticket.STATUS_CLOSED)) @@ -220,7 +209,7 @@ def assert_counts( contacts={contact1: 2, contact2: 2}, ) - squash_ticket_counts() # shouldn't change counts + squash_item_counts() # shouldn't change counts assert_counts( self.org, @@ -1846,37 +1835,3 @@ def _record_last_close(self, org, d: date, seconds: int, undo: bool = False): TicketDailyTiming.objects.create( count_type=TicketDailyTiming.TYPE_LAST_CLOSE, scope=f"o:{org.id}", day=d, count=count, seconds=seconds ) - - -class BackfillItemCountsTest(MigrationTest): - app = "tickets" - migrate_from = "0070_update_triggers" - migrate_to = "0071_backfill_item_counts" - - def setUpBeforeMigration(self, apps): - self.general = self.org.default_ticket_topic - self.cats = Topic.create(self.org, self.admin, "Cats") - - contact1 = self.create_contact("Bob", urns=["twitter:bobby"]) - contact2 = self.create_contact("Jim", urns=["twitter:jimmy"]) - - org2_general = self.org2.default_ticket_topic - org2_contact = self.create_contact("Bob", urns=["twitter:bobby"], org=self.org2) - - self.create_ticket(contact1, topic=self.general, assignee=self.admin) - self.create_ticket(contact2, topic=self.general, assignee=self.admin) - self.create_ticket(contact1, topic=self.general, assignee=self.admin, closed_on=timezone.now()) - self.create_ticket(contact2, topic=self.cats, assignee=self.agent) - self.create_ticket(contact1, topic=self.cats) - self.create_ticket(org2_contact, topic=org2_general) - - def test_migration(self): - self.assertEqual( - { - f"tickets:C:{self.general.id}:{self.admin.id}": 1, - f"tickets:O:{self.general.id}:{self.admin.id}": 2, - f"tickets:O:{self.cats.id}:0": 1, - f"tickets:O:{self.cats.id}:{self.agent.id}": 1, - }, - {c["scope"]: c["count"] for c in self.org.counts.order_by("scope").values("scope", "count")}, - ) From 54c948cf9512a82b88c401ef20466a4c3378a257 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 21:06:34 +0000 Subject: [PATCH 295/557] Use new tickets counts for API endpoint --- temba/api/v2/views.py | 6 +++--- temba/contacts/tests.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index c494e46d9a2..b6e8bfedee0 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -23,7 +23,7 @@ from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, OptIn, SystemLabel from temba.orgs.models import OrgMembership, User from temba.orgs.views.mixins import OrgPermsMixin -from temba.tickets.models import Ticket, TicketCount, Topic +from temba.tickets.models import Ticket, Topic from temba.utils import str_to_bool from temba.utils.uuid import is_uuid @@ -3459,8 +3459,8 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) def prepare_for_serialization(self, object_list, using: str): - open_counts = TicketCount.get_by_topics(self.request.org, object_list, Ticket.STATUS_OPEN) - closed_counts = TicketCount.get_by_topics(self.request.org, object_list, Ticket.STATUS_CLOSED) + open_counts = Ticket.get_topic_counts(self.request.org, object_list, Ticket.STATUS_OPEN) + closed_counts = Ticket.get_topic_counts(self.request.org, object_list, Ticket.STATUS_CLOSED) for topic in object_list: topic.open_count = open_counts[topic] topic.closed_count = closed_counts[topic] diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 32f7e0fc837..d7e14528c7d 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -31,7 +31,7 @@ from temba.schedules.models import Schedule from temba.tests import CRUDLTestMixin, MigrationTest, MockResponse, TembaTest, matchers, mock_mailroom from temba.tests.engine import MockSessionWriter -from temba.tickets.models import Ticket, TicketCount, Topic +from temba.tickets.models import Ticket, Topic from temba.triggers.models import Trigger from temba.utils import json, s3 from temba.utils.dates import datetime_to_timestamp @@ -1842,8 +1842,8 @@ def test_release(self, mr_mocks): self.assertEqual(2, len(contact.fields)) self.assertEqual(1, contact.campaign_fires.count()) - self.assertEqual(2, TicketCount.get_all(self.org, Ticket.STATUS_OPEN)) - self.assertEqual(1, TicketCount.get_all(self.org, Ticket.STATUS_CLOSED)) + self.assertEqual(2, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_OPEN)) + self.assertEqual(1, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_CLOSED)) # first try releasing with _full_release patched so we can check the state of the contact before the task # to do a full release has kicked off @@ -1879,8 +1879,8 @@ def test_release(self, mr_mocks): # tickets deleted (only for this contact) self.assertEqual(0, contact.tickets.count()) - self.assertEqual(1, TicketCount.get_all(self.org, Ticket.STATUS_OPEN)) - self.assertEqual(0, TicketCount.get_all(self.org, Ticket.STATUS_CLOSED)) + self.assertEqual(1, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_OPEN)) + self.assertEqual(0, Ticket.get_status_count(self.org, self.org.topics.all(), Ticket.STATUS_CLOSED)) # contact who used to own our urn had theirs released too self.assertEqual(0, old_contact.calls.all().count()) From eae580116345317e820b73dd6da22e08afaee71c Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 31 Oct 2024 14:42:26 -0700 Subject: [PATCH 296/557] Fix the My Tickets icon, wasn't always accurate --- package.json | 2 +- temba/tickets/models.py | 6 ++++++ temba/tickets/views.py | 2 +- yarn.lock | 8 ++++---- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e7b65a1e286..fec6bc05a17 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.110.0", + "@nyaruka/temba-components": "0.110.3", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 8138824002b..62c61acbbf8 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -340,6 +340,9 @@ class TicketFolder(metaclass=ABCMeta): icon = None verbose_name = None + def get_icon(self, count) -> str: + return self.icon + def get_queryset(self, org, user, *, ordered: bool): qs = org.tickets.all() @@ -375,6 +378,9 @@ class MineFolder(TicketFolder): name = _("My Tickets") icon = "tickets_mine" + def get_icon(self, count) -> str: + return self.icon if count else "tickets_mine_done" + def get_queryset(self, org, user, *, ordered: bool): return super().get_queryset(org, user, ordered=ordered).filter(assignee=user) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index e8f17a4ff88..9360d37e96d 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -181,7 +181,7 @@ def derive_menu(self): { "id": folder.slug, "name": folder.name, - "icon": folder.icon, + "icon": folder.get_icon(counts[folder.slug]), "count": counts[folder.slug], "href": f"/ticket/{folder.slug}/open/", } diff --git a/yarn.lock b/yarn.lock index ccb615c765a..29d0e314b4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.110.0": - version "0.110.0" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.110.0.tgz#bdb6fb20fe1d073e086fcc39d940f3d279dec82f" - integrity sha512-PGPNTV09tKUu7FoQUnEhdn7tVt8xNRM+ZBPjDvjKibNM1QAfHPcTK2RfnTt1ZI4HU/LlDHiVqjid8F9EgXnV1Q== +"@nyaruka/temba-components@0.110.3": + version "0.110.3" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.110.3.tgz#a20bb373f426ec659db71a2b9811de009e33970b" + integrity sha512-UlLsPvcIBRrfyHvfQA7p8jllMRtNp17gr/siTDE/dKsQp0fJmiU8LanRXCB2B85J5Cr227HM/CzZW89X1ajiiQ== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From b7ec700519c1f85201e67aa2f3f3cf6c2d615954 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 22:02:38 +0000 Subject: [PATCH 297/557] Fix browsing definitions API endpoint docs --- temba/api/v2/tests.py | 5 +++++ temba/api/v2/views.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index 60fb343b572..cfe8c509346 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -2891,6 +2891,11 @@ def test_definitions(self): raw=lambda j: len(j["flows"]) == 1 and j["flows"][0]["spec_version"] == Flow.CURRENT_SPEC_VERSION, ) + # test fetching docs anonymously + self.client.logout() + response = self.client.get(reverse("api.v2.definitions")) + self.assertContains(response, "Deprecated endpoint") + @override_settings(ORG_LIMIT_DEFAULTS={"fields": 10}) def test_fields(self): endpoint_url = reverse("api.v2.fields") + ".json" diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index b6e8bfedee0..4822892055b 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -1505,6 +1505,9 @@ class Depends(Enum): all = 2 def get(self, request, *args, **kwargs): + if self.is_docs(): + return Response({}) + org = request.org params = request.query_params flow_uuids = params.getlist("flow") From d21dfe4875ef2bfec866ac56f635abe7feb959f0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 22:05:10 +0000 Subject: [PATCH 298/557] Coverage --- temba/tickets/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index df669ae378a..010522209f8 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -26,6 +26,7 @@ Topic, export_ticket_stats, ) +from .tasks import squash_ticket_counts class TicketTest(TembaTest): @@ -1810,7 +1811,7 @@ def assert_timings(): assert_timings() - TicketDailyTiming.squash() + squash_ticket_counts() assert_timings() From 2bbcb1aaf883a6557260a08c789403ff5e376069 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 31 Oct 2024 17:17:28 -0500 Subject: [PATCH 299/557] Update CHANGELOG.md for v9.3.88 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5490fb3c9a..7bcc59cdde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +v9.3.88 (2024-10-31) +------------------------- + * Fix browsing definitions API endpoint docs + * Fix the My Tickets icon, wasn't always accurate + * Prevent deletion of non-empty teams + * Start reading from new ticket counts + v9.3.87 (2024-10-31) ------------------------- * Make shortcuts an optional attribute on compose diff --git a/pyproject.toml b/pyproject.toml index 92d8948ba53..5fb9d8f1554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.87" +version = "9.3.88" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index cfca1158e38..a6ad405b3a4 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.87" +__version__ = "9.3.88" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 1a38e517a8b59c317801fc80131a3baeb0df7b9f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 13:16:51 +0000 Subject: [PATCH 300/557] Fix calculating field usages on API endpoint --- temba/api/v2/tests.py | 17 ++++++++++++++--- temba/api/v2/views.py | 17 +++++++++++++---- temba/utils/db/queries.py | 9 +++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 temba/utils/db/queries.py diff --git a/temba/api/v2/tests.py b/temba/api/v2/tests.py index cfe8c509346..02dfc35b65e 100644 --- a/temba/api/v2/tests.py +++ b/temba/api/v2/tests.py @@ -2908,11 +2908,22 @@ def test_fields(self): registered = self.create_field("registered", "Registered On", value_type=ContactField.TYPE_DATETIME) self.create_field("not_ours", "Something Else", org=self.org2) - # add our date field to a campaign event + # add our date field to some campaign events campaign = Campaign.create(self.org, self.admin, "Reminders", self.create_group("Farmers")) CampaignEvent.create_flow_event( - self.org, self.admin, campaign, registered, offset=1, unit="W", flow=self.create_flow("Flow") + self.org, self.admin, campaign, registered, offset=1, unit="W", flow=self.create_flow("Event 1") ) + CampaignEvent.create_flow_event( + self.org, self.admin, campaign, registered, offset=2, unit="W", flow=self.create_flow("Event 2") + ) + + # and some regular flows + self.create_flow("Flow 1").field_dependencies.add(registered) + self.create_flow("Flow 2").field_dependencies.add(registered) + self.create_flow("Flow 3").field_dependencies.add(registered) + + # and a group + self.create_group("Farmers").query_fields.add(registered) deleted = self.create_field("deleted", "Deleted") deleted.release(self.admin) @@ -2928,7 +2939,7 @@ def test_fields(self): "type": "datetime", "featured": False, "priority": 0, - "usages": {"campaign_events": 1, "flows": 0, "groups": 0}, + "usages": {"campaign_events": 2, "flows": 3, "groups": 1}, "agent_access": "view", "label": "Registered On", "value_type": "datetime", diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index 4822892055b..a90f66a3972 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -9,7 +9,7 @@ from rest_framework.reverse import reverse from smartmin.views import SmartTemplateView -from django.db.models import Count, Prefetch, Q +from django.db.models import OuterRef, Prefetch, Q from django.utils.translation import gettext_lazy as _ from temba.archives.models import Archive @@ -25,6 +25,7 @@ from temba.orgs.views.mixins import OrgPermsMixin from temba.tickets.models import Ticket, Topic from temba.utils import str_to_bool +from temba.utils.db.queries import SubqueryCount from temba.utils.uuid import is_uuid from ..models import APIPermission, Resthook, ResthookSubscriber, SSLPermission, WebHookEvent @@ -1636,9 +1637,17 @@ def derive_queryset(self): org = self.request.org return ( self.model.objects.filter(org=org, is_active=True, is_proxy=False) - .annotate(flow_count=Count("dependent_flows", filter=Q(dependent_flows__is_active=True))) - .annotate(group_count=Count("dependent_groups", filter=Q(dependent_groups__is_active=True))) - .annotate(campaignevent_count=Count("campaign_events", filter=Q(campaign_events__is_active=True))) + .annotate( + flow_count=SubqueryCount(Flow.objects.filter(field_dependencies__id=OuterRef("id"), is_active=True)) + ) + .annotate( + group_count=SubqueryCount(ContactGroup.objects.filter(query_fields__id=OuterRef("id"), is_active=True)) + ) + .annotate( + campaignevent_count=SubqueryCount( + CampaignEvent.objects.filter(relative_to__id=OuterRef("id"), is_active=True) + ) + ) ) def filter_queryset(self, queryset): diff --git a/temba/utils/db/queries.py b/temba/utils/db/queries.py new file mode 100644 index 00000000000..990473d1da2 --- /dev/null +++ b/temba/utils/db/queries.py @@ -0,0 +1,9 @@ +from django.db import models +from django.db.models import Subquery + + +class SubqueryCount(Subquery): + # Count(..) in Django uses grouping and breaks for more than 1 annotated column. + # See https://stackoverflow.com/a/47371514/1164966 + template = "(SELECT count(*) FROM (%(subquery)s) _count)" + output_field = models.IntegerField() From 7615e1b4b398b959bdbb9d03ba791978d4c9653b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 09:14:12 -0500 Subject: [PATCH 301/557] Update CHANGELOG.md for v9.3.89 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bcc59cdde0..338b2f8b74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.89 (2024-11-05) +------------------------- + * Fix calculating field usages on API endpoint + * Stop writing old ticket counts + v9.3.88 (2024-10-31) ------------------------- * Fix browsing definitions API endpoint docs diff --git a/pyproject.toml b/pyproject.toml index 5fb9d8f1554..794c01e79cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.88" +version = "9.3.89" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index a6ad405b3a4..d3940731f12 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.88" +__version__ = "9.3.89" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 16b386987b9e0f78753ecf51c213ae28995ad440 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 15:36:27 +0000 Subject: [PATCH 302/557] Start writing notification counts to orgs_itemcount --- .../migrations/0025_update_triggers.py | 84 +++++++++++++++++++ temba/notifications/models.py | 4 + temba/notifications/tests.py | 46 +++++----- temba/sql/current_functions.sql | 68 +++++++++++++-- temba/sql/current_triggers.sql | 14 +++- 5 files changed, 185 insertions(+), 31 deletions(-) create mode 100644 temba/notifications/migrations/0025_update_triggers.py diff --git a/temba/notifications/migrations/0025_update_triggers.py b/temba/notifications/migrations/0025_update_triggers.py new file mode 100644 index 00000000000..9a437492feb --- /dev/null +++ b/temba/notifications/migrations/0025_update_triggers.py @@ -0,0 +1,84 @@ +# Generated by Django 5.1.2 on 2024-11-05 15:08 + +from django.db import migrations + +SQL = """ +---------------------------------------------------------------------- +-- Determines the item count scope for a notification +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_countscope(_notification notifications_notification) RETURNS TEXT STABLE AS $$ +BEGIN + RETURN format('notifications:%s:%s', _notification.user_id, CASE WHEN _notification.is_seen THEN 'S' ELSE 'U' END); +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles DELETE statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_delete() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_notification_countscope(oldtab), -count(*), FALSE FROM oldtab + WHERE position('U' IN medium) > 0 GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles INSERT statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_insert() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_notification_countscope(newtab), count(*), FALSE FROM newtab + WHERE position('U' IN medium) > 0 GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles UPDATE statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_update() RETURNS TRIGGER AS $$ +BEGIN + -- add negative counts for all old count scopes that don't match the new ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT o.org_id, temba_notification_countscope(o), -count(*), FALSE FROM oldtab o + INNER JOIN newtab n ON n.id = o.id + WHERE position('U' IN o.medium) > 0 AND temba_notification_countscope(o) != temba_notification_countscope(n) + GROUP BY 1, 2; + + -- add positive counts for all new count scopes that don't match the old ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT n.org_id, temba_notification_countscope(n), count(*), FALSE FROM newtab n + INNER JOIN oldtab o ON o.id = n.id + WHERE position('U' IN n.medium) > 0 AND temba_notification_countscope(o) != temba_notification_countscope(n) + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER temba_notification_on_delete +AFTER DELETE ON notifications_notification REFERENCING OLD TABLE AS oldtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_delete(); + +CREATE TRIGGER temba_notification_on_insert +AFTER INSERT ON notifications_notification REFERENCING NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_insert(); + +CREATE TRIGGER temba_notification_on_update +AFTER UPDATE ON notifications_notification REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_update(); + +DROP FUNCTION extract_jsonb_keys(_jsonb JSONB); +""" + + +class Migration(migrations.Migration): + + dependencies = [("notifications", "0024_notification_data_and_more")] + + operations = [migrations.RunSQL(SQL)] diff --git a/temba/notifications/models.py b/temba/notifications/models.py index 6ce84985c89..da75d254d47 100644 --- a/temba/notifications/models.py +++ b/temba/notifications/models.py @@ -226,6 +226,10 @@ def mark_seen(cls, org, user, notification_type: str = None, *, scope: str = Non def get_unseen_count(cls, org: Org, user: User) -> int: return NotificationCount.get_total(org, user) + @classmethod + def get_unseen_count_new(cls, org: Org, user: User) -> int: + return org.counts.filter(scope=f"notifications:{user.id}:U").sum() + @property def type(self): from .types import TYPES # noqa diff --git a/temba/notifications/tests.py b/temba/notifications/tests.py index a6effad8d65..240ca809618 100644 --- a/temba/notifications/tests.py +++ b/temba/notifications/tests.py @@ -529,7 +529,7 @@ def test_invitation_accepted(self): self.assertEqual(["admin@nyaruka.com"], mail.outbox[0].recipients()) # previous address self.assertIn("User bob@nyaruka.com accepted an invitation to join your workspace.", mail.outbox[0].body) - def test_get_unseen_count(self): + def test_counts(self): imp = ContactImport.objects.create( org=self.org, mappings={}, num_records=5, created_by=self.editor, modified_by=self.editor ) @@ -543,38 +543,42 @@ def test_get_unseen_count(self): self.org2, "tickets:activity", scope="", users=[self.editor], medium="UE" ) # different org - self.assertEqual(2, Notification.get_unseen_count(self.org, self.agent)) - self.assertEqual(3, Notification.get_unseen_count(self.org, self.editor)) - self.assertEqual(0, Notification.get_unseen_count(self.org2, self.agent)) - self.assertEqual(1, Notification.get_unseen_count(self.org2, self.editor)) + def assert_count(org, user, expected: int): + self.assertEqual(expected, Notification.get_unseen_count(org, user)) + self.assertEqual(expected, Notification.get_unseen_count_new(org, user)) + + assert_count(self.org, self.agent, 2) + assert_count(self.org, self.editor, 3) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) Notification.mark_seen(self.org, self.agent, "tickets:activity", scope="") - self.assertEqual(1, Notification.get_unseen_count(self.org, self.agent)) - self.assertEqual(3, Notification.get_unseen_count(self.org, self.editor)) - self.assertEqual(0, Notification.get_unseen_count(self.org2, self.agent)) - self.assertEqual(1, Notification.get_unseen_count(self.org2, self.editor)) + assert_count(self.org, self.agent, 1) + assert_count(self.org, self.editor, 3) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) Notification.objects.filter(org=self.org, user=self.editor, notification_type="tickets:opened").delete() - self.assertEqual(1, Notification.get_unseen_count(self.org, self.agent)) - self.assertEqual(2, Notification.get_unseen_count(self.org, self.editor)) - self.assertEqual(0, Notification.get_unseen_count(self.org2, self.agent)) - self.assertEqual(1, Notification.get_unseen_count(self.org2, self.editor)) + assert_count(self.org, self.agent, 1) + assert_count(self.org, self.editor, 2) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) squash_notification_counts() - self.assertEqual(1, Notification.get_unseen_count(self.org, self.agent)) - self.assertEqual(2, Notification.get_unseen_count(self.org, self.editor)) - self.assertEqual(0, Notification.get_unseen_count(self.org2, self.agent)) - self.assertEqual(1, Notification.get_unseen_count(self.org2, self.editor)) + assert_count(self.org, self.agent, 1) + assert_count(self.org, self.editor, 2) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) Notification.mark_seen(self.org, self.editor) - self.assertEqual(1, Notification.get_unseen_count(self.org, self.agent)) - self.assertEqual(0, Notification.get_unseen_count(self.org, self.editor)) - self.assertEqual(0, Notification.get_unseen_count(self.org2, self.agent)) - self.assertEqual(1, Notification.get_unseen_count(self.org2, self.editor)) + assert_count(self.org, self.agent, 1) + assert_count(self.org, self.editor, 0) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) def test_trim_task(self): self.org.suspend() diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index 5e9bb6472dd..0081ff63c07 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-31 21:17 UTC +-- Generated by collect_sql on 2024-11-05 15:35 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row @@ -41,14 +41,6 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE OR REPLACE FUNCTION - extract_jsonb_keys(_jsonb JSONB) -RETURNS TEXT[] AS $$ -BEGIN - RETURN ARRAY(SELECT * FROM JSONB_OBJECT_KEYS(_jsonb)); -END; -$$ IMMUTABLE LANGUAGE plpgsql; - ---------------------------------------------------------------------- -- Determines the (mutually exclusive) system label for a broadcast record ---------------------------------------------------------------------- @@ -604,6 +596,15 @@ BEGIN END; $$ LANGUAGE plpgsql; +---------------------------------------------------------------------- +-- Determines the item count scope for a notification +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_countscope(_notification notifications_notification) RETURNS TEXT STABLE AS $$ +BEGIN + RETURN format('notifications:%s:%s', _notification.user_id, CASE WHEN _notification.is_seen THEN 'S' ELSE 'U' END); +END; +$$ LANGUAGE plpgsql; + ---------------------------------------------------------------------- -- Trigger procedure to notification counts on notification changes ---------------------------------------------------------------------- @@ -624,6 +625,55 @@ BEGIN END; $$ LANGUAGE plpgsql; +---------------------------------------------------------------------- +-- Handles DELETE statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_delete() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_notification_countscope(oldtab), -count(*), FALSE FROM oldtab + WHERE position('U' IN medium) > 0 GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles INSERT statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_insert() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT org_id, temba_notification_countscope(newtab), count(*), FALSE FROM newtab + WHERE position('U' IN medium) > 0 GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +---------------------------------------------------------------------- +-- Handles UPDATE statements on notification table +---------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION temba_notification_on_update() RETURNS TRIGGER AS $$ +BEGIN + -- add negative counts for all old count scopes that don't match the new ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT o.org_id, temba_notification_countscope(o), -count(*), FALSE FROM oldtab o + INNER JOIN newtab n ON n.id = o.id + WHERE position('U' IN o.medium) > 0 AND temba_notification_countscope(o) != temba_notification_countscope(n) + GROUP BY 1, 2; + + -- add positive counts for all new count scopes that don't match the old ones + INSERT INTO orgs_itemcount("org_id", "scope", "count", "is_squashed") + SELECT n.org_id, temba_notification_countscope(n), count(*), FALSE FROM newtab n + INNER JOIN oldtab o ON o.id = n.id + WHERE position('U' IN n.medium) > 0 AND temba_notification_countscope(o) != temba_notification_countscope(n) + GROUP BY 1, 2; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + ---------------------------------------------------------------------- -- Determines the item count scope for a ticket ---------------------------------------------------------------------- diff --git a/temba/sql/current_triggers.sql b/temba/sql/current_triggers.sql index 36661be93f8..6cc961b7948 100644 --- a/temba/sql/current_triggers.sql +++ b/temba/sql/current_triggers.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-10-31 21:17 UTC +-- Generated by collect_sql on 2024-11-05 15:35 UTC CREATE TRIGGER temba_broadcast_on_delete AFTER DELETE ON msgs_broadcast REFERENCING OLD TABLE AS oldtab @@ -86,6 +86,18 @@ CREATE TRIGGER temba_msg_on_update AFTER UPDATE ON msgs_msg REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_msg_on_update(); +CREATE TRIGGER temba_notification_on_delete +AFTER DELETE ON notifications_notification REFERENCING OLD TABLE AS oldtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_delete(); + +CREATE TRIGGER temba_notification_on_insert +AFTER INSERT ON notifications_notification REFERENCING NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_insert(); + +CREATE TRIGGER temba_notification_on_update +AFTER UPDATE ON notifications_notification REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab +FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_update(); + CREATE TRIGGER temba_notifications_update_notificationcount AFTER INSERT OR UPDATE OF is_seen OR DELETE ON notifications_notification From 57b2a5748f26c867f5c99b4713785deed116f14f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 11:13:39 -0500 Subject: [PATCH 303/557] Update CHANGELOG.md for v9.3.90 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338b2f8b74e..6680f73ecd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.90 (2024-11-05) +------------------------- + * Start writing notification counts to orgs_itemcount + v9.3.89 (2024-11-05) ------------------------- * Fix calculating field usages on API endpoint diff --git a/pyproject.toml b/pyproject.toml index 794c01e79cc..5b19ea80a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.89" +version = "9.3.90" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index d3940731f12..c6688d9b292 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.89" +__version__ = "9.3.90" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 21885040f9ed2e6aaf2470ecf0eeb5921099fbc9 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 16:38:47 +0000 Subject: [PATCH 304/557] Add data migration to backfill new notification counts --- .../migrations/0026_backfill_new_counts.py | 26 +++++++++++++++ temba/notifications/tests.py | 33 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 temba/notifications/migrations/0026_backfill_new_counts.py diff --git a/temba/notifications/migrations/0026_backfill_new_counts.py b/temba/notifications/migrations/0026_backfill_new_counts.py new file mode 100644 index 00000000000..4395dfe2bcc --- /dev/null +++ b/temba/notifications/migrations/0026_backfill_new_counts.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.2 on 2024-11-05 16:22 + +from django.db import migrations, transaction + + +def backfill_item_counts(apps, schema_editor): # pragma: no cover + Org = apps.get_model("orgs", "Org") + + for org in Org.objects.all(): + user_ids = org.notifications.values_list("user_id", flat=True).distinct() + + for user_id in user_ids: + with transaction.atomic(): + seen_count = org.notifications.filter(user_id=user_id, medium__contains="U", is_seen=True).count() + unseen_count = org.notifications.filter(user_id=user_id, medium__contains="U", is_seen=False).count() + + org.counts.filter(scope__startswith=f"notifications:{user_id}:").delete() + org.counts.create(scope=f"notifications:{user_id}:S", count=seen_count, is_squashed=True) + org.counts.create(scope=f"notifications:{user_id}:U", count=unseen_count, is_squashed=True) + + +class Migration(migrations.Migration): + + dependencies = [("notifications", "0025_update_triggers")] + + operations = [migrations.RunPython(backfill_item_counts, migrations.RunPython.noop)] diff --git a/temba/notifications/tests.py b/temba/notifications/tests.py index 240ca809618..8f64edf5472 100644 --- a/temba/notifications/tests.py +++ b/temba/notifications/tests.py @@ -8,7 +8,7 @@ from temba.flows.models import ResultsExport from temba.msgs.models import MessageExport from temba.orgs.models import Invitation, OrgRole -from temba.tests import CRUDLTestMixin, TembaTest, matchers +from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers from temba.tickets.models import TicketExport from .incidents.builtin import ChannelTemplatesFailedIncidentType, OrgFlaggedIncidentType @@ -598,3 +598,34 @@ def test_trim_task(self): self.assertFalse(Notification.objects.filter(id=notification1.id).exists()) self.assertTrue(Notification.objects.filter(id=notification2.id).exists()) + + +class BackfillNewCountsTest(MigrationTest): + app = "notifications" + migrate_from = "0025_update_triggers" + migrate_to = "0026_backfill_new_counts" + + def setUpBeforeMigration(self, apps): + imp = ContactImport.objects.create( + org=self.org, mappings={}, num_records=5, created_by=self.editor, modified_by=self.editor + ) + Notification.create_all( + imp.org, "import:finished", scope=f"contact:{imp.id}", users=[self.editor], contact_import=imp, medium="UE" + ) + Notification.create_all(self.org, "tickets:opened", scope="", users=[self.agent, self.editor], medium="UE") + Notification.create_all(self.org, "tickets:activity", scope="", users=[self.agent, self.editor], medium="UE") + Notification.create_all(self.org, "tickets:reply", scope="12", users=[self.editor], medium="E") # email only + Notification.create_all( + self.org2, "tickets:activity", scope="", users=[self.editor], medium="UE" + ) # different org + + self.org2.counts.all().delete() + + def test_migration(self): + def assert_count(org, user, expected: int): + self.assertEqual(expected, Notification.get_unseen_count_new(org, user)) + + assert_count(self.org, self.agent, 2) + assert_count(self.org, self.editor, 3) + assert_count(self.org2, self.agent, 0) + assert_count(self.org2, self.editor, 1) From 41a23298a85f81b6fe16e24f405c02eaf7cd6184 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 16:43:02 +0000 Subject: [PATCH 305/557] Start using new notification counts --- temba/notifications/models.py | 24 +----------------------- temba/notifications/tasks.py | 7 +------ temba/notifications/tests.py | 9 ++++----- temba/settings_common.py | 3 +-- 4 files changed, 7 insertions(+), 36 deletions(-) diff --git a/temba/notifications/models.py b/temba/notifications/models.py index da75d254d47..a39f76fba68 100644 --- a/temba/notifications/models.py +++ b/temba/notifications/models.py @@ -224,10 +224,6 @@ def mark_seen(cls, org, user, notification_type: str = None, *, scope: str = Non @classmethod def get_unseen_count(cls, org: Org, user: User) -> int: - return NotificationCount.get_total(org, user) - - @classmethod - def get_unseen_count_new(cls, org: Org, user: User) -> int: return org.counts.filter(scope=f"notifications:{user.id}:U").sum() @property @@ -269,7 +265,7 @@ class Meta: class NotificationCount(SquashableModel): """ - A count of a user's unseen notifications in a specific org + TODO drop """ squash_over = ("org_id", "user_id") @@ -277,21 +273,3 @@ class NotificationCount(SquashableModel): org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="notification_counts") user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="notification_counts") count = models.IntegerField(default=0) - - @classmethod - def get_squash_query(cls, distinct_set): - sql = """ - WITH deleted as ( - DELETE FROM %(table)s WHERE "org_id" = %%s AND "user_id" = %%s RETURNING "count" - ) - INSERT INTO %(table)s("org_id", "user_id", "count", "is_squashed") - VALUES (%%s, %%s, GREATEST(0, (SELECT SUM("count") FROM deleted)), TRUE); - """ % { - "table": cls._meta.db_table - } - - return sql, (distinct_set.org_id, distinct_set.user_id) * 2 - - @classmethod - def get_total(cls, org: Org, user: User) -> int: - return cls.sum(cls.objects.filter(org=org, user=user)) diff --git a/temba/notifications/tasks.py b/temba/notifications/tasks.py index cdd525a4596..e768b9044d4 100644 --- a/temba/notifications/tasks.py +++ b/temba/notifications/tasks.py @@ -6,7 +6,7 @@ from temba.utils.crons import cron_task from temba.utils.models import delete_in_batches -from .models import Notification, NotificationCount +from .models import Notification logger = logging.getLogger(__name__) @@ -32,11 +32,6 @@ def send_notification_emails(): return {"sent": num_sent, "errored": num_errored} -@cron_task(lock_timeout=1800) -def squash_notification_counts(): - NotificationCount.squash() - - @cron_task() def trim_notifications(): trim_before = timezone.now() - settings.RETENTION_PERIODS["notification"] diff --git a/temba/notifications/tests.py b/temba/notifications/tests.py index 8f64edf5472..fab4f8a9fe6 100644 --- a/temba/notifications/tests.py +++ b/temba/notifications/tests.py @@ -7,13 +7,13 @@ from temba.contacts.models import ContactExport, ContactImport from temba.flows.models import ResultsExport from temba.msgs.models import MessageExport -from temba.orgs.models import Invitation, OrgRole +from temba.orgs.models import Invitation, ItemCount, OrgRole from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers from temba.tickets.models import TicketExport from .incidents.builtin import ChannelTemplatesFailedIncidentType, OrgFlaggedIncidentType from .models import Incident, Notification -from .tasks import send_notification_emails, squash_notification_counts, trim_notifications +from .tasks import send_notification_emails, trim_notifications from .types.builtin import ( ExportFinishedNotificationType, InvitationAcceptedNotificationType, @@ -545,7 +545,6 @@ def test_counts(self): def assert_count(org, user, expected: int): self.assertEqual(expected, Notification.get_unseen_count(org, user)) - self.assertEqual(expected, Notification.get_unseen_count_new(org, user)) assert_count(self.org, self.agent, 2) assert_count(self.org, self.editor, 3) @@ -566,7 +565,7 @@ def assert_count(org, user, expected: int): assert_count(self.org2, self.agent, 0) assert_count(self.org2, self.editor, 1) - squash_notification_counts() + ItemCount.squash() assert_count(self.org, self.agent, 1) assert_count(self.org, self.editor, 2) @@ -623,7 +622,7 @@ def setUpBeforeMigration(self, apps): def test_migration(self): def assert_count(org, user, expected: int): - self.assertEqual(expected, Notification.get_unseen_count_new(org, user)) + self.assertEqual(expected, Notification.get_unseen_count(org, user)) assert_count(self.org, self.agent, 2) assert_count(self.org, self.editor, 3) diff --git a/temba/settings_common.py b/temba/settings_common.py index 8fe5d1b62c5..eaf15923c2d 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -764,9 +764,8 @@ "squash-channel-counts": {"task": "squash_channel_counts", "schedule": timedelta(seconds=60)}, "squash-group-counts": {"task": "squash_group_counts", "schedule": timedelta(seconds=60)}, "squash-flow-counts": {"task": "squash_flow_counts", "schedule": timedelta(seconds=60)}, - "squash-item-counts": {"task": "squash_item_counts", "schedule": timedelta(seconds=45)}, + "squash-item-counts": {"task": "squash_item_counts", "schedule": timedelta(seconds=30)}, "squash-msg-counts": {"task": "squash_msg_counts", "schedule": timedelta(seconds=60)}, - "squash-notification-counts": {"task": "squash_notification_counts", "schedule": timedelta(seconds=60)}, "squash-ticket-counts": {"task": "squash_ticket_counts", "schedule": timedelta(seconds=60)}, "sync-classifier-intents": {"task": "sync_classifier_intents", "schedule": timedelta(seconds=300)}, "track-org-channel-counts": {"task": "track_org_channel_counts", "schedule": crontab(hour=4, minute=0)}, From 7e693df7c0818ddee54d97536c106a8f986bc3e9 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 18:29:15 +0000 Subject: [PATCH 306/557] Update some deps --- poetry.lock | 94 +++++++++++++++++++++++++------------------------- pyproject.toml | 8 ++--- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/poetry.lock b/poetry.lock index 527ca6871eb..b7671cb8b4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -85,17 +85,17 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.34.143" +version = "1.35.54" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.143-py3-none-any.whl", hash = "sha256:0d16832f23e6bd3ae94e35ea8e625529850bfad9baccd426de96ad8f445d8e03"}, - {file = "boto3-1.34.143.tar.gz", hash = "sha256:b590ce80c65149194def43ebf0ea1cf0533945502507837389a8d22e3ecbcf05"}, + {file = "boto3-1.35.54-py3-none-any.whl", hash = "sha256:2d5e160b614db55fbee7981001c54476cb827c441cef65b2fcb2c52a62019909"}, + {file = "boto3-1.35.54.tar.gz", hash = "sha256:7d9c359bbbc858a60b51c86328db813353c8bd1940212cdbd0a7da835291c2e1"}, ] [package.dependencies] -botocore = ">=1.34.143,<1.35.0" +botocore = ">=1.35.54,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -104,13 +104,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.143" +version = "1.35.54" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.143-py3-none-any.whl", hash = "sha256:094aea179e8aaa1bc957ad49cc27d93b189dd3a1f3075d8b0ca7c445a2a88430"}, - {file = "botocore-1.34.143.tar.gz", hash = "sha256:059f032ec05733a836e04e869c5a15534420102f93116f3bc9a5b759b0651caf"}, + {file = "botocore-1.35.54-py3-none-any.whl", hash = "sha256:9cca1811094b6cdc144c2c063a3ec2db6d7c88194b04d4277cd34fc8e3473aff"}, + {file = "botocore-1.35.54.tar.gz", hash = "sha256:131bb59ce59c8a939b31e8e647242d70cf11d32d4529fa4dca01feea1e891a76"}, ] [package.dependencies] @@ -119,7 +119,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.20.11)"] +crt = ["awscrt (==0.22.0)"] [[package]] name = "cachetools" @@ -513,38 +513,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] [package.dependencies] @@ -557,7 +557,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1168,13 +1168,13 @@ files = [ [[package]] name = "phonenumbers" -version = "8.13.40" +version = "8.13.49" description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." optional = false python-versions = "*" files = [ - {file = "phonenumbers-8.13.40-py2.py3-none-any.whl", hash = "sha256:9582752c20a1da5ec4449f7f97542bf8a793c8e2fec0ab57f767177bb8fc0b1d"}, - {file = "phonenumbers-8.13.40.tar.gz", hash = "sha256:f137c2848b8e83dd064b71881b65680584417efa202177fd330e2f7ff6c68113"}, + {file = "phonenumbers-8.13.49-py2.py3-none-any.whl", hash = "sha256:e17140955ab3d8f9580727372ea64c5ada5327932d6021ef6fd203c3db8c8139"}, + {file = "phonenumbers-8.13.49.tar.gz", hash = "sha256:e608ccb61f0bd42e6db1d2c421f7c22186b88f494870bf40aa31d1a2718ab0ae"}, ] [[package]] @@ -1613,18 +1613,18 @@ files = [ [[package]] name = "redis" -version = "5.0.7" +version = "5.2.0" description = "Python client for Redis database and key-value store" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, - {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, + {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, + {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, ] [package.extras] -hiredis = ["hiredis (>=1.0.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] [[package]] name = "regex" @@ -2234,4 +2234,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "fca9fb691232af20d971529578d99ca518db214322c383cfdb929bef56b43bba" +content-hash = "3b0fe2caecb71e316d485218ec6a5f35cad9ed75814f10c439bfef067a7462b1" diff --git a/pyproject.toml b/pyproject.toml index 5b19ea80a58..4c354fa9371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ djangorestframework = "^3.15.1" dj-database-url = "^0.5.0" smartmin = "^5.1.0" celery = "^5.4.0" -redis = "^5.0.7" -boto3 = "^1.34.137" -cryptography = "^43.0.1" +redis = "^5.2.0" +boto3 = "^1.35.54" +cryptography = "^43.0.3" vonage = "2.5.2" pyotp = "2.4.1" twilio = "6.24.0" @@ -36,7 +36,7 @@ colorama = "^0.4.6" gunicorn = "^22.0.0" iptools = "^0.7.0" iso8601 = "^0.1.14" -phonenumbers = "^8.13.40" +phonenumbers = "^8.13.49" pycountry = "^22.3.5" python-dateutil = "^2.9.0" packaging = "^22.0" From ad177cd17270643d269095b8a4a61f1ac8f7c28d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 13:45:23 -0500 Subject: [PATCH 307/557] Update CHANGELOG.md for v9.3.91 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6680f73ecd4..7e6411de23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v9.3.91 (2024-11-05) +------------------------- + * Update some deps + * Start using new notification counts + * Add data migration to backfill new notification counts + v9.3.90 (2024-11-05) ------------------------- * Start writing notification counts to orgs_itemcount diff --git a/pyproject.toml b/pyproject.toml index 4c354fa9371..d93c0d76355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.90" +version = "9.3.91" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index c6688d9b292..4dce3d47859 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.90" +__version__ = "9.3.91" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From fafeb995bc5b437b7a7960ab410c2f44345c26f2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 18:37:33 +0000 Subject: [PATCH 308/557] Remove database triggers to maintain old notification counts --- .../migrations/0025_update_triggers.py | 5 ++- .../migrations/0027_update_triggers.py | 16 ++++++++++ temba/sql/current_functions.sql | 32 +------------------ temba/sql/current_triggers.sql | 7 +--- 4 files changed, 22 insertions(+), 38 deletions(-) create mode 100644 temba/notifications/migrations/0027_update_triggers.py diff --git a/temba/notifications/migrations/0025_update_triggers.py b/temba/notifications/migrations/0025_update_triggers.py index 9a437492feb..21db72fc343 100644 --- a/temba/notifications/migrations/0025_update_triggers.py +++ b/temba/notifications/migrations/0025_update_triggers.py @@ -79,6 +79,9 @@ class Migration(migrations.Migration): - dependencies = [("notifications", "0024_notification_data_and_more")] + dependencies = [ + ("notifications", "0024_notification_data_and_more"), + ("orgs", "0156_itemcount"), + ] operations = [migrations.RunSQL(SQL)] diff --git a/temba/notifications/migrations/0027_update_triggers.py b/temba/notifications/migrations/0027_update_triggers.py new file mode 100644 index 00000000000..0cdf526cc94 --- /dev/null +++ b/temba/notifications/migrations/0027_update_triggers.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.2 on 2024-11-05 18:33 + +from django.db import migrations + +SQL = """ +DROP TRIGGER temba_notifications_update_notificationcount ON notifications_notification; +DROP FUNCTION temba_notification_on_change(); +DROP FUNCTION temba_insert_notificationcount(INT, INT, INT); +""" + + +class Migration(migrations.Migration): + + dependencies = [("notifications", "0026_backfill_new_counts")] + + operations = [migrations.RunSQL(SQL)] diff --git a/temba/sql/current_functions.sql b/temba/sql/current_functions.sql index 0081ff63c07..38259528836 100644 --- a/temba/sql/current_functions.sql +++ b/temba/sql/current_functions.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-11-05 15:35 UTC +-- Generated by collect_sql on 2024-11-05 18:36 UTC ---------------------------------------------------------------------- -- Convenience method to call contact_toggle_system_group with a row @@ -376,16 +376,6 @@ CREATE OR REPLACE FUNCTION temba_insert_flowpathcount(_flow_id INTEGER, _from_uu END; $$ LANGUAGE plpgsql; ----------------------------------------------------------------------- --- Inserts a new notificationcount row with the given values ----------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_insert_notificationcount(_org_id INT, _user_id INT, _count INT) RETURNS VOID AS $$ -BEGIN - INSERT INTO notifications_notificationcount("org_id", "user_id", "count", "is_squashed") - VALUES(_org_id, _user_id, _count, FALSE); -END; -$$ LANGUAGE plpgsql; - ---------------------------------------------------------------------- -- Handles DELETE statements on ivr_call table ---------------------------------------------------------------------- @@ -605,26 +595,6 @@ BEGIN END; $$ LANGUAGE plpgsql; ----------------------------------------------------------------------- --- Trigger procedure to notification counts on notification changes ----------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION temba_notification_on_change() RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'INSERT' AND position('U' IN NEW.medium) > 0 AND NOT NEW.is_seen THEN -- new notification inserted - PERFORM temba_insert_notificationcount(NEW.org_id, NEW.user_id, 1); - ELSIF TG_OP = 'UPDATE' AND position('U' IN NEW.medium) > 0 THEN -- existing notification updated - IF OLD.is_seen AND NOT NEW.is_seen THEN -- becoming unseen again - PERFORM temba_insert_notificationcount(NEW.org_id, NEW.user_id, 1); - ELSIF NOT OLD.is_seen AND NEW.is_seen THEN -- becoming seen - PERFORM temba_insert_notificationcount(NEW.org_id, NEW.user_id, -1); - END IF; - ELSIF TG_OP = 'DELETE' AND position('U' IN OLD.medium) > 0 AND NOT OLD.is_seen THEN -- existing notification deleted - PERFORM temba_insert_notificationcount(OLD.org_id, OLD.user_id, -1); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - ---------------------------------------------------------------------- -- Handles DELETE statements on notification table ---------------------------------------------------------------------- diff --git a/temba/sql/current_triggers.sql b/temba/sql/current_triggers.sql index 6cc961b7948..5134e8eb9c3 100644 --- a/temba/sql/current_triggers.sql +++ b/temba/sql/current_triggers.sql @@ -1,4 +1,4 @@ --- Generated by collect_sql on 2024-11-05 15:35 UTC +-- Generated by collect_sql on 2024-11-05 18:36 UTC CREATE TRIGGER temba_broadcast_on_delete AFTER DELETE ON msgs_broadcast REFERENCING OLD TABLE AS oldtab @@ -98,11 +98,6 @@ CREATE TRIGGER temba_notification_on_update AFTER UPDATE ON notifications_notification REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab FOR EACH STATEMENT EXECUTE PROCEDURE temba_notification_on_update(); -CREATE TRIGGER temba_notifications_update_notificationcount - AFTER INSERT OR UPDATE OF is_seen OR DELETE - ON notifications_notification - FOR EACH ROW EXECUTE PROCEDURE temba_notification_on_change(); - CREATE TRIGGER temba_ticket_on_change_trg AFTER INSERT OR UPDATE OR DELETE ON tickets_ticket FOR EACH ROW EXECUTE PROCEDURE temba_ticket_on_change(); From f93037e25884c4e9578acd1d941a48eff56dc2a6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 19:30:18 +0000 Subject: [PATCH 309/557] Remove old migration test --- temba/notifications/tests.py | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/temba/notifications/tests.py b/temba/notifications/tests.py index fab4f8a9fe6..7588b66c54a 100644 --- a/temba/notifications/tests.py +++ b/temba/notifications/tests.py @@ -8,7 +8,7 @@ from temba.flows.models import ResultsExport from temba.msgs.models import MessageExport from temba.orgs.models import Invitation, ItemCount, OrgRole -from temba.tests import CRUDLTestMixin, MigrationTest, TembaTest, matchers +from temba.tests import CRUDLTestMixin, TembaTest, matchers from temba.tickets.models import TicketExport from .incidents.builtin import ChannelTemplatesFailedIncidentType, OrgFlaggedIncidentType @@ -597,34 +597,3 @@ def test_trim_task(self): self.assertFalse(Notification.objects.filter(id=notification1.id).exists()) self.assertTrue(Notification.objects.filter(id=notification2.id).exists()) - - -class BackfillNewCountsTest(MigrationTest): - app = "notifications" - migrate_from = "0025_update_triggers" - migrate_to = "0026_backfill_new_counts" - - def setUpBeforeMigration(self, apps): - imp = ContactImport.objects.create( - org=self.org, mappings={}, num_records=5, created_by=self.editor, modified_by=self.editor - ) - Notification.create_all( - imp.org, "import:finished", scope=f"contact:{imp.id}", users=[self.editor], contact_import=imp, medium="UE" - ) - Notification.create_all(self.org, "tickets:opened", scope="", users=[self.agent, self.editor], medium="UE") - Notification.create_all(self.org, "tickets:activity", scope="", users=[self.agent, self.editor], medium="UE") - Notification.create_all(self.org, "tickets:reply", scope="12", users=[self.editor], medium="E") # email only - Notification.create_all( - self.org2, "tickets:activity", scope="", users=[self.editor], medium="UE" - ) # different org - - self.org2.counts.all().delete() - - def test_migration(self): - def assert_count(org, user, expected: int): - self.assertEqual(expected, Notification.get_unseen_count(org, user)) - - assert_count(self.org, self.agent, 2) - assert_count(self.org, self.editor, 3) - assert_count(self.org2, self.agent, 0) - assert_count(self.org2, self.editor, 1) From 23df4e7fe09c0737062325ddad205a2ce46551c6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 14:45:56 -0500 Subject: [PATCH 310/557] Update CHANGELOG.md for v9.3.92 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e6411de23d..9cd222f6029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v9.3.92 (2024-11-05) +------------------------- + * Remove database triggers to maintain old notification counts + v9.3.91 (2024-11-05) ------------------------- * Update some deps diff --git a/pyproject.toml b/pyproject.toml index d93c0d76355..ebd704b49c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.91" +version = "9.3.92" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 4dce3d47859..424ed3a12a4 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.91" +__version__ = "9.3.92" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From e79c76b6c3ac0132172e73357a16082339e8e051 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 19:49:43 +0000 Subject: [PATCH 311/557] Drop no longer used count models --- .../0028_delete_notificationcount.py | 16 ++++++++++++++ temba/notifications/models.py | 13 ----------- temba/orgs/models.py | 2 -- .../migrations/0073_delete_ticketcount.py | 16 ++++++++++++++ temba/tickets/models.py | 22 +------------------ 5 files changed, 33 insertions(+), 36 deletions(-) create mode 100644 temba/notifications/migrations/0028_delete_notificationcount.py create mode 100644 temba/tickets/migrations/0073_delete_ticketcount.py diff --git a/temba/notifications/migrations/0028_delete_notificationcount.py b/temba/notifications/migrations/0028_delete_notificationcount.py new file mode 100644 index 00000000000..0eb5c370e8d --- /dev/null +++ b/temba/notifications/migrations/0028_delete_notificationcount.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.2 on 2024-11-05 19:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0027_update_triggers"), + ] + + operations = [ + migrations.DeleteModel( + name="NotificationCount", + ), + ] diff --git a/temba/notifications/models.py b/temba/notifications/models.py index a39f76fba68..7b7b71c4fd6 100644 --- a/temba/notifications/models.py +++ b/temba/notifications/models.py @@ -11,7 +11,6 @@ from temba.contacts.models import ContactImport from temba.orgs.models import Export, Org from temba.utils.email import EmailSender -from temba.utils.models import SquashableModel logger = logging.getLogger(__name__) @@ -261,15 +260,3 @@ class Meta: condition=Q(is_seen=False), ), ] - - -class NotificationCount(SquashableModel): - """ - TODO drop - """ - - squash_over = ("org_id", "user_id") - - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="notification_counts") - user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="notification_counts") - count = models.IntegerField(default=0) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index eaab8f6bd74..0f0b5d3ebbb 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1318,7 +1318,6 @@ def delete(self) -> dict: counts = defaultdict(int) delete_in_batches(self.notifications.all()) - delete_in_batches(self.notification_counts.all()) delete_in_batches(self.incidents.all()) delete_in_batches(self.flow_labels.all()) @@ -1356,7 +1355,6 @@ def delete(self) -> dict: delete_in_batches(self.sessions.all()) delete_in_batches(self.ticket_events.all()) delete_in_batches(self.tickets.all()) - delete_in_batches(self.ticket_counts.all()) delete_in_batches(self.topics.all()) delete_in_batches(self.teams.all()) delete_in_batches(self.airtime_transfers.all()) diff --git a/temba/tickets/migrations/0073_delete_ticketcount.py b/temba/tickets/migrations/0073_delete_ticketcount.py new file mode 100644 index 00000000000..c9d3119574e --- /dev/null +++ b/temba/tickets/migrations/0073_delete_ticketcount.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.2 on 2024-11-05 19:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("tickets", "0072_drop_old_triggers"), + ] + + operations = [ + migrations.DeleteModel( + name="TicketCount", + ), + ] diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 12dae66c2cf..81b223e34f7 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -18,7 +18,7 @@ from temba.utils.dates import date_range from temba.utils.db.functions import SplitPart from temba.utils.export import MultiSheetExporter -from temba.utils.models import DailyCountModel, DailyTimingModel, SquashableModel, TembaModel +from temba.utils.models import DailyCountModel, DailyTimingModel, TembaModel from temba.utils.uuid import is_uuid, uuid4 logger = logging.getLogger(__name__) @@ -427,26 +427,6 @@ def get_queryset(self, org, user, *, ordered: bool): return super().get_queryset(org, user, ordered=ordered).filter(topic=self.topic) -class TicketCount(SquashableModel): - """ - TODO drop - """ - - org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="ticket_counts") - scope = models.CharField(max_length=32) - status = models.CharField(max_length=1, choices=Ticket.STATUS_CHOICES) - count = models.IntegerField(default=0) - - class Meta: - indexes = [ - models.Index(fields=("org", "status")), - models.Index(fields=("org", "scope", "status")), - models.Index( - name="ticket_count_unsquashed", fields=("org", "scope", "status"), condition=Q(is_squashed=False) - ), - ] - - class TicketDailyCount(DailyCountModel): """ Ticket activity daily counts by who did it and when. Mailroom writes these. From 36ef0827bd0c7407575d11bd7b7659e1c4acf49d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 20:37:31 +0000 Subject: [PATCH 312/557] Allow invitations to specify team and block team deletion when it has pending invitations --- .../migrations/0157_alter_invitation_team.py | 22 +++++++++++++++++++ temba/orgs/models.py | 4 ++-- temba/orgs/tests.py | 19 ++++++++++++++-- temba/tickets/tests.py | 13 +++++++++-- temba/tickets/views.py | 1 + templates/tickets/team_delete.html | 6 ++++- 6 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 temba/orgs/migrations/0157_alter_invitation_team.py diff --git a/temba/orgs/migrations/0157_alter_invitation_team.py b/temba/orgs/migrations/0157_alter_invitation_team.py new file mode 100644 index 00000000000..cdb13f94a4b --- /dev/null +++ b/temba/orgs/migrations/0157_alter_invitation_team.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.2 on 2024-11-05 20:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("orgs", "0156_itemcount"), + ("tickets", "0072_drop_old_triggers"), + ] + + operations = [ + migrations.AlterField( + model_name="invitation", + name="team", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="invitations", to="tickets.team" + ), + ), + ] diff --git a/temba/orgs/models.py b/temba/orgs/models.py index eaab8f6bd74..c8518354ad1 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1523,7 +1523,7 @@ class Invitation(SmartModel): email = models.EmailField() secret = models.CharField(max_length=64, unique=True) role_code = models.CharField(max_length=1, choices=OrgRole.choices(), default=OrgRole.EDITOR.code) - team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True) + team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True, related_name="invitations") @classmethod def create(cls, org, user, email: str, role: OrgRole, team=None): @@ -1555,7 +1555,7 @@ def send(self): def accept(self, user): from temba.notifications.types.builtin import InvitationAcceptedNotificationType - self.org.add_user(user, self.role) + self.org.add_user(user, self.role, team=self.team) InvitationAcceptedNotificationType.create(self) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 42208309d15..5dd8720af3b 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -123,11 +123,26 @@ def test_model(self): self.assertEqual("RapidPro Invitation", mail.outbox[0].subject) self.assertIn(f"https://app.rapidpro.io/org/join/{invitation.secret}/", mail.outbox[0].body) - user = User.create("invitededitor@nyaruka.com", "Bob", "", "Qwerty123", "en-US") - invitation.accept(user) + new_editor = User.create("invitededitor@nyaruka.com", "Bob", "", "Qwerty123", "en-US") + invitation.accept(new_editor) self.assertEqual(1, self.admin.notifications.count()) self.assertFalse(invitation.is_active) + self.assertEqual({self.editor, new_editor}, set(self.org.get_users(roles=[OrgRole.EDITOR]))) + + # invite an agent user to a specific team + sales = Team.create(self.org, self.admin, "Sales", topics=[]) + invitation = Invitation.create(self.org, self.admin, "invitedagent@nyaruka.com", OrgRole.AGENT, team=sales) + + self.assertEqual(OrgRole.AGENT, invitation.role) + self.assertEqual(sales, invitation.team) + + invitation.send() + new_agent = User.create("invitedagent@nyaruka.com", "Bob", "", "Qwerty123", "en-US") + invitation.accept(new_agent) + + self.assertEqual({self.agent, new_agent}, set(self.org.get_users(roles=[OrgRole.AGENT]))) + self.assertEqual({new_agent}, set(sales.get_users())) def test_expire_task(self): invitation1 = Invitation.objects.create( diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 010522209f8..7e17277151c 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -9,7 +9,7 @@ from django.utils import timezone from temba.contacts.models import Contact, ContactField, ContactURN -from temba.orgs.models import Export, Org, OrgMembership, OrgRole +from temba.orgs.models import Export, Invitation, Org, OrgMembership, OrgRole from temba.orgs.tasks import squash_item_counts from temba.tests import CRUDLTestMixin, TembaTest, matchers, mock_mailroom from temba.utils.dates import datetime_to_timestamp @@ -603,6 +603,7 @@ def test_delete(self): team1 = Team.create(self.org, self.admin, "Sales", topics=[sales]) team2 = Team.create(self.org, self.admin, "Other", topics=[sales]) self.org.add_user(self.agent, OrgRole.AGENT, team=team1) + invite = Invitation.create(self.org, self.admin, "newagent@nyaruka.com", OrgRole.AGENT, team=team1) delete_url = reverse("tickets.team_delete", args=[team1.id]) @@ -610,10 +611,18 @@ def test_delete(self): # deleting blocked for team with agents response = self.assertDeleteFetch(delete_url, [self.admin]) - self.assertContains(response, "Sorry, the Sales team can't be deleted") + self.assertContains(response, "Sorry, the Sales team can't be deleted while it still has agents") self.org.add_user(self.agent, OrgRole.AGENT, team=team2) + # deleting blocked for team with pending invitations + response = self.assertDeleteFetch(delete_url, [self.admin]) + self.assertContains( + response, "Sorry, the Sales team can't be deleted while it still has pending invitations" + ) + + invite.release() + # try again... response = self.assertDeleteFetch(delete_url, [self.admin]) self.assertContains(response, "You are about to delete the Sales team") diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 9360d37e96d..0a52469a05e 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -131,6 +131,7 @@ class Delete(BaseDeleteModal): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["has_agents"] = self.object.get_users().exists() + context["has_invitations"] = self.object.invitations.filter(is_active=True).exists() return context class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): diff --git a/templates/tickets/team_delete.html b/templates/tickets/team_delete.html index 4d0330a09f2..0861a78a555 100644 --- a/templates/tickets/team_delete.html +++ b/templates/tickets/team_delete.html @@ -6,6 +6,10 @@ {% blocktrans trimmed %} Sorry, the {{ object }} team can't be deleted while it still has agents. Move the agents to another team first and then try again. {% endblocktrans %} + {% elif has_invitations %} + {% blocktrans trimmed %} + Sorry, the {{ object }} team can't be deleted while it still has pending invitations. Cancel the invitations and then try again. + {% endblocktrans %} {% else %} {% blocktrans trimmed %} You are about to delete the {{ object }} team. There is no way to undo this. Are you sure? @@ -13,5 +17,5 @@ {% endif %} {% endblock fields %} {% block form-buttons %} - {% if not has_agents %}{% endif %} + {% if not has_agents and not has_invitations %}{% endif %} {% endblock form-buttons %} From f2f6d18b0bddcfcfa32abaa7a1cf59e28b7678d1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 16:00:06 -0500 Subject: [PATCH 313/557] Update CHANGELOG.md for v9.3.93 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd222f6029..3dc71d9b295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v9.3.93 (2024-11-05) +------------------------- + * Allow invitations to specify team and block team deletion when it has pending invitations + * Drop no longer used count models + v9.3.92 (2024-11-05) ------------------------- * Remove database triggers to maintain old notification counts diff --git a/pyproject.toml b/pyproject.toml index ebd704b49c7..ddc3323c053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.92" +version = "9.3.93" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 424ed3a12a4..88e7984f440 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.92" +__version__ = "9.3.93" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From 428ce9800b718ee62b46033655537fede10616dc Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 5 Nov 2024 21:58:21 +0000 Subject: [PATCH 314/557] Fix displaying of exports based on status "groups" --- templates/contacts/export_download.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/templates/contacts/export_download.html b/templates/contacts/export_download.html index 01e2da7f0bf..bde207b9537 100644 --- a/templates/contacts/export_download.html +++ b/templates/contacts/export_download.html @@ -3,13 +3,14 @@ {% block "extra-fields" %} - - + + + {% else %} + + + {% endif %} {% endblock "extra-fields" %} From a184d7d9099ca1a043fb29fdfaa899331ef16c74 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 6 Nov 2024 20:31:21 +0000 Subject: [PATCH 315/557] OrgMiddleware should prevent cross-org POSTs --- temba/middleware.py | 8 +++++++ temba/utils/tests.py | 55 +++++++++++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/temba/middleware.py b/temba/middleware.py index 875f455202b..b3bbc1356fc 100644 --- a/temba/middleware.py +++ b/temba/middleware.py @@ -6,6 +6,7 @@ from django.conf import settings from django.contrib import messages +from django.http import HttpResponseForbidden from django.utils import timezone, translation from temba.orgs.models import Org, User @@ -42,6 +43,13 @@ def __call__(self, request): assert hasattr(request, "user"), "must be called after django.contrib.auth.middleware.AuthenticationMiddleware" request.org = self.determine_org(request) + + # if request is a POST with an org header, ensure it matches the current org + if request.method == "POST": + posted_org_id = request.headers.get("X-Temba-Org") + if posted_org_id and request.org and request.org.id != int(posted_org_id): + return HttpResponseForbidden() + if request.org: # set our current role for this org request.role = request.org.get_user_role(request.user) diff --git a/temba/utils/tests.py b/temba/utils/tests.py index ba4d6dac6d5..6f7968cf521 100644 --- a/temba/utils/tests.py +++ b/temba/utils/tests.py @@ -14,7 +14,7 @@ from django.urls import reverse from django.utils import timezone -from temba.orgs.models import Org +from temba.orgs.models import OrgRole from temba.tests import TembaTest, matchers, override_brand from temba.utils import json, uuid from temba.utils.compose import compose_serialize @@ -254,42 +254,59 @@ def test_task3(foo, bar): class MiddlewareTest(TembaTest): def test_org(self): + index_url = reverse("public.public_index") - self.other_org = Org.objects.create( - name="Other Org", - timezone=ZoneInfo("Africa/Kigali"), - flow_languages=["eng", "kin"], - created_by=self.admin, - modified_by=self.admin, - ) - self.other_org.initialize() + response = self.client.get(index_url) + self.assertFalse(response.has_header("X-Temba-Org")) + + # if a user has a single org, that becomes the current org + self.login(self.admin) + + response = self.client.get(index_url) + self.assertEqual(str(self.org.id), response["X-Temba-Org"]) + + # if not, org isn't set + self.org2.add_user(self.admin, OrgRole.ADMINISTRATOR) - response = self.client.get(reverse("public.public_index")) + response = self.client.get(index_url) self.assertFalse(response.has_header("X-Temba-Org")) + # org will be read from session if set + s = self.client.session + s.update({"org_id": self.org.id}) + s.save() + + response = self.client.get(index_url) + self.assertEqual(str(self.org.id), response["X-Temba-Org"]) + + # org can be sent as a header too and we check it matches + response = self.client.post(reverse("flows.flow_create"), {}, headers={"X-Temba-Org": str(self.org.id)}) + self.assertEqual(200, response.status_code) + + response = self.client.post(reverse("flows.flow_create"), {}, headers={"X-Temba-Org": str(self.org2.id)}) + self.assertEqual(403, response.status_code) + self.login(self.customer_support) # our staff user doesn't have a default org - response = self.client.get(reverse("public.public_index")) + response = self.client.get(index_url) self.assertFalse(response.has_header("X-Temba-Org")) # but they can specify an org to service as a header - response = self.client.get(reverse("public.public_index"), headers={"X-Temba-Service-Org": str(self.org.id)}) + response = self.client.get(index_url, headers={"X-Temba-Service-Org": str(self.org.id)}) self.assertEqual(response["X-Temba-Org"], str(self.org.id)) - response = self.client.get(reverse("public.public_index")) + response = self.client.get(index_url) self.assertFalse(response.has_header("X-Temba-Org")) - self.login(self.admin) + self.login(self.editor) - response = self.client.get(reverse("public.public_index")) + response = self.client.get(index_url) self.assertEqual(response["X-Temba-Org"], str(self.org.id)) # non-staff can't specify a different org from there own - response = self.client.get( - reverse("public.public_index"), headers={"X-Temba-Service-Org": str(self.other_org.id)} - ) - self.assertNotEqual(response["X-Temba-Org"], str(self.other_org.id)) + response = self.client.get(index_url, headers={"X-Temba-Service-Org": str(self.org2.id)}) + self.assertNotEqual(response["X-Temba-Org"], str(self.org2.id)) def test_redirect(self): self.assertNotRedirect(self.client.get(reverse("public.public_index")), None) From ae6fa0c53c34227f7a92413f21dc1bf72ec55709 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 6 Nov 2024 22:07:18 +0000 Subject: [PATCH 316/557] Restrict staff servicing org perms to non-POST requests --- temba/api/models.py | 10 +++---- temba/middleware.py | 4 --- temba/orgs/models.py | 9 ------ temba/orgs/tests.py | 57 +++++--------------------------------- temba/orgs/views/mixins.py | 32 +++++++++++---------- temba/orgs/views/tests.py | 36 ++++++++++++++++++++++++ temba/orgs/views/views.py | 12 ++++---- 7 files changed, 71 insertions(+), 89 deletions(-) create mode 100644 temba/orgs/views/tests.py diff --git a/temba/api/models.py b/temba/api/models.py index 66d6ef93a57..7920c286c27 100644 --- a/temba/api/models.py +++ b/temba/api/models.py @@ -72,17 +72,17 @@ def has_permission(self, request, view): if request.auth: # auth token was used - role = org.get_user_role(request.auth.user) + role = org.get_user_role(request.user) - # only editors and administrators can use API tokens - if role not in APIToken.ALLOWED_ROLES: + # only editors, administrators and servicing staff can use API tokens + if role not in APIToken.ALLOWED_ROLES and not request.user.is_staff: return False elif org: role = org.get_user_role(request.user) else: return False - has_perm = role.has_api_perm(permission) + has_perm = request.user.is_staff or role.has_api_perm(permission) # viewers can only ever get from the API if role == OrgRole.VIEWER: @@ -220,7 +220,7 @@ def create(cls, org, user): Creates a new API token for this user """ - assert org.get_user_role(user) in cls.ALLOWED_ROLES + assert org.get_user_role(user) in cls.ALLOWED_ROLES or user.is_staff return cls.objects.create(user=user, org=org, key=generate_secret(40)) diff --git a/temba/middleware.py b/temba/middleware.py index 875f455202b..e1dcd3038c5 100644 --- a/temba/middleware.py +++ b/temba/middleware.py @@ -42,10 +42,6 @@ def __call__(self, request): assert hasattr(request, "user"), "must be called after django.contrib.auth.middleware.AuthenticationMiddleware" request.org = self.determine_org(request) - if request.org: - # set our current role for this org - request.role = request.org.get_user_role(request.user) - request.branding = settings.BRAND # continue the chain, which in the case of the API will set request.org diff --git a/temba/orgs/models.py b/temba/orgs/models.py index e164e81626f..641754aefab 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -259,11 +259,6 @@ def has_org_perm(self, org, permission: str) -> bool: """ Determines if a user has the given permission in the given org. """ - if self.is_staff: - return True - - if self.is_anonymous: # pragma: needs cover - return False # has it innately? e.g. Granter group if self.has_perm(permission): @@ -1107,10 +1102,6 @@ def get_membership(self, user: User): """ def get(): - # for staff we return a faked membership: admin role, no team - if user.is_staff: - return OrgMembership(org=self, user=user, role_code=OrgRole.ADMINISTRATOR.code, team=None) - return OrgMembership.objects.filter(org=self, user=user).first() if user not in self._membership_cache: diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 5dd8720af3b..ec1d8817ae3 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -214,80 +214,37 @@ def test_has_org_perm(self): ( self.org, "contacts.contact_list", - { - self.agent: False, - self.user: True, - self.admin: True, - self.admin2: False, - self.customer_support: True, - }, + {self.agent: False, self.user: True, self.admin: True, self.admin2: False}, ), ( self.org2, "contacts.contact_list", - { - self.agent: False, - self.user: False, - self.admin: False, - self.admin2: True, - self.customer_support: True, - }, + {self.agent: False, self.user: False, self.admin: False, self.admin2: True}, ), ( self.org2, "contacts.contact_read", - { - self.agent: False, - self.user: False, - self.admin: False, - self.admin2: True, - self.customer_support: True, # needed for servicing - }, + {self.agent: False, self.user: False, self.admin: False, self.admin2: True}, ), ( self.org, "orgs.org_edit", - { - self.agent: False, - self.user: False, - self.admin: True, - self.admin2: False, - self.customer_support: True, - }, + {self.agent: False, self.user: False, self.admin: True, self.admin2: False}, ), ( self.org2, "orgs.org_edit", - { - self.agent: False, - self.user: False, - self.admin: False, - self.admin2: True, - self.customer_support: True, - }, + {self.agent: False, self.user: False, self.admin: False, self.admin2: True}, ), ( self.org, "orgs.org_grant", - { - self.agent: False, - self.user: False, - self.admin: False, - self.admin2: False, - self.customer_support: True, - granter: True, - }, + {self.agent: False, self.user: False, self.admin: False, self.admin2: False, granter: True}, ), ( self.org, "xxx.yyy_zzz", - { - self.agent: False, - self.user: False, - self.admin: False, - self.admin2: False, - self.customer_support: True, # staff have implicit all perm access - }, + {self.agent: False, self.user: False, self.admin: False, self.admin2: False}, ), ) for org, perm, checks in tests: diff --git a/temba/orgs/views/mixins.py b/temba/orgs/views/mixins.py index 53541772c18..af43e50d9dd 100644 --- a/temba/orgs/views/mixins.py +++ b/temba/orgs/views/mixins.py @@ -22,31 +22,33 @@ def get_user(self): def derive_org(self): return self.request.org - def has_org_perm(self, permission): + def has_org_perm(self, permission: str): + # nobody has an org perm without an org org = self.derive_org() - if org: - return self.get_user().has_org_perm(org, permission) - return False + if not org: + return False + + user = self.get_user() + + # check special cases + if user.is_anonymous: + return False + if user.is_superuser: + return True + if user.is_staff and self.request.method != "POST": + return True + + return self.get_user().has_org_perm(org, permission) def has_permission(self, request, *args, **kwargs): """ Figures out if the current user has permissions for this view. """ + self.kwargs = kwargs self.args = args self.request = request - org = self.derive_org() - - if self.get_user().is_staff and org: - return True - - if self.get_user().is_anonymous: - return False - - if self.get_user().has_perm(self.permission): # pragma: needs cover - return True - return self.has_org_perm(self.permission) def dispatch(self, request, *args, **kwargs): diff --git a/temba/orgs/views/tests.py b/temba/orgs/views/tests.py new file mode 100644 index 00000000000..cbc66edee73 --- /dev/null +++ b/temba/orgs/views/tests.py @@ -0,0 +1,36 @@ +from django.urls import reverse + +from temba.tests import TembaTest + + +class OrgPermsMixinTest(TembaTest): + def test_has_permission(self): + create_url = reverse("tickets.topic_create") + + # no anon access + self.assertLoginRedirect(self.client.get(create_url)) + + # no agent role access to this specific view + self.login(self.agent) + self.assertLoginRedirect(self.client.get(create_url)) + + # editor role does have access tho + self.login(self.editor) + self.assertEqual(200, self.client.get(create_url).status_code) + + # staff can't access without org + self.login(self.customer_support) + self.assertLoginRedirect(self.client.get(create_url)) + + self.login(self.customer_support, choose_org=self.org) + self.assertEqual(200, self.client.get(create_url).status_code) + + # staff still can't POST + self.assertLoginRedirect(self.client.post(create_url, {"name": "Sales"})) + + # but superuser can + self.customer_support.is_superuser = True + self.customer_support.save(update_fields=("is_superuser",)) + + self.assertEqual(200, self.client.get(create_url).status_code) + self.assertRedirect(self.client.post(create_url, {"name": "Sales"}), "hide") diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 96cc76f91fb..73d339bc7b0 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -994,6 +994,7 @@ def derive_url_pattern(cls, path, action): return r"^%s/%s/((?P[A-z]+)/)?$" % (path, action) def has_permission(self, request, *args, **kwargs): + # allow staff access without an org since this view includes staff menu if self.request.user.is_staff: return True @@ -2120,13 +2121,12 @@ def form_valid(self, form): return super().form_valid(form) - def has_permission(self, request, *args, **kwargs): - perm = "orgs.org_country" - + @property + def permission(self): if self.request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" and self.request.method == "GET": - perm = "orgs.org_languages" - - return self.request.user.has_perm(perm) or self.has_org_perm(perm) + return "orgs.org_languages" + else: + return "orgs.org_country" class InvitationCRUDL(SmartCRUDL): From 31fa65f76ba7f53bd689f05314a9cff9148140e3 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 14:50:29 +0000 Subject: [PATCH 317/557] Fix tests --- temba/mailroom/events.py | 4 +++- temba/staff/tests.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/temba/mailroom/events.py b/temba/mailroom/events.py index 27d307aa6cf..81beb69ade2 100644 --- a/temba/mailroom/events.py +++ b/temba/mailroom/events.py @@ -262,7 +262,9 @@ def from_channel_event(cls, org: Org, user: User, obj: ChannelEvent) -> dict: def _url_for_user(org: Org, user: User, view_name: str, args: list, perm: str = None) -> str: - return reverse(view_name, args=args) if user.has_org_perm(org, perm or view_name) else None + allowed = user.has_org_perm(org, perm or view_name) or user.is_staff + + return reverse(view_name, args=args) if allowed else None def _msg_in(obj) -> dict: diff --git a/temba/staff/tests.py b/temba/staff/tests.py index b2a64fd4931..134312e35ac 100644 --- a/temba/staff/tests.py +++ b/temba/staff/tests.py @@ -187,13 +187,22 @@ def test_service(self, mr_mocks): response = self.client.post(service_url, {"other_org": self.org.id, "next": "/flow/"}) self.assertRedirect(response, "/flow/") - # create a new contact + # try to create a new contact (should fail because servicing staff can't POST) response = self.client.post( - reverse("contacts.contact_create"), data=dict(name="Ben Haggerty", phone="0788123123") + reverse("contacts.contact_create"), data={"name": "Ben Haggerty", "phone": "0788123123"} ) - self.assertNoFormErrors(response) + self.assertLoginRedirect(response) + + # become super user + self.customer_support.is_superuser = True + self.customer_support.save(update_fields=("is_superuser",)) + + # now it should work + response = self.client.post( + reverse("contacts.contact_create"), data={"name": "Ben Haggerty", "phone": "0788123123"} + ) + self.assertEqual(200, response.status_code) - # make sure that contact's created on is our cs rep contact = Contact.objects.get(urns__path="+250788123123", org=self.org) self.assertEqual(self.customer_support, contact.created_by) From 49979aa60cf58e701bcdb29ebd97212fe0922c2e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 14:51:46 +0000 Subject: [PATCH 318/557] Remove experimental mailgun channel type --- temba/channels/types/mailgun/__init__.py | 1 - temba/channels/types/mailgun/tests.py | 61 ------------------------ temba/channels/types/mailgun/type.py | 43 ----------------- temba/channels/types/mailgun/views.py | 49 ------------------- temba/settings_common.py | 1 - 5 files changed, 155 deletions(-) delete mode 100644 temba/channels/types/mailgun/__init__.py delete mode 100644 temba/channels/types/mailgun/tests.py delete mode 100644 temba/channels/types/mailgun/type.py delete mode 100644 temba/channels/types/mailgun/views.py diff --git a/temba/channels/types/mailgun/__init__.py b/temba/channels/types/mailgun/__init__.py deleted file mode 100644 index 247ca8a2082..00000000000 --- a/temba/channels/types/mailgun/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .type import MailgunType # noqa diff --git a/temba/channels/types/mailgun/tests.py b/temba/channels/types/mailgun/tests.py deleted file mode 100644 index 17eecd4cdfd..00000000000 --- a/temba/channels/types/mailgun/tests.py +++ /dev/null @@ -1,61 +0,0 @@ -from django.urls import reverse - -from temba.tests import TembaTest - -from ...models import Channel - - -class MailgunTypeTest(TembaTest): - def test_claim(self): - claim_url = reverse("channels.types.mailgun.claim") - - self.login(self.admin) - - response = self.client.get(reverse("channels.channel_claim")) - self.assertNotContains(response, claim_url) - - self.login(self.customer_support, choose_org=self.org) - - response = self.client.get(reverse("channels.channel_claim")) - self.assertContains(response, claim_url) - - response = self.client.get(claim_url) - self.assertEqual(200, response.status_code) - self.assertEqual({"subject": "Chat with Nyaruka"}, response.context["form"].initial) - - # try to submit with invalid email address - response = self.client.post( - claim_url, - { - "address": "!!!!!!", - "subject": "Chat with Bob", - "sending_key": "0123456789", - "signing_key": "9876543210", - }, - follow=True, - ) - self.assertFormError(response.context["form"], "address", "Enter a valid email address.") - - response = self.client.post( - claim_url, - { - "address": "bob@acme.com", - "subject": "Chat with Bob", - "sending_key": "0123456789", - "signing_key": "9876543210", - }, - follow=True, - ) - self.assertEqual(200, response.status_code) - - channel = Channel.objects.get(channel_type="MLG") - self.assertEqual("bob@acme.com", channel.name) - self.assertEqual("bob@acme.com", channel.address) - self.assertEqual( - { - "auth_token": "0123456789", - "default_subject": "Chat with Bob", - "signing_key": "9876543210", - }, - channel.config, - ) diff --git a/temba/channels/types/mailgun/type.py b/temba/channels/types/mailgun/type.py deleted file mode 100644 index 12af39bff18..00000000000 --- a/temba/channels/types/mailgun/type.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.utils.translation import gettext_lazy as _ - -from temba.contacts.models import URN - -from ...models import ChannelType, ConfigUI -from .views import ClaimView - - -class MailgunType(ChannelType): - """ - A Mailgun email channel. - """ - - code = "MLG" - name = "Mailgun" - category = ChannelType.Category.API - - courier_url = r"^mlg/(?P[a-z0-9\-]+)/receive$" - schemes = [URN.EMAIL_SCHEME] - - claim_blurb = _("Add a %(link)s channel to send and receive messages as emails.") % { - "link": 'Mailgun' - } - claim_view = ClaimView - - config_ui = ConfigUI( - blurb=_( - "To finish configuring this channel, you'll need to add a route for received messages that forwards them." - ), - endpoints=[ - ConfigUI.Endpoint( - courier="receive", - label=_("Receive URL"), - help=_("The URL to forward new emails to."), - ), - ], - ) - - CONFIG_DEFAULT_SUBJECT = "default_subject" - CONFIG_SIGNING_KEY = "signing_key" - - def is_available_to(self, org, user): - return user.is_staff, user.is_staff diff --git a/temba/channels/types/mailgun/views.py b/temba/channels/types/mailgun/views.py deleted file mode 100644 index a8f9a7a4a8e..00000000000 --- a/temba/channels/types/mailgun/views.py +++ /dev/null @@ -1,49 +0,0 @@ -from smartmin.views import SmartFormView - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from ...models import Channel -from ...views import ClaimViewMixin - - -class ClaimView(ClaimViewMixin, SmartFormView): - class Form(ClaimViewMixin.Form): - address = forms.EmailField(label=_("Email Address"), help_text=_("The email address.")) - subject = forms.CharField(label=_("Subject"), help_text=_("The default subject for new emails.")) - sending_key = forms.CharField( - label=_("Sending API key"), - help_text=_("A sending API key you have configured for this domain."), - max_length=50, - ) - signing_key = forms.CharField( - label=_("Webhook Signing key"), - help_text=_("The signing key used for webhook calls."), - max_length=50, - ) - - form_class = Form - - def derive_initial(self): - return {"subject": f"Chat with {self.request.org.name}"} - - def form_valid(self, form): - from .type import MailgunType - - address = form.cleaned_data["address"] - - self.object = Channel.create( - self.request.org, - self.request.user, - None, - self.channel_type, - name=address, - address=address, - config={ - Channel.CONFIG_AUTH_TOKEN: form.cleaned_data["sending_key"], - MailgunType.CONFIG_DEFAULT_SUBJECT: form.cleaned_data["subject"], - MailgunType.CONFIG_SIGNING_KEY: form.cleaned_data["signing_key"], - }, - ) - - return super().form_valid(form) diff --git a/temba/settings_common.py b/temba/settings_common.py index eaf15923c2d..872bf985204 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -869,7 +869,6 @@ "temba.channels.types.m3tech.M3TechType", "temba.channels.types.macrokiosk.MacrokioskType", "temba.channels.types.mblox.MbloxType", - "temba.channels.types.mailgun.MailgunType", "temba.channels.types.messagebird.MessageBirdType", "temba.channels.types.messangi.MessangiType", "temba.channels.types.mtn.MtnType", From ecdbaa59ee3707db9d435329c262b83b1f77d424 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 6 Nov 2024 21:00:09 +0000 Subject: [PATCH 319/557] Allow creating invitations with teams --- temba/orgs/models.py | 2 +- temba/orgs/tests.py | 25 ++++++++++++++++++++++++ temba/orgs/views/views.py | 24 ++++++++++++++++++----- templates/flows/flow_create.html | 2 +- templates/orgs/invitation_create.html | 28 ++++++++++++++++++++++++++- templates/orgs/invitation_list.html | 5 ++++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index e164e81626f..f517b0f5a0a 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -1524,7 +1524,7 @@ class Invitation(SmartModel): team = models.ForeignKey("tickets.Team", on_delete=models.PROTECT, null=True, related_name="invitations") @classmethod - def create(cls, org, user, email: str, role: OrgRole, team=None): + def create(cls, org, user, email: str, role: OrgRole, *, team=None): assert not team or org == team.org return cls.objects.create( diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 5dd8720af3b..a5038af9632 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -4116,6 +4116,31 @@ def test_create(self): form_errors={"email": "User has already been invited to this workspace."}, ) + # invite an agent (defaults to default team) + self.assertCreateSubmit( + create_url, + self.admin, + {"email": "newagent@nyaruka.com", "role": "T"}, + new_obj_query=Invitation.objects.filter( + org=self.org, email="newagent@nyaruka.com", role_code="T", team=self.org.default_ticket_team + ), + ) + + # if we have a teams feature, we can select a team + self.org.features += [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) + sales = Team.create(self.org, self.admin, "New Team", topics=[]) + + self.assertCreateFetch(create_url, [self.admin], form_fields={"email": None, "role": "E", "team": None}) + self.assertCreateSubmit( + create_url, + self.admin, + {"email": "otheragent@nyaruka.com", "role": "T", "team": sales.id}, + new_obj_query=Invitation.objects.filter( + org=self.org, email="otheragent@nyaruka.com", role_code="T", team=sales + ), + ) + def test_delete(self): inv1 = Invitation.create(self.org, self.admin, "bob@nyaruka.com", OrgRole.EDITOR) inv2 = Invitation.create(self.org, self.admin, "jim@nyaruka.com", OrgRole.AGENT) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 96cc76f91fb..36bb5365a95 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -41,6 +41,7 @@ from temba.formax import FormaxMixin from temba.notifications.mixins import NotificationTargetMixin from temba.orgs.tasks import send_user_verification_email +from temba.tickets.models import Team from temba.utils import analytics, json, languages, on_transaction_commit, str_to_bool from temba.utils.email import EmailSender, parse_smtp_url from temba.utils.fields import ( @@ -2140,7 +2141,13 @@ class List(RequireFeatureMixin, ContextMenuMixin, BaseListView): default_order = ("-created_on",) def build_context_menu(self, menu): - menu.add_modax(_("New"), "invite-create", reverse("orgs.invitation_create"), as_button=True) + menu.add_modax( + _("New"), + "invitation-create", + reverse("orgs.invitation_create"), + title=_("New Invitation"), + as_button=True, + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -2153,12 +2160,15 @@ class Form(forms.ModelForm): role = forms.ChoiceField( choices=OrgRole.choices(), initial=OrgRole.EDITOR.code, label=_("Role"), widget=SelectWidget() ) + team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, widget=SelectWidget()) def __init__(self, org, *args, **kwargs): self.org = org super().__init__(*args, **kwargs) + self.fields["team"].queryset = org.teams.filter(is_active=True).order_by(Lower("name")) + def clean_email(self): email = self.cleaned_data["email"] @@ -2172,7 +2182,7 @@ def clean_email(self): class Meta: model = Invitation - fields = ("email", "role") + fields = ("email", "role", "team") form_class = Form require_feature = Org.FEATURE_USERS @@ -2180,6 +2190,9 @@ class Meta: submit_button_name = _("Send") success_url = "@orgs.invitation_list" + def derive_exclude(self): + return [] if Org.FEATURE_TEAMS in self.request.org.features else ["team"] + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["org"] = self.request.org @@ -2191,9 +2204,10 @@ def get_context_data(self, **kwargs): return context def save(self, obj): - self.object = Invitation.create( - self.request.org, self.request.user, obj.email, OrgRole.from_code(self.form.cleaned_data["role"]) - ) + role = OrgRole.from_code(self.form.cleaned_data["role"]) + team = (obj.team or self.request.org.default_ticket_team) if role == OrgRole.AGENT else None + + self.object = Invitation.create(self.request.org, self.request.user, obj.email, role, team=team) def post_save(self, obj): obj.send() diff --git a/templates/flows/flow_create.html b/templates/flows/flow_create.html index 772253a6780..1baeaab88d6 100644 --- a/templates/flows/flow_create.html +++ b/templates/flows/flow_create.html @@ -1,4 +1,4 @@ -{% extends 'includes/modax.html' %} +{% extends "includes/modax.html" %} {% load smartmin i18n %} {% block fields %} diff --git a/templates/orgs/invitation_create.html b/templates/orgs/invitation_create.html index e31f9ffbbdd..2a0e62a32c4 100644 --- a/templates/orgs/invitation_create.html +++ b/templates/orgs/invitation_create.html @@ -1,5 +1,5 @@ {% extends "includes/modax.html" %} -{% load i18n %} +{% load i18n smartmin %} {% block pre-form %}
    @@ -8,3 +8,29 @@ {% endblocktrans %}
    {% endblock pre-form %} +{% block fields %} + {% render_field 'email' %} + {% render_field 'role' %} + {% if form.fields.team %} + + {% endif %} +{% endblock fields %} +{% block modal-script %} + {{ block.super }} + +{% endblock modal-script %} diff --git a/templates/orgs/invitation_list.html b/templates/orgs/invitation_list.html index 7bb93d7a633..07c8463a6fb 100644 --- a/templates/orgs/invitation_list.html +++ b/templates/orgs/invitation_list.html @@ -26,7 +26,10 @@ {% for obj in object_list %}
    - +
    {% trans "Name" %}
    {{ obj.name }}{{ obj.user_count }} + {% if obj.all_topics %} + {% trans "All" %} + {% else %} + {% for topic in obj.topics.all %} + + {{ topic.name }} + + {% endfor %} + {% endif %} +
    {% trans "Group" %} - {% if group.group_type == "M" or group.group_type == "S" %} + {% if group.group_type == "M" or group.group_type == "S" %} + {% trans "Group" %} {{ group.name }} - {% else %} - {{ group.name }} - {% endif %} - {% trans "Status" %}{{ group.name|cut:"\\" }}
    {{ obj.email }}{{ obj.role.display }} + {{ obj.role.display }} + {% if obj.team %}({{ obj.team.name }}){% endif %} + {{ obj.created_on|day }} Date: Thu, 7 Nov 2024 15:20:19 +0000 Subject: [PATCH 320/557] User and invitation list views should show team for agent users if that feature is enabled --- temba/orgs/tests.py | 26 +++++++++++++++++++++++--- temba/orgs/views/views.py | 4 +++- templates/orgs/invitation_list.html | 2 +- templates/orgs/user_list.html | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index a5038af9632..d128b760574 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2832,7 +2832,18 @@ def test_list(self): self.assertRequestDisallowed(list_url, [None, self.user, self.editor, self.agent]) - self.assertListFetch(list_url, [self.admin], context_objects=[self.admin, self.agent, self.editor, self.user]) + response = self.assertListFetch( + list_url, [self.admin], context_objects=[self.admin, self.agent, self.editor, self.user] + ) + self.assertNotContains(response, "(All Topics)") + + self.org.features += [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) + + response = self.assertListFetch( + list_url, [self.admin], context_objects=[self.admin, self.agent, self.editor, self.user] + ) + self.assertContains(response, "(All Topics)") # can search by name or email self.assertListFetch(list_url + "?search=andy", [self.admin], context_objects=[self.admin]) @@ -4057,9 +4068,18 @@ def test_list(self): self.assertRequestDisallowed(list_url, [None, self.user, self.editor, self.agent]) inv1 = Invitation.create(self.org, self.admin, "bob@nyaruka.com", OrgRole.EDITOR) - inv2 = Invitation.create(self.org, self.admin, "jim@nyaruka.com", OrgRole.AGENT) + inv2 = Invitation.create( + self.org, self.admin, "jim@nyaruka.com", OrgRole.AGENT, team=self.org.default_ticket_team + ) + + response = self.assertListFetch(list_url, [self.admin], context_objects=[inv2, inv1]) + self.assertNotContains(response, "(All Topics)") + + self.org.features += [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) - self.assertListFetch(list_url, [self.admin], context_objects=[inv2, inv1]) + response = self.assertListFetch(list_url, [self.admin], context_objects=[inv2, inv1]) + self.assertContains(response, "(All Topics)") def test_create(self): create_url = reverse("orgs.invitation_create") diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 36bb5365a95..d754368c028 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -373,9 +373,10 @@ def get_context_data(self, **kwargs): for user in context["object_list"]: membership = self.request.org.get_membership(user) user.role = membership.role - # user.team = membership.team # TODO enable this when orgs can create teams + user.team = membership.team context["has_viewers"] = self.request.org.get_users(roles=[OrgRole.VIEWER]).exists() + context["has_teams"] = Org.FEATURE_TEAMS in self.request.org.features context["admin_count"] = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).count() return context @@ -2152,6 +2153,7 @@ def build_context_menu(self, menu): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["validity_days"] = settings.INVITATION_VALIDITY.days + context["has_teams"] = Org.FEATURE_TEAMS in self.request.org.features return context class Create(RequireFeatureMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView): diff --git a/templates/orgs/invitation_list.html b/templates/orgs/invitation_list.html index 07c8463a6fb..ee3d66c8913 100644 --- a/templates/orgs/invitation_list.html +++ b/templates/orgs/invitation_list.html @@ -28,7 +28,7 @@ {{ obj.email }} {{ obj.role.display }} - {% if obj.team %}({{ obj.team.name }}){% endif %} + {% if obj.team and has_teams %}({{ obj.team.name }}){% endif %} {{ obj.created_on|day }} diff --git a/templates/orgs/user_list.html b/templates/orgs/user_list.html index c39e53487c7..715808c9a34 100644 --- a/templates/orgs/user_list.html +++ b/templates/orgs/user_list.html @@ -34,7 +34,7 @@ {{ obj.name }} {{ obj.role.display }} - {% if obj.team %}({{ obj.team.name }}){% endif %} + {% if obj.team and has_teams %}({{ obj.team.name }}){% endif %} {% if obj.role.code != "A" or admin_count > 1 %} From 5c112af4f851faff50a311488dd075fa3296bb6c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 16:10:52 +0000 Subject: [PATCH 321/557] Allow updating agent team from user list page --- temba/orgs/tests.py | 15 +++++++++++++++ temba/orgs/views/views.py | 31 ++++++++++++++++++++++++++----- templates/orgs/user_update.html | 28 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 templates/orgs/user_update.html diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index d128b760574..ffb45089651 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2896,6 +2896,21 @@ def test_update(self): self.assertEqual({self.user, self.editor}, set(self.org.get_users(roles=[OrgRole.EDITOR]))) + # adding teams feature enables team selection for agents + self.org.features += [Org.FEATURE_TEAMS] + self.org.save(update_fields=("features",)) + sales = Team.create(self.org, self.admin, "Sales", topics=[]) + + update_url = reverse("orgs.user_update", args=[self.agent.id]) + + self.assertUpdateFetch( + update_url, [self.admin], form_fields={"role": "T", "team": self.org.default_ticket_team} + ) + self.assertUpdateSubmit(update_url, self.admin, {"role": "T", "team": sales.id}) + + self.org._membership_cache = {} + self.assertEqual(sales, self.org.get_membership(self.agent).team) + # try updating ourselves... update_url = reverse("orgs.user_update", args=[self.admin.id]) diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index d754368c028..74572f76005 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -434,10 +434,18 @@ def get_context_data(self, **kwargs): class Update(RequireFeatureMixin, ModalFormMixin, OrgObjPermsMixin, SmartUpdateView): class Form(forms.ModelForm): role = forms.ChoiceField(choices=OrgRole.choices(), required=True, label=_("Role"), widget=SelectWidget()) + team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, widget=SelectWidget()) + + def __init__(self, org, *args, **kwargs): + self.org = org + + super().__init__(*args, **kwargs) + + self.fields["team"].queryset = org.teams.filter(is_active=True).order_by(Lower("name")) class Meta: model = User - fields = ("role",) + fields = ("role", "team") form_class = Form require_feature = Org.FEATURE_USERS @@ -448,20 +456,33 @@ def get_object_org(self): def get_queryset(self): return self.request.org.get_users() + def derive_exclude(self): + return [] if Org.FEATURE_TEAMS in self.request.org.features else ["team"] + def derive_initial(self): - # viewers default to editors - role = self.request.org.get_user_role(self.object) - return {"role": OrgRole.EDITOR.code if role == OrgRole.VIEWER else role.code} + membership = self.request.org.get_membership(self.object) + return { + # viewers default to editors + "role": OrgRole.EDITOR.code if membership.role == OrgRole.VIEWER else membership.role.code, + "team": membership.team, + } + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org"] = self.request.org + return kwargs def save(self, obj): role = OrgRole.from_code(self.form.cleaned_data["role"]) + team = self.form.cleaned_data.get("team") + team = (team or self.request.org.default_ticket_team) if role == OrgRole.AGENT else None # don't update if user is the last administrator and role is being changed to something else has_other_admins = self.request.org.get_users(roles=[OrgRole.ADMINISTRATOR]).exclude(id=obj.id).exists() if role != OrgRole.ADMINISTRATOR and not has_other_admins: return obj - self.request.org.add_user(obj, role) + self.request.org.add_user(obj, role, team=team) return obj def get_success_url(self): diff --git a/templates/orgs/user_update.html b/templates/orgs/user_update.html new file mode 100644 index 00000000000..8cb73f5382a --- /dev/null +++ b/templates/orgs/user_update.html @@ -0,0 +1,28 @@ +{% extends "includes/modax.html" %} +{% load i18n smartmin %} + +{% block fields %} + {% render_field 'role' %} + {% if form.fields.team %} + + {% endif %} +{% endblock fields %} +{% block modal-script %} + {{ block.super }} + +{% endblock modal-script %} From d6960e1739d33e1348ecd9d7fc6bf2b8af7590d7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 11:41:42 -0500 Subject: [PATCH 322/557] Update CHANGELOG.md for v9.3.94 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- temba/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc71d9b295..519dee97072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v9.3.94 (2024-11-07) +------------------------- + * Allow updating agent team from user list page + * User and invitation list views should show team for agent users if that feature is enabled + * Allow creating invitations with teams + * Remove experimental mailgun channel type + * Fix displaying of exports based on status "groups" + v9.3.93 (2024-11-05) ------------------------- * Allow invitations to specify team and block team deletion when it has pending invitations diff --git a/pyproject.toml b/pyproject.toml index ddc3323c053..b989a66d3f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "temba" -version = "9.3.93" +version = "9.3.94" description = "Hosted service for visually building interactive messaging applications" authors = ["Nyaruka "] diff --git a/temba/__init__.py b/temba/__init__.py index 88e7984f440..4500c2ce454 100644 --- a/temba/__init__.py +++ b/temba/__init__.py @@ -1,4 +1,4 @@ -__version__ = "9.3.93" +__version__ = "9.3.94" # This will make sure the app is always imported when # Django starts so that shared_task will use this app. From fba8bc4e70835fb4a3fffbb409b7526bbeda3834 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 11:54:48 -0500 Subject: [PATCH 323/557] Move msg view forms into forms.py --- temba/msgs/forms.py | 171 ++++++++++++++++++++++++++++++++++++++++++ temba/msgs/views.py | 176 +------------------------------------------- 2 files changed, 175 insertions(+), 172 deletions(-) create mode 100644 temba/msgs/forms.py diff --git a/temba/msgs/forms.py b/temba/msgs/forms.py new file mode 100644 index 00000000000..84f83fa79b6 --- /dev/null +++ b/temba/msgs/forms.py @@ -0,0 +1,171 @@ +from django import forms +from django.forms import Form, ValidationError +from django.utils.translation import gettext_lazy as _ + +from temba import mailroom +from temba.schedules.views import ScheduleFormMixin +from temba.templates.models import TemplateTranslation +from temba.utils import json, languages +from temba.utils.fields import ComposeField, ComposeWidget, ContactSearchWidget + +from .models import Msg + + +class ComposeForm(Form): + compose = ComposeField( + widget=ComposeWidget( + attrs={ + "chatbox": True, + "attachments": True, + "counter": True, + "completion": True, + "quickreplies": True, + "optins": True, + "templates": True, + } + ), + ) + + def clean_compose(self): + base_language = self.initial.get("base_language", "und") + primary_language = self.org.flow_languages[0] if self.org.flow_languages else None + + def is_language_missing(values): + if values: + text = values.get("text", "") + attachments = values.get("attachments", []) + return not (text or attachments) + return True + + # need at least a base or a primary + compose = self.cleaned_data["compose"] + base = compose.get(base_language, None) + primary = compose.get(primary_language, None) + + if is_language_missing(base) and is_language_missing(primary): + raise forms.ValidationError(_("This field is required.")) + + # check that all of our text and attachments are limited + # these are also limited client side, so this is a fail safe + for values in compose.values(): + if values: + text = values.get("text", "") + attachments = values.get("attachments", []) + if text and len(text) > Msg.MAX_TEXT_LEN: + raise forms.ValidationError(_(f"Maximum allowed text is {Msg.MAX_TEXT_LEN} characters.")) + if attachments and len(attachments) > Msg.MAX_ATTACHMENTS: + raise forms.ValidationError(_(f"Maximum allowed attachments is {Msg.MAX_ATTACHMENTS} files.")) + + primaryValues = compose.get(primary_language or base_language, {}) + template = primaryValues.get("template", None) + locale = primaryValues.get("locale", None) + variables = primaryValues.get("variables", []) + if template: + translation = TemplateTranslation.objects.filter( + template__org=self.org, template__uuid=template, locale=locale + ).first() + if translation: + for idx, param in enumerate(translation.variables): + # non text variables are required + if param.get("type") != "text": + if idx >= len(variables) or not variables[idx]: + raise forms.ValidationError(_("The attachment for the WhatsApp template is required.")) + + return compose + + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) + self.org = org + isos = [iso for iso in org.flow_languages] + + if self.initial and "base_language" in self.initial: + compose = self.initial["compose"] + base_language = self.initial["base_language"] + + if base_language not in isos: + # if we have a value for the primary org language show that first + if isos and isos[0] in compose: + isos.append(base_language) + else: + # otherwise, put our base_language first + isos.insert(0, base_language) + + # our base language might be a secondary language, see if it should be first + elif isos[0] not in compose: + isos.remove(base_language) + isos.insert(0, base_language) + + langs = [{"iso": iso, "name": str(_("Default")) if iso == "und" else languages.get_name(iso)} for iso in isos] + compose_attrs = self.fields["compose"].widget.attrs + compose_attrs["languages"] = json.dumps(langs) + + +class ScheduleForm(ScheduleFormMixin): + SEND_NOW = "now" + SEND_LATER = "later" + + SEND_CHOICES = ( + (SEND_NOW, _("Send right now")), + (SEND_LATER, _("Schedule for later")), + ) + + send_when = forms.ChoiceField( + choices=SEND_CHOICES, widget=forms.RadioSelect(attrs={"widget_only": True}), required=False + ) + + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["start_datetime"].required = False + self.set_org(org) + + def clean(self): + send_when = self.data.get("schedule-send_when", ScheduleForm.SEND_LATER) # doesn't exist for updates + start_datetime = self.data.get("schedule-start_datetime") + + if send_when == ScheduleForm.SEND_LATER and not start_datetime: + raise forms.ValidationError(_("Select when you would like the broadcast to be sent")) + + return super().clean() + + class Meta: + fields = ScheduleFormMixin.Meta.fields + ("send_when",) + + +class TargetForm(Form): + + contact_search = forms.JSONField( + widget=ContactSearchWidget( + attrs={ + "in_a_flow": True, + "not_seen_since_days": True, + "widget_only": True, + "endpoint": "/broadcast/preview/", + "placeholder": _("Enter contact query"), + } + ), + ) + + def __init__(self, org, *args, **kwargs): + super().__init__(*args, **kwargs) + self.org = org + + def clean_contact_search(self): + contact_search = self.cleaned_data.get("contact_search") + recipients = contact_search.get("recipients", []) + + if contact_search["advanced"] and ("query" not in contact_search or not contact_search["query"]): + raise ValidationError(_("A contact query is required.")) + + if not contact_search["advanced"] and len(recipients) == 0: + raise ValidationError(_("Contacts or groups are required.")) + + if contact_search["advanced"]: + try: + contact_search["parsed_query"] = ( + mailroom.get_client().contact_parse_query(self.org, contact_search["query"], parse_only=True).query + ) + except mailroom.QueryValidationException as e: + raise ValidationError(str(e)) + + return contact_search diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 936a62714ae..eb09a48e3c3 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -10,7 +10,6 @@ from django import forms from django.conf import settings from django.db.models.functions.text import Lower -from django.forms import Form, ValidationError from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse from django.utils import timezone @@ -29,18 +28,10 @@ BaseUsagesModal, ) from temba.orgs.views.mixins import BulkActionMixin, OrgObjPermsMixin, OrgPermsMixin -from temba.schedules.views import ScheduleFormMixin -from temba.templates.models import Template, TemplateTranslation -from temba.utils import json, languages +from temba.templates.models import Template +from temba.utils import json from temba.utils.compose import compose_deserialize, compose_serialize -from temba.utils.fields import ( - CompletionTextarea, - ComposeField, - ComposeWidget, - ContactSearchWidget, - InputWidget, - SelectWidget, -) +from temba.utils.fields import CompletionTextarea, ContactSearchWidget, InputWidget, SelectWidget from temba.utils.models import patch_queryset_count from temba.utils.views.mixins import ( ContextMenuMixin, @@ -52,6 +43,7 @@ ) from temba.utils.views.wizard import SmartWizardUpdateView, SmartWizardView +from .forms import ComposeForm, ScheduleForm, TargetForm from .models import Broadcast, Label, LabelCount, Media, MessageExport, Msg, OptIn, SystemLabel @@ -150,166 +142,6 @@ def build_context_menu(self, menu): menu.add_modax(_("Export"), "export-messages", self.derive_export_url(), title=_("Export Messages")) -class ComposeForm(Form): - compose = ComposeField( - widget=ComposeWidget( - attrs={ - "chatbox": True, - "attachments": True, - "counter": True, - "completion": True, - "quickreplies": True, - "optins": True, - "templates": True, - } - ), - ) - - def clean_compose(self): - base_language = self.initial.get("base_language", "und") - primary_language = self.org.flow_languages[0] if self.org.flow_languages else None - - def is_language_missing(values): - if values: - text = values.get("text", "") - attachments = values.get("attachments", []) - return not (text or attachments) - return True - - # need at least a base or a primary - compose = self.cleaned_data["compose"] - base = compose.get(base_language, None) - primary = compose.get(primary_language, None) - - if is_language_missing(base) and is_language_missing(primary): - raise forms.ValidationError(_("This field is required.")) - - # check that all of our text and attachments are limited - # these are also limited client side, so this is a fail safe - for values in compose.values(): - if values: - text = values.get("text", "") - attachments = values.get("attachments", []) - if text and len(text) > Msg.MAX_TEXT_LEN: - raise forms.ValidationError(_(f"Maximum allowed text is {Msg.MAX_TEXT_LEN} characters.")) - if attachments and len(attachments) > Msg.MAX_ATTACHMENTS: - raise forms.ValidationError(_(f"Maximum allowed attachments is {Msg.MAX_ATTACHMENTS} files.")) - - primaryValues = compose.get(primary_language or base_language, {}) - template = primaryValues.get("template", None) - locale = primaryValues.get("locale", None) - variables = primaryValues.get("variables", []) - if template: - translation = TemplateTranslation.objects.filter( - template__org=self.org, template__uuid=template, locale=locale - ).first() - if translation: - for idx, param in enumerate(translation.variables): - # non text variables are required - if param.get("type") != "text": - if idx >= len(variables) or not variables[idx]: - raise forms.ValidationError(_("The attachment for the WhatsApp template is required.")) - - return compose - - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - self.org = org - isos = [iso for iso in org.flow_languages] - - if self.initial and "base_language" in self.initial: - compose = self.initial["compose"] - base_language = self.initial["base_language"] - - if base_language not in isos: - # if we have a value for the primary org language show that first - if isos and isos[0] in compose: - isos.append(base_language) - else: - # otherwise, put our base_language first - isos.insert(0, base_language) - - # our base language might be a secondary language, see if it should be first - elif isos[0] not in compose: - isos.remove(base_language) - isos.insert(0, base_language) - - langs = [{"iso": iso, "name": str(_("Default")) if iso == "und" else languages.get_name(iso)} for iso in isos] - compose_attrs = self.fields["compose"].widget.attrs - compose_attrs["languages"] = json.dumps(langs) - - -class ScheduleForm(ScheduleFormMixin): - SEND_NOW = "now" - SEND_LATER = "later" - - SEND_CHOICES = ( - (SEND_NOW, _("Send right now")), - (SEND_LATER, _("Schedule for later")), - ) - - send_when = forms.ChoiceField( - choices=SEND_CHOICES, widget=forms.RadioSelect(attrs={"widget_only": True}), required=False - ) - - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["start_datetime"].required = False - self.set_org(org) - - def clean(self): - send_when = self.data.get("schedule-send_when", ScheduleForm.SEND_LATER) # doesn't exist for updates - start_datetime = self.data.get("schedule-start_datetime") - - if send_when == ScheduleForm.SEND_LATER and not start_datetime: - raise forms.ValidationError(_("Select when you would like the broadcast to be sent")) - - return super().clean() - - class Meta: - fields = ScheduleFormMixin.Meta.fields + ("send_when",) - - -class TargetForm(Form): - - contact_search = forms.JSONField( - widget=ContactSearchWidget( - attrs={ - "in_a_flow": True, - "not_seen_since_days": True, - "widget_only": True, - "endpoint": "/broadcast/preview/", - "placeholder": _("Enter contact query"), - } - ), - ) - - def __init__(self, org, *args, **kwargs): - super().__init__(*args, **kwargs) - self.org = org - - def clean_contact_search(self): - contact_search = self.cleaned_data.get("contact_search") - recipients = contact_search.get("recipients", []) - - if contact_search["advanced"] and ("query" not in contact_search or not contact_search["query"]): - raise ValidationError(_("A contact query is required.")) - - if not contact_search["advanced"] and len(recipients) == 0: - raise ValidationError(_("Contacts or groups are required.")) - - if contact_search["advanced"]: - try: - contact_search["parsed_query"] = ( - mailroom.get_client().contact_parse_query(self.org, contact_search["query"], parse_only=True).query - ) - except mailroom.QueryValidationException as e: - raise ValidationError(str(e)) - - return contact_search - - class BroadcastCRUDL(SmartCRUDL): actions = ( "list", From 922a73f7fab8c603faace54632f34285445ee2d4 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 7 Nov 2024 17:06:33 +0000 Subject: [PATCH 324/557] More obvious account servicing --- static/js/formax.js | 20 ++-- static/js/frame.js | 15 +-- temba/contacts/tests.py | 2 +- temba/msgs/tests.py | 2 +- temba/orgs/tests.py | 2 +- temba/staff/views.py | 4 +- temba/tests/crudl.py | 4 +- temba/tickets/tests.py | 2 +- temba/utils/views/mixins.py | 14 +-- templates/frame.html | 146 +++++++++++++----------------- templates/includes/servicing.html | 18 ++++ 11 files changed, 116 insertions(+), 113 deletions(-) create mode 100644 templates/includes/servicing.html diff --git a/static/js/formax.js b/static/js/formax.js index 114dbf7934b..67e5ef06d0b 100644 --- a/static/js/formax.js +++ b/static/js/formax.js @@ -43,13 +43,13 @@ window.fetchData = function (section) { var url; const headers = { - 'X-FORMAX': true, - 'X-PJAX': true, - 'X-FORMAX-ACTION': section.dataset.action, + 'X-Formax': true, + 'X-Formax-Action': section.dataset.action, + 'X-Pjax': true }; if (section.closest('.spa-container')) { - headers['TEMBA-SPA'] = 1; + headers['X-Temba-Spa'] = 1; } if (section.dataset.href) { @@ -59,7 +59,7 @@ window.fetchData = function (section) { const options = { headers: headers, method: 'GET', - container: id, + container: id }; return fetchAjax(url, options).then(function () { @@ -127,13 +127,13 @@ var _submitFormax = function (e) { const followRedirects = section.dataset.action === 'redirect'; const headers = { - 'X-FORMAX': true, - 'X-PJAX': true, - 'X-FORMAX-ACTION': section.dataset.action, + 'X-Formax': true, + 'X-Formax-Action': section.dataset.action, + 'X-Pjax': true }; if (section.closest('.spa-container')) { - headers['TEMBA-SPA'] = 1; + headers['X-Temba-Spa'] = 1; } var formData = new FormData(form); @@ -142,7 +142,7 @@ var _submitFormax = function (e) { headers: headers, method: 'POST', body: formData, - container: id, + container: id }; if (followRedirects) { diff --git a/static/js/frame.js b/static/js/frame.js index 828d7b2dcef..c41af339a7d 100644 --- a/static/js/frame.js +++ b/static/js/frame.js @@ -200,8 +200,9 @@ function spaRequest(url, options) { const body = options.body || null; const headers = options.headers || {}; - headers['TEMBA-REFERER-PATH'] = refererPath; - headers['TEMBA-PATH'] = url; + headers['X-Temba-Referrer-Path'] = refererPath; + headers['X-Temba-Path'] = url; + headers['X-Temba-Org'] = window.org_id; if (!ignoreHistory) { addToHistory(url); @@ -248,8 +249,8 @@ function fetchAjax(url, options) { options['headers']['X-CSRFToken'] = csrf; } - options['headers']['TEMBA-SPA'] = 1; - options['headers']['X-PJAX'] = 1; + options['headers']['X-Temba-Spa'] = 1; + options['headers']['X-Pjax'] = 1; let container = options['container'] || null; @@ -279,8 +280,8 @@ function fetchAjax(url, options) { }); // if we have a version mismatch, reload the page - var version = response.headers.get('x-temba-version'); - var org = response.headers.get('x-temba-org'); + var version = response.headers.get('X-Temba-Version'); + var org = response.headers.get('X-Temba-Org'); if (response.type !== 'cors' && org && org != org_id) { if (response.redirected) { @@ -438,7 +439,7 @@ function showModax(header, endpoint, modaxOptions) { modax['-temba-redirected'] = refreshMenu; } - modax.headers = { 'TEMBA-SPA': 1 }; + modax.headers = { 'X-Temba-Spa': 1 }; modax.header = header; modax.endpoint = endpoint; diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index d7e14528c7d..2728fcf3af6 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -152,7 +152,7 @@ def test_list(self, mr_mocks): active_contacts = self.org.active_contacts_group # fetch with spa flag - response = self.client.get(list_url, content_type="application/json", HTTP_TEMBA_SPA="1") + response = self.client.get(list_url, content_type="application/json", HTTP_X_TEMBA_SPA="1") self.assertEqual(response.context["base_template"], "spa.html") mr_mocks.contact_search("age = 18", contacts=[frank]) diff --git a/temba/msgs/tests.py b/temba/msgs/tests.py index 764b73fd165..2c97ab332ac 100644 --- a/temba/msgs/tests.py +++ b/temba/msgs/tests.py @@ -809,7 +809,7 @@ def test_filter(self): self.assertRedirect(response, reverse("orgs.org_choose")) # can as org viewer user - response = self.requestView(label3_url, self.user, HTTP_TEMBA_SPA=1) + response = self.requestView(label3_url, self.user, HTTP_X_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"]) diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 42208309d15..4b97e32314e 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -2474,7 +2474,7 @@ def test_create_child_spa(self): self.org.features = [Org.FEATURE_CHILD_ORGS] self.org.save(update_fields=("features",)) - response = self.client.post(create_url, {"name": "Child Org", "timezone": "Africa/Nairobi"}, HTTP_TEMBA_SPA=1) + response = self.client.post(create_url, {"name": "Child Org", "timezone": "Africa/Nairobi"}, HTTP_X_TEMBA_SPA=1) self.assertRedirect(response, reverse("orgs.org_list")) diff --git a/temba/staff/views.py b/temba/staff/views.py index c5f42843d5b..97f53180881 100644 --- a/temba/staff/views.py +++ b/temba/staff/views.py @@ -253,10 +253,10 @@ def form_valid(self, form): success_url = form.cleaned_data["next"] or reverse("msgs.msg_inbox") return HttpResponseRedirect(success_url) - # invalid form login 'logs out' the user from the org and takes them to the org manage page + # invalid form login 'logs out' the user from the org and takes them to the root def form_invalid(self, form): switch_to_org(self.request, None) - return HttpResponseRedirect(reverse("staff.org_list")) + return HttpResponseRedirect("/") class UserCRUDL(SmartCRUDL): diff --git a/temba/tests/crudl.py b/temba/tests/crudl.py index 77d4bdd66c4..423d0dbd62c 100644 --- a/temba/tests/crudl.py +++ b/temba/tests/crudl.py @@ -190,8 +190,8 @@ def assertContentMenu(self, url: str, user, items: list, choose_org=None): user, checks=[StatusCode(200), ContentType("application/json")], choose_org=choose_org, - HTTP_TEMBA_CONTENT_MENU=1, - HTTP_TEMBA_SPA=1, + HTTP_X_TEMBA_CONTENT_MENU=1, + HTTP_X_TEMBA_SPA=1, ) self.assertEqual(items, [item.get("label", "-") for item in response.json()["items"]]) diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 061caed4553..32a8507516a 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -752,7 +752,7 @@ def test_list(self): response = self.client.get( list_url, content_type="application/json", - HTTP_TEMBA_REFERER_PATH=f"/tickets/mine/open/{ticket.uuid}", + HTTP_X_TEMBA_REFERER_PATH=f"/tickets/mine/open/{ticket.uuid}", ) self.assertEqual(("tickets", "mine", "open", str(ticket.uuid)), response.context["temba_referer"]) diff --git a/temba/utils/views/mixins.py b/temba/utils/views/mixins.py index 3b6e9268ebc..c8ec9af09a5 100644 --- a/temba/utils/views/mixins.py +++ b/temba/utils/views/mixins.py @@ -21,8 +21,8 @@ logger = logging.getLogger(__name__) TEMBA_MENU_SELECTION = "temba_menu_selection" -TEMBA_CONTENT_ONLY = "x-temba-content-only" -TEMBA_VERSION = "x-temba-version" +TEMBA_CONTENT_ONLY = "X-Temba-Content-Only" +TEMBA_VERSION = "X-Temba-Version" class NoNavMixin: @@ -211,7 +211,7 @@ def build_context_menu(self, menu: Menu): # pragma: no cover pass def get(self, request, *args, **kwargs): - if "HTTP_TEMBA_CONTENT_MENU" in self.request.META: + if "HTTP_X_TEMBA_CONTENT_MENU" in self.request.META: return JsonResponse({"items": self._get_context_menu()}) return super().get(request, *args, **kwargs) @@ -264,7 +264,7 @@ def form_valid(self, form): messages.success(self.request, self.derive_success_message()) - if "HTTP_X_PJAX" not in self.request.META: + if "HTTP_X_TEBMA_PJAX" not in self.request.META: return HttpResponseRedirect(self.get_success_url()) else: # pragma: no cover return self.render_modal_response(form) @@ -282,14 +282,14 @@ class SpaMixin: @cached_property def spa_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_PATH", "").split("/") if s) + return tuple(s for s in self.request.META.get("HTTP_X_TEMBA_PATH", "").split("/") if s) @cached_property def spa_referrer_path(self) -> tuple: - return tuple(s for s in self.request.META.get("HTTP_TEMBA_REFERER_PATH", "").split("/") if s) + return tuple(s for s in self.request.META.get("HTTP_X_TEMBA_REFERER_PATH", "").split("/") if s) def is_content_only(self): - return "HTTP_TEMBA_SPA" in self.request.META + return "HTTP_X_TEMBA_SPA" in self.request.META def get_template_names(self): templates = super().get_template_names() diff --git a/templates/frame.html b/templates/frame.html index 101f1ba10ec..39177b6939c 100644 --- a/templates/frame.html +++ b/templates/frame.html @@ -178,95 +178,79 @@
    -
    - {% if request.user.is_staff and request.session.servicing %} -
    - -
    -
    - - -
    {{ user_org.name }}
    -
    -
    -
    +
    + {% include "includes/servicing.html" %} +
    +
    + + {% block menu-header %} + {% endblock menu-header %} +
    - {% endif %} -
    - - {% block menu-header %} - {% endblock menu-header %} - -
    -
    -
    -
    - - +
    +
    +
    + + +
    -
    -
    - {% block extra-style %} - {% endblock extra-style %} - {% block extra-script %} - {% endblock extra-script %} - {% block page-header %} - {% csrf_token %} -
    -
    -
    -
    - {% block title %} -
    - {% block title-text %} - {{ title }} - {% endblock title-text %} -
    - {% endblock title %} +
    + {% block extra-style %} + {% endblock extra-style %} + {% block extra-script %} + {% endblock extra-script %} + {% block page-header %} + {% csrf_token %} +
    +
    +
    +
    + {% block title %} +
    + {% block title-text %} + {{ title }} + {% endblock title-text %} +
    + {% endblock title %} +
    +
    +
    +
    + {% if has_context_menu %} + {% include "spa_page_menu.html" %} + {% endif %}
    -
    - +
    + {% block subtitle %} + {% endblock subtitle %}
    - {% if has_context_menu %} - {% include "spa_page_menu.html" %} - {% endif %} -
    -
    - {% block subtitle %} - {% endblock subtitle %}
    -
    - {% endblock page-header %} - {% block alert-messages %} - {% if user_org.is_suspended %} - {% include "org_suspended_include.html" %} - {% endif %} - {% endblock alert-messages %} - {% block content %} - {% endblock content %} -
    - {% block footer %} - - {% endblock footer %} + Copyright © 2012-2023 TextIt. All rights reserved. +
    + {% endblock footer %} +
    diff --git a/templates/includes/servicing.html b/templates/includes/servicing.html new file mode 100644 index 00000000000..5166d6e8685 --- /dev/null +++ b/templates/includes/servicing.html @@ -0,0 +1,18 @@ +{% if request.user.is_staff and request.session.servicing %} + +{% endif %} From 943b3f2b003cce7c390934faba4fe553ce2e9944 Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 7 Nov 2024 17:07:38 +0000 Subject: [PATCH 325/557] Include temba-org header on components --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index fec6bc05a17..f6ad94d1b99 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@nyaruka/flow-editor": "1.35.2", - "@nyaruka/temba-components": "0.110.3", + "@nyaruka/temba-components": "0.111.0", "codemirror": "5.18.2", "colorette": "1.2.2", "fa-icons": "0.2.0", diff --git a/yarn.lock b/yarn.lock index 29d0e314b4f..71850b63201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,10 +103,10 @@ serialize-javascript "^6.0.2" tiny-lru "^11.2.5" -"@nyaruka/temba-components@0.110.3": - version "0.110.3" - resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.110.3.tgz#a20bb373f426ec659db71a2b9811de009e33970b" - integrity sha512-UlLsPvcIBRrfyHvfQA7p8jllMRtNp17gr/siTDE/dKsQp0fJmiU8LanRXCB2B85J5Cr227HM/CzZW89X1ajiiQ== +"@nyaruka/temba-components@0.111.0": + version "0.111.0" + resolved "https://registry.yarnpkg.com/@nyaruka/temba-components/-/temba-components-0.111.0.tgz#006257390396401c9d60f9156d8d453e38e5b17e" + integrity sha512-0QY7kZL8THYzIFFnbRbvypiywin2Fwz6v4x6SFusVj2mSa1Mp18phWO29/RpRqEA5qB3PlelwTS8rBq8fnnFGQ== dependencies: "@lit/localize" "^0.12.1" color-hash "^2.0.2" From ab116be1ef801340e402441d09a1d8db4a80a51d Mon Sep 17 00:00:00 2001 From: Eric Newcomer Date: Thu, 7 Nov 2024 17:34:41 +0000 Subject: [PATCH 326/557] Service changes now take you out to root --- temba/staff/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/temba/staff/tests.py b/temba/staff/tests.py index b2a64fd4931..e1b72373361 100644 --- a/temba/staff/tests.py +++ b/temba/staff/tests.py @@ -166,9 +166,9 @@ def test_service(self, mr_mocks): response = self.client.get(service_url, {"other_org": 325253256, "next": inbox_url}) self.assertContains(response, "Invalid org") - # posting invalid org just redirects back to manage page + # posting invalid org takes you back out response = self.client.post(service_url, {"other_org": 325253256}) - self.assertRedirect(response, "/staff/org/") + self.assertRedirect(response, "/") # then service our org response = self.client.get(service_url, {"other_org": self.org.id}) @@ -202,7 +202,7 @@ def test_service(self, mr_mocks): # stop servicing response = self.client.post(service_url, {}) - self.assertRedirect(response, "/staff/org/") + self.assertRedirect(response, "/") self.assertIsNone(self.client.session["org_id"]) self.assertFalse(self.client.session["servicing"]) From 60fabf1556a87d9d992ccd849bf8dbb29cb5c111 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 17:55:27 +0000 Subject: [PATCH 327/557] Only allow GET requests by servicing staff users --- temba/orgs/views/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temba/orgs/views/mixins.py b/temba/orgs/views/mixins.py index af43e50d9dd..5511206de64 100644 --- a/temba/orgs/views/mixins.py +++ b/temba/orgs/views/mixins.py @@ -35,7 +35,7 @@ def has_org_perm(self, permission: str): return False if user.is_superuser: return True - if user.is_staff and self.request.method != "POST": + if user.is_staff and self.request.method == "GET": return True return self.get_user().has_org_perm(org, permission) From 8bb3f7d375f3c1786d0c1109a17bdfaa78aeb453 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 7 Nov 2024 18:08:09 +0000 Subject: [PATCH 328/557] Remove no longer used partial template view --- temba/flows/urls.py | 7 +------ temba/flows/views.py | 9 --------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/temba/flows/urls.py b/temba/flows/urls.py index be3465e876e..42cfd4c98f4 100644 --- a/temba/flows/urls.py +++ b/temba/flows/urls.py @@ -1,12 +1,7 @@ -from django.urls import re_path - -from .views import FlowCRUDL, FlowLabelCRUDL, FlowRunCRUDL, FlowSessionCRUDL, FlowStartCRUDL, PartialTemplate +from .views import FlowCRUDL, FlowLabelCRUDL, FlowRunCRUDL, FlowSessionCRUDL, FlowStartCRUDL urlpatterns = FlowCRUDL().as_urlpatterns() urlpatterns += FlowLabelCRUDL().as_urlpatterns() urlpatterns += FlowRunCRUDL().as_urlpatterns() urlpatterns += FlowSessionCRUDL().as_urlpatterns() urlpatterns += FlowStartCRUDL().as_urlpatterns() -urlpatterns += [ - re_path(r"^partials/(?P