Membership invitations and order emails #47

Merged
valberg merged 10 commits from benjaoming/membersystem:membership-invite into main 2024-08-14 09:17:30 +00:00
9 changed files with 131 additions and 6 deletions
Showing only changes of commit 1d26bbc17a - Show all commits

View file

@ -141,14 +141,19 @@ ignore = [
"EM102", # Exception must not use a f-string literal, assign to variable first
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"FIX", # TODO, FIXME, XXX
"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.
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
"TRY003", # Avoid specifying long messages outside the exception class
]
[tool.ruff.lint.isort]

View file

@ -6,6 +6,7 @@
"""
from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail.message import EmailMessage
from django.http import HttpRequest
@ -13,11 +14,15 @@ from django.template import loader
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from src.membership.models import Membership
class BaseEmail(EmailMessage):
"""Send emails via templated body and subjects.
This base class is extended for all email functionality.
Because all emails are sent to the Member object, we can keep them gathered here, even when they are generated by
other apps (like the accounting app).
"""
template = "membership/email/base.txt"
@ -25,7 +30,7 @@ class BaseEmail(EmailMessage):
template_subject = None
default_subject = "SET SUBJECT HERE"
def __init__(self, request: HttpRequest, *args, **kwargs) -> None: # noqa: D107
def __init__(self, request: HttpRequest, *args, **kwargs) -> None:
self.context = kwargs.pop("context", {})
self.user = kwargs.pop("user", None)
if self.user:
@ -76,7 +81,7 @@ class BaseEmail(EmailMessage):
subject = str(self.default_subject)
return subject
def send_with_feedback(self, success_msg: str | None = None, no_message: bool = False) -> None:
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))
@ -86,3 +91,20 @@ class BaseEmail(EmailMessage):
messages.success(self.request, success_msg)
except RuntimeError:
messages.error(self.request, _("Not sent, something wrong with the mail server."))
class InviteEmail(BaseEmail):
template = "membership/emails/invite.txt"
default_subject = _("Invite to data.coop membership")
def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None:
self.membership = membership
kwargs["user"] = membership.user
super().__init__(request, *args, **kwargs)
def get_context_data(self) -> dict:
c = super().get_context_data()
c["membership"] = self.membership
c["token"] = default_token_generator.make_token(self.membership.user)
c["referral_code"] = self.membership.referral_code
return c

19
src/membership/forms.py Normal file
View file

@ -0,0 +1,19 @@
from allauth.account.forms import SetPasswordForm
class CreatePasswordForm(SetPasswordForm):
"""Create a new password for a user account that is created through an invite."""
def __init__(self, *args, **kwargs) -> None:
self.membership = kwargs.pop("membership")
super().__init__(*args, **kwargs)
def save(self) -> None:
"""Save instance to db.
Note: You can hack a re-activation of a deactivated account
by getting a valid token before deactivation (from the reset password form).
We can block this by also setting Membership.revoked=False when deactivating someone's account.
"""
self.user.is_active = True
super().save()

View file

@ -0,0 +1,32 @@
# Generated by Django 5.1rc1 on 2024-08-07 22:32
import uuid
from django.db import migrations, models
def create_uuid(apps, schema_editor):
Membership = apps.get_model('membership', 'Membership')
for membership in Membership.objects.all():
membership.referral_code = uuid.uuid4()
membership.save()
class Migration(migrations.Migration):
dependencies = [
('membership', '0008_alter_membership_membership_type'),
]
operations = [
migrations.AddField(
model_name='membership',
name='referral_code',
field=models.UUIDField(blank=True, null=True, unique=True, default=uuid.uuid4, editable=False),
),
migrations.RunPython(create_uuid),
migrations.AddField(
model_name='membership',
name='referral_code',
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),
),
]

View file

@ -1,5 +1,6 @@
"""Models for the membership app."""
import uuid
from typing import ClassVar
from typing import Self
@ -122,6 +123,9 @@ class Membership(CreatedModifiedAbstract):
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
# This code is used for inviting a user to create an account for this membership.
referral_code = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
membership_type = models.ForeignKey(
"membership.MembershipType",
related_name="memberships",

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Membership" %}
{% endblock %}
{% block content %}
<div class="content-view">
<h2>Membership invite</h2>
</div>
{% endblock %}

View file

@ -4,6 +4,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator
from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django_ratelimit.decorators import ratelimit
from django_view_decorator import namespaced_decorator_factory
@ -11,6 +16,8 @@ from utils.view_utils import RenderConfig
from utils.view_utils import RowAction
from utils.view_utils import render
from .forms import CreatePasswordForm
from .models import Membership
from .permissions import ADMINISTRATE_MEMBERS
from .selectors import get_member
from .selectors import get_members
@ -118,11 +125,11 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
@member_view(
paths="invite/<str:token>/",
paths="invite/<str:membership_code>/<str:token>/",
name="membership-invite",
login_required=False,
)
def invite(request: HttpRequest, token: str) -> HttpResponse:
def invite(request: HttpRequest, referral_code: str, 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,
@ -130,8 +137,26 @@ def invite(request: HttpRequest, token: str) -> HttpResponse:
We ratelimit this view so it's not possible to brute-force tokens.
"""
# Firstly, we get the membership by the referral code.
membership = get_object_or_404(Membership, referral_code=referral_code, user__is_active=False, revoked=False)
token_valid = default_token_generator.check_token(membership.user, token)
if not token_valid:
raise HttpResponseNotAllowed("Token not valid - maybe it expired?")
if request.method == "POST":
form = CreatePasswordForm(membership=membership, user=membership, data=request.POST)
if form.is_valid():
messages.info(request, _("Password is set for your account and you can now login."))
return redirect("account_login")
else:
form = CreatePasswordForm(user=membership)
context = {
"token": token,
"membership": membership,
"form": form,
}
return render(
request=request,

View file

@ -182,6 +182,10 @@ LOGGING = {
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
# The number of seconds a password reset link is valid for (default: 3 days).
# We've extended this to 7 days because invites then last for 1 week.
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 7
if DEBUG:
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
MIDDLEWARE += [