Skip to content

Commit 6322e03

Browse files
authored
admin can manage newsletter and test sending it (simple-login#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
1 parent 7db3ec2 commit 6322e03

File tree

8 files changed

+294
-1
lines changed

8 files changed

+294
-1
lines changed

app/admin_model.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
Phase,
2626
ProviderComplaint,
2727
Alias,
28+
Newsletter,
2829
)
30+
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
2931

3032

3133
class SLModelView(sqla.ModelView):
@@ -469,3 +471,83 @@ def download_eml(self):
469471
)
470472
},
471473
)
474+
475+
476+
def _newsletter_plain_text_formatter(view, context, model: Newsletter, name):
477+
# to display newsletter plain_text with linebreaks in the list view
478+
return Markup(model.plain_text.replace("\n", "<br>"))
479+
480+
481+
def _newsletter_html_formatter(view, context, model: Newsletter, name):
482+
# to display newsletter html with linebreaks in the list view
483+
return Markup(model.html.replace("\n", "<br>"))
484+
485+
486+
class NewsletterAdmin(SLModelView):
487+
list_template = "admin/model/newsletter-list.html"
488+
edit_template = "admin/model/newsletter-edit.html"
489+
edit_modal = False
490+
491+
can_edit = True
492+
can_create = True
493+
494+
column_formatters = {
495+
"plain_text": _newsletter_plain_text_formatter,
496+
"html": _newsletter_html_formatter,
497+
}
498+
499+
@action(
500+
"send_newsletter_to_user",
501+
"Send this newsletter to myself or the specified userID",
502+
)
503+
def send_newsletter_to_user(self, newsletter_ids):
504+
user_id = request.form["user_id"]
505+
if user_id:
506+
user = User.get(user_id)
507+
if not user:
508+
flash(f"No such user with ID {user_id}", "error")
509+
return
510+
else:
511+
flash("use the current user", "info")
512+
user = current_user
513+
514+
for newsletter_id in newsletter_ids:
515+
newsletter = Newsletter.get(newsletter_id)
516+
sent, error_msg = send_newsletter_to_user(newsletter, user)
517+
if sent:
518+
flash(f"{newsletter} sent to {user}", "success")
519+
else:
520+
flash(error_msg, "error")
521+
522+
@action(
523+
"send_newsletter_to_address",
524+
"Send this newsletter to a specific address",
525+
)
526+
def send_newsletter_to_address(self, newsletter_ids):
527+
to_address = request.form["to_address"]
528+
if not to_address:
529+
flash("to_address missing", "error")
530+
return
531+
532+
for newsletter_id in newsletter_ids:
533+
newsletter = Newsletter.get(newsletter_id)
534+
# use the current_user for rendering email
535+
sent, error_msg = send_newsletter_to_address(
536+
newsletter, current_user, to_address
537+
)
538+
if sent:
539+
flash(
540+
f"{newsletter} sent to {to_address} with {current_user} context",
541+
"success",
542+
)
543+
else:
544+
flash(error_msg, "error")
545+
546+
547+
class NewsletterUserAdmin(SLModelView):
548+
column_searchable_list = ["id"]
549+
column_filters = ["id", "user.email", "newsletter.subject"]
550+
column_exclude_list = ["created_at", "updated_at", "id"]
551+
552+
can_edit = False
553+
can_create = False

app/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3256,3 +3256,29 @@ def is_active(self):
32563256

32573257

32583258
# endregion
3259+
3260+
3261+
class Newsletter(Base, ModelMixin):
3262+
__tablename__ = "newsletter"
3263+
subject = sa.Column(sa.String(), nullable=False, unique=True, index=True)
3264+
3265+
html = sa.Column(sa.Text)
3266+
plain_text = sa.Column(sa.Text)
3267+
3268+
def __repr__(self):
3269+
return f"<Newsletter {self.id} {self.subject}>"
3270+
3271+
3272+
class NewsletterUser(Base, ModelMixin):
3273+
"""This model keeps track of what newsletter is sent to what user"""
3274+
3275+
__tablename__ = "newsletter_user"
3276+
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=True)
3277+
newsletter_id = sa.Column(
3278+
sa.ForeignKey(Newsletter.id, ondelete="cascade"), nullable=True
3279+
)
3280+
# not use created_at here as it should only used for auditting purpose
3281+
sent_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
3282+
3283+
user = orm.relationship(User)
3284+
newsletter = orm.relationship(Newsletter)

app/newsletter_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import os
2+
3+
from jinja2 import Environment, FileSystemLoader
4+
5+
from app.config import ROOT_DIR, URL
6+
from app.email_utils import send_email
7+
from app.log import LOG
8+
from app.models import NewsletterUser
9+
10+
11+
def send_newsletter_to_user(newsletter, user) -> (bool, str):
12+
"""Return whether the newsletter is sent successfully and the error if not"""
13+
try:
14+
templates_dir = os.path.join(ROOT_DIR, "templates", "emails")
15+
env = Environment(loader=FileSystemLoader(templates_dir))
16+
html_template = env.from_string(newsletter.html)
17+
text_template = env.from_string(newsletter.plain_text)
18+
19+
to_email, unsubscribe_link, via_email = user.get_communication_email()
20+
if not to_email:
21+
return False, f"{user} not subscribed to newsletter"
22+
23+
send_email(
24+
to_email,
25+
newsletter.subject,
26+
text_template.render(
27+
user=user,
28+
URL=URL,
29+
),
30+
html_template.render(
31+
user=user,
32+
URL=URL,
33+
),
34+
)
35+
36+
NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True)
37+
return True, ""
38+
except Exception as err:
39+
LOG.w(f"cannot send {newsletter} to {user}", exc_info=True)
40+
return False, str(err)
41+
42+
43+
def send_newsletter_to_address(newsletter, user, to_address) -> (bool, str):
44+
"""Return whether the newsletter is sent successfully and the error if not"""
45+
try:
46+
templates_dir = os.path.join(ROOT_DIR, "templates", "emails")
47+
env = Environment(loader=FileSystemLoader(templates_dir))
48+
html_template = env.from_string(newsletter.html)
49+
text_template = env.from_string(newsletter.plain_text)
50+
51+
send_email(
52+
to_address,
53+
newsletter.subject,
54+
text_template.render(
55+
user=user,
56+
URL=URL,
57+
),
58+
html_template.render(
59+
user=user,
60+
URL=URL,
61+
),
62+
)
63+
64+
NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True)
65+
return True, ""
66+
except Exception as err:
67+
LOG.w(f"cannot send {newsletter} to {user}", exc_info=True)
68+
return False, str(err)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""empty message
2+
3+
Revision ID: c66f2c5b6cb1
4+
Revises: 89081a00fc7d
5+
Create Date: 2022-07-21 19:06:38.330239
6+
7+
"""
8+
import sqlalchemy_utils
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'c66f2c5b6cb1'
15+
down_revision = '89081a00fc7d'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.create_table('newsletter',
23+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
24+
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
25+
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
26+
sa.Column('subject', sa.String(), nullable=False),
27+
sa.Column('html', sa.Text(), nullable=True),
28+
sa.Column('plain_text', sa.Text(), nullable=True),
29+
sa.PrimaryKeyConstraint('id')
30+
)
31+
op.create_index(op.f('ix_newsletter_subject'), 'newsletter', ['subject'], unique=True)
32+
op.create_table('newsletter_user',
33+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
34+
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
35+
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
36+
sa.Column('user_id', sa.Integer(), nullable=True),
37+
sa.Column('newsletter_id', sa.Integer(), nullable=True),
38+
sa.Column('sent_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
39+
sa.ForeignKeyConstraint(['newsletter_id'], ['newsletter.id'], ondelete='cascade'),
40+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
41+
sa.PrimaryKeyConstraint('id')
42+
)
43+
# ### end Alembic commands ###
44+
45+
46+
def downgrade():
47+
# ### commands auto generated by Alembic - please adjust! ###
48+
op.drop_table('newsletter_user')
49+
op.drop_index(op.f('ix_newsletter_subject'), table_name='newsletter')
50+
op.drop_table('newsletter')
51+
# ### end Alembic commands ###

server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
CustomDomainAdmin,
4141
AdminAuditLogAdmin,
4242
ProviderComplaintAdmin,
43+
NewsletterAdmin,
44+
NewsletterUserAdmin,
4345
)
4446
from app.api.base import api_bp
4547
from app.auth.base import auth_bp
@@ -97,6 +99,8 @@
9799
Coupon,
98100
AdminAuditLog,
99101
ProviderComplaint,
102+
Newsletter,
103+
NewsletterUser,
100104
)
101105
from app.monitor.base import monitor_bp
102106
from app.oauth.base import oauth_bp
@@ -745,6 +749,8 @@ def init_admin(app):
745749
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
746750
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
747751
admin.add_view(ProviderComplaintAdmin(ProviderComplaint, Session))
752+
admin.add_view(NewsletterAdmin(Newsletter, Session))
753+
admin.add_view(NewsletterUserAdmin(NewsletterUser, Session))
748754

749755

750756
def register_custom_commands(app):
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{#
2+
Automatically increase textarea height to match content to facilitate editing
3+
#}
4+
{% extends 'admin/model/edit.html' %}
5+
6+
{% block head %}
7+
8+
{{ super() }}
9+
<style>
10+
body{
11+
max-width: 80%;
12+
margin: auto;
13+
}
14+
</style>
15+
{% endblock %}
16+
{% block tail %}
17+
18+
{{ super() }}
19+
<script type="application/javascript">
20+
$('textarea').each(function (index) {
21+
this.style.height = "";
22+
this.style.height = this.scrollHeight + "px";
23+
});
24+
</script>
25+
{% endblock %}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{#
2+
Add custom input form so admin can enter a user id to send a newsletter to
3+
Based on https://github.com/flask-admin/flask-admin/issues/974#issuecomment-168215285
4+
#}
5+
{% extends 'admin/model/list.html' %}
6+
7+
{% block model_menu_bar_before_filters %}
8+
9+
<br>
10+
<li id="here" class="form-row">
11+
<input name="user_id"
12+
class="form-control"
13+
placeholder="User ID"
14+
aria-describedby="userID"/>
15+
<input name="to_address"
16+
class="form-control"
17+
placeholder="Specify an address to receive the newsletter for testing"
18+
aria-describedby="Email address"/>
19+
</li>
20+
{% endblock %}
21+
{% block tail %}
22+
23+
{{ super() }}
24+
<script type="application/javascript">
25+
$("input[name='user_id']").appendTo($("#action_form"))
26+
$("input[name='to_address']").appendTo($("#action_form"))
27+
$("#action_form").appendTo($("#here"))
28+
$("#action_form").attr("style", "")
29+
</script>
30+
{% endblock %}

templates/emails/base.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section %}
1+
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
22
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
33
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
44
<head>
@@ -12,6 +12,11 @@
1212
height: 100%;
1313
margin: 0;
1414
-webkit-text-size-adjust: none;
15+
line-height: 1.6;
16+
}
17+
18+
img {
19+
max-width: 100%;
1520
}
1621

1722
a {

0 commit comments

Comments
 (0)