Files
azionelab/backend/bookings/models.py
2026-04-30 00:47:36 +02:00

141 lines
4.3 KiB
Python

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):
if self.party_size > 1:
return f"{self.name} ({self.party_size})"
return self.name
@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"])