diff --git a/apps/admin/email.py b/apps/admin/email.py index 940492b78..8813c95dd 100644 --- a/apps/admin/email.py +++ b/apps/admin/email.py @@ -1,7 +1,7 @@ -from collections.abc import Sequence from typing import Literal from flask import flash, redirect, render_template, url_for +from flask.typing import ResponseReturnValue from sqlalchemy import select from wtforms import SelectField, StringField, SubmitField from wtforms.validators import DataRequired @@ -15,8 +15,9 @@ from models.village import VillageMember from ..common.email import ( - enqueue_trusted_emails, + enqueue_emails, format_trusted_html_email, + format_trusted_plaintext_email, preview_trusted_email, ) from ..common.forms import Form @@ -43,7 +44,7 @@ class EmailComposeForm(Form): send = SubmitField("Send Email") -def get_users(dest: Literal["ticket", "cfp", "purchasers", "villages"]) -> Sequence[User]: +def get_users(dest: Literal["ticket", "cfp", "purchasers", "villages"]) -> list[User]: query = select(User) if dest == "ticket": query = ( @@ -81,13 +82,13 @@ def get_email_reason(dest: str) -> str: @admin.route("/email", methods=["GET", "POST"]) -def email(): +def email() -> ResponseReturnValue: + # This function is almost identical to apps.villages.admin.admin_email_owners, consider updating there too form = EmailComposeForm() if form.validate_on_submit(): + users: list[User] if form.destination.data == "ticket_and_cfp": - users = set() - users.update(get_users("ticket")) - users.update(get_users("cfp")) + users = list(set(get_users("ticket")) | set(get_users("cfp"))) else: users = get_users(form.destination.data) @@ -117,12 +118,18 @@ def email(): ) if form.send.data is True: - enqueue_trusted_emails( - users, - form.subject.data, - form.text.data, - reason=reason, + assert form.text.data # DataRequired() + assert form.subject.data # DataRequired() + body: str = form.text.data + subject: str = form.subject.data + enqueue_emails( + "bulk_contact", + users=users, + subject=subject, + text_body=format_trusted_plaintext_email(body), + html_body=format_trusted_html_email(body, subject, reason=reason), ) + db.session.commit() flash(f"Email queued for sending to {len(users)} users") return redirect(url_for(".email")) diff --git a/apps/base/scheduled_tasks.py b/apps/base/scheduled_tasks.py index fc5982126..a8f729eae 100644 --- a/apps/base/scheduled_tasks.py +++ b/apps/base/scheduled_tasks.py @@ -1,61 +1,115 @@ +import logging +from datetime import timedelta +from itertools import groupby +from typing import Any + from flask import current_app as app +from prometheus_client import Counter +from sqlalchemy import case, select +from sqlalchemy.orm import joinedload from main import db, mail -from models.email import EmailJobRecipient +from models import naive_utcnow +from models.email import EmailJob, EmailJobRecipient, EmailJobType from models.scheduled_task import scheduled_task -from models.volunteer.notify import VolunteerNotifyRecipient from ..config import config +logger = logging.getLogger(__name__) + +EMAIL_PRIORITIES: dict[EmailJobType, int] = { + "notify_volunteer": 0, + "cfp": 1, + "bulk_contact": 2, +} +EMAIL_YIELD_INTERVAL = timedelta(seconds=30) + +email_yields = Counter("emf_email_yields", "Queued email yields") + @scheduled_task(minutes=1) -def send_emails(): - """Send queued emails, allowing for failure""" - count = 0 +def send_emails() -> int: + """ + Send queued emails in priority order, allowing for failure. - with mail.get_connection(app.config.get("BULK_MAIL_BACKEND")) as conn: - for rec in EmailJobRecipient.query.filter(EmailJobRecipient.sent == False): - count += send_email(conn, rec) - return count + The job only runs once a minute, so this isn't really suitable for time-sensitive emails. + We yield if the process takes too long (e.g. mail server is struggling), + as only one scheduled task can run at once. + """ -def send_email(conn, rec): - sent_count = mail.send_mail( - subject=rec.job.subject, - message=rec.job.text_body, - from_email=config.from_email("CONTACT_EMAIL"), - recipient_list=[rec.user.email], - fail_silently=True, - connection=conn, - html_message=rec.job.html_body, - ) - if sent_count > 0: - rec.sent = True - db.session.commit() - return sent_count + start = naive_utcnow() + recs: list[EmailJobRecipient] = list( + db.session.scalars( + select(EmailJobRecipient) + .join(EmailJobRecipient.job) + .options( + joinedload(EmailJobRecipient.job), + ) + .where( + EmailJobRecipient.sent == False, + EmailJobRecipient.sent_at.is_(None), + ) + .order_by( + case(EMAIL_PRIORITIES, value=EmailJob.type), + EmailJobRecipient.id, + ) + ) + ) -@scheduled_task(minutes=1) -def send_volunteer_emails(): - """Send queued volunteer notifications""" count = 0 - with mail.get_connection() as conn: - for rec in VolunteerNotifyRecipient.query.filter(VolunteerNotifyRecipient.sent == False): - count += send_volunteer_email(conn, rec) + job_type: EmailJobType + for job_type, grouped_recs in groupby(recs, lambda r: r.job.type): + if job_type == "bulk_contact": + # Use config beginning BULK_MAIL_ on production-like systems (see apps/common/backends/bulk.py) + backend = app.config.get("BULK_MAIL_BACKEND") + else: + backend = None + + with mail.get_connection(backend=backend) as conn: + for rec in grouped_recs: + count += send_email(conn, rec) + + if naive_utcnow() - start > EMAIL_YIELD_INTERVAL: + logger.warning("Email sending is taking too long, yielding") + email_yields.inc() + return count + return count -def send_volunteer_email(conn, rec): - sent_count = mail.send_mail( +def send_email(conn: Any, rec: EmailJobRecipient) -> int: + match rec.job.type: + case "notify_volunteer": + from_email = config.from_email("VOLUNTEER_EMAIL") + assert rec.user.volunteer + recipient = rec.user.volunteer.volunteer_email + + case "cfp": + from_email = config.from_email("CONTENT_EMAIL") + recipient = rec.user + + case "cfp_speakers": + from_email = config.from_email("SPEAKERS_EMAIL") + recipient = rec.user + + case "bulk_contact": + from_email = config.from_email("CONTACT_EMAIL") + recipient = rec.user + + sent_count: int = mail.send_mail( subject=rec.job.subject, message=rec.job.text_body, - from_email=config.from_email("VOLUNTEER_EMAIL"), - recipient_list=[rec.volunteer.volunteer_email], + html_message=rec.job.html_body, + from_email=from_email, + recipient_list=[recipient], fail_silently=True, connection=conn, - html_message=rec.job.html_body, ) if sent_count > 0: rec.sent = True + rec.sent_at = naive_utcnow() db.session.commit() + return sent_count diff --git a/apps/cfp_review/email.py b/apps/cfp_review/email.py index 96bd200e8..6d1451b21 100644 --- a/apps/cfp_review/email.py +++ b/apps/cfp_review/email.py @@ -6,11 +6,10 @@ current_app as app, ) from flask import render_template -from flask_mailman import EmailMessage +from apps.common.email import enqueue_emails from models.content import Proposal - -from ..config import config +from models.email import EmailJobType ProposalEmailReason = Literal[ "accepted", @@ -25,8 +24,8 @@ def send_email_for_proposal(proposal: Proposal, reason: ProposalEmailReason) -> None: - from_email_ = config.from_email("CONTENT_EMAIL") title = (proposal.schedule_item and proposal.schedule_item.title) or proposal.title + email_type: EmailJobType = "cfp" if reason == "accepted": subject = f'''Your EMF {proposal.human_type} "{title}" has been accepted!''' @@ -44,43 +43,47 @@ def send_email_for_proposal(proposal: Proposal, reason: ProposalEmailReason) -> elif reason == "reserve-list": subject = f'''Your EMF {proposal.human_type} "{title}", and EMF tickets''' template = "emails/cfp-reserve-list.txt" - from_email_ = config.from_email("SPEAKERS_EMAIL") + email_type = "cfp_speakers" elif reason == "please-finalise": # Can be sent before or after scheduling. We want them # to update for the line-up, so the earlier the better. subject = f'''We need information about your EMF {proposal.human_type} "{title}"''' template = "emails/cfp-please-finalise.txt" - from_email_ = config.from_email("SPEAKERS_EMAIL") + email_type = "cfp_speakers" elif reason == "check-scheduled-duration": # This email is basically the same as "please-finalise" but less urgent subject = f'''Your EMF {proposal.human_type} "{title}" is ready to schedule, please check your slot''' template = "emails/cfp-check-scheduled-duration.txt" - from_email_ = config.from_email("SPEAKERS_EMAIL") + email_type = "cfp_speakers" elif reason == "slot-scheduled": subject = f'''Your EMF {proposal.human_type} "{title}" has been scheduled''' template = "emails/cfp-slot-scheduled.txt" - from_email_ = config.from_email("SPEAKERS_EMAIL") + email_type = "cfp_speakers" elif reason == "slot-moved": # TODO: might be nice to highlight which slot has moved subject = f'''Your EMF {proposal.human_type} slot has been moved ("{title}")''' template = "emails/cfp-slot-moved.txt" - from_email_ = config.from_email("SPEAKERS_EMAIL") + email_type = "cfp_speakers" else: raise Exception(f"Invalid proposal email type {reason}") app.logger.info("Sending %s email for proposal %s", reason, proposal.id) - msg = EmailMessage(subject, from_email=from_email_, to=[proposal.user.email]) - msg.body = render_template( + text_body = render_template( template, user=proposal.user, proposal=proposal, reserve_ticket_link=app.config["RESERVE_LIST_TICKET_LINK"], ) - msg.send() + enqueue_emails( + type=email_type, + users=[proposal.user], + subject=subject, + text_body=text_body, + ) diff --git a/apps/common/email.py b/apps/common/email.py index f596dd2fe..437f4cd4e 100644 --- a/apps/common/email.py +++ b/apps/common/email.py @@ -6,7 +6,8 @@ from markupsafe import Markup from main import db, mail -from models.email import EmailJob, EmailJobRecipient +from models.email import EmailJob, EmailJobRecipient, EmailJobType +from models.user import User from ..config import config as app_config @@ -123,27 +124,28 @@ def format_trusted_plaintext_email(markdown_text, **kwargs): def preview_trusted_email(preview_address, subject, body): subject = "[PREVIEW] " + subject formatted_plaintext = format_trusted_plaintext_email(body) + # FIXME: add reason? formatted_html = format_trusted_html_email(body, subject) mail.send_mail( subject=subject, message=formatted_plaintext, + html_message=formatted_html, from_email=app_config.from_email("CONTACT_EMAIL"), recipient_list=[preview_address], - html_message=formatted_html, ) -def enqueue_trusted_emails(users, subject, body, **kwargs): +def enqueue_emails( + type: EmailJobType, users: list[User], subject: str, text_body: str, html_body: Markup | None = None +) -> None: """Queue an email for sending by the background email worker.""" job = EmailJob( - subject, - format_trusted_plaintext_email(body, **kwargs), - format_trusted_html_email(body, subject, **kwargs), + type=type, + subject=subject, + text_body=text_body, + html_body=html_body, ) db.session.add(job) - for user in users: - db.session.add(EmailJobRecipient(job, user)) - - db.session.commit() + db.session.add(EmailJobRecipient(job=job, user=user)) diff --git a/apps/villages/admin.py b/apps/villages/admin.py index a427862a9..5c41c1a6b 100644 --- a/apps/villages/admin.py +++ b/apps/villages/admin.py @@ -11,14 +11,16 @@ from wtforms.validators import DataRequired from wtforms.widgets import TextArea +from apps.admin.email import get_email_reason, get_users from main import db from models.user import User from models.village import Village, VillageMember from ..common import require_permission from ..common.email import ( - enqueue_trusted_emails, + enqueue_emails, format_trusted_html_email, + format_trusted_plaintext_email, preview_trusted_email, ) from ..common.forms import Form @@ -166,15 +168,19 @@ def admin_village_admins(village_id: int) -> ResponseReturnValue: @villages.route("/admin/email-owners", methods=["GET", "POST"]) @village_admin_required def admin_email_owners() -> ResponseReturnValue: + # This function is almost identical to apps.admin.email.email, consider updating there too form = EmailComposeForm() if form.validate_on_submit(): - users = User.query.join(User.village_membership).filter(VillageMember.admin).distinct() + users: list[User] = get_users("villages") + + reason = get_email_reason("villages") + if form.preview.data is True: return render_template( "villages/admin/email.html", - html=format_trusted_html_email(form.text.data, form.subject.data), + html=format_trusted_html_email(form.text.data, form.subject.data, reason=reason), form=form, - count=users.count(), + count=len(users), ) if form.send_preview.data is True: @@ -183,14 +189,29 @@ def admin_email_owners() -> ResponseReturnValue: flash(f"Email preview sent to {form.send_preview_address.data}") return render_template( "villages/admin/email.html", - html=format_trusted_html_email(form.text.data, form.subject.data), + html=format_trusted_html_email( + form.text.data, + form.subject.data, + reason=reason, + ), form=form, - count=users.count(), + count=len(users), ) if form.send.data is True: - enqueue_trusted_emails(users, form.subject.data, form.text.data) - flash(f"Email queued for sending to {users.count()} users") + assert form.text.data # DataRequired() + assert form.subject.data # DataRequired() + body: str = form.text.data + subject: str = form.subject.data + enqueue_emails( + type="bulk_contact", + users=users, + subject=subject, + text_body=format_trusted_plaintext_email(body), + html_body=format_trusted_html_email(body, subject), + ) + db.session.commit() + flash(f"Email queued for sending to {len(users)} users") return redirect(url_for(".admin_email_owners")) return render_template("villages/admin/email.html", form=form) diff --git a/apps/volunteer/notify.py b/apps/volunteer/notify.py index e8dad4f05..bf10945b8 100644 --- a/apps/volunteer/notify.py +++ b/apps/volunteer/notify.py @@ -1,14 +1,16 @@ from apps.common.email import ( + enqueue_emails, format_trusted_html_email, format_trusted_plaintext_email, ) from main import db, mail -from models.volunteer.notify import VolunteerNotifyJob, VolunteerNotifyRecipient from ..config import config def preview_trusted_notify(preview_address, subject, body): + # This is basically a copy of apps.common.email.preview_trusted_email + subject = "[PREVIEW] " + subject reason = f"You're receiving this notification because you have volunteered to help at Electromagnetic Field {config.event_year}." formatted_plaintext = format_trusted_plaintext_email(body) @@ -17,22 +19,22 @@ def preview_trusted_notify(preview_address, subject, body): mail.send_mail( subject=subject, message=formatted_plaintext, + html_message=formatted_html, from_email=config.from_email("VOLUNTEER_EMAIL"), recipient_list=[preview_address], - html_message=formatted_html, ) -def enqueue_trusted_notify(volunteers, subject, body, **kwargs): +def enqueue_trusted_notify(volunteers, subject, body): """Queue an notification for sending by the background worker.""" - job = VolunteerNotifyJob( - subject, - format_trusted_plaintext_email(body, **kwargs), - format_trusted_html_email(body, subject, **kwargs), + # These are converted back to volunteers in the email sender + users = [v.user for v in volunteers] + reason = f"You're receiving this notification because you have volunteered to help at Electromagnetic Field {config.event_year}." + enqueue_emails( + type="notify_volunteer", + users=users, + subject=subject, + text_body=format_trusted_plaintext_email(body), + html_body=format_trusted_html_email(body, subject, reason), ) - db.session.add(job) - - for volunteer in volunteers: - db.session.add(VolunteerNotifyRecipient(job, volunteer)) - db.session.commit() diff --git a/migrations/versions/21562f36309d_add_emailjob_type_etc.py b/migrations/versions/21562f36309d_add_emailjob_type_etc.py new file mode 100644 index 000000000..322bfacdd --- /dev/null +++ b/migrations/versions/21562f36309d_add_emailjob_type_etc.py @@ -0,0 +1,71 @@ +"""Add EmailJob.type, etc + +Revision ID: 21562f36309d +Revises: a1fe3b750f1e +Create Date: 2026-05-16 14:35:46.544884 + +""" + +# revision identifiers, used by Alembic. +revision = '21562f36309d' +down_revision = 'a1fe3b750f1e' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +def upgrade(): + op.drop_table('volunteer_notify_recipient') + op.drop_table('volunteer_notify_job') + with op.batch_alter_table('email_job', schema=None) as batch_op: + batch_op.add_column(sa.Column('type', sa.Enum('bulk_contact', 'cfp', 'cfp_speakers', 'notify_volunteer', native_enum=False), server_default='bulk_contact', nullable=False)) + batch_op.alter_column('html_body', + existing_type=sa.VARCHAR(), + nullable=True) + + with op.batch_alter_table('email_recipient', schema=None) as batch_op: + batch_op.add_column(sa.Column('sent_at', sa.TIMESTAMP(), nullable=True)) + batch_op.create_index(batch_op.f('ix_email_recipient_sent_at'), ['sent_at'], unique=False) + + with op.batch_alter_table('volunteer', schema=None) as batch_op: + batch_op.alter_column('volunteer_email', + existing_type=sa.VARCHAR(), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + with op.batch_alter_table('volunteer', schema=None) as batch_op: + batch_op.alter_column('volunteer_email', + existing_type=sa.VARCHAR(), + nullable=True) + + with op.batch_alter_table('email_recipient', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_email_recipient_sent_at')) + batch_op.drop_column('sent_at') + + with op.batch_alter_table('email_job', schema=None) as batch_op: + batch_op.alter_column('html_body', + existing_type=sa.VARCHAR(), + nullable=False) + batch_op.drop_column('type') + + op.create_table('volunteer_notify_job', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('subject', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('text_body', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('html_body', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('created', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_volunteer_notify_job')) + ) + op.create_table('volunteer_notify_recipient', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('volunteer_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('job_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('sent', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['volunteer_notify_job.id'], name=op.f('fk_volunteer_notify_recipient_job_id_volunteer_notify_job')), + sa.ForeignKeyConstraint(['volunteer_id'], ['volunteer.id'], name=op.f('fk_volunteer_notify_recipient_volunteer_id_volunteer')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_volunteer_notify_recipient')) + ) + # ### end Alembic commands ### diff --git a/models/email.py b/models/email.py index 86bb2f7b0..68287b05b 100644 --- a/models/email.py +++ b/models/email.py @@ -1,27 +1,38 @@ from datetime import datetime +from typing import Literal, get_args +import sqlalchemy from sqlalchemy import ForeignKey, func, select from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship +from main import NaiveDT from models.user import User from . import BaseModel, naive_utcnow -__all__ = ["EmailJob", "EmailJobRecipient"] +__all__ = ["EmailJob", "EmailJobRecipient", "EmailJobType"] + + +EmailJobType = Literal["bulk_contact", "cfp", "cfp_speakers", "notify_volunteer"] class EmailJob(BaseModel): __tablename__ = "email_job" id: Mapped[int] = mapped_column(primary_key=True) + type: Mapped[EmailJobType] = mapped_column( + sqlalchemy.Enum( + *get_args(EmailJobType), + native_enum=False, + ), + # TODO after 2026: replace server_default with default + server_default="bulk_contact", + ) subject: Mapped[str] text_body: Mapped[str] - html_body: Mapped[str] + html_body: Mapped[str | None] created: Mapped[datetime] = mapped_column(default=naive_utcnow) - def __init__(self, subject, text_body, html_body): - self.subject = subject - self.text_body = text_body - self.html_body = html_body + recipients: Mapped[list[EmailJobRecipient]] = relationship(back_populates="job") @classmethod def get_export_data(cls): @@ -38,14 +49,12 @@ class EmailJobRecipient(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) job_id: Mapped[int] = mapped_column(ForeignKey("email_job.id")) + # TODO after 2026: remove/replace with column_property sent: Mapped[bool] = mapped_column(default=False) + sent_at: Mapped[NaiveDT | None] = mapped_column(index=True) - user: Mapped[User] = relationship() - job: Mapped[EmailJob] = relationship() - - def __init__(self, job, user): - self.job = job - self.user = user + user: Mapped[User] = relationship(back_populates="email_job_recipients") + job: Mapped[EmailJob] = relationship(back_populates="recipients") EmailJob.recipient_count = column_property( diff --git a/models/user.py b/models/user.py index de448270e..5044ac097 100644 --- a/models/user.py +++ b/models/user.py @@ -31,6 +31,7 @@ from .content.schedule import Occurrence, ScheduleItem from .content.tagging import Tag from .diversity import UserDiversity + from .email import EmailJobRecipient from .payment import Payment from .product import Voucher from .purchase import AdmissionTicket, Purchase, PurchaseTransfer, Ticket @@ -274,6 +275,7 @@ class User(BaseModel, UserMixin): primaryjoin="PurchaseTransfer.from_user_id == User.id", cascade="all, delete-orphan", ) + email_job_recipients: Mapped[list[EmailJobRecipient]] = relationship(back_populates="user") ### Content will_have_ticket: Mapped[bool] = mapped_column(default=False) # for CfP filtering diff --git a/models/volunteer/notify.py b/models/volunteer/notify.py deleted file mode 100644 index 9b0f0aca7..000000000 --- a/models/volunteer/notify.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import datetime - -from sqlalchemy import ForeignKey, func, select -from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship - -from .. import BaseModel, naive_utcnow - - -class VolunteerNotifyJob(BaseModel): - __tablename__ = "volunteer_notify_job" - id: Mapped[int] = mapped_column(primary_key=True) - subject: Mapped[str] - text_body: Mapped[str] - html_body: Mapped[str] - created: Mapped[datetime] = mapped_column(default=naive_utcnow) - - def __init__(self, subject, text_body, html_body): - self.subject = subject - self.text_body = text_body - self.html_body = html_body - - -class VolunteerNotifyRecipient(BaseModel): - __tablename__ = "volunteer_notify_recipient" - id: Mapped[int] = mapped_column(primary_key=True) - volunteer_id: Mapped[int] = mapped_column(ForeignKey("volunteer.id")) - job_id: Mapped[int] = mapped_column(ForeignKey("volunteer_notify_job.id")) - sent: Mapped[bool] = mapped_column(default=False) - - volunteer = relationship("Volunteer") - job = relationship("VolunteerNotifyJob") - - def __init__(self, job, volunteer): - self.job = job - self.volunteer = volunteer - - -VolunteerNotifyJob.recipient_count = column_property( - select(func.count(VolunteerNotifyRecipient.job_id)) - .where(VolunteerNotifyRecipient.job_id == VolunteerNotifyJob.id) - .scalar_subquery(), - deferred=True, -) diff --git a/models/volunteer/volunteer.py b/models/volunteer/volunteer.py index 4e9ed1ce2..abecb72ac 100644 --- a/models/volunteer/volunteer.py +++ b/models/volunteer/volunteer.py @@ -61,7 +61,7 @@ class Volunteer(BaseModel, UserMixin): nickname: Mapped[str | None] banned: Mapped[bool] = mapped_column(default=False) volunteer_phone: Mapped[str | None] - volunteer_email: Mapped[str | None] + volunteer_email: Mapped[str] over_18: Mapped[bool] = mapped_column(default=False) allergies: Mapped[set[str]] = mapped_column(MutableSetAsList.as_mutable(ARRAY(String)), default=set()) allergies_other: Mapped[str] = mapped_column(default="") diff --git a/templates/admin/email.html b/templates/admin/email.html index 3e478c0a8..cdb976a7c 100644 --- a/templates/admin/email.html +++ b/templates/admin/email.html @@ -1,3 +1,4 @@ +{# This file is almost identical to templates/villages/admin/email.html, consider updating there too #} {% extends "admin/base.html" %} {% block title %}Compose email{% endblock %} {% block body %} diff --git a/templates/villages/admin/email.html b/templates/villages/admin/email.html index b99c05875..29ae76ce6 100644 --- a/templates/villages/admin/email.html +++ b/templates/villages/admin/email.html @@ -1,3 +1,4 @@ +{# This file is almost identical to templates/admin/email.html, consider updating there too #} {% extends "villages/admin/base.html" %} {% block title %}Compose email{% endblock %} {% block body %} @@ -24,9 +25,8 @@
Preview Email Address: {{ form.send_preview_address(cols=30) }} {{ form.send_preview(class_="btn btn-success") }} -
+ {{ form.send(class_="btn btn-danger pull-right") }} +Preview Email Address: {{ form.send_preview_address(cols=30) }} {{ form.send_preview(class_="btn btn-success") }}
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/volunteer/admin/flask-admin-base.html b/templates/volunteer/admin/flask-admin-base.html index 6c9b2425d..7b020c36b 100644 --- a/templates/volunteer/admin/flask-admin-base.html +++ b/templates/volunteer/admin/flask-admin-base.html @@ -41,4 +41,9 @@ {% endblock %} {% block foot %} + + {% block tail %} + {% endblock %} + {% endblock %} + diff --git a/tests/volunteer/conftest.py b/tests/volunteer/conftest.py index c4ed2573d..1f53fa5e8 100644 --- a/tests/volunteer/conftest.py +++ b/tests/volunteer/conftest.py @@ -30,7 +30,7 @@ def session(db, user): def volunteer(db, user): volunteer = Volunteer.query.filter(Volunteer.user_id == user.id).one_or_none() if volunteer is None: - volunteer = Volunteer(user=user) + volunteer = Volunteer(user=user, volunteer_email=user.email) db.session.add(volunteer) user.grant_permission("volunteer:user") db.session.flush()