diff --git a/pyproject.toml b/pyproject.toml index 7ebb19b..a5431e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/membership/emails.py b/src/membership/emails.py index 20a243a..486108a 100644 --- a/src/membership/emails.py +++ b/src/membership/emails.py @@ -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 diff --git a/src/membership/forms.py b/src/membership/forms.py new file mode 100644 index 0000000..23b8b2e --- /dev/null +++ b/src/membership/forms.py @@ -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() diff --git a/src/membership/migrations/0009_membership_referral_code.py b/src/membership/migrations/0009_membership_referral_code.py new file mode 100644 index 0000000..f8c0e07 --- /dev/null +++ b/src/membership/migrations/0009_membership_referral_code.py @@ -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), + ), + ] diff --git a/src/membership/models.py b/src/membership/models.py index 9483d0f..fca8c74 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -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", diff --git a/src/membership/templates/membership/emails/invitation.txt b/src/membership/templates/membership/emails/invite.txt similarity index 100% rename from src/membership/templates/membership/emails/invitation.txt rename to src/membership/templates/membership/emails/invite.txt diff --git a/src/membership/templates/membership/invite.html b/src/membership/templates/membership/invite.html new file mode 100644 index 0000000..d9cbce0 --- /dev/null +++ b/src/membership/templates/membership/invite.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Membership" %} +{% endblock %} + +{% block content %} + +
+

Membership invite

+ +
+{% endblock %} diff --git a/src/membership/views.py b/src/membership/views.py index 0c5ebf9..3331844 100644 --- a/src/membership/views.py +++ b/src/membership/views.py @@ -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//", + paths="invite///", 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, diff --git a/src/project/settings.py b/src/project/settings.py index 4f558a1..d6a800c 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -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 += [