Compare commits

..

3 commits

Author SHA1 Message Date
Benjamin Bach 93e8028413
WIP: Add generic email functionality, extending for sending out invite emails and order links... 2024-08-07 14:33:15 +02:00
Benjamin Bach 9e1cf5cf2a
Add django-ratelimit as dependency 2024-08-06 23:49:21 +02:00
Benjamin Bach 00c615f318 More admin controls + Fix pay/success error 500 (#45)
Reviewed-on: data.coop/membersystem#45
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-04 17:12:02 +00:00
13 changed files with 176 additions and 3 deletions

View file

@ -23,6 +23,7 @@ dependencies = [
"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-oauth-toolkit~=2.4",
"django-ratelimit~=4.1",
"django_stubs_ext~=5.0", "django_stubs_ext~=5.0",
"stripe~=10.5", "stripe~=10.5",
] ]
@ -146,6 +147,8 @@ ignore = [
"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: Complains about positional arguments that are keyworded.
"FBT002", # Misbehaves: Complains about positional arguments that are keyworded.
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View file

@ -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

View file

@ -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

View file

@ -43,7 +43,9 @@ class OrderAdmin(admin.ModelAdmin):
inlines = (OrderProductInline,) inlines = (OrderProductInline,)
form = OrderAdminForm form = OrderAdminForm
list_display = ("member", "description", "created", "is_paid") list_display = ("member", "description", "created", "is_paid", "total_with_vat")
search_fields = ("member__email", "membership__membership_type__name", "description")
list_filter = ("is_paid", "membership__membership_type")
@admin.register(models.Payment) @admin.register(models.Payment)

View file

@ -4,6 +4,7 @@ from hashlib import md5
from typing import Self from typing import Self
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.db import models from django.db import models
from django.db.models.aggregates import Sum from django.db.models.aggregates import Sum
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -96,6 +97,11 @@ class Order(CreatedModifiedAbstract):
return sum(item.vat * item.quantity for item in self.items.all()) return sum(item.vat * item.quantity for item in self.items.all())
@property @property
@admin.display(
ordering=None,
description="Total (incl. VAT)",
boolean=False,
)
def total_with_vat(self) -> Money: def total_with_vat(self) -> Money:
"""Return the TOTAL amount WITH VAT.""" """Return the TOTAL amount WITH VAT."""
return self.total + self.total_vat return self.total + self.total_vat

View file

@ -102,7 +102,7 @@ def success(request: HttpRequest, order_id: int) -> HttpResponse:
quickly as possible. quickly as possible.
""" """
user = request.user # People just need to login to pay something, not necessarily be a member user = request.user # People just need to login to pay something, not necessarily be a member
order = models.Order.objects.get(pk=order_id, member=user) order = get_object_or_404(models.Order, pk=order_id, member=user)
context = { context = {
"order": order, "order": order,

View file

@ -29,6 +29,10 @@ admin.site.unregister(User)
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
"""Admin for Membership model.""" """Admin for Membership model."""
list_display = ("user", "period", "membership_type", "activated", "revoked")
list_filter = ("period", "membership_type", "activated", "revoked")
search_fields = ("membership_type__name", "user__email", "user__first_name", "user__last_name")
@admin.register(MembershipType) @admin.register(MembershipType)
class MembershipTypeAdmin(admin.ModelAdmin): class MembershipTypeAdmin(admin.ModelAdmin):

88
src/membership/emails.py Normal file
View file

@ -0,0 +1,88 @@
"""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 django.contrib import messages
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 _
class BaseEmail(EmailMessage):
"""Send emails via templated body and subjects.
This base class is extended for all email functionality.
"""
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: # noqa: D107
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."))

View file

@ -0,0 +1,19 @@
# Generated by Django 5.1b1 on 2024-08-04 10:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('membership', '0007_membership_activated_membership_activated_on_and_more'),
]
operations = [
migrations.AlterField(
model_name='membership',
name='membership_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.membershiptype', verbose_name='membership type'),
),
]

View file

@ -125,7 +125,7 @@ class Membership(CreatedModifiedAbstract):
membership_type = models.ForeignKey( membership_type = models.ForeignKey(
"membership.MembershipType", "membership.MembershipType",
related_name="memberships", related_name="memberships",
verbose_name=_("subscription type"), verbose_name=_("membership type"),
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )

View file

@ -0,0 +1,9 @@
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
{% block content %}{% endblock %}
Regards,
{{ site_name }}
{{ protocol }}://{{ domain }}

View file

@ -0,0 +1,11 @@
{% extends "users/mail/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:
{{ token_passphrase }}
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 %}

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
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
@ -113,3 +114,27 @@ 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:token>/",
name="membership-invite",
login_required=False,
)
def invite(request: HttpRequest, 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.
"""
context = {
"token": token,
}
return render(
request=request,
template_name="membership/invite.html",
context=context,
)