from django.db import models, transaction 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 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), ), ]