From e873df8e2eb43aecb5c287a4c4110936e2787906 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Mon, 12 Aug 2024 00:08:26 +0200 Subject: [PATCH] Send invitation emails and have an invite form to create a membership --- src/membership/admin.py | 50 ++++++++++++++++++- src/membership/emails.py | 2 +- src/membership/forms.py | 21 +++++++- .../0009_membership_referral_code.py | 2 +- .../0010_waitinglistentry_has_user.py | 18 +++++++ src/membership/models.py | 24 ++++++++- .../templates/membership/emails/invite.txt | 10 ++-- .../templates/membership/invite.html | 9 +++- src/membership/views.py | 16 +++--- src/project/templates/base.html | 10 ++++ 10 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 src/membership/migrations/0010_waitinglistentry_has_user.py diff --git a/src/membership/admin.py b/src/membership/admin.py index 6ccb55e..240c108 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -14,7 +14,9 @@ from django.db import transaction from django.db.models import QuerySet from django.http import HttpRequest from django.http import HttpResponse +from django.utils.text import slugify +from .emails import InviteEmail from .models import Member from .models import Membership from .models import MembershipType @@ -63,7 +65,7 @@ def decorate_ensure_membership_type_exists(membership_type: MembershipType, labe @transaction.atomic def ensure_membership_type_exists( request: HttpRequest, - queryset: QuerySet, + queryset: QuerySet[Member], membership_type: MembershipType, ) -> HttpResponse: """Inner function that ensures that a membership exists for a given queryset of Member objects.""" @@ -95,7 +97,12 @@ class MemberAdmin(UserAdmin): """Member admin is actually an admin for User objects.""" inlines = (MembershipInlineAdmin,) - actions: list[Callable] = [] # noqa: RUF012 + actions: list[str | Callable] = ["send_invite"] # noqa: RUF012 + list_display = ("email", "current_membership", "username", "is_staff", "is_active", "date_joined") + + @admin.display(description="membership") + def current_membership(self, instance: Member) -> Membership | None: + return instance.memberships.current() def get_actions(self, request: HttpRequest) -> dict: """Populate actions with dynamic data (MembershipType).""" @@ -113,7 +120,46 @@ class MemberAdmin(UserAdmin): return super_dict + @admin.action(description="Send invite email to selected inactive accounts") + def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None: + for member in queryset: + if member.is_active: + messages.error( + request, + f"This member will not receive an invite because the account is marked as active: {member.email}", + ) + continue + if not member.memberships.current(): + messages.error( + request, + f"This member will not receive an invite because it has no active membership: {member.email}", + ) + continue + membership = member.memberships.current() + email = InviteEmail(membership, request) + email.send() + messages.success(request, f"Sent an invitation to: {member.email}") + @admin.register(WaitingListEntry) class WaitingListEntryAdmin(admin.ModelAdmin): """Admin for WaitingList model.""" + + list_display = ("email", "has_user") + actions = ("create_user",) + + @admin.action(description="Create user account for entries") + def create_user(self, request: HttpRequest, queryset: QuerySet[WaitingListEntry]) -> None: + """Create a user account for this entry. + + Note that actions can soon be made available from the edit page, too: + https://github.com/django/django/pull/16012 + """ + for entry in queryset: + Member.objects.create_user(email=entry.email, username=slugify(entry.email), is_active=False) + entry.has_user = True + entry.save() + messages.info( + request, + f"Added user for f{entry.email} - ensure they have a membership and send an invite email.", + ) diff --git a/src/membership/emails.py b/src/membership/emails.py index 486108a..f1d96fc 100644 --- a/src/membership/emails.py +++ b/src/membership/emails.py @@ -14,7 +14,7 @@ from django.template import loader from django.utils import translation from django.utils.translation import gettext_lazy as _ -from src.membership.models import Membership +from .models import Membership class BaseEmail(EmailMessage): diff --git a/src/membership/forms.py b/src/membership/forms.py index 23b8b2e..b91d03b 100644 --- a/src/membership/forms.py +++ b/src/membership/forms.py @@ -1,13 +1,30 @@ +from allauth.account.adapter import get_adapter as get_allauth_adapter from allauth.account.forms import SetPasswordForm +from django import forms +from django.utils.translation import gettext_lazy as _ -class CreatePasswordForm(SetPasswordForm): +class InviteForm(SetPasswordForm): """Create a new password for a user account that is created through an invite.""" + username = forms.CharField( + label=_("Username"), + widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}), + ) + def __init__(self, *args, **kwargs) -> None: self.membership = kwargs.pop("membership") + kwargs["user"] = self.membership.user super().__init__(*args, **kwargs) + def clean_username(self) -> str: + """Clean the username value. + + Taken from the allauth Signup form - we should consider that data can be leaked here. + """ + value = self.cleaned_data["username"] + return get_allauth_adapter().clean_username(value) + def save(self) -> None: """Save instance to db. @@ -15,5 +32,7 @@ class CreatePasswordForm(SetPasswordForm): 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.username = self.cleaned_data["username"] self.user.is_active = True + self.user.save() super().save() diff --git a/src/membership/migrations/0009_membership_referral_code.py b/src/membership/migrations/0009_membership_referral_code.py index f8c0e07..4fcf65a 100644 --- a/src/membership/migrations/0009_membership_referral_code.py +++ b/src/membership/migrations/0009_membership_referral_code.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): field=models.UUIDField(blank=True, null=True, unique=True, default=uuid.uuid4, editable=False), ), migrations.RunPython(create_uuid), - migrations.AddField( + migrations.AlterField( model_name='membership', name='referral_code', field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False), diff --git a/src/membership/migrations/0010_waitinglistentry_has_user.py b/src/membership/migrations/0010_waitinglistentry_has_user.py new file mode 100644 index 0000000..331dc28 --- /dev/null +++ b/src/membership/migrations/0010_waitinglistentry_has_user.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1rc1 on 2024-08-11 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('membership', '0009_membership_referral_code'), + ] + + operations = [ + migrations.AddField( + model_name='waitinglistentry', + name='has_user', + field=models.BooleanField(default=False, help_text='Once a user account is generated (use the admin action), this field will be marked.', verbose_name='has user'), + ), + ] diff --git a/src/membership/models.py b/src/membership/models.py index fca8c74..b581b51 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -5,6 +5,7 @@ from typing import ClassVar from typing import Self from django.contrib.auth.models import User +from django.contrib.auth.models import UserManager from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.fields import DateRangeField from django.contrib.postgres.fields import RangeOperators @@ -43,7 +44,23 @@ class Member(User): ), ) - objects = QuerySet.as_manager() + objects = UserManager.from_queryset(QuerySet)() + + def get_display_name(self) -> str: + """Choose how to display the user in emails and UI and ultimately to other users. + + It's crucial that we currently don't have a good solution for this. + We should allow the user to define their own nick. + """ + return self.username + + @property + def language_code(self) -> str: + """Returns the user's preferred language code. + + We don't have an actual setting for this... because this is a proxy table. + """ + return "da-dk" class Meta: proxy = True @@ -213,6 +230,11 @@ class WaitingListEntry(CreatedModifiedAbstract): email = models.EmailField() geography = models.CharField(verbose_name=_("geography"), blank=True, default="") comment = models.TextField(blank=True) + has_user = models.BooleanField( + default=False, + verbose_name=_("has user"), + help_text=_("Once a user account is generated (use the admin action), this field will be marked."), + ) def __str__(self) -> str: return self.email diff --git a/src/membership/templates/membership/emails/invite.txt b/src/membership/templates/membership/emails/invite.txt index 3e83848..216ba0e 100644 --- a/src/membership/templates/membership/emails/invite.txt +++ b/src/membership/templates/membership/emails/invite.txt @@ -1,11 +1,7 @@ -{% extends "users/mail/base.txt" %}{% load i18n %} +{% extends "membership/emails/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: +{% block content %}{% url 'member:membership-invite' token=token referral_code=referral_code as invite_url %}{% blocktrans %}Here is your secret URL for creating an account with us: -{{ token_passphrase }} - -Use this code within 1 hour (before {{ expiry }}). You can login here: - -{{ protocol }}://{{ domain }}{{ login_url }}?next={{ next }} +{{ protocol }}://{{ domain }}{{ invite_url }} If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %} diff --git a/src/membership/templates/membership/invite.html b/src/membership/templates/membership/invite.html index d9cbce0..a36ca35 100644 --- a/src/membership/templates/membership/invite.html +++ b/src/membership/templates/membership/invite.html @@ -8,7 +8,14 @@ {% block content %}
-

Membership invite

+

{% trans "Create account" %}

+

{% trans "Congratulations! You've been invited to create an account with us:" %}

+

Email: {{ membership.user.email }}

+
+ {% csrf_token %} + {{ form.as_p }} + +
{% endblock %} diff --git a/src/membership/views.py b/src/membership/views.py index 3331844..c82cf5b 100644 --- a/src/membership/views.py +++ b/src/membership/views.py @@ -6,7 +6,7 @@ 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.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ @@ -16,7 +16,7 @@ from utils.view_utils import RenderConfig from utils.view_utils import RowAction from utils.view_utils import render -from .forms import CreatePasswordForm +from .forms import InviteForm from .models import Membership from .permissions import ADMINISTRATE_MEMBERS from .selectors import get_member @@ -125,7 +125,7 @@ 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, ) @@ -137,21 +137,25 @@ def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse We ratelimit this view so it's not possible to brute-force tokens. """ + if request.user.is_authenticated: + return HttpResponseForbidden("You're already logged in. So you cannot receive an invite.") + # 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?") + raise HttpResponseForbidden("Token not valid - maybe it expired?") if request.method == "POST": - form = CreatePasswordForm(membership=membership, user=membership, data=request.POST) + form = InviteForm(membership=membership, data=request.POST) if form.is_valid(): + form.save() messages.info(request, _("Password is set for your account and you can now login.")) return redirect("account_login") else: - form = CreatePasswordForm(user=membership) + form = InviteForm(membership=membership) context = { "token": token, diff --git a/src/project/templates/base.html b/src/project/templates/base.html index 485fbdf..10935b0 100644 --- a/src/project/templates/base.html +++ b/src/project/templates/base.html @@ -102,6 +102,16 @@
+ {% if messages %} +
+ {% for message in messages %} +

📨

+

{{ message }}

+ {% endfor %} +
+ + {% endif %} + {% block content %}{% endblock %}