Files
guilan-ace-backend/apps/events/models.py
Amirhossein Khalili 6c5fadef2f feat: derive event lifecycle status from dates and flags
Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
2026-10-03 12:46:16 +03:30

323 lines
12 KiB
Python

from django.db import models, transaction
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.text import slugify
import uuid
import markdown
from location_field.models.plain import PlainLocationField as LocationField
from core.media import (
delete_image_derivatives_by_name,
get_image_previous_name,
safe_process_public_image,
)
from core.models import BaseModel
from apps.events.event_status import EVENT_STATUS_LABELS
class Event(BaseModel):
class TypeChoices(models.TextChoices):
ONLINE = 'online', 'آنلاین'
ON_SITE = 'on_site', 'حضوری'
HYBRID = 'hybrid', 'آنلاین/حضوری'
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True, blank=True)
description = models.TextField(help_text="Event description in Markdown format")
start_time = models.DateTimeField()
end_time = models.DateTimeField()
address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name")
location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True,
help_text="Select location on map")
event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE)
online_link = models.URLField(max_length=500, blank=True, null=True,
help_text="Link for online events (e.g., Zoom, Google Meet)")
is_published = models.BooleanField(default=False)
is_cancelled = models.BooleanField(default=False)
capacity = models.PositiveIntegerField(null=True, blank=True,
help_text="Maximum number of attendees (leave blank for unlimited)")
price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.")
registration_start_date = models.DateTimeField(null=True, blank=True)
registration_end_date = models.DateTimeField(null=True, blank=True)
featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True)
gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries',
help_text="Images taken during or related to the event.")
registration_success_markdown = models.TextField(
blank=True, null=True,
help_text="Optional markdown shown to users after a successful registration."
)
class Meta:
ordering = ['-start_time']
indexes = [
models.Index(fields=['is_published', 'start_time'], name='event_pub_start_idx'),
models.Index(fields=['event_type']),
]
def __str__(self):
return self.title
def clean(self):
super().clean()
should_validate_deadline = self.pk is None
if self.pk is not None:
previous_schedule = type(self).all_objects.filter(pk=self.pk).values(
"registration_end_date", "start_time"
).first()
should_validate_deadline = previous_schedule is None or (
previous_schedule["registration_end_date"] != self.registration_end_date
or previous_schedule["start_time"] != self.start_time
)
if (
should_validate_deadline
and self.registration_end_date
and self.start_time
and self.registration_end_date > self.start_time
):
raise ValidationError({
"registration_end_date": "مهلت ثبت‌نام باید تا زمان شروع رویداد پایان یابد."
})
@property
def effective_registration_end_date(self):
"""Cap registration at event start, including records with legacy dates."""
if self.registration_end_date is None:
return self.start_time
return min(self.registration_end_date, self.start_time)
def get_display_status(self, *, now=None, registration_count=None):
now = now or timezone.now()
if not self.is_published:
return "draft"
if self.is_cancelled:
return "cancelled"
if self.end_time <= now:
return "completed"
if self.start_time <= now:
return "in_progress"
if now >= self.effective_registration_end_date:
return "registration_closed"
if registration_count is None:
registration_count = getattr(self, "browse_registration_count", None)
if registration_count is None:
registration_count = self.current_attendees_count
if self.capacity is not None and registration_count >= self.capacity:
return "full"
if self.registration_start_date is not None and now < self.registration_start_date:
return "upcoming"
return "registration_open"
@property
def status(self):
"""Computed lifecycle state; no status value is persisted on Event."""
return self.get_display_status()
@property
def status_label(self):
return EVENT_STATUS_LABELS[self.status]
def save(self, *args, **kwargs):
self.clean()
previous_image_name = get_image_previous_name(self, "featured_image")
current_image_name = self.featured_image.name if self.featured_image else None
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
if previous_image_name != current_image_name and previous_image_name:
delete_image_derivatives_by_name(
self.featured_image.storage if self.featured_image else None,
previous_image_name,
"event_featured",
delete_original=True,
)
if previous_image_name != current_image_name and self.featured_image:
safe_process_public_image(self.featured_image, "event_featured")
@property
def description_html(self):
"""Convert markdown description to HTML"""
return markdown.markdown(
self.description,
extensions=[
'markdown.extensions.extra',
'markdown.extensions.toc',
]
)
@property
def is_registration_open(self):
now = timezone.now()
return (
self.is_published
and not self.is_cancelled
and now < self.start_time
and (self.registration_start_date is None or now >= self.registration_start_date)
and now < self.effective_registration_end_date
)
@property
def current_attendees_count(self):
"""Count confirmed attendees and currently held paid-checkout seats."""
now = timezone.now()
return self.registrations.filter(is_deleted=False).filter(
models.Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
| models.Q(
status=Registration.StatusChoices.PENDING,
checkout_expires_at__gt=now,
)
).count()
@property
def has_available_slots(self):
"""Check whether registration slots are available, treating None as unlimited capacity."""
if self.capacity is None:
return True
return self.current_attendees_count < self.capacity
class Registration(BaseModel):
class StatusChoices(models.TextChoices):
PENDING = 'pending', 'Pending'
CONFIRMED = 'confirmed', 'Confirmed'
CANCELLED = 'cancelled', 'Cancelled'
ATTENDED = 'attended', 'Attended'
class AttendanceVerificationSource(models.TextChoices):
STAFF_ADMIN = 'staff_admin', 'Staff admin'
STAFF_API = 'staff_api', 'Staff API'
SYSTEM = 'system', 'System'
HISTORICAL = 'historical', 'Historical backfill'
class CancellationReasonChoices(models.TextChoices):
CHECKOUT_EXPIRED = 'checkout_expired', 'Checkout expired'
USER_CANCELLED = 'user_cancelled', 'User cancelled checkout'
EVENT_CANCELLED = 'event_cancelled', 'Event cancelled'
PAYMENT_FAILED = 'payment_failed', 'Payment failed'
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
registered_at = models.DateTimeField(auto_now_add=True)
checkout_started_at = models.DateTimeField(null=True, blank=True, editable=False)
checkout_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
cancellation_reason = models.CharField(
max_length=32,
choices=CancellationReasonChoices.choices,
blank=True,
default='',
editable=False,
)
status = models.CharField(max_length=10, choices=StatusChoices.choices,
default=StatusChoices.PENDING)
attendance_verified_at = models.DateTimeField(null=True, blank=True)
attendance_verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='verified_event_attendance',
)
attendance_verification_source = models.CharField(
max_length=20,
choices=AttendanceVerificationSource.choices,
blank=True,
)
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
confirmation_notification_sent_at = models.DateTimeField(null=True, blank=True)
cancellation_notification_sent_at = models.DateTimeField(null=True, blank=True)
discount_code = models.ForeignKey(
"payments.DiscountCode",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="registrations",
)
discount_amount = models.PositiveIntegerField(default=0)
final_price = models.PositiveIntegerField(null=True, blank=True)
class Meta:
ordering = ['-registered_at']
indexes = [
models.Index(fields=['event', 'status']),
models.Index(fields=['user']),
]
def __str__(self):
return f"{self.user.username} registered for {self.event.title}"
@property
def status_label(self):
"""Human-readable label for the current registration status."""
return self.get_status_display()
@transaction.atomic
def save(self, *args, **kwargs):
is_create = self._state.adding
old_status = None
if not is_create and self.pk:
old_status = self.__class__.objects.only("status").get(pk=self.pk).status
super().save(*args, **kwargs)
if (
self.status == self.StatusChoices.CONFIRMED
and (is_create or old_status != self.StatusChoices.CONFIRMED)
and not self.confirmation_notification_sent_at
):
RegistrationNotification.objects.get_or_create(
registration=self,
kind=RegistrationNotification.Kind.CONFIRMED,
)
if (
not is_create
and old_status != self.StatusChoices.CANCELLED
and self.status == self.StatusChoices.CANCELLED
and not self.cancellation_notification_sent_at
):
RegistrationNotification.objects.get_or_create(
registration=self,
kind=RegistrationNotification.Kind.CANCELLED,
)
from apps.gamification.services import sync_registration_attendance
sync_registration_attendance(self)
class RegistrationNotification(models.Model):
"""Durable intent to notify after a registration status transition commits."""
class Kind(models.TextChoices):
CONFIRMED = "confirmed", "Confirmed"
CANCELLED = "cancelled", "Cancelled"
registration = models.ForeignKey(Registration, on_delete=models.CASCADE, related_name="notification_outbox")
kind = models.CharField(max_length=10, choices=Kind.choices)
created_at = models.DateTimeField(auto_now_add=True)
delivered_at = models.DateTimeField(null=True, blank=True)
lease_until = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["registration", "kind"], name="unique_registration_notification")]
indexes = [
models.Index(
fields=["lease_until", "created_at"],
name="reg_notif_pending_idx",
condition=models.Q(delivered_at__isnull=True),
),
]