Files
guilan-ace-backend/apps/events/models.py
Amirhossein Khalili b4903f7cb1
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
F(backend): add public media derivatives pipeline
2026-05-20 14:26:51 +03:30

289 lines
12 KiB
Python

from django.db import models
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.utils.text import slugify
import hashlib
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'
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)
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
confirmation_email_sent_at = models.DateTimeField(null=True, blank=True)
cancellation_email_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_email_sent_at:
# lazy import to avoid circular import
from apps.events.tasks import send_registration_confirmation_email
send_registration_confirmation_email.delay(str(self.pk))
self.confirmation_email_sent_at = timezone.now()
super().save(update_fields=["confirmation_email_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_email_sent_at):
from apps.events.tasks import send_registration_cancellation_email
send_registration_cancellation_email.delay(str(self.pk))
self.cancellation_email_sent_at = timezone.now()
super().save(update_fields=["cancellation_email_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_email_sent_at):
from apps.events.tasks import send_registration_confirmation_email
send_registration_confirmation_email.delay(str(self.pk))
self.confirmation_email_sent_at = timezone.now()
super().save(update_fields=["confirmation_email_sent_at"])
class EventEmailLog(BaseModel):
class KindChoices(models.TextChoices):
INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users"
SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials"
EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement"
EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2"
EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3"
EVENT_REMINDER = "send_event_reminder", "Event reminder"
class StatusChoices(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED
KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS
KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT
KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2
KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3
KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER
KIND_CHOICES = KindChoices.choices
STATUS_PENDING = StatusChoices.PENDING
STATUS_SENT = StatusChoices.SENT
STATUS_FAILED = StatusChoices.FAILED
STATUS_CHOICES = StatusChoices.choices
event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs')
kind = models.CharField(max_length=64, choices=KIND_CHOICES)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
error = models.TextField(blank=True, null=True)
sent_at = models.DateTimeField(blank=True, null=True)
context_hash = models.CharField(max_length=64, blank=True, null=True)
class Meta:
unique_together = ("event", "user", "kind", "context_hash")
indexes = [
models.Index(fields=["event", "kind", "status"]),
models.Index(fields=["user", "kind", "status"]),
]
def __str__(self):
return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}"
@staticmethod
def _hash_context(context):
if context is None:
return None
if not isinstance(context, str):
context = str(context)
return hashlib.sha256(context.encode("utf-8")).hexdigest()
@classmethod
def claim(cls, *, event_id, user_id, kind, context=None):
context_hash = cls._hash_context(context)
log, created = cls.objects.get_or_create(
event_id=event_id,
user_id=user_id,
kind=kind,
context_hash=context_hash,
defaults={"status": cls.STATUS_PENDING},
)
if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT):
return log, True
if not created:
log._commit_status(cls.STATUS_PENDING, error="")
return log, False
def _commit_status(self, status, *, error="", sent_at=None):
self.status = status
self.error = error
update_fields = ["status", "error"]
if status == self.STATUS_SENT:
self.sent_at = sent_at or timezone.now()
update_fields.append("sent_at")
elif self.sent_at is not None:
self.sent_at = None
update_fields.append("sent_at")
if hasattr(self, "updated_at"):
update_fields.append("updated_at")
self.save(update_fields=update_fields)
def mark_sent(self):
self._commit_status(self.STATUS_SENT)
def mark_failed(self, error):
self._commit_status(self.STATUS_FAILED, error=error)