From 6322e039963eb8e3b58bc2b835e855d4d8f561d4 Mon Sep 17 00:00:00 2001 From: Son Nguyen Kim Date: Fri, 22 Jul 2022 11:24:53 +0200 Subject: [PATCH] admin can manage newsletter and test sending it (#1177) * admin can manage newsletter and test sending it * add comments * comment * doc * not userID not specified, send the newsletter to current user * automatically match textarea height to content when editing newsletter * increase text height and limit img size to 100% in email template * admin can send newsletter to a specific address --- app/admin_model.py | 82 +++++++++++++++++++ app/models.py | 26 ++++++ app/newsletter_utils.py | 68 +++++++++++++++ .../versions/2022_072119_c66f2c5b6cb1_.py | 51 ++++++++++++ server.py | 6 ++ templates/admin/model/newsletter-edit.html | 25 ++++++ templates/admin/model/newsletter-list.html | 30 +++++++ templates/emails/base.html | 7 +- 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 app/newsletter_utils.py create mode 100644 migrations/versions/2022_072119_c66f2c5b6cb1_.py create mode 100644 templates/admin/model/newsletter-edit.html create mode 100644 templates/admin/model/newsletter-list.html diff --git a/app/admin_model.py b/app/admin_model.py index 4c65389e..f3af2813 100644 --- a/app/admin_model.py +++ b/app/admin_model.py @@ -25,7 +25,9 @@ from app.models import ( Phase, ProviderComplaint, Alias, + Newsletter, ) +from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address class SLModelView(sqla.ModelView): @@ -469,3 +471,83 @@ class ProviderComplaintAdmin(SLModelView): ) }, ) + + +def _newsletter_plain_text_formatter(view, context, model: Newsletter, name): + # to display newsletter plain_text with linebreaks in the list view + return Markup(model.plain_text.replace("\n", "
")) + + +def _newsletter_html_formatter(view, context, model: Newsletter, name): + # to display newsletter html with linebreaks in the list view + return Markup(model.html.replace("\n", "
")) + + +class NewsletterAdmin(SLModelView): + list_template = "admin/model/newsletter-list.html" + edit_template = "admin/model/newsletter-edit.html" + edit_modal = False + + can_edit = True + can_create = True + + column_formatters = { + "plain_text": _newsletter_plain_text_formatter, + "html": _newsletter_html_formatter, + } + + @action( + "send_newsletter_to_user", + "Send this newsletter to myself or the specified userID", + ) + def send_newsletter_to_user(self, newsletter_ids): + user_id = request.form["user_id"] + if user_id: + user = User.get(user_id) + if not user: + flash(f"No such user with ID {user_id}", "error") + return + else: + flash("use the current user", "info") + user = current_user + + for newsletter_id in newsletter_ids: + newsletter = Newsletter.get(newsletter_id) + sent, error_msg = send_newsletter_to_user(newsletter, user) + if sent: + flash(f"{newsletter} sent to {user}", "success") + else: + flash(error_msg, "error") + + @action( + "send_newsletter_to_address", + "Send this newsletter to a specific address", + ) + def send_newsletter_to_address(self, newsletter_ids): + to_address = request.form["to_address"] + if not to_address: + flash("to_address missing", "error") + return + + for newsletter_id in newsletter_ids: + newsletter = Newsletter.get(newsletter_id) + # use the current_user for rendering email + sent, error_msg = send_newsletter_to_address( + newsletter, current_user, to_address + ) + if sent: + flash( + f"{newsletter} sent to {to_address} with {current_user} context", + "success", + ) + else: + flash(error_msg, "error") + + +class NewsletterUserAdmin(SLModelView): + column_searchable_list = ["id"] + column_filters = ["id", "user.email", "newsletter.subject"] + column_exclude_list = ["created_at", "updated_at", "id"] + + can_edit = False + can_create = False diff --git a/app/models.py b/app/models.py index 82f0e4dc..c0a0a428 100644 --- a/app/models.py +++ b/app/models.py @@ -3256,3 +3256,29 @@ class PartnerSubscription(Base, ModelMixin): # endregion + + +class Newsletter(Base, ModelMixin): + __tablename__ = "newsletter" + subject = sa.Column(sa.String(), nullable=False, unique=True, index=True) + + html = sa.Column(sa.Text) + plain_text = sa.Column(sa.Text) + + def __repr__(self): + return f"" + + +class NewsletterUser(Base, ModelMixin): + """This model keeps track of what newsletter is sent to what user""" + + __tablename__ = "newsletter_user" + user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=True) + newsletter_id = sa.Column( + sa.ForeignKey(Newsletter.id, ondelete="cascade"), nullable=True + ) + # not use created_at here as it should only used for auditting purpose + sent_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) + + user = orm.relationship(User) + newsletter = orm.relationship(Newsletter) diff --git a/app/newsletter_utils.py b/app/newsletter_utils.py new file mode 100644 index 00000000..3b9af812 --- /dev/null +++ b/app/newsletter_utils.py @@ -0,0 +1,68 @@ +import os + +from jinja2 import Environment, FileSystemLoader + +from app.config import ROOT_DIR, URL +from app.email_utils import send_email +from app.log import LOG +from app.models import NewsletterUser + + +def send_newsletter_to_user(newsletter, user) -> (bool, str): + """Return whether the newsletter is sent successfully and the error if not""" + try: + templates_dir = os.path.join(ROOT_DIR, "templates", "emails") + env = Environment(loader=FileSystemLoader(templates_dir)) + html_template = env.from_string(newsletter.html) + text_template = env.from_string(newsletter.plain_text) + + to_email, unsubscribe_link, via_email = user.get_communication_email() + if not to_email: + return False, f"{user} not subscribed to newsletter" + + send_email( + to_email, + newsletter.subject, + text_template.render( + user=user, + URL=URL, + ), + html_template.render( + user=user, + URL=URL, + ), + ) + + NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True) + return True, "" + except Exception as err: + LOG.w(f"cannot send {newsletter} to {user}", exc_info=True) + return False, str(err) + + +def send_newsletter_to_address(newsletter, user, to_address) -> (bool, str): + """Return whether the newsletter is sent successfully and the error if not""" + try: + templates_dir = os.path.join(ROOT_DIR, "templates", "emails") + env = Environment(loader=FileSystemLoader(templates_dir)) + html_template = env.from_string(newsletter.html) + text_template = env.from_string(newsletter.plain_text) + + send_email( + to_address, + newsletter.subject, + text_template.render( + user=user, + URL=URL, + ), + html_template.render( + user=user, + URL=URL, + ), + ) + + NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True) + return True, "" + except Exception as err: + LOG.w(f"cannot send {newsletter} to {user}", exc_info=True) + return False, str(err) diff --git a/migrations/versions/2022_072119_c66f2c5b6cb1_.py b/migrations/versions/2022_072119_c66f2c5b6cb1_.py new file mode 100644 index 00000000..c06dfcdf --- /dev/null +++ b/migrations/versions/2022_072119_c66f2c5b6cb1_.py @@ -0,0 +1,51 @@ +"""empty message + +Revision ID: c66f2c5b6cb1 +Revises: 89081a00fc7d +Create Date: 2022-07-21 19:06:38.330239 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c66f2c5b6cb1' +down_revision = '89081a00fc7d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('newsletter', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('subject', sa.String(), nullable=False), + sa.Column('html', sa.Text(), nullable=True), + sa.Column('plain_text', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_newsletter_subject'), 'newsletter', ['subject'], unique=True) + op.create_table('newsletter_user', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('newsletter_id', sa.Integer(), nullable=True), + sa.Column('sent_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.ForeignKeyConstraint(['newsletter_id'], ['newsletter.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('newsletter_user') + op.drop_index(op.f('ix_newsletter_subject'), table_name='newsletter') + op.drop_table('newsletter') + # ### end Alembic commands ### diff --git a/server.py b/server.py index 37d8647e..24364640 100644 --- a/server.py +++ b/server.py @@ -40,6 +40,8 @@ from app.admin_model import ( CustomDomainAdmin, AdminAuditLogAdmin, ProviderComplaintAdmin, + NewsletterAdmin, + NewsletterUserAdmin, ) from app.api.base import api_bp from app.auth.base import auth_bp @@ -97,6 +99,8 @@ from app.models import ( Coupon, AdminAuditLog, ProviderComplaint, + Newsletter, + NewsletterUser, ) from app.monitor.base import monitor_bp from app.oauth.base import oauth_bp @@ -745,6 +749,8 @@ def init_admin(app): admin.add_view(CustomDomainAdmin(CustomDomain, Session)) admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session)) admin.add_view(ProviderComplaintAdmin(ProviderComplaint, Session)) + admin.add_view(NewsletterAdmin(Newsletter, Session)) + admin.add_view(NewsletterUserAdmin(NewsletterUser, Session)) def register_custom_commands(app): diff --git a/templates/admin/model/newsletter-edit.html b/templates/admin/model/newsletter-edit.html new file mode 100644 index 00000000..d7c588f0 --- /dev/null +++ b/templates/admin/model/newsletter-edit.html @@ -0,0 +1,25 @@ +{# +Automatically increase textarea height to match content to facilitate editing +#} +{% extends 'admin/model/edit.html' %} + +{% block head %} + + {{ super() }} + +{% endblock %} +{% block tail %} + + {{ super() }} + +{% endblock %} diff --git a/templates/admin/model/newsletter-list.html b/templates/admin/model/newsletter-list.html new file mode 100644 index 00000000..ac6933ca --- /dev/null +++ b/templates/admin/model/newsletter-list.html @@ -0,0 +1,30 @@ +{# +Add custom input form so admin can enter a user id to send a newsletter to +Based on https://github.com/flask-admin/flask-admin/issues/974#issuecomment-168215285 +#} +{% extends 'admin/model/list.html' %} + +{% block model_menu_bar_before_filters %} + +
+
  • + + +
  • +{% endblock %} +{% block tail %} + + {{ super() }} + +{% endblock %} diff --git a/templates/emails/base.html b/templates/emails/base.html index 99052078..2d776105 100644 --- a/templates/emails/base.html +++ b/templates/emails/base.html @@ -1,4 +1,4 @@ -{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section %} +{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} @@ -12,6 +12,11 @@ height: 100%; margin: 0; -webkit-text-size-adjust: none; + line-height: 1.6; + } + + img { + max-width: 100%; } a {