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.
323 lines
12 KiB
Python
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),
|
|
),
|
|
]
|