Source code for django_program.sponsors.models

"""Sponsor level, sponsor, benefit, and bulk purchase models for django-program."""

from typing import TYPE_CHECKING

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify

if TYPE_CHECKING:
    from decimal import Decimal

from django_program.pretalx.models import AbstractOverride


[docs] class SponsorLevel(models.Model): """A sponsorship tier for a conference. Defines a named tier (e.g. "Gold", "Silver", "Bronze") with pricing, a description of included benefits, and the number of complimentary tickets that sponsors at this level receive automatically. """ conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="sponsor_levels", ) name = models.CharField(max_length=200) slug = models.SlugField(max_length=200, blank=True) cost = models.DecimalField(max_digits=10, decimal_places=2) description = models.TextField(blank=True, default="") benefits_summary = models.TextField( blank=True, default="", help_text="Plain text summary of benefits for this level.", ) comp_ticket_count = models.PositiveIntegerField( default=0, help_text="Number of complimentary tickets to auto-generate for sponsors at this level.", ) order = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "name"] unique_together = [("conference", "slug")] def __str__(self) -> str: return f"{self.name} ({self.conference.slug})"
[docs] def save(self, *args: object, **kwargs: object) -> None: """Auto-generate slug from name if not set.""" if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs)
[docs] class SponsorOverride(AbstractOverride): """Local override applied on top of sponsor data. Allows conference organizers to patch individual fields of a sponsor without modifying the original record. Inherits ``save()``/``clean()`` conference auto-set and validation from ``AbstractOverride``. """ _parent_field = "sponsor" conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="sponsor_overrides", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_sponsor_overrides", ) sponsor = models.OneToOneField( Sponsor, on_delete=models.CASCADE, related_name="override", ) override_name = models.CharField( max_length=200, blank=True, default="", help_text="Override the sponsor name.", ) override_description = models.TextField( blank=True, default="", help_text="Override the sponsor description.", ) override_website_url = models.URLField( blank=True, default="", help_text="Override the sponsor website URL.", ) override_logo_url = models.URLField( blank=True, default="", help_text="Override the sponsor logo URL.", ) override_contact_name = models.CharField( max_length=200, blank=True, default="", help_text="Override the sponsor contact name.", ) override_contact_email = models.EmailField( blank=True, default="", help_text="Override the sponsor contact email.", ) override_is_active = models.BooleanField( null=True, blank=True, default=None, help_text="Override the sponsor active status. Leave blank for no override.", ) override_level = models.ForeignKey( SponsorLevel, on_delete=models.SET_NULL, null=True, blank=True, related_name="sponsor_overrides", help_text="Override the sponsor level.", ) def __str__(self) -> str: level_name = self.sponsor.level.name if self.sponsor.level_id else "Unknown" return f"Override: {self.sponsor.name} ({level_name})" @property def is_empty(self) -> bool: """Return True when no override fields carry a value.""" return ( not self.override_name and not self.override_description and not self.override_website_url and not self.override_logo_url and not self.override_contact_name and not self.override_contact_email and self.override_is_active is None and not self.override_level_id )
[docs] class SponsorBenefit(models.Model): """A specific benefit tracked for a sponsor. Tracks individual deliverables owed to a sponsor as part of their sponsorship package (e.g. "Logo on website", "Booth space"). The ``is_complete`` flag marks whether the benefit has been fulfilled. """ sponsor = models.ForeignKey( Sponsor, on_delete=models.CASCADE, related_name="benefits", ) name = models.CharField(max_length=200) description = models.TextField(blank=True, default="") is_complete = models.BooleanField(default=False) notes = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["name"] def __str__(self) -> str: return f"{self.name} - {self.sponsor.name}"
[docs] class BulkPurchase(models.Model): """Tracks a sponsor's bulk voucher purchase order. Represents a request by a sponsor to purchase a batch of voucher codes (e.g. discounted or comp tickets for employees/clients). Stores the payment lifecycle, Stripe references, and the voucher generation configuration used when the purchase is fulfilled. """
[docs] class PaymentStatus(models.TextChoices): """Payment lifecycle states for a bulk purchase.""" PENDING = "pending", "Pending" APPROVED = "approved", "Approved" PROCESSING = "processing", "Processing" PAID = "paid", "Paid" FAILED = "failed", "Failed" REFUNDED = "refunded", "Refunded"
conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="bulk_purchases", ) sponsor = models.ForeignKey( Sponsor, on_delete=models.SET_NULL, null=True, blank=True, related_name="bulk_purchases", help_text="The sponsor this deal is for (leave blank for non-sponsor deals).", ) quantity = models.PositiveIntegerField( help_text="Number of voucher codes to generate.", ) product_description = models.CharField( max_length=300, blank=True, default="", help_text="Description of the ticket type or product this purchase covers.", ) ticket_type = models.ForeignKey( "program_registration.TicketType", on_delete=models.SET_NULL, null=True, blank=True, related_name="bulk_purchases", help_text="Optional link to the ticket type these vouchers are for.", ) addon = models.ForeignKey( "program_registration.AddOn", on_delete=models.SET_NULL, null=True, blank=True, related_name="bulk_purchases", help_text="Optional link to the add-on these vouchers are for (shirts, tutorials, etc.).", ) payment_status = models.CharField( max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.PENDING, ) stripe_payment_intent_id = models.CharField(max_length=200, blank=True, default="") stripe_checkout_session_id = models.CharField(max_length=200, blank=True, default="") unit_price = models.DecimalField(max_digits=10, decimal_places=2) total_amount = models.DecimalField(max_digits=10, decimal_places=2) voucher_config = models.JSONField( default=dict, help_text=("Voucher generation parameters: voucher_type, discount_value, max_uses, valid_from, valid_until."), ) requested_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="requested_bulk_purchases", ) approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="approved_bulk_purchases", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: sponsor_name = self.sponsor.name if self.sponsor else "No sponsor" return f"BulkPurchase #{self.pk} - {sponsor_name} x{self.quantity}"
[docs] def clean(self) -> None: """Validate sponsor/conference consistency and bulk eligibility.""" if self.sponsor_id and self.conference_id and self.sponsor.conference_id != self.conference_id: msg = "Sponsor must belong to the same conference as the bulk purchase." raise ValidationError({"sponsor": msg}) if self.ticket_type_id and self.conference_id and self.ticket_type.conference_id != self.conference_id: msg = "Ticket type must belong to the same conference as the bulk purchase." raise ValidationError({"ticket_type": msg}) if self.addon_id and self.conference_id and self.addon.conference_id != self.conference_id: msg = "Add-on must belong to the same conference as the bulk purchase." raise ValidationError({"addon": msg}) if self.ticket_type_id and not self.ticket_type.bulk_enabled: msg = "This ticket type does not have bulk purchasing enabled." raise ValidationError({"ticket_type": msg}) if self.addon_id and not self.addon.bulk_enabled: msg = "This add-on does not have bulk purchasing enabled." raise ValidationError({"addon": msg})
@property def computed_total(self) -> Decimal: """Return unit_price * quantity for verification against total_amount.""" return self.unit_price * self.quantity @property def vouchers_generated(self) -> int: """Return the number of vouchers already generated for this purchase.""" return self.vouchers.count() @property def is_fulfilled(self) -> bool: """Return True when all vouchers have been generated.""" return self.vouchers_generated >= self.quantity
[docs] class BulkPurchaseVoucher(models.Model): """Links a bulk purchase to its generated voucher codes. Acts as a join table between ``BulkPurchase`` and the registration app's ``Voucher`` model, tracking which vouchers were created as part of a given bulk order. """ bulk_purchase = models.ForeignKey( BulkPurchase, on_delete=models.CASCADE, related_name="vouchers", ) voucher = models.ForeignKey( "program_registration.Voucher", on_delete=models.CASCADE, related_name="bulk_purchase_links", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] unique_together = [("bulk_purchase", "voucher")] def __str__(self) -> str: return f"{self.bulk_purchase}{self.voucher.code}"