membersystem/src/membership/models.py

218 lines
6.7 KiB
Python
Raw Normal View History

2024-07-14 22:19:37 +00:00
"""Models for the membership app."""
from typing import ClassVar
from typing import Self
from django.contrib.auth.models import User
2023-01-02 21:13:25 +00:00
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField
from django.contrib.postgres.fields import RangeOperators
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
from djmoney.money import Money
2023-01-02 21:13:25 +00:00
from utils.mixins import CreatedModifiedAbstract
2024-07-14 22:19:37 +00:00
class NoSubscriptionPeriodFoundError(Exception):
"""Raised when no subscription period is found."""
class Member(User):
2024-07-14 22:19:37 +00:00
"""Proxy model for the User model to add some convenience methods."""
class QuerySet(models.QuerySet):
2024-07-14 22:19:37 +00:00
"""QuerySet for the Member model."""
def annotate_membership(self) -> Self:
"""Annotate whether the user has an active membership."""
2023-09-18 18:58:30 +00:00
from .selectors import get_current_subscription_period
current_subscription_period = get_current_subscription_period()
if not current_subscription_period:
2024-07-14 22:19:37 +00:00
raise NoSubscriptionPeriodFoundError
return self.annotate(
active_membership=models.Exists(
Membership.objects.filter(
user=models.OuterRef("pk"),
2023-09-18 18:58:30 +00:00
period=current_subscription_period.id,
),
),
)
objects = QuerySet.as_manager()
class Meta:
proxy = True
2023-01-02 21:13:25 +00:00
class SubscriptionPeriod(CreatedModifiedAbstract):
2024-07-14 22:19:37 +00:00
"""A subscription period.
Denotes a period for which members should pay their membership fee for.
"""
2023-01-02 21:13:25 +00:00
class QuerySet(models.QuerySet):
"""QuerySet for the Membership model."""
def _current(self) -> Self:
"""Filter memberships for the current period."""
return self.filter(period__contains=timezone.now())
def current(self) -> "Membership | None":
"""Get the current membership."""
try:
return self._current().get()
except self.model.DoesNotExist:
return None
objects = QuerySet.as_manager()
2023-01-02 21:13:25 +00:00
period = DateRangeField(verbose_name=_("period"))
class Meta:
2024-07-14 22:19:37 +00:00
constraints: ClassVar = [
2023-01-02 21:13:25 +00:00
ExclusionConstraint(
name="exclude_overlapping_periods",
expressions=[
("period", RangeOperators.OVERLAPS),
],
),
]
2024-02-29 20:30:11 +00:00
def __str__(self) -> str:
2024-02-29 20:25:59 +00:00
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
class Membership(CreatedModifiedAbstract):
2024-07-14 22:19:37 +00:00
"""A membership.
Tracks that a user has membership of a given type for a given period.
"""
class QuerySet(models.QuerySet):
2024-07-14 22:19:37 +00:00
"""QuerySet for the Membership model."""
def for_member(self, member: Member) -> Self:
"""Filter memberships for a given member."""
return self.filter(user=member)
2024-07-14 22:19:37 +00:00
def _current(self) -> Self:
"""Filter memberships for the current period."""
return self.filter(activated=True, revoked=False, period__period__contains=timezone.now())
def current(self) -> "Membership | None":
2024-07-14 22:19:37 +00:00
"""Get the current membership."""
try:
return self._current().get()
except self.model.DoesNotExist:
return None
def previous(self) -> list["Membership"]:
2024-07-14 22:19:37 +00:00
"""Get previous memberships."""
# A naïve way to get previous by just excluding the current. This
# means that there must be some protection against "future"
# memberships.
return list(self.all().difference(self._current()))
objects = QuerySet.as_manager()
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
membership_type = models.ForeignKey(
"membership.MembershipType",
2019-08-31 22:27:36 +00:00
related_name="memberships",
verbose_name=_("subscription type"),
on_delete=models.PROTECT,
)
2023-01-02 21:13:25 +00:00
period = models.ForeignKey(
"membership.SubscriptionPeriod",
on_delete=models.PROTECT,
2023-01-02 21:13:25 +00:00
)
order = models.ForeignKey(
"accounting.Order",
null=True,
blank=True,
verbose_name=_("order"),
help_text=_("The order filled in for paying this membership."),
on_delete=models.PROTECT,
)
activated = models.BooleanField(
default=False, verbose_name=_("activated"), help_text=_("Membership was activated.")
)
activated_on = models.DateTimeField(null=True, blank=True)
revoked = models.BooleanField(
default=False,
verbose_name=_("revoked"),
help_text=_(
"Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration "
"of the membership period."
),
)
revoked_reason = models.TextField(blank=True)
revoked_on = models.DateTimeField(null=True, blank=True)
2024-07-14 22:19:37 +00:00
class Meta:
verbose_name = _("membership")
verbose_name_plural = _("memberships")
2024-02-29 20:30:11 +00:00
def __str__(self) -> str:
return f"{self.user} - {self.period}"
class MembershipType(CreatedModifiedAbstract):
2024-07-14 22:19:37 +00:00
"""A membership type.
Models membership types. Currently only a name, but will in the future
possibly contain more information like fees.
"""
2024-07-14 22:19:37 +00:00
name = models.CharField(verbose_name=_("name"), max_length=64)
products = models.ManyToManyField("accounting.Product")
2024-07-21 11:41:50 +00:00
active = models.BooleanField(default=True)
2024-07-21 11:41:50 +00:00
class Meta:
verbose_name = _("membership type")
verbose_name_plural = _("membership types")
2024-02-29 20:30:11 +00:00
def __str__(self) -> str:
return self.name
2024-07-21 11:41:50 +00:00
def create_membership(self, user: User) -> Membership:
"""Create a current membership for this type."""
from .selectors import get_current_subscription_period
return Membership.objects.create(
membership_type=self,
user=user,
period=get_current_subscription_period(),
)
@property
def total_including_vat(self) -> Money:
"""Calculate the total price of this membership (including VAT)."""
return sum(product.price + product.vat for product in self.products.all())
class WaitingListEntry(CreatedModifiedAbstract):
"""People who for some reason could want to be added to a waiting list and invited to join later."""
email = models.EmailField()
2024-07-20 20:45:44 +00:00
geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
comment = models.TextField(blank=True)
def __str__(self) -> str:
return self.email
class Meta:
verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries")