diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py deleted file mode 100644 index dfe029036..000000000 --- a/pycroft/lib/mail.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -pycroft.lib.mail -~~~~~~~~~~~~~~~~ -""" - -from __future__ import annotations -import logging -import os -import smtplib -import ssl -import traceback -import typing as t -from contextvars import ContextVar -from dataclasses import dataclass, field, InitVar -from email.header import Header -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.utils import make_msgid, formatdate -from functools import lru_cache - -import jinja2 -from werkzeug.local import LocalProxy - -from pycroft.lib.exc import PycroftLibException - - -# TODO proxy and DI; set at app init -_config_var: ContextVar[MailConfig] = ContextVar("config") -config: MailConfig = LocalProxy(_config_var) # type: ignore[assignment] - -logger = logging.getLogger('mail') -logger.setLevel(logging.INFO) - - -@dataclass -class Mail: - to_name: str - to_address: str - subject: str - body_plain: str - body_html: str | None = None - reply_to: str | None = None - - @property - def body_plain_mime(self) -> MIMEText: - return MIMEText(self.body_plain, "plain", _charset="utf-8") - - @property - def body_html_mime(self) -> MIMEText | None: - if not self.body_html: - return None - return MIMEText(self.body_html, "html", _charset="utf-8") - - -class MailTemplate: - template: str - subject: str - args: dict - - def __init__(self, **kwargs: t.Any) -> None: - self.args = kwargs - - def render(self, **kwargs: t.Any) -> tuple[str, str]: - plain = self.jinja_template.render(mode="plain", **self.args, **kwargs) - html = self.jinja_template.render(mode="html", **self.args, **kwargs) - - return plain, html - - @property - def jinja_template(self) -> jinja2.Template: - return _get_template(self.template) - - -@lru_cache(maxsize=None) -def _get_template(template_location: str) -> jinja2.Template: - if config is None: - raise RuntimeError("`mail.config` not set up!") - return config.template_env.get_template(template_location) - - -def compose_mail(mail: Mail, from_: str, default_reply_to: str | None) -> MIMEMultipart: - msg = MIMEMultipart("alternative", _charset="utf-8") - msg["Message-Id"] = make_msgid() - msg["From"] = from_ - msg["To"] = str(Header(mail.to_address)) - msg["Subject"] = mail.subject - msg["Date"] = formatdate(localtime=True) - msg.attach(mail.body_plain_mime) - if (html := mail.body_html_mime) is not None: - msg.attach(html) - if reply_to := mail.reply_to or default_reply_to: - msg["Reply-To"] = reply_to - - print(msg) - - return msg - - -class RetryableException(PycroftLibException): - pass - - -def send_mails(mails: list[Mail]) -> tuple[bool, int]: - """Send MIME text mails - - Returns False, if sending fails. Else returns True. - - :param mails: A list of mails - - :returns: Whether the transmission succeeded - :context: config - """ - if config is None: - raise RuntimeError("`mail.config` not set up!") - - mail_envelope_from = config.mail_envelope_from - mail_from = config.mail_from - mail_reply_to = config.mail_reply_to - smtp_host = config.smtp_host - smtp_port = config.smtp_port - smtp_user = config.smtp_user - smtp_password = config.smtp_password - smtp_ssl = config.smtp_ssl - - use_ssl = smtp_ssl == 'ssl' - use_starttls = smtp_ssl == 'starttls' - ssl_context = None - - if use_ssl or use_starttls: - try: - ssl_context = ssl.create_default_context() - ssl_context.verify_mode = ssl.VerifyMode.CERT_REQUIRED - ssl_context.check_hostname = True - except ssl.SSLError as e: - # smtp.connect failed to connect - logger.critical('Unable to create ssl context', extra={ - 'trace': True, - 'data': {'exception_arguments': e.args} - }) - raise RetryableException from e - - try: - smtp: smtplib.SMTP - if use_ssl: - assert ssl_context is not None - smtp = smtplib.SMTP_SSL(host=smtp_host, port=smtp_port, - context=ssl_context) - else: - smtp = smtplib.SMTP(host=smtp_host, port=smtp_port) - - if use_starttls: - smtp.starttls(context=ssl_context) - - if smtp_user: - assert smtp_password is not None - smtp.login(smtp_user, smtp_password) - except (OSError, smtplib.SMTPException) as e: - traceback.print_exc() - - # smtp.connect failed to connect - logger.critical( - "Unable to connect to SMTP server: %s", - e, - extra={ - "trace": True, - "tags": {"mailserver": f"{smtp_host}:{smtp_host}"}, - "data": {"exception_arguments": e.args}, - }, - ) - - raise RetryableException from e - else: - failures: int = 0 - - for mail in mails: - try: - mime_mail = compose_mail(mail, from_=mail_from, default_reply_to=mail_reply_to) - assert mail_envelope_from is not None - smtp.sendmail(from_addr=mail_envelope_from, to_addrs=mail.to_address, - msg=mime_mail.as_string()) - except smtplib.SMTPException as e: - traceback.print_exc() - logger.critical( - 'Unable to send mail: "%s" to "%s": %s', mail.subject, mail.to_address, e, - extra={ - 'trace': True, - 'tags': {'mailserver': f"{smtp_host}:{smtp_host}"}, - 'data': {'exception_arguments': e.args, 'to': mail.to_address, - 'subject': mail.subject} - } - ) - failures += 1 - - smtp.close() - - logger.info('Tried to send mails (%i/%i succeeded)', len(mails) - failures, len(mails), extra={ - 'tags': {'mailserver': f"{smtp_host}:{smtp_host}"} - }) - - return failures == 0, failures - - -class UserConfirmEmailTemplate(MailTemplate): - template = "user_confirm_email.html" - subject = "Bitte bestätige deine E-Mail Adresse // Please confirm your email address" - - -class UserResetPasswordTemplate(MailTemplate): - template = "user_reset_password.html" - subject = "Neues Passwort setzen // Set a new password" - - -class UserMovedInTemplate(MailTemplate): - template = "user_moved_in.html" - subject = "Wohnortänderung // Change of residence" - - -class UserCreatedTemplate(MailTemplate): - template = "user_created.html" - subject = "Willkommen bei der AG DSN // Welcome to the AG DSN" - - -class MemberRequestPendingTemplate(MailTemplate): - template = "member_request_pending.html" - subject = "Deine Mitgliedschaftsanfrage // Your member request" - - -class MemberRequestDeniedTemplate(MailTemplate): - template = "member_request_denied.html" - subject = "Mitgliedschaftsanfrage abgelehnt // Member request denied" - - -class MemberRequestMergedTemplate(MailTemplate): - template = "member_request_merged.html" - subject = "Mitgliedskonto zusammengeführt // Member account merged" - - -class TaskFailedTemplate(MailTemplate): - template = "task_failed.html" - subject = "Aufgabe fehlgeschlagen // Task failed" - - -class MemberNegativeBalance(MailTemplate): - template = "member_negative_balance.html" - subject = "Deine ausstehenden Zahlungen // Your due payments" - - -def send_template_mails( - email_addresses: list[str], template: MailTemplate, **kwargs: t.Any -) -> None: - mails = [] - - for addr in email_addresses: - body_plain, body_html = template.render(**kwargs) - - mail = Mail(to_name='', - to_address=addr, - subject=template.subject, - body_plain=body_plain, - body_html=body_html) - mails.append(mail) - - from pycroft.task import send_mails_async - - send_mails_async.delay(mails) - - -@dataclass -class MailConfig: - mail_envelope_from: str - mail_from: str - mail_reply_to: str | None - smtp_host: str - smtp_user: str - smtp_password: str - smtp_port: int = field(default=465) - smtp_ssl: str = field(default="ssl") - - template_path_type: InitVar[str | None] = None - template_path: InitVar[str | None] = None - template_env: jinja2.Environment = field(init=False) - - @classmethod - def from_env(cls) -> t.Self: - env = os.environ - config = cls( - mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"], - mail_from=env["PYCROFT_MAIL_FROM"], - mail_reply_to=env.get("PYCROFT_MAIL_REPLY_TO"), - smtp_host=env["PYCROFT_SMTP_HOST"], - smtp_user=env["PYCROFT_SMTP_USER"], - smtp_password=env["PYCROFT_SMTP_PASSWORD"], - template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE"), - template_path=env.get("PYCROFT_TEMPLATE_PATH"), - ) - if (smtp_port := env.get("PYCROFT_SMTP_PORT")) is not None: - config.smtp_port = int(smtp_port) - if (smtp_ssl := env.get("PYCROFT_SMTP_SSL")) is not None: - config.smtp_ssl = smtp_ssl - - return config - - def __post_init__(self, template_path_type: str | None, template_path: str | None) -> None: - template_loader: jinja2.BaseLoader - if template_path_type is None: - template_path_type = "filesystem" - if template_path is None: - template_path = "pycroft/templates" - - if template_path_type == "filesystem": - template_loader = jinja2.FileSystemLoader(searchpath=f"{template_path}/mail") - else: - template_loader = jinja2.PackageLoader( - package_name="pycroft", package_path=f"{template_path}/mail" - ) - - self.template_env = jinja2.Environment(loader=template_loader) diff --git a/pycroft/lib/mail/__init__.py b/pycroft/lib/mail/__init__.py new file mode 100644 index 000000000..23aa2863c --- /dev/null +++ b/pycroft/lib/mail/__init__.py @@ -0,0 +1,45 @@ +""" +pycroft.lib.mail +~~~~~~~~~~~~~~~~ +""" + +from __future__ import annotations +import typing as t + + +from .concepts import Mail, MailTemplate +from .config import MailConfig, config, _config_var +from .submission import send_mails, RetryableException +from .templates import ( + UserConfirmEmailTemplate, + UserResetPasswordTemplate, + UserMovedInTemplate, + UserCreatedTemplate, + MemberRequestPendingTemplate, + MemberRequestDeniedTemplate, + MemberRequestMergedTemplate, + TaskFailedTemplate, + MemberNegativeBalance, +) + + +def send_template_mails( + email_addresses: list[str], template: MailTemplate, **kwargs: t.Any +) -> None: + mails = [] + + for addr in email_addresses: + body_plain, body_html = template.render(**kwargs) + + mail = Mail( + to_name="", + to_address=addr, + subject=template.subject, + body_plain=body_plain, + body_html=body_html, + ) + mails.append(mail) + + from pycroft.task import send_mails_async + + send_mails_async.delay(mails) diff --git a/pycroft/lib/mail/concepts.py b/pycroft/lib/mail/concepts.py new file mode 100644 index 000000000..753c15e21 --- /dev/null +++ b/pycroft/lib/mail/concepts.py @@ -0,0 +1,75 @@ +import typing as t +from dataclasses import dataclass +from email.header import Header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import make_msgid, formatdate +from functools import lru_cache + +import jinja2 + +from .config import config + + +@dataclass +class Mail: + to_name: str + to_address: str + subject: str + body_plain: str + body_html: str | None = None + reply_to: str | None = None + + @property + def body_plain_mime(self) -> MIMEText: + return MIMEText(self.body_plain, "plain", _charset="utf-8") + + @property + def body_html_mime(self) -> MIMEText | None: + if not self.body_html: + return None + return MIMEText(self.body_html, "html", _charset="utf-8") + + def compose(self, from_: str, default_reply_to: str | None) -> MIMEMultipart: + msg = MIMEMultipart("alternative", _charset="utf-8") + msg["Message-Id"] = make_msgid() + msg["From"] = from_ + msg["To"] = str(Header(self.to_address)) + msg["Subject"] = self.subject + msg["Date"] = formatdate(localtime=True) + msg.attach(self.body_plain_mime) + if (html := self.body_html_mime) is not None: + msg.attach(html) + if reply_to := self.reply_to or default_reply_to: + msg["Reply-To"] = reply_to + + print(msg) + + return msg + + +class MailTemplate: + template: str + subject: str + args: dict + + def __init__( + self, loader: t.Callable[[str], jinja2.Template] | None = None, **kwargs: t.Any + ) -> None: + self.jinja_template: jinja2.Template = (loader or _get_template)(self.template) + self.args = kwargs + + # TODO don't put this as a method on the template… We want a separate render method for each template. + def render(self, **kwargs: t.Any) -> tuple[str, str]: + plain = self.jinja_template.render(mode="plain", **self.args, **kwargs) + html = self.jinja_template.render(mode="html", **self.args, **kwargs) + + return plain, html + + +@lru_cache(maxsize=None) +def _get_template(template_location: str) -> jinja2.Template: + try: + return config.template_env.get_template(template_location) + except RuntimeError as e: + raise RuntimeError("`mail.config` not set up!") from e diff --git a/pycroft/lib/mail/config.py b/pycroft/lib/mail/config.py new file mode 100644 index 000000000..4216ff6e3 --- /dev/null +++ b/pycroft/lib/mail/config.py @@ -0,0 +1,69 @@ +import os +import typing as t +from contextvars import ContextVar +from dataclasses import dataclass, field, InitVar + +import jinja2 +from werkzeug.local import LocalProxy + +type SmtpSslType = t.Literal["ssl", "starttls"] | None + + +@dataclass +class MailConfig: + mail_envelope_from: str + mail_from: str + mail_reply_to: str | None + smtp_host: str + smtp_user: str + smtp_password: str + smtp_port: int = field(default=465) + smtp_ssl: SmtpSslType = field(default="ssl") + + template_path_type: InitVar[str | None] = None + template_path: InitVar[str | None] = None + template_env: jinja2.Environment = field(init=False) + + @classmethod + def from_env(cls) -> t.Self: + env = os.environ + config = cls( + mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"], + mail_from=env["PYCROFT_MAIL_FROM"], + mail_reply_to=env.get("PYCROFT_MAIL_REPLY_TO"), + smtp_host=env["PYCROFT_SMTP_HOST"], + smtp_user=env["PYCROFT_SMTP_USER"], + smtp_password=env["PYCROFT_SMTP_PASSWORD"], + template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE"), + template_path=env.get("PYCROFT_TEMPLATE_PATH"), + ) + if (smtp_port := env.get("PYCROFT_SMTP_PORT")) is not None: + config.smtp_port = int(smtp_port) + if (smtp_ssl := env.get("PYCROFT_SMTP_SSL")) is not None: + match smtp_ssl: + case "ssl" | "starttls": + config.smtp_ssl = smtp_ssl + case _: + raise ValueError("PYCROFT_SMTP_SSL must be either 'ssl' or 'starttls' if set") + + return config + + def __post_init__(self, template_path_type: str | None, template_path: str | None) -> None: + template_loader: jinja2.BaseLoader + if template_path_type is None: + template_path_type = "filesystem" + if template_path is None: + template_path = "pycroft/templates" + + if template_path_type == "filesystem": + template_loader = jinja2.FileSystemLoader(searchpath=f"{template_path}/mail") + else: + template_loader = jinja2.PackageLoader( + package_name="pycroft", package_path=f"{template_path}/mail" + ) + + self.template_env = jinja2.Environment(loader=template_loader) + + +_config_var: ContextVar[MailConfig] = ContextVar("config") +config: MailConfig = LocalProxy(_config_var) # type: ignore[assignment] diff --git a/pycroft/lib/mail/submission.py b/pycroft/lib/mail/submission.py new file mode 100644 index 000000000..977d77bb3 --- /dev/null +++ b/pycroft/lib/mail/submission.py @@ -0,0 +1,138 @@ +import logging +import smtplib +import ssl +import traceback + +from pycroft.lib.exc import PycroftLibException +from .concepts import Mail +from .config import config, SmtpSslType + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +def send_mails(mails: list[Mail]) -> tuple[bool, int]: + """Send MIME text mails + + Returns False, if sending fails. Else returns True. + + :param mails: A list of mails + + :returns: Whether the transmission succeeded + :context: config + """ + if config is None: + raise RuntimeError("`mail.config` not set up!") + + mail_envelope_from = config.mail_envelope_from + assert mail_envelope_from is not None + mail_from = config.mail_from + mail_reply_to = config.mail_reply_to + smtp_host = config.smtp_host + smtp_port = config.smtp_port + + + failures: int = 0 + + with ( + smtp := try_create_smtp( + config.smtp_ssl, smtp_host, smtp_port, config.smtp_user, config.smtp_password + ) + ): + for mail in mails: + mime_mail = mail.compose(from_=mail_from, default_reply_to=mail_reply_to) + try: + smtp.sendmail( + from_addr=mail_envelope_from, + to_addrs=mail.to_address, + msg=mime_mail.as_string(), + ) + except smtplib.SMTPException as e: + traceback.print_exc() + logger.critical( + 'Unable to send mail: "%s" to "%s": %s', + mail.subject, + mail.to_address, + e, + extra={ + "trace": True, + "tags": {"mailserver": f"{smtp_host}:{smtp_port}"}, + "data": { + "exception_arguments": e.args, + "to": mail.to_address, + "subject": mail.subject, + }, + }, + ) + failures += 1 + + logger.info( + "Tried to send mails (%i/%i succeeded)", + len(mails) - failures, + len(mails), + extra={"tags": {"mailserver": f"{smtp_host}:{smtp_port}"}}, + ) + + return failures == 0, failures + + +def try_create_smtp( + smtp_ssl: SmtpSslType, smtp_host: str, smtp_port: int, smtp_user: str, smtp_password: str +) -> smtplib.SMTP: + """ + :raises RetryableException: + """ + try: + smtp: smtplib.SMTP + if smtp_ssl == "ssl": + smtp = smtplib.SMTP_SSL( + host=smtp_host, port=smtp_port, context=try_create_ssl_context() + ) + else: + smtp = smtplib.SMTP(host=smtp_host, port=smtp_port) + + if smtp_ssl == "starttls": + smtp.starttls(context=try_create_ssl_context()) + + if smtp_user: + assert smtp_password is not None + smtp.login(smtp_user, smtp_password) + return smtp + except (OSError, smtplib.SMTPException) as e: + traceback.print_exc() + + # smtp.connect failed to connect + logger.critical( + "Unable to connect to SMTP server: %s", + e, + extra={ + "trace": True, + "tags": {"mailserver": f"{smtp_host}:{smtp_port}"}, + "data": {"exception_arguments": e.args}, + }, + ) + + raise RetryableException from e + + +def try_create_ssl_context() -> ssl.SSLContext: + """ + :raises RetryableException: + """ + try: + ssl_context = ssl.create_default_context() + ssl_context.verify_mode = ssl.VerifyMode.CERT_REQUIRED + ssl_context.check_hostname = True + except ssl.SSLError as e: + # smtp.connect failed to connect + logger.critical( + "Unable to create ssl context", + extra={"trace": True, "data": {"exception_arguments": e.args}}, + ) + raise RetryableException from e + return ssl_context + + +class RetryableException(PycroftLibException): + pass diff --git a/pycroft/lib/mail/templates.py b/pycroft/lib/mail/templates.py new file mode 100644 index 000000000..a45f546e5 --- /dev/null +++ b/pycroft/lib/mail/templates.py @@ -0,0 +1,62 @@ +import typing as t +from .concepts import MailTemplate + + +@t.final +class UserConfirmEmailTemplate(MailTemplate): + template = "user_confirm_email.html" + subject = "Bitte bestätige deine E-Mail Adresse // Please confirm your email address" + + +@t.final +class UserResetPasswordTemplate(MailTemplate): + template = "user_reset_password.html" + subject = "Neues Passwort setzen // Set a new password" + + +@t.final +class UserMovedInTemplate(MailTemplate): + template = "user_moved_in.html" + subject = "Wohnortänderung // Change of residence" + + +@t.final +class UserCreatedTemplate(MailTemplate): + template = "user_created.html" + subject = "Willkommen bei der AG DSN // Welcome to the AG DSN" + + +@t.final +class MemberRequestPendingTemplate(MailTemplate): + template = "member_request_pending.html" + subject = "Deine Mitgliedschaftsanfrage // Your member request" + + +@t.final +class MemberRequestDeniedTemplate(MailTemplate): + template = "member_request_denied.html" + subject = "Mitgliedschaftsanfrage abgelehnt // Member request denied" + + +@t.final +class MemberRequestMergedTemplate(MailTemplate): + template = "member_request_merged.html" + subject = "Mitgliedskonto zusammengeführt // Member account merged" + + +@t.final +class TaskFailedTemplate(MailTemplate): + template = "task_failed.html" + subject = "Aufgabe fehlgeschlagen // Task failed" + + +@t.final +class MemberNegativeBalance(MailTemplate): + template = "member_negative_balance.html" + subject = "Deine ausstehenden Zahlungen // Your due payments" + + +@t.final +class MoveOutReminder(MailTemplate): + template = "move_out_reminder.html" + subject = "Deine ausstehenden Zahlungen // Your due payments" diff --git a/pycroft/lib/user/mail.py b/pycroft/lib/user/mail.py index 3af2ac19b..177b256d2 100644 --- a/pycroft/lib/user/mail.py +++ b/pycroft/lib/user/mail.py @@ -1,5 +1,6 @@ import os import typing as t +from datetime import date, timedelta from sqlalchemy import select, ScalarResult from sqlalchemy.orm import Session @@ -8,10 +9,14 @@ from pycroft.lib.mail import ( MailTemplate, Mail, +) +from pycroft.lib.mail.templates import ( + MemberRequestMergedTemplate, + MoveOutReminder, UserConfirmEmailTemplate, UserResetPasswordTemplate, - MemberRequestMergedTemplate, ) +from pycroft.lib.membership import select_user_and_last_mem from pycroft.model import session from pycroft.model.session import with_transaction from pycroft.model.user import ( @@ -22,6 +27,7 @@ PropertyGroup, ) from pycroft.model.facilities import Building, Room +from pycroft.model.swdd import Tenancy from pycroft.task import send_mails_async from .user_id import ( @@ -51,6 +57,7 @@ def user_send_mails( use_internal: bool = True, body_plain: str | None = None, subject: str | None = None, + send_mails: t.Callable[[list[Mail]], None] | None = None, **kwargs: t.Any, ) -> None: """ @@ -58,6 +65,7 @@ def user_send_mails( :param users: Users who should receive the mail :param template: The template that should be used. Can be None if body_plain is supplied. + if supplied, must take a `user` parameter. :param soft_fail: Do not raise an exception if a user does not have an email and use_internal is set to True :param use_internal: If internal mail addresses can be used (@agdsn.me) @@ -68,6 +76,11 @@ def user_send_mails( :return: """ + assert (template is not None) ^ (body_plain is not None), \ + "user_send_mails should be called with either template or plain body" + assert (body_plain is not None) == (subject is not None), \ + "subject must be passed if and only if body is passed" + mails = [] for user in users: @@ -113,7 +126,7 @@ def user_send_mails( ) mails.append(mail) - send_mails_async.delay(mails) + (send_mails or send_mails_async.delay)(mails) def user_send_mail( @@ -206,3 +219,35 @@ def send_password_reset_mail(user: User) -> bool: return False return True + + +def mail_soon_to_move_out_members(session: Session, send_mails: t.Callable[[list[Mail]], None]): + """Dependency-free implementation of the celery task of the same name.""" + contract_end = contract_end_reminder_date(session) + user_send_mails( + get_members_with_contract_end_at(session, contract_end), + template=MoveOutReminder(), + contract_end=contract_end, + send_mails=send_mails, + ) + + +def get_members_with_contract_end_at(session: Session, date: date) -> ScalarResult[User]: + """Select members whose contract ends at a given date. + + We only show users with an unbounded membership in the member_group. + """ + last_mem = select_user_and_last_mem().cte("last_mem") + stmt = ( + select(last_mem) + .join(User, last_mem.c.user_id == User.id) + .outerjoin(User.tenancies) + .where(Tenancy.mietende == date, last_mem.c.mem_end.is_(None)) + .with_only_columns(User) + ) + return session.scalars(stmt) + + +def contract_end_reminder_date(session: Session): + return date.today() + timedelta(days=7) + diff --git a/pycroft/task.py b/pycroft/task.py index 013be3b1a..682446049 100644 --- a/pycroft/task.py +++ b/pycroft/task.py @@ -11,7 +11,7 @@ import os import sys import typing as t -from datetime import timedelta +from datetime import timedelta, date import sentry_sdk from celery import Celery, Task as CeleryTask @@ -34,6 +34,7 @@ _config_var, MailConfig, ) +from pycroft.lib.mail.templates import MoveOutReminder from pycroft.lib.task import get_task_implementation, get_scheduled_tasks from pycroft.lib.traffic import delete_old_traffic_data from pycroft.model import session @@ -42,6 +43,7 @@ from pycroft.model.task import TaskStatus from scripts.connection import try_create_connection + if dsn := os.getenv('PYCROFT_SENTRY_DSN'): logging_integration = LoggingIntegration( level=logging.INFO, # INFO / WARN create breadcrumbs, just as SQL queries @@ -204,6 +206,12 @@ def mail_negative_members(): send_mails_async.delay([mail]) +@app.task(base=DBTask) +def mail_soon_to_move_out_members(): + from pycroft.lib.user.mail import mail_soon_to_move_out_members as impl + impl(t.cast(Session, session.session), send_mails_async.delay) + + @app.task(ignore_result=True, rate_limit=1, bind=True) def send_mails_async(self, mails: list[Mail]): success = False diff --git a/pycroft/templates/mail/move_out_reminder.html b/pycroft/templates/mail/move_out_reminder.html new file mode 100644 index 000000000..f2830ef35 --- /dev/null +++ b/pycroft/templates/mail/move_out_reminder.html @@ -0,0 +1,40 @@ +{%- extends "base.html.j2" -%} +{%- block body -%} +* English version below * + +Hallo {{ user.name }}, + +Unserer Kenntnis nach endet dein Mietvertrag am {{ contract_end }}. +Deine Mitgliedschaft bei der AG DSN endet *nicht automatisch*. +Bitte denke also daran, sie zu beenden, +sofern du nicht über deine Wohndauer hinaus bei uns Mitglied bleiben möchtest. + +Dein Mitgliedschaftsende kannst du unter folgendem Link vormerken: [1] + +Bei Fragen oder Problemen antworte gerne auf diese Mail. + +Viele Grüße +Deine AG DSN + +[1] https://agdsn.de/sipa/usersuite/terminate-membership + + +* English version * + +Hello {{ user.name }}, + +To our knowledge, your rental agreement ends on {{ contract_end }}. +Your membership with AG DSN does *not* end automatically. +Please remember to cancel your membership +if you do not wish to remain a member beyond the duration of your stay. + +You can register your termination at the following link: [1] + +If you have any questions or problems, please reply to this email. + +Best regards, +Your AG DSN + +[1] https://agdsn.de/sipa/usersuite/terminate-membership + +{%- endblock -%} diff --git a/tests/lib/user/test_mail.py b/tests/lib/user/test_mail.py index 0ec9b6d68..b3eda28e4 100644 --- a/tests/lib/user/test_mail.py +++ b/tests/lib/user/test_mail.py @@ -1,12 +1,12 @@ # Copyright (c) 2025. The Pycroft Authors. See the AUTHORS file. # This file is part of the Pycroft project and licensed under the terms of # the Apache License, Version 2.0. See the LICENSE file for details +from itertools import combinations + import pytest from pycroft.lib.user import get_active_users_with_building from pycroft.model.facilities import Room, Building -from itertools import combinations - from pycroft.model.user import User from ...factories import UserFactory, RoomFactory, BuildingFactory, AddressFactory diff --git a/tests/lib/user/test_move_out_reminder.py b/tests/lib/user/test_move_out_reminder.py new file mode 100644 index 000000000..a512064bb --- /dev/null +++ b/tests/lib/user/test_move_out_reminder.py @@ -0,0 +1,92 @@ +# Copyright (c) 2025. The Pycroft Authors. See the AUTHORS file. +# This file is part of the Pycroft project and licensed under the terms of +# the Apache License, Version 2.0. See the LICENSE file for details +from __future__ import annotations + +import typing as t +from datetime import datetime, date + +import pytest +from sqlalchemy.orm import Session + +from pycroft.helpers.interval import closedopen +from pycroft.lib.user.mail import get_members_with_contract_end_at +from pycroft.model.user import User +from ...factories import UserFactory + +def test_move_out_reminder(session, config, gen_user: UserWithTenancyGen): + u = gen_user( + session, + date(2019, 10, 1), + date(2020, 9, 30), + None, + ) + session.flush() + + # TODO fixture: member with contract end at 2020-01-08 + # TODO fixture: member with the former, but also + + m = get_members_with_contract_end_at(session, date(2020, 9, 30)) + assert m.all() == [u] + + + +class UserWithTenancyGen(t.Protocol): + def __call__( + self, + session: Session, + tenancy_begin: date, + tenancy_end: date, + mem_end: datetime | None = None, + ) -> User: ... + + +@pytest.fixture(scope="function") +def gen_user(config) -> UserWithTenancyGen: + def gen( + session: Session, tenancy_begin: date, tenancy_end: date, mem_end: datetime | None = None + ) -> User: + u = t.cast( + User, + UserFactory.create( + with_membership=True, + registered_at=tenancy_begin, + membership__active_during=closedopen(tenancy_begin, mem_end), + membership__group=config.member_group, + ), + ) + u.swdd_person_id = u.id + 10000 + session.add(u) + session.flush() + from sqlalchemy import Table, MetaData, Column, Integer, Text, Date + from sqlalchemy.sql import text + + swdd_vv = Table( + "swdd_vv", + MetaData(), + Column("persvv_id", Integer, nullable=False), + Column("person_id", Integer, nullable=False), + Column("vo_suchname", Text, nullable=False), + Column("person_hash", Text, nullable=False), + Column("mietbeginn", Date, nullable=False), + Column("mietende", Date, nullable=False), + Column("status_id", Integer, nullable=False), + schema="swdd", + ) + assert u.room + session.execute( + swdd_vv.insert().values( + persvv_id=u.id + 10000, + person_id=u.id + 10000, + vo_suchname=u.room.swdd_vo_suchname, + person_hash="", + mietbeginn=tenancy_begin.isoformat(), + mietende=tenancy_end.isoformat(), + status_id=1, + ) + ) + session.execute(text("refresh materialized view swdd_vv")) + return u + + return gen +