forked from data.coop/membersystem
Send invitation emails and have an invite form to create a membership
This commit is contained in:
parent
1d26bbc17a
commit
e873df8e2e
|
@ -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}",
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
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