218 lines
9.2 KiB
Python
218 lines
9.2 KiB
Python
from django.db import models
|
|
from django.conf import settings
|
|
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
|
|
|
|
|
|
class Event(BaseModel):
|
|
class TypeChoices(models.TextChoices):
|
|
ONLINE = 'online', 'آنلاین'
|
|
ON_SITE = 'on_site', 'حضوری'
|
|
HYBRID = 'hybrid', 'آنلاین/حضوری'
|
|
|
|
class StatusChoices(models.TextChoices):
|
|
DRAFT = 'draft', 'Draft'
|
|
PUBLISHED = 'published', 'Published'
|
|
CANCELLED = 'cancelled', 'Cancelled'
|
|
COMPLETED = 'completed', 'Completed'
|
|
|
|
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)")
|
|
|
|
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
|
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=['status', 'start_time']),
|
|
models.Index(fields=['event_type']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def save(self, *args, **kwargs):
|
|
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.registration_start_date is None or now >= self.registration_start_date) and \
|
|
(self.registration_end_date is None or now <= self.registration_end_date)
|
|
|
|
@property
|
|
def current_attendees_count(self):
|
|
"""Count confirmed attendees"""
|
|
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).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'
|
|
|
|
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)
|
|
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()
|
|
|
|
def save(self, *args, **kwargs):
|
|
# detect create vs update
|
|
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
|
|
)
|
|
|
|
# save first (so we have a pk + final values)
|
|
super().save(*args, **kwargs)
|
|
|
|
# 1) on create -> send confirmation if pending/confirmed (and not sent before)
|
|
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_notification_sent_at:
|
|
# lazy import to avoid circular import
|
|
from apps.events.tasks import send_registration_confirmation_notification
|
|
send_registration_confirmation_notification.delay(str(self.pk))
|
|
self.confirmation_notification_sent_at = timezone.now()
|
|
super().save(update_fields=["confirmation_notification_sent_at"])
|
|
# 2) status changed -> cancelled
|
|
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_notification_sent_at):
|
|
from apps.events.tasks import send_registration_cancellation_notification
|
|
send_registration_cancellation_notification.delay(str(self.pk))
|
|
self.cancellation_notification_sent_at = timezone.now()
|
|
super().save(update_fields=["cancellation_notification_sent_at"])
|
|
|
|
# 3) status changed -> confirmed (if not sent before)
|
|
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_notification_sent_at):
|
|
from apps.events.tasks import send_registration_confirmation_notification
|
|
send_registration_confirmation_notification.delay(str(self.pk))
|
|
self.confirmation_notification_sent_at = timezone.now()
|
|
super().save(update_fields=["confirmation_notification_sent_at"])
|
|
|
|
from apps.gamification.services import sync_registration_attendance
|
|
sync_registration_attendance(self)
|