Membership invitations and order emails #47
|
@ -141,14 +141,19 @@ ignore = [
|
||||||
"EM102", # Exception must not use a f-string literal, assign to variable first
|
"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)
|
"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)
|
"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
|
"D105", # Missing docstring in magic method
|
||||||
"D106", # Missing docstring in public nested class
|
"D106", # Missing docstring in public nested class
|
||||||
|
"D107", # Missing docstring in `__init__`
|
||||||
"FIX", # TODO, FIXME, XXX
|
"FIX", # TODO, FIXME, XXX
|
||||||
"TD", # TODO, FIXME, XXX
|
"TD", # TODO, FIXME, XXX
|
||||||
"ANN002", # Missing type annotation for `*args`
|
"ANN002", # Missing type annotation for `*args`
|
||||||
"ANN003", # Missing type annotation for `**kwargs`
|
"ANN003", # Missing type annotation for `**kwargs`
|
||||||
"FBT001", # Misbehaves: Complains about positional arguments that are keyworded.
|
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
|
||||||
"FBT002", # Misbehaves: Complains about positional arguments that are keyworded.
|
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
|
||||||
|
"TRY003", # Avoid specifying long messages outside the exception class
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import messages
|
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.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.mail.message import EmailMessage
|
from django.core.mail.message import EmailMessage
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -13,11 +14,15 @@ from django.template import loader
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from src.membership.models import Membership
|
||||||
|
|
||||||
|
|
||||||
class BaseEmail(EmailMessage):
|
class BaseEmail(EmailMessage):
|
||||||
"""Send emails via templated body and subjects.
|
"""Send emails via templated body and subjects.
|
||||||
|
|
||||||
This base class is extended for all email functionality.
|
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"
|
template = "membership/email/base.txt"
|
||||||
|
@ -25,7 +30,7 @@ class BaseEmail(EmailMessage):
|
||||||
template_subject = None
|
template_subject = None
|
||||||
default_subject = "SET SUBJECT HERE"
|
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.context = kwargs.pop("context", {})
|
||||||
self.user = kwargs.pop("user", None)
|
self.user = kwargs.pop("user", None)
|
||||||
if self.user:
|
if self.user:
|
||||||
|
@ -76,7 +81,7 @@ class BaseEmail(EmailMessage):
|
||||||
subject = str(self.default_subject)
|
subject = str(self.default_subject)
|
||||||
return 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."""
|
"""Send email, possibly adding feedback via django.contrib.messages."""
|
||||||
if not success_msg:
|
if not success_msg:
|
||||||
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
|
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
|
||||||
|
@ -86,3 +91,20 @@ class BaseEmail(EmailMessage):
|
||||||
messages.success(self.request, success_msg)
|
messages.success(self.request, success_msg)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
messages.error(self.request, _("Not sent, something wrong with the mail server."))
|
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
19
src/membership/forms.py
Normal 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()
|
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
32
src/membership/migrations/0009_membership_referral_code.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,6 @@
|
||||||
"""Models for the membership app."""
|
"""Models for the membership app."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
|
@ -122,6 +123,9 @@ class Membership(CreatedModifiedAbstract):
|
||||||
|
|
||||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
|
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_type = models.ForeignKey(
|
||||||
"membership.MembershipType",
|
"membership.MembershipType",
|
||||||
related_name="memberships",
|
related_name="memberships",
|
||||||
|
|
14
src/membership/templates/membership/invite.html
Normal file
14
src/membership/templates/membership/invite.html
Normal 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 %}
|
|
@ -4,6 +4,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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.utils.translation import gettext_lazy as _
|
||||||
from django_ratelimit.decorators import ratelimit
|
from django_ratelimit.decorators import ratelimit
|
||||||
from django_view_decorator import namespaced_decorator_factory
|
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 RowAction
|
||||||
from utils.view_utils import render
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
from .forms import CreatePasswordForm
|
||||||
|
from .models import Membership
|
||||||
from .permissions import ADMINISTRATE_MEMBERS
|
from .permissions import ADMINISTRATE_MEMBERS
|
||||||
from .selectors import get_member
|
from .selectors import get_member
|
||||||
from .selectors import get_members
|
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)
|
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
|
||||||
@member_view(
|
@member_view(
|
||||||
paths="invite/<str:token>/",
|
paths="invite/<str:membership_code>/<str:token>/",
|
||||||
name="membership-invite",
|
name="membership-invite",
|
||||||
login_required=False,
|
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.
|
"""View to invite a member to create a membership.
|
||||||
|
|
||||||
The token belongs to a non-active Member object. If the token is valid,
|
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.
|
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 = {
|
context = {
|
||||||
"token": token,
|
"token": token,
|
||||||
|
"membership": membership,
|
||||||
|
"form": form,
|
||||||
}
|
}
|
||||||
return render(
|
return render(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
|
@ -182,6 +182,10 @@ LOGGING = {
|
||||||
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
||||||
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
|
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:
|
if DEBUG:
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
MIDDLEWARE += [
|
MIDDLEWARE += [
|
||||||
|
|
Loading…
Reference in a new issue