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
This commit is contained in:
Son Nguyen Kim 2022-07-22 11:24:53 +02:00 committed by GitHub
parent 7db3ec246e
commit 6322e03996
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 294 additions and 1 deletions

View File

@ -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", "<br>"))
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", "<br>"))
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

View File

@ -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"<Newsletter {self.id} {self.subject}>"
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)

68
app/newsletter_utils.py Normal file
View File

@ -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)

View File

@ -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 ###

View File

@ -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):

View File

@ -0,0 +1,25 @@
{#
Automatically increase textarea height to match content to facilitate editing
#}
{% extends 'admin/model/edit.html' %}
{% block head %}
{{ super() }}
<style>
body{
max-width: 80%;
margin: auto;
}
</style>
{% endblock %}
{% block tail %}
{{ super() }}
<script type="application/javascript">
$('textarea').each(function (index) {
this.style.height = "";
this.style.height = this.scrollHeight + "px";
});
</script>
{% endblock %}

View File

@ -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 %}
<br>
<li id="here" class="form-row">
<input name="user_id"
class="form-control"
placeholder="User ID"
aria-describedby="userID"/>
<input name="to_address"
class="form-control"
placeholder="Specify an address to receive the newsletter for testing"
aria-describedby="Email address"/>
</li>
{% endblock %}
{% block tail %}
{{ super() }}
<script type="application/javascript">
$("input[name='user_id']").appendTo($("#action_form"))
$("input[name='to_address']").appendTo($("#action_form"))
$("#action_form").appendTo($("#here"))
$("#action_form").attr("style", "")
</script>
{% endblock %}

View File

@ -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 %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
@ -12,6 +12,11 @@
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
line-height: 1.6;
}
img {
max-width: 100%;
}
a {