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
10 changed files with 142 additions and 20 deletions
Showing only changes of commit e873df8e2e - Show all commits

View file

@ -14,7 +14,9 @@ from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.text import slugify
from .emails import InviteEmail
from .models import Member from .models import Member
from .models import Membership from .models import Membership
from .models import MembershipType from .models import MembershipType
@ -63,7 +65,7 @@ def decorate_ensure_membership_type_exists(membership_type: MembershipType, labe
@transaction.atomic @transaction.atomic
def ensure_membership_type_exists( def ensure_membership_type_exists(
request: HttpRequest, request: HttpRequest,
queryset: QuerySet, queryset: QuerySet[Member],
membership_type: MembershipType, membership_type: MembershipType,
) -> HttpResponse: ) -> HttpResponse:
"""Inner function that ensures that a membership exists for a given queryset of Member objects.""" """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.""" """Member admin is actually an admin for User objects."""
inlines = (MembershipInlineAdmin,) 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: def get_actions(self, request: HttpRequest) -> dict:
"""Populate actions with dynamic data (MembershipType).""" """Populate actions with dynamic data (MembershipType)."""
@ -113,7 +120,46 @@ class MemberAdmin(UserAdmin):
return super_dict 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}",
benjaoming marked this conversation as resolved
Review

Is the idea not that the member does not have a membership and thus should be sent an invite?

Is the idea not that the member does not have a membership and thus should be sent an invite?
Review

I've replaced "active" with "current" - the idea is that we should not invite members before we have also created their membership + order (there's another admin function for that).

Once the actions are defined, we can maybe merge some of the stuff into a single-click action... but I just wanna make sure that we keep the flexibility for now.. until the whole signup/apply process is defined.

I've replaced "active" with "current" - the idea is that we should not invite members before we have also created their membership + order (there's another admin function for that). Once the actions are defined, we can maybe merge some of the stuff into a single-click action... but I just wanna make sure that we keep the flexibility for now.. until the whole signup/apply process is defined.
)
continue
membership = member.memberships.current()
email = InviteEmail(membership, request)
email.send()
messages.success(request, f"Sent an invitation to: {member.email}")
@admin.register(WaitingListEntry) @admin.register(WaitingListEntry)
class WaitingListEntryAdmin(admin.ModelAdmin): class WaitingListEntryAdmin(admin.ModelAdmin):
"""Admin for WaitingList model.""" """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.",
)

View file

@ -14,7 +14,7 @@ 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 from .models import Membership
class BaseEmail(EmailMessage): class BaseEmail(EmailMessage):

View file

@ -1,13 +1,30 @@
from allauth.account.adapter import get_adapter as get_allauth_adapter
from allauth.account.forms import SetPasswordForm 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.""" """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: def __init__(self, *args, **kwargs) -> None:
self.membership = kwargs.pop("membership") self.membership = kwargs.pop("membership")
kwargs["user"] = self.membership.user
super().__init__(*args, **kwargs) 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: def save(self) -> None:
"""Save instance to db. """Save instance to db.
@ -15,5 +32,7 @@ class CreatePasswordForm(SetPasswordForm):
by getting a valid token before deactivation (from the reset password form). 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. 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.is_active = True
self.user.save()
super().save() super().save()

View file

@ -24,7 +24,7 @@ class Migration(migrations.Migration):
field=models.UUIDField(blank=True, null=True, unique=True, default=uuid.uuid4, editable=False), field=models.UUIDField(blank=True, null=True, unique=True, default=uuid.uuid4, editable=False),
), ),
migrations.RunPython(create_uuid), migrations.RunPython(create_uuid),
migrations.AddField( migrations.AlterField(
model_name='membership', model_name='membership',
name='referral_code', name='referral_code',
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False), field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),

View file

@ -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'),
),
]

View file

@ -5,6 +5,7 @@ from typing import ClassVar
from typing import Self from typing import Self
from django.contrib.auth.models import User 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.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField from django.contrib.postgres.fields import DateRangeField
from django.contrib.postgres.fields import RangeOperators 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: class Meta:
proxy = True proxy = True
@ -213,6 +230,11 @@ class WaitingListEntry(CreatedModifiedAbstract):
email = models.EmailField() email = models.EmailField()
geography = models.CharField(verbose_name=_("geography"), blank=True, default="") geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
comment = models.TextField(blank=True) comment = models.TextField(blank=True)
has_user = models.BooleanField(
benjaoming marked this conversation as resolved Outdated

Why not have this as a ForeignKey("Member")?

Why not have this as a `ForeignKey("Member")`?

It's sensitive really. I suppose that once this turns into an application, we have to consider that people give out details that are 100% associated with their membership activity and if we want to keep that on record, we should make sure it's either very consistent with the functionality or write a privacy statement.

I was leaning a bit more towards deleting these records... but on that note, "right to be forgotten" is way easier to invoke when we ensure that we use FKs between personal data (or PII).

Changing.......

It's sensitive really. I suppose that once this turns into an application, we have to consider that people give out details that are 100% associated with their membership activity and if we want to keep that on record, we should make sure it's either very consistent with the functionality or write a privacy statement. I was leaning a bit more towards deleting these records... but on that note, "right to be forgotten" is way easier to invoke when we ensure that we use FKs between personal data (or PII). Changing.......
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: def __str__(self) -> str:
return self.email return self.email

View file

@ -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 }} {{ protocol }}://{{ domain }}{{ invite_url }}
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 %} If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}

View file

@ -8,7 +8,14 @@
{% block content %} {% block content %}
<div class="content-view"> <div class="content-view">
<h2>Membership invite</h2> <h2>{% trans "Create account" %}</h2>
<p>{% trans "Congratulations! You've been invited to create an account with us:" %}</p>
<p>Email: <strong>{{ membership.user.email }}</strong></p>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">{% trans "Create account" %}</button>
</form>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator 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 get_object_or_404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _ 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 RowAction
from utils.view_utils import render from utils.view_utils import render
from .forms import CreatePasswordForm from .forms import InviteForm
from .models import Membership from .models import Membership
from .permissions import ADMINISTRATE_MEMBERS from .permissions import ADMINISTRATE_MEMBERS
from .selectors import get_member 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) @ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
@member_view( @member_view(
paths="invite/<str:membership_code>/<str:token>/", paths="invite/<str:referral_code>/<str:token>/",
name="membership-invite", name="membership-invite",
login_required=False, 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. 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. # 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) 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) token_valid = default_token_generator.check_token(membership.user, token)
if not token_valid: if not token_valid:
raise HttpResponseNotAllowed("Token not valid - maybe it expired?") raise HttpResponseForbidden("Token not valid - maybe it expired?")
if request.method == "POST": if request.method == "POST":
form = CreatePasswordForm(membership=membership, user=membership, data=request.POST) form = InviteForm(membership=membership, data=request.POST)
if form.is_valid(): if form.is_valid():
form.save()
messages.info(request, _("Password is set for your account and you can now login.")) messages.info(request, _("Password is set for your account and you can now login."))
return redirect("account_login") return redirect("account_login")
else: else:
form = CreatePasswordForm(user=membership) form = InviteForm(membership=membership)
context = { context = {
"token": token, "token": token,

View file

@ -102,6 +102,16 @@
</ol> </ol>
</nav> </nav>
<article> <article>
{% if messages %}
<div class="content-view">
{% for message in messages %}
<p>📨</p>
<p><strong>{{ message }}</strong></p>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</article> </article>
</main> </main>