Membership invitations and order emails #47
|
@ -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
|
|||||||
|
)
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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),
|
||||||
|
|
18
src/membership/migrations/0010_waitinglistentry_has_user.py
Normal file
18
src/membership/migrations/0010_waitinglistentry_has_user.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
valberg
commented
Why not have this as a Why not have this as a `ForeignKey("Member")`?
benjaoming
commented
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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue
Is the idea not that the member does not have a membership and thus should be sent an invite?
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.