import hashlib import secrets from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from shows.models import Performance, TimeStampedModel class Reservation(TimeStampedModel): class Status(models.TextChoices): PENDING = "pending", "Pending" CONFIRMED = "confirmed", "Confirmed" CANCELLED = "cancelled", "Cancelled" EXPIRED = "expired", "Expired" performance = models.ForeignKey( Performance, on_delete=models.PROTECT, related_name="reservations", ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True, ) name = models.CharField(max_length=200) email = models.EmailField(db_index=True) phone = models.CharField(max_length=40, blank=True) party_size = models.PositiveIntegerField(validators=[MinValueValidator(1)]) notes = models.TextField(blank=True) confirmed_at = models.DateTimeField(blank=True, null=True) qr_code_generated_at = models.DateTimeField(blank=True, null=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["performance", "status"]), models.Index(fields=["email"]), models.Index(fields=["created_at"]), ] def __str__(self): return f"{self.name} ({self.party_size}) for {self.performance}" @property def is_confirmed(self): return self.status == self.Status.CONFIRMED def confirm(self): if self.status != self.Status.PENDING: raise ValidationError("Only pending reservations can be confirmed.") if not self.confirmed_at: self.confirmed_at = timezone.now() self.status = self.Status.CONFIRMED self.save(update_fields=["status", "confirmed_at", "updated_at"]) def confirm_with_token(self, raw_token): token = self.tokens.get_valid_token(raw_token, ReservationToken.Purpose.CONFIRMATION) self.confirm() token.mark_used() return token class ReservationTokenQuerySet(models.QuerySet): def get_valid_token(self, raw_token, purpose): token_hash = ReservationToken.hash_token(raw_token) now = timezone.now() return self.get( token_hash=token_hash, purpose=purpose, used_at__isnull=True, expires_at__gt=now, ) class ReservationToken(models.Model): class Purpose(models.TextChoices): CONFIRMATION = "confirmation", "Confirmation" CHECK_IN = "check_in", "Check-in" reservation = models.ForeignKey( Reservation, on_delete=models.CASCADE, related_name="tokens", ) purpose = models.CharField(max_length=20, choices=Purpose.choices, db_index=True) token_hash = models.CharField(max_length=64, unique=True) expires_at = models.DateTimeField() used_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) objects = ReservationTokenQuerySet.as_manager() class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["reservation", "purpose"]), models.Index(fields=["purpose", "expires_at"]), models.Index(fields=["used_at"]), ] def __str__(self): return f"{self.get_purpose_display()} token for reservation {self.reservation_id}" @staticmethod def generate_raw_token(): return secrets.token_urlsafe(32) @staticmethod def hash_token(raw_token): return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() @classmethod def create_token(cls, reservation, purpose, expires_at): raw_token = cls.generate_raw_token() token = cls.objects.create( reservation=reservation, purpose=purpose, token_hash=cls.hash_token(raw_token), expires_at=expires_at, ) return token, raw_token @property def is_expired(self): return timezone.now() >= self.expires_at @property def is_used(self): return self.used_at is not None def mark_used(self): self.used_at = timezone.now() self.save(update_fields=["used_at"])