Membership invitations and order emails #47
|
@ -147,6 +147,8 @@ ignore = [
|
|||
"TD", # TODO, FIXME, XXX
|
||||
"ANN002", # Missing type annotation for `*args`
|
||||
"ANN003", # Missing type annotation for `**kwargs`
|
||||
"FBT001", # Misbehaves: Complains about positional arguments that are keyworded.
|
||||
"FBT002", # Misbehaves: Complains about positional arguments that are keyworded.
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
|
|
88
src/membership/emails.py
Normal file
88
src/membership/emails.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
"""Send email to members, using templates and contexts for the emails.
|
||||
|
||||
* We keep everything as plain text for now.
|
||||
* Notice that emails can be multilingual
|
||||
* Generally, an email consists of templates (for body and subject) and a get_context() method.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.mail.message import EmailMessage
|
||||
from django.http import HttpRequest
|
||||
from django.template import loader
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class BaseEmail(EmailMessage):
|
||||
"""Send emails via templated body and subjects.
|
||||
|
||||
This base class is extended for all email functionality.
|
||||
"""
|
||||
|
||||
template = "membership/email/base.txt"
|
||||
# Optional: Set to a template path for subject
|
||||
template_subject = None
|
||||
default_subject = "SET SUBJECT HERE"
|
||||
|
||||
def __init__(self, request: HttpRequest, *args, **kwargs) -> None: # noqa: D107
|
||||
self.context = kwargs.pop("context", {})
|
||||
self.user = kwargs.pop("user", None)
|
||||
if self.user:
|
||||
kwargs["to"] = [self.user.email]
|
||||
self.context["user"] = self.user
|
||||
self.context["recipient_name"] = self.user.get_display_name()
|
||||
|
||||
# Necessary to set request before instantiating body and subject
|
||||
self.request = request
|
||||
kwargs.setdefault("subject", self.get_subject())
|
||||
kwargs.setdefault("body", self.get_body())
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_context_data(self) -> dict:
|
||||
"""Resolve common context for sending emails.
|
||||
|
||||
When overwriting, remember to call this via super().
|
||||
"""
|
||||
c = self.context
|
||||
site = get_current_site(self.request)
|
||||
c["request"] = self.request
|
||||
c["domain"] = site.domain
|
||||
c["site_name"] = site.name
|
||||
c["protocol"] = "http" if self.request and not self.request.is_secure() else "https"
|
||||
return c
|
||||
|
||||
def get_body(self) -> str:
|
||||
"""Build the email body from template and context."""
|
||||
if self.user and self.user.language_code:
|
||||
with translation.override(self.user.language_code):
|
||||
body = loader.render_to_string(self.template, self.get_context_data())
|
||||
else:
|
||||
body = loader.render_to_string(self.template, self.get_context_data())
|
||||
return body
|
||||
|
||||
def get_subject(self) -> str:
|
||||
"""Build the email subject from template or self.default_subject."""
|
||||
if self.user and self.user.language_code:
|
||||
with translation.override(self.user.language_code):
|
||||
if self.template_subject:
|
||||
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||
else:
|
||||
subject = str(self.default_subject)
|
||||
elif self.template_subject:
|
||||
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||
else:
|
||||
subject = str(self.default_subject)
|
||||
return subject
|
||||
|
||||
def send_with_feedback(self, success_msg: str | None = None, no_message: bool = False) -> None:
|
||||
"""Send email, possibly adding feedback via django.contrib.messages."""
|
||||
if not success_msg:
|
||||
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
|
||||
try:
|
||||
self.send(fail_silently=False)
|
||||
if not no_message:
|
||||
messages.success(self.request, success_msg)
|
||||
except RuntimeError:
|
||||
messages.error(self.request, _("Not sent, something wrong with the mail server."))
|
9
src/membership/templates/membership/emails/base.txt
Normal file
9
src/membership/templates/membership/emails/base.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
|
||||
Regards,
|
||||
{{ site_name }}
|
||||
|
||||
{{ protocol }}://{{ domain }}
|
11
src/membership/templates/membership/emails/invitation.txt
Normal file
11
src/membership/templates/membership/emails/invitation.txt
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "users/mail/base.txt" %}{% load i18n %}
|
||||
|
||||
{% block content %}{% url 'users:login_token' token=user.token_uuid as login_url %}{% blocktrans with expiry=user.token_expiry next=next %}Here is a 1-time code for confirming your account:
|
||||
|
||||
{{ token_passphrase }}
|
||||
|
||||
Use this code within 1 hour (before {{ expiry }}). You can login here:
|
||||
|
||||
{{ protocol }}://{{ domain }}{{ login_url }}?next={{ next }}
|
||||
|
||||
If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from django_view_decorator import namespaced_decorator_factory
|
||||
from utils.view_utils import RenderConfig
|
||||
from utils.view_utils import RowAction
|
||||
|
@ -113,3 +114,27 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
|
|||
template_name="membership/members_admin_detail.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
|
||||
@member_view(
|
||||
paths="invite/<str:token>/",
|
||||
name="membership-invite",
|
||||
login_required=False,
|
||||
)
|
||||
def invite(request: HttpRequest, token: str) -> HttpResponse:
|
||||
"""View to invite a member to create a membership.
|
||||
|
||||
The token belongs to a non-active Member object. If the token is valid,
|
||||
the caller is allowed to create a membership.
|
||||
|
||||
We ratelimit this view so it's not possible to brute-force tokens.
|
||||
"""
|
||||
context = {
|
||||
"token": token,
|
||||
}
|
||||
return render(
|
||||
request=request,
|
||||
template_name="membership/invite.html",
|
||||
context=context,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue