Membership invitations and order emails #47
|
@ -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(
|
||||||
benjaoming marked this conversation as resolved
|
|||||||
|
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(
|
||||||
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.......
|
|||||||
|
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
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.