Source code for django_program.registration.conditions

"""Discount and condition engine for conference registration.

Architecture
------------
This module implements a composable condition/discount system that controls
product eligibility and automatic price reductions. It replaces symposion's
InheritanceManager-based approach with a cleaner abstract-base + concrete-model
pattern.

Design decisions:

1. **Abstract bases, concrete conditions.** ``ConditionBase`` and ``DiscountEffect``
   are abstract Django models. Each concrete condition (e.g. ``SpeakerCondition``)
   inherits both and lives in its own database table. No InheritanceManager, no
   polymorphic queries on a shared table.

2. **Evaluation via ``ConditionEvaluator`` service.** The evaluator queries each
   concrete condition table in priority order, checks ``evaluate()`` against the
   current user/conference context, then calls ``calculate_discount()`` for
   matching items. This keeps model code thin and orchestration testable.

3. **Priority-based, first-match-per-item.** Conditions are ordered by
   ``priority`` (lower = first). For each cart item, the first matching condition
   wins unless the engine is explicitly configured for stacking (future work).

4. **Condition discounts apply before voucher discounts.** The cart pricing
   pipeline applies condition-based discounts first, then voucher discounts on
   the remainder.

5. **Admin-friendly.** Each condition type has its own ModelAdmin with relevant
   filters and M2M widgets.

Condition types
~~~~~~~~~~~~~~~
- ``TimeOrStockLimitCondition`` -- active within a time window and/or stock cap.
- ``SpeakerCondition`` -- auto-applies to users linked to a Pretalx Speaker.
- ``GroupMemberCondition`` -- applies to members of specified Django auth groups.
- ``IncludedProductCondition`` -- unlocks when user has purchased enabling products.
- ``DiscountForProduct`` -- direct discount on specific products (time/stock limited).
- ``DiscountForCategory`` -- percentage discount on ticket types and/or add-ons.
"""

from decimal import ROUND_HALF_UP, Decimal
from typing import TYPE_CHECKING

from django.contrib.auth.models import Group

if TYPE_CHECKING:
    from django.conf import settings
from django.db import models
from django.utils import timezone


[docs] class ConditionBase(models.Model): """Abstract base for all conditions that gate product eligibility or discounts. Subclasses implement ``evaluate()`` to determine whether the condition is met for a given user in a conference context. """ conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="%(class)s_conditions", ) name = models.CharField(max_length=200) description = models.TextField(blank=True, default="") is_active = models.BooleanField(default=True) priority = models.IntegerField( default=0, help_text="Lower values are evaluated first.", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)
[docs] class Meta: abstract = True ordering = ["priority", "name"]
def __str__(self) -> str: return self.name def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: """Return True if this condition is met for the given user. Args: user: The authenticated user to evaluate against. conference: The conference context. Raises: NotImplementedError: Subclasses must override this method. """ raise NotImplementedError
def _validate_discount_value(value: Decimal) -> None: """Validate that discount_value is non-negative.""" if value < 0: from django.core.exceptions import ValidationError # noqa: PLC0415 raise ValidationError("Discount value cannot be negative.")
[docs] class DiscountEffect(models.Model): """Abstract base for discount effects that reduce price. Provides the discount calculation logic shared by all condition types that can produce a price reduction. """
[docs] class DiscountType(models.TextChoices): """The type of discount to apply.""" PERCENTAGE = "percentage", "Percentage" FIXED_AMOUNT = "fixed_amount", "Fixed Amount"
discount_type = models.CharField( max_length=20, choices=DiscountType.choices, default=DiscountType.PERCENTAGE, ) discount_value = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Percentage (0-100) or fixed amount depending on discount_type.", validators=[_validate_discount_value], ) max_quantity = models.PositiveIntegerField( default=0, help_text="Maximum items this discount applies to. 0 means unlimited.", ) applicable_ticket_types = models.ManyToManyField( "program_registration.TicketType", blank=True, related_name="%(class)s_discounts", help_text="Ticket types this discount applies to. Empty means all.", ) applicable_addons = models.ManyToManyField( "program_registration.AddOn", blank=True, related_name="%(class)s_discounts", help_text="Add-ons this discount applies to. Empty means all.", )
[docs] class Meta: abstract = True
[docs] def calculate_discount(self, unit_price: Decimal, quantity: int) -> Decimal: """Calculate the discount amount for the given price and quantity. Args: unit_price: The per-unit price of the item. quantity: The number of items. Returns: The total discount amount (always non-negative). """ effective_qty = quantity if self.max_quantity > 0: effective_qty = min(quantity, self.max_quantity) line_total = unit_price * effective_qty if self.discount_type == self.DiscountType.PERCENTAGE: clamped = min(self.discount_value, Decimal(100)) pct = clamped / Decimal(100) return (line_total * pct).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if self.discount_type == self.DiscountType.FIXED_AMOUNT: return min(self.discount_value * effective_qty, line_total) return Decimal("0.00")
def _check_time_and_stock( start_time: object, end_time: object, limit: int, times_used: int, ) -> bool: """Shared evaluation logic for time-window and stock-limited conditions. Args: start_time: Optional start of the validity window. end_time: Optional end of the validity window. limit: Maximum uses (0 = unlimited). times_used: Current usage count. Returns: True if the current time is within the window and stock remains. """ now = timezone.now() if start_time and now < start_time: return False if end_time and now > end_time: return False return not (limit > 0 and times_used >= limit)
[docs] class TimeOrStockLimitCondition(ConditionBase, DiscountEffect): """Condition met when within a time window and/or stock limit. Use this for early-bird discounts, flash sales, or any promotion with a defined start/end time and optional usage cap. """ start_time = models.DateTimeField(null=True, blank=True) end_time = models.DateTimeField(null=True, blank=True) limit = models.PositiveIntegerField( default=0, help_text="Maximum number of times this condition can be used. 0 means unlimited.", ) times_used = models.PositiveIntegerField(default=0) class Meta(ConditionBase.Meta): verbose_name = "time/stock limit condition" verbose_name_plural = "time/stock limit conditions"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if within the time window and stock has not been exhausted. Args: user: The authenticated user (unused for this condition type). conference: The conference context (unused, already filtered by FK). """ return _check_time_and_stock(self.start_time, self.end_time, self.limit, self.times_used)
[docs] class SpeakerCondition(ConditionBase, DiscountEffect): """Auto-applies to users linked to a Pretalx Speaker. Checks whether the user has a Speaker record in the pretalx app for the same conference, optionally filtering by presenter/copresenter role. """ is_presenter = models.BooleanField( default=True, help_text="Apply to primary speakers (those listed as speaker on a talk).", ) is_copresenter = models.BooleanField( default=False, help_text="Apply to additional speakers / copresenters.", ) class Meta(ConditionBase.Meta): verbose_name = "speaker condition" verbose_name_plural = "speaker conditions"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if the user is linked to a Speaker for this conference. Pretalx has no explicit primary/copresenter role, so any linked speaker qualifies when either flag is set. When both flags are True, any speaker qualifies. When only ``is_presenter`` is True, any speaker on at least one talk qualifies. When only ``is_copresenter`` is True, the speaker must appear on a talk with at least one other speaker. Args: user: The authenticated user to check. conference: The conference context. """ from django_program.pretalx.models import Speaker # noqa: PLC0415 speakers = Speaker.objects.filter(conference=self.conference, user=user) if not speakers.exists(): return False if self.is_presenter: return True if self.is_copresenter: for speaker in speakers: for talk in speaker.talks.filter(conference=self.conference): if talk.speakers.count() > 1: return True return False return False
[docs] class GroupMemberCondition(ConditionBase, DiscountEffect): """Applies to members of specific Django auth groups. Useful for staff discounts, volunteer pricing, or any role-based discount controlled via Django's built-in group system. """ groups = models.ManyToManyField( Group, related_name="condition_discounts", help_text="User must be a member of at least one of these groups.", ) class Meta(ConditionBase.Meta): verbose_name = "group member condition" verbose_name_plural = "group member conditions"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if the user belongs to at least one of the configured groups. Args: user: The authenticated user to check. conference: The conference context (unused, already filtered by FK). """ group_ids = set(self.groups.values_list("pk", flat=True)) if not group_ids: return False user_group_ids = set(user.groups.values_list("pk", flat=True)) return bool(group_ids & user_group_ids)
[docs] class IncludedProductCondition(ConditionBase, DiscountEffect): """Unlocks discount on target products when user has purchased enabling products. For example, purchasing a "Tutorial" ticket could unlock a discount on the "Tutorial Lunch" add-on. """ enabling_ticket_types = models.ManyToManyField( "program_registration.TicketType", related_name="enabling_conditions", help_text="User must have a paid order containing one of these ticket types.", ) class Meta(ConditionBase.Meta): verbose_name = "included product condition" verbose_name_plural = "included product conditions"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if the user has purchased at least one enabling ticket type. Args: user: The authenticated user to check. conference: The conference context. """ enabling_ids = set(self.enabling_ticket_types.values_list("pk", flat=True)) if not enabling_ids: return False from django_program.registration.models import Order, OrderLineItem # noqa: PLC0415 return OrderLineItem.objects.filter( order__user=user, order__conference=self.conference, order__status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED], ticket_type_id__in=enabling_ids, ).exists()
[docs] class DiscountForProduct(ConditionBase, DiscountEffect): """Direct discount on specific products, optionally time/stock limited. Unlike other conditions, this evaluates to True for all users as long as the time window and stock limit are satisfied. Use ``applicable_ticket_types`` and ``applicable_addons`` to control which products receive the discount. """ start_time = models.DateTimeField(null=True, blank=True) end_time = models.DateTimeField(null=True, blank=True) limit = models.PositiveIntegerField( default=0, help_text="Maximum number of times this discount can be used. 0 means unlimited.", ) times_used = models.PositiveIntegerField(default=0) class Meta(ConditionBase.Meta): verbose_name = "product discount" verbose_name_plural = "product discounts"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if within the time window and stock has not been exhausted. Args: user: The authenticated user (unused for product discounts). conference: The conference context (unused, already filtered by FK). """ return _check_time_and_stock(self.start_time, self.end_time, self.limit, self.times_used)
[docs] class DiscountForCategory(ConditionBase): """Percentage discount on all products in specified categories. Applies a flat percentage reduction to ticket types and/or add-ons for the conference. Does not use ``DiscountEffect`` because it uses its own simplified percentage-only calculation with category-level targeting. """ percentage = models.DecimalField( max_digits=5, decimal_places=2, help_text="Percentage discount to apply (0-100).", ) apply_to_tickets = models.BooleanField( default=True, help_text="Apply this discount to all ticket types.", ) apply_to_addons = models.BooleanField( default=True, help_text="Apply this discount to all add-ons.", ) start_time = models.DateTimeField(null=True, blank=True) end_time = models.DateTimeField(null=True, blank=True) limit = models.PositiveIntegerField( default=0, help_text="Maximum number of times this discount can be used. 0 means unlimited.", ) times_used = models.PositiveIntegerField(default=0) class Meta(ConditionBase.Meta): verbose_name = "category discount" verbose_name_plural = "category discounts"
[docs] def evaluate(self, user: settings.AUTH_USER_MODEL, conference: object) -> bool: # noqa: ARG002 """Return True if within the time window and stock has not been exhausted. Args: user: The authenticated user (unused for category discounts). conference: The conference context (unused, already filtered by FK). """ return _check_time_and_stock(self.start_time, self.end_time, self.limit, self.times_used)
[docs] def calculate_discount(self, unit_price: Decimal, quantity: int) -> Decimal: """Calculate the percentage discount for the given price and quantity. Args: unit_price: The per-unit price of the item. quantity: The number of items. Returns: The total discount amount. """ line_total = unit_price * quantity pct = self.percentage / Decimal(100) return (line_total * pct).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)