mirror of
https://github.com/simple-login/app.git
synced 2024-09-20 15:05:59 +08:00
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,
|
||||
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
|
||||
|
|
|
@ -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
68
app/newsletter_utils.py
Normal 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)
|
51
migrations/versions/2022_072119_c66f2c5b6cb1_.py
Normal file
51
migrations/versions/2022_072119_c66f2c5b6cb1_.py
Normal 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 ###
|
|
@ -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):
|
||||
|
|
25
templates/admin/model/newsletter-edit.html
Normal file
25
templates/admin/model/newsletter-edit.html
Normal 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 %}
|
30
templates/admin/model/newsletter-list.html
Normal file
30
templates/admin/model/newsletter-list.html
Normal 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 %}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue