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 {