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:
parent
7db3ec246e
commit
6322e03996
|
@ -25,7 +25,9 @@ from app.models import (
|
||||||
Phase,
|
Phase,
|
||||||
ProviderComplaint,
|
ProviderComplaint,
|
||||||
Alias,
|
Alias,
|
||||||
|
Newsletter,
|
||||||
)
|
)
|
||||||
|
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
|
||||||
|
|
||||||
|
|
||||||
class SLModelView(sqla.ModelView):
|
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
|
||||||
|
|
|
@ -3256,3 +3256,29 @@ class PartnerSubscription(Base, ModelMixin):
|
||||||
|
|
||||||
|
|
||||||
# endregion
|
# 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)
|
||||||
|
|
|
@ -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)
|
|
@ -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 ###
|
|
@ -40,6 +40,8 @@ from app.admin_model import (
|
||||||
CustomDomainAdmin,
|
CustomDomainAdmin,
|
||||||
AdminAuditLogAdmin,
|
AdminAuditLogAdmin,
|
||||||
ProviderComplaintAdmin,
|
ProviderComplaintAdmin,
|
||||||
|
NewsletterAdmin,
|
||||||
|
NewsletterUserAdmin,
|
||||||
)
|
)
|
||||||
from app.api.base import api_bp
|
from app.api.base import api_bp
|
||||||
from app.auth.base import auth_bp
|
from app.auth.base import auth_bp
|
||||||
|
@ -97,6 +99,8 @@ from app.models import (
|
||||||
Coupon,
|
Coupon,
|
||||||
AdminAuditLog,
|
AdminAuditLog,
|
||||||
ProviderComplaint,
|
ProviderComplaint,
|
||||||
|
Newsletter,
|
||||||
|
NewsletterUser,
|
||||||
)
|
)
|
||||||
from app.monitor.base import monitor_bp
|
from app.monitor.base import monitor_bp
|
||||||
from app.oauth.base import oauth_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(CustomDomainAdmin(CustomDomain, Session))
|
||||||
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
|
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
|
||||||
admin.add_view(ProviderComplaintAdmin(ProviderComplaint, Session))
|
admin.add_view(ProviderComplaintAdmin(ProviderComplaint, Session))
|
||||||
|
admin.add_view(NewsletterAdmin(Newsletter, Session))
|
||||||
|
admin.add_view(NewsletterUserAdmin(NewsletterUser, Session))
|
||||||
|
|
||||||
|
|
||||||
def register_custom_commands(app):
|
def register_custom_commands(app):
|
||||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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">
|
<!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">
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
@ -12,6 +12,11 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
Loading…
Reference in New Issue