From 95ff87fbf37d7a177c3598b38700aeefd2dd4a5c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Dec 2024 19:24:17 -0600 Subject: [PATCH] feat: add feature to edit email settings, basic message preview --- docs/api/wuttaweb.emails.rst | 6 + docs/api/wuttaweb.views.email.rst | 6 + docs/index.rst | 2 + src/wuttaweb/app.py | 7 +- .../feedback.html.mako | 0 .../feedback.txt.mako | 0 src/wuttaweb/emails.py | 48 +++ src/wuttaweb/forms/schema.py | 35 +- src/wuttaweb/forms/widgets.py | 37 +++ src/wuttaweb/menus.py | 6 + .../templates/deform/readonly/email_recips.pt | 5 + .../templates/email/settings/view.mako | 39 +++ src/wuttaweb/views/email.py | 298 ++++++++++++++++++ src/wuttaweb/views/essential.py | 2 + tests/forms/test_schema.py | 44 +++ tests/forms/test_widgets.py | 51 ++- tests/test_emails.py | 23 ++ tests/views/test_email.py | 211 +++++++++++++ tests/views/test_essential.py | 10 + 19 files changed, 826 insertions(+), 4 deletions(-) create mode 100644 docs/api/wuttaweb.emails.rst create mode 100644 docs/api/wuttaweb.views.email.rst rename src/wuttaweb/{email/templates => email-templates}/feedback.html.mako (100%) rename src/wuttaweb/{email/templates => email-templates}/feedback.txt.mako (100%) create mode 100644 src/wuttaweb/emails.py create mode 100644 src/wuttaweb/templates/deform/readonly/email_recips.pt create mode 100644 src/wuttaweb/templates/email/settings/view.mako create mode 100644 src/wuttaweb/views/email.py create mode 100644 tests/test_emails.py create mode 100644 tests/views/test_email.py create mode 100644 tests/views/test_essential.py diff --git a/docs/api/wuttaweb.emails.rst b/docs/api/wuttaweb.emails.rst new file mode 100644 index 0000000..c197877 --- /dev/null +++ b/docs/api/wuttaweb.emails.rst @@ -0,0 +1,6 @@ + +``wuttaweb.emails`` +=================== + +.. automodule:: wuttaweb.emails + :members: diff --git a/docs/api/wuttaweb.views.email.rst b/docs/api/wuttaweb.views.email.rst new file mode 100644 index 0000000..ba3f5c1 --- /dev/null +++ b/docs/api/wuttaweb.views.email.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.email`` +======================== + +.. automodule:: wuttaweb.views.email + :members: diff --git a/docs/index.rst b/docs/index.rst index e7f07f0..cd1d227 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.db api/wuttaweb.db.continuum api/wuttaweb.db.sess + api/wuttaweb.emails api/wuttaweb.forms api/wuttaweb.forms.base api/wuttaweb.forms.schema @@ -54,6 +55,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.views.base api/wuttaweb.views.batch api/wuttaweb.views.common + api/wuttaweb.views.email api/wuttaweb.views.essential api/wuttaweb.views.master api/wuttaweb.views.people diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 63def58..7546228 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -43,9 +43,12 @@ log = logging.getLogger(__name__) class WebAppProvider(AppProvider): """ The :term:`app provider` for WuttaWeb. This adds some methods to - the :term:`app handler`, which are specific to web apps. + the :term:`app handler`, which are specific to web apps. It also + registers some :term:`email templates ` for the + app, etc. """ - email_templates = 'wuttaweb:email/templates' + email_modules = ['wuttaweb.emails'] + email_templates = ['wuttaweb:email-templates'] def get_web_handler(self, **kwargs): """ diff --git a/src/wuttaweb/email/templates/feedback.html.mako b/src/wuttaweb/email-templates/feedback.html.mako similarity index 100% rename from src/wuttaweb/email/templates/feedback.html.mako rename to src/wuttaweb/email-templates/feedback.html.mako diff --git a/src/wuttaweb/email/templates/feedback.txt.mako b/src/wuttaweb/email-templates/feedback.txt.mako similarity index 100% rename from src/wuttaweb/email/templates/feedback.txt.mako rename to src/wuttaweb/email-templates/feedback.txt.mako diff --git a/src/wuttaweb/emails.py b/src/wuttaweb/emails.py new file mode 100644 index 0000000..4954bec --- /dev/null +++ b/src/wuttaweb/emails.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +:term:`Email Settings ` for WuttaWeb +""" + +from wuttjamaican.email import EmailSetting + + +class feedback(EmailSetting): + """ + Sent when user submits feedback via the web app. + """ + default_subject = "User Feedback" + + def sample_data(self): + """ """ + model = self.app.model + person = model.Person(full_name="Barney Rubble") + user = model.User(username='barney', person=person) + return { + 'user': user, + 'user_name': str(person), + 'user_url': '#', + 'referrer': 'http://example.com/', + 'client_ip': '127.0.0.1', + 'message': "This app is cool but needs a new feature.\n\nAllow me to describe...", + } diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 6fa39c9..b5e7667 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -30,9 +30,11 @@ import uuid as _uuid import colander import sqlalchemy as sa +from wuttjamaican.db.model import Person +from wuttjamaican.conf import parse_list + from wuttaweb.db import Session from wuttaweb.forms import widgets -from wuttjamaican.db.model import Person class WuttaDateTime(colander.DateTime): @@ -569,5 +571,36 @@ class FileDownload(colander.String): return widgets.FileDownloadWidget(self.request, **kwargs) +class EmailRecipients(colander.String): + """ + Custom schema type for :term:`email setting` recipient fields + (``To``, ``Cc``, ``Bcc``). + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + + return '\n'.join(parse_list(appstruct)) + + def deserialize(self, node, cstruct): + """ """ + if cstruct is colander.null: + return colander.null + + values = [value for value in parse_list(cstruct) + if value] + return ', '.join(values) + + def widget_maker(self, **kwargs): + """ + Constructs a default widget for the field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.EmailRecipientsWidget`. + """ + return widgets.EmailRecipientsWidget(**kwargs) + + # nb. colanderalchemy schema overrides sa.DateTime.__colanderalchemy_config__ = {'typ': WuttaDateTime} diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index f90768a..0fa8773 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -51,6 +51,8 @@ from deform.widget import (Widget, TextInputWidget, TextAreaWidget, DateTimeInputWidget, MoneyInputWidget) from webhelpers2.html import HTML +from wuttjamaican.conf import parse_list + from wuttaweb.db import Session from wuttaweb.grids import Grid @@ -423,6 +425,41 @@ class PermissionsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class EmailRecipientsWidget(TextAreaWidget): + """ + Widget for :term:`email setting` recipient fields (``To``, ``Cc``, + ``Bcc``). + + This is a subclass of + :class:`deform:deform.widget.TextAreaWidget`. It uses these + Deform templates: + + * ``textarea`` + * ``readonly/email_recips`` + + See also the :class:`~wuttaweb.forms.schema.EmailRecipients` + schema type, which uses this widget. + """ + readonly_template = 'readonly/email_recips' + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get('readonly', self.readonly) + if readonly: + kw['recips'] = parse_list(cstruct or '') + + return super().serialize(field, cstruct, **kw) + + def deserialize(self, field, pstruct): + """ """ + if pstruct is colander.null: + return colander.null + + values = [value for value in parse_list(pstruct) + if value] + return ', '.join(values) + + class BatchIdWidget(Widget): """ Widget for use with the diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 1ac1180..ae3745f 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -174,6 +174,12 @@ class MenuHandler(GenericHandler): 'perm': 'permissions.list', }, {'type': 'sep'}, + { + 'title': "Email Settings", + 'route': 'email_settings', + 'perm': 'email_settings.list', + }, + {'type': 'sep'}, { 'title': "App Info", 'route': 'appinfo', diff --git a/src/wuttaweb/templates/deform/readonly/email_recips.pt b/src/wuttaweb/templates/deform/readonly/email_recips.pt new file mode 100644 index 0000000..71c610d --- /dev/null +++ b/src/wuttaweb/templates/deform/readonly/email_recips.pt @@ -0,0 +1,5 @@ +
    + +
  • ${recip}
  • +
    +
diff --git a/src/wuttaweb/templates/email/settings/view.mako b/src/wuttaweb/templates/email/settings/view.mako new file mode 100644 index 0000000..609801c --- /dev/null +++ b/src/wuttaweb/templates/email/settings/view.mako @@ -0,0 +1,39 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="tool_panels()"> + ${parent.tool_panels()} + ${self.tool_panel_preview()} + + +<%def name="tool_panel_preview()"> + + + + Preview HTML + + + + Preview TXT + + + + diff --git a/src/wuttaweb/views/email.py b/src/wuttaweb/views/email.py new file mode 100644 index 0000000..587f7b8 --- /dev/null +++ b/src/wuttaweb/views/email.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Views for email settings +""" + +import colander + +from wuttaweb.views import MasterView +from wuttaweb.forms.schema import EmailRecipients + + +class EmailSettingView(MasterView): + """ + Master view for :term:`email settings `. + """ + model_name = 'email_setting' + model_key = 'key' + model_title = "Email Setting" + url_prefix = '/email/settings' + filterable = False + sortable = True + sort_on_backend = False + paginated = False + creatable = False + deletable = False + + labels = { + 'key': "Email Key", + 'replyto': "Reply-To", + } + + grid_columns = [ + 'key', + 'subject', + 'to', + 'enabled', + ] + + # TODO: why does this not work? + sort_defaults = 'key' + + form_fields = [ + 'key', + 'description', + 'subject', + 'sender', + 'replyto', + 'to', + 'cc', + 'bcc', + 'notes', + 'enabled', + ] + + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.email_handler = self.app.get_email_handler() + + def get_grid_data(self, columns=None, session=None): + """ + This view calls + :meth:`~wuttjamaican:wuttjamaican.email.EmailHandler.get_email_settings()` + on the :attr:`email_handler` to obtain its grid data. + """ + data = [] + for setting in self.email_handler.get_email_settings().values(): + data.append(self.normalize_setting(setting)) + return data + + def normalize_setting(self, setting): + """ """ + key = setting.__name__ + return { + 'key': key, + 'description': setting.__doc__, + 'subject': self.email_handler.get_auto_subject(key, rendered=False, setting=setting), + 'sender': self.email_handler.get_auto_sender(key), + 'replyto': self.email_handler.get_auto_replyto(key) or colander.null, + 'to': self.email_handler.get_auto_to(key), + 'cc': self.email_handler.get_auto_cc(key), + 'bcc': self.email_handler.get_auto_bcc(key), + 'notes': self.email_handler.get_notes(key) or colander.null, + 'enabled': self.email_handler.is_enabled(key), + } + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # key + g.set_searchable('key') + g.set_link('key') + + # subject + g.set_searchable('subject') + g.set_link('subject') + + # to + g.set_renderer('to', self.render_to_short) + + def render_to_short(self, setting, field, value): + """ """ + recips = value + if not recips: + return + + if len(recips) < 3: + return ', '.join(recips) + + recips = ', '.join(recips[:2]) + return f"{recips}, ..." + + def get_instance(self): + """ """ + key = self.request.matchdict['key'] + setting = self.email_handler.get_email_setting(key, instance=False) + if setting: + return self.normalize_setting(setting) + + raise self.notfound() + + def get_instance_title(self, setting): + """ """ + return setting['subject'] + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # description + f.set_readonly('description') + + # replyto + f.set_required('replyto', False) + + # to + f.set_node('to', EmailRecipients()) + + # cc + f.set_node('cc', EmailRecipients()) + + # bcc + f.set_node('bcc', EmailRecipients()) + + # notes + f.set_widget('notes', 'notes') + f.set_required('notes', False) + + # enabled + f.set_node('enabled', colander.Boolean()) + + def persist(self, setting): + """ """ + session = self.Session() + key = self.request.matchdict['key'] + + def save(name, value): + self.app.save_setting(session, f'{self.config.appname}.email.{key}.{name}', value) + + def delete(name): + self.app.delete_setting(session, f'{self.config.appname}.email.{key}.{name}') + + # subject + if setting['subject']: + save('subject', setting['subject']) + else: + delete('subject') + + # sender + if setting['sender']: + save('sender', setting['sender']) + else: + delete('sender') + + # replyto + if setting['replyto']: + save('replyto', setting['replyto']) + else: + delete('replyto') + + # to + if setting['to']: + save('to', setting['to']) + else: + delete('to') + + # cc + if setting['cc']: + save('cc', setting['cc']) + else: + delete('cc') + + # bcc + if setting['bcc']: + save('bcc', setting['bcc']) + else: + delete('bcc') + + # notes + if setting['notes']: + save('notes', setting['notes']) + else: + delete('notes') + + # enabled + save('enabled', 'true' if setting['enabled'] else 'false') + + def render_to_response(self, template, context): + """ """ + if self.viewing: + setting = context['instance'] + context['setting'] = setting + context['has_html_template'] = self.email_handler.get_auto_body_template( + setting['key'], 'html') + context['has_txt_template'] = self.email_handler.get_auto_body_template( + setting['key'], 'txt') + + return super().render_to_response(template, context) + + def preview(self): + """ + View for showing a rendered preview of a given email template. + + This will render the email template according to the "mode" + requested - i.e. HTML or TXT. + """ + key = self.request.matchdict['key'] + setting = self.email_handler.get_email_setting(key) + context = setting.sample_data() + mode = self.request.params.get('mode', 'html') + + if mode == 'txt': + body = self.email_handler.get_auto_txt_body(key, context) + self.request.response.content_type = 'text/plain' + + else: # html + body = self.email_handler.get_auto_html_body(key, context) + + self.request.response.text = body + return self.request.response + + @classmethod + def defaults(cls, config): + """ """ + cls._email_defaults(config) + cls._defaults(config) + + @classmethod + def _email_defaults(cls, config): + """ """ + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + instance_url_prefix = cls.get_instance_url_prefix() + + # fix permission group + config.add_wutta_permission_group(permission_prefix, + model_title_plural, + overwrite=False) + + # preview + config.add_route(f'{route_prefix}.preview', + f'{instance_url_prefix}/preview') + config.add_view(cls, attr='preview', + route_name=f'{route_prefix}.preview', + permission=f'{permission_prefix}.view') + + +def defaults(config, **kwargs): + base = globals() + + EmailSettingView = kwargs.get('EmailSettingView', base['EmailSettingView']) + EmailSettingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 4b87a13..20b6881 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -31,6 +31,7 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.auth` +* :mod:`wuttaweb.views.email` * :mod:`wuttaweb.views.settings` * :mod:`wuttaweb.views.progress` * :mod:`wuttaweb.views.people` @@ -45,6 +46,7 @@ def defaults(config, **kwargs): config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.auth')) + config.include(mod('wuttaweb.views.email')) config.include(mod('wuttaweb.views.settings')) config.include(mod('wuttaweb.views.progress')) config.include(mod('wuttaweb.views.people')) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index a9eabf8..7b15660 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -392,3 +392,47 @@ class TestFileDownload(DataTestCase): widget = typ.widget_maker() self.assertIsInstance(widget, widgets.FileDownloadWidget) self.assertEqual(widget.url, '/foo') + + +class TestEmailRecipients(TestCase): + + def test_serialize(self): + typ = mod.EmailRecipients() + node = colander.SchemaNode(typ) + + recips = [ + 'alice@example.com', + 'bob@example.com', + ] + recips_str = ', '.join(recips) + + # values + result = typ.serialize(node, recips_str) + self.assertEqual(result, '\n'.join(recips)) + + # null + result = typ.serialize(node, colander.null) + self.assertIs(result, colander.null) + + def test_deserialize(self): + typ = mod.EmailRecipients() + node = colander.SchemaNode(typ) + + recips = [ + 'alice@example.com', + 'bob@example.com', + ] + recips_str = ', '.join(recips) + + # values + result = typ.deserialize(node, recips_str) + self.assertEqual(result, recips_str) + + # null + result = typ.deserialize(node, colander.null) + self.assertIs(result, colander.null) + + def test_widget_maker(self): + typ = mod.EmailRecipients() + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.EmailRecipientsWidget) diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index cd3c7c4..e324458 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -10,7 +10,7 @@ from pyramid import testing from wuttaweb import grids from wuttaweb.forms import widgets as mod from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions, - WuttaDateTime) + WuttaDateTime, EmailRecipients) from tests.util import WebTestCase @@ -304,6 +304,55 @@ class TestPermissionsWidget(WebTestCase): self.assertIn("Polish the widgets", html) +class TestEmailRecipientsWidget(WebTestCase): + + def make_field(self, node, **kwargs): + # TODO: not sure why default renderer is in use even though + # pyramid_deform was included in setup? but this works.. + kwargs.setdefault('renderer', deform.Form.default_renderer) + return deform.Field(node, **kwargs) + + def test_serialize(self): + node = colander.SchemaNode(EmailRecipients()) + field = self.make_field(node) + widget = mod.EmailRecipientsWidget() + + recips = [ + 'alice@example.com', + 'bob@example.com', + ] + recips_str = ', '.join(recips) + + # readonly + result = widget.serialize(field, recips_str, readonly=True) + self.assertIn('
    ', result) + self.assertIn('
  • alice@example.com
  • ', result) + + # editable + result = widget.serialize(field, recips_str) + self.assertIn('