From 48791c6c2e8f5f3dab6f530fe7dbdc2a2eb18990 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 1 Jul 2024 16:30:12 +0000 Subject: [PATCH 01/28] Fix globals list template and move annotate_usage into DependencyMixin --- temba/globals/models.py | 5 ----- temba/orgs/models.py | 6 +++++- templates/globals/global_list.html | 21 +++++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/temba/globals/models.py b/temba/globals/models.py index 0b4e2c73035..2d5a542d3c6 100644 --- a/temba/globals/models.py +++ b/temba/globals/models.py @@ -2,7 +2,6 @@ from django.conf import settings from django.db import models -from django.db.models import Count from django.utils.translation import gettext_lazy as _ from temba.orgs.models import DependencyMixin, Org @@ -57,10 +56,6 @@ def is_valid_key(cls, key): def is_valid_name(cls, name): return regex.match(r"^[A-Za-z0-9_\- ]+$", name, regex.V0) and len(name) <= cls.MAX_NAME_LEN - @classmethod - def annotate_usage(cls, queryset): - return queryset.annotate(usage_count=Count("dependent_flows", distinct=True)) - def release(self, user): super().release(user) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 51a0b21f250..d53c679da94 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -26,7 +26,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 Prefetch +from django.db.models import Count, Prefetch from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -61,6 +61,10 @@ class DependencyMixin: def get_dependents(self): return {"flow": self.dependent_flows.filter(is_active=True)} + @classmethod + def annotate_usage(cls, queryset): + return queryset.annotate(usage_count=Count("dependent_flows", distinct=True)) + def release(self, user): """ Mark this dependency's flows as having issues, and then remove the dependencies diff --git a/templates/globals/global_list.html b/templates/globals/global_list.html index cd11ff782e5..c15acc3c45f 100644 --- a/templates/globals/global_list.html +++ b/templates/globals/global_list.html @@ -36,7 +36,7 @@ {% endblock pre-table %}
{% include "includes/short_pagination.html" %}
- +
{% for obj in object_list %} @@ -48,16 +48,17 @@ From ada10395b2d90c8544794b8b0f672c4bd986c17f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 1 Jul 2024 16:31:16 +0000 Subject: [PATCH 02/28] Add new template list and read pages and remove old channel specific ones --- temba/channels/types/dialog360/type.py | 2 - temba/channels/types/dialog360_legacy/type.py | 2 - temba/channels/types/twilio_whatsapp/type.py | 2 - temba/channels/types/whatsapp/type.py | 5 +- temba/channels/types/whatsapp_legacy/type.py | 2 - temba/msgs/views.py | 5 + temba/settings_common.py | 3 +- temba/templates/models.py | 14 ++- temba/templates/templatetags/__init__.py | 0 temba/templates/templatetags/templates.py | 12 +++ temba/templates/templatetags/tests.py | 9 ++ temba/templates/tests.py | 95 ++++++------------- temba/templates/urls.py | 4 +- temba/templates/views.py | 64 +++++-------- templates/templates/template_list.html | 77 +++++++++++++++ ...lation_channel.html => template_read.html} | 41 ++++---- 16 files changed, 194 insertions(+), 143 deletions(-) create mode 100644 temba/templates/templatetags/__init__.py create mode 100644 temba/templates/templatetags/templates.py create mode 100644 temba/templates/templatetags/tests.py create mode 100644 templates/templates/template_list.html rename templates/templates/{templatetranslation_channel.html => template_read.html} (60%) diff --git a/temba/channels/types/dialog360/type.py b/temba/channels/types/dialog360/type.py index 32d9f0f3206..847a999fb03 100644 --- a/temba/channels/types/dialog360/type.py +++ b/temba/channels/types/dialog360/type.py @@ -35,8 +35,6 @@ class Dialog360Type(ChannelType): config_ui = ConfigUI() # has own template - menu_items = [dict(label=_("Message Templates"), view_name="templates.templatetranslation_channel")] - def get_headers(self, channel): return {"D360-API-KEY": channel.config[Channel.CONFIG_AUTH_TOKEN], "Content-Type": "application/json"} diff --git a/temba/channels/types/dialog360_legacy/type.py b/temba/channels/types/dialog360_legacy/type.py index e36301d5af9..2b67072763c 100644 --- a/temba/channels/types/dialog360_legacy/type.py +++ b/temba/channels/types/dialog360_legacy/type.py @@ -34,8 +34,6 @@ class Dialog360LegacyType(ChannelType): config_ui = ConfigUI() # has own template - menu_items = [dict(label=_("Message Templates"), view_name="templates.templatetranslation_channel")] - def get_headers(self, channel): return {"D360-API-KEY": channel.config[Channel.CONFIG_AUTH_TOKEN], "Content-Type": "application/json"} diff --git a/temba/channels/types/twilio_whatsapp/type.py b/temba/channels/types/twilio_whatsapp/type.py index 4fd7770ac15..a853ec6b6cb 100644 --- a/temba/channels/types/twilio_whatsapp/type.py +++ b/temba/channels/types/twilio_whatsapp/type.py @@ -64,8 +64,6 @@ class TwilioWhatsappType(ChannelType): ], ) - menu_items = [dict(label=_("Message Templates"), view_name="templates.templatetranslation_channel")] - def get_error_ref_url(self, channel, code: str) -> str: return f"https://www.twilio.com/docs/api/errors/{code}" diff --git a/temba/channels/types/whatsapp/type.py b/temba/channels/types/whatsapp/type.py index 516d8bca870..9269a3038a4 100644 --- a/temba/channels/types/whatsapp/type.py +++ b/temba/channels/types/whatsapp/type.py @@ -34,10 +34,7 @@ class WhatsAppType(ChannelType): claim_blurb = _("If you have an enterprise WhatsApp account, you can connect it to communicate with your contacts") claim_view = ClaimView - menu_items = [ - dict(label=_("Message Templates"), view_name="templates.templatetranslation_channel"), - dict(label=_("Verify Number"), view_name="channels.types.whatsapp.request_code"), - ] + menu_items = [dict(label=_("Verify Number"), view_name="channels.types.whatsapp.request_code")] def get_urls(self): return [ diff --git a/temba/channels/types/whatsapp_legacy/type.py b/temba/channels/types/whatsapp_legacy/type.py index 868559964d6..342a1b1cfcd 100644 --- a/temba/channels/types/whatsapp_legacy/type.py +++ b/temba/channels/types/whatsapp_legacy/type.py @@ -47,8 +47,6 @@ class WhatsAppLegacyType(ChannelType): config_ui = ConfigUI() # has own template - menu_items = [dict(label=_("Message Templates"), view_name="templates.templatetranslation_channel")] - def get_urls(self): return [ self.get_claim_url(), diff --git a/temba/msgs/views.py b/temba/msgs/views.py index 1454aefdfac..89194934cbb 100644 --- a/temba/msgs/views.py +++ b/temba/msgs/views.py @@ -755,6 +755,11 @@ def derive_menu(self): name=_("Broadcasts"), href=reverse("msgs.broadcast_list"), ), + self.create_menu_item( + menu_id="templates", + name=_("Templates"), + href=reverse("templates.template_list"), + ), self.create_divider(), self.create_menu_item( menu_id="calls", diff --git a/temba/settings_common.py b/temba/settings_common.py index 1a654a1d7d3..6fda1ee52a9 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -488,7 +488,7 @@ "request_logs.httplog_list", "request_logs.httplog_read", "request_logs.httplog_webhooks", - "templates.template_list", + "templates.template.*", "tickets.ticket.*", "tickets.topic.*", "triggers.trigger.*", @@ -569,6 +569,7 @@ "orgs.user_token", "request_logs.httplog_webhooks", "templates.template_list", + "templates.template_read", "tickets.ticket.*", "tickets.topic.*", "triggers.trigger.*", diff --git a/temba/templates/models.py b/temba/templates/models.py index 2716145cd8b..b134a6f5937 100644 --- a/temba/templates/models.py +++ b/temba/templates/models.py @@ -1,11 +1,12 @@ import uuid from django.db import models +from django.db.models import Count, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from temba.channels.models import Channel -from temba.orgs.models import Org +from temba.orgs.models import DependencyMixin, Org from temba.utils.languages import alpha2_to_alpha3 from temba.utils.models import update_if_changed @@ -32,7 +33,7 @@ def _parse_language(self, lang: str) -> str: return f"{language}-{country}" if country else language -class Template(models.Model): +class Template(models.Model, DependencyMixin): """ Templates represent messages that can be used in flows and have template variables substituted into them. These are currently only used for WhatsApp channels. @@ -72,6 +73,15 @@ def is_approved(self): return True + @classmethod + def annotate_usage(cls, queryset): + qs = super().annotate_usage(queryset) + + return qs.annotate( + translation_count=Count("translations", filter=Q(translations__is_active=True)), + channel_count=Count("translations__channel", filter=Q(translations__is_active=True), distinct=True), + ) + class Meta: unique_together = ("org", "name") diff --git a/temba/templates/templatetags/__init__.py b/temba/templates/templatetags/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/temba/templates/templatetags/templates.py b/temba/templates/templatetags/templates.py new file mode 100644 index 00000000000..90447fe8616 --- /dev/null +++ b/temba/templates/templatetags/templates.py @@ -0,0 +1,12 @@ +import re + +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() +handlebars_regex = re.compile(r"{{([^}]+)}}") + + +@register.filter +def handlebars(text): + return mark_safe(handlebars_regex.sub(r"{{\1}}", text)) diff --git a/temba/templates/templatetags/tests.py b/temba/templates/templatetags/tests.py new file mode 100644 index 00000000000..bcc511b994a --- /dev/null +++ b/temba/templates/templatetags/tests.py @@ -0,0 +1,9 @@ +from temba.tests import TembaTest + +from .templates import handlebars + + +class TemplatesTest(TembaTest): + def test_handlebars(self): + self.assertEqual("Hello", handlebars("Hello")) + self.assertEqual("Hello {{name}}", handlebars("Hello {{name}}")) diff --git a/temba/templates/tests.py b/temba/templates/tests.py index 5f9916fa065..c8dc42d6608 100644 --- a/temba/templates/tests.py +++ b/temba/templates/tests.py @@ -306,79 +306,46 @@ def mock_fail_fetch(ch): self.assertEqual("Error refreshing whatsapp templates: boom", mock_log_error.call_args[0][0]) -class TemplateTranslationCRUDLTest(CRUDLTestMixin, TembaTest): - def test_channel(self): +class TemplateCRUDLTest(CRUDLTestMixin, TembaTest): + def test_list(self): channel = self.create_channel("D3C", "360Dialog channel", address="1234", country="BR") - tt1 = TemplateTranslation.get_or_create( - channel, - "hello", - locale="eng-US", - status=TemplateTranslation.STATUS_APPROVED, - external_id="1234", - external_locale="en_US", - namespace="foo_namespace", - components=[ - { - "name": "body", - "type": "body/text", - "content": "Hello {{1}}", - "variables": {"1": 0}, - "params": [{"type": "text"}], - } - ], - variables=[{"type": "text"}], + template1 = Template.objects.create(org=self.org, name="hello") + template2 = Template.objects.create(org=self.org, name="goodbye") + + TemplateTranslation.objects.create( + template=template1, channel=channel, locale="eng-US", status=TemplateTranslation.STATUS_APPROVED ) - tt2 = TemplateTranslation.get_or_create( - channel, - "goodbye", - locale="eng-US", - status=TemplateTranslation.STATUS_PENDING, - external_id="2345", - external_locale="en_US", - namespace="foo_namespace", - components=[ - { - "name": "body", - "type": "body/text", - "content": "Goodbye {{1}}", - "variables": {"1": 0}, - "params": [{"type": "text"}], - } - ], - variables=[{"type": "text"}], + TemplateTranslation.objects.create( + template=template1, channel=channel, locale="spa", status=TemplateTranslation.STATUS_APPROVED + ) + TemplateTranslation.objects.create( + template=template2, channel=channel, locale="eng", status=TemplateTranslation.STATUS_PENDING + ) + TemplateTranslation.objects.create( + template=template1, channel=self.channel, locale="eng-US", status=TemplateTranslation.STATUS_PENDING ) - # and one for another channel - TemplateTranslation.get_or_create( - self.channel, - "hello", - locale="eng-US", + # add template and translation in other org + channel_other_org = self.create_channel("D3C", "360Dialog channel", address="2345", org=self.org2) + template_other_org = Template.objects.create(org=self.org2, name="hello") + TemplateTranslation.objects.create( + template=template_other_org, + channel=channel_other_org, + locale="eng", status=TemplateTranslation.STATUS_PENDING, - external_id="5678", - external_locale="en_US", - namespace="foo_namespace", - components=[ - { - "name": "body", - "type": "body/text", - "content": "Goodbye {{1}}", - "variables": {"1": 0}, - "params": [{"type": "text"}], - } - ], - variables=[{"type": "text"}], ) - channel_url = reverse("templates.templatetranslation_channel", args=[channel.uuid]) + list_url = reverse("templates.template_list") - self.assertRequestDisallowed(channel_url, [None, self.agent, self.admin2]) - response = self.assertListFetch(channel_url, [self.user, self.editor, self.admin], context_objects=[tt2, tt1]) - - self.assertContains(response, "Hello") - self.assertContentMenu(channel_url, self.admin, ["Sync Logs"]) + self.assertRequestDisallowed(list_url, [None, self.agent]) + response = self.assertListFetch( + list_url, [self.user, self.editor, self.admin], context_objects=[template2, template1] + ) - response = self.client.get(reverse("templates.templatetranslation_channel", args=["1234567890-1234"])) - self.assertEqual(404, response.status_code) + self.assertContains(response, "goodbye") + self.assertContains(response, "1 translation,") + self.assertContains(response, "hello") + self.assertContains(response, "3 translations,") class RemoveDuplicateTranslationsTest(MigrationTest): diff --git a/temba/templates/urls.py b/temba/templates/urls.py index 339c2782322..ac65a39ee13 100644 --- a/temba/templates/urls.py +++ b/temba/templates/urls.py @@ -1,3 +1,3 @@ -from .views import TemplateTranslationCRUDL +from .views import TemplateCRUDL -urlpatterns = TemplateTranslationCRUDL().as_urlpatterns() +urlpatterns = TemplateCRUDL().as_urlpatterns() diff --git a/temba/templates/views.py b/temba/templates/views.py index 4ba1caa270d..15f42d33340 100644 --- a/temba/templates/views.py +++ b/temba/templates/views.py @@ -1,24 +1,26 @@ -from smartmin.views import SmartCRUDL, SmartListView +from smartmin.views import SmartCRUDL, SmartListView, SmartReadView -from django.http import Http404 -from django.urls import reverse -from django.utils.functional import cached_property -from django.utils.translation import gettext_lazy as _ +from temba.orgs.views import DependencyUsagesModal, OrgObjPermsMixin, OrgPermsMixin +from temba.utils.views import SpaMixin -from temba.channels.models import Channel -from temba.orgs.views import OrgObjPermsMixin -from temba.utils.views import ContentMenuMixin, SpaMixin +from .models import Template, TemplateTranslation -from .models import TemplateTranslation +class TemplateCRUDL(SmartCRUDL): + model = Template + actions = ("list", "read", "usages") -class TemplateTranslationCRUDL(SmartCRUDL): - model = TemplateTranslation - actions = ("channel",) - path = "template" + class List(SpaMixin, OrgPermsMixin, SmartListView): + default_order = ("-created_on",) - class Channel(SpaMixin, ContentMenuMixin, OrgObjPermsMixin, SmartListView): - permission = "channels.channel_read" + def derive_menu_path(self): + return "/msg/templates" + + def get_queryset(self, **kwargs): + return Template.annotate_usage(super().get_queryset(**kwargs).filter(org=self.request.org)) + + class Read(SpaMixin, OrgObjPermsMixin, SmartReadView): + slug_url_kwarg = "uuid" status_icons = { TemplateTranslation.STATUS_PENDING: "template_pending", TemplateTranslation.STATUS_APPROVED: "template_approved", @@ -26,36 +28,14 @@ class Channel(SpaMixin, ContentMenuMixin, OrgObjPermsMixin, SmartListView): TemplateTranslation.STATUS_UNSUPPORTED: "template_unsupported", } - @classmethod - def derive_url_pattern(cls, path, action): - return r"^%s/%s/(?P[^/]+)/$" % (path, action) - - def build_content_menu(self, menu): - menu.add_link(_("Sync Logs"), reverse("request_logs.httplog_channel", args=[self.channel.uuid])) - def derive_menu_path(self): - return f"/settings/channels/{self.channel.uuid}" - - def get_object_org(self): - return self.channel.org - - @cached_property - def channel(self): - try: - return Channel.objects.get(is_active=True, uuid=self.kwargs["channel"]) - except Channel.DoesNotExist: - raise Http404("Channel not found") - - def derive_queryset(self, **kwargs): - return ( - super() - .derive_queryset(**kwargs) - .filter(channel=self.channel, is_active=True) - .order_by("template__name") - ) + return "/msg/templates" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["channel"] = self.channel + context["translations"] = context["object"].translations.order_by("locale", "channel") context["status_icons"] = self.status_icons return context + + class Usages(DependencyUsagesModal): + permission = "templates.template_read" diff --git a/templates/templates/template_list.html b/templates/templates/template_list.html new file mode 100644 index 00000000000..58ad4b87577 --- /dev/null +++ b/templates/templates/template_list.html @@ -0,0 +1,77 @@ +{% extends "smartmin/list.html" %} +{% load i18n %} + +{% block content %} +
+ {% blocktrans trimmed %} + Templates are refreshed from supporting channels every 15 minutes. + {% endblocktrans %} +
+ {% block pre-table %} + + + {% endblock pre-table %} +
{% include "includes/short_pagination.html" %}
+
+
{% with usage_count=obj.usage_count %} - {% if usage_count %}{% endif %} -
-
- {% blocktrans trimmed count counter=usage_count %} - {{ counter }} Use - {% plural %} - {{ counter }} Uses - {% endblocktrans %} + {% if usage_count %} +
+
+ {% blocktrans trimmed count counter=usage_count %} + {{ counter }} use + {% plural %} + {{ counter }} uses + {% endblocktrans %} +
-
+ {% endif %} {% endwith %}
+ + {% for obj in object_list %} + + + + + + {% empty %} + + + + {% endfor %} + +
{{ obj.name }} + {# djlint:off #} + {% blocktrans trimmed count counter=obj.translation_count %}{{ counter }} translation{% plural %}{{ counter }} translations{% endblocktrans %}, + {# djlint:on #} + {% blocktrans trimmed count counter=obj.channel_count %} + {{ counter }} channel + {% plural %} + {{ counter }} channels + {% endblocktrans %} + +
+ {% with usage_count=obj.usage_count %} + {% if usage_count %} +
+
+ {% blocktrans trimmed count counter=usage_count %} + {{ counter }} use + {% plural %} + {{ counter }} uses + {% endblocktrans %} +
+
+ {% endif %} + {% endwith %} +
+
{% trans "No templates have been fetched yet." %}
+
+{% endblock content %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %} diff --git a/templates/templates/templatetranslation_channel.html b/templates/templates/template_read.html similarity index 60% rename from templates/templates/templatetranslation_channel.html rename to templates/templates/template_read.html index 628398c4ded..f122d9df159 100644 --- a/templates/templates/templatetranslation_channel.html +++ b/templates/templates/template_read.html @@ -1,47 +1,43 @@ -{% extends "smartmin/list.html" %} -{% load i18n smartmin %} +{% extends "smartmin/read.html" %} +{% load i18n smartmin templates %} {% block title-text %} - {{ channel.name }} - {% trans "Templates" %} + {% trans "Template" %}: {{ object.name }} {% endblock title-text %} -{% block subtitle %} - {% trans "Templates are refreshed every 15 minutes." %} -{% endblock subtitle %} {% block content %} -
{% include "includes/short_pagination.html" %}
-
- {% for translation in object_list %} - {% ifchanged translation.template.name %} -
{{ translation.template.name }}
- {% endifchanged %} +
+ {% for translation in translations %}
{% for comp in translation.components %}
- {% if comp.type == "header" or comp.type == "header/text" %} -
{{ comp.content }}
+ {% if comp.type == "header/text" %} +
{{ comp.content|handlebars }}
{% elif comp.type == "header/media" %} {% if comp.content %}
{{ comp.content }}
{% else %} {{ translation.variables.0.type|upper }} {% endif %} - {% elif comp.type == "body" or comp.type == "body/text" %} -
{{ comp.content }}
- {% elif comp.type == "footer" or comp.type == "footer/text" %} -
{{ comp.content }}
+ {% elif comp.type == "body/text" %} +
{{ comp.content|handlebars }}
+ {% elif comp.type == "footer/text" %} +
{{ comp.content|handlebars }}
{% endif %}
{% endfor %}
{% for comp in translation.components %} {% if comp.type|slice:":7" == "button/" %} -
{{ comp.display|default:comp.content }}
+
{{ comp.display|default:comp.content|handlebars }}
{% endif %} {% endfor %}
+ {# djlint:off #} + {{ translation.channel }} + {# djlint:on #} {{ translation.locale }} @@ -52,7 +48,7 @@
{% empty %} - {% trans "No synced templates at this time." %} + {% trans "No translations." %} {% endfor %}
@@ -60,6 +56,11 @@ {% block extra-style %} {{ block.super }}