"""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 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. """ 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(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, ) 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) product = models.ForeignKey("accounting.Product", on_delete=models.PROTECT) current = models.BooleanField(default=False) 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(), )