forked from data.coop/membersystem
Membership invitations and order emails (#47)
* [x] Create invite emails from admin * [x] Sign up on special invite form (create password and username) * [x] Create email with unpaid orders and payment links * [x] Lodge unpaid orders somewhere in UI for visibility Reviewed-on: data.coop/membersystem#47 Reviewed-by: valberg <valberg@orn.li> Co-authored-by: Benjamin Bach <benjamin@overtag.dk> Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
This commit is contained in:
parent
c81481747f
commit
b3795977ed
|
@ -21,6 +21,7 @@ WORKDIR /app
|
||||||
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
||||||
|
|
||||||
# Only copy the requirements file first to leverage Docker cache
|
# Only copy the requirements file first to leverage Docker cache
|
||||||
|
RUN mkdir requirements/
|
||||||
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
|
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
|
||||||
|
|
||||||
RUN mkdir -p /app/src/static && \
|
RUN mkdir -p /app/src/static && \
|
||||||
|
|
|
@ -98,3 +98,8 @@ make requirements
|
||||||
# Build Docker image with new Python requirements
|
# Build Docker image with new Python requirements
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
* This project uses [django-zen-queries](https://github.com/dabapps/django-zen-queries), which will sometimes raise a `QueriesDisabledError` in your templates. You can find a difference of opinion about that, but you can find a difference of opinion about many things, right?
|
||||||
|
* If a linting error annoys you, please feel free to strike back by adding a `noqa` to the line that has displeased the linter and move on with life.
|
||||||
|
|
|
@ -18,6 +18,8 @@ dependencies = [
|
||||||
"django-oauth-toolkit~=2.4",
|
"django-oauth-toolkit~=2.4",
|
||||||
"django-registries==0.0.3",
|
"django-registries==0.0.3",
|
||||||
"django-view-decorator==0.0.4",
|
"django-view-decorator==0.0.4",
|
||||||
|
"django-oauth-toolkit~=2.4",
|
||||||
|
"django-ratelimit~=4.1",
|
||||||
"django-zen-queries~=2.1",
|
"django-zen-queries~=2.1",
|
||||||
"django_stubs_ext~=5.0",
|
"django_stubs_ext~=5.0",
|
||||||
"environs[django]>=11,<12",
|
"environs[django]>=11,<12",
|
||||||
|
@ -137,12 +139,19 @@ ignore = [
|
||||||
"EM102", # Exception must not use a f-string literal, assign to variable first
|
"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)
|
"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)
|
"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
|
"D105", # Missing docstring in magic method
|
||||||
"D106", # Missing docstring in public nested class
|
"D106", # Missing docstring in public nested class
|
||||||
|
"D107", # Missing docstring in `__init__`
|
||||||
"FIX", # TODO, FIXME, XXX
|
"FIX", # TODO, FIXME, XXX
|
||||||
"TD", # TODO, FIXME, XXX
|
"TD", # TODO, FIXME, XXX
|
||||||
"ANN002", # Missing type annotation for `*args`
|
"ANN002", # Missing type annotation for `*args`
|
||||||
"ANN003", # Missing type annotation for `**kwargs`
|
"ANN003", # Missing type annotation for `**kwargs`
|
||||||
|
"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]
|
[tool.ruff.lint.isort]
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
# - django-allauth~=0.63
|
# - django-allauth~=0.63
|
||||||
# - django-money~=3.5
|
# - django-money~=3.5
|
||||||
# - django-oauth-toolkit~=2.4
|
# - django-oauth-toolkit~=2.4
|
||||||
|
# - django-ratelimit~=4.1
|
||||||
# - django-registries==0.0.3
|
# - django-registries==0.0.3
|
||||||
# - django-stubs-ext~=5.0
|
# - django-stubs-ext~=5.0
|
||||||
# - django-view-decorator==0.0.4
|
# - django-view-decorator==0.0.4
|
||||||
|
@ -53,6 +54,8 @@ django-money==3.5.3
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
django-oauth-toolkit==2.4.0
|
django-oauth-toolkit==2.4.0
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
|
django-ratelimit==4.1.0
|
||||||
|
# via hatch.envs.default
|
||||||
django-registries==0.0.3
|
django-registries==0.0.3
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
django-stubs-ext==5.0.4
|
django-stubs-ext==5.0.4
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
# - django-allauth~=0.63
|
# - django-allauth~=0.63
|
||||||
# - django-money~=3.5
|
# - django-money~=3.5
|
||||||
# - django-oauth-toolkit~=2.4
|
# - django-oauth-toolkit~=2.4
|
||||||
|
# - django-ratelimit~=4.1
|
||||||
# - django-registries==0.0.3
|
# - django-registries==0.0.3
|
||||||
# - django-stubs-ext~=5.0
|
# - django-stubs-ext~=5.0
|
||||||
# - django-view-decorator==0.0.4
|
# - django-view-decorator==0.0.4
|
||||||
|
@ -81,6 +82,8 @@ django-money==3.5.3
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-oauth-toolkit==2.4.0
|
django-oauth-toolkit==2.4.0
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
|
django-ratelimit==4.1.0
|
||||||
|
# via hatch.envs.dev
|
||||||
django-registries==0.0.3
|
django-registries==0.0.3
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-stubs==1.16.0
|
django-stubs==1.16.0
|
||||||
|
|
|
@ -2,7 +2,11 @@
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from membership.emails import OrderEmail
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -26,7 +30,7 @@ class OrderAdminForm(forms.ModelForm):
|
||||||
model = models.Order
|
model = models.Order
|
||||||
exclude = () # noqa: DJ006
|
exclude = () # noqa: DJ006
|
||||||
|
|
||||||
def clean(self): # noqa: D102, ANN201
|
def clean(self): # noqa: ANN201
|
||||||
cd = super().clean()
|
cd = super().clean()
|
||||||
if not cd["account"] and cd["member"]:
|
if not cd["account"] and cd["member"]:
|
||||||
try:
|
try:
|
||||||
|
@ -43,10 +47,25 @@ class OrderAdmin(admin.ModelAdmin):
|
||||||
inlines = (OrderProductInline,)
|
inlines = (OrderProductInline,)
|
||||||
form = OrderAdminForm
|
form = OrderAdminForm
|
||||||
|
|
||||||
|
actions = ("send_order",)
|
||||||
|
|
||||||
list_display = ("member", "description", "created", "is_paid", "total_with_vat")
|
list_display = ("member", "description", "created", "is_paid", "total_with_vat")
|
||||||
search_fields = ("member__email", "membership__membership_type__name", "description")
|
search_fields = ("member__email", "membership__membership_type__name", "description")
|
||||||
list_filter = ("is_paid", "membership__membership_type")
|
list_filter = ("is_paid", "membership__membership_type")
|
||||||
|
|
||||||
|
@admin.action(description="Send order link to selected unpaid orders")
|
||||||
|
def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None:
|
||||||
|
for order in queryset:
|
||||||
|
if order.is_paid:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Order pk={order.id} is already marked paid, not sending email to: {order.member.email}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
email = OrderEmail(order, request)
|
||||||
|
email.send()
|
||||||
|
messages.success(request, f"Sent an order for order pk={order.id} link to: {order.member.email}")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Payment)
|
@admin.register(models.Payment)
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
@ -61,15 +80,15 @@ class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Product)
|
@admin.register(models.Product)
|
||||||
class ProductAdmin(admin.ModelAdmin): # noqa: D101
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "price", "vat")
|
list_display = ("name", "price", "vat")
|
||||||
|
|
||||||
|
|
||||||
class TransactionInline(admin.TabularInline): # noqa: D101
|
class TransactionInline(admin.TabularInline):
|
||||||
model = models.Transaction
|
model = models.Transaction
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Account)
|
@admin.register(models.Account)
|
||||||
class AccountAdmin(admin.ModelAdmin): # noqa: D101
|
class AccountAdmin(admin.ModelAdmin):
|
||||||
list_display = ("owner", "balance")
|
list_display = ("owner", "balance")
|
||||||
inlines = (TransactionInline,)
|
inlines = (TransactionInline,)
|
||||||
|
|
|
@ -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,49 @@ 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"Computer says no! This member will not receive an invite because the account is marked "
|
||||||
|
f"as active: {member.email}. That means the member has probably created a password and a username "
|
||||||
|
f"already, please tell them to use the password reminder function.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not member.memberships.current():
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Computer says no! This member will not receive an invite because it has no current "
|
||||||
|
f"membership: {member.email}. You need to create a current membership before sending the invite.",
|
||||||
|
)
|
||||||
|
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", "member")
|
||||||
|
actions = ("create_member",)
|
||||||
|
|
||||||
|
@admin.action(description="Create member account for entries")
|
||||||
|
def create_member(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 = Member.objects.create_user(email=entry.email, username=slugify(entry.email), is_active=False)
|
||||||
|
entry.member = member
|
||||||
|
entry.save()
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"Added user for {entry.email} - ensure they have a membership and send an invite email.",
|
||||||
|
)
|
||||||
|
|
126
src/membership/emails.py
Normal file
126
src/membership/emails.py
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
"""Send email to members, using templates and contexts for the emails.
|
||||||
|
|
||||||
|
* We keep everything as plain text for now.
|
||||||
|
* Notice that emails can be multilingual
|
||||||
|
* Generally, an email consists of templates (for body and subject) and a get_context() method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from accounting.models import Order
|
||||||
|
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
|
||||||
|
from django.template import loader
|
||||||
|
from django.utils import translation
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .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"
|
||||||
|
# Optional: Set to a template path for subject
|
||||||
|
template_subject = None
|
||||||
|
default_subject = "SET SUBJECT HERE"
|
||||||
|
|
||||||
|
def __init__(self, request: HttpRequest, *args, **kwargs) -> None:
|
||||||
|
self.context = kwargs.pop("context", {})
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
if self.user:
|
||||||
|
kwargs["to"] = [self.user.email]
|
||||||
|
self.context["user"] = self.user
|
||||||
|
self.context["recipient_name"] = self.user.get_display_name()
|
||||||
|
|
||||||
|
# Necessary to set request before instantiating body and subject
|
||||||
|
self.request = request
|
||||||
|
kwargs.setdefault("subject", self.get_subject())
|
||||||
|
kwargs.setdefault("body", self.get_body())
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self) -> dict:
|
||||||
|
"""Resolve common context for sending emails.
|
||||||
|
|
||||||
|
When overwriting, remember to call this via super().
|
||||||
|
"""
|
||||||
|
c = self.context
|
||||||
|
site = get_current_site(self.request)
|
||||||
|
c["request"] = self.request
|
||||||
|
c["domain"] = site.domain
|
||||||
|
c["site_name"] = site.name
|
||||||
|
c["protocol"] = "http" if self.request and not self.request.is_secure() else "https"
|
||||||
|
return c
|
||||||
|
|
||||||
|
def get_body(self) -> str:
|
||||||
|
"""Build the email body from template and context."""
|
||||||
|
if self.user and self.user.language_code:
|
||||||
|
with translation.override(self.user.language_code):
|
||||||
|
body = loader.render_to_string(self.template, self.get_context_data())
|
||||||
|
else:
|
||||||
|
body = loader.render_to_string(self.template, self.get_context_data())
|
||||||
|
return body
|
||||||
|
|
||||||
|
def get_subject(self) -> str:
|
||||||
|
"""Build the email subject from template or self.default_subject."""
|
||||||
|
if self.user and self.user.language_code:
|
||||||
|
with translation.override(self.user.language_code):
|
||||||
|
if self.template_subject:
|
||||||
|
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||||
|
else:
|
||||||
|
subject = str(self.default_subject)
|
||||||
|
elif self.template_subject:
|
||||||
|
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||||
|
else:
|
||||||
|
subject = str(self.default_subject)
|
||||||
|
return subject
|
||||||
|
|
||||||
|
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))
|
||||||
|
try:
|
||||||
|
self.send(fail_silently=False)
|
||||||
|
if not no_message:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class OrderEmail(BaseEmail):
|
||||||
|
template = "membership/emails/order.txt"
|
||||||
|
default_subject = _("Your data.coop order and payment")
|
||||||
|
|
||||||
|
def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None:
|
||||||
|
self.order = order
|
||||||
|
kwargs["user"] = order.member
|
||||||
|
super().__init__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self) -> dict:
|
||||||
|
c = super().get_context_data()
|
||||||
|
c["order"] = self.order
|
||||||
|
return c
|
39
src/membership/forms.py
Normal file
39
src/membership/forms.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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 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"]
|
||||||
|
# The allauth adapter ensures the username is unique.
|
||||||
|
return get_allauth_adapter().clean_username(value)
|
||||||
|
|
||||||
|
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.username = self.cleaned_data["username"]
|
||||||
|
self.user.is_active = True
|
||||||
|
self.user.save()
|
||||||
|
super().save()
|
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
|
@ -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.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='referral_code',
|
||||||
|
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),
|
||||||
|
),
|
||||||
|
]
|
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 5.1rc1 on 2024-08-14 08:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0009_membership_referral_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(help_text='Once a member account is generated (use the admin action), this field will be marked.', null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='membership.member', verbose_name='has member'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,9 +1,11 @@
|
||||||
"""Models for the membership app."""
|
"""Models for the membership app."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import ClassVar
|
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
|
||||||
|
@ -42,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
|
||||||
|
@ -122,6 +140,9 @@ class Membership(CreatedModifiedAbstract):
|
||||||
|
|
||||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
|
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_type = models.ForeignKey(
|
||||||
"membership.MembershipType",
|
"membership.MembershipType",
|
||||||
related_name="memberships",
|
related_name="memberships",
|
||||||
|
@ -209,6 +230,14 @@ 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)
|
||||||
|
member = models.ForeignKey(
|
||||||
|
Member,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("has member"),
|
||||||
|
help_text=_("Once a member account is generated (use the admin action), this field will be marked."),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.email
|
return self.email
|
||||||
|
|
9
src/membership/templates/membership/emails/base.txt
Normal file
9
src/membership/templates/membership/emails/base.txt
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% trans "Cooperatively yours," %}
|
||||||
|
{{ site_name }}
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}
|
7
src/membership/templates/membership/emails/invite.txt
Normal file
7
src/membership/templates/membership/emails/invite.txt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||||
|
|
||||||
|
{% 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:
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}{{ invite_url }}
|
||||||
|
|
||||||
|
If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}
|
6
src/membership/templates/membership/emails/order.txt
Normal file
6
src/membership/templates/membership/emails/order.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}{% url 'order:detail' order_id=order.id as order_url %}{% blocktrans %}You have an order in our system, you can pay it here:
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}{{ order_url }}
|
||||||
|
{% endblocktrans %}{% endblock %}
|
21
src/membership/templates/membership/invite.html
Normal file
21
src/membership/templates/membership/invite.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Membership" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
|
@ -4,12 +4,20 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
|
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 _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_ratelimit.decorators import ratelimit
|
||||||
from django_view_decorator import namespaced_decorator_factory
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
from utils.view_utils import RenderConfig
|
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 InviteForm
|
||||||
|
from .models import Membership
|
||||||
from .permissions import ADMINISTRATE_MEMBERS
|
from .permissions import ADMINISTRATE_MEMBERS
|
||||||
from .selectors import get_member
|
from .selectors import get_member
|
||||||
from .selectors import get_members
|
from .selectors import get_members
|
||||||
|
@ -113,3 +121,49 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
|
||||||
template_name="membership/members_admin_detail.html",
|
template_name="membership/members_admin_detail.html",
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
|
||||||
|
@member_view(
|
||||||
|
paths="invite/<str:referral_code>/<str:token>/",
|
||||||
|
name="membership-invite",
|
||||||
|
login_required=False,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
the caller is allowed to create a membership.
|
||||||
|
|
||||||
|
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 HttpResponseForbidden("Token not valid - maybe it expired?")
|
||||||
|
|
||||||
|
if request.method == "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 = InviteForm(membership=membership)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"token": token,
|
||||||
|
"membership": membership,
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="membership/invite.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
|
@ -182,6 +182,10 @@ LOGGING = {
|
||||||
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
||||||
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
|
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:
|
if DEBUG:
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
MIDDLEWARE += [
|
MIDDLEWARE += [
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -14,6 +14,10 @@
|
||||||
It is very much under construction.
|
It is very much under construction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% for order in unpaid_orders %}
|
||||||
|
<p>You have an unpaid order: <a href="{% url "order:detail" order_id=order.id %}">View Order ID {{ order.id }}</a></p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
<hr>
|
<hr>
|
||||||
<br>
|
<br>
|
||||||
|
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from accounting.models import Order
|
||||||
from django_view_decorator import view
|
from django_view_decorator import view
|
||||||
from utils.view_utils import render
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
@ -19,7 +20,11 @@ if TYPE_CHECKING:
|
||||||
)
|
)
|
||||||
def index(request: HttpRequest) -> HttpResponse:
|
def index(request: HttpRequest) -> HttpResponse:
|
||||||
"""View to show the index page."""
|
"""View to show the index page."""
|
||||||
return render(request, "index.html")
|
unpaid_orders = Order.objects.filter(member=request.user, is_paid=False)
|
||||||
|
|
||||||
|
context = {"unpaid_orders": list(unpaid_orders)}
|
||||||
|
|
||||||
|
return render(request, "index.html", context=context)
|
||||||
|
|
||||||
|
|
||||||
@view(
|
@view(
|
||||||
|
|
Loading…
Reference in a new issue