"""Models for the membership app.""" from typing import ClassVar from typing import Self from django.contrib.auth.models import User 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 from utils.mixins import CreatedModifiedAbstract class NoSubscriptionPeriodFoundError(Exception): """Raised when no subscription period is found.""" class Member(User): """Proxy model for the User model to add some convenience methods.""" class QuerySet(models.QuerySet): """QuerySet for the Member model.""" def annotate_membership(self) -> Self: """Annotate whether the user has an active membership.""" from .selectors import get_current_subscription_period current_subscription_period = get_current_subscription_period() if not current_subscription_period: raise NoSubscriptionPeriodFoundError return self.annotate( active_membership=models.Exists( Membership.objects.filter( user=models.OuterRef("pk"), period=current_subscription_period.id, ), ), ) objects = QuerySet.as_manager() class Meta: proxy = True class SubscriptionPeriod(CreatedModifiedAbstract): """A subscription period. Denotes a period for which members should pay their membership fee for. """ 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() period = DateRangeField(verbose_name=_("period")) class Meta: constraints: ClassVar = [ ExclusionConstraint( name="exclude_overlapping_periods", expressions=[ ("period", RangeOperators.OVERLAPS), ], ), ] def __str__(self) -> str: return f"{self.period.lower} - {self.period.upper or _('next general assembly')}" class Membership(CreatedModifiedAbstract): """A membership. Tracks that a user has membership of a given type for a given period. """ class QuerySet(models.QuerySet): """QuerySet for the Membership model.""" def for_member(self, member: Member) -> Self: """Filter memberships for a given member.""" return self.filter(user=member) 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": """Get the current membership.""" try: return self._current().get() except self.model.DoesNotExist: return None def previous(self) -> list["Membership"]: """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", related_name="memberships", verbose_name=_("subscription type"), on_delete=models.PROTECT, ) period = models.ForeignKey( "membership.SubscriptionPeriod", on_delete=models.PROTECT, ) 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) class Meta: verbose_name = _("membership") verbose_name_plural = _("memberships") def __str__(self) -> str: return f"{self.user} - {self.period}" class MembershipType(CreatedModifiedAbstract): """A membership type. Models membership types. Currently only a name, but will in the future possibly contain more information like fees. """ name = models.CharField(verbose_name=_("name"), max_length=64) products = models.ManyToManyField("accounting.Product") active = models.BooleanField(default=True) class Meta: verbose_name = _("membership type") verbose_name_plural = _("membership types") def __str__(self) -> str: return self.name 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() 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")