From 858bb6cabdc469a566ea7f8ef90683e1f3073876 Mon Sep 17 00:00:00 2001 From: Mark Steward Date: Sun, 3 May 2026 21:06:51 +0100 Subject: [PATCH] Send CfP emails via a queue We often send a bunch of emails for CfP acceptance, which clogs up admin processes and can fail. Inserting them into a DB table also means we get the same transaction lifetime, so we don't need to worry about races or timeouts. This also combines bulk and notification email sending, syncs up the village owner emailing code, and partially fixes the flask-admin template/JS. --- apps/admin/email.py | 31 +++-- apps/base/scheduled_tasks.py | 122 +++++++++++++----- apps/cfp_review/email.py | 27 ++-- apps/common/email.py | 22 ++-- apps/villages/admin.py | 37 ++++-- apps/volunteer/notify.py | 26 ++-- .../21562f36309d_add_emailjob_type_etc.py | 71 ++++++++++ models/email.py | 33 +++-- models/user.py | 2 + models/volunteer/notify.py | 43 ------ models/volunteer/volunteer.py | 2 +- templates/admin/email.html | 1 + templates/villages/admin/email.html | 8 +- .../volunteer/admin/flask-admin-base.html | 5 + tests/volunteer/conftest.py | 2 +- 15 files changed, 283 insertions(+), 149 deletions(-) create mode 100644 migrations/versions/21562f36309d_add_emailjob_type_etc.py delete mode 100644 models/volunteer/notify.py 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 @@

Compose

{{ form.preview(class_="btn btn-success pull-right") }} {% if html %} - {{ form.send(class_="btn btn-danger pull-right") }} -

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()